ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PointNet到PointNet++:点云深度学习核心原理解析与PyTorch实战

PointNet到PointNet++:点云深度学习核心原理解析与PyTorch实战 第一次接触3D计算机视觉的时候我默认把它当成“多了一个深度通道的图像处理”。直到我用PointNet在ModelNet40上复现分类任务折腾了一周准确率卡在83%才意识到问题的根源根本不在调参而在对点云无序集合这个底层性质的认知。这篇文章我会顺着PointNet到PointNet的完整路线把设计动机、网络结构和PyTorch代码串起来讲适合理工科学生、刚转3D方向的工程师以及所有看到点云就条件反射想做体素化的朋友。文章不贴整篇源码仓库但会把最关键的结构、训练闭环和踩过的坑都拆开。1. 点云数据为什么让“传统图像思维”集体失效1.1 无序性同一个点云有多少种“存储姿势”图像有一个隐含假设像素排布是固定的。第i行第j列的像素天然拥有固定的空间位置。点云不是这样。同样一个桌面激光扫描仪先扫到桌角还是先扫到桌面中心完全不重要。你把这1万个点的顺序随机打乱它表达的还是同一个物体。这意味着网络必须对输入顺序不敏感这就是常说的置换不变性。很多第一次写点云网络的人会犯同一个错误直接套用处理序列数据的思路把点云当成 N 个点的“序列”。一旦用 RNN、LSTM 这类顺序模型就等于强行给点云引入了一个不存在的顺序假设。模型最后学到的很可能是“点序的统计模式”而不是物体的形状。测试时换一组扫描顺序性能立刻崩塌。PointNet 的答案是全局对称函数。所谓对称函数就是无论输入怎么排列输出都不变。最典型的就是 max、sum 这类操作。你有一堆点的特征先对所有点做 MLP把每个点的三维坐标映射到高维特征空间再用 max pooling 把 N 个点压成一个全局特征。这个全局特征跟点的顺序无关因为 max 不看顺序。1.2 稀疏性与视点依赖MLP不是“万能萃取机”点云的第二个麻烦是稀疏且分布不均匀。一个物体表面可能有几万个点但放在三维空间里这些点只占了极小一部分体积。你想把点云转成网格体素比如 32×32×32看起来不大但工业场景里一个房间点云就是百万级栅格化之后绝大多数体素是空的。分辨率低了丢细节分辨率高了显存直接爆炸。第三个麻烦是视点依赖。同一个物体从不同角度扫描点云密度、遮挡情况完全不同。激光雷达扫到的点近处密、远处疏结构光扫描仪扫到的点受物体材质影响很大。这些变化不能靠“把点云平移一下”解决。PointNet 用 T-Net 学习归一化变换就是为了尽可能消除点云在空间朝向和特征空间里的不一致性。所以处理点云不能像处理图像那样依赖局部卷积核的固定窗口。你需要的是(1) 对逐点坐标做非线性变换(2) 对所有点特征做对称聚合(3) 在聚合前想办法让网络对旋转平移更鲁棒。这三件事就是 PointNet 的设计骨架。2. PointNet的设计骨架对称函数、共享MLP与T-Net2.1 MaxPooling是怎么“消化”无序性的PointNet 的前向流程可以压缩成一句话每个点独立过 MLP然后全局 MaxPooling最后接全连接分类。关键点是MLP 是作用在每个点上的不同点之间共享同一组权重。PyTorch 里直接用 Conv1d 加 kernel_size1 实现不需要写循环效率很高。为什么 MaxPooling 能保留有效特征因为网络在高维空间里学到的前几个维度可以理解为“这个点属于哪个语义部件的置信度”。比如飞机点云里有机翼上的点、机身点、尾翼点。某些维度专门对机翼点激活某些维度专门对尾翼点激活。MaxPooling 在这些维度上取最大值相当于说只要存在某个机翼特征很强的点整个物体就拥有机翼部件。但这个设计也有代价全局 MaxPooling 把 N 个点的信息压成了一个向量局部上下文被抹掉了。你只知道“物体有类似机翼的部件”却不知道机翼和机身怎么连接。这就是 PointNet 对细粒度结构不敏感的根本原因。2.2 T-Net到底在对齐什么论文里另一个常被忽略的点是 T-Net输入变换网络。它的思路很朴素点云的分类结果不应该随物体的旋转改变。网络主体是一个小型的 PointNet输入原始点云输出一个 3×3 的旋转矩阵作用在点坐标上让点云先“转正”再做后续特征提取。特征空间里还有一个更大的变换矩阵维度是 64×64 甚至更高。这个矩阵会参与特征对齐所以论文里加了一个正交正则项鼓励学到的变换矩阵接近正交矩阵。我在复现时的感受是小规模实验里 64×64 变换矩阵占内存很大而且如果训练数据不够多特征变换并不见得带来明显提升。很多简化版实现甚至直接去掉特征变换分类精度只下降零点几个点。T-Net 的意义更多在于“网络自己学对齐”而不是某些手工设计的归一化方法。但如果你的点云本身已经被对齐过或者你做了充分的随机旋转数据增强T-Net 的收益会变小。理解这一点就不会盲目迷信论文里的每个模块。2.3 PointNet的局限全局特征掩盖了局部几何PointNet 在 ModelNet40 上能到 89% 左右的分类准确率这建立在 ModelNet40 是干净的对齐合成数据这一前提上。真实场景里一个点云往往包含多个物体、复杂背景和遮挡。对每个点只依赖全局特征做判断很容易把局部细节抹掉。用桌子举例。桌子面是一个平面结构桌腿是四个细长结构。PointNet 的全局特征可能记住了“这个物体有一个水平面”但无法建模“水平面四周有垂直支撑”这种组合关系更没办法分割出每一根桌腿。PointNet 的出发点就是在多个尺度上学习局部区域的特征再做层级聚合让网络同时具备全局分类和局部感知能力。3. PointNet的破局思路邻域分组与层级抽象3.1 从全局到局部Sample / Group / PointNet三步循环PointNet 把 PointNet 当成一个基础模块然后在点云上反复做“局部区域的特征提取”。每次迭代包含三个动作采样、分组、PointNet。采样用的是最远点采样Farthest Point Sampling。目的不是随机挑点而是让选出来的中心点尽可能均匀覆盖整个点云。随机采样可能让很多点挤在一团最远点采样保证不管点云密度怎么变中心点都能分布到整个空间。然后对每个中心点在某个半径范围内找邻居点形成一个局部点集。这些点集再被送进一个 PointNet输出一个局部特征向量。这样第一层把 N 个点变成了 N1 个中心点的局部特征N1 通常小于 N。第二层再对 N1 个中心点做采样和分组特征抽象范围更大。经过两层之后网络看到了从细粒度到粗粒度的完整层级信息类似图像上从边缘到部件再到整体的感知过程。3.2 密度不均是绕不开的问题MSG与MRG真实点云的密度分布极不均匀。扫描仪离得近的地方点很密离得远的地方点很疏。如果在固定半径里分组密度高的区域会有大量重复点密度低的区域可能一个邻居都没有。PointNet 给了两套方案。多尺度分组 MSG在多个半径下分别做分组每组跑一个 PointNet再把所有尺度特征拼接。多分辨率分组 MRG 更省算力把上一层的特征和这一层直接从原始点提取的特征拼接起来。MSG 效果通常更好但计算量成倍增加MRG 更快适合实时性要求高的场景。我自己在做落地点云分类时会先统计数据集里每个物体点云的密度分布再定半径。ModelNet40 这类合成数据用单尺度就够了但如果是自动驾驶点云一定要仔细设计多尺度分组。3.3 关键代码最远点采样和查询分组PyTorch实现最远点采样的实现逻辑很直观。从随机选一个点开始每次选一个距离已有中心点集合最远的点迭代 N1 次。def farthest_point_sample(xyz, npoint): xyz: [B, N, 3] 输入点云坐标 npoint: 需要采样的中心点个数 返回: [B, npoint] 中心点在原始点云里的索引 B, N, _ xyz.shape device xyz.device centroids torch.zeros(B, npoint, dtypetorch.long).to(device) distance torch.full((B, N), 1e10).to(device) farthest torch.randint(0, N, (B,), dtypetorch.long).to(device) for i in range(npoint): centroids[:, i] farthest centroid xyz[torch.arange(B), farthest].view(B, 1, 3) dist torch.sum((xyz - centroid) ** 2, dim-1) mask dist distance distance[mask] dist[mask] farthest torch.max(distance, dim-1).values return centroids这个写法用的是平方距离避免开根号的开销。距离必须在 batch 内逐样本独立计算不能混着共享否则采样结果会被别的样本污染。分组时先拿到每个中心点的邻居索引。最直接的办法是两两算距离然后取半径以内的点。如果点云点数不大这种 O(BNM) 的写法可以接受点数上万后建议调用 torch.cdist 配合 mask 加速。核心分组代码可以写成def ball_query(xyz, new_xyz, radius, nsample): xyz: [B, N, 3] 原始点坐标 new_xyz: [B, M, 3] 中心点坐标 radius: 查询半径 nsample: 每个中心点最多取多少个邻居 B, N, _ xyz.shape M new_xyz.shape[1] dist torch.cdist(new_xyz, xyz) # [B, M, N] mask dist radius # 对每个中心点先选满足距离条件的点再补齐到 nsample 个 ...要注意的点是如果半径内邻居数不足 nsample不能直接 pad 0否则 padding 点会持续产生干扰特征。常规做法是重复采样已有的邻居点让每个局部点集都有固定大小才能送进 Conv1d 和 BatchNorm。4. PyTorch复现要点从ModelNet40到训练闭环4.1 环境与数据集准备环境上我建议直接用 Anaconda 管理避免不小心污染系统 Python。创建一个 3.9 左右的虚拟环境然后根据 PyTorch 官网选一个和你 CUDA 版本匹配的安装命令。如果只是先跑通 PointNet 分类CPU 也能跑但 ModelNet40 全量训练最好还是有一张 8G 显存以上的显卡。数据集下载很关键。ModelNet40 是最常用的点云分类基准到斯坦福 ShapeNet 页面下载modelnet40_normal_resampled.zip就行。解压后每个类别一个目录每个模型有 4 个文件model.off、model.png、model.txt和model_normal.txt。其中model_normal.txt每行是点的 x、y、z 坐标和法向量 nx、ny、nz。读文件不能一次性把整个数据集放内存。ModelNet40 全部文件加起来不到 1G可以全读进来但真实工业点云一定得用 DataLoader 分批读取。我一般会写一个PointCloudDataset在__getitem__里读文件、采样到固定点数、做归一化、随机旋转增强。4.2 分类网络完整forward结构这是简化版 PointNet 分类网络保留了最核心的共享 MLP 和全局 MaxPoolingclass PointNetClassifier(nn.Module): def __init__(self, num_classes40): super().__init__() # 逐点 MLP用 kernel_size1 的 Conv1d 实现 self.mlp1 nn.Sequential( nn.Conv1d(3, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), ) self.mlp2 nn.Sequential( nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 1024, 1), nn.BatchNorm1d(1024), nn.ReLU(), ) self.fc nn.Sequential( nn.Linear(1024, 512), nn.BatchNorm1d(512), nn.ReLU(), nn.Dropout(0.4), nn.Linear(512, 256), nn.BatchNorm1d(256), nn.ReLU(), nn.Dropout(0.4), nn.Linear(256, num_classes), ) def forward(self, x): # x: [B, N, 3] - [B, 3, N] x x.transpose(1, 2) x self.mlp1(x) x self.mlp2(x) x x.max(dim-1).values # 全局最大池化 return self.fc(x)forward 里第一件事是转置因为输入通常是[B, N, 3]而 Conv1d 期望的通道维在第二维。这里通用的经验是所有逐点 MLP 都用 kernel_size1这样每个点独立计算不同点之间不互相干扰。4.3 训练脚本细节损失函数、学习率、评估指标分类任务用交叉熵损失优化器用 Adam 或 SGDmomentum 都行。论文原版用的是 Adam初始学习率 0.001每 20 个 epoch 衰减 0.5。实际复现里SGD 通常更稳但 Adam 调试成本低。我的建议是先用 Adam 跑通流程再回头尝试 SGD。训练时最容易忽略的是 BatchNorm 的行为。PyTorch 的 BatchNorm 在训练和 eval 模式下统计量不同。如果你在验证阶段忘记调model.eval()验证准确率会莫名其妙地抖动。反过来如果训练阶段没开model.train()BatchNorm 不更新模型可能长期不收敛。评估指标不要只看 overall accuracy。ModelNet40 虽然类别均衡但每个类别样本数其实不一样。最好同时统计 per-class accuracy 和准确率曲线。如果某些类别一直偏低去可视化对应类别的点云大概率能发现数据本身存在问题。4.4 训练结果怎么看Accuracy和Loss曲线解读我复现时定位卡点的一个技巧同时打印训练集准确率和验证集准确率。如果训练集准确率很快就到 95% 以上验证集却只有 80%这是过拟合。减少 Dropout 之前的隐藏层宽度、加大数据增强、增加 Dropout 概率都比盲目加数据更有效。Loss 曲线出现锯齿很常见尤其是 batch size 比较小的时候。不要看到一个 epoch 的 Loss 上升就调学习率至少观察 5 个 epoch 的趋势。最理想的状态是训练 Loss 和验证 Loss 一起平滑下降之后差距慢慢拉开。如果你发现从第一个 epoch 开始验证 Loss 就完全不动先检查数据集标签是否对齐、归一化是否写错而不是调网络结构。5. 实际复现中的五个“隐形坑”5.1 点云归一化方式直接影响收敛很多教程会把点云坐标直接减去均值除以标准差这个操作用在图像上没问题用在点云上要谨慎。点云的绝对尺度本身是有意义的比如小螺丝和大卡车尺度完全不同。如果按标准差归一化两个物体可能被压成同样大小网络就很难靠尺度区分类别。常见的做法是先把点云中心平移到原点再除以最大半径把整个物体限制在单位球内。这样既保留了物体内的相对尺度又让网络输入稳定。我在 ModelNet40 上做过对比中心化但不缩放时分类精度波动明显加入单位球归一化后稳定提升 1 个多点。5.2 随机采样种子固定不固定结论会变原始点云每个类别点数不固定训练时通常会随机采样到固定点数比如 1024 或 2048。这个随机采样一旦种子不同输入数据分布就不同模型表现会有波动。很多复现结果不一致原因就在这里。我的建议是配置里固定全局随机种子并且把采样逻辑写到数据集的__getitem__里而不是预处理时一次性采样保存。这样每个 epoch 看到的点集都不同相当于一种廉价的数据增强。5.3 BatchNorm在batch size过小时的表现PointNet 的逐点 MLP 里几乎每层都接 BatchNorm。当 batch size 只有 2 或 4 时统计量估计非常不稳定训练 Loss 会不停跳。我当时为了省显存把 batch size 从 32 降到 8结果准确率掉了 3 个点后来才发现是 BatchNorm 的问题。如果显存不够又必须用小 batch可以考虑把BatchNorm1d换成GroupNorm或者固定 BatchNorm 的 running stats 只在训练若干步之后再更新。但最省事的方案仍然是采用梯度累积用 4 次 batch_size8 的前向累积出 32 的效果。5.4 分类模型的MaxPooling索引不要丢PointNet 分类只用全局特征不需要每个点的局部特征。但如果你接下来要做分割或者想用特征做可视化分析MaxPooling 的索引非常重要。torch 的max返回 value 和 indices很多简化实现直接取.values把 indices 丢了。后续做特征上采样时还得重新算一遍白白浪费计算。正确做法是把.indices保存下来或者在整个 forward 里保留 point feature。PointNet 分割网络就是把中间某层逐点特征和全局特征拼接在一起再用 MLP 升维。没有中间特征分割分支完全没法接。5.5 数据集划分别只按文件列表还要考虑类别均衡ModelNet40 官方提供了训练集和测试集划分但有些第三方下载地址会混入重复模型。如果数据清洗不干净同一个物体的不同 3D 模型可能同时出现在训练集和测试集评估结果虚高。我的做法是下载后用文件名去重再统计每个类别在训练集和测试集的数量。出现 0 样本类别就直接删掉否则在计算 per-class accuracy 时会出现除零警告真实业务里更容易误导人。6. 从分类到分割PointNet家族的迁移套路6.1 分割网络如何复用分类backbonePointNet 的分割任务本质上是一个逐点分类问题。分类网络把 N 个点压缩成 1 个全局特征分割网络则需要输出 N 个点的标签。所以分割网络在 MaxPooling 之前必须截留逐点特征不能直接走到全连接层。具体做法是输入点云先经过输入变换和第一组共享 MLP得到 64 维逐点特征记为 point_feat。然后继续高维 MLP 到 1024 维MaxPooling 得到全局特征。将 point_feat 和 global_feat 在特征维度拼接得到每个点 [641024] 的向量再接共享 MLP 映射到类别数。这个结构可以用一句话记前半段和分类一致后半段把全局特征广播回每个点。6.2 局部特征与全局特征拼接后网络在学什么拼接操作之后每个点既知道自己是谁又知道整个物体是什么。比如分割椅子的场景某个点是椅背的一部分靠自身局部特征可能模棱两可但全局特征告诉网络“整个物体是椅子”于是这个点被分到椅背的概率提高。但是这种方法的问题在于全局特征只有一个尺度。PointNet 的分割网络会用特征传播层把多个层级的局部特征逐层上采样回原始点数。上采样不是简单的插值而是根据距离加权把远一点的局部特征传播给附近点。这样分割结果既有局部细节又有多尺度上下文。6.3 进阶方向从PointNet到TransformerPointNet 已经能用局部邻域解决很多问题但它的邻居定义仍然依赖固定的球半径和采样策略。当点云密度突变、物体结构复杂时手工设定半径很费劲。近年来的 Point Transformer 系列用注意力机制自适应地选择重要点理论上更灵活但训练成本和显存占用也更大。我的建议是做工业项目优先从 PointNet 起步它可控、可解释、部署方便当你发现 PointNet 在局部遮挡严重的场景里一直调不好半径再去看注意力方案。不要一上来就上最重的模型毕竟 3D 数据的迭代成本比 2D 图像高得多。就我个人的经验而言PointNet 和 PointNet 最大的价值不是刷榜而是让你深刻理解“无序集合怎么学特征”这件事。把这两个网络的 PyTorch 代码完整写过一遍后续看任何 3D 点云模型都会轻松很多。最后分享一个小技巧训练前先在几十个样本上过一遍 forward用 tensorboard 记录模型输出 shape很多维度报错能在五分钟内解决别等到训练跑到一半才发现问题。
返回列表