SECOND这套代码,我前前后后读过三遍。第一遍跑通训练,第二遍把前向推明白,第三遍才开始真正一行一行抠细节。说实话前两遍都只能算“能跑”,第三遍才觉得自己勉强配得上“懂”这个字。这篇就把我第三遍读源码时留下的注释和笔记整理出来,围绕数据组织、体素化、稀疏卷积、检测头和损失函数这条主线,把每一步在代码里是怎么落地的讲清楚。适合已经跑过一遍代码、想深入理解内部实现的同学,如果你还没跑通,建议先把前两篇的环境配置和数据准备补上再回来看这篇。
1. 读SECOND源码前的三项准备
1.1 什么样的代码才算“看懂”了
先聊一个很实际的问题:读完源码之后,你判断自己“看懂了”的标准是什么?我给自己定的标准很简单——能完整回答一个shape是怎么变的:输入点云是(N, 4),经过某一层后变成(M, T, 4),再过VFE变成(M, C),稀疏卷积阶段变成若干组稀疏特征,最后稠密化变成(B, C, H, W, L),最终检测头输出(B, anchor_num, 7)之类的预测张量。如果你能闭着眼睛把这个链条里的每一步拼接起来,那才叫真看懂了。看代码的时候不要聚焦在“某一个函数干了什么”,而要时刻盯着“当前的张量是什么形状、从哪里来、要到哪里去”。
1.2 目录结构与数据流全图
主流的SECOND复现仓库,目录一般分成几块:数据加载与预处理、模型结构、稀疏卷积相关算子、训练流程、后处理和评估逻辑。我读代码的顺序是固定的,先走数据流再走模型:
- 数据加载与处理:读取点云,做体素化,生成真值标签
- 模型主体:稀疏卷积特征提取、RPN下采样、检测头
- 训练部分:损失计算、优化器、学习率调度
- 推理部分:解码、NMS、结果评估
对应到数据张量上,全流程的shape变化可以用一张表记下来:
| 阶段 | 张量形态 | 说明 |
|---|---|---|
| 原始点云 | (N, 4) | N为该帧点数,4表示x、y、z和反射强度 |
| 体素化后 | (M, T, 4) | M为非空体素数量,T为每个体素最多保留的点数 |
| VFE特征 | (M, C) | 每个非空体素聚合成一个C维特征向量 |
| 稀疏卷积输出 | (M', C') | 稀疏位置参与计算,位置随之变化 |
| 稠密化 | (B, C', H, W, L) | 转成规则网格,进入2D/3D CNN分支 |
| 检测头输出 | (B, A, C+7) | A为anchor数,C为类别数,7为3D框参数 |
这张表就是阅读源码的地图。后面每个模块我都是按照这张表往下钻的。
1.3 先读配置而不是先读代码
很多人读源码第一件事就点开model.py,看得一头雾水。我的习惯是先打开配置文件。配置文件里藏着所有数字:体素大小、点云范围、每个类别预设的anchor尺寸、分类损失权重、回归损失权重、正负样本IoU阈值、最大体素数、每体素最大点数。这些数字决定了代码里百分之七十的分支逻辑。
举个例子,配置里写了max_points_per_voxel=5,那你去看体素化部分,就知道代码里一定有一个“超出5个点的体素怎么处理”的操作,通常是随机采样或直接截断。配置文件就是把代码里的隐形分支显式化。所以读源码前,先把配置里每个字段的含义弄明白,读代码时才能对上号。
2. 体素化与数据流水线:点云如何变成稀疏张量
2.1 数据增强和坐标预处理
进入源码第一步是做数据增强和坐标预处理。在训练时常见的是随机全局旋转、随机翻转、随机缩放,这些操作在读数据阶段就对整帧点云应用。为什么在这里做而不是在模型里做?因为数据增强作用于输入域,在数据加载阶段做最自然,也能通过多进程并行来减少训练时的开销。
代码里你会看到类似这样的逻辑:
# 数据增强:绕z轴随机旋转一定角度 # 注意3D检测里一般只绕z轴旋转,因为车是停在地面上的 # 绕x轴或y轴旋转会让物体脱离水平面,反而不符合真实场景分布 if self.use_random_rotation: angle = np.random.uniform(-np.pi / 4, np.pi / 4) rot_mat = np.array([ [np.cos(angle), -np.sin(angle), 0], [np.sin(angle), np.cos(angle), 0], [0, 0, 1] ]) points[:, :3] = points[:, :3] @ rot_mat.T # 旋转之后,真值box的八个顶点和yaw角也要同步旋转这段代码还包含一个容易忽略的点:旋转点云之后,真值框的朝向角yaw也必须跟着加同一个角度,否则数据就不对齐了。很多初学者在自写数据处理时漏掉真值变换,导致训练loss永远降不下去。
2.2 体素坐标、哈希索引与采样策略
体素化的核心是把连续坐标映射到离散格子。假设点云范围为[x_min, x_max] × [y_min, y_max] × [z_min, z_max],体素大小为[vx, vy, vz],那么每个点所在的体素整数坐标是:
coords = np.floor((points[:, :3] - [x_min, y_min, z_min]) / [vx, vy, vz]).astype(np.int32)到这里只是第一步。因为3D点云稀疏性非常强,一个70m × 80m × 4m的空间,用0.1m左右的体素划分,网格总量可能有几十万甚至上百万,但其中有数据的格子往往只有几千到几万个。如果直接用一个三维数组去存特征,显存和内存都撑不住。所以源码里通常用哈希键把这些非空体素组织起来:
# 把三维整数坐标压成一个唯一整数键,用于快速去重和索引 # 这种压键方式相当于把一个三维下标映射到一维空间 unique_key = coords[:, 0] * grid_y * grid_z + coords[:, 1] * grid_z + coords[:, 2]每个点算出自己的key之后,统计相同key的点,就知道每个体素里落入多少点。之后按照配置里的max_points_per_voxel做截断,一般保留前若干个点或随机采样若干个点。说实话,随机采样比顺序保留要稳一点,理由是点云扫描顺序通常会引入系统性偏差,顺序保留可能让靠近扫描线起始位置的点被重复保留。
数据处理最后整理成(M, T, 4)的张量,T固定为最大点数,不足T的体素补零。同时维护一个(M, 3)的整数体素坐标矩阵,这两个矩阵是后续所有操作的输入。
2.3 VFE模块的逐行注释
VFE(Voxel Feature Encoding)模块是把体素内所有点聚合成一个特征的网络骨架。早期版本的做法用一个PointNet风格的MLP对每个点升维,然后做最大池化得到体素特征,再把池化后的特征广播回每个点,和原始逐点特征拼接在一起。这个设计的直观解释是:最大池化能保留体素内最显著的特征,拼接回去让每个点既知道自己独有的信息,也知道整个体素中的全局响应。
# voxel_features: (M, T, 4) -> (M, T, 8) # 第一步:MLP升维 point_feats = self.mlp(voxel_features) # 逐点逐体素过MLP # 第二步:在T维上做最大池化 pooled, _ = point_feats.max(dim=1) # (M, 8) # 第三步:池化结果广播回每个点,并拼接 pooled = pooled.unsqueeze(1).expand(-1, T, -1) # (M, T, 8) out = torch.cat([point_feats, pooled], dim=-1) # (M, T, 16)注意补零体素带来的问题:补零的位置最大池化时并不会产生干扰,但在后续计算中,零向量参与计算会无谓消耗计算量。所以严格实现里会用一个掩码标记有效点,在池化时把无效位置置为极小值,避免空体素干扰。
2.4 踩过的坑:最大点数与体素上限的处理
读源码时我发现一个特别容易踩的坑:实机点云中点密度不均匀,某些体素可能塞入几十个点,而配置只允许5个点。如果只是简单截断前5个点,会丢失体素内最有代表性的信息。合理做法是先判断数量,超出时随机挑5个点,或者按照反射强度排序择优保留——因为反射强度往往能反映目标表面的几何细节。
另一个坑是体素数量的上限。一帧点云中非空体素数量可能远超过配置里的max_voxels,这时就需要按一定策略丢弃一部分。源码中常见的是按点数量从多到少排序,保留点多的体素、丢弃几乎没点的体素。这样做的逻辑是,那些只有一两个点的体素大概率是离群噪点,保留价值低。这个细节在需求不夸张的小配置上不会触发,但一旦做大规模场景训练,配置不跟上就会悄悄丢数据。
3. 稀疏卷积才是SECOND的灵魂
3.1 稠密卷积为什么在3D点云上走不通
早先的3D检测方案直接对稠密体素网格做3D卷积,但计算量非常不友好。一个简化的计算能说明问题:假设网格是300×300×40,用64通道3D卷积核做一次卷积,乘法次数是300×300×40×64×64×27,规模到千万亿级别,单个GPU根本不现实。但实际数据中非空体素只占个位数百分比,绝大多数位置是空气,算一个乘加都是在浪费算力。SECOND的思路就是:只在有数据的位置做卷积,把算力集中在实际存在的特征上。
3.2 稀疏卷积的数据结构:位置表和特征表
在源码里,稀疏卷积并不直接操作常规四维张量,而是维护两张表:一张是特征表,形状大致是(M, C),只记录非空位置的特征;另一张是坐标表,记录每个特征对应的整数体素坐标,形状是(M, 3)。卷积的过程变成在坐标表上做邻居查询,而不是在稠密网格上做滑窗。
如果把稠密卷积理解为:在每个滑窗位置,把周围的像素和卷积核相乘;那么稀疏卷积就是:遍历所有已有的非空位置,只对它们所在的那些滑窗进行计算。这里有一个关键的数据结构叫“哈希表”,用于快速查询某个坐标周围是否存在有效输入。源码注释里经常看到类似这样的伪代码逻辑:
# 输入坐标表 coords: (M, 3) # 卷积核尺寸 k_size = 3,膨胀率 stride = 1 # 对每个输入坐标 p=(x,y,z),遍历卷积核偏移 k=(i,j,l) # 输出坐标 q = p * stride + k # 用哈希表判断 q 是否落在有效输出坐标集合中 # 只有同时满足“输入有效”和“输出有效”的位置才算一个有效连接这个“构建有效连接”的过程相当于把稀疏卷积转化成一个矩阵乘法。源码里会预先算好一张规则表,记录每个输出坐标关联的输入坐标索引以及对应的卷积核权重位置,之后直接查表执行矩阵乘。这也是稀疏卷积库能提速的关键。
3.3 子流形稀疏卷积与下采样阶段的代码解读
SECOND里用了一个特殊机制——子流形稀疏卷积。普通稀疏卷积在卷积核滑到空位置时会扩散出新的非空输出位置,导致经过几次卷积后稀疏度下降,空位置也被激活。子流形稀疏卷积限制输出位置必须与输入非空位置重合,保证计算始终发生在原始特征所在位置附近。这样做有两个好处:计算量可控,稀疏结构稳定。
我读源码时专门对比了两种卷积在SECOND中的分工。前面几层用子流形稀疏卷积做特征精炼,局部几何信息逐步完善;中间穿插一或几次步长为2的稀疏卷积做下采样,特征图空间尺寸缩小,感受野变大。下采样代码的核心思路只有寥寥几行,但推导起来很有意思:
# 稀疏下采样:stride=2 # 输出坐标 = floor(输入坐标 / 2) 的整数部分 # 可用哈希表在O(1)时间内把输入特征“分桶”到输出坐标上 out_coords = floor(input_coords / 2) # 然后把落在同一out_coord的输入特征聚合(比如取均值) # 这样空间分辨率减半,通道数增加需要提醒一点:稀疏下采样之后,特征数量大幅减少,如果某些输出坐标没有映射到任何输入坐标,它们不会被激活。这导致下一步操作要考虑“空洞”区域,尤其是随后要做的稀疏到稠密的过程,对真值框穿过的区域必须保证被特征覆盖。解决办法是适当放宽下采样倍率,或者在最后一个稀疏卷积阶段输出多个层级的特征做融合。
3.4 稀疏特征转稠密的边界条件
稀疏卷积输出的特征本质上是若干个离散体素位置上的向量,无法直接送入2D RPN。源码中的做法是初始化一个全零的稠密特征张量,形状为(B, C, H, W),然后把稀疏特征按坐标回填进去。这个操作相当于把稀疏点云“铺”到鸟瞰图网格上。
这里有一个关键点:稀疏特征的z维怎么处理。一种做法是保留3D grid,将整个空间的H×W×L投影到H×W,对z维做汇总;另一种做法是在稀疏卷积阶段逐步把z方向的维度压缩,最终直接生成BEV特征。SECOND源码里比较常见的是先保持3D结构,再在最后用一个聚合操作把z方向压掉。压掉之前需要决定聚合方式:直接取最大响应还是求和。
我的注释习惯会在这里标注一句:聚合方式会直接影响检测效果。最大池化能保留最强的激活特征,适合有明确几何凸起的物体;平均池化对多体素重叠的较大目标更平滑。工程上很多实现用最大池化,因为点云稀疏,目标内部特征差异大,最大池化能保留最显著的边界线索。
4. 检测头和损失:3D框的“最后一步”如何实现
4.1 Anchor分配与正负样本
从RPN拿到高分辨率BEV特征后,检测头要输出每个锚框的分类置信度和回归偏移量。Anchor的策略是每个BEV网格位置预置若干组尺寸各异的框。类别不同,anchor的长宽高也不一样,比如轿车类普遍比卡车类小。配置文件中会写明每个类别的anchor尺寸。
源码中anchor的正负样本分配逻辑不复杂:计算每个anchor与所有真值3D框的3D IoU,大于正阈值(典型配置如0.7)的为正样本,小于负阈值(典型如0.3)的为负样本,介于两者之间的设为忽略样本。忽略样本不参与损失计算,避免训练初期大量模棱两可的框干扰收敛。
多类别处理时要注意anchor数翻倍,检测头的输出也相应翻倍。所以最后预测张量的“类别通道”并不是直接做成多标签分类,而是每个anchor类别独立预测,再在后续NMS里跨类别合并。
4.2 回归目标编码:为什么用delta
3D框回归的目标不是直接输出中心坐标和长宽高,而是输出相对anchor的偏移量。这个做法的直接原因是绝对坐标数值跨度大、尺度差异大,直接回归很难稳定训练。delta编码把目标归一化到单位尺度附近,让网络更容易学。
具体来说,每个3D框由七个数表示:中心(x, y, z)、尺寸(l, w, h)、航向角θ。anchor同样有这七个数。源码里回归分支的编码目标大概是:
# anchor: (xa, ya, za, la, wa, ha, theta_a) # 真值: (x, y, z, l, w, h, theta) # 回归目标 dx = (x - xa) / wa dy = (y - ya) / la dz = (z - za) / ha # 尺寸维度使用log缩放,因为真实尺寸和anchor尺寸是乘性关系 dl = torch.log(l / la) dw = torch.log(w / wa) dh = torch.log(h / ha) # 角度:直接用差值,再通过sin/cos归一化(下面单独说) dtheta = theta - theta_a这里有一个容易混淆的地方:dx除以的是wa而不是la,dy除以的是la。为什么要交叉除?这样可以让中心偏移在长宽方向分别归一化,配合anchor的朝向。如果anchor朝向与真值方向差异很大,这种交叉归一化方式会提供更合理的梯度。
4.3 损失函数的源码注释:Focal Loss和Smooth L1
SECOND的损失函数分两大块:分类损失和回归损失。源码里分类分支的默认选择是focal loss的一种改进形式。为什么要用focal loss?因为3D检测里anchor数量巨大,负样本占压倒性多数,普通交叉熵会让模型只学会输出“无目标”,收敛到很差的状态。focal loss在交叉熵基础上对易分类样本降权,让难分类的稀疏正样本得到更多梯度。
# focal loss的核心公式:CE(p_t) * (1 - p_t)^gamma # gamma取2时,易分类样本的损失权重会被极端压低 # SECOND的改进是把alpha平衡因子也纳入到计算中 loss_cls = -alpha * (1 - p_t) ** gamma * torch.log(p_t)回归分支用的是smooth L1。smooth L1在误差绝对值较小时退化成L2的平方项,梯度更平滑;误差大时退化成L1的绝对值项,不容易被离群点拉爆。这个折中让模型在回归框参数时更稳。
损失权重一般可以在配置里调整,常见设置是分类损失权重为1.0,回归损失权重为2.0左右。角度损失会在回归损失里单独占一块,因为角度是周期性量,误差度量跳到边界附近时特殊处理。
4.4 角度周期性这个难题
角度预测是整个3D检测里最微妙的一环。直接回归θ的差值,在0和2π附近会产生很大的跳变:目标真实角度是0.1,预测是6.1,差值为6.0,但实际上这两个角度几乎指向同一个方向。如果直接用这个差值算L1损失,模型会收到错误梯度。
源码中的处理方法分几步。首先在真值编码时,不直接用绝对角度差,而是用角度差的正弦值和余弦值来编码。其次,预测时网络输出一组“方向向量”或直接输出sin和cos分量,再还原成角度。这样就把一个周期性量转化为两个连续值,回避了角度周期的跳变问题。
# 角度残差的正弦余弦编码 dtheta = theta - theta_a # 源码中通常会把dtheta消到[-pi, pi]区间,再用sin(dtheta)和cos(dtheta)表示 target_sin = torch.sin(dtheta) target_cos = torch.cos(dtheta) # 网络输出两个标量,通过atan2还原预测角度 pred_theta = theta_a + torch.atan2(pred_sin, pred_cos)另外,有些实现会额外增加一个方向分类分支,把朝向粗分为前向或后向,例如角度在90°±45°区间内算一类。原因是sin/cos编码虽然解决了周期性,但0°和180°两种对称朝向在损失上无法区分,实际驾驶场景中车辆头尾方向是明确的,加上方向分类能有效解决这个问题。
5. 推理与后处理:从预测张量还原成真实3D框
5.1 推理时的数据流与batch组织
推理阶段和训练阶段最大的区别是:不计算损失,不再需要真值标签,但数据流水线前半部分是相同的——点云读进来、体素化、稀疏卷积、稠密化、RPN、检测头。区别只在于最后从预测张量解码。
源码中推理阶段的batch组织要注意一个细节:不同帧点云的点数、非空体素数差异很大,直接拼接会导致某帧占满上限、另一帧饿死。因此在数据加载时就已经对每帧做了体素数量上限截断,并把多帧数据合并成batch。非空体素数量不够的帧会补一批无意义体素来对齐尺寸,用batch index区分不同帧的体素。
5.2 解码:从delta到绝对坐标的逆变换
解码就是把训练时的编码过程反过来。网络输出回归目标dx、dy、dz、dl、dw、dh、dtheta,乘上anchor的尺寸,然后还原成3D框的中心、尺寸和朝向。
# 解码过程 x = dx * wa + xa y = dy * la + ya z = dz * ha + za l = torch.exp(dl) * la w = torch.exp(dw) * wa h = torch.exp(dh) * ha theta = dtheta + theta_a这一段看起来平平无奇,但需要对长宽和中心坐标的组合保持正确顺序。如果你在编码阶段用了交叉归一化,解码阶段就必须严格对应回去,否则框会整体错位。
5.3 BEV NMS的实现逻辑与IOU近似
NMS(非极大抑制)是检测输出后处理的一部分。直接在所有3D框之间计算真实3D IoU非常耗时,因为要计算两个三维多面体的交集体积。SECOND源码中常用的一种近似是:把3D框投影到鸟瞰图上,先计算2D IoU。对绝大多数驾驶场景目标来说,物体在地面方向的投影已经足够区分重叠程度。z方向的重叠物理意义上影响不大,因为目标都坐落在地面上。
2D BEV IoU的计算也分成两条路径:精确多边形相交算法,或者用采样近似。工程实现中,最常见的是把每个框转成8个顶点,再在水平面上用多边形剪裁算面积。这个复杂度比3D剪裁低一个数量级,能在毫秒级别跑完一个batch。
源码里还会对置信度按类别分别做NMS,而不是所有类别混在一起。这是因为轿车、行人、自行车三类目标允许相互重叠,在物理世界完全可能同时出现在同一视野区域,混在一起做NMS会误杀有效检测。
5.4 用可视化回测自己的理解
读源码读到怀疑人生时,最有效的验证方式是可视化中间结果。把稀疏卷积输出的特征按体素坐标还原到点云空间,染上颜色,看看特征激活和真实目标位置的对应关系。再配合解码后输出的3D框,把预测结果和原始点云画在一起。这是我自己读SECOND源码时最有收获的一步。
我通常会写一个很小的可视化脚本:读一帧点云,输出模型中间层特征,按坐标用颜色映射显示激活强度。如果某个区域的激活强度集中在一辆车的轮廓附近,说明你的数据处理和网络前向都通了;如果激活跑到空白区域,大概率是坐标对齐出了问题,优先检查体素坐标和特征回填时的索引关系。
6. 训练与工程化的几个实战细节
6.1 显存管理与稀疏卷积的弹性
SECOND的稀疏卷积带来一个隐性的“弹性”:显存占用主要取决于非空体素数量,而不是空间网格大小。因此网格范围设得大一些,只要实际点云没有增加,显存增长就不会太夸张。我实测下来,同样的配置下,稠密3D卷积占用的显存大约是稀疏卷积的十几倍。这也意味着你可以把点云输入范围适当放宽,而不用太担心OOM。
不过这个弹性有边界。当你同时开多个数据集并行读取时,CPU侧的体素化计算可能成为瓶颈。如果训练速度上不去,先查数据加载线程数和体素化是否够并行,再考虑是否减通道数。
6.2 混合精度、断点续训与随机性
训练3D检测模型时,混合精度能大幅减少显存占用并提速。但混合精度之后容易出现loss不下降或梯度nan,原因通常是某些稀疏卷积算子对fp16的支持不够友好。我的处理方式是:先用全精度把模型跑通一个小数据集,确认收敛后再开混合精度;开了AMP后给loss加一个scale因子,遇到inf/nan自动跳过本轮。
断点续训也是个经典坑。保存断点时一定要把优化器状态、学习率调度器状态、epoch数和随机数生成器状态都保存下来。如果你只保存模型权重,恢复时lr回到初始值,模型精度会莫名变差。我在代码里会专门用一个字典保存这些状态,并定期将数份断点冗余存储,避免训练中磁盘写入失败导致前功尽弃。
6.3 从零复现时的改造顺序建议
最后说一点关于复现顺序的个人经验。如果你打算从零开始复现SECOND,我建议按这个顺序推进:
- 先跑通数据加载和可视化,确认点云能正确显示、体素化后能还原出大致轮廓;
- 再实现模型前向,用随机输入直接跑通,不要求loss变小,只看形状是否正确;
- 实现损失函数,用一个小数据集或少量训练步数观察loss是否下降;
- 等前向、损失、反向闭环之后,再慢慢加入数据增强、混合精度、多卡训练这些工程优化。
这个顺序的核心原则是:先保证“能学”,再追求“学得好”。很多人一上来就堆各种trick,结果出问题时根本不知道是哪一环出错。
SECOND的源码整体上不花哨,但每一行都对应着扎实的工程考量。读完之后你会明显感觉,自己不仅能改配置调参,还能在遇到性能瓶颈时判断是哪个模块需要动刀。希望对读代码的你有一点帮助。