十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

从PointNet到PointNet++:三维点云分类的PyTorch实现与踩坑笔记

从PointNet到PointNet++:三维点云分类的PyTorch实现与踩坑笔记 简介这是一份面向3D计算机视觉入门与进阶学习者的PDF技术资料聚焦点云深度学习的经典模型系统梳理从PointNet到PointNet的理论脉络与PyTorch代码实现。内容涵盖点云的表示形式与特性置换不变性、旋转不变性、PointNet出现前的三类主流方案基于3DCNN的体素模型、映射到2D空间利用CNN、传统人工特征并重点拆解PointNet的设计动机、Hausdorff空间连续函数逼近的理论证明、T-Net对齐机制、网络总体结构及缺失鲁棒性实验同时给出了对应的PyTorch代码解析含T-Net与Encoder-Decoder结构帮助读者真正跑通并理解实现细节。资源为单个PDF文件大小约1.75MB轻量易读适合作为课程学习笔记、期末复习或项目入门参考。目前已有254人学习下载对于想快速掌握点云深度学习基础并动手实践代码的读者而言是一份内容完整、理论结合实战的精选资料。从PointNet到PointNet我在三维点云分类上的踩坑笔记与PyTorch实现做过三维视觉或者LiDAR相关项目的朋友应该都绕不开一个起点——PointNet。2023年我第一次尝试用深度学习方法处理三维点云数据时最让我头疼的不是怎么搭建网络而是搞不懂为什么点云这种不规则的数据也能用神经网络直接处理以及后来在面对真实场景中密度不均的点云时PointNet的表现又为什么会这么拉胯。如果你也有类似的困惑或者正处于理论看得明白、代码跑不起来、效果调不上去的阶段这篇文章应该能帮你省下不少时间。我会从PointNet的设计动机讲起逐步拆解PointNet对它的改进逻辑然后直接给出我用PyTorch实现的核心模块代码最后分享一些我在实际训练中积累的调参心得和坑点。这篇文章面向的是有基本深度学习基础、想快速上手3D点云任务的读者无论是复现论文还是为后续项目打底都能直接用。1. 从PointNet开始为什么点云网络要对称1.1 点云的核心难点在于无序性点云本质上是一个集合不是一张图也不是一个序列。同一把椅子不管你先输入哪个点、后输入哪个点它代表的都是同一把椅子。这个性质叫做置换不变性Permutation Invariance。如果你直接套用CNN或者RNN那套思路把点云排列成矩阵喂进网络那同一个物体就会因为点的输入顺序不同产生天差地别的特征表达网络根本学不出一个稳定的映射关系。所以PointNet最关键的贡献是提出了用对称函数symmetric function来解决无序性问题。什么叫对称函数就是无论输入顺序怎么变输出结果都不变的函数。加法是对称的乘法是对称的最大值也是对称的。PointNet的做法就是把每个点通过共享的多层感知机MLP映射到一个高维特征空间然后用Max Pooling把所有点的特征压缩成一个全局特征向量。这个Max Pooling就是那个对称函数。1.2 为什么选择Max Pooling而不是Sum或Mean很多初学者会问既然Sum和Mean也是对称函数为什么PointNet偏偏选了Max我在复现的时候也纠结过这个问题后来在实验里体会得比较深。Mean的问题是会把所有点的信息平均掉导致个别显著特征被淹没Sum的问题是对点的数量极其敏感点云密度一变特征的绝对值就漂移了。而Max保留了每个特征通道上最显著的那个激活值相当于让网络自动去关注最能代表这个物体的点。这种做法和人类识别物体的方式是有相似之处的。我判断一个物体是不是椅子可能只需要看到四条腿一个靠背这几个关键结构点就够了不需要把每个点都平均一遍。Max Pooling本质上是在让网络自己去挑关键点。1.3 输入变换网络T-Net的作用PointNet原文里还有一个输入变换网络简单说就是一个小型网络用来预测一个变换矩阵对输入点云做旋转对齐。我当时看论文的时候觉得这个模块可有可无直到自己做实验时发现不加T-Net在某些数据集上分类精度会掉两三个点。它的逻辑也不难理解点云的全局坐标是随意的同一个物体倒过来翻过去都还是同一个物体网络如果要在所有姿态下都认出它就得消耗大量参数去学习姿态不变性。T-Net相当于在输入端先做一次归一化把物体的姿态摆正再丢给主网络提取特征。不过在实际工程里T-Net并不总是物有所值。它增加了参数量训练起来也更慢如果你的任务本身对旋转不敏感比如你已经做过坐标对齐可以考虑去掉它。这个我们在后面的代码部分会展示怎么灵活处理。2. PointNet的天然短板全局特征掩盖了局部结构2.1 局部上下文信息才是三维识别的关键PointNet的效果在当时是惊艳的但它有一个致命的假设所有点云都可以用一个全局特征来代表。这个假设在玩具数据集上成立在真实场景中就崩了。想象一个场景一个点云里同时有椅子和桌子你想区分它们光靠全局Max Pooling出的一个1024维向量很难捕捉到椅背和坐面交接处这种精细的局部几何关系。更直观的例子是同样是一面墙墙面上的窗户、门框、插座它们的局部几何特征完全不同但如果你只用一个全局特征去描述这面墙这些细节就全被平均掉了。PointNet对这类场景的泛化能力非常差我在用真实场景点云做实验时直观感受就是它能把大的物体类别分对比如墙、地面、桌子但细粒度分类基本没法看。2.2 靠堆更多点解决不了问题有人可能会想既然Max Pooling会把局部信息丢掉那我多采一些点、把网络加深一点不就能保留更多信息了吗这个思路在实践中是走不通的。一方面点的数量增加后计算量和显存占用是线性增长的训练效率急剧下降另一方面Max Pooling本身是一种强压缩操作无论输入多少点输出的全局特征维度是固定的大量点的信息在Pooling那一步就丢失了。你可以理解为你往漏斗里倒再多水出口也就那么大。所以PointNet的出发点非常直接点云需要像图像一样做分层抽取特征从局部到全局逐级抽象而不是一步到位压成一个全局向量。3. PointNet的精髓分层结构如何恢复局部上下文3.1 从全局到局部的三级抽象PointNet把PointNet的特征提取机制包装成了一个局部特征学习模块叫Set Abstraction集合抽象。每一个Set Abstraction做的事情有三步采样中心点、分组邻域点、对每组做PointNet式的特征提取。通过堆叠多个Set Abstraction网络可以逐级扩大感受野——第一层看到的是小范围局部几何第二层看到的是由局部特征组成的更大范围结构第三层再进一步抽象。这和CNN里从边缘到纹理再到语义的抽象过程非常相似。我在跑通第一版代码后对比过只用一个Set Abstraction和堆了三个Set Abstraction的模型在分类任务上的差距前者在ModelNet40上勉强90%后者能达到93%以上。分层结构对特征的表达能力提升是实打实的。3.2 最远点采样FPS为什么不用随机采样在采样中心点的时候PointNet选择了最远点采样而不是随机采样。原因很简单随机采样容易把点都聚在一堆导致中心点分布不均匀覆盖不全整个物体表面。最远点采样的逻辑是每选一个新点都挑距离已有集合最远的那个这样能保证采样点尽可能均匀地覆盖整个点云。FPS的实现不复杂但要注意它的时间复杂度是O(n^2)的。我之前在几百万点的点云上直接跑FPS结果等了十几分钟后来才意识到这个环节可能是性能瓶颈之一。工程上可以考虑先用体素下采样或者随机采样粗筛一下再做FPS效果差不多但速度快很多。3.3 分组方式球查询Ball Query与K近邻的选择选定中心点后要在中心点周围找邻域点组成一个个局部区域。PointNet原文用的是球查询Ball Query就是在以中心点为球心、固定半径的球内取所有点。为什么不用K近邻因为K近邻在不同密度区域取到的空间范围差异很大密集区域取K个点可能只覆盖很小一块稀疏区域取K个点可能跨越很大空间。球查询能保证每个局部区域的空间尺度一致这对后续的密度自适应处理非常重要。当然使用球查询也有麻烦就是需要调半径。这个半径设多大和数据的尺度关系很大。比如ModelNet40归一化后的点云坐标基本在[-1,1]之间半径设0.2到0.4比较合理但如果是真实雷达点云坐标单位是米那半径可能得设成1到2米。我的建议是先统计一下你的点云中点的平均间距用这个间距的2到4倍作为初始半径。4. PyTorch代码落地PointNet核心模块实现4.1 一个可运行的PointNet分类网络骨架先放一个我实际用过的PointNet分类网络核心代码做了简化去掉了T-Net里比较耗时的部分保留最核心的结构。你可以直接跑通再逐步往里加东西。import torch import torch.nn as nn import torch.nn.functional as F class PointNetfeat(nn.Module): def __init__(self, global_featTrue): super(PointNetfeat, self).__init__() self.global_feat global_feat self.conv1 nn.Conv1d(3, 64, 1) self.conv2 nn.Conv1d(64, 128, 1) self.conv3 nn.Conv1d(128, 1024, 1) self.bn1 nn.BatchNorm1d(64) self.bn2 nn.BatchNorm1d(128) self.bn3 nn.BatchNorm1d(1024) def forward(self, x): # x: [B, C_in, N] x F.relu(self.bn1(self.conv1(x))) # [B, 64, N] x F.relu(self.bn2(self.conv2(x))) # [B, 128, N] x self.bn3(self.conv3(x)) # [B, 1024, N] x torch.max(x, 2, keepdimTrue)[0] # [B, 1024, 1] x x.view(-1, 1024) return x这里的关键点是卷积核大小全部是1也就是对每个点独立做MLP不做跨点卷积。torch.max是在点的维度第2维上做对称操作。我自己最初写这版的时候犯了一个错误把torch.max和torch.mean搞混导致输出特征尺度完全不同后面所有层梯度都不稳定。如果你的任务里点云密度差异很大可以试试用torch.mean或者在Max之后拼接Mean有时候反而更稳。4.2 加T-Net的完整版逻辑T-Net的输入输出都是一个变换矩阵结构上就是一个小型的PointNet。比如输入变换T-Net输入是原始点云输出是一个3x3的矩阵把这个矩阵乘到原始坐标上就完成了第一次姿态对齐。下面是输入变换部分的PyTorch示例class STN3d(nn.Module): def __init__(self): super(STN3d, self).__init__() self.conv1 nn.Conv1d(3, 64, 1) self.conv2 nn.Conv1d(64, 128, 1) self.conv3 nn.Conv1d(128, 1024, 1) self.fc1 nn.Linear(1024, 512) self.fc2 nn.Linear(512, 256) self.fc3 nn.Linear(256, 9) self.bn1 nn.BatchNorm1d(64) self.bn2 nn.BatchNorm1d(128) self.bn3 nn.BatchNorm1d(1024) def forward(self, x): batchsize x.size(0) x F.relu(self.bn1(self.conv1(x))) x F.relu(self.bn2(self.conv2(x))) x self.bn3(self.conv3(x)) x torch.max(x, 2, keepdimTrue)[0] x x.view(-1, 1024) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) # 加上单位矩阵的残差连接让初始变换近似单位阵 iden torch.eye(3, devicex.device).view(1, 9).repeat(batchsize, 1) x x iden x x.view(-1, 3, 3) return x要注意的是T-Net最后一定要加一个残差单位矩阵否则网络初始化的变换矩阵是随机的会把输入点云扭曲得很厉害训练很难收敛。这是论文里特别强调的细节也是我在刚开始复现时最容易忽略的点。4.3 分类头与损失函数配置PointNet的分类头就很简单了把全局特征过几个全连接层输出类别Logits即可class PointNetCls(nn.Module): def __init__(self, num_classes40): super(PointNetCls, self).__init__() self.feat PointNetfeat(global_featTrue) self.fc1 nn.Linear(1024, 512) self.fc2 nn.Linear(512, 256) self.fc3 nn.Linear(256, num_classes) self.dropout nn.Dropout(p0.3) def forward(self, x): x self.feat(x) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.dropout(x) x self.fc3(x) return x用交叉熵损失加一个很小的权重衰减优化器我用的是Adam初始学习率0.001在ModelNet40上50个epoch左右能收敛到89%~90%。如果你把T-Net完整加回去精度可以到92%以上但如果只是做baseline对比上面的版本足够了。5. PointNet关键模块实现从FPS到Ball Query5.1 最远点采样的高效实现FPS的PyTorch实现有很多种最直观的写法是用循环逐个点采样但那样太慢。我在项目里用的是基于距离矩阵的批量实现为了便于理解下面给一个简洁版本def farthest_point_sample(xyz, npoint): xyz: [B, N, 3] 原始点云坐标 npoint: 需要采样的中心点数量 device xyz.device B, N, _ xyz.shape centroids torch.zeros(B, npoint, dtypetorch.long, devicedevice) distance torch.ones(B, N, devicedevice) * 1e10 farthest torch.randint(0, N, (B,), dtypetorch.long, devicedevice) for i in range(npoint): centroids[:, i] farthest centroid_xyz xyz[torch.arange(B), farthest].unsqueeze(1) # [B, 1, 3] dist torch.sum((xyz - centroid_xyz) ** 2, dim-1) # [B, N] distance torch.min(distance, dist) farthest torch.max(distance, dim-1)[1] return centroids注意distance torch.min(distance, dist)这一步它保留的是每个点到已选点集合的最小距离每次选距离最大的那个点作为下一个中心点。我之前没想明白这段逻辑以为每次都要重算距离后来才反应过来这样可以避免O(npointNN)的复杂度。5.2 球查询分组与特征聚合球查询的本质是找每个中心点半径内的邻域点并把它们的特征聚在一起。这里有个经常被忽略的细节每个球内的点数是不固定的而Tensor要求维度固定所以一般会固定一个最大点数K比如取半径内最近的K个点不足K的用第一个点中心点自己补齐。def ball_query(radius, nsample, xyz, new_xyz): xyz: [B, N, 3] 原始点 new_xyz: [B, S, 3] 中心点 B, N, _ xyz.shape S new_xyz.shape[1] group_idx torch.arange(N, devicexyz.device).view(1, 1, N).repeat(B, S, 1) # 计算所有点到所有中心点的距离 dist torch.sum((xyz[:, None, :, :] - new_xyz[:, :, None, :]) ** 2, dim-1) # 距离大于半径的点置为N哨兵值 group_idx[dist radius ** 2] N # 取最近的前nsample个点 group_idx group_idx.sort(dim-1)[0][:, :, :nsample] # 若存在哨兵N则用第一个点即中心点自身填充 group_first group_idx[:, :, 0].view(B, S, 1).repeat(1, 1, nsample) mask group_idx N group_idx[mask] group_first[mask] return group_idx拿到邻域索引后下一步就是根据索引把每个局部区域的原始坐标和特征抠出来然后送到一个PointNet模块里做局部特征提取。这一步相当于把之前的PointNetfeat搬到了每个局部区域上不过卷积的输入通道不再是3而是3上一个特征层的输出维度因为要拼上中心点的坐标信息比如相对坐标。5.3 密度自适应多尺度分组与单尺度分组的选择PointNet有一个非常重要的改进是密度自适应。因为真实点云在不同区域的密度差异很大如果只用固定半径的球查询稀疏区域里可能一个邻域点都没有。论文里给了两种方案多尺度分组MSG和单尺度分组SSG加密度预测。我的建议是如果你的任务以真实场景为主优先用MSG。做法是在同一层用多个不同的半径做球查询然后把这几个分支的特征拼在一起。虽然计算量变大但稳定性很好。如果只是跑数据集benchmarkSSG就够了。我在SemanticKITTI上做语义分割时对比过MSG比SSG在稀疏区域的分割精度高了不少但训练时间也多了将近一倍。工程上怎么取舍要看你自己的算力余量。6. 从理论到实际训练数据预处理与调参6.1 数据预处理决定成败中心化与归一化很多人跑PointNet系模型第一件事就是把点云丢进网络结果效果奇差然后怀疑模型有问题。我踩过这个坑。PointNet系列对输入坐标的尺度非常敏感尤其是球查询的半径它和坐标的尺度直接相关。如果你在ModelNet40上把半径设成0.4然后直接拿到一个以米为单位的LiDAR点云上跑那球查询出来的邻域数量完全不对。我的标准预处理流程是先对每个点云减去质心坐标做中心化再除以所有点到质心的最大距离做归一化把尺度压到[-1,1]之间。这一步做完之后固定半径参数才有跨数据集迁移的可能。另外如果能做数据增强尽量对点云做随机旋转绕Z轴或者全方向、随机抖动和高斯噪声这些操作在3D分类任务上的涨点效果非常明显和图像领域做平移翻转是一个道理。6.2 我在训练过程中踩过的几个坑第一个坑是BatchNorm在点云上的表现。PointNet系网络几乎每层都有BatchNorm但点云数据在一个batch里如果点的数量不一致通常需要padding到统一长度。如果用mask方式忽略padding点那BatchNorm统计的是所有点包括padding的分布训练会很不稳定。我的解决方案是尽量保证同一个batch里的点云点数接近做填充时用坐标原点和特征零向量填充并在Max Pooling时把padding点mask掉。第二个坑是最远点采样的计算开销。我在一个48万点的点云上做完FPS后发现一个epoch要跑25分钟后面对代码做profile才发现瓶颈就是FPS。后来改体素下采样到65536点再做FPS速度提升非常明显精度只掉了0.1%左右。如果你的输入点云规模很大建议先做个下采样热身。第三个坑是特征维度和反传。用PointNet做分割任务时最后需要用Feature Propagation把高层特征传播回每个点这涉及插值和跳跃连接。很多人在这个环节维度没对齐报错找半天。我的经验是先把草图维度的变化写在注释里每过一层就print一下shape对齐问题能肉眼发现。6.3 实用工程建议从基准复现到自己的数据如果你是想用自己的数据做点云分类但又没有现成的标注我建议先用ModelNet40把代码全套跑通把精度刷到论文的水平然后再迁移到自己的数据上。迁移时最需要调整的几个超参数是球查询半径、每个局部区域的最大点数、Set Abstraction的层数和每层的特征维度。一般做法是先用小半径浅层网络快速验证数据加载和训练流程是通的再逐步加深加宽。网络结构上对于点数较多的点云比如2万点以上用三层Set Abstraction比较合适点数较少的比如1024点两层就够了。每一层的采样点数量我习惯按原始点数的1/4到1/8逐级减少。特征维度趋势是递增的比如第一层输出64维第二层128维第三层256维或者512维具体视算力来定。7. 当我回头看PointNet系列它至今仍值得啃从理论到代码我把PointNet和PointNet从头到尾过了一遍最大的感受是这套方法的简洁性背后是极强的设计智慧。PointNet用几个1x1卷积加一个Max Pooling就解决了置换不变性问题PointNet用一套采样-分组-抽象的分层框架就把局部特征和密度变化问题一起消化了。如今Transformer类的3D方法比如Point Transformer、Point-BERT层出不穷但它们的很多基础组件最远点采样、球查询、分组特征提取依然沿用PointNet的设计。对我来说这篇文章的核心收获是不要只把PointNet系列当一个baseline跑跑精度就完事而是要理解它每个设计决策背后的动机——为什么是Max不是Mean为什么是球查询不是KNN为什么采样用FPS。只有把这些想明白你在面对真实点云项目时才知道该改哪里、怎么改。希望这篇笔记能帮你少走一些弯路。本文还有配套的精品资源点击获取
返回列表