做激光雷达3D目标检测这几年,我最大的感受是:公开测试集上的高mAP都是“虚的”,遮挡一上来,所有模型都会现原形。
不光是行人被前车挡成半截这种视觉意义上的遮挡,更麻烦的是点云这种主动传感器绕不过去的物理限制——激光束打不到被挡住的表面,那就是打不到,模型再怎么学,输入里就是没有那一块信息。CVPR2022上有一篇关于遮挡attribution maps的研究工作,正好切中了我一直以来的痛点:它把激光雷达3D目标检测器在面对遮挡目标时“哪里被影响、影响有多大”这个问题,从一句经验性的“遮挡确实影响性能”,变成了一张张可量化的归因图。这篇博文我会从论文的核心思路讲到我的复现过程,再聊工程落地时踩过的坑和细节,全程干货,适合做自动驾驶感知算法、点云3D检测以及模型可解释性方向的同学参考。
1. 遮挡为什么会击穿激光雷达检测器
1.1 点云的结构性残缺
激光雷达的工作原理大家都清楚,发射激光束、接收返回的反射点,靠测量时间差或者相位差来计算距离。这套机制决定了它是“视线传播”的:只有光线能照到的表面才会产生点云,被挡住的区域永远不会有任何采样点。
有人可能会觉得,这不就是少了一些点嘛,信息不完整而已,影响应该有限。但真实情况比“少点”要严重得多,因为点云缺失不是随机的,而是结构性的。所谓结构性,就是缺失的方向和遮挡物的空间位置强相关。一辆车从左侧被公交车挡住,那么它左侧一整片表面完全消失,剩下的点云集中在右侧轮廓和车尾;一个行人被前方货车遮挡,只剩下上半身和伸出来的手臂,在鸟瞰视角下看起来像一根折断的线条。
这种结构性缺失对检测器的影响,不只是信息量减少,而是引入了系统性的形状偏差。模型在训练阶段看到的绝大多数目标都是完整的矩形BEV轮廓,到了推理阶段遇到被遮挡目标,看到的是“扇形残片”或“半边轮廓”。卷积核在提取局部特征时,对残缺模板的响应模式和对完整模板的响应模式差别很大,置信度自然就掉下来了。我在实际项目里做过统计,KITTI数据中完全不遮挡的样本比例其实不低,但真实城市道路中,目标在大多数生命周期内都处于被部分遮挡状态。训练分布和推理分布一错位,模型能力再强也没有发挥空间。
1.2 BEV特征层面的“语义黑洞”
现在主流的激光雷达3D检测器,PointPillars、CenterPoint、SECOND、PV-RCNN这些,基本都会把原始点云转换成BEV视角的俯视图特征。转换方式可能不同,有的用pillar聚合,有的用体素特征投影,但最终都会落到一个类图像化的特征图上,然后再接卷积骨干网络做检测。
在BEV特征图上,一个完整小车的响应是一个紧密的矩形区域,边缘清晰,形状规整。但是当目标被遮挡时,它的BEV响应区域会“缺角”,形状变得不对称。目标中心如果恰好被遮挡物覆盖,CenterPoint这类基于中心点的检测器会直接把中心热图预测偏掉,最后一阶段的边界框回归自然也会跟着错。
我把这个现象叫“语义黑洞”:被遮挡目标在BEV特征图上不是简单变暗,而是变出一种“有方向的缺损模式”。网络在训练中学过的是各种完整形状的模板,遇到这种缺损模式时,很容易把它误判成远处小目标、异形目标甚至背景噪点。用一个不太严谨但很好懂的类比:完整矩形是成年人脸的正面照,左侧遮挡目标相当于只拍到侧脸。人可以通过轮廓和常识脑补出整张脸,但当前数据驱动下的检测器“脑补”能力很差,它更倾向于把侧脸当成另一个物种。归因图的意义,就是把“侧脸区域被遮挡导致判错”这件事,逐位置地标出来。
1.3 遮挡分级与评测盲区
做数据标注时,遮挡是有明确等级的。以KITTI为例,occlusion字段分0、1、2、3四档:0是完全可见,1是目标被挡住一部分但主要形状还在,2是大部分被遮挡,3是严重遮挡或者标注者都很难判断。Waymo数据集里也有类似的遮挡维度,nuScenes则额外标注了每帧里目标的可见比例。
绝大多数论文评估mAP时,只按Easy、Moderate、Hard三个难度级别来报数据。但这里有个问题:Hard档位里不仅包含遮挡最重的样本,还叠加了目标尺寸小、距离远、点云稀疏等因素。也就是说,一个模型在Hard档位性能下降,你很难说清楚到底是遮挡导致的,还是小目标检测能力本身就弱。归因图的出现,为解耦这些因素提供了一把钥匙——它能在特征层面指出特定目标上哪些区域对检测决策贡献最大,哪些区域的缺失让分类得分一落千丈,从而把“遮挡的影响”从一堆混杂因素里单独提取出来分析。
2. 归因图原理与3D检测中的计算要点
2.1 为什么用归因图分析遮挡
先把归因图这个概念说清楚。给定一个训练好的检测模型f,输入某个样本x,模型输出某个目标类别(比如“Car”)的置信度得分。归因图就是一张和输入或中间特征图尺寸完全相同的权重图,图中每个位置的数值,表示这个位置对最终类别得分贡献了多少。数值越大,说明模型在这个位置“看到”的信息越重要。
那有人会问,分析遮挡为什么不用更朴素的方法?比如把某个区域的点云直接删除,看检测得分变化多少;或者用LIME这类方法对输入加随机扰动,统计哪些扰动影响最大。这些方法理论上是可行的,但在点云3D检测上有两个绕不开的麻烦。第一,点云输入是稀疏高维数据,体素化之后一个目标区域也有成百上千个有效位置,想在原始点云级别做mask采样,计算开销非常大。第二,随机扰动同时动了一堆雷达回波点,你很难定位到具体是哪一块点云残片导致了检测失败。
梯度类归因方法就没有这个问题。它在特征层面逐位置计算梯度,可以直接告诉你“哪些格子对类别得分贡献最大”。积分梯度又是梯度类方法里最稳妥的选择,因为单纯取梯度会碰到梯度饱和问题——当一个位置的响应已经很强时,继续增加它的值对得分提升很小,梯度看起来反而不高。积分梯度从全零基线出发,沿着特征从基线到实际值的直线路径做积分,把路径上每一个点的梯度都累加起来,这样既避免饱和,又满足敏感性和实现不变性这些公理化的要求。
2.2 积分梯度的核心实现与参数选择
积分梯度的数学形式很简洁:
Attr_i(x) = (x_i - x'_i) × ∫₀¹ ∂F(x' + α(x - x')) / ∂x_i dα
其中x是当前样本的BEV特征图,x'是全零基线,α是从0到1的插值系数,F是目标类别得分。实际操作里,积分用Riemann和来近似,也就是把α从0到1切成m段,每一段都算一次梯度,最后取平均再乘以特征差。伪代码是这样:
# 以BEV特征图 bev_feature 为输入,计算类别 class_idx 的积分梯度归因图 def compute_attribution(model, bev_feature, class_idx, steps=50): baseline = torch.zeros_like(bev_feature) # 全零基线 grad_accum = None for alpha in torch.linspace(0, 1, steps): scaled = baseline + alpha * (bev_feature - baseline) scaled.requires_grad_(True) score = model.bev_head(scaled)[class_idx] # 取类别 logit grad = torch.autograd.grad(score, scaled, retain_graph=True)[0] if grad_accum is None: grad_accum = grad else: grad_accum += grad avg_grad = grad_accum / steps attr = (bev_feature - baseline) * avg_grad return attr这里的m(steps)是核心参数。我在复现时先按默认的50步跑,发现归因图在细节区域有抖动,提高到100步后稳定了很多。再往上升到200步,稳定性提升有限,但整张图的归因计算成本翻倍。所以我最终定在100步左右,兼顾精度和速度。还有一个容易被忽略的点:基线选择。理论上全零基线符合公式推导,但在稀疏BEV特征图里,很多位置本身就是0,直接乘上去会让归因值集中到非零区域,这没问题。可如果模型对全零基线输出一个偏置很大的logit,会导致归因图整体偏移。遇到这种情况可以单独做一次基线输出校准,把所有梯度都减去基线的梯度分量。
2.3 在哪个特征层计算归因图
论文里归因图的计算位置不是原始点云,而是检测器中间的BEV特征图。这个选择是有道理的。点云体素特征经过聚合和投影之后,信息密度比较高,遮挡造成的模式缺损在BEV上非常明显;同时梯度可以干净地从检测头反传到BEV特征图,不需要处理稀疏卷积里复杂的索引关系;而最终检测框的回归也是从BEV特征图出发的,所以这一层和检测结果之间有明确的因果链。
具体到代码层面,需要找到模型里“BEV特征图输出层”。在OpenPCDet里,SECOND类模型通常是spconv特征金字塔输出,PointPillars是pillar scatter之后由2D骨干网络输出的特征图。这里有个非常影响结果的细节:我在CenterPoint上复现时,一开始偷懒直接对总loss做梯度,结果归因图被box回归分支压住了,看起来一团糟。后来改成对分类分支的logit做梯度,整个归因图才变得可解释。背后的逻辑是清晰的,我们想分析的是“检测器为什么认为这里有个目标”,而不是“检测器的框拟合得好不好”,所以梯度一定要来自分类决策,而不是回归损失。
2.4 遮挡目标在归因图上的三种典型形态
我把复现中反复观察到的可视化结果归成三类,虽然没有写进论文,但对指导实验很有用。
第一种是“边缘高亮型”。目标被一侧遮挡,归因图在未被遮挡的那一侧边缘出现高亮值,遮挡侧数值极低。这说明检测器在主要依靠残存边缘识别目标,边缘信息被“放大”了。第二种是“中心坍缩型”。目标中心区域被遮挡,整个对象在BEV上变成一个C形,归因图在C形两端有响应但中心塌陷,卷积感受野往往会把这个C形特征误判成两个独立的小目标。第三种是“完全消失型”。遮挡率达到七成以上时,目标在BEV特征图上只剩下极少数格子有响应,归因值整体接近零,检测器基本把这个位置当成背景噪点。
这三种形态给了一个很重要的工程启发:遮挡对检测器的影响,和目标“语义重心”的位置强相关。如果能够通过归因图知道哪些位置是检测器决策的关键组成部分,那么在这些部位丢失时,就可以做出针对性补偿或置信度惩罚,而不是无脑要求检测器在所有情况下都输出高置信度的边界框。
3. 核心方法:用遮挡归因图反哺检测器
3.1 归因指导的遮挡感知训练
论文最有价值的点,不是画几张归因图给研究员看,而是把归因图当作监督信号,去指导模型学习“遮挡情况下应该如何响应”。整个训练流程可以拆成三步。
第一步,先训练一个基线检测器,然后收集带遮挡标注的训练样本,计算每个样本的归因图,得到各类目标的关键区域mask。这一步的产物是一张空间注意力权重图,图上高亮的位置就是该目标“决定成败”的部件。第二步,用这个mask去加权特征级别的损失函数,让网络在训练时更关注那些“虽然被遮挡但语义关键的区域”。最常见的实现是在BEV坐标系下取目标对应的矩形区域,用归一化后的归因值作为空间权重,叠加到特征损失上。第三步,在训练损失中显式加入一个归因图正则项,约束模型在特征学习阶段就形成遮挡鲁棒性。
这里有个我踩过的坑:直接对特征图做强约束容易过拟合。尤其是在小数据集上训练时,如果将归因图mask的权重设得过大,模型会强行记忆训练集里固定的遮挡模式,泛化能力反而下降。我实测下来,归因正则项系数放在主损失的0.3到0.5之间比较合适。超过0.8,训练集mAP确实上去了,但验证集mAP一路下跌,典型过拟合信号。另外一点,归因正则项必须和足够强的数据增强配合使用,否则网络学到的只是“关注这个固定区域”,而不是“关注那些真正重要的语义部位”。
3.2 从归因图生成遮挡感知的数据增强
另一个非常实用的落地方向是数据增强。传统3D检测的数据增强就是全局旋转、翻转、缩放、加噪点,这些操作解决不了结构性遮挡问题,因为遮挡不是全局几何变换能模拟出来的。
用归因图做数据增强的思路很直接:先从训练集里挑出遮挡较重的样本,计算遮挡归因图,然后基于归因图生成一个“遮挡掩码”,把这个掩码作用到完整目标的点云上,模拟真实遮挡。具体操作时,我会对完整目标点云计算当前检测器对该目标的归因图,然后在目标区域内按归因值从高到低排序,把前20%到30%的高值位置对应点云隐藏或替换为噪点。这样生成的新样本,在特征层面的缺损方式和真实遮挡高度一致,模型训练时就相当于学会了“即使态度最强烈的部位被挡住,也要继续输出正确检测”。
这一招也是我觉得整篇工作最适合直接抄作业的地方。很多论文的思路是把网络结构改得越来越复杂,但这个方法利用归因图把“最难的点”变成了“额外的训练样本”,算是在方案选择上的一个聪明点。对我个人而言,它不需要改任何网络结构,只需要在数据加载管线里加一个“按归因值隐藏点云”的预处理,就能在不大动干戈的情况下提升Hard档位mAP。
3.3 推理阶段的遮挡鲁棒性策略
训练阶段能不能改,往往不是工程团队能决定的,因为重新训练几周的成本太高。但推理阶段加一个轻量级归因计算,是完全可行的。
最实用的做法是“遮挡感知的置信度修正”。推理时先跑一遍检测器拿到一堆候选框,只对top-k个检测框对应的BEV区域计算一次轻量级归因图。然后统计这个区域内有效归因值的覆盖比例。如果覆盖比例高,说明检测器找到了很多可信的特征依据,置信度可以适当上调;如果覆盖比例极低,说明检测器可能是在靠一丝残缺信号硬猜,置信度就应该下调。这相当于在不动网络结构的前提下,把实际部署中的precision-recall曲线重新拉一拉。实测下来,这个技巧对大目标遮挡场景尤其有效,因为大目标的BEV区域比较大,归因图覆盖比例的区分度更高。
当然这样一个基于阈值调整的方案需要单独在验证集上标定一组系数,避免误伤无遮挡目标。我在做实验时会在校准集上按遮挡等级分别统计覆盖比例和真实标签的关系,画出一条映射曲线再做修正,而不是简单地拍脑袋定一个阈值。
4. 复现过程中的实操记录
4.1 环境与框架怎么选
想复现这类带归因图分析的研究,框架选择很重要。我用的是OpenPCDet,它有SECOND、PointPillars、CenterPoint的现成实现,代码结构清晰,方便在中间层挂hook拿特征图。环境配置我整理了一张表,都是基于我的实测经验:
| 组件 | 推荐配置 | 备注 |
|---|---|---|
| 系统 | Ubuntu 20.04 | Windows上跑spconv问题太多,不建议折磨自己 |
| GPU | V100 / A100 / RTX 3090 | 显存低于16G做整图归因会比较吃力 |
| CUDA | 11.3 | spconv 2.x对CUDA版本敏感,换了版本容易出编译问题 |
| PyTorch | 1.10 / 1.12 | 版本太高会和spconv有ABI兼容问题 |
| spconv | 2.1.23+ | 编译时注意gcc版本,否则运行时报segmentation fault |
| 数据集 | 先KITTI再nuScenes/Waymo | KITTI量小迭代快,跑通逻辑再上大数据 |
我强烈建议先在KITTI数据上把归因计算逻辑整个跑通,再去碰Waymo。KITTI的标注规模小、难度分级明确,用来调试归因图和训练策略效率很高。Waymo的激光雷达配置更复杂,多激光雷达融合后遮挡模式也更复杂,直接上手会同时引入定位误差和数据分布变化两个变量,出了问题很难判断到底归因逻辑错了还是数据处理错了。
4.2 归因图计算的核心实现细节
归因图计算本身需要同时对模型做前向和反向传播。如果对每个样本、每个检测框都全量计算,开销惊人。我的实现流程是这样的。
第一步,前向拿到BEV特征图,shape一般是(B, C, H, W),例如(B, 128, 1000, 1000),语义上一格对应0.1米的物理空间。第二步,选定目标检测框对应的BEV区域,用这个区域的分类分支logit作为计算目标。第三步,通过autograd.grad从该logit反传到BEV特征图,得到区域梯度。第四步,沿积分路径重复50到100次,累加得归因图。第五步,把归因图resize到原始点云尺寸或体素网格尺寸,供可视化或掩码生成使用。
有一个极其容易翻车的地方是坐标映射。BEV特征图的H和W与实际空间坐标要严格对齐。以输入范围[-50m, 50m]、体素边长0.1m为例,一个位于(10m, 20m)的目标,对应的BEV网格索引应该是((10+50)/0.1, (20+50)/0.1) = (600, 700)。我一开始直接拿检测框的归一化坐标去算,结果归因图错位了一大截,可视化出来怎么看怎么不对劲。这类坐标换算最好写一个独立工具函数,用已知位置的框做几次单测,确认无误后再大规模跑。
再说性能优化。积分梯度要50次前向加反向,对大分辨率BEV特征图来说显存和时间都很紧张。我的做法是对BEV特征图先做一次RoI裁剪,只保留目标周围一定范围内的局部特征,再做积分路径。这一步能把计算量削掉八成以上。论文里为完整性做全图归因没问题,但工程复现强烈建议RoI局部化。
4.3 遮挡标注与分层评估配置
做遮挡归因实验,离不开遮挡标注。KITTI的occlusion字段天然就是实验依据,分层评估时我建议这样分组:
| 分组 | 对应遮挡等级 | 主要观察指标 |
|---|---|---|
| Easy / 无遮挡 | occlusion=0 | mAP@0.7 IoU |
| Moderate / 部分遮挡 | occlusion=1 | mAP@0.7 IoU + 定位偏移统计 |
| Hard / 大部分遮挡 | occlusion=2 | mAP@0.7 IoU + 召回率 |
为什么强调分层评估?因为如果只看整体mAP,大量无遮挡样本会把遮挡改进量稀释掉,看起来好像方法有效但又不是特别有效。但如果你把Easy和Hard拆开看,往往会发现Hard档位提升了三四个点,Easy档位反而掉了一两个点。这个现象本身是有价值的结论:遮挡感知训练本质上是在抑制模型对残缺目标的“过度自信”,对完整目标会有一点微弱的副作用。复现论文或者评估自己方案时,一定要分层看,不要被整体指标骗了。
5. 常见问题与排查技巧实录
5.1 归因图全是雪花噪声
这是我最常被问到的现象。归因图可视化之后一片密密麻麻的噪声点,根本看不出来哪里有结构。原因基本有三个方向。
第一个是积分梯度步数太少,近似误差太大,先把steps提到50以上再观察。第二个是BEV特征图里有大量接近零的特征值,基线附近的梯度变化剧烈,可以先对特征做归一化,或者对梯度取绝对值后再做平滑。第三个是目标类别得分本身已经接近零,说明模型压根没觉得这里是目标,此时归因值自然发散。这种时候应该换一个更容易激活的类别分支,或者用softmax后的概率而不是原始logits来计算梯度。
5.2 换了检测器架构归因图不可解释
在PointPillars上复现得很顺利,换到SECOND或者PV-RCNN上就乱套,这是很多同学会遇到的第二个问题。原因在检测器结构差异:SECOND的BEV特征由稀疏体素特征投影而来,特征通道多但空间范围有限;PointPillars的BEV特征是pillar聚合后的密集特征,更接近2D语义分割的输入;PV-RCNN则走了voxel-set abstraction路线,BEV特征图并不是唯一的中间表示。
所以直接在某一个框架里写好的归因代码,换了框架大概率要重新设计hook位置。我建议的做法是先把模型结构图完整画出来,确认BEV特征到底在哪一层产生、哪一层是后续检测头的唯一输入,再对接归因计算。比直接照搬前一个框架的代码要省时间。
5.3 加了归因正则项mAP反而下降
这个情况我遇到过,而且当时差点把整个方法否掉。最后排查出来主要是两类原因。
第一类原因是正则项权重过大。一开始我直接把权重设成1.0,结果模型快速过拟合到训练集里的遮挡模式,验证集mAP不升反降。把权重降到0.3到0.5之后才稳住。第二类原因是BN统计被污染。计算归因图时如果模型处于train模式,梯度反传会连带着更新BN统计量,造成训练震荡。正确做法是归因图计算使用torch.no_grad()做推理,只在数据采样阶段使用,不影响主训练的反传。
5.4 多卡训练显存爆炸
多卡并行训练时,如果每一张卡还要额外计算归因图,显存很容易翻倍。我的解决方式有两种。一是把归因图计算和正常训练解耦,开一个单独进程或者单独用一张卡做归因图生成,结果缓存下来供训练采样使用。二是在每个epoch的前10%迭代里集中计算一次归因图,缓存复用,而不是每个样本都重新算。归因图本质上是一个“数据分析工具”,用来发现模型对遮挡敏感的区域,然后把结论固化成训练策略。没必要做成每步都跑的重模块。
6. 从论文到工程:遮挡归因图的落地空间
6.1 数据闭环中的“困难样本筛选器”
做数据闭环的朋友应该对这个场景很熟悉:路采数据大量回传,但真正有价值的困难场景少之又少。如果纯靠人工看,效率低,主观性也大。用归因图可以做一个自动化筛选器。
具体做法是,对回传数据里每个检测目标计算归因图,统计有效归因覆盖率。覆盖率很低的样本,意味着模型在“没有根据”的情况下输出了检测结果,要么是遮挡太严重,要么是标注本身就有问题。把这类样本挑出来给标注团队重新复核,数据质量会明显提升。我在实际项目中用这个方法筛过一批遮挡严重的夜间样本,挑出来重标注之后,模型再训练的时候Hard档位收益非常明显。
6.2 设计一套遮挡感知的评测指标
既然归因图能描述“模型依据什么做出决策”,那就可以围绕它设计一套新的评测指标。传统mAP只评价检测结果对不对,没有解释“为什么不对”。而用归因图可以计算一个“决策可靠性”得分:一个正确检测框的有效归因覆盖率越高,说明这个检测越可靠;一个错误检测框的归因覆盖率也很高,那就说明模型被某块强特征误导了。
这个指标在评测集上和人工经验的相关性相当高。我自己的团队后来在挑选模型版本时,不再只看整体mAP,而是增加了一个“Hard遮挡决策可靠性”指标。两个版本mAP接近,但归因可靠性高的那个版本,在实际道路测试中的假正例明显更少。这个方向我认为后续还有不少扩展空间。
6.3 轻量级在线遮挡估计的尝试
归因图计算成本高,但它的思想可以“降维”成一个轻量级在线模块。训练好后的检测器,在推理时对top-k检测框做一次快速归因,可以估计出每个框的遮挡严重程度。这个估计值不参与训练,只参与后处理。
比如在前面讲的置信度修正之外,还可以根据遮挡程度动态调整NMS阈值。严重遮挡的目标框置信度普遍偏低,在NMS阶段很容易被相邻高分框压制掉。如果把遮挡估计和置信度联合起来决定NMS保留策略,可以在不明显增加误检的情况下多找回几个被遮挡的漏检框。这个思路我试过,在拥堵场景下对车目标的小幅召回提升有效,但要仔细调整阈值,否则误检也会跟着涨。
最后再分享一个个人体会
归因图在3D检测里不只是分析工具,它最大价值在于把“模型为什么漏检这个目标”从直觉变成可量化指标。我在实际应用里最常做的事,是用归因图快速筛选训练数据中那些关键区域覆盖不足的样本,挑出来给标注团队复核,数据闭环里这一步带来的收益比单纯调模型参数更明显。如果你也在被激光雷达检测器的遮挡问题困扰,我的建议是先别急着换模型结构,把归因图算出来看看哪些目标在“瞎猜”,再决定训练策略往哪个方向改。踩过几次坑之后你会发现,很多以为是模型能力的问题,其实在数据层面就已经错了。