ARTICLE DETAIL

资讯详情

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

PointPainting复现全攻略:多模态融合给点云上色提升3D检测

PointPainting复现全攻略:多模态融合给点云上色提升3D检测 做三维目标检测的朋友一定绕不开多模态融合这个话题。PointPainting是2020年CVPR上来自Waymo的一篇工作核心思路非常简单先用2D语义分割把图像里的类别信息抠出来然后通过投影关系把这些类别信息“涂”到激光雷达点云上最后让任意一个3D检测器去消费这些经过语义增强的点云。我花了大约一周时间完整复现了这套流程过程中踩了不少坑这篇文章就把整个复现过程、关键细节和排查经验一次性写清楚。这篇复现笔记适合两类人。一类是刚接触3D检测、想通过复现经典论文理解多模态融合pipeline的学生另一类是已经在做点云感知、想给现有检测器提一点精度收益的工程师。PointPainting的好处在于它不依赖复杂的跨模态注意力机制也不要求检测模型必须改造结构你只要把点云特征扩展一下剩下的活全部交给现有3D检测框架是一种非常“朴实但有效”的融合方式。1. 复现目标与方法整体拆解1.1 一点核心给点云上色的三步流水线PointPainting整个方法可以用三步来概括。第一步把相机图像输入一个2D语义分割网络得到逐像素的类别得分。这个得分通常是一个C维向量C是语义类别数比如Cityscapes的19类或者KITTI语义分割数据集里自定义的类别集合。每个像素都会输出一个softmax后的概率分布表示该像素属于各个类别的置信度。第二步把激光雷达点云通过标定参数投影到图像平面找到每个三维点对应的图像像素然后把该像素的C维语义得分取出来拼接到这个点的原始特征后面。这一步是“上色”的核心点云的每个点从(x, y, z, r)变成了(x, y, z, r, s1, s2, ..., sC)其中s就是分割网络给那个像素打的类别分数。第三步把增强后的点云送进任意一个3D检测器比如PointPillars、VoxelNet或者SECOND训练方式和之前完全一样。检测器会从增强特征里自己学习哪些语义类别对定位目标有帮助。我复现时最直观的感受是这个pipeline真的太“解耦”了。图像分割和3D检测是两个独立模块互相之间没有梯度耦合融合只发生在数据准备阶段也就是投影生成语义点云这一步。这也意味着你可以随时替换更好的分割模型或者替换更好的3D检测器而不需要动整个结构。1.2 为什么这套设计在当时是聪明的选择很多人会问为什么不直接用3D语义分割或者直接做BEV特征融合先从数据形态看。激光雷达点云本质上是很稀疏的尤其对于远处的行人和骑车人一帧可能只有几个点这时候点云的几何信息根本不足以让检测器做出正确判断。但图像是稠密的即使目标只有几个像素分割模型也能输出一个类别概率。PointPainting抓住的正是这个互补性几何信息从3D来语义信息从2D来两者互不抢戏合成之后各取所长。另一个值得体会的设计动机是工程收益。当时很多多模态融合方法都需要重新设计检测网络的backbone或者引入跨模态注意力模块训练和部署成本都不低。PointPainting直接把语义信息编码到点云的point-wise特征里所有改动都集中在数据预处理和输入维度上网络结构几乎不需要变化。这对工业界非常友好因为现有推理链路几乎不用重构只需要在点云进入模型之前增加一个“批量打标签”的环节就行。我当时复现时也想过一个问题分割网络输出的类别概率算不算“特征”训练过程中会不会干扰几何特征实际跑下来发现只要把语义向量拼在几何特征后面并且让检测网络的第一个卷积层在维度上自适应调整网络很轻松就能学会使用这些语义通道。毕竟检测器本来就要学会处理不同强度的特征输入类别概率也是一种连续值比学习离散的one-hot编码更容易收敛。2. 环境准备与数据集处理2.1 复现环境选型稳定比新版本更重要复现论文这件事环境版本选择是第一个隐形坑。PointPainting本身没有什么特殊算子不需要编译什么魔改CUDA所以环境要求并不苛刻但建议尽量选一套经过社区验证的组合。我这次用的是Python 3.8、PyTorch 1.9、CUDA 11.1搭配OpenPCDet或者MMDetection3D这类已经实现好PointPillars的框架。这两个框架都有比较成熟的KITTI数据加载器会比从零手写节省很多时间。如果要自己手写PointPillars注意几个依赖项OpenCV用于图像读取和可视化验证Numba或Cython用于加速pillar的voxelization操作numpy肯定跑不了。PyTorch版本其实1.8到2.x都可以但如果你用的是老版本mmdet3d或OpenPCDet要留意它们的依赖和PyTorch版本是否兼容别在环境上浪费一天。一个建议是先验证基础环境能跑通一个已有的3D检测模型再做PointPainting的改造。我见过太多人一上来就改代码结果连KITTI数据都没加载出来后面每跑一个实验都在怀疑是环境问题还是自己代码问题心态直接崩掉。2.2 KITTI数据的目录结构与准备细节PointPainting复现最常用的数据集是KITTI毕竟论文里做了KITTI和nuScenes两套实验KITTI公开资料多、数据规模小、迭代快适合做方法验证。KITTI目标检测数据集需要下载图像、点云和标定文件三部分。目录我建议按照OpenPCDet或MMDetection3D的标准结构来组织kitti/ ├── training/ │ ├── calib/ │ ├── image_2/ │ ├── label_2/ │ └── velodyne/ ├── testing/ │ ├── calib/ │ ├── image_2/ │ └── velodyne/ └── ImageSets/ ├── train.txt ├── val.txt └── trainval.txt训练集和验证集的划分直接用官方给出的txt文件即可不要自己随机切否则和社区里的结果没法对比。点云数据是bin文件每个点4个float32分别是x、y、z、intensity。图像是左侧彩色相机cam2拍摄的一般选左图做分割输入就够了论文里融合时也是用单个相机视角并没有做多相机拼接。标定文件是复现成败的重中之重后面投影模块会详细说。KITTI每帧的calib文件里有P0到P3、R0_rect和Tr_velo_to_cam这几个矩阵它们分别对应不同相机的外参内参和激光雷达到相机的变换。最需要注意的是投影到cam2图像时内参用的是P2矫正旋转用的是R0_rect外参用的是Tr_velo_to_cam三者顺序一定不能搞反。2.3 分割模型选择与类别映射PointPainting对分割模型没有强依赖你用DeepLabv3、PSPNet还是最近两年的SegFormer都可以。关键点是分割输出类别要和你后面要检测的目标类别形成合理映射。KITTI 3D检测的标签主要关注Car、Pedestrian、Cyclist三类但PointPainting并不建议只保留这三个类别的语义得分更好的做法是保留整个分割模型的全部输出通道比如Cityscapes的19类让检测网络自己决定哪些语义有用。这里有个很容易踩的坑类别ID的对应关系。Cityscapes的标签顺序和KITTI原始分割标签并不一致有的类别名称相同但ID号不同如果你直接用别人训练好的分割模型输出一个整数掩码图然后用这个整数掩码喂给点云就可能导致“人”的语义被涂到“车”的点上。我在复现时干脆把分割模型输出做成了one-hot或者softmax的C维向量而不是整数索引这样逐通道位置就固定下来方便检查。如果你有精力在KITTI语义分割数据集上对分割模型做一点微调会更好。论文实验里其实也是用分割模型在相关城市道路场景上处理过的。不过为了快速复现直接用Cityscapes预训练权重也能跑出一个像样的结果毕竟城市道路的语义类别相对固定跨数据集泛化性没有你想象中那么差。3. 核心环节投影模块是复现成功的关键3.1 投影公式与坐标系变换投影模块是整个PointPainting里最需要仔细对待的地方。激光雷达点云坐标系和图像像素坐标系之间不是直接对应关系中间要经过激光雷达到相机的刚体变换、相机坐标系下的畸变矫正、以及相机内参投影三步。KITTI给定的一帧数据里点云是Velodyne坐标系下的三维点图像是cam2相机拍摄的画面。要把点云投到图像上公式是先在Velodyne点云后面补一个齐次维度1左乘Tr_velo_to_cam得到cam0相机坐标系的点然后再左乘R0_rect得到矫正后的相机坐标最后在齐次形式下左乘P2得到图像像素坐标。注意P2是3x4矩阵矫正后的相机坐标需要补成4维齐次坐标。整个变换写出来就是points_cam R0_rect Tr_velo_to_cam points_velo_homo points_img_homo P2 points_cam_homo得到的结果是一个3维向量前两维除以第三维就得到图像的u、v坐标第三维表示深度。深度小于等于0的点要当作无效点处理因为它们要么在相机背后要么正好在成像平面上。我当时复现时最常犯的错误是把R0_rect和Tr_velo_to_cam的乘法顺序写反了或者把P2误写成P0。只要这个顺序错了一个投影出来的点云语义就会乱到让你怀疑人生。3.2 代码实现从激光雷达到像素的完整函数给出一个可以直接用的投影函数代码基于numpy实现方便你把它接到任何预处理脚本里。import numpy as np def project_velo_to_image(points, P2, R0_rect, Tr_velo_to_cam): points: (N, 3) 激光雷达坐标系下的点云 P2: (3, 4) 相机投影矩阵 R0_rect: (3, 3) 矫正旋转矩阵 Tr_velo_to_cam: (3, 4) 激光雷达到相机的外参 返回: points_cam: (N, 3) 相机坐标系下的点 points_img: (N, 2) 图像像素坐标 u, v valid_depth: (N,) 深度是否有效 n points.shape[0] # 点云补齐次坐标 points_velo_homo np.hstack((points, np.ones((n, 1)))) # (N, 4) # 先外参变换到cam0再矫正 points_cam (R0_rect Tr_velo_to_cam points_velo_homo.T).T # (N, 3) valid_depth points_cam[:, 2] 0 # 相机坐标补齐次投影到像素 points_cam_homo np.hstack((points_cam, np.ones((n, 1)))) # (N, 4) points_img (P2 points_cam_homo.T).T # (N, 3) points_img[:, 0] / points_img[:, 2] # u points_img[:, 1] / points_img[:, 2] # v return points_cam, points_img, valid_depth拿到u、v之后还要判断像素坐标是否落在图像范围内比如宽1242、高375。只有满足0 u 1242、0 v 375并且对应像素在分割结果中有有效类别得分才能继续拼接语义向量。其他无效点就直接在语义通道上填0。3.3 遮挡处理与可视化验证投影过程中有个容易被忽略的问题遮挡。激光雷达扫描的是场景的表面如果一个三维点本来属于远处的背景物体但它的投影像素落在了近处物体的轮廓内部就会出现“远处点拿到近处物体语义”的错配。这种情况在物体边缘尤其常见。稳妥的做法是做深度缓冲z-buffer如果多个三维点投影到同一个像素位置只保留深度值最小的那个点去获取语义。其实KITTI一帧点云里被近处物体完全挡住的点是根本扫不到的所以这个问题没有想象中严重但边缘处还是会有些瑕疵。我复现时加了一个轻量处理先按深度排序再按像素坐标去重保证一个像素尽量只被最近点占用。不过我还是要强调调试投影模块时不要直接去训练检测器那样出了问题根本没法定位。我的习惯是先把投影结果可视化出来把点云的语义类别映射成颜色然后把点云投影到图像上叠加显示跟原图做对比。# 伪代码示意可视化流程 for each point: color palette[semantic_label[point]] draw point at (u, v) with color如果你看到一辆车的点云轮廓和图像里的车重叠得严丝合缝行人的点云刚好落在行人身体区域那说明投影模块基本没问题了。很多人跳过这步直接训练后面就算检测效果不好都不知道是投影错了还是检测网络没收敛排查起来极其痛苦。4. 完整复现流程从语义点云到3D检测器4.1 点云特征的改造从(x,y,z,r)到(x,y,z,r,color)在跑PointPainting之前先明确一下你用的点云检测器读取点云的位置在哪。以OpenPCDet为例数据预处理部分会把每帧点云读成(num_points, 4)的数组之后会经过voxelization或者pillar化操作。你要做的就是在点云进入检测网络之前把每帧点云扩展成(num_points, 4 num_classes)的形状。拼接语义特征时我强烈建议使用分割网络输出经过softmax的概率值而不是logits也不是one-hot。原因是概率值范围在0到1之间和点云原始特征(x, y, z, r)的数值量级比较接近网络更容易学到稳定特征。如果直接用logits可能出现绝对值很大的负数或正数导致第一个卷积层输入波动过大。原始论文里用的也是softmax后的得分这个细节可以帮你省掉很多调loss的麻烦。代码层面的操作非常朴素def append_semantics_to_points(points, seg_scores, valid_idx): # points: (N, 4) # seg_scores: (N, num_classes) 只有有效点才有非零值 semantic_features np.zeros((points.shape[0], num_classes), dtypenp.float32) semantic_features[valid_idx] seg_scores[valid_idx] augmented_points np.hstack((points, semantic_features)) return augmented_points这里注意无效点的语义通道全部填0相当于告诉网络“这个区域我拿不到图像语义信息”。这比随便填一个负数要安全得多。4.2 检测网络适配与损失调整PointPillars这类检测器的网络输入通道需要用增强后的特征维度重新配置。如果你用OpenPCDet或者MMDetection3D一般会有一个配置项定义每个点输入的特征维度。pointpillars默认的point_features通常是4到9维因为除了原始点特征外还要计算pillar内部的相对坐标等统计量。你把原始点云输入从4维改成4num_classes后后面那些统计量的计算维度也要跟着变否则代码会直接报shape不匹配。损失函数方面3D检测器内部常用的分类损失是focal loss或者sigmoid交叉熵回归损失是smooth L1PointPainting不需要对这两部分做特殊改动。但我自己复现时发现语义特征加入后网络前几个epoch的收敛速度会比baseline慢一点点因为输入维度变大了特征分布更复杂。这不是坏事只要训练轮数给够一般能超过baseline。有一个容易被忽视的点如果分割模型的类别数特别多比如30类甚至更多那么点云特征维度会被撑得很大不仅显存占用增加3D检测器前几层的参数量也会跟着涨。这时候可以考虑先做一次PCA降维把语义向量压到8到16维再拼接到点云特征上。不过这是工程优化手段复现论文时建议先保持原始类别数后续再自己尝试压缩。4.3 训练超参数设定与评估口径为了公平对比PointPainting带来的增益训练设置要尽量和baseline保持一致。我这次用的是单卡V100batch size设为4初始学习率3e-3Adam优化器CosineAnnealing学习率调度总共训练80个epoch。PointPillars本身训练速度不慢KITTI训练集大概3700帧80个epoch在单卡V100上跑大约10到15小时。评估指标方面KITTI官方关注的是3D AP和BEV AP按Easy、Moderate、Hard三个难度分别统计。我建议直接用官方评估协议或者用和训练框架配套的评估脚本。在对比baseline和PointPainting结果时要确保两者的GT采样策略、类别定义、评估范围完全一致否则AP差异可能来自评估脚本而不是方法本身。从实际效果看PointPainting在Pedestrian和Cyclist上的提升往往比Car更明显。原因是这类目标在点云中的点数本来就少几何信息不足图像语义带来的补充信息价值更大。如果你发现Car的AP提升不大这不是bug是这类目标本身点云足够稠密语义增强的边际收益有限。5. 常见问题与排查技巧实录5.1 投影错乱、语义乱飞这是复现PointPainting时最常遇到的问题。表现是点云语义反投到图像上时点的颜色和图像内容明显对不上比如车的点云上刷的是天空的语义或者行人的点云刷成了路面。排查顺序应该从内向外先检查P2矩阵再检查R0_rect和Tr_velo_to_cam的顺序最后检查像素坐标是否除的是正确的深度分量。我踩过最大的一次坑是把P2和P0搞混了P0是cam0的投影矩阵P2是cam2左彩色相机的投影矩阵两者差了一个平移偏移导致所有点投影到图像上整体向右偏移了一段距离。另外记得检查标定文件里的时间戳和点云、图像是否对应。KITTI本身数据同步做得好但如果你自己采集数据或者从其他数据集复现时间戳错位会导致动态物体上的投影偏移很顽固地出现。5.2 语义标签与点云稀疏冲突有人复现后会发现增强点云里真正拿到非零语义的点的比例不到一半然后开始担心语义信息不够用。这其实要分两种情况。一种是投影范围本身有限激光雷达点云一部分落在图像视野外这部分点本来就该是零语义没有影响。另一种是图像视野内但因为遮挡或者深度无效导致很多点没能取到对应像素这才是需要优化的。解决视野内无效点比例过高的办法一是提高分割模型输入分辨率让远距离小目标的像素更清晰二是检查相机和激光雷达的外参标定是否符合当前数据如果外参偏了投影自然对不准。我在调试时会把每帧有效语义点比例打印出来如果低于60%就说明投影或者数据加载环节可能有问题。5.3 显存不足与训练效率点云特征从4维扩展到419维之后输入数据量会在二进制级别明显变大。PointPillars实际输入是经过pillar化后的稀疏张量通道变多会增加第一个卷积层的计算量但通常不会造成灾难性显存爆炸。如果训练时显存报错优先降低batch size或者开启梯度累积。我单卡跑80个epoch就是靠batch size 4加梯度累积2来模拟batch size 8的效果。图像分割模型推理时也要注意显存占用。分割模型和数据预处理如果放在同一个进程里一遍遍forward会有额外显存开销。我的做法是先离线把所有训练图像的分割结果预处理成numpy数组存盘训练时直接读文件不再加载分割模型。这样训练显存占用和baseline几乎一样。5.4 复现指标与论文不一致的常见原因很多人在复现完后发现AP值和论文对不上首先不要慌。论文里的模型通常十亿级别的训练资源和超参搜索复现时硬件、batch size、epoch、数据增强策略都可能不一样指标不可能完全一样。但有几个容易造成大误差的细节要注意。一是类别映射是否正确比如Cityscapes的person是否包含了rider如果没有行人的语义覆盖率就会低。二是分割模型的质量如果你随手用一个低精度分割权重PointPainting不仅可能没增益还可能因为错误语义干扰检测器。三是训练随机性KITTI数据量小不同随机种子跑出来的AP波动可能有1到2个点对比实验时要固定同一个种子。5.5 排查速查表现象可能原因排查方法点云语义反投到图像上错位P2矩阵用错、R0顺序反了、外参错误用单个点手动算投影坐标和图像叠加可视化图像视野内有效语义点比例低外参不准、分割输入分辨率低、遮挡处理不当打印有效比例提高分割推理分辨率Car的AP没有提升Car点云稠密PointPainting收益有限重点看Pedestrian/Cyclist和Hard难度指标训练Loss下降但AP不涨类别映射错、GT和语义标签语义不一致反投可视化语义标签检查类别ID对应关系显存OOM输入特征维度变高计算量增大降低batch size、梯度累积、分割结果离线预处理6. 复盘与经验总结6.1 我复现过程中最值得分享的三个经验第一投影可视化这一步绝对不能省。我第一版代码里投影公式写错了如果没有可视化训练完整个流程才发现AP比baseline还低那排查周期至少浪费两天。后来我强制自己写完投影函数必须先把可视化截图拿出来对比再继续下一步这个习惯让后续所有实验变得非常顺畅。第二用独立的预处理脚本把语义分割结果和投影结果都落盘训练时只读文件。这样不仅能省显存而且你能随时检查数据质量。比如某个batch里行人的语义涂得很差你直接把对应的npy和点云文件调出来看不用复现训练环境就能定位问题。多模态融合的bug经常藏在数据准备阶段让数据准备阶段变成可离线复盘的独立模块会帮你节省大量时间。第三对比实验的公平性特别重要。PointPainting的增益本质是一个相对提升你只有把baseline训练得很充分才能看到融合带来的真实收益。如果baseline只跑了一半epoch或者数据增强策略不匹配PointPainting反而可能显得比baseline还差。同一套训练配置、同一个随机种子、同样的epoch数量这是复现实验的基本底线。6.2 如果从零开始复现我会简化什么如果让我重新来一遍我不会一开始就追着论文复现完整pipeline而是先找一个已经跑通的PointPillars实现把KITTI数据加载、训练、评估整个链路跑通确认自己的baseline是可靠的。然后才在数据加载那一层插入PointPainting的语义拼接逻辑。语义分割模型直接用Cityscapes预训练权重先不微调快速看整体流程有没有问题。等pipeline彻底通了再回头优化分割模型或者调整类别映射这样每一步的变量都很小出了问题好定位。从代码工程量角度看PointPainting的核心代码其实只有投影函数和点特征拼接那几十行。工程上大量的时间反而花在调试数据目录、环境依赖和评估口径上。所以不要被“多模态融合”这个听起来很高级的词唬住把一个环节拆开它其实就是一次数据增强。6.3 后续可以这样扩展PointPainting能扩展的方向其实不少。比如把2D分割模型换成更强的实时分割模型观察3D检测精度的变化或者把单帧投影改成多帧时序融合让语义信息在多帧之间传播再或者把点云投影生成的语义特征同样叠加到BEV表达里看能不能进一步提升检测器的语义感知能力。这些都是论文之外可以自己玩的方向。对我个人来说复现PointPainting最大的收获不是那辆车的AP又涨了几个点而是学会了怎么把一个看似简单的融合思想真正落到代码里并且理解每一步为什么这样做。如果你也在复现多模态检测的路上希望这篇笔记能帮你少走点弯路。
返回列表