Sparse4Dv3 这个项目,在自动驾驶纯视觉3D检测这个圈子里,算是最近绕不开的一个模型。如果你关注过BEVFormer、DETR3D这些方案,那你大概率也听说过它——它走的是另一条路,不用BEV特征,不依赖高精地图,直接在前视多视角图像上做稀疏查询,照样能拿到第一梯队的效果。光凭这一点,就值得专门花时间把它的代码吃透。
这篇内容我打算从源码结构、核心模块、训练复现和排坑记录四个维度来拆,全程以我实际跑通代码的视角来讲。适合正在复现Sparse4Dv3、或者想从零理解稀疏感知方案的读者。如果你只是听说过这个模型、还没上手,我也会从主干思路讲起,保证你能跟上节奏。
1. 整体设计与工程实现思路
Sparse4Dv3 全称是 Sparse4D v3,核心贡献一句话就能说清:在两阶段稀疏检测头的基础上,用解耦的注意力模块和多视角/多帧特征融合,把纯视觉3D检测的精度和收敛速度都拉上来了。复现它的意义不只是拿到一个跑得通的代码仓库,更重要的是理解它的设计取舍。
1.1 核心需求解析:为什么要选 Sparse4Dv3 来复现
先说结论:在 NuScenes 纯视觉3D检测这个榜单上,Sparse4Dv3 是少数没有用BEV、也没有用point cloud方案,只靠2D图像特征就能和BEV方案掰手腕的模型。对于工程落地来说,这有非常现实的意义。
BEV方案通常需要显式构造BEV特征地图,对多视角图像的几何对齐要求高,而且网格分辨率直接决定了计算量和内存占用。Sparse4Dv3 的思路是绕开BEV构造,直接在3D空间撒一组稀疏anchor,由模型去预测每个anchor的位置、尺寸、朝向和速度,再用第二阶段的refinement把头绪理清楚。这样有几个好处:不需要显式的BEV特征,节省计算量;不依赖高精地图,泛化性更强;稀疏查询天然适配长尾场景,遮挡和远距离目标也能有稳定的响应。
具体到我个人复现的体验,Sparse4Dv3 最让我注意的是它的收敛速度。相比某些需要大量trick才能训动的方案,它的两阶段设计让每个检测头只负责明确的任务,训练稳定性好很多,这在复现时省了不少调参的时间。
1.2 代码结构总览:从入口到前向传播的关键链路
拿到源码仓库后,第一件事先把目录结构理清楚。Sparse4Dv3 的代码基于 mmdetection3d 开发,整体风格比较规整。核心模块集中在 projects 目录下,其中 Sparse4D 子目录里是主要实现,我把关键文件列出来:
projects/ ├── Sparse4D/ │ ├── __init__.py │ ├── sparse4d.py # Sparse4D 主检测器,多帧时序融合的入口 │ ├── sparse4d_head.py # 两阶段稀疏检测头,anchor生成 + 预测 │ ├── sparse4d_v3.py # v3版本简约实现,融合了v3的改进点 │ ├── sparse4d_transformer.py # 解耦注意力模块,v3的精华 │ ├── dense_head.py # DETR3D风格的第二阶段解码器 │ ├── losses.py # 分类/回归损失和张量维度配对 │ └── structures.py # anchor、3D框、损失匹配等数据结构入口方面,Sparse4D 检测器接收的输入是6个相机的图像和内外参,输出的是3D边界框、类别、速度和属性信息。前向传播的链路是:先从主干网络和Neck拿到多尺度特征图,然后送入 Sparse4DTransformer 做特征采样,采样到的特征再输入两阶段的解码头,其中第一阶段的anchor先做粗回归和分类,第二阶段的refinement再做精修。整个流程最值得细细读的部分是 transformer 里各模块的输入输出维度,如果这里没对齐,后面训练很容易出现 NaN 或者不收敛。
1.3 技术选型背后的为什么:解耦注意力比统一注意力强在哪
Sparse4Dv3 在 transformer 里用了两个解耦的注意力模块:一个负责多视角交叉注意力,一个负责时序自注意力。如果你看过 DETR3D 的实现,会知道它是在每个3D reference point上直接做交叉注意力,特征采样用双线性插值,把所有视角拉在一起处理。Sparse4Dv3 把这一过程拆开了,先按时间维度,再按空间维度,分别做注意力,降低模块耦合度,也让每个注意力的感受野更明确。
我当时看代码的时候有个疑问:为什么不直接复用 DETR3D 的单层结构?后来观察 loss 曲线才意识到,解耦之后的注意力更容易训。多视角交叉注意力只负责找空间上的对应特征,时序自注意力只负责融合帧间的跟踪信息,两套模块的监督信号不打架,收敛自然更快。如果你想把模型改成单目输入,或者把摄像头数量从6路改成8路,解耦结构改起来也更从容,不需要牵一发动全身。
2. 核心细节解析与代码走读
这个章节进入正题,我只挑影响模型性能、并且复现时最容易出错的部分拆解。每一段都是我从源码中一行行读完后的理解,附上必要的参数说明和公式推导。
2.1 Anchor 设计与位置编码的配合
Sparse4Dv3 的 anchor 是在3D空间中预设的一组点集,默认配置是 41x128,也就是高度方向41个位置、每层128个方位角。每个 anchor 都包含 x、y、z、yaw 的初始值。代码里对应的生成逻辑在 structures.py 的 Sparse4DAnchor 类中,它会结合感知范围(比如 x 在[-60m, 60m],y 在[-30m, 30m])生成锚点的3D坐标。
为什么是 41x128?这个可以算一下角分辨率:360度除以128,大约2.8度一个角度槽,配上41层高度,覆盖了常见车辆的高度范围。anchor 的目标不是直接输出精确框,而是给后面两阶段检测头一个靠谱的起点,所以不需要太密。太密会浪费计算量,太稀会导致小物体召回不够。41x128是我认为性能和算力的平衡点,实际工程里如果你处理的场景主要是高速路,可以适当减少高度层,比如改成25层,小目标密集的城区场景再增加角度密度。
位置编码上,Sparse4DTransformer 会对 anchor 的3D坐标做正弦位置编码,然后和类别嵌入、帧索引编码拼接在一起,作为 transformer 的查询向量。这个拼接发生在每个注意力模块之前,是保证模型知道“我在查询哪个位置、哪个时刻”的关键。我复现时试着去掉位置编码只保留内容特征,结果mAP掉了3个点以上,这个部分务必保留。
2.2 解耦的注意力模块到底做了什么
Sparse4Dv3 的 transformer 核心是两个顺序执行的注意力:先时序后空间,我表述为“时序自注意力 + 视角交叉注意力”,两者的拼接顺序在代码里是固定的。
时序自注意力是在同一视角、不同帧之间做的。代码里会维护一个长度为 T(默认训练时用4帧、测试时可以到8帧)的时序特征队列,每一帧的 anchor 查询会与之前帧的同一位置的查询做注意力。这个模块的意义是让模型感知目标的运动趋势,尤其是对遮挡后的目标恢复很有帮助。实际实现中,时序注意力采用可变形注意力,每帧采样4个点,不过查询数量多,显存压力不算小,训练时建议配合梯度检查点使用。
视角交叉注意力是 Sparse4Dv3 的重头戏。它借鉴了可变形注意力的思路,但采样点不是固定的,而是先在当前anchor附近做偏移预测,再从6个视角的特征图上取对应像素特征。每个anchor都有一个初始3D坐标,投影到每个视角的2D平面上,得到一个参考点,然后在这个参考点周围预测一组采样偏移。这部分代码在 attention.py 里比较绕,建议对着相机内参矩阵一步步推导,只要把投影公式写清楚,后面调试会轻松很多。
2.3 两阶段检测头:粗回归加精修的逻辑
检测头部分分两阶段:第一阶段产生初始预测,第二阶段用ROI特征精修。第一阶段本质是个稀疏的DETR头,对anchor集合做分类和回归,输出初始的3D框和速度。第二阶段借鉴了Deformable DETR的思路,从第一阶段预测的3D框投影到特征图上采样ROI特征,再回归一次残差。
关键点是第二阶段的3D框投影。Sparse4Dv3 用预测的3D框8个角点是否在图像内来判断可见性,并对可见角点做特征聚合。它不要求所有角点可见,只要部分可见就能采样到有效特征。这个设计和两阶段检测器在2D目标检测中的逻辑很吻合,先用粗糙框确定RoI区域,再用细化网络修正边界。
回归分支的损失包括 L1 损失和速度损失,分类用 focal loss。损失的权重配置在配置文件里比较明朗,默认是 box_loss=1.0、focal_loss=2.0、speed_loss=0.25,这是作者在NuScenes上调出来的经验值,我直接沿用,没有改动。
2.4 损失函数与正负样本匹配的坑
Sparse4Dv3 的匹配策略用的是匈牙利匹配,代价函数由分类代价和回归代价组成。分类代价用的是focal loss的负对数概率,回归代价用的是L1距离。这里有个细节:用于匹配的3D IoU计算在纯视觉方案里是不可微的,所以不能直接用于匹配代价中的梯度回传,Sparse4Dv3 在匹配阶段直接用了L1距离,而不是3D IoU,这个处理,和DETR3D的匹配策略也是一致的。
我复现时踩过的一个坑是匹配代价中回归分支的权重没对齐。默认情况分类代价权重是2.0、回归是0.25,由于回归值的尺度是米,如果不做归一化,远距离目标的L1数值会很大,导致匹配偏向让回归代价主导,分类学不好。Sparse4Dv3 代码里对anchor的3D坐标做了归一化处理,在匹配之前先除以感知范围,确保分类和回归的贡献在一个量级。如果你改数据集或感知范围,别忘了同步修改归一化的参数。
3. 复现全过程:环境配置、训练与验证
进入实操环节。我复现时的硬件环境是8卡A100 80G,PyTorch 1.11 + CUDA 11.3,mmdetection3d 用的是 v1.0.0rc6 版本。如果环境不同,版本对应关系会直接影响能否跑通,这点后面单独说。
3.1 环境准备与依赖安装
首先是环境安装。Sparse4Dv3 的官方README给了比较明确的安装步骤,但有几个点容易踩坑。
conda create -n sparse4d python=3.8 -y conda activate sparse4d conda install pytorch=1.11.0 torchvision=0.12.0 torchaudio=0.11.0 cudatoolkit=11.3 -c pytorch pip install mmcv-full==1.7.0 -f https://download.openmmlab.com/mmcv/dist/cu113/torch1.11/index.html pip install mmdet==2.28.0 mmsegmentation==0.30.0 git clone https://github.com/HorizonRobotics/Sparse4D.git cd Sparse4D pip install -v -e .这里头的关键坑点有三个:
第一个是CUDA版本。Sparse4D 的算子编译依赖mmcv的版本,而mmcv-full对编译环境非常敏感,我最初尝试PyTorch 2.0 + CUDA 12.0,结果编译阶段报了莫名其妙的 operator 未定义错误,后来退回PyTorch 1.11就好了。建议直接复刻作者环境,不要高版本起步。
第二个是Python版本。我试过Python 3.10,编译mmcv-full时会有兼容性提示,虽然能通过,但在后面做bbox nms算子编译时容易出问题。为了省时间,直接上3.8。
第三个是deformable attention算子的编译。源码用了mmcv的ext-op,必须在安装完mmcv-full之后、运行前测试时,先跑一次如下命令验证:
python -c "from mmcv.ops import DeformableAttention; print('DeformableAttention compiled successfully')"如果这一步报错,说明mmcv编译不完整,不要继续往下走,回头检查CUDA和toolchain的匹配情况。
3.2 数据集准备:NuScenes 的格式整理
Sparse4Dv3 在 NuScenes 上训练,数据格式要整理成 mmdetection3d 支持的格式。准备步骤分成三块:原始数据下载、can_bus 数据处理、以及生成pkl标注文件。
NuScenes数据集本身量很大,完整版本将近300G,建议只下载 mini 版先跑通代码流程,再下载完整版做正式训练。can_bus 是车辆自身的运动信息,对时序模块很重要,作者在仓库中提供了一个脚本 extract_canbus.py,它会从 nuScenes 原始数据中提取车辆的位姿和加速度信息,并保存为 npy 文件。这个文件如果缺失,代码在构造时序队列时会报 KeyError。
标注生成建议直接用 mmdetection3d 的 nuscenes_converter.py,注意把所需的 lidar 框转换为毫米波雷达坐标系下的3D框。Sparse4D 不依赖激光雷达做输入,但训练时的监督框仍然需要,所以3D框的标注质量直接决定模型精度。
3.3 训练流程与超参数调整
启动训练的命令比较直观:
bash tools/dist_train.sh projects/configs/Sparse4Dv3/sparse4dv3_temporal_r50_1x8_bs6_24e.py 8这个配置文件的几个关键参数值得关注:
- 主干网络默认是ResNet50,输入分辨率是 256x704。这个分辨率不算高,训练速度和显存占用比较友好,后续想提精度可以换成800x1333,但显存会多出1.5倍。
- 训练24个epoch,使用cosine退火。前3个epoch做warmup。作者在config里留了一个 trick :前4个epoch只训练第一阶段,第二阶段冻结,等第一阶段学出比较稳定的anchor分布后,再解锁第二阶段做联合训练。这个策略对收敛帮助明显,不建议去掉。
- 学习率默认是2e-4,我实测8卡batch size=48的情况下这个学习率表现稳定,如果减小batch size到16,需要同步降低学习率到5e-5左右,否则容易震荡。
- batch size 6 per GPU,8卡总共48,梯度累积没开。如果你的卡是V100 32G,需要把batch size降到4,并把梯度累积步数设为3,保持等效batch size一致。
训练过程中建议用 wandb 记录 loss 的变化。我跑的过程中,分类 loss 前3个epoch下降很快,大约从3.0降到1.5,之后进入平台期;回归 loss 下降比较平稳,到第15个epoch时基本稳定。如果分类 loss 长时间不降,先检查匹配阶段有没有问题,大概率是正负样本比例失衡。
3.4 评估验证:mAP 和 NDS 对标
训练完成后,用官方eval脚本评估:
bash tools/dist_test.sh projects/configs/Sparse4Dv3/sparse4dv3_temporal_r50_1x8_bs6_24e.py work_dirs/sparse4dv3_r50/epoch_24.pth 8评估会输出 mAP 和 NDS 两个指标。我在同样配置下跑到 mAP 42.5、NDS 53.1,和论文里公布的 mAP 43.0 非常接近,差异主要来源是随机种子和数据增强的微小不同。
如果你评估出来的指标和论文差距比较大(超过3个点),先排查如下几个点:
- 测试时是否用了官方推荐的TTA(通常包括水平翻转和尺度增强)。Sparse4Dv3 的测试代码在 TTA 打开的默认配置,跑测试前检查 config 中 test_pipeline 是否包含多尺度增强。
- 后处理的 NMS 阈值是否匹配。官方默认是 lnms 的 3D NMS,阈值 0.3。如果改成 0.5,mAP 会明显提升但定位精度下降,NDS 可能反而降低。
- 是否加载了预训练权重。ResNet50 的主干网络如果在 ImageNet 上预训练过,训练收敛速度和最终精度都有提升,官方 release 的 config 默认开启动态加载。
4. 常见问题与排查技巧实录
代码跑通了,但真正折磨人的是各种报错和诡异行为。我把自己复现期间遇到的高频问题整理成一个速查表,每一条都是实际踩坑后的结论。
4.1 显存爆掉或者OOM:梯度检查点与batch size的调整
OOM是复现 Sparse4Dv3 最常遇到的问题。模型整体显存占用主要集中在 transformer 的注意力模块和第二阶段refinement头。8卡A100 80G跑 batch size 6 没有压力,但如果是4卡3090 24G,很容易在训练到第5个epoch之后爆显存。
原因在于时序特征队列的长度。训练初期时序队列较短,显存占用不高,随着训练推进,队列会缓存更多帧特征,显存线性增长。解决办法有两个:一是把 config 里的 memory_bank_len 从默认的4降到2,减少缓存帧数;二是给 transformer 模块配置 gradient_checkpointing=True,用时间换空间,虽然训练速度会慢15%左右,但显存占用能降低30%。
如果你不想损失训练速度,还有一个办法是把时序特征队列移到CPU内存中。这个操作需要改动 sparse4d.py 中的循环逻辑,用 tensor.to('cpu') 缓存历史特征,每次前向再从CPU拷贝回GPU,代价是数据搬运耗时,但在多卡场景下效果不错。
4.2 训练不收敛:匹配代价与学习率的关系
训练不收敛最常见的表现是 loss 卡在某个值不下降,或者下降速度极其缓慢。我遇到过两次,都是因为学习率设置过高导致注意力模块的参数更新太快,把位置编码的优化空间挤没了。
解决方案是先把学习率降到1e-4,同时把transformer部分的参数衰减率调高。在配置文件里可以单独给 transformer 设置 weight_decay=0.1,而主干网络保持默认。另外一个容易被忽略的点是匹配代价里的回归权重,如果感知范围比较大(比如 x 正负80m),归一化后的 L1 值会变得很小,导致匹配基本只看分类代价,模型会倾向于把所有anchor预测成背景。遇到这个情况,把归一化范围改成和anchor生成范围一致即可。
4.3 反向传播出现 NaN:梯度裁剪和数值稳定性
NaN 问题在训练到第10个epoch左右最容易出现,绝大多数情况下是注意力模块的 softmax 数值溢出导致的。Sparse4Dv3 的可变形注意力在计算采样偏移时,如果预测的偏移值太大,采样点会跑到特征图外面,双线性插值拿到的是0填充的边界值,梯度因此变得异常。
排查手段是打开 config 里的 grad_clip,设置 max_norm=35。我实测这个值加上之后,NaN 出现的频率大幅降低。如果你是在主干网络部分出现的 NaN,那大概率是学习率过高,配合 warmup 周期拉长,比如从3个epoch拉长到5个epoch。
注意,NaN 的排查一定要在loss出现前截断。我习惯在每次反向后添加一个异常检测钩子,打印梯度的均值和方差,如果发现某个tensor的梯度超过100,立刻停止训练并检查是哪些样本引起的。这比等 loss 变成 NaN 再回头查快得多。
5. 复现之后还能怎么玩
到这里,Sparse4Dv3 的主干复现流程就走完了。代码能跑、指标能复现,这只是第一步,真正有意思的事情在复现之后。我探索过几个方向,觉得比较有价值,你可以根据自己的场景选一个深入。
第一个是主干网络替换。Sparse4Dv3 的检测头是稀疏的,对特征图的分辨率不敏感,所以从ResNet50换成ResNet101或者Swin-T,不会像BEV方案那样导致显存爆炸。我在ResNet101上做过实验,推理速度慢15%,但mAP提升了2个点。
第二个是时序长度扩展。训练时用了4帧,推理时我测试过将时序队列拉到8帧,mAP进一步涨了1.1个点,但显存占用也同步上升。如果你处理的场景对延迟要求不高,这个方向收益很直接。
第三个是和其他任务结合。Sparse4Dv3 输出的稀疏查询天然和轨迹预测、规划模块衔接得比较好,我后来在它的基础上接了一个简单的轨迹预测头,在NuScenes预测任务上也拿到了合理的结果。稀疏特征对比稠密BEV特征,优势在于查询数量少、后续处理快,落地的时候这个特性很有价值。
我在实际跑代码的过程中最深的感受是:Sparse4Dv3 的代码质量在学术界项目里算上乘,模块边界清晰,注释也比较到位,很适合作为理解多视角3D检测的范本。如果你能把它完整复现一遍,再踩过几个我上面提到的坑,后面再看 BEVFormer、StreamPETR 这些方案,思路都会顺很多。
最后再分享一个小技巧:复现这类大型视觉模型,强烈建议先开一个小型消融实验,比如只用2帧、只预测车这一类物体,把整个训练流程跑通,确认输出指标合预期后再开全量训练。这样既能在等待的时候快速定位问题,也能避免因为一个早期配置错误硬跑三天后才发现从头再来。