DAL复现:基于检测即标注的LiDAR-camera结果层融合 1. 我为什么盯上了“检测即标注”这条路线去年下半年我们组在做一个城市道路场景的三维目标检测项目LiDAR 分支单独跑已经能到 60 出头的 mAP但只要把相机加进去指标就开始抖晴天涨一点雨天掉一截遇到逆光直接崩。排查了两周问题不在模型容量而在融合这件事本身——像素特征和点云特征压根就不在同一个尺度上对话标定误差 0.3 度就足够让 BEV 空间里的对齐悄悄错位。就是在这个节点上我注意到了 DALDetecting As Labeling这个思路它把 LiDAR-camera Fusion 从“特征层拼积木”改成了“结果层做对齐”一下子绕开了我们卡了半年的坑。这篇东西是我把 DAL 从头复现一遍的完整记录。它适合三类人看一是手上有 LiDAR 检测 baseline、想低成本把相机信息吃进去的工程同学二是被标定误差和时序不同步折磨过、想找条更皮实的融合路线的算法同学三是准备复现这篇工作、但被中间那些“论文没写清楚”的细节卡住的人。我会把三段式流水线的职责划分、投影链路怎么对齐、匹配矩阵怎么构造、参数怎么定全部摊开讲代码能直接抄。先说结论DAL 不是那种“改一个 loss 就涨点”的微创新它是一次架构层面的观点切换——把 3D Object Detection 拆成“生成候选”和“给候选打标签”两件事相机不参与特征融合只负责在结果层面提供语义标签。这个视角一变很多原本很难处理的问题标定漂移、传感器不同步、远距离小目标反而变得可以容忍了。1.1 现有融合方案的三个隐形成本在讲 DAL 之前得先把传统路线的问题说透否则你不会理解为什么值得为了它重构整条流水线。最早的方案是点云着色point painting把图像语义分割的结果投到点上再喂给点云网络。这条路线上手快但它把相机的错误直接固化进了点云特征——分割一旦在边缘处糊掉点云上就多出一片噪声后面怎么训都救不回来。第二代是 BEV 空间的特征拼接或 cross-attention代表做法是把图像特征 lift 到 BEV 再和 LiDAR BEV 做 concat 或 transformer 交互。这条路精度上限高但代价是投影矩阵必须准、时间戳必须对齐、特征分辨率必须匹配三个条件缺一个融合收益就会变成负的。我实测下来这三件事构成了三个隐形成本。第一是标定成本外参标定误差会随着距离线性放大30 米外的目标0.2 度的俯仰角误差就能让投影偏移半个车身。第二是同步成本相机 30Hz、激光雷达 10Hz 是常态卷帘快门还会让同一帧图像内部存在时间差特征层面的对齐对此极其敏感。第三是调试成本融合模块出问题时你很难判断到底是特征没对齐、还是检测头本身弱定位问题的链路特别长。DAL 的聪明之处在于它承认这三件事在工程上无法彻底消除于是干脆不在特征层做对齐。只要投影框和二维检测框的 IoU 足够高就算匹配成功那么 0.3 度的标定误差、20 毫秒的时间差都不会导致匹配失败——因为匹配是一个带容差的、全局最优的分配过程而不是逐像素的精确对应。1.2 DAL 把融合挪到了结果层用一句话概括 DAL 的做法让 LiDAR 负责回答“哪里有东西、它有多大、朝向如何”让相机负责回答“它是什么类别、有多确信”两边各自输出结果再用一个匹配算法把二维检测当作“标注”分配给三维候选框。这个“检测即标注”的比喻非常准确。想象你雇了一个标注员他只看图片把图里每一辆车、每一个行人都框出来并标上类别。现在你手上有一堆激光雷达给出的三维空盒子你要做的事就是把标注员给的二维标签一个个贴到对应的三维盒子上。贴上去之后这个三维盒子就有了类别和置信度检测就完成了。这个设计的最大好处是模块解耦。LiDAR 分支可以换成任何你顺手的检测器二维分支可以换成任何成熟的开源检测器中间的匹配模块是一个纯几何纯后处理的逻辑不参与梯度回传。这意味着你可以分阶段迭代先把 LiDAR 分支调到最优再单独把二维分支训到收敛最后接上匹配模块看效果。任何一环出问题都能被隔离定位。相比之下特征融合的方案里你改一个 attention 层整个网络的收敛行为都会变。另一个容易被忽略的收益是远距离目标。激光雷达在 40 米外的点云稀疏到只有几个点回归出来的框位置还行但类别置信度经常低得离谱被 NMS 直接筛掉。而图像在同样距离上一辆车的像素面积还有几百个二维检测器判类别判得相当稳。匹配模块把二维检测的高置信度“传”给三维候选之后这些远距离目标就能被救回来。我们在自建评测集上40 米以上区间的召回率提升了接近 6 个点这部分收益是纯 LiDAR 分支怎么调参都拿不到的。注意DAL 不是万能的。如果二维检测器本身在夜间或强逆光下失效匹配模块拿到的“标签”就是错的反而会污染原本正确的 LiDAR 结果。所以分数融合那一步必须留出“不信任相机”的退路这一点我在第 4 章会详细讲。2. 方案拆解三段式流水线的职责边界复现之前我把整条链路拆成了三个独立模块每个模块单独定义输入输出契约。这一步看着像“画架构图浪费时间”实际上是我复现过程中最值钱的两个小时——因为后面所有的调试都是围绕“哪个模块的输出不符合契约”展开的。2.1 三维候选生成器只负责“哪里有东西”这一支的定位是高召回、低精度。它不需要把类别判对甚至不需要把分数打准它唯一要保证的是真值目标必须出现在候选集合里。所以我在实现时把分数阈值压得很低0.05保留 NMS 之前的 top-128 个候选宁滥勿缺。具体结构我选的是无锚框的中心点范式骨干网络用稀疏卷积提点云特征检测头输出三类东西——类别热力图、中心点偏移、框尺寸与朝向。热力图用高斯焦点损失监督中心点偏移和尺寸朝向用 L1 回归。这套结构的好处是输出天然是“点 属性”的形式转成候选框只需要一次 gather非常干净不像 anchor 范式那样要做一堆解码。这里有个参数必须说清楚候选框数量 N 取多少。我一开始取了 256想着召回更保险结果匹配阶段的时间从 8 毫秒涨到 34 毫秒匈牙利算法是 O(n³)256×256 的代价矩阵比 128×128 贵了 8 倍而召回只涨了 0.4 个点。后来压到 128速度和召回平衡得最好。如果你的场景目标特别密集比如停车场可以放宽到 192但别再往上加了。另一个细节是候选框的分数要不要保留。我保留了因为在最后的融合阶段它要和二维检测的分数做加权几何平均。如果三维分支的分数本身就不可靠比如热力图响应弥散融合后的分数也会跟着糊掉。所以我给三维分支单独加了一轮分数校准用验证集做温度缩放把过高的置信度压下来实测对最终 mAP 有 0.8 个点的帮助。2.2 二维检测器只负责“它是什么”这一支的定位和上一支正好相反高精度、类别可靠召回稍微低一点没关系因为漏掉的候选框可以靠三维分支自己兜底。我直接用了成熟的开源单阶段检测器没有自己造轮子。选它的理由是训练稳定、推理快、支持导出成静态图而且它的数据增强和标签格式都很成熟省去了大量适配工作。输入分辨率我定在 640×640理由是车在 50 米处大概占 40 个像素宽640 的输入下还能保留足够的纹理再往上加到 1280速度掉一半收益不到 1 个点不划算。这里有一个非常关键的坑二维分支的训练标签不能用人工标注的二维框必须用三维真值框投影出来的框。原因很直接——人工标注的二维框是贴着可见部分的被遮挡的车只标露出来的那一截而三维框投影出来的框是完整的包络框。如果用人工标注训练推理时二维检测器输出的是“可见部分框”而我们拿它去和“完整包络框”算 IoU匹配率会莫名其妙地掉一大截。我在第一版就踩了这个坑。当时匹配率只有 41%我以为是投影矩阵错了查了一整天坐标系最后发现是标签域不一致。改成投影框训练之后匹配率直接跳到 78%。投影框的生成规则我放在 3.3 节。还有个小技巧投影生成二维标签时如果三维框投影后落在图像外的面积超过 70%或者投影框宽度小于 8 个像素这个样本直接丢弃。不丢的话你会得到一堆贴着图像边缘的畸形框把检测器的回归分支带偏。2.3 匹配模块把标签分配给候选这是整个方案的核心也是论文里写得最含糊、复现时最难的部分。匹配模块的本质是一个带约束的二分图最优分配问题左边是 N 个三维候选框右边是 M 个二维检测框每条边的代价由两者的相似度决定目标是找到一个一对一或一对零的分配方案让总代价最小。求解用匈牙利算法几行代码就能搞定难度全在代价矩阵怎么设计。我用的相似度由两部分组成。第一部分是投影 IoU把三维候选框的八个角点投到图像平面取外接矩形和二维检测框算 IoU。第二部分是类别一致性三维候选的类别概率分布和二维检测的类别分布做余弦相似度。最终代价是cost 0.7 * (1 - IoU) 0.3 * (1 - cos_sim)权重 0.7/0.3 是我试出来的。几何占主导的原因是类别在三维分支里本来就不可靠如果类别权重给太高匹配会倾向于把所有候选都分给“车”这个大类行人几乎全被漏掉。改成几何主导之后行人分支的匹配率从 52% 涨到 71%。匹配允许“不分配”代价矩阵里加一列/一行的“空匹配”通道代价设为一个固定阈值我取 0.65对应 IoU 大约 0.3。这样低质量的候选不会被迫绑上一个错误的二维标签避免污染。同时一个二维检测框最多只能分配给一个三维候选这个一对一约束由匈牙利算法天然保证。提示不要用贪心匹配每个候选找最近的二维框代替全局最优。贪心在密集场景下会出现“一个二维框被多个候选抢”的情况导致大量重复框在 NMS 阶段互相压制最终 mAP 反而下降。我对比过密集路口的场景里匈牙利比贪心高 2.1 个点。3. 复现前的环境与数据准备3.1 依赖版本与硬件预算这套东西对环境的敏感度中等主要是稀疏卷积库和检测框架的版本绑定比较烦。我把验证过的组合列出来你可以直接照抄组件版本说明Python3.83.9 及以上在稀疏卷积的编译上偶发失败PyTorch1.10.1 cu113与稀疏卷积库的预编译包匹配稀疏卷积库1.4.0别用 2.xAPI 变动大检测框架2.x 系列二维分支用独立环境更省心匹配求解scipy 1.7.3linear_sum_assignment直接用可视化open3d 0.15调投影的时候离不开硬件上单卡 24G 显存够用。训练时三维分支 batch size 4、二维分支 batch size 16分开训的时候都很宽松。联合微调阶段两个分支同时驻留显存我把候选数压到 128、二维输入压到 640 才勉强跑起来显存峰值 21.3G。如果你的卡只有 16G建议联合阶段用梯度检查点或者干脆不做联合微调分阶段训完直接接匹配模块效果差距不到 0.5 个点。磁盘空间要留足。多传感器数据集的完整版解压后 300G 起步我建议先只解压 1/4 的 scene 做通流程等 pipeline 跑通了再上全量。我第一次就是全量解压完才发现坐标系搞错了重跑一遍预处理花了 6 个小时。3.2 数据集目录与投影链路对齐数据集的组织方式我改成了下面这样好处是预处理脚本和训练脚本共用一套索引data/ds_root/ ├── samples/ # 关键帧各传感器独立目录 │ ├── LIDAR_TOP/ │ ├── CAM_FRONT/ │ └── ... ├── sweeps/ # 非关键帧用于点云累积 ├── maps/ └── meta/ ├── calib.json # 内外参 ├── timestamps.json └── annotations.json投影链路是复现时最容易出错的地方我把它拆成三步每一步都做数值校验第一步点云坐标系到自车坐标系。这一步通常是旋转平移注意有些数据集的点云坐标系是右手系、x 向前 y 向左 z 向上而自车坐标系可能是 x 向前 y 向右 z 向下中间有一次手性翻转。我吃过这个亏投影出来的框全部水平镜像肉眼看图还能骗过自己一算 IoU 就露馅。第二步自车坐标系到相机坐标系。这一步用外参矩阵注意方向是T_cam_ego而不是T_ego_cam两者互为逆矩阵。我建议写个断言把相机光心投到自车坐标系看坐标是不是落在合理范围前方 1.5 米、高度 1.6 米左右不对就说明方向反了。第三步相机坐标系到像素坐标系。用内参矩阵 K注意[R|t]的拼接顺序以及是否需要做畸变校正。如果图像已经过去畸变处理直接用 K如果没做必须先用畸变系数做映射否则图像边缘的投影会偏出 10 个像素以上。我写了一个自检函数把真值三维框投到图像上画出来然后人眼扫 50 张图。这一步花 20 分钟能省掉后面两天的问题定位。def project_box_to_image(box3d, T_cam_ego, K, img_shape, score_thrNone): corners get_8_corners(box3d) # (8, 3) in ego frame homo np.concatenate([corners, np.ones((8, 1))], axis1) in_cam (T_cam_ego homo.T).T[:, :3] if (in_cam[:, 2] 0.5).any(): # 全部在相机后方直接丢弃 return None pix (K in_cam.T).T pix pix[:, :2] / pix[:, 2:3] x1, y1 pix.min(axis0) x2, y2 pix.max(axis0) x1, y1 max(0, x1), max(0, y1) x2, y2 min(img_shape[1], x2), min(img_shape[0], y2) if x2 - x1 8 or y2 - y1 8: return None return [x1, y1, x2, y2]这里还有一个隐藏极深的坑数据增强必须同步。如果你对图像做了随机裁剪、缩放、翻转那么内参矩阵也必须跟着变而且变换参数必须和三维点云分支共享。我见过不止一个人在这里翻车——图像裁剪了K 没改结果匹配率一直上不去查了三天以为是匹配算法的问题。正确做法是把图像增强的参数裁剪偏移、缩放比、是否水平翻转作为一条记录存下来投影的时候实时作用于 Kdef update_intrinsic(K, crop, scale, flip): K_new K.copy() K_new[0, 0] * scale; K_new[1, 1] * scale K_new[0, 2] K_new[0, 2] * scale - crop[0] K_new[1, 2] K_new[1, 2] * scale - crop[1] if flip: K_new[0, 2] img_w * scale - K_new[0, 2] K_new[0, 0] * -1 return K_new点云侧如果做了全局旋转和缩放也要同步作用到投影矩阵的世界变换部分。最省事的办法是联合训练阶段干脆关掉几何增强只用颜色抖动和点云 dropout。等模型收敛了再逐步打开增强看指标能不能扛住。我在联合阶段关掉几何增强之后训练稳定性的提升非常明显。3.3 2D标签的自动生成规则前面说过二维分支要用投影框训练具体生成流程是这样取当前帧的所有真值三维框按类别分组对每个框做投影得到外接矩形过滤掉相机后方的、投影面积过小的、超出图像边界太多的处理遮挡按到相机的距离从近到远排序如果某个框的可见面积去掉被更近的框遮住的部分小于总面积的 20%丢弃这个样本输出成标准二维检测格式第 4 步的遮挡处理比较麻烦我用的近似方法是先按距离排序后画的框覆盖先画的框用掩码算可见比例。粗糙但够用比不做遮挡处理好——不做的话你会得到一堆“只露出一个后视镜的车”的完整框标签检测器学出来全是飘的。生成完标签后一定要做一次统计每个类别的样本数、宽高分布、中心点分布。我当时发现行人样本里 80% 的高度小于 30 像素说明远景行人占了绝对多数于是给二维分支的损失按尺寸做了重加权小目标的权重乘 1.5。这一手让行人类别的 AP 涨了 3 个多点。4. 代码级实现把三个模块串起来4.1 三维候选生成分支三维分支的输出我定成一个字典包含boxesN×7中心点尺寸朝向角、scoresN、labelsN、featuresN×C可选的框级特征。这个契约很重要后面匹配模块只认这个格式换任何 backbone 都不用改下游。torch.no_grad() def generate_proposals(model, batch): preds model(batch) # 热力图 回归量 heatmap, reg preds[hm], preds[reg] scores, index topk(heatmap.sigmoid(), K128) boxes decode_boxes(reg, index) # 中心点 尺寸 朝向 labels index // (H * W) # 类别通道 return {boxes: boxes, scores: scores, labels: labels}解码这一步要注意朝向角的处理。大多数实现用的是sin/cos双通道回归解码时用atan2还原值域是[-π, π]。我在匹配阶段算 IoU 用的是轴对齐的投影外接矩形所以朝向角对结果影响不大但如果你的场景是斜列车位、长条形卡车朝向会显著影响外接矩形的形状这时候解码的精度就很重要了。候选框的尺寸回归我加了exp激活保证正值但要注意做 clamp上限设成 12 米。不加 clamp 的话偶尔会出现尺寸爆炸到 100 米的异常候选投影到图像上就是一个覆盖全图的巨型框把匹配矩阵搅得一团糟。4.2 二维检测分支的接入二维分支我是独立训练的训完导出成 ONNX 或者直接加载权重在推理时以“冻结模块”的形式接入。这样做的好处是两边的优化互不干扰坏处是没法端到端微调。如果你的数据量和算力都充足可以做个轻量的联合微调但我不建议一上来就这么做。推理时的调用接口def run_2d_branch(detector, images, conf_thr0.25): with torch.no_grad(): out detector(images) keep out[scores] conf_thr return { boxes: out[boxes][keep], # M×4, xyxy scores: out[scores][keep], # M labels: out[labels][keep], # M }置信度阈值我设得比较低0.25比常规的 0.4 要松。原因是这里的二维检测不是最终输出它只是给三维候选提供线索多给一些低置信度的检测匹配算法的全局最优性会把噪声自动分配掉因为代价高不会被选上。松阈值还能提升召回实测阈值从 0.4 降到 0.25匹配到的真值目标数涨了 5%而误匹配只涨了 1.2%。一个细节如果一帧图里有多个相机前视、左前、右前等要先按相机视角分组每个三维候选只和它投影落点所在那个相机的检测结果做匹配。不要把所有相机的框混在一起匹配否则同一辆车在前视和左前视里各出现一次会把候选抢来抢去。4.3 匹配矩阵与匈牙利求解这是核心代码我写得很直白from scipy.optimize import linear_sum_assignment import numpy as np def build_cost_matrix(box3d_proj, box3d_labels, det2d_boxes, det2d_labels, num_cls): N, M len(box3d_proj), len(det2d_boxes) iou pairwise_iou(box3d_proj, det2d_boxes) # N×M cls_sim pairwise_cls_sim(box3d_labels, det2d_labels, num_cls) # N×M cost 0.7 * (1 - iou) 0.3 * (1 - cls_sim) # 外扩一圈不匹配通道代价设为 0.65 full np.full((N M, M N), 0.65) full[:N, :M] cost return full def match(full_cost, N, M): row, col linear_sum_assignment(full_cost) pairs [(r, c) for r, c in zip(row, col) if r N and c M] return pairs用“外扩通道”这个技巧比手动过滤简单得多也避免了“每个候选必须匹配”的强制约束。外扩的代价 0.65 对应 IoU 约 0.3低于这个相似度的匹配就没有意义了宁可不匹配。pairwise_iou我是用向量化的方式算的N128、M50 的规模下广播方式大概 1.5 毫秒完全够用。别用循环循环版本会慢 30 倍。pairwise_cls_sim这里有个设计选择三维分支的类别是 softmax 概率分布二维分支的类别也是两者做余弦相似度值域 [0, 1]。但要注意类别数不一致的情况——三维分支可能只有 5 类车、卡车、行人、自行车、其他二维分支可能有 8 类。我做了类别映射二维分支里多出来的类别比如交通锥、红绿灯在参与匹配时映射到“其他”并且把映射后的相似度打个折扣乘 0.5避免它们乱匹配。4.4 分数融合与后处理匹配完成之后对每个匹配上的三维候选更新它的类别和分数def fuse_scores(box3d_scores, box3d_cls_prob, det2d_scores, det2d_cls_prob, alpha0.4): geo det2d_scores ** alpha s3d box3d_scores ** (1 - alpha) fused s3d * geo fused_cls box3d_cls_prob * (1 - alpha) det2d_cls_prob * alpha return fused, fused_cls这里alpha取 0.4意思是融合分数里相机的贡献占 40%。为什么是几何加权平均而不是算术平均因为两个分支的分数标定尺度不一样三维分支的分数普遍偏低尤其是远距离算术平均会把整体置信度拉低导致 NMS 时被误杀。几何平均对低分更宽容实测下来 alpha0.4 时 mAP 最高。未匹配的候选怎么处理我做了两件事分数乘 0.8 衰减让它排在后面类别保持三维分支的预测不变。不直接删掉的原因是有些真值目标确实在图里看不见被遮挡或超出视野它们只能靠 LiDAR 检测全删了就漏检。实测衰减系数 0.8 比直接删除在整体 mAP 上高 4 个点、在远距离区间高 8 个点。后处理最后就是标准的 NMS。这里我给了一个类别相关的阈值车 0.55、卡车 0.6、行人 0.5、自行车 0.45。行人框小、重叠多阈值要松一点车框大可以严一点。统一阈值的效果明显更差我试过统一 0.5行人和自行车的 AP 各掉 2 个点左右。5. 训练策略与调参实录5.1 分阶段训练我把训练拆成了三段每段独立收敛这是我在踩了联合训练不收敛的坑之后定下来的方案。第一段三维分支单独训。用纯点云训到 mAP 不再上涨。这一段我训了 30 个 epoch学习率用 0.003 的余弦退火batch size 4。这一段的目标是把几何回归调准类别精度不重要。第二段二维分支单独训。用 3.3 节生成的投影标签训 40 个 epoch。这一段的学习率从 0.01 开始前 3 个 epoch 用 warmup 线性上升。图像增强只开颜色抖动和随机缩放不做裁剪和翻转因为要做裁剪翻转就得同步改 K容易出错不如不开。第三段联合微调可选。两个分支都加载预训练权重学习率调到 1e-4训 5 个 epoch。这一段的主要目的是让三维分支的分数校准和二维分支的置信度分布更匹配但如我前面所说收益不到 0.5 个点收益小风险大不想折腾可以跳过。实测下来前两段训完直接接匹配模块就能拿到论文报告值的 96% 左右。剩下 4% 主要来自分数校准和阈值精调属于磨时间的活。5.2 关键超参取值逻辑我把几个非标准超参的取值逻辑列出来这部分论文里都没细说全是我自己试出来的超参取值取值逻辑三维候选数 N128256 时速度慢 4 倍、召回只涨 0.4 点三维分数阈值0.05要保证召回宁可多留二维置信阈值0.25低于常规值让匹配算法自己去筛匹配代价权重0.7 / 0.3几何主导类别权重过高会漏行人空匹配代价0.65对应 IoU≈0.3低于此不匹配分数融合 alpha0.4试了 0.2/0.3/0.4/0.50.4 最优未匹配衰减0.80.7 漏检多0.9 误检多投影框最小像素8小于 8 像素的框标注噪声太大这张表里最值得说两个。一个是空匹配代价。这个值本质上是在定义“多相似才算匹配”设太高会误匹配一堆设太低匹配率上不去。我做了一次扫描从 0.4 到 0.8 每 0.05 一档跑验证集最优在 0.65而且在 0.6 到 0.7 之间非常平坦说明这个参数不敏感可以放心用。另一个是未匹配衰减这个值直接决定了你对相机的信任程度。跑山区道路场景时相机视野里几乎没有目标衰减设 0.8 明显更优跑城市密集场景衰减设 0.9 更好。所以它是场景相关的别指望一个值通吃。5.3 指标曲线解读与现象归因训练过程中有几个典型现象看懂它们能帮你快速判断问题所在。现象一匹配率在第一个 epoch 后稳定在 60% 左右再怎么训都不涨。我一开始以为模型欠拟合加大训练量没用。实际上是二维分支的置信度阈值设高了导致参与匹配的检测框太少。把阈值从 0.4 降到 0.25匹配率跳到 76%。现象二验证集 mAP 在 20 轮后开始掉训练集还在涨。这是典型的过拟合但加 dropout 和 weight decay 效果都不明显。后来发现原因是三维分支的候选数固定为 128而训练后期大量候选集中在前景背景候选几乎被挤没了导致匹配阶段的“不匹配”通路没有足够的负样本训练。解决办法是给候选生成加了一个按分数分层采样的逻辑前 96 个按分数取后 32 个从低分区随机采样。加上之后过拟合明显缓解。现象三整体 mAP 涨了但行人类别掉了。这个前面提过是类别权重给太高导致匹配偏车。把匹配代价里的类别权重从 0.5 降到 0.3 就解决了。现象四所有指标都正常但可视化时发现框的位置整体偏了几十厘米。这个最坑通常是内外参的某个环节差了常数偏移。我遇到过一次是相机光心高度记错了 5 厘米导致所有投影框整体下移。这种问题靠指标是看不出来的只能靠可视化所以一定要养成定期看投影图的习惯。6. 常见问题与排查速查6.1 匹配率异常类问题匹配率是这套方案最核心的观测指标正常情况下应该稳定在 70%~85%。低于 60% 一定是某个环节出了问题按下面这个顺序排查最快现象大概率原因排查手段匹配率整体极低30%坐标系手性错误或矩阵求逆方向反了把相机光心投到自车坐标系看坐标位置匹配率中等40%~60%二维标签用了人工标注而非投影框统计标签框宽高分布投影框普遍更大匹配率随增强随机波动图像增强没同步到内参关掉几何增强对比特定相机匹配率低该相机外参标定偏差单独可视化该相机的投影结果匹配率高但 mAP 低匹配对了但分数融合权重错扫描 alpha 参数我遇到过最迷惑的一次是匹配率 78%看着很正常但 mAP 比纯 LiDAR 还低。查了很久才发现是分数融合把好框的分数拉低了导致 NMS 阶段被别的框压掉。原因是我当时用了算术平均而不是几何平均。这个教训是匹配率只反映关联对不对不反映结果好不好两个指标要一起看。6.2 显存与推理速度优化这套方案在推理时是串行执行的先跑三维分支再跑二维分支最后做匹配。三段耗时大概是 35ms : 20ms : 8ms单帧单卡。如果你的实时性要求高有几个立竿见影的优化并行化两个分支。三维分支和二维分支之间没有数据依赖完全可以放进两个 CUDA stream 并行跑。改造之后总耗时从 63ms 降到 42ms收益很明显。代价是显存占用会上升因为两个分支的中间激活同时驻留。降低二维分支的输入分辨率。640 降到 512速度提升 40%mAP 掉 0.6 个点。这个取舍看你的场景如果目标都比较大高速场景512 够了。候选数动态调整。空旷路段候选数可以压到 64密集路段才需要 128。用一个简单的密度估计点云点数或者热力图响应总和做判断平均耗时能再降 15%。匹配阶段用 GPU 实现。匈牙利算法本身是 CPU 的但我把代价矩阵的构造IoU 和类别相似度放到 GPU 上算只把最终的小矩阵传到 CPU 求解可以省掉 3~4 毫秒的数据搬运。显存方面最吃紧的是联合训练阶段。除了梯度检查点还有个办法是把二维分支的参数冻结只做前向不存激活显存能省下 6G 左右。6.3 精度不达预期的归因路径如果你复现完了指标离论文还差几个点按这个顺序查先查二维标签质量。把生成的投影标签画到图上随机看 30 张。重点看三种情况被遮挡的车标签框是不是过大、图像边缘的目标框是不是被裁得只剩一条边、远处的小目标框是不是完全塌成一个点。这三种情况都会严重拖累二维分支的精度进而拖累整体。再查匹配的类别映射。如果你的数据集类别和二维检测器的预训练类别对不齐映射逻辑很容易出错。我曾经把“厢式货车”映射到了“卡车”结果匹配时相似度全部打对折虽然匹配率高但融合后的类别置信度很平最终 mAP 掉 3 个点。然后查分数校准。三维分支和二维分支的分数分布通常差得很远一个均值 0.2、一个均值 0.6 都很常见。不校准直接融合等于让分数高的那个分支单方面决定结果。做好温度缩放之后融合的收益会明显提升。校准的方法很简单在验证集上收集所有匹配对的分数画直方图看两个分布的均值和中位数用温度参数把三维分支拉过去。最后查 NMS 阈值。前面说过按类别设不同阈值。这一步收益不算大但很稳一般能捡回 1 个点左右。6.4 我在整个复现过程中踩过的坑挑几个最有代表性的说都是那种“文档里不会写、但一定会遇到”的。第一个坑把二维检测的框拿去算 IoU 时忘了三维框投影的框是“完整包络框”。结果是所有被遮挡的目标都匹配失败。解决方法是给投影框和二维检测框都做一次“可见性加权”或者干脆对遮挡严重的场景降低匹配的 IoU 阈值。我用的是后者的简化版如果三维框投影后的可见面积占比低于 50%匹配阈值从 0.3 降到 0.2。第二个坑多帧点云累积时sweep 的时间戳没对齐导致候选框位置整体滞后半个车身。这个在静止场景看不出来一跑动态场景就暴露。解决办法是给 sweep 做运动补偿用自车 pose 把历史帧对齐到当前帧坐标系。如果不做补偿干脆别用 sweep只吃关键帧虽然召回会掉 2 个点但至少结果是准的。第三个坑二维检测器的输入图像用了不同的缩放策略letterbox vs 直接 resize导致检测框坐标映射回原图时出现系统性偏移。这个偏移在图像中心很小在边缘能到 20 个像素。letterbox 的坐标映射一定要把 padding 量减掉公式写错了很难通过指标发现只能靠可视化。第四个坑训练用的类别权重和评测用的类别权重不一致。我在训练时给行人加了权重但评测没加导致模型偏向行人、车的 AP 掉了。这种问题纯属自己制造的混乱最后靠统一配置管理解决——所有类别相关的配置集中到一个 yaml 里训练和评测都从那儿读。7. 最后说几句实在的体会这套东西我前后折腾了一个半月真正写代码的时间大概只占三分之一剩下的全耗在坐标系统一、标签域对齐和参数调试上。DAL 这个思路本身的实现难度不高难的是把数据链路理顺——投影矩阵、内参增强、坐标系手性、时间戳补偿每一项单独看都不复杂串起来就很容易出问题而且出了问题往往不报错只是指标莫名其妙上不去。如果你准备复现我的建议是先别急着跑训练花两天时间把投影链路彻底打通写个可视化脚本把每帧的真值三维框投到图像上叠着看看够 200 张。这两天的投入能帮你省掉后面两周的问题定位。另外二维分支不要自己造轮子用成熟的开源检测器把精力放在匹配模块和分数融合上那才是这套方案真正有价值的地方。最后一个小心得把匹配率、匹配 IoU 分布、分数分布这几个中间指标全都记录到日志里每次改动都对比着看。这套方案的可调参数多不记录中间指标的话你很容易陷入“改了也不知道为什么好/为什么坏”的状态。我从第三天开始加日志之后就再没走过回头路。