拿到MIT-BEVFusion源码,很多朋友都会卡在两头:一个是多模态特征到底怎么“合起来”,另一个是合完之后又是怎么“吐出来”检测结果。这两块对应到代码里,恰好就是标题里的fuser和decoder。这篇文章我按自己的阅读路径,把这两部分拆开讲:先讲它们在整条pipeline里的位置,再贴关键实现思路,最后把我实际调试过程中踩过的坑一起列出来。不管你是准备在BEVFusion上改融合策略,还是想把它迁移到自己的数据集,希望这份解读能帮你省点时间。
1. 整体设计与源码结构
1.1 从传感器到“一个BEV格子”:fuser和decoder各管哪一段
先明确一个概念,MIT-BEVFusion的整个前向流程可以粗分为四段:模态编码、多模态对齐、特征融合、任务解码。我这里说的“fuser”指的是第三段里真正做BEV特征融合的模块;“decoder”指的是最后把BEV特征解码成类别、3D框、速度等输出的模块。
以常见的nuscenes配置为例,整个流程大概是这样的:
- 图像通道:6路相机图像进入SwinTransformer,输出多尺度2D特征;
- 点云通道:激光雷达点云经过体素化后进入VoxelResBackBone8x,最终输出一个BEV空间的特征张量;
- fuser:把2D图像特征通过内外参投影到BEV坐标系,再与点云BEV特征融合;
- decoder:在融合后的BEV特征上加检测头/分割头,输出目标类别、3D框参数、速度等信息。
这里面有个容易混淆的点:MIT-BEVFusion源码里的decoder并不只是一个“解码器”类,它通常承载了两个功能,一是把融合后的BEV特征通过卷积做进一步编码(有时也叫neck或者head的前处理),二是把它映射到具体任务输出。不同版本命名不完全一致,有的叫DetectionDecoder,有的直接把CenterHead写进detector的head字段。我看的时候是直接把整个“融合特征之后、输出loss之前”的部分都归结为decoder的职责,这样理解起来更清晰。
如果你去翻bevfusion/configs/bevfusion_lidar-cam.py这类配置文件,会看到核心模型结构大致长这样:
model = dict( type='BEVFusion', encoders=dict( camera=dict(...), lidar=dict(...) ), fuser=dict( type='ConvFuser', in_channels=[80, 64], out_channels=256, ), decoder=dict( type='DetectionDecoder', in_channels=256, ... ), )所以,从代码阅读的角度看,fuser和decoder就是从bevfusion这个顶层模型里顺藤摸瓜找到的两个关键模块。与其一上来钻到mmdet3d一堆基类里迷茫,不如先在这两个模块上建立整体感。
1.2 为什么“先投影再加”,而不是“先加再投影”
很多第一次读BEVFusion的人会问:图像特征和点云特征既然都要到BEV空间,为什么不先把它俩拼起来再一起做投影?这里涉及一个本质区别:点云天然就在三维空间里,体素化后沿着Z轴压缩,得到的BEV特征有着明确的物理坐标;而图像特征是在相机平面上的,它本身没有任何BEV位置信息,必须靠相机内外参和深度假设才能映射到三维空间。
深度的不确定性是图像投影到BEV的核心难点。对BEVFusion来说,它没有像LSS那样预测一个显式的深度分布,而是选择了一种更贴合实测的做法:先把图像特征放进一个与BEV特征长宽对齐的空间,再利用采样网格把图像特征“撒”到对应的BEV位置上。这部分工作放在fuser里完成,而不是放在encoder里,目的就是让整个多模态融合的灵活性更高——你换一种点云encoder、换一种图像encoder,最后融合的方式可以完全不变。
换句话说,fuser在架构上起到的是“接口”作用,保证相机分支和激光雷达分支的特征在同一个BEV栅格语义下相加。而decoder是另一个方向的“接口”,它把统一BEV特征映射回任务空间。这两个模块各自独立,也正因如此,你可以在不改动encoder的情况下,单独升级fuser的融合策略,或者替换decoder来适配新的下游任务。
2. 融合特征fuser:把相机特征搬进BEV
2.1 图像特征与BEV特征的坐标面对齐
fuser做的事情,一句话概括:把相机的2D特征通过“像素->相机坐标->车身坐标->BEV栅格”的几何变换,投到BEV特征图上,再与雷达BEV特征做融合。这里最关键的是坐标变换和特征采样。
实际代码中,这个过程并不是对每个像素逐一做重投影,而是预计算一个采样网格(grid)。拿一张HxW的BEV特征图来说,我们在BEV坐标系里生成一个与BEV特征图分辨率一致的网格坐标,然后把每一个BEV网格中心点看作一个三维点,通过相机外参把该点转换到相机坐标系,再用相机内参把它投影到图像像素坐标。这样一来,我们就得到了“每个BEV位置应该从图像的哪个像素位置取特征”的对应关系,之后用双线性采样一次性完成。
这个思路一定要想明白:不是把图像特征“推”到BEV,而是把BEV位置“拉”到图像平面去采样。Pull-based方式和LSS那类Push-based方式不一样,它不会产生模糊的深度分布堆叠,但缺点也很明显——需要依赖一个先验的地平面假设,因为网格中心点的Z坐标默认取0。
实际代码里,创建采样网格的核心大概长这样(简化版):
def create_sampling_grid(bev_h, bev_w, voxel_size): # 生成BEV网格中心坐标,默认Z=0 x = torch.arange(bev_w) * voxel_size[0] + voxel_size[0] / 2 y = torch.arange(bev_h) * voxel_size[1] + voxel_size[1] / 2 grid_x, grid_y = torch.meshgrid(x, y, indexing='xy') grid_z = torch.zeros_like(grid_x) grid = torch.stack([grid_x, grid_y, grid_z, torch.ones_like(grid_x)], dim=-1) # [H, W, 4] return grid网格生成之后再乘以相机外参的逆矩阵,把车体系坐标转到相机系,再用内参做透视除法,得到归一化的图像像素坐标。这一套流程在代码里通常被封装成CameraTransform或者LiftSplatShoot里的变换类,但思想是一致的。
2.2 前向张量流转与关键shape
我们具体推一遍shape流转。假设相机输入是6张图,每张图在某个尺度下的特征图是[6, C_cam, H_cam, W_cam];点云分支输出的BEV特征是[B, C_lidar, bev_h, bev_w]。
第一步,对每个相机而言,BEV网格是[bev_h, bev_w, 4],通过外参矩阵和内参矩阵后,采样坐标变成[bev_h, bev_w, 2],取值范围已经归一化到-1到1之间(这是grid_sample要求的输入格式)。
第二步,对每一路相机特征执行F.grid_sample(cam_feature, grid),得到[1, C_cam, bev_h, bev_w]。如果BEV分辨率是180x180,相机特征通道是64,那么这个中间结果就是[1, 64, 180, 180]。
第三步,因为有6路相机,每一路都会生成一个[1, 64, 180, 180]的特征。但要注意,同一个BEV位置可能被多个相机同时看到。常见做法是取最大值或者计算平均值。MIT-BEVFusion的实现里,我印象比较深的是它会先对路相机加权,有些版本直接用mean,有些版本在训练中学一组权重,类似attention。为了保险,你可以去fuser模块里看有没有可学习的camera_weights参数。
第四步,将聚合后的图像BEV特征与点云BEV特征对齐通道,然后相加或拼接。
这个过程中,最容易出问题的就是相机的可见性掩膜。某个BEV网格投影到图像后,像素坐标可能落在图像边界之外,那这个位置就不应该参与特征融合。如果不加掩膜,grid_sample的边界填充值(默认是0)会污染融合结果,严重时会造成整条特征边界处出现一条“黑边”效应。
2.3 ConvFuser / AddFuser:不止是相加
MIT-BEVFusion里常见两种fuser实现:
AddFuser:直接把图像BEV特征和点云BEV特征做逐元素相加,实现最简单。ConvFuser:先把两个特征在通道维度拼接,再通过若干卷积层压缩到目标通道,让网络自己学习融合权重。
我用下来最大的感受:在相同训练配置下,ConvFuser的收敛稳定性和最终精度通常比AddFuser好一点,但显存和计算量也明显更高。原因很好理解,ConvFuser相当于在融合点引入了一层可学习的非线性映射,网路有机会抑制掉错误投影带来的噪声;而相加操作一旦某一路模态的特征分布差异很大,比如图像特征均值明显高于点云特征,融合后特征就会偏向某一模态。
这里给个建议:如果你只是想快速验证pipeline,先用AddFuser跑通;如果是要刷精度或者做最终方案,再换ConvFuser。不要一上来就在小数据集上对比过拟合以后的测试集,融合方式带来的差异往往在训练足够充分之后才拉开。
2.4 融合权重的坑
我实际使用中踩过一个大坑:图像分支的BEV特征和点云分支的BEV特征在数值尺度上可能差了好几倍。SwinTransformer输出的特征值范围通常在-3到3之间,而点云VoxelBackBone8x经过多层卷积后,某些通道的激活值可能到几十甚至上百。如果不做任何归一化,直接相加的话,图像分支在融合后基本被淹没。
解决办法一般有两种:
- 在fuser前对图像BEV特征做LayerNorm/BatchNorm;
- 在融合后接一层可学习的通道缩放。
MIT-BEVFusion在ConvFuser里一般自带归一化和激活,所以这个问题不明显。但如果你自己实现AddFuser,一定要注意输入特征的数值范围。建议在融合前把两路特征可视化成热力图看一眼,如果某一支几乎全黑或者全白,就不要急着往下做,先把特征尺度拉到一个量级再说。
3. 解码特征decoder:从BEV特征到检测框
3.1 decoder的职责划分
如果你写过自然语言处理里的decoder,比如Transformer解码器,可能习惯把“解码”理解为自回归地逐个生成输出。但在BEVFusion里,decoder的含义更接近“把统一BEV特征解码到任务空间”:它不生成序列,而是同时预测图像平面上每个位置有没有目标、目标的框参数是什么。如果你非要做类比,可以把它想成一个“卷积版的非自回归解码器”,所有位置的输出一次性并发生成。
在MTL(多任务学习)设定下,decoder往往还承担着多任务分支的管理职责。典型配置是:一个分支输出检测结果,另一个分支输出BEV分割图。两个分支共享backbone融合后的BEV特征,但在decoder内部各走各的卷积头。这样既能让两个任务互相促进,又不会在最终输出层面互相干扰。
具体到代码,decoder的输入是fuser的输出,shape一般是[B, 256, 180, 180];输出则取决于任务:
- 检测任务:输出类别分数和3D框回归量;
- 分割任务:输出每个BEV网格的语义类别概率。
这两个分支的loss在训练时各自计算,再按权重相加。代码里decoder通常就挂在模型前向的return_loss逻辑里。
3.2 CenterHead:一个非常典型的decoder具体实现
MIT-BEVFusion在检测分支上大多沿用CenterPoint的CenterHead,这算是我见过最典型的“BEV feature -> object”解码结构。它做的事情可以三步讲清楚:
- 对BEV特征做步长为2的下采样卷积,得到不同尺度的特征图;
- 在每层特征图上分别接三个并行的卷积头:类别头、回归头、速度头;
- 训练时用中心点热图监督类别头,推理时从热图峰值提取目标中心,再用回归头输出框参数。
回归头输出的维度一般是10维:中心点偏移(x,y)、尺寸(l,w,h)、朝向(sin,cos)、速度(vx,vy),有些版本还会加上Z轴偏移。这个维度设计一开始很容易把初学者绕晕,尤其在从config文件看head的num_classes和bbox_coder的时候,经常搞混哪些是类别维度,哪些是回归维度。
我建议你读decoder源码时从loss函数入手,反向看每个分支的输出shape。例如CenterHead的loss()里对heatmap_pred做focal loss,对bbox_pred做L1 loss,对vel_pred也做L1 loss。看明白这三个loss的输入输出,整个decoder的职责边界就清楚了。
3.3 3D框解码公式与坐标变换
decoder输出回归量之后,必须经过一个解码过程才能得到真正的3D检测框。这个解码在CenterPoint中叫decode_bbox。它做的事情可以分解成以下几步:
- 把输出特征图上的像素坐标乘以下采样倍数,映射回BEV网格坐标;
- 在类别热图上用3x3 max pooling找局部峰值,并按分数阈值筛选候选中心;
- 对每个候选中心,取回归头对应位置的偏移量、尺寸、朝向和速度;
- 结合BEV网格的物理范围(实际就是
voxel_size和point_cloud_range),换算成真实世界坐标系下的中心点坐标。
假设某个中心点在特征图上的位置是(i, j),那么它在BEV网格里的坐标是(i * stride, j * stride),进一步换算到真实坐标:
x = point_cloud_range[0] + (i * stride + offset_x) * voxel_size[0] y = point_cloud_range[1] + (j * stride + offset_y) * voxel_size[1] z = point_cloud_range[2] + offset_z这里的offset_x、offset_y就是回归头预测的中心点偏移。如果不做这个坐标映射,你拿到手的检测框就还停留在“特征图像素空间”,和点云的可视化对不上。我在第一次跑通项目时就是忘了把检测框变换到真实坐标,结果在可视化脚本里看到框全堆在原点附近,排查了半天才发现是坐标系的坑。
3.4 多任务decoder与共享特征
BEVFusion强调多任务学习,decoder部分很有意思的一点是,它在检测分支之外还会挂一个分割头。这个分割头不是简单地对BEV特征直接分类,而是先把BEV特征上采样到更高的空间分辨率,再用一个轻量级卷积头输出分割logits。
这个设计的出发点是:检测和分割对特征的位置精度要求不同。检测只需要对目标中心位置敏感,分割则希望每个BEV格子都能得到较好的语义预测。共享backbone可以节省计算量,但如果decoder不做区分,任务的梯度会相互干扰。因此,分割分支一般会有自己独立的反卷积上采样路径,相当于在“共享的浅层特征”之上,给每个任务留了一小段“私有解码网络”。
这种“共享主干+任务私有neck+任务头”的结构非常适合做工程落地,因为你可以非常方便地扩展新任务,比如增加车道线检测、可行驶区域分割等,只需要在decoder里加一个分支,而不需要动底层的传感器编码器。
4. 实操:把fuser和decoder在你的环境里跑通
4.1 环境准备与依赖版本
这部分网上教程很多,我只强调几个关键点。首先,建议直接跟着MIT-BEVFusion的官方README搭环境,Python版本最好在3.8以上,PyTorch版本和mmcv、mmdet3d的对应关系要严格对齐,否则编译spconv或者装torchsparse时会非常痛苦。如果你用的是较新的显卡,记得先确认CUDA版本和PyTorch是否匹配。
我自己的推荐版本组合是:
| 依赖 | 版本建议 |
|---|---|
| Python | 3.8 或 3.9 |
| PyTorch | 1.10 ~ 1.13 |
| torchvision | 与PyTorch对应 |
| mmcv-full | 1.5.x 或 1.6.x |
| mmdet3d | 1.0.0rc6 附近 |
| spconv | 2.x,注意CUDA匹配 |
实测下来,mmcv和mmdet3d这两个库的版本最敏感,经常出现编译通过但运行报mmcv._ext找不到符号的问题。遇到这类情况,不要整环境重装,先查一下是不是某个算子需要重新编译。我一般会把MMCV_WITH_OPS=1环境变量打开再pip install -e,这样能规避很多蹊跷的bug。
4.2 用一个小脚本单独验证fuser输出
跑完整训练之前,先用一个最小脚本把fuser前向跑通,能帮你快速确认几何变换有没有问题。代码路径大概是bevfusion/models/fusers,你先实例化一个ConvFuser,然后造两个假的输入:一个是图片特征camera_feats,一个是点云BEV特征lidar_feats。
import torch from bevfusion.models.fusers import ConvFuser fuser = ConvFuser( in_channels=[64, 64], out_channels=256, with_residual=False, ) camera_feats = [torch.randn(1, 64, 180, 180)] # 模拟图像BEV特征 lidar_feats = torch.randn(1, 64, 180, 180) # 模拟点云BEV特征 out = fuser(camera_feats, lidar_feats) print(out.shape) # [1, 64, 180, 180] 或 [1, 256, 180, 180],取决于实现这里有个细节:camera_feats往往是一个列表,因为相机分支可能输出多尺度特征,不同尺度分别投影到BEV后,再和点云特征融合。如果你只传一个tensor,很可能直接报类型错误。这个设计初看很怪,但看懂了多尺度融合的逻辑就明白了——不同尺度图像特征能提供不同粒度的语义信息,fuser内部会把它们都对齐到BEV空间后再合并。
跑通之后,建议把fuser输出可视化一下,用简单代码把每个通道做均值之后保存成热力图:
import matplotlib.pyplot as plt feat = out[0].detach().cpu().numpy() # [C, H, W] feat_map = feat.mean(axis=0) # [H, W] plt.imshow(feat_map, cmap='viridis') plt.colorbar() plt.savefig('fuser_output.png')如果融合正常,你会看到一幅有一定结构的热力图,道路上能看出轮廓;如果全是噪声或者大片空洞,说明几何变换那一步有问题,大概率是内外参矩阵和点云范围对不上。
4.3 decoder输出与可视化联调
fuser跑通后,再验证decoder。还是老办法,先伪造一个标准BEV特征输入,直接跑decoder前向。在CenterHead类里,forward输出一般是一个dict,key包括cls_preds、bbox_preds、vel_preds等。这些preds都是一组不同尺度特征图上的预测,别只接第一个,因为训练loss会对每个尺度算。
from bevfusion.models.decoders import DetectionDecoder decoder = DetectionDecoder(in_channels=256, ...) bev_feat = torch.randn(1, 256, 180, 180) preds = decoder(bev_feat) for k, v in preds.items(): if isinstance(v, list): print(k, [t.shape for t in v]) else: print(k, v.shape)正常情况,cls_preds的头两个维度是[B*num_classes, H, W]或[B, num_classes, H, W],bbox_preds是[B, 10, H, W]左右。看到这些shape之后,你就可以把preds送入CenterPoint的bbox_coder.decode,得到一组3D框,再叠加到点云上可视化。
我习惯的联调路径是:先跑一次真实点云+相机数据的前向,同时保存点云、图像特征BEV热力图、融合后的BEV热力图、检测框结果,四个图摆在一起对比。这样做能一目了然地发现是投影错了、融合错了、还是decoder解码错了。如果光盯着数字看,定位问题会慢很多。
4.4 参数速查:不同配置下的关键shape
为了方便调试,我把常见配置下的关键shape整理成一张表:
| 配置项 | 数值 | 说明 |
|---|---|---|
| 图像分辨率 | 256x704 | 输入尺寸,可调 |
| BEV网格大小 | 180x180 | 分辨率,由config指定 |
| 点云范围 | [-51.2, -51.2, -5.0, 51.2, 51.2, 3.0] | 单位米 |
| voxel_size | [0.2, 0.2, 8] | 体素大小 |
| 点云BEV特征通道 | 64 | 不同backbone可能不同 |
| 图像BEV特征通道 | 64 | 投影前 |
| fuser输出通道 | 256 | 融合后 |
| decoder回归通道 | 10 | 不含类别 |
这张表的价值在于,你可以一边读代码一边对照shape。尤其是当你修改了BEV范围或者图像分辨率之后,很多模块不需要改,但shape会对不上,这时回头查表就能快速定位是哪个环节因为参数没同步而产生了维度错误。
5. 常见问题与排查技巧实录
5.1 相机投影后特征全为零或者全图噪声
这是我被问得最多的问题。特征全为零,通常不是因为融合代码写错了,而是采样网格的坐标范围不对。grid_sample要求坐标在-1到1之间,如果你的投影计算把像素坐标归一化时使用了错误的图像宽高,或者内外参矩阵顺序搞反了,生成的grid就全部落在图像范围之外,采样结果自然就是零。
排查方法很简单:手动打印grid的min和max,确认是否在-1到1之间,再看有百分之多少的值落在有效范围内。如果有效比例极低,重点检查内外参矩阵的维度是不是[4, 4]、有没有做转置、以及外参是不是把车身坐标转到相机坐标而不是反过来。
还有一次我遇到全图都是噪声,原因是BEV网格的Z坐标没有按雷达安装高度设置,默认取了0,导致投影点全偏到图像边缘或远处。如果你的传感器是倾斜安装或高度差比较大,这个Z值不能简单当0处理。
5.2 融合后特征退化,精度不如单模态
出现这种“加了反而更差”的情况,首先要怀疑两路特征的尺度不一致,这是一切融合失败的常见根源。解决办法前面提过,先做归一化,或者换成ConvFuser让网络学习融合权重。其次是相机投影的可见性掩膜没做干净,导致大量越界采样值混进融合特征。
另外一种容易被忽视的情况是训练轮次不足。多模态融合模型的收敛速度通常比单模态慢,因为梯度既要学习特征提取,又要学习跨模态对齐。如果只训了不到10个epoch,精度低于单模态纯点云分支,其实是很正常的。不要急着加模块,先把训练轮次拉长,观察loss曲线是否有持续下降的趋势。
5.3 decoder输出维度对不上loss的shape
decoder很多时候会同时输出多尺度特征图,而不同尺度的特征图分辨率不同。在CenterHead中,下采样倍数不一样,对应的中心点偏移范围和框尺寸范围也不同。如果你改了decoder的下采样倍数,却没有同步改loss的down_ratio参数,就会出现loss在某个尺度上剧烈震荡甚至为NaN。
还有一个隐蔽的维度问题:预测类别数和数据集实际类别数不一致。nuscenes的检测任务一般是10类,但你如果在自定义数据集上只标了5类,而config文件里忘记改num_classes,那么类别头的输出通道不会变,loss在索引标签时就会直接报错。这个问题报错信息往往很隐蔽,有些时候是shape不匹配,有些时候直接是空tensor。建议每次换数据集,先把num_classes和num_reg_dims这两个数字从config里挖出来核对一遍。
5.4 NMS结果为空或者密集到没法用
推理阶段,检测框都要经过NMS后处理。如果NMS出来结果为空,先看热图阈值是不是设得过高;如果结果密集且大量重叠,看阈值是否过低,或者回归分支是否收敛。还有一个很容易踩的坑:CenterHead的3x3 max pool会把相邻两个目标合并成一个峰值,当目标距离特别近时,远一点的框会被抑制掉。
解决这类问题的思路不是盲目调阈值,而是先出一份热图可视化,看模型到底在哪些位置激活强烈。如果热图激活位置和目标中心偏移明显,问题多半在decoder训练不充分;如果热图激活分散成一团,可以考虑提高热图损失权重,或者调整focal loss的参数。
5.5 显存不够,融合分支是主要开销
BEVFusion的显存消耗比纯点云方案大不少,尤其是图像分支的SwinTransformer和fuser里的高分辨率grid_sample。如果你手里的卡只有12G显存,建议按顺序做三件事:
- 降低图像输入分辨率,从256x704降到224x640;
- 把fuser从
ConvFuser换回AddFuser,省下拼接卷积的显存; - 关闭图像分支的多尺度输出,只在单一尺度上做投影融合。
这三个操作能显著降低训练显存,但对精度的折损不完全相同。我实测下来,把图像分辨率降低的损失相对小,而关闭多尺度特征对远距离小目标的召回率影响比较明显。所以如果你重点关注近距离障碍物,可以放心降分辨率;如果需要兼顾远距离小目标,建议保留多尺度,只在fuser结构上做减法。
6. 一些个人体会
读BEVFusion的fuser和decoder,与其说是读算法,不如说是在读一套“数据怎么在空间里流转”的工程方案。fuser的核心从来不是那几次卷积,而是相机几何和BEV栅格之间的对齐;decoder的核心也不是CenterHead那三四个卷积头,而是从热图到3D框这一整条解码链路里每一步的坐标变换。
我个人的建议是,读代码时一定准备一张草稿纸,每读一个模块就把输入输出的shape写下来,同时把“这个shape对应到真实世界里的哪个含义”也标在旁边。比如看到一个[B, 10, 180, 180],就要立刻反应出来:10是中心偏移3维加尺寸3维加朝向2维加速度2维。只有这样把数字和物理意义绑定起来,你才能在改代码时不至于迷失。
最后分享一个调试小技巧:在fuser输出和decoder输入之间,临时插入一个断点,用matplotlib把BEV特征图保存下来,再用点云工具把同一帧的雷达点云BEV投影也保存下来。两者叠在一起看一眼,如果结构轮廓基本吻合,再往下调模型;如果不吻合,后面所有训练都是无效劳动。这一步前处理可视化检查,能帮你省至少一个星期的调参时间。