很多人拿到CenterPoint源码第一反应都是直奔两阶段优化模块,觉得这才是CVPR2021那篇论文最值钱的东西。我当时也是这么干的,结果翻了两天代码就卡住了——不把VoxelNet那条三维稀疏卷积的链路摸清楚,后面所有精修逻辑都像浮在云里。后来我老老实实从数据入口开始,把体素化、稀疏卷积、BEV特征压缩、CenterHead热力图解码、再到二阶段refine网络重新过了一遍,才算是把整个仓库串起来了。这篇笔记就按这个顺序写,重点落在源码实现细节和设计动机上,顺便把复现过程中遇到过的问题一并交代,给准备啃这套代码的同学一条能直接走通的路。
1. CenterPoint整体设计:从Anchor到Center的关键切换
1.1 传统Anchor方案在点云检测里的痛点
CenterPoint之前,主流3D检测器比如SECOND、PointPillars,基本都走Anchor路线。Anchor在3D空间里是一个个带尺寸、带旋转角度的三维框,检测头在这个框的基础上回归偏移量。这套思路在2D检测上很成熟,但搬到点云里有一个问题非常棘手:类别越多,Anchor组合越爆炸。一个类别可能就要几十个预设框,姿势、长度、宽度、高度、角度全都得手动设计。
我对这个痛感的体会很深。之前在某个数据集上调参,同一个类别在不同距离、不同激光雷达线束下,尺寸和长宽比差异很大。Anchor尺寸设计得不好,检测头再强也救不回来。而CenterPoint的想法很直接:既然2D检测有CenterNet这种基于中心点的范式,3D点云为什么不试试?
1.2 Center-Based思路与CenterPoint源码主干
CenterPoint的核心是把“目标在哪”这个问题简化成“目标的中心点在哪个BEV像素上”。它不再依赖Anchor的先验尺寸和角度,而是在鸟瞰图(BEV)上预测类别热力图,热力图的峰值位置就是物体中心。围绕中心点再回归高度、尺寸、朝向、速度这些属性。
源码结构其实非常清晰,主要分四块:
- 数据预处理模块:负责点云体素化、数据增强、GT热力图生成。
- Backbone模块:基于VoxelNet结构,用三维稀疏卷积把体素特征逐步编码,最终压缩成BEV特征图。
- 一阶段检测头CenterHead:在BEV特征图上生成类别热力图并回归3D框参数。
- 二阶段优化模块RoI Head:利用原始点云和Backbone特征,对第一阶段输出的候选框做精修。
这个结构把“粗检测”和“精修”拆得非常干净。一阶段负责高召回,二阶段负责高精度,两阶段之间通过候选框和点云采样连接起来。读源码的时候先建立这个全局图景,后面不容易迷路。
1.3 源码目录与一次完整推理流程
以早期官方仓库的det3d结构为参考,核心代码集中在det3d目录下。建议按下面顺序读:
- det3d/datasets/processor:点云体素化、数据增强实现。
- det3d/models/backbones/voxelnet.py:稀疏卷积骨干网络。
- det3d/models/heads/center_head.py:一阶段检测头。
- det3d/models/roi_heads:二阶段精修网络。
一次完整推理流程可以概括成下面这条链路:
原始点云 -> 体素化 -> 稀疏卷积编码 -> Z轴压缩得到BEV特征 -> CenterHead预测热力图和回归量 -> 热力图峰值解码出候选3D框 -> NMS去重 -> 候选框内点云采样 -> PointNet/MLP精修 -> 输出最终3D框
注意,CenterPoint和CenterNet有一个关键区别:CenterNet是在图像像素上直接预测中心点,而CenterPoint是先通过三维稀疏卷积生成BEV特征图,再在BEV特征图上预测。这意味着坐标系的转换必须非常小心,后面我会专门讲这个坑。
2. VoxelNet骨架源码全流程:从原始点云到BEV特征图
2.1 点云体素化的实现与参数选择
体素化是CenterPoint整个数据处理链路的入口,也是很容易被忽视的一个环节。源码里的体素化模块通常叫VoxelGenerator或者Voxelization,核心逻辑是一致的:把三维空间划分成网格,每个体素内的点聚合到一起。
体素化有几个关键参数需要认真理解:
| 参数 | 含义 | 影响 |
|---|---|---|
| voxel_size | 体素尺寸 | 越小特征越精细,计算量越大 |
| point_cloud_range | 点云有效范围 | 决定体素网格数量 |
| max_num_points | 单个体素最大点数 | 限制显存占用 |
| max_voxels | 每帧最大体素数 | 限制显存占用和数据增强空间 |
以nuScenes这类数据集的常见配置为例,voxel_size通常是[0.1, 0.1, 0.2]米,point_cloud_range大致是[-50, -50, -5, 50, 50, 3]。意思是X和Y方向各100米范围,Z方向从-5米到3米。
体素化的核心流程并不神秘:
- 根据point_cloud_range和voxel_size,把每个点映射到它所属的体素网格坐标。
- 用哈希表或者排序方式,把属于同一个体素的点聚合到一起。
- 对每个体素内的点做特征聚合,通常是取点的坐标、反射强度特征。
这里有一个很实用的经验:训练时max_voxels不要设置得太大,否则每个batch的体素数量波动剧烈,导致GPU显存利用率不稳定。但测试推理时可以把max_voxels适当放开,避免因为截断丢失远处目标。
我在实际调试中遇到过一个诡异现象:某个类别的目标在远距离的时候老是检测不到,排查到最后发现是point_cloud_range设置太小,把远处有价值的点直接截断了。这类参数不会报错,但会影响上限,值得反复检查。
2.2 稀疏卷积骨干:spconv与submanifold卷积
点云体素化之后,如果直接使用普通三维卷积,会立刻遇到显存爆炸问题。原因很简单:100米x100米x8米的空间,用0.1m体素划分,会有上千万个网格,但其中99%以上的体素都是空的。普通三维卷积要在这个完整网格上做计算,浪费太大。
稀疏卷积就是专门解决这个问题的。它只对非空体素有计算,空体素直接跳过。CenterPoint源码的Backbone使用了spconv库。核心用法大概长这样:
import spconv import torch sp_tensor = spconv.SparseConvTensor( features=voxel_features, # [num_voxels, C] indices=coords, # [num_voxels, 4] spatial_shape=spatial_shape, batch_size=batch_size ) x = spconv.SparseSequential( spconv.SubMConv3d(in_channels, out_channels, 3, padding=1, indice_key="subm1"), spconv.SparseSequential(...) )这里有两个非常容易踩坑的地方。
第一个是坐标轴顺序。大多数稀疏卷积实现里,coords的排序是[batch, z, y, x],不是我们熟悉的[x, y, z]。如果你在数据预处理阶段把坐标顺序搞反了,网络也能训练,但效果会非常差,因为三维空间的结构被打乱了。
第二个是indice_key的作用。稀疏卷积在执行前需要根据输入坐标和卷积核大小,建立一张“哪些体素参与哪些输出位置计算”的索引表,这个建表开销不小。使用indice_key可以让相同输入输出形状的卷积层复用这张索引表,典型的子流形稀疏卷积(SubMConv3d)在多个层之间共享同一个indice_key,能明显降低计算开销。
VoxelNet骨干通常是多层稀疏卷积叠加,通道数逐渐增大,空间分辨率逐渐降低。每一层都在扩大感受野,让网络能够看到更大的上下文信息。
2.3 Z轴压缩成BEV特征图
稀疏卷积处理完三维体素特征之后,下一步是把特征沿Z轴方向压缩成二维BEV特征图,因为后续CenterHead的检测是在BEV平面上做的。
压缩的方式有很多种,源码实现里通常是把同一个(x, y)坐标下所有Z层的特征做池化,常见的是求和或取最大值。实际使用中,求和池化比最大池化更稳定,不容易丢失弱响应特征。
这里有一个关于感受野和分辨率的权衡。输入点云范围100米,voxel_size 0.1米,那么原始BEV网格是1000x1000,这个分辨率如果直接进CenterHead,计算量太大。所以Backbone在压缩Z轴之前,已经通过稀疏卷积把空间分辨率逐步降低,比如从1000x1000降到250x250左右。
BEV特征图的分辨率直接影响检测精度。分辨率太低,小目标的中心点很难被准确预测;分辨率太高,后续的卷积计算和Heatmap解码开销都变大。源码里的设计基本上是在两者之间取平衡。
3. 一阶段CenterHead:heatmap、回归分支与训练损失
3.1 类别热力图的生成与高斯半径
CenterHead最核心的输出是类别热力图(heatmap),形状是[B, num_classes, H, W]。每个通道对应一个类别,热力图的峰值位置代表该类目标中心的投影位置。
训练时,GT热力图不是简单地在真值中心点位置置1,而是在中心点附近生成一个高斯核。高斯半径的大小通常和目标尺寸、体素分辨率有关。目标越大,允许的峰值扩散范围越宽。
为什么要用高斯核而不是单点标签?因为点云投影到BEV时存在量化误差,而且网络在预测中心点时本来就有偏移。高斯核给中心点周围的像素一个过渡的软标签,让网络能够学会“接近中心”的预测,而不是只能识别精确中心。
Heatmap损失使用Focal Loss的变体。核心思想是:正样本像素(靠近中心的像素)贡献主要梯度,负样本像素中难分类的区域给予更多关注,简单负样本梯度被抑制。这样可以缓解类别不平衡问题,因为自动驾驶场景里,空背景像素数量远多于目标像素。
3.2 回归分支的通道设计与方向处理
CenterHead在热力图之外,还会输出一组回归分支。每个BEV像素位置,网络预测以下信息:
- 中心点偏移量:热力图峰值对应的浮点坐标与真实目标中心之间的偏移。
- 高度信息:目标中心在Z轴上的位置。
- 三维尺寸:长、宽、高。
- 朝向角:目标绕Z轴的旋转角。
- 速度:目标在X、Y方向上的速度分量。
这里要重点说一下朝向角的处理。直接回归角度看起来简单,但角度是周期性的,0度和360度在数值上差别巨大,直接做L1损失会在角度边界处产生很大的梯度误差。
CenterPoint的设计思路是“方向分类 + 残差回归”。把角度空间划分为若干个bin,网络先分类目标朝向落在哪个bin里,然后再回归该bin内的残差。这样角度预测变成两个更稳定的小任务,极大缓解了周期性带来的问题。如果后面你自己改网络输出头,强烈建议保留这个设计。
速度分支是CenterPoint比较有特色的地方。很多3D检测网络不预测速度,下游跟踪需要自己关联和估计。CenterPoint直接在检测阶段把速度预测出来,对多目标跟踪非常友好。
3.3 损失函数与权重调参
一阶段的总损失由四部分组成:
- Heatmap分类损失:Focal Loss变体。
- Box回归损失:中心点偏移、高度、尺寸的L1损失。
- 方向损失:分类损失加残差L1损失。
- 速度损失:L1损失。
训练时这些损失的权重需要仔细调。我一开始直接把所有权重设成1,结果Heatmap损失被Box回归损失完全压过,网络学出来的中心点很不准。后来把Heatmap损失权重提上去,把Box回归权重适当降低,收敛速度和最终效果都有明显改善。
权重配置没有银弹,但可以参考这个经验:先让Heatmap损失主导训练,让网络学会找目标,再逐步提高回归分支的权重,让框的质量跟上。
4. 两阶段优化的完整实现:从粗预测到精修的链路
4.1 一阶段预测不是终点
一阶段检测头输出候选框之后,论文已经可以完成检测任务了。但实测中你会发现,这些框的质量还有提升空间,尤其是在目标被遮挡、点云稀疏、或者目标尺寸较小的情况下。原因在于BEV特征图已经丢失了一部分三维空间细节,仅靠BEV特征很难把3D框的高度、朝向估计得非常精细。
两阶段优化要解决的问题就是:利用原始点云的精细信息,对一阶段的粗预测做修正。这个过程从优化角度理解,很像一个两阶段鲁棒优化策略:第一阶段在全局范围内快速圈定候选区域,第二阶段在候选区域内集中资源做精细建模,点云的噪声和稀疏性在第二阶段对整体结果的影响被显著降低。这也是为什么理论上二阶段能带来稳定精度增益的原因。
4.2 候选框内点云采样与特征归一化
二阶段的第一步,是把一阶段输出的每个候选3D框当成一个采样区域,从原始点云中把落在该区域内的点采出来。
源码里负责这块的函数一般叫sample_pts_by_boxes或者类似的名字。具体流程是:
- 获取一阶段输出的候选框,每个框用7个参数表示:中心坐标(x, y, z)加尺寸(w, l, h)加朝向角theta。
- 对每个候选框,遍历原始点云,把位于框内的点筛选出来。
- 对采样点做数据增强,比如随机丢弃一部分点,模拟点云稀疏情况。
采样完成后,最关键的一步是坐标归一化。每个点不仅要保留原始坐标和反射强度,还要加上它相对于所在候选框中心点的归一化坐标。归一化之后,网络可以很自然地理解每个点在框内的相对位置,是靠近顶部、底部,还是靠近边缘。
这个细节太重要了。如果直接用全局坐标输入refine网络,同样的一个点在空间不同位置,输入特征完全不同层,网络学不到“这个点在框内相对位置”的不变性。归一化相当于把问题从“全局坐标系下的点云分类”变成了“局部坐标系下的结构理解”,难度大幅降低。
4.3 点云特征与BEV特征融合后如何refine
二阶段的精修网络通常是一个轻量级PointNet或者类PointNet结构。它接收候选框内的采样点特征,输出一个全局特征向量,代表这个候选框的内部结构信息。
下面是二阶段refine部分非常简化的伪代码流程:
def refine_proposal(proposals, points, bev_features): sampled_points = sample_pts_by_boxes(proposals, points) normalized_points = normalize_by_box(sampled_points, proposals) point_feat = pointnet_encode(normalized_points) # [B, K, C] bev_roi_feat = bev_roi_pooling(bev_features, proposals) # 可选 fused_feat = cat([point_feat, bev_roi_feat], dim=-1) refine_cls, refine_reg = refine_mlp(fused_feat) return refine_cls, refine_reg精修网络输出两个东西:
- 新的置信度分数,用来重新评估这个候选框是不是真的包含目标。
- 相对于一阶段预测框的修正量。
修正量的目标通常是一阶段预测框与GT框之间的差异。训练时根据候选框和GT框的IoU来分配正负样本标签。比如IoU大于0.55的正样本参与回归,IoU小于某个阈值则作为负样本。
我在源码里看到二阶段并没有做复杂的旋转RoI Align,而是主要依赖点云归一化之后过PointNet。这个设计的好处是结构简单、对旋转不敏感,而且点云特征已经足够表达框内的几何结构。
4.4 2阶段损失函数与推理代价
二阶段的损失由分类损失和回归损失两部分组成。分类部分用来区分候选框是正样本还是负样本,常用交叉熵损失;回归部分对正样本的修正量做L1或者Smooth L1损失。
推理阶段,二阶段会对每个候选框重新产生一个分数。最终输出时要注意,这个新分数不能直接和原一阶段Heatmap分数融合,一般直接使用二阶段分数作为最终置信度。如果你的任务对速度和算力极其敏感,可以考虑在推理时只保留一阶段的输出,二阶段作为训练时的辅助约束来用。
二阶段会引入额外的前向计算开销,主要是点云采样和PointNet推理。实际测试下来,这部分延迟在GPU上还好,但部署到车端边缘设备时需要仔细优化。最简单的办法是限制输入到二阶段的候选框数量,比如只保留前100个或者前200个候选框,而不是全部候选框都做精修。
5. 训练、debug与效果优化里的实际坑位
5.1 显存占用与体素分辨率
CenterPoint的显存占用大头不在稀疏卷积本身,而在于把稀疏特征dense化以及BEV特征图上的计算。体素设置越小,BEV空间分辨率越高,显存和计算量上升非常快。
建议按这个顺序排查显存问题:
- 先检查max_voxels和max_num_points是否合理。
- 再确认BEV特征图的分辨率是不是过高。
- 最后看batch size是不是太大,必要时配合混合精度训练降低显存。
我复现时曾经把point_cloud_range设得过大,BEV特征图中间层分辨率达到500x500,结果一个batch都没跑起来,显存直接溢出。把范围恢复到常规值之后问题立刻消失。
5.2 后处理参数(thresh、NMS)对结果的影响
Heatmap解码时,分类阈值head_thresh对最终结果影响非常明显。阈值太高漏检严重,尤其是小目标和远处目标;阈值太低则产生大量低质量候选框,给二阶段带来额外负担。
NMS方面,3D检测里常使用类别无关的NMS,即所有类别放在一起做NMS。这样能减少计算量,但是不同类别如果互相遮挡严重,个别情况下会把正确结果挤掉。如果你追求极致精度,可以尝试按类别分开做NMS。
5.3 源码调试技巧
调试这套代码,最重要的一点是把坐标系统彻底理清楚。整个流程中涉及到至少三次坐标变换:
- 点云全局坐标到体素网格坐标。
- 体素网格坐标到稀疏张量的indices。
- BEV特征图坐标到真实世界坐标。
建议准备一个小脚本,随机取几个点,手动计算它经过这些变换后的坐标,然后和源码输出对照。很多复现效果差的case,最后都发现是坐标轴顺序或者坐标系原点不一致导致的。
另外,调试时先用单帧数据、num_workers=0跑通整个链路,确认前向和反向都正常了,再上完整训练。这一点真的太重要了,一上来就多卡并行训练,出问题连定位都困难。
5.4 复现收益和个人体会
这套源码读完之后,回头看很多近两年的3D检测方案,会发现大量工作都是在CenterPoint这个骨架上做改进的。VoxelNet编码器加CenterHead加二阶段精修的范式,已经成了很多现代点云检测器的标配。
我个人觉得,CenterPoint最容易让人忽略的价值不是某个模块有多强,而是它对“稀疏三维数据处理”这件事给出了一个非常完整的工程范式:坐标怎么组织、稀疏卷积怎么高效使用、中心点标签怎么生成、两阶段特征怎么对齐。这些经验迁移到任何点云任务里都成立。
如果你只是为了出结果,一阶段的CenterHead已经能跑出一个不错的水平;但想要真正理解这套方案为什么鲁棒、为什么精度高,二阶段里那套“候选框内归一化点云特征”的思路绝对值得反复琢磨。我现在做自定义数据集上的点云检测,遇到精度瓶颈时,第一反应就是回到这个范式里寻找思路。