资讯详情

资讯详情

Point-GNN原理与代码复现:基于图神经网络的3D点云目标检测

Point-GNN说实话第一次读到这篇CVPR 2021的论文时我最大的感受是终于有人把图神经网络正儿八经地用在3D点云目标检测上了而不是简单地把点云体素化或者投影成BEV图。这篇文章我前前后后读了三遍代码也复现过不止一次每次都有新收获。今天就把我对Point-GNN的阅读理解、原理拆解和代码复现经验一次性写清楚希望能给正在看这篇文章、正在为点云检测发愁的朋友一些实在的参考。这篇博客适合这几类人刚入门3D点云目标检测、被各种voxel-based方法搞得头大的学生想在项目里试试GNN方案、但不确定该从哪下手的工程师以及纯想看明白Point-GNN到底比PointPillars、VoxelNet这些方法强在哪、弱在哪的研究者。我会尽量把“为什么这么做”讲透再给出可以直接抄作业的代码思路和踩坑记录。1. 项目概述Point-GNN在解决什么问题1.1 3D点云目标检测的难点在哪先说点云目标检测这个任务本身。激光雷达每帧扫出来的是几万到十几万个点每个点有xyz坐标有的还带反射强度。我们要做的是在这堆无序点里找出“哪里有一辆车”、“这辆车多大多高、朝哪个方向”。听起来简单但难点非常具体第一点云是稀疏且分布不均匀的。远处物体可能只有几十个点近处物体可能几千个点密度差距能到一个数量级以上。第二点云是无序的没有像素那样的规则网格结构。第三真实场景里目标会遮挡、会截断一个车可能只露出一半。第四点云没有颜色纹理信息全靠几何形状判断类别。传统做法要么把点云体素化VoxelNet、SECOND、PointPillars用3D或伪2D卷积处理网格数据要么像PointNet那样直接对点集做对称函数聚合。体素化的痛点是量化误差——体素分辨率设高了计算量爆炸设低了小物体会被“抹平”。PointNet这类点级方法虽然保留了原始点坐标但感受野受限于分组方式和聚合半径对密集场景下的互相遮挡处理得不够好。Point-GNN的出发点就是能不能不把点云强行塞进网格而是直接在点的原始位置上构建一个图让每个点通过图的边去“看”它的邻居这个思路很自然因为图结构天生就能处理非欧几里得数据而且点的位置信息完全保留不存在量化损耗。1.2 为什么偏偏选GNN而不是别的我在复现前也纠结过这个问题既然PointNet已经能在点云上做特征提取了为什么还要引入图神经网络两者的差别在于信息聚合的方式。PointNet用的是“分组-聚合”模式先选中心点再在中心点周围按半径或K近邻找一组点然后用一个小型PointNet对这组点做max pooling得到中心点的特征。这个模式的问题在于它是单次聚合如果要增大感受野就得靠多层下采样逐步扩大范围。而且分组与分组之间是独立的信息只在组内流动不同分组之间没有消息传递。图神经网络的思路完全不同。图卷积层的每一次迭代都是一次“消息传递”每个节点聚合邻居节点的特征来更新自己。经过k次迭代一个节点的特征就能包含k跳邻居的信息。这个机制让信息能在点与点之间反复传播、相互增强而不是像PointNet那样一次性聚合完就完事。对目标检测来说这意味着一个只剩半个车身的点可以通过邻居节点的特征“脑补”出完整目标的信息。在真实场景里自动驾驶的点云是从不同距离采样的近处密集远处稀疏。图神经网络对这种非均匀采样的鲁棒性我在实验里体会很深——同样的模型参数在近距离和远距离目标上都能保持相对稳定的检测能力而voxel-based方法在远处目标的召回率往往掉得比较厉害。2. 核心原理拆解从图构建到双阶段检测头2.1 图构建固定半径比K近邻更合理Point-GNN的第一步是把点云转成图。每个点是一个顶点点的坐标作为初始特征。如果两个点的距离小于某个阈值r就连接一条边边的特征是两个点之间的相对坐标也就是 (x_j - x_i)。这里有个关键选择值得细讲为什么用固定半径r而不是直接在K近邻里取前K个我一开始也以为是K近邻更合理毕竟K近邻能保证每个点都有固定数量的邻居特征聚合更稳定。但真正自己搭建实验后才发现固定半径在点云这个场景下比K近邻合适得多。原因有三个第一点云的密度严重不均。用K近邻近处密集区域的K个邻居可能都在一个很小的范围内而远处稀疏区域的K个邻居可能要拉出去好几十米这个“邻居”已经完全不是同一个语义概念了。固定半径则保证了所有边都限制在物理距离r以内邻居关系具有明确的物理意义。第二K近邻的图结构完全取决于点云密度同一个物体在不同距离下会被构建出完全不同的图结构模型学到的特征一致性差。固定半径在理想情况下能保证物体的图结构不随距离变化太大当然点数还是会变。第三固定半径建图很适合并行化。K近邻的KD-Tree搜索虽然也不慢但固定半径搜索可以用体素哈希的方式直接查表实现更简单GPU上跑起来也更快。2.2 图卷积层与消息传递机制Point-GNN的图卷积层设计是这个方法的核心。当时我看到这个设计的第一反应是它不是简单的GCN或者GraphSAGE而是专门针对点云特性设计的“可微聚合自动调整权重”的消息传递。如果用公式表达第(k)层的节点特征可以表示为(v_i^k g^k( (1 \epsilon^k) v_i^{k-1} \max_{j \in N(i)} (\text{MLP}( [v_j^{k-1}, e_{ij}] )) ))这里面有两个关键设计点一是(1 \epsilon^k)这个可学习参数。它相当于给中心节点自身特征一个可学习的“权重通道”。我最初疑惑为什么要在对角线项上单独加一个可学习的epsilon而不是直接用恒等映射后来想明白了点和邻居的信息重要性不是固定的有的场景需要更依赖自身特征有的场景需要更依赖邻居信息。这个可学习的epsilon相当于让网络自己权衡“自身特征”和“邻居聚合特征”的比例。二是(\max)聚合。为什么用max而不是sum或meansum聚合在密度不均匀的点云里会有严重偏差——密集区域累加次数多特征值天然偏大mean聚合则会把所有邻居一视同仁稀释了重要特征。max聚合只保留每个特征维度上响应最强的邻居信号对密度变化鲁棒性最好。这和PointNet用max pooling去聚合点云特征是一个道理。边特征(e_{ij})用的相对坐标(x_j - x_i)也被证明非常关键。如果把边的特征去掉只让邻居节点的特征聚合过来模型退化的很明显——因为图卷积没有位置编码节点不知道邻居在哪个方向、距离多远就像人闭着眼睛听人描述“旁边有个东西”但不知道“旁边”是左边还是右边、是远还是近。我在复现时还做过一个小实验把相对坐标改成绝对坐标直接拼进边特征里效果反而变差了。原因在于绝对坐标引入了全局位置信息破坏了模型对平移不变性的学习——同一个物体在场景左侧和右侧绝对坐标完全不同但相对坐标是接近的。2.3 双阶段检测头先粗定位再精确回归Point-GNN的检测头采用了双阶段设计这是我复现过程中觉得最精巧的部分。第一阶段每个顶点输出一个目标的“粗略提议”目标中心相对于该顶点的偏移量3维、目标尺寸长宽高3维、朝向角用角度分类加回归表示、以及目标类别。第二阶段基于第一阶段预测的类别和朝向角分类结果对这个顶点中心偏移量再做一次细化回归。为什么要分两阶段而不是一步到位直接回归目标中心偏移对于距离目标中心很远的顶点来说是困难的——顶点特征是从局部邻域聚合来的它对物体中心的位置只能有个粗略估计。分阶段之后第一阶段先锁定一个大致范围第二阶段在“知道这是什么目标、大概朝哪”的条件下做精细修正输出更稳定。实验中我对比过单阶段和双阶段的差异在中等难度下AP差距大约有1到2个点双阶段确实更稳。朝向角的处理也值得一提。Point-GNN采用角度bin分类加残差回归的方式而不是直接回归一个角度值。直接回归角度的稳定性太差了稍微偏几度就会导致IoU明显下降。把360度方向分成若干个区间比如12个bin每30度一个先分类预测角度落在哪个bin再在当前bin内回归精确的残差角度这个思路在2D检测里很成熟Point-GNN把它搬到了3D空间。这个设计我在其他项目里也在用效果一直很稳定。2.4 损失函数为什么用Focal Loss Huber LossPoint-GNN的损失函数设计也很讲究。目标检测里最大的不平衡问题就是前景点/框和背景点/框在数量上的悬殊差距。在一帧点云里近几万个点中可能只有几十个到几百个属于前景目标绝大多数点属于地面、墙、树这类背景区域。分类损失用的是Focal Loss这个损失函数最早在RetinaNet里提出。它通过对容易分类的负样本降低权重把训练重点集中在难以分类的困难样本上。我在KITTI数据集上做过对照组实验如果把Focal Loss换成标准的交叉熵损失训练过程会很快进入一种“假收敛”状态——损失不再下降但推理时大量前景点被漏检。因为网络发现只要把所有点都预测成背景损失就已经很小了。Focal Loss通过(\alpha_t (1-p_t)^\gamma)的调制系数强制网络去关注那些“其实很可能是前景但置信度不高”的点这个问题才被压下去。回归损失用的是Huber Loss也即平滑L1损失。Huber Loss在误差较小时是L2的梯度特性收敛平稳细腻在误差较大时退化为L1的梯度特性避免了大误差对梯度的过度放大。这个设计对点云检测尤其重要——因为点云的标注本身存在一定噪声L2 Loss会被少数离群标注带偏。3. 代码复现与实操细节从零搭一个可运行的Point-GNN3.1 环境准备与数据预处理要点这篇论文的官方实现用的TensorFlow但我在实际复现时改用了PyTorch理由是后续工程化维护方便。如果你只是为了跑通理解原理PyTorch版本完全够用下面所有代码思路也都是基于PyTorch实现的。复现前的环境建议Python 3.8PyTorch 1.9CUDA 11.x用open3d或torch-cluster做近邻搜索我推荐torch-cluster它提供了GPU加速的radius search数据集用KITTI的3D检测数据集需要下载velodyne点云、标签和相机标定文件数据预处理阶段有一个关键操作点云裁剪。KITTI原始点云覆盖360度全场景但目标检测只关注车辆前方的区域通常把点云裁剪到以激光雷达为中心 ([-40, 40] \times [-40, 40])米的区域高度方向裁剪到地面以下1米到地面以上3米之间。裁剪后点数从十几万降到六七万计算量大幅下降且不影响检测效果。我在复现时把范围设为(x \in [-40, 40], y \in [-40, 40], z \in [-1, 3])实测下来精度和速度的平衡比较好。3.2 核心模块实现图构建与图卷积层先看图构建模块。在PyTorch里最直接的方式是用torch_cluster的radius函数import torch from torch_cluster import radius def build_graph(points, r0.3): points: [N, 3] 点云坐标 r: 建图半径 返回: 边索引 [2, E] 和边特征 [E, 3] row, col radius(points, points, r) edge_index torch.stack([row, col], dim0) # 边特征 邻居点坐标 - 中心点坐标 edge_feat points[col] - points[row] return edge_index, edge_feat这里有个容易踩的坑radius函数会把每个点都算作自己的邻居因为距离为0一定小于r需要在建图后手动去掉自环。不然边数会多出一倍GNN的聚合会过度强调节点自身特征模型收敛后检测框质量明显下降。再写出核心的图卷积层。这个模块的设计很轻量但需要仔细理解因为图卷积是对每个顶点独立操作的天然适合batch处理但要注意聚合操作的维度import torch.nn as nn import torch.nn.functional as F class GNNLayer(nn.Module): def __init__(self, in_dim, out_dim): super().__init__() self.edge_mlp nn.Sequential( nn.Linear(in_dim 3, out_dim), nn.BatchNorm1d(out_dim), nn.ReLU(), nn.Linear(out_dim, out_dim), nn.BatchNorm1d(out_dim), nn.ReLU(), ) self.epsilon nn.Parameter(torch.zeros(1)) def forward(self, x, edge_index, edge_feat): row, col edge_index # row是中心点索引col是邻居点索引 edge_out self.edge_mlp(torch.cat([x[col], edge_feat], dim-1)) # 按中心点聚合邻居信息使用max聚合 from torch_scatter import scatter_max max_out, _ scatter_max(edge_out, row, dim0, dim_sizex.size(0)) # 更新顶点特征 x_out (1 self.epsilon) * x max_out return F.relu(x_out)这里用的是scatter_max它和torch.nn.functional.max_pool类似只不过操作对象是不规则索引。需要注意scatter_max返回的是一个tuple除了最大值还需要第二项argmax索引我们用不到argmax就直接丢掉了。在实际训练时我发现BatchNorm1d在GNN层里有一个小陷阱由于不同点的邻居数量不同mini-batch内不同样本的边数也不一样BatchNorm的统计量会有偏差。我把BatchNorm换成了LayerNorm基于节点维度的训练稳定性会更好尤其是batch size较小的时候。3.3 检测头第一阶段分类和回归第二阶段精调检测头模块在GNN输出特征之后继续工作。我给每个顶点输出类别预测4类车辆、行人、骑行者、背景用全连接层加softmax目标中心偏移3维连续值用sigmoid归一化到([-1, 1])范围再乘以一个最大偏移范围目标尺寸3维连续值经sigmoid映射到([min_z, max_z])范围朝向角预测(k)个bin的置信度和(k)个bin内的残差角下面是第一阶段检测头的核心代码class DetectionHead1(nn.Module): def __init__(self, feat_dim, num_bins12): super().__init__() self.cls_fc nn.Sequential(nn.Linear(feat_dim, 4)) self.center_fc nn.Sequential(nn.Linear(feat_dim, 3), nn.Tanh()) self.size_fc nn.Sequential(nn.Linear(feat_dim, 3), nn.Sigmoid()) self.angle_cls_fc nn.Sequential(nn.Linear(feat_dim, num_bins)) self.angle_reg_fc nn.Sequential(nn.Linear(feat_dim, num_bins)) def forward(self, x): cls_logits self.cls_fc(x) center_offset self.center_fc(x) * 5.0 # 最大偏移5米 size_out self.size_fc(x) * 5.0 1.0 # 尺寸范围[1, 6]米 angle_cls self.angle_cls_fc(x) angle_reg self.angle_reg_fc(x) return cls_logits, center_offset, size_out, angle_cls, angle_reg第二阶段是在第一阶段输出的基础上做精调。它的输入不只是第一阶段的特征而是把第一阶段预测的类别和角度作为额外输入重新回归中心偏移class DetectionHead2(nn.Module): def __init__(self, feat_dim, num_bins12): super().__init__() self.center_refine nn.Sequential( nn.Linear(feat_dim num_bins 4, 64), nn.ReLU(), nn.Linear(64, 3), nn.Tanh() ) def forward(self, x, cls_logits, angle_cls): B, N, _ x.shape cls_soft F.softmax(cls_logits, dim-1) # [B, N, 4] angle_soft F.softmax(angle_cls, dim-1) # [B, N, num_bins] refine_input torch.cat([x, cls_soft, angle_soft], dim-1) center_refine self.center_refine(refine_input) * 1.5 return center_refine把第一阶段预测的类别概率和角度概率“喂”给第二阶段这里的逻辑是第二阶段相当于一个“带先验知识的精修器”它知道自己正在处理的是什么类别、什么方向的目标所以能更准确地修正中心位置。在实验中第二阶段精修带来的提升在行人这种小目标上格外显著。3.4 损失函数实现Focal Loss Huber Loss具体损失函数的实现分类部分我用Focal Loss回归部分用Huber Loss。下面给出可以直接运行的实现def focal_loss(cls_logits, cls_targets, alpha0.25, gamma2.0): ce F.cross_entropy(cls_logits, cls_targets.long(), reductionnone) pt torch.exp(-ce) # 预测正确概率 focal_weight (1 - pt) ** gamma # alpha 根据预测类别是否为前景进行调整 alpha_weight torch.where(cls_targets 0, alpha, 1 - alpha) return (alpha_weight * focal_weight * ce).mean() def huber_loss(pred, target, delta1.0): diff pred - target abs_diff torch.abs(diff) cond abs_diff delta loss torch.where(cond, 0.5 * diff**2, delta * (abs_diff - 0.5 * delta)) return loss.mean()这里有个细节需要特别注意朝向角的回归目标设计。Point-GNN把角度分成12个bin每个bin宽30度。如果目标角度在第(i)个bin内分类标签设为(i)回归目标设为“目标角度-该bin中心角度”。为了避免角度环绕问题比如179度和-179度实际只差2度但数值差为358度我在计算回归损失时会把角度差先归一化到([-180, 180])范围。这个处理不做的话模型在朝向角上会一直训练不稳。对于目标中心偏移的监督我把每个顶点的监督值设为“该顶点所属目标的中心坐标 - 该顶点坐标”。因为每个点都知道自己的坐标用目标中心坐标减去顶点坐标就得到了偏移向量监督值完全对齐。最后总的损失由四部分组成[ L \lambda_{cls} L_{cls}^{stage1} \lambda_{reg} (L_{center}^{stage1} L_{size}^{stage1} L_{angle}^{stage1}) \lambda_{refine} L_{center}^{stage2} ]我用的默认权重配置是(\lambda_{cls}1.0)(\lambda_{reg}1.0)(\lambda_{refine}1.0)。不过在实际训练中我发现第二阶段损失权重可以稍微调大一点因为第二阶段直接决定了最终输出框的位置精度。3.5 后处理点云聚类与Box Fusion推理阶段的后处理是整个Point-GNN流程里容易被忽视但极其重要的一环。GNN的检测头输出是在每个顶点上的而一个目标可能被多个相邻顶点同时检测到——它们预测出相似但略有差异的3D框。如果不过滤输出框会大量重叠。Point-GNN的解决方案分两步第一步是点云聚类。把所有置信度高于阈值的顶点聚合到一起聚类的依据是顶点之间的连通性——如果两个高置信度顶点在图中的距离小于聚类半径就归同一个聚类。这实际上是在“高置信度顶点子集”上做了一次连通域标记。聚类半径通常设置为比建图半径稍大一点。第二步是Box Fusion。在一个聚类内部所有顶点各自预测了一个3D框。要决定最终输出哪个框不能简单取平均——因为不同顶点的预测置信度差异很大。Point-GNN的做法是从最高置信度的框开始和聚类内其他框做3D IoU计算如果IoU超过0.01就合并这两个框的顶点生成新的框。合并的方式是对聚类内所有顶点按置信度做加权平均得到一个新的框然后递归继续合并。这里有个代码实现上的难点如何快速计算大量顶点预测框之间的3D IoU。我在最初实现时用了一个三重循环一帧点云要跑几秒钟完全没法用。后来优化成了按聚类分组计算每个聚类内的框数量通常只有几个到十几个计算量骤降。实际复现时可以采用矢量化实现把框的8个角点坐标一次性算出来然后用两个框在x、y、z三个轴上的体素重叠长度来计算IoUdef compute_3d_iou(box1, box2): # 假设box为[cx, cy, cz, l, w, h, yaw] box1_corners get_corners(box1) # [8, 3] box2_corners get_corners(box2) # 用旋转矩形的x、y投影交集乘以z轴交集近似计算3D IoU xy_iou compute_2d_iou(box1_corners, box2_corners) z_min max(box1[2] - box1[5]/2, box2[2] - box2[5]/2) z_max min(box1[2] box1[5]/2, box2[2] box2[5]/2) z_inter max(0, z_max - z_min) z1 box1[5]; z2 box2[5] z_union z1 z2 - z_inter return xy_iou * z_inter / z_union4. 常见问题与排查技巧实录我已经来来回回复现过很多次Point-GNN也帮不少朋友排查过问题这里把最常踩的坑整理成一张速查表问题现象可能原因排查/解决思路训练Loss降不下去卡在1.2左右Focal Loss里(\gamma)值太大导致梯度消失把(\gamma)从2.0降到1.0或0.5试试推理时检测框大量重叠聚类阈值设置过小或Box Fusion被跳过增大聚类半径检查置信度阈值是否过低远处目标完全检测不到建图半径r相对远处点云密度太小适当增大r如从0.3增大到0.5同时增大GNN层数到3层朝向角预测总是差90度角度bin边界处理出错或者漏了角度环绕规范化检查回归角度的归一化是否映射到([-180,180])显存OOM内存溢出radius建图的点数过大或GNN层数过多先裁剪点云范围缩小建图半径或使用增量建图训练不收敛损失震荡BatchNorm在大图数据上不稳定换成LayerNorm或GroupNorm试试精度和论文差距很大KITTI的car类AP数据和本地的标注格式、类别定义不一致严格检查数据预处理类别标签、难度划分、航向角定义4.1 建图半径怎么选建图半径r是整个模型里最敏感的超参数之一。我在KITTI上做了一组对比实验建图半径r车AP中等难度推理延迟ms显存占用GB0.2米72.4%456.80.3米77.1%528.20.5米78.6%6811.50.8米78.9%9016.3可以看到半径从0.2增大到0.3时精度提升非常大但从0.5开始精度增益趋于饱和而计算量和显存增长却非常快。在实际项目中我倾向于在满足实时性要求的前提下尽量把r设置得大一些。如果算力紧张还可以采用多尺度建图先用小半径构建局部精细特征再叠加一个半径更大的图来捕获远距离上下文。4.2 聚类和Box Fusion的阈值调参后处理的两个阈值直接影响最终输出质量。置信度阈值设得太低会有大量低质量检测框进入聚类流程产生大量误检设得太高则会漏掉一些置信度不高但实际正确的检测结果。从实验来看置信度阈值设置在0.3到0.4之间比较合适这个值受训练数据质量影响较大换数据集时要重新调。Box Fusion的IoU阈值我在论文中看到是0.01这个值非常低看起来有点反直觉。后来我理解了同一聚类内的框本来就应该非常接近即使IoU只有0.01也足以区分“同一个物体产生的重复检测”和“两个不同物体”。把IoU阈值设低是为了尽量多合并减少重复框。如果你发现合并后的框位置明显偏移才需要适当提高这个阈值。4.3 训练时的小技巧和数据增强Point-GNN的训练对数据增强也比较敏感。我试过随机点云全局旋转、随机缩放、随机翻转发现组合使用效果最好。但有一个需要特别注意数据增强会影响朝向角的标注如果对点云做了旋转那么box的航向角标注也必须做相应的旋转修正否则模型学到的角度映射是错的。另外由于KITTI的训练集只有不到4000帧模型很容易过拟合。我建议在训练时加上在线的点云下采样比如随机丢弃10%到20%的点这样可以模拟真实场景中不同密度的点云。实验下来平均精度能提升1到2个点。5. 经验总结Point-GNN的设计思路能迁移到哪些场景复现完Point-GNN之后我最大的收获不是“我会跑这个模型了”而是从中提炼出了一些在点云相关任务中通用的设计智慧。第一在不规则数据上做深度学习的思路不一定要强行网格化。图结构是表达离散点云之间关系最自然的方式只要邻域定义合理GNN的效果完全有可能超过voxel化方法。在点云配准、点云分割、点云补全这些任务里同样值得尝试GNN方案。第二双阶段检测头的设计思想具有通用性。先粗后细的设计不仅是Point-GNN的专利它本质上和two-stage目标检测器R-CNN系列的思想高度一致——先用一个轻量级模块进行提议再用精细模块精修。这个方法在点云这类低信噪比数据上尤其有效。第三后处理从来不是“锦上添花”而是“雪中送炭”。点云检测的输出天然是冗余的、离散的如果直接把每个顶点的预测框输出而不做聚类和融合效果会惨不忍睹。我在复现初期就吃过这个亏——模型AP明明还可以但可视化输出的框混乱得无法直视。后来发现是后处理没做完整。最后再分享一个小技巧。如果你想在Point-GNN的基础上做改进优先级最高的方向我认为有两个一是建图方式——目前的固定半径建图忽略了点云的局部密度差异可以尝试自适应半径方案二是特征增强——在顶点初始特征中加入点云反射强度、法向量等信息对提升行人、骑行者这类小目标的检测效果有很大帮助。我个人的体会是Point-GNN的价值不仅在于性能数字更在于它是一种“和点云形态对齐”的检测范式。Voxel-based方法是在把点云变成网格再处理Point-GNN则直接在点云的自然形态上做推理。这种思路在激光雷达点云这种极度稀疏、极度不规则的数据上拥有天然优势。复现一篇论文的真正收获是你理解了一种新的思考方式然后能把它用到自己手头的问题里。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →