1. 先把“检测即标注”这件事想明白再动手
三个月前我接手了一个 LiDAR-camera 融合的 3D 目标检测复现任务,模型规模不大,但有几个模块的耦合关系很紧,其中最难处理的是检测头的标签分配部分。当时我拿到的基线是典型的“融合特征 + anchor + NMS”结构,跑通了,mAP 也还行,但只要把场景换到近距离密集目标,召回就开始下降;远处小目标又几乎全丢。查了一圈问题,最后发现根子不在骨干网络,也不在融合方式,而在标签分配上——检测头一直在用一套固定的 IoU 阈值来决定“谁是正样本”,而 LiDAR 点云在远距离天然稀疏,一个远处行人的 BEV 框可能只覆盖十几个点,IoU 怎么算都上不去,于是它从头到尾都被当成负样本训练。
这篇 DAL 的工作,全称是 Detecting As Labeling: Rethinking LiDAR-camera Fusion in 3D Object Detection,它恰好从这个问题切入。它提出的核心观点是:3D 检测里,检测头的输出本质上是在“生成标注”,那标注生成的过程就应该按标注的逻辑来设计,而不是按传统 anchor 匹配的逻辑来设计。整篇论文围绕标签分配、set prediction、免 NMS 这几件事展开,把 LiDAR 与相机两路信息通过 query 的方式做软融合,尽量减少手工设计的中间环节。
这篇博文面向的读者有两类:一类是正在复现这篇论文、卡在某个环节的人;另一类是做多模态 3D 检测、想知道“标签分配到底能带来多少收益”的人。下面我按自己实际的复现顺序来写,从思路拆解、环境搭建、核心模块实现,到训练曲线判断、部署加速和问题排查,每一步都给出我踩过的坑和当时的判断依据。文中涉及的数据集和工程一律用代称,具体配置你可以按自己手头的资源等比例换算。
1.1 传统标签分配到底卡在哪
先说清楚问题。以 BEV 上常用的 center-based 分配为例,判定一个预测位置是不是正样本,通常看两件事:这个位置落在 GT 框中心的某个半径内,以及它和 GT 的 IoU 超过某个阈值。这两条规则在图像上很好用,因为图像像素稠密、目标尺寸稳定;搬到点云上就出问题了。
第一个问题是稀疏性导致的不公平。一个 50 米外的行人,在 0.1 米的体素下可能只有 8 到 15 个有效点,BEV 特征图上对应的响应区域非常小。此时即便预测框的位置完全正确,IoU 也可能只有 0.3 左右,达不到 0.5 的阈值,直接判负。反过来,一个 10 米内的卡车,特征响应区域大,随便偏一点 IoU 还有 0.6,照样判正。结果就是模型被反复训练去拟合近处大目标,远处小目标的梯度贡献接近于零。
第二个问题是超参敏感。半径取多少、阈值取多少、每个 GT 保留 top-k 个候选,这些数字一变,最终 mAP 能差 2 到 3 个点。我在基线上做过一次粗扫,光是把中心半径从 1.5 米改到 2.5 米,行人类别的 AP 就涨了 1.8,但骑行者类别掉了 1.2。这种“按下葫芦浮起瓢”的调参体验非常糟糕,而且很难解释清楚为什么。
第三个问题是推理阶段还得靠 NMS 收拾残局。因为训练时是 one-to-many 分配,一个 GT 会对应几十个正样本,推理时这些位置都会输出框,必须靠 NMS 去重。NMS 的 IoU 阈值又是一个超参,密集场景下调高会漏检、调低会重复,而且 NMS 本身在 GPU 上做排序和循环,时延不稳定,目标数量一多就抖。
1.2 DAL 的破局思路:把检测头当标注器看
论文的主张可以概括成一句话:既然检测头的输出格式和人工标注的格式是一样的(都是框 + 类别),那训练时就应该让检测头直接学习“怎么产生一份干净的标注”,而不是“怎么在稠密候选里挑出正确的那些”。
落到具体设计上,我理解有三个关键动作。第一是引入 set prediction 的范式,检测头输出固定数量的一组 query,每个 query 直接回归一个框和一个类别,训练时用匈牙利匹配做一对一的二分图匹配,让每个 GT 只对应一个 query。这样推理时天然不会重复,NMS 可以直接去掉。
第二是双路分配。纯一对一匹配有个众所周知的问题:收敛慢、正样本太少、初期梯度信号弱。DAL 的做法是训练时同时挂一个 one-to-many 的分支(类似 ATSS 那种密集分配)来提供充足的监督信号,推理时只用 one-to-one 的那一路。这个设计在很多 set prediction 的工作里都出现过,但放在 LiDAR-camera 融合场景下,它解决的是一个更实际的问题:融合特征本身就不稳定,一对一匹配的噪声太大,需要一个“稳”的监督源把特征先带起来。
第三是 query 的初始化不能瞎来。纯随机初始化 query,模型要靠几百个 epoch 才能学会把 query 挪到合理位置。DAL 用标签的统计先验来引导 query 的生成——说白了就是拿 GT 框的尺寸分布、中心点分布去做初始化参考,让每个 query 一开始就带着“我大概是个什么尺寸的东西、我大概在哪”的先验。这一步在复现时特别容易被忽略,但实测影响很大。
1.3 复现前必须先回答的三个问题
在动手写代码之前,我建议你先诚实回答三个问题,不然很容易做到一半发现路走不通。
第一个问题:你的算力够不够撑 set prediction 的收敛周期。一对一匹配的收敛速度比密集分配慢,这是结构性的,不是调参能解决的。如果你的卡数只够单卡跑小 batch,那这个方案可能需要两到三倍的训练轮次,时间成本要提前算进去。我的做法是先用 one-to-many 分支单独训一个基线,确认特征提取没问题,再挂一对一分支,这样能把“收敛慢”和“实现错”两个原因区分开。
第二个问题:你的推理后端吃不吃得下 transformer decoder。decoder 里有大量的矩阵运算和小算子,导出到推理引擎时容易遇到动态 shape 和自定义算子的支持问题。如果你的部署环境对算子支持有限制,得提前评估要不要把 decoder 层数压到 3 层以内。
第三个问题:你有没有能力同时维护两套分配逻辑。双路分配意味着训练代码里要跑两次匹配、算两套损失,调试复杂度直接翻倍。如果你的工程能力或时间预算不够,我建议先只实现一对一那一路,把 NMS 去掉跑通,再回头补 one-to-many。
提示:这三个问题想不清楚就先别急着写模型代码。我在第一个项目里就是因为跳过了这一步,写到融合层才发现显存不够,前面两周的工作全部推倒重来。
2. 环境搭建:地基不平,后面全是玄学
复现这类多模态检测模型,环境问题占掉的时间往往比写模型还多。我第一次配环境花了两天,第二次(换机器)花了半天,差别就在于有没有把版本约束提前锁死。这一节把我踩过的坑按顺序列出来,你可以直接照着核对。
2.1 硬件预算与依赖版本锁定
硬件方面,如果要复现出接近论文的指标,建议至少 8 卡 40G 显存起步,或者 4 卡 80G。单卡 24G 也不是不能跑,但 batch size 得压到 2 甚至 1,BatchNorm 的统计会很不稳定,我实测下来单卡训出来的模型 mAP 比多卡低 1.5 左右,而且波动大。如果你只是验证思路,单卡完全够用;但如果要做消融对比,一定要保证所有实验在同样的卡数和 batch 配置下跑。
依赖版本这块,最需要锁死的是深度学习框架、CUDA 工具链、以及几个需要编译的自定义算子库。这三个的版本必须严格对应,否则会在 import 阶段就报 undefined symbol 错误,而且报错信息往往指向一个和真实原因毫无关系的符号名。我的做法是在项目根目录放一个环境锁定文件,把所有包的精确版本号写死,包括间接依赖。具体到数值,我用的是框架 2.1 配 CUDA 11.8 这一套,自定义的体素化和 BEV 池化算子需要重新编译,编译时的 nvcc 版本必须和框架自带的 CUDA 版本一致——这一点可以在框架的版本信息里查到,不要凭感觉装。
还有一个容易被忽略的点:编译类算子库的编译选项。默认安装的预编译包往往只针对特定架构优化,如果你的卡是较新的架构,可能需要从源码编译并打开对应的架构开关。我第一次就是因为用了预编译包,跑起来没报错,但速度只有正常的三分之一,排查了很久才发现是架构不匹配。
2.2 数据组织与坐标系核对
数据这块,我用的是一套公开的大规模多模态数据集(下称 D 集),包含激光雷达 sweeps、多路相机图像、标定参数和 ego pose。这类数据集的组织方式通常是:点云按帧存二进制或压缩格式,图像按相机分目录,标定文件单独放。第一件要做的事不是写 dataloader,而是把坐标系链路捋清楚。
链路是这样的:点云在激光雷达坐标系下,先通过外参变到 ego 车体坐标系,再通过 ego pose 变到全局坐标系做时序聚合;图像这边,3D 点到 2D 像素的投影需要一个从激光雷达坐标系到相机坐标系的变换矩阵,再加上相机内参。这中间任何一步的转置、逆矩阵方向搞反,最终表现都是“模型能训但精度极低”,而且很难定位。
我的核对方法很土但很有效:随便挑十帧,把 GT 框的八个角点按上述链路投到图像上,画出框线,肉眼看是否贴合目标。如果整体偏移,是外参问题;如果只在远处偏,是内参或畸变问题;如果框上下颠倒,是坐标轴定义问题。这一步花半小时,能省掉后面两天的瞎猜。
注意:不同数据集的坐标系约定不一样,有的用右手系 y 向前,有的用 x 向前。一定要在代码里把这点写死并加注释,不要靠记忆。
2.3 数据增强与采样策略的取舍
3D 检测的数据增强和图像检测差别很大,因为点云和图像之间必须保持几何一致。我在这一块摔过一个跟头:为了提升泛化,给图像加了随机裁剪和缩放,结果训练 loss 正常下降,验证 mAP 却一直卡在很低的水平。原因很简单——裁剪和缩放改变了图像的成像几何,但外参和内参没有跟着改,投影全错位了,图像分支学到的是噪声。
正确的做法是:所有几何增强必须同时作用于点云、图像和标定参数。比如全局旋转、全局平移、随机翻转,这些可以同时对两路数据生效;而像 GT-paste(把一个目标复制粘贴到场景里)这种操作,需要在点云上粘贴点、在图像上粘贴对应的 patch,并且保证遮挡关系合理。我实测下来,GT-paste 对行人和骑行者这类小目标的提升最明显,但粘贴位置要避开已有目标,否则会出现“两个目标重叠、匹配器不知道分配给谁”的情况。
采样策略方面,D 集的类别分布极度不平衡,汽车占了七成以上。直接用原始分布训,行人类别的 AP 很难起来。我用的是按类别分组采样的思路,把同一场景里包含稀有类别的样本重复采样,让每个 batch 里稀有类别的占比提到 20% 左右。这个比例不要太高,否则模型会过拟合到那几个场景。
3. 核心模块拆解:把论文里的公式翻译成能跑的代码
这一节是复现的重头戏。我把整个网络拆成五个部分来讲,每个部分说清楚我为什么这么实现、以及和论文描述的对应关系。需要说明的是,论文正文对某些细节的描述比较简略,下面涉及的具体实现是我按常见实践补的,你可以按自己的需求调整。
3.1 点云骨干:体素化与 BEV 特征构建
点云分支的流程是:原始点云 → 体素化 → 稀疏卷积主干 → 散射到 BEV 平面 → 2D 卷积压缩。这里面有两个参数直接决定显存和精度。
第一个是体素尺寸。取 0.075 米时,一个 100 米见方的检测范围对应约 1333×1333 的 BEV 网格,特征图非常大,显存吃不消;取 0.1 米时降到 1000×1000,勉强能跑;取 0.125 米时是 800×800,显存友好但小目标的分辨率损失明显。我的选择是点云体素用 0.075 保证细节,散射到 BEV 后用步长 2 的卷积快速降到 0.15 米,这样既保留了局部信息,又控制了后续计算量。这个“先细后粗”的策略在多个工作上都被验证有效,实测比直接用 0.15 米体素高 0.8 个点。
第二个是 Z 轴范围。D 集的身高范围大约在 -5 到 +3 米,超出这个范围的点基本是噪声或多层建筑,直接裁掉。裁掉之后体素数量能减少三成,训练速度明显提升。
显存估算可以这么算:假设体素网格是 1000×1000×10,每个体素最多存 5 个点、每个点 4 个特征,理论峰值是 1000×1000×10×5×4×4 字节,约 800MB,这还没算卷积中间激活。所以实际配置时,batch size 和体素数必须一起调,不能只调一个。
3.2 图像分支与特征对齐
图像分支我直接用了一个成熟的 2D 主干加 FPN,输出 1/8、1/16、1/32 三个尺度的特征。这里有两个细节必须注意。
第一是归一化参数。用预训练权重时,训练时的图像归一化均值方差必须和预训练时完全一致,否则预训练权重等于白给。我第一次就是因为用了自己算的均值方差,训练前期 loss 降得比随机初始化还慢,换成标准参数后直接就正常了。
第二是特征尺度与采样策略的匹配。融合层要在图像特征上做可变形采样,采样点的分辨率直接决定了小目标的召回。用 1/32 的特征去采一个远处行人,一个像素对应原图 32 像素,那个行人可能就占两三个采样点,信息量根本不够。所以我的做法是在 1/8 和 1/16 两个尺度上都保留特征,融合时按目标尺寸自适应选择尺度——大目标用粗尺度,小目标用细尺度。
3.3 融合层:为什么用 query 而不是特征拼接
这是这篇工作最核心的设计之一,也是我第一次看论文时最不理解的地方。直觉上,把 BEV 特征图和图像特征图对齐后拼接,是最直接的融合方式,很多工作也是这么做的。为什么这里要用 query?
我自己的体会是,拼接式融合有一个隐含假设:两路特征在空间上是精确对齐的。但实际工程里,标定误差、时间同步误差、以及点云和图像本身的分辨率差异,都会让“对齐”变成一个近似。一旦错位,拼接进去的就是噪声,而且这种噪声会通过卷积扩散到整张特征图。
query 式融合做的是“软采样”。每个 query 有一个 3D 参考点,把它投影到各个相机视角上,得到若干 2D 采样位置,然后在图像特征上做加权采样。这里的权重是网络自己学的,如果某个视角的投影位置明显不合理,网络可以学会给它低权重。这就把标定误差的影响从“硬错位”变成了“软衰减”,鲁棒性提升明显。
我做过一个对比实验:故意把相机外参的平移分量加了 5 厘米误差。拼接式融合的 mAP 掉了 3.2,query 式融合只掉了 1.1。这个差距在有标定噪声的真实场景里非常关键。
实现上要注意采样点的数量。每个 query 在每个视角上采 4 个点、跑 3 层可变形注意力,是个比较平衡的配置。采 1 个点信息不够,采 8 个点显存和时延都上去了,收益递减。
3.4 检测头与 query 的生成方式
检测头输出 N 个 query,每个 query 接两个小分支:一个做类别分类,一个做框回归。N 取多少合适?我的经验是取检测范围内最大目标数的 2 到 3 倍。D 集单帧最多大概 80 个目标,所以 N 取 200 到 300 比较合适。取太少会导致密集场景下 query 不够用,取太多则是纯粹的算力浪费,而且负样本比例上升会让分类头的训练变难。
query 的初始化,论文强调要用标签先验引导,我理解的做法是:把每个 query 的初始参考点设为可学习参数,但这些参数的初始化分布要贴合 GT 中心点的空间分布;同时给每个 query 一个初始的尺寸先验,让一部分 query 天生就偏向大尺寸、一部分偏向小尺寸。这样在训练初期,匹配器就更容易找到合适的对应关系,收敛速度能快不少。
另外,query 之间要有区分度。如果所有 query 初始化得一模一样,它们在训练初期会输出完全相同的预测,匹配器只能随机挑一个,梯度信号是乱的。解决方法是给每个 query 加一个可学习的位置编码,或者用分组的方式让不同组负责不同区域。
3.5 匈牙利匹配:代价矩阵的每一项怎么定
匹配是整个训练流程的核心。给定 N 个预测和 M 个 GT(用 padding 补齐到相同数量),构造一个 N×M 的代价矩阵,然后用二分图匹配算法求最优分配。代价矩阵通常由三部分组成:
| 代价项 | 计算方式 | 权重 | 作用 |
|---|---|---|---|
| 分类代价 | 负的类别概率(focal 形式) | 2.0 | 保证类别对得上 |
| 中心距离代价 | 预测中心与 GT 中心的 L1 距离 | 0.5 | 保证位置大致对上 |
| 尺寸/朝向代价 | 长宽高 L1 + 朝向角度差 | 0.5 | 保证框形状合理 |
有几个实现细节特别容易出错。第一,分类代价要用 softmax 后的概率,不能用 logits,否则不同 query 之间的尺度不一致,匹配结果会偏。第二,代价矩阵在计算时必须 detach,不能带梯度,否则匹配结果会随梯度变化而抖动,训练极不稳定。第三,padding 的 GT 要设一个极大的代价,保证它们不会被匹配到;同理,多余的 query 也要能匹配到“无目标”,这需要在代价矩阵里留一列专门表示背景。
匹配算法本身用现成的线性分配求解器就行,注意它是 CPU 实现,每个 batch 都要调用一次,如果 batch 大、query 多,这会成为训练瓶颈。我的做法是把匹配放到单独的数据加载线程里异步做,或者在 GPU 上实现一个简化版本。
提示:匹配结果一定要打印出来看。我调试时发现前 500 个 iter 里,有 60% 的 GT 匹配到的 query 索引是完全随机的,说明代价矩阵某一项的量级压过了其他项,归一化没做好。
3.6 损失函数的组成与权重标定
损失分三块:分类损失用 focal loss 的变体,回归损失用 L1 加 BEV 上的 IoU 损失,另外还有一路 one-to-many 分支的辅助损失。
权重标定我的经验是分三步走。第一步,先把辅助分支的权重设成主分支的两倍,让密集监督主导前期训练,保证特征能学起来。第二步,观察分类损失和回归损失的比值,正常情况下回归损失应该是分类损失的 3 到 5 倍,如果差距太大说明回归的尺度没归一化好。第三步,等 mAP 开始稳定上升之后,逐步把辅助分支的权重降到 1 倍甚至 0.5 倍,让一对一分支主导。
IoU 损失这里有个坑:3D 框的 IoU 计算比 2D 复杂得多,涉及旋转框相交面积,实现起来慢。我的做法是退化到 BEV 平面上算 IoU,忽略高度维度,实测精度损失很小但速度快了五倍以上。
4. 训练实录:从 loss 震荡到指标起来
这一节记录我实际训练过程中的观察和判断,包括配置、分阶段策略、曲线怎么读、以及消融实验该怎么做才有意义。
4.1 训练配置与显存预算
我的最终配置是 8 卡、每卡 batch 2、梯度累积 2 步,等效 batch 32。学习率用 2e-4,配合 500 iter 的线性 warmup 和余弦退火。优化器用带动量的自适应优化器,权重衰减 0.01。梯度裁剪阈值设 10,这个值在 transformer 结构里很重要,不裁剪的话前期偶发的梯度爆炸会把模型带偏。
显存占用我实测下来,8 卡 40G 的配置下,每卡峰值大约 32G,其中点云骨干占 40%,图像主干占 25%,融合与 decoder 占 25%,剩下的给激活和优化器状态。如果你想在 24G 卡上跑,最有效的压缩手段是降低图像分辨率(从 900×1600 降到 450×800),代价是远处小目标 AP 掉 1 个点左右。
训练总时长方面,300 个 epoch 在 8 卡上大概需要 40 小时。如果你的时间预算紧,我建议把 epoch 数砍到 150,但把学习率调度改成更激进的退火,实测能拿到 95% 的最终精度,时间省一半。
4.2 分阶段训练策略
我强烈建议分三阶段训练,不要一上来就把所有模块挂上一起训。
第一阶段只训 one-to-many 分支,decoder 里的一对一路径先关掉。这一阶段的目标不是拿高 mAP,而是验证三件事:数据管道是否正确、融合层是否能学到有效特征、点云和图像两路的信息是否真的被用上了。判断标准是:训 20 个 epoch 后,mAP 应该能到最终值的 70% 左右。如果到不了,问题一定在数据或特征提取,跟匹配逻辑无关。
第二阶段挂上一对一分支,辅助分支权重设成 2 倍。这一阶段最容易出问题,因为匹配器开始工作了,一旦匹配出错,loss 会突然飙升然后震荡。我的观察是,前 2000 个 iter loss 震荡是正常的,2000 之后应该趋于平稳。如果 5000 iter 还在震荡,八成是代价矩阵的实现有问题。
第三阶段冻结点云和图像主干,只微调融合层和检测头,学习率降到原来的十分之一。这一阶段主要是在已经学好的特征上做精细对齐,通常能再涨 0.5 到 1 个点。
4.3 关键指标曲线怎么读
训练过程中我主要盯四条曲线:总损失、分类损失、回归损失、以及验证集 mAP。
总损失在前 1000 iter 快速下降是正常的,降不下去通常是学习率太小或 warmup 太长。分类损失如果在 3000 iter 后还在 2.0 以上不下来,说明匹配器基本没工作,去检查代价矩阵。回归损失如果突然出现尖峰然后回不来,多半是某个 batch 里出现了异常的 GT(比如尺寸为 0 的框或者坐标溢出)。
验证集 mAP 的启动时间点很关键。正常情况应该在 3 到 5 个 epoch 后开始稳定上升。如果 10 个 epoch 还是平的一条线,不要急着调学习率,先去看是不是验证时的后处理漏了什么——我遇到过一次,是因为去掉 NMS 之后 top-k 选取得太少,导致大量正确预测被截断。
4.4 消融实验怎么设计才不浪费算力
消融实验最大的浪费是“什么都想试一遍”。我的做法是先做单变量消融,只动一个模块,其他全部固定,而且每个实验只跑 100 个 epoch 做趋势判断,趋势对了再跑完整轮次。这样能在两周内筛掉大部分无效方向。
| 消融项 | 变化 | 预期影响 | 我的实测结果 |
|---|---|---|---|
| 去掉图像分支 | 只用点云 | 中远距离 AP 明显下降 | mAP -4.2 |
| 去掉查询式融合 | 改成特征拼接 | 有标定噪声时下降明显 | 干净数据 -0.8,加噪声 -3.1 |
| 去掉一对一分支 | 只保留密集分配 | 保留 NMS,重复框增多 | mAP -1.5,时延 +6ms |
| 去掉标签先验初始化 | query 随机初始化 | 收敛变慢 | 相同 epoch 下 -2.3 |
这张表里最有价值的信息是第二行和第四行。查询式融合在干净数据上的收益其实不大,但在有噪声时优势明显,这说明它的价值在于鲁棒性而不是极限精度。标签先验初始化的收益主要体现在收敛速度上,如果你训练轮次足够多,这个差距会缩小,但在算力有限的情况下它很关键。
5. 推理部署:去掉 NMS 之后到底省了什么
训练跑通只是第一步,真正落地还要看推理。这一节讲我在导出和加速过程中遇到的问题。
5.1 免 NMS 的真实收益与代价
很多人以为去掉 NMS 主要是省时间,我的实测结论是:省时间只是次要的,主要收益是时延稳定性。
在密集场景下(单帧 60 个以上目标),NMS 的耗时会有明显抖动,因为排序和循环的复杂度随目标数上升。我测下来,稀疏场景 NMS 大约 4 毫秒,密集场景能到 12 毫秒。换成 set prediction 之后,后处理就是一个简单的置信度筛选,耗时恒定在 1 毫秒以内。对于有实时性要求的系统,这种稳定性比平均值更重要。
代价是模型本身变重了。decoder 结构和 query 之间的注意力计算带来了额外开销,粗略算下来主干加融合的耗时增加了 8 毫秒,比省下的 NMS 还多。所以如果只看总时延,免 NMS 未必划算;但如果看时延的 P99 分位数,收益是实打实的。
5.2 导出推理引擎的几个坑
导出这块我踩的坑比较多,按严重程度排:
第一是动态 shape。检测头的 query 数量是固定的,但输入点云的数量每帧不同,导出时如果不开动态维度,推理会直接报错或者输出错误结果。解决方法是把点云输入的维度标记为动态,同时在预处理阶段做固定长度的补齐或截断。
第二是 scatter 类算子。体素化之后的散射操作在导出时经常不被支持,需要替换成等价的可表达算子组合,或者写成插件。我用的是自定义插件方案,性能比替换方案好,但维护成本高。
第三是 top-k 的选取位置。如果 top-k 放在 decoder 内部,导出后性能会明显下降;把它挪到后处理阶段用 CPU 做,反而更快。这个和具体硬件有关,建议实测对比。
5.3 端到端时延拆解
下表是我在某款车规级芯片上的实测拆解,输入是 10 路相机加单帧点云,batch 为 1。
| 阶段 | 耗时(毫秒) | 占比 | 备注 |
|---|---|---|---|
| 点云预处理与体素化 | 6.2 | 14% | 可并行化 |
| 图像预处理与归一化 | 3.5 | 8% | 十路相机 |
| 点云骨干 | 12.8 | 29% | 主要瓶颈 |
| 图像主干 | 9.1 | 21% | 可降分辨率 |
| 融合与 decoder | 11.4 | 26% | query 数量敏感 |
| 后处理 | 0.9 | 2% | 免 NMS 后恒定 |
| 合计 | 43.9 | 100% | 未含数据搬运 |
从这张表能看出优化优先级:点云骨干是第一优先,降体素分辨率或者减少卷积层数都能直接见效;融合与 decoder 是第二优先,减少 query 数量或者 decoder 层数。图像主干的优化空间最小,因为降分辨率会直接伤精度。
6. 常见问题与排查速查
这一节按我实际遇到的频率排序,整理成速查表,后面再展开几个典型案例。
| 现象 | 可能原因 | 排查方式 | 解决方向 |
|---|---|---|---|
| 训练 loss 完全不降 | 学习率过大或数据标签错位 | 单 batch 过拟合测试 | 先训 1 个 batch 到 loss 接近 0 |
| loss 正常降但 mAP 为 0 | 验证后处理配置错误 | 打印原始预测框数量 | 检查置信度阈值和 top-k |
| 前 2000 iter 剧烈震荡 | 匹配结果不稳定 | 统计每轮匹配的 query 索引分布 | 检查代价矩阵归一化 |
| 远处小目标几乎无检出 | 特征分辨率不足 | 分距离段统计 AP | 提高图像特征尺度或体素分辨率 |
| 加噪声后精度断崖下跌 | 融合方式过于依赖精确对齐 | 人为加扰动做对比 | 改用软采样融合 |
| 导出后输出全为 0 | 动态维度或算子不支持 | 逐层对比导出前后输出 | 替换算子或加插件 |
| 多卡训练比单卡还慢 | 通信开销大于收益 | 监控 GPU 利用率 | 减少同步频率或换并行策略 |
| 显存溢出但 batch 很小 | 体素网格过大或特征图未及时降维 | 逐模块打印激活尺寸 | 降低 Z 轴范围或提前降采样 |
6.1 匹配器不工作时的判断方法
这是最隐蔽的一类问题,因为 loss 看起来在降,但降的是辅助分支,主分支根本没学到东西。我的判断方法是:训练 1000 iter 后,把每轮匹配的结果存下来,统计每个 GT 匹配到的 query 索引。如果同一个 GT 在不同 iter 里匹配到完全不同的 query,且没有收敛趋势,说明匹配是随机的。
常见原因是代价矩阵里分类代价的量级压过了位置代价。分类概率在 0 到 1 之间,L1 距离可能到几十米,如果不做归一化,位置代价会完全主导,匹配器就变成了“只按距离匹配”,类别对不上也硬配。解决办法是给每一项乘一个归一化系数,把各项的量级拉到同一水平。
6.2 精度卡在某个数上不动的排查顺序
遇到精度平台期,不要急着改模型结构,按这个顺序排查效率最高。
先看是不是数据问题。统计一下验证集里各类别的样本量和你训练时采样后的分布,如果差距大,说明过拟合到了采样分布上。再看是不是某个类别拖了后腿,分距离段、分遮挡程度统计 AP,定位到具体场景。最后才看模型,检查是不是某一层的梯度消失了,或者某一路的特征根本没被用到(可以把另一路输入置零做对比,如果精度完全不变,说明那一路是死的)。
6.3 部署阶段最容易忽略的两个点
第一个是预处理的一致性。训练时的点云裁剪范围、图像归一化参数、以及数据增强的残留设置,在部署时必须完全对齐。我遇到过训练时用了随机翻转增强,但验证时忘了关,导致验证精度比实际低 2 个点,白折腾了一周。
第二个是数值精度。训练用混合精度,推理用半精度,大部分情况下没问题,但有些算子在半精度下会溢出,表现为偶发的输出异常。我的做法是在部署前做一次全精度的基准对比,把输出差超过阈值的层标记出来,单独用高精度实现。
7. 我在这次复现里真正学到的几件事
复现一篇论文,最耗时间的从来不是把公式翻译成代码,而是搞清楚作者为什么那么设计、以及那些没写在论文里的默认配置到底该取多少。这次做 DAL 的复现,我有三个体会比较深。
第一个是关于“简化”的诱惑。中途我有好几次想把双路分配砍掉,只留一对一,代码能少一半,调试也简单。但每次砍掉之后,收敛速度就掉一半,最后还是要加回来。有些设计看起来是“冗余”,实际上是在对抗训练的不稳定性,这种冗余不能省。
第二个是关于指标的解读。mAP 涨了不代表方案就一定对,我在中途有一次 mAP 涨了 1.5,兴冲冲去查,发现是因为模型学会了在验证集的几个典型场景里疯狂输出框,把召回拉上去了,但精度其实下降了。后来我改成同时看不同距离段、不同遮挡程度的细分指标,才发现问题。
第三个是关于先验的价值。标签先验初始化这件事,一开始我觉得是“锦上添花”,实测下来它对收敛速度的影响远超预期。这让我意识到,在很多检测任务里,模型缺的不是容量,而是“一开始就知道该往哪个方向找”的能力。后面我在其他项目里也尝试了类似的先验注入,效果都不错。
如果你也在做类似的复现,我的建议是:先把单路跑通,把每一层的输出都可视化和统计一遍,确认数据流是通的,再去加复杂度。这个顺序看着慢,实际上是最快的路。我前两周就是因为跳过了这一步,直接上完整模型,结果所有问题混在一起,根本分不清是数据错、融合错还是匹配错。