ARTICLE DETAIL

资讯详情

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

PCRNet:全局特征编码点云配准的PyTorch复现与工程实践

PCRNet:全局特征编码点云配准的PyTorch复现与工程实践 刚读到这个标题的时候我第一反应是“又一个把PointNet套在配准上的工作”。但真正把代码跑起来、把论文里那套思路拆开之后我发现PCRNet其实是点云配准从“逐点对应”走向“全局表征回归”的一个很有意思的样本。如果你做过ICP、FPFH这类传统配准又被初值敏感、迭代慢、描述子失效折磨过那PCRNet绝对值得花一个下午去研究。这篇文章我会从问题本质、网络设计、PyTorch复现、训练细节和踩坑经验五个方面展开把一个标题背后的完整技术栈讲清楚。不管你是刚入门3D视觉的学生还是已经在做激光雷达配准、机器人抓取定位的工程师这篇文章里都有能直接拿去用的东西。1. 点云配准从 ICP 到深度学习的转折点1.1 传统方法为什么不够用点云配准说穿了就是给定两个在不同坐标系下采集到的点云求出一个刚体变换旋转矩阵R和平移向量t让它们在空间中对齐。这个问题的经典解法是ICPIterative Closest Point它的逻辑非常直白先假设一个初始位姿然后反复执行“找最近点对、计算变换、再找最近点”这个循环直到收敛。ICP在扫描配准、SLAM回环、物体重建里用了三十年可它的缺点和它的知名度一样突出。ICP对初始位姿极其敏感两个点云初始偏差稍微大一点最近点对就会配对错位最终陷入局部最优输出一个看起来合理但实际是错的变换。另外ICP本质上是贪心式的最近点搜索一旦两个点云覆盖范围不对称、密度差异大或者场景里有重复结构对应关系几乎必然出错。传统的改进思路是绕开最近点搜索改用特征描述子先建立对应关系比如FPFH、SHOT这类手工特征再通过RANSAC或全局优化去求解位姿。这套流程在纹理丰富、几何特征明显的场景下效果不错但遇到平面多、结构对称的室内环境或者几何退化的工业零件手工特征照样失效。问题出在“手工设计特征”这一步——你不可能为所有可能出现的几何形态都设计出合适的描述子。1.2 PCRNet 的切入点全局特征编码替代逐点匹配PCRNet的出发点非常直接能不能不建立逐点对应关系而是直接把两片点云的全局几何特征编码出来再通过网络回归出它们之间的相对位姿这个思路和传统的“特征匹配位姿求解”完全是两种范式。传统方法关心的是“哪些点和哪些点对应”PCRNet关心的是“这两片点云整体长什么样以及它们之间差了多少”。它把配准问题从一个组合优化问题变成了一个在全局描述子空间里的回归问题。具体到实现PCRNet把源点云Source和目标点云Target分别喂给两个共享权重的PointNet编码器得到两个全局特征向量。然后把这些特征拼接起来通过全连接层直接回归出旋转和平移。整个过程不需要最近点搜索不需要迭代求解也不需要任何手工特征描述子。这个设计的巧妙之处在于PointNet天然具有置换不变性。点云是没有固定顺序的点集同一个物体打散点的顺序后应该得到同样的特征表示。PointNet通过先把每个点独立升维、再用Max Pooling聚合的方式来保证这一点所以编码器不会因为点云的输入顺序变化而输出不同特征。这个性质对配准任务来说至关重要因为两片点云的采集顺序本来就是随机的。需要强调的是PCRNet并不是要彻底取代ICP。按照论文里的设计意图它更像是粗配准模块先用网络回归出一个接近真实值的初始变换再用ICP去精修。后面我会讲到这种“深度学习出初值、传统算法精修”的搭配在精度和鲁棒性上都比单独使用任何一种方案要强得多。2. PCRNet 网络结构拆解与设计动机2.1 输入是怎么构造的独立编码还是合并编码PCRNet的第一版设计对应论文里Pavlo Melnyk等人提出的版本常被用来对比PointNetLK是让源点云和目标点云分别通过PointNet编码得到两个特征向量后拼接在一起再经过回归网络输出变换参数。而PointNetLK走的是另一条路线它把两片点云的全局特征之差作为更新方向在特征空间里迭代地“拼”位姿。这个区别很关键PCRNet用拼接特征直接回归绝对位姿PointNetLK则模仿LK光流法在特征层面做增量迭代。在实际复现时我推荐先按PCRNet原文的方式实现两个共享权重的PointNet分别编码Source和Template输出特征维度通常取512或1024。要注意的是输入点云的数量不需要完全一致但建议通过随机采样固定为1024个点这样训练和推理时batch维度一致效率最高。另外输入点云在喂给网络前一般要减去质心也就是做一次简单的去中心化这样平移回归头的数值范围会小很多训练更容易收敛。2.2 PointNet 编码器为什么它适合提取点云特征PointNet是Charles Qi等人在CVPR 2017提出的点云深度学习基石网络。它的核心结构可以概括为三句话每个点独立做多层感知机MLP升维所有点共享同一组MLP权重最后用Max Pooling把任意数量的点特征聚合为一个全局特征。为什么MLP要共享权重因为点云中的点是无序的而且不同位置的点在语义上可能完全对等。比如一个桌子的点云桌面中央的点和桌角的点本质上都是“桌子的点”它们应该被同一个函数映射到特征空间。共享权重保证了特征提取过程不会因为点的空间位置不同而采用不同标准也保证了置换不变性。Max Pooling这个操作是PointNet设计里最“反直觉”但最有效的部分。它从所有点的特征向量里逐维度取最大值。你可以把它理解成“投票”每个点都可以对某一个特征维度投一票但只有数值最大的观点会被采纳。这样即使某些点被噪声污染或者遮挡丢失只要还有一部分点提供了正确的局部证据全局特征就不会受到太大影响。不过Max Pooling也有信息损失的问题。它只保留每个维度的最大响应其他点的细节信息都被丢掉了。这也是为什么PointNet之后的PointNet、DGCNN等网络要引入局部聚合和边卷积来弥补这个问题。但在PCRNet里编码器只需要提取“整体形状”级别的判别性特征PointNet的全局池化反而是优势——它天然滤掉了局部噪声让网络更关注整体几何差异。2.3 回归头与三种变体从分类头到 SE(3) 参数化原始PointNet论文里有一个分类头和一个分割头。分类头由全局特征经过若干全连接层后输出一个类别概率分布。PCRNet把分类头替换成了回归头输出一个8维向量前4维是旋转的四元数表示后3维是平移向量。有些实现版本还会加1维表示尺度但刚体变换不需要缩放一般只输出7维或者8维取决于是否用四元数归一化前的原始维度。四元数作为旋转表示比旋转矩阵更适合直接回归。旋转矩阵有9个元素但实际自由度只有3个直接回归9个值很难保证输出矩阵是正交的。四元数只有4个元素约束是模长为1网络输出后做一次L2归一化就能得到一个合法的旋转表示。四元数还是有歧义的q和-q表示同一个旋转这会带来训练时的非凸性问题。所以PCRNet原文在使用四元数时会加一些技巧比如在损失函数里对q取绝对值或者强制q的第一个分量为正。除了直接回归PCRNet-R论文里还提到了两种变体分类式PCRNet-C和迭代式Iterative PCRNet。分类式把旋转角度离散化输出一个角度类别的概率分布再取期望值当作预测角度迭代式则模仿ICP的思路在训练时让网络对“当前残差位姿”进行多次预测每次把预测结果作用到源点云上逐步逼近真实位姿。迭代式效果最好但训练更复杂需要把配准变换写进计算图。损失函数方面最常用的是预测参数和真值参数之间的L1或L2距离。但直接比较平移和旋转会面临量纲不一致的问题平移误差通常是厘米级甚至米级旋转误差却是角度量级。直接相加会导致网络优先拟合平移而忽视旋转。更合理的做法是加权损失比如旋转部分乘一个系数λ或者使用“旋转误差向量”加“平移误差向量”的组合方式。我在后面的复现部分会给出具体的loss实现。3. 从零复现 PCRNet数据、代码与训练配置3.1 数据集准备ModelNet40 与 KITTI复现PCRNet最常用的数据集是ModelNet40。它是一个包含40个类别的CAD模型数据集每个模型以网格mesh形式提供可以在官网下载到h5格式或者源码格式。配准任务需要从这些网格模型上采样出点云然后随机生成一个刚体变换作为真值把源点云变换到目标位置。整个过程可以用Open3D或者Trimesh完成。获取ModelNet40后建议把数据整理成以下形式每一条训练样本包括待配准的源点云shape为N×3、目标点云shape为N×3以及它们之间的变换真值R和t。生成随机变换时旋转角度的范围按论文建议取0°到45°平移量控制在[-0.5, 0.5]之间。这个范围很重要——它决定了你的网络要覆盖的空间。如果训练时只见过小角度的旋转推理时遇到大角度旋转一定会翻车。KITTI数据集主要用来做自动驾驶场景下的验证。它提供的是激光雷达采集到的真实场景点云点云数量大、噪声多还带有尺度变化。比起ModelNet40这种干净的单物体数据KITTI的难度高出一个量级。训练时直接用KITTI会比较吃力更推荐的做法是在ModelNet40上预训练再在KITTI的子序列上做微调或者只把KITTI当作测试集来评估泛化能力。下载和处理数据时我踩过一个大坑ModelNet40的类别文件夹和点云样本文件名在不同来源的版本里命名不一致有的用“airplane_0001.ply”有的用“Airplane_26.ply”。建议在做数据管线时先把所有文件名统一化生成一个record列表避免后续训练时因为找不到文件而中断。数据规模方面ModelNet40一共只有一万多个模型对PCRNet这种全局编码的网络来说完全够用不需要做太多数据增强也能收敛。3.2 核心代码实现PointNet 编码器与回归头我用PyTorch实现了PCRNet的核心部分。PointNet编码器的结构很简洁输入N×3的点云输出1024维全局特征回归头由三个全连接层组成最后一层输出7维参数四元数4维平移3维。下面给出可以直接运行的代码骨架。import torch import torch.nn as nn import torch.nn.functional as F class PointNetEncoder(nn.Module): def __init__(self, global_feat_dim1024): super().__init__() 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, 64, 1), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, 1), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, global_feat_dim, 1), nn.BatchNorm1d(global_feat_dim), nn.ReLU(), ) def forward(self, x): # x: (B, 3, N) x self.mlp1(x) x self.mlp2(x) x torch.max(x, dim-1, keepdimFalse)[0] # (B, C) return x class PCRNet(nn.Module): def __init__(self, feature_dim1024): super().__init__() self.encoder PointNetEncoder(global_feat_dimfeature_dim) self.regressor nn.Sequential( nn.Linear(feature_dim * 2, 512), nn.ReLU(), nn.Linear(512, 256), nn.ReLU(), nn.Linear(256, 7), ) def forward(self, source, template): # source, template: (B, N, 3) src source.transpose(1, 2) # (B, 3, N) tpl template.transpose(1, 2) # (B, 3, N) src_feat self.encoder(src) tpl_feat self.encoder(tpl) feat torch.cat([src_feat, tpl_feat], dim-1) params self.regressor(feat) rotation F.normalize(params[:, :4], dim-1) translation params[:, 4:] return rotation, translation这段代码里有两个细节值得说明。第一个是全连接层最后一层没有激活函数因为回归输出需要无界值第二个是四元数在输出后立即做了L2归一化保证它落在单位球面上。如果在训练过程中发现自己对四元数归一化操作不放心也可以先输出原始4维向量在损失函数计算前再归一化效果差别不大。实现时还需要注意PyTorch的Conv1d默认接受(B, C, L)格式的输入所以forward里必须先transpose一下点云维度。这个细节虽然很小但不少新手都会在这里报维度不匹配的错误。3.3 训练策略与超参数PCRNet的训练进程相对友好但它对超参数还是有一些讲究。下面是我试下来比较稳定的一组配置。优化器Adam初始学习率1e-3每20个epoch乘以0.5损失函数旋转四元数L1损失 平移L1损失旋转部分加权系数λ1.0Batch Size32如果你的显存不够16也可以但收敛会慢一些训练轮数200个epoch通常在100个epoch左右就能看到不错的粗配准效果输入点数1024个点随机从原始点云中采样损失函数的实现是最容易出问题的地方。先看四元数的L1损失如果真值四元数是q_gt预测值是q_pred直接计算|q_pred - q_gt|是有风险的因为q和-q表示同一个旋转。举个例子真值旋转角度为180°对应的四元数可能是(1,0,0,0)但网络学到的可能是(-1,0,0,0)。两者的模长一致、表示的旋转一致但直接做减法会得到很大的loss导致网络被误导。解决方法是先把四元数归一化然后计算如下对齐后的距离def quat_distance(q_pred, q_gt): q_pred F.normalize(q_pred, dim-1) q_gt F.normalize(q_gt, dim-1) # 保证 q 和 -q 对齐 dot torch.sum(q_pred * q_gt, dim-1, keepdimTrue) sign torch.where(dot 0, torch.ones_like(dot), -torch.ones_like(dot)) q_pred q_pred * sign return torch.mean(torch.abs(q_pred - q_gt))平移损失的权重要不要比旋转大我的经验是前期旋转误差大的时候把旋转的权重提高一些可以帮助网络先学出“大致朝向”后期再逐渐增加平移惩罚。不过这个策略在工程上实现比较繁琐大多数情况下旋转和平移的权重都设为1也能跑通。评估指标方面最常用的是RRE相对旋转误差和RTE相对平移误差。RRE计算的是预测旋转与真值旋转之间的夹角公式是arccos((trace(R_pred^T R_gt) - 1) / 2)。RTE则是预测平移与真值平移之间的欧氏距离。这两个指标能直观反映配准质量。要想更严谨还可以统计“成功率”即RRE小于某个阈值比如5°且RTE小于某个阈值比如0.05m的样本占比。4. 训练与推理中的常见问题与排查技巧4.1 模型不收敛时先从数据看起我第一次训练PCRNet时踩过一个很典型的坑损失函数下降一阵子后停在了一个稍微高于零的平台旋转误差也始终降不下去。排查了半天最后发现是随机变换的生成逻辑有bug——旋转矩阵由欧拉角随机生成但真值四元数在计算时用了不同的旋转顺序比如训练时用RzRyRx转换四元数时却用了RxRyRz导致网络一直试图拟合自相矛盾的标签。所以如果你发现模型不收敛第一件事不是调网络结构而是做一次“数据一致性检查”。做法很简单取一批训练样本手动跑一遍数据增强代码打印出源点云、目标点云、真值变换然后自己算一遍变换后的点云是否合理。再拿一个已经训练好的模型做反向验证对同一对点云用网络预测与用真值变换分别作用在源点云上看看两者是否接近。这一步能筛掉绝大多数数据问题。另一个常见的收敛问题是输入点云没有去中心化。点云坐标范围特别大的时候比如一个物体模型分布在[-1, 1]范围内一个场景点云分布在[-100, 100]范围内回归头输出的平移值跨度会非常大导致loss震荡剧烈。解决办法很简单在送入网络前分别对源点云和目标点云减去各自的质心推理时再把质心偏移加回到最终的平移预测里。Batch Size的影响也不容忽视。PointNet里的BatchNorm层在Batch Size太小的时候效果很差。我测试过Batch Size为4的训练收敛速度明显比32要慢很多而且最终精度也更低。如果你的显存受限可以尝试固定输入点数为512而不是1024来降低显存开销而不是一味减小Batch Size。4.2 推理阶段如何落地到实际点云训练和测试用ModelNet40的干净点云是一回事实际应用中面对激光雷达扫描或者RGB-D相机重建出来的点云情况会复杂得多。实际点云的特点是数量不固定、密度不均匀、存在大量离群点还有遮挡造成的缺失区域。我建议推理流程做成这样先对原始点云做体素降采样控制点数在2048到4096之间再用统计滤波把离群点去掉最后随机采样到网络要求的固定点数比如1024。这些预处理步骤可以直接用Open3D实现import open3d as o3d def preprocess(pcd_path, num_points1024): pcd o3d.io.read_point_cloud(pcd_path) pcd pcd.voxel_down_sample(voxel_size0.02) pcd, _ pcd.remove_statistical_outlier(nb_neighbors20, std_ratio2.0) pts np.asarray(pcd.points) if len(pts) num_points: idx np.random.choice(len(pts), num_points, replaceFalse) else: idx np.random.choice(len(pts), num_points, replaceTrue) return pts[idx]推理时还要注意网络输出的是四元数如果你后续要接ICP精配准需要先把四元数转换成旋转矩阵再施加到源点云上然后用ICP做精配准。PyTorch里可以用scipy.spatial.transform.Rotation实现转换from scipy.spatial.transform import Rotation quat rotation_pred.detach().cpu().numpy() # [w, x, y, z] R Rotation.from_quat([quat[1], quat[2], quat[3], quat[0]]).as_matrix()顺序问题要格外小心。PyTorch或者模型输出的四元数常见顺序是[w, x, y, z]而scipy的from_quat要求[x, y, z, w]mw如果搞混旋转方向直接就反了。这种错误在推理阶段往往表现得非常隐蔽——loss训练时已经收敛了但可视化时发现旋转完全对不上。4.3 旋转表示相关的坑旋转表示是深度学习配准绕不开的难点。除了四元数PCRNet也有实现用“旋转向量”axis-angle作为输出。旋转向量的好处是自由度只有3维不需要额外的归一化约束但它在角度为0附近存在奇异性训练时容易产生数值不稳定的输出。四元数虽然在归一化上更干净但前面提到的q与-q的歧义问题也需要特别处理。我的建议是如果你要用四元数训练时一定加符号对齐如果你不想处理符号问题可以直接用旋转矩阵的9维表示然后在损失函数里加一个正交化正则项强制输出接近SO(3)流形。不过后者通常收敛得更慢训练时需要更多epoch。还有一点如果只是做粗配准最终的旋转角度误差在3°到5°以内其实就可以接受因为它后续会接ICP。如果拿PCRNet直接输出最终结果而不做任何精修精度通常会让你失望。这也是论文里强调PCRNet不是ICP替代品、而是ICP初始化工具的原因。5. 我的实操心得与后续扩展建议5.1 什么时候该用 PCRNet什么时候不该用跑了大量实验之后我的主观结论是PCRNet最适合的场景是物体级配准比如机械臂抓取时的物体位姿估计、工业零件的模型配准、AR场景下的物体跟踪。这些场景的共同点是目标物体形状清晰、几何结构有一定判别性而且初始位姿未知或偏差较大。如果你做的是大规模场景配准比如多栋建筑的LiDAR点云拼接PCRNet基本不适用。原因在于全局特征编码面对大规模场景时会失去判别性——几十万个点里两个来自不同位置的扫描片段可能看起来“差不多”网络无法从全局特征里区分出谁是谁。这种情况更适合用基于关键点匹配的配准方法比如FPFHRANSAC或者更先进的深度关键点网络。另外PCRNet对点云的覆盖范围很敏感。训练时如果只见过物体在完整状态下的点云推理时遇到严重遮挡的物体比如旁边有障碍物挡了一半网络性能会明显下降。这是PointNet类全局特征的通病处理方式是在训练时对点云做随机裁剪和随机球面遮挡来增强鲁棒性。5.2 我实验过的几个有效改进如果你打算自己改进PCRNet有几个方向值得尝试。第一个是把PointNet换成DGCNN或者PointTransformer。PointNet的问题在于只提取全局特征局部结构信息丢失严重DGCNN通过EdgeConv在特征空间里构造近邻图能保留更多局部几何细节配准精度通常会有可感知的提升。第二个改进是把回归头改成迭代结构。我参考Iterative PCRNet的思路在推理时重复执行“编码-预测残差变换-作用到源点云-再编码”这个循环3到5次每次预测的都是当前残差位姿。这样做的好处是网络每次只需预测较小范围的变换任务变简单精度提升很明显。代价是推理时间成倍增加大约是从单次前向传播的几毫秒变成几十毫秒。第三个方向是在损失函数上做文章。除了L1损失我试过在旋转预测上使用测地线损失也就是直接用旋转矩阵之间的夹角作为监督信号。这种方式在理论上更贴合几何含义但实现上需要对旋转矩阵做对数映射稍麻烦一些。实测显示与普通的L1四元数损失相比测地线损失在训练初期收敛得更快但最终精度差别不大。如果你只是复现而非进攻科研L1损失就足够了。5.3 一个小技巧用可视化验算而不是数字指标最后分享一个小技巧也算是我踩过多次坑之后养成的工作习惯评判一个配准模型好不好不要只看RRE和RTE的数字指标一定要实际可视化几次配准结果。数字指标很容易骗人。比如某一次实验里RTE是0.02米看起来很好但可视化之后发现点云在y轴方向上存在一个完全对称的翻转——因为物体的几何形状恰好关于y轴对称网络输出的是一个几何上合法的旋转但语义上完全是错的。这种情况在对称物体比如圆环、长方体、旋转体上经常出现RRE和RTE这些指标根本无法反映出来。所以我现在的做法是每个epoch结束后随机挑出3个验证样本把源点云用预测变换作用后和目标点云一并渲染出来肉眼确认是否真的对齐了。这一步看起来原始却能在训练过程中及时暴露问题远比等到训练结束再分析指标高效得多。PCRNet这个方向走到今天已经算不上最前沿的工作了但它的研究价值依然很高。它用最简洁的PointNet架构证明了“全局特征编码直接回归”这一思路在点云配准上的可行性也为后来一系列基于深度学习的配准方法打下了基础。如果你正在做三维视觉相关的工作无论是学习还是工程应用我建议都亲手复现一遍这个模型它一定能帮你建立对点云深度学习完整而扎实的理解。
返回列表