先聊个背景。BEV感知这几年的迭代速度非常快,从LSS到BEVFormer再到BEVFusion,核心思路都绕不开一件事:怎么把不同传感器的特征放到同一个鸟瞰图坐标系里,然后在这个坐标系上出检测、分割、车道线等结果。MIT-BEVFusion在BEVFusion的基础上做了不少改动,其中我觉得最值得读的就是融合模块和解码模块——这也是很多人读懂整个工程的两道坎。第四篇就专门把fuser和decoder这两块代码掰开揉碎讲清楚。
这篇内容适合谁?如果你已经在跑通MIT-BEVFusion的train/val流程,想深入改网络结构,或者想把它的融合思路迁移到自己的多传感器方案里,那这篇文章就是写给你看的。我会按源码的实际结构来拆,从张量形状到前向流程,再到和loss的衔接,尽量做到每段代码逻辑都能和工作原理对上。
1. 在拆代码之前,先把数据流和核心张量在脑子里过一遍
1.1 Fuser和Decoder在MIT-BEVFusion里的位置
MIT-BEVFusion的完整数据流可以分成4段:图像和点云各自的backbone提取特征、视图变换(LSS那套深度分布估计)、融合模块、解码head输出。
其中fuser位于view transform之后、decoder之前,它的输入是voxel特征、BEV特征、图像特征、深度特征四个大块,输出是一个融合了相机和激光雷达信息的BEV特征。decoder则紧接在fuser后面,把融合后的BEV特征解码成各种任务头需要的结果,包括3D检测框、实例分割、车道线等。
有一点需要提前说清楚:BEVFusion原版是先用图像特征生成深度分布,然后外积到BEV网格上和lidar特征融合。MIT-BEVFusion保留了这部分思路,但把融合方式改成了一种基于三维点投影的注意力机制MA-Fusion(Multi-scale Attention Fusion)。所以在读代码的时候,不要拿原版BEVFusion的经验硬套,很多变量名虽然一样,但语义已经变了。
1.2 你必须先搞清楚的四个特征张量
我在读代码时遇到的第一个障碍,就是这些特征张量形状太像了,B、C、H、W到处都是,但不代表同一个东西。这里先列一张表,后面讲fuser时反复会用到。
| 张量名 | 来源 | 形状(无batch维度理解时) | 含义 |
|---|---|---|---|
| voxel_feature | lidar backbone 3D卷积输出 | (C, D, H, W) | 体素空间中的稀疏特征,保留了三维几何结构 |
| decoder_feature | BEV特征编码器输出 | (C, H_bev, W_bev) | 从voxel特征压平到BEV平面的特征 |
| camera_feature | image backbone输出 | (N, C_img, H_img, W_img) | 多相机图像特征,N是相机数量 |
| depth_feature | depth net输出 | (N, D_bins, H_img, W_img) | 每个像素在D个离散深度上的概率分布 |
其中D是深度区间数,MIT-BEVFusion里通常取64或80,对应0到预设最大距离的离散化。voxel_feature的D维度在不同实现里可能被放到中间还是最后,读代码时要注意维度排列方式。融合模块里要把三维voxel坐标投影到图像坐标,所以它内部用的是几何坐标而不是纯特征,这点很关键。
1.3 融合和decoder的坐标系约定
fuser做投影时,voxel_feature中的每个位置对应车体坐标系下的一个三维点。比如BEV特征平面是180x180,每个网格代表0.4米或0.5米,那么网格索引(i, j)对应的x、y坐标就可以算出来。z方向在体素特征里保留了高度维度,通常也是离散的。
投影到图像坐标时,需要相机内参K、外参R|t。代码里transform参数打包了这些矩阵。我读代码时走了弯路,因为我一开始以为输入transform是已经组合好的3x4投影矩阵,后来发现它分成了几部分,转置和维度扩展的步骤特别多,很容易把矩阵方向搞反。建议读代码前先把K、外参的shape和坐标系写清楚,再对照投影源码看,否则就是对着代码猜数学。
2. Fuser模块拆解:MA-Fusion的核心实现
2.1 fusion/fusion.py里的模块划分
fusion目录下核心文件就是fusion.py。里面有两个类:FusionModule和FusionFrustum。FusionModule是一个外层包装,包含BEV特征编码、融合模块和decoder的实例化,是整个融合解码部分的入口。FusionFrustum才是真正实现多尺度注意力融合的地方,也是这篇文章的重点。
为什么区分两个类?FusionModule承担的是“组装”职责,把融合前后需要的模块都串起来,方便上层直接调用。FusionFrustum承担的是“计算”职责,只做融合这一件事。这样设计的好处是,如果你只想替换融合策略,不需要动FusionModule的前向逻辑,改FusionFrustum就行。我在自己项目里扩展新的融合方法时,也是沿用这种分层方式,代码会干净很多。
FusionFrustum的__init__里主要定义了query、key、value三个卷积,一个用于融合后特征压缩的conv,以及一个跨模态注意力模块。这些层的输入输出通道数均与feat_channels保持一致。还有num_heads等注意力参数,这些参数控制着后续注意力计算的head数量。
记得在阅读时注意FusionModule中通常会包含bev_backbone、fusion、decoder等成员变量,这些成员在forward里按顺序被调用。这种组合方式让整个网络变成了一条清晰的流水线。
2.2 FusionFrustum的核心前向逻辑逐段拆解
FusionFrustum的前向函数参数比较多,但核心逻辑可以分3步:构造query/key/value,三维点投影到图像平面并采样,注意力加权融合。下面是我提炼后的伪代码,和实际源码逻辑一致:
def forward(self, bev_feature, decoder_feature, voxel_feature, camera_feature, depth_feature, transform): B, C, D, H, W = voxel_feature.shape # 1. 把相机特征和深度特征在通道维度上拼接 # camera_feature: (B, N, C_img, H_img, W_img) # depth_feature: (B, N, D_bins, H_img, W_img) # 拼接后得到 (B, N, C_img + D_bins, H_img, W_img) fused_camera_feature = torch.cat([camera_feature, depth_feature], dim=2) # 2. 用3D体素坐标投影到图像坐标系 # 得到每个voxel位置对应的图像坐标和mask point_coords, mask = self.project_voxel_to_image(voxel_feature, transform) # 3. 用BEV特征生成query,用图像特征生成key和value query = self.query_conv(decoder_feature) # (B, C, H_bev, W_bev) key = self.key_conv(fused_camera_feature) # (B, N, C, H_img, W_img) value = self.value_conv(fused_camera_feature) # (B, N, C, H_img, W_img) # 4. 根据投影坐标在key、value上做特征采样 sampled_key = self.bilinear_sample(key, point_coords) # (B, C, D, H, W) sampled_value = self.bilinear_sample(value, point_coords) # 5. 计算注意力并加权求和 attn = torch.einsum('bchw,bcdhw->bdhw', query, sampled_key) attn = attn / (C ** 0.5) # 处理无效投影点 attn = attn.masked_fill(~mask, float('-inf')) attn = F.softmax(attn, dim=1) # 6. 权重乘以value并和BEV特征融合 fused = torch.einsum('bdhw,bcdhw->bchw', attn, sampled_value) output = self.bev_attn(bev_feature, fused) return output这里我略去了一些维度permute和reshape操作,但整体顺序就是这样。我读这段代码时花了很久才反应过来,query并不是来自图像特征,而是来自decoder_feature(也就是BEV特征)。而key和value来自图像特征。这个设计和transformer里常规做法不完全一样,核心想法是把BEV特征作为查询,在图像特征中寻找与每个BEV位置相关的信息。为什么这么设计?因为BEV特征携带了三维位置先验,投影到图像后能精确知道该找哪个像素,能大幅缩小注意力的搜索空间。
还有一点值得注意,源码中这里的attn并没有像标准transformer那样做softmax后跨所有key求和,而是分别在每个BEV位置对D维度做归一化。也就是说,每个三维体素位置都独立地获得了一个关于深度维度的注意力分布。这本质上是在选择:这个体素应该更相信哪个深度层上的图像特征。这种空间注意力方式比把整个BEV特征展平成序列再全局注意要省显存得多,推理速度也快不少。
2.3 三维点投影到图像平面那一步是怎么实现的
融合的几何基础是把voxel_feature里每个体素的三维坐标投影到图像平面上。实际源码里不会直接生成所有体素坐标的显式xyz张量,而是用torch.meshgrid构造一个三维网格,再经过体素大小和范围偏移量换算成真实坐标。
假设BEV网格范围是[-50米, 50米],z轴范围是[-5米, 3米],分辨率是0.4米,那么x方向网格数是250,y方向也是250,z方向取决于体素高度设置(比如20或40)。每个体素的中心坐标就是:
x = min_x + (ix + 0.5) * voxel_size_x y = min_y + (iy + 0.5) * voxel_size_y z = min_z + (iz + 0.5) * voxel_size_z得到三维坐标后,要投影到某一相机的像素坐标系。这里需要先用外参把车体坐标系下的坐标转到相机坐标系,再用内参投影:
cam_coords = R @ lidar_coords + t pixel_x = fx * cam_x / cam_z + cx pixel_y = fy * cam_y / cam_z + cy代码里不会显式写这个公式,而是用矩阵乘法一次性算完。投影完成后,必须做一个越界检查,把落在图像范围外的点标记为无效,同时把cam_z小于某个阈值的点也标记为无效(否则会投影到相机后面,产生错误的像素坐标)。这一步非常关键,我在实践里遇到过不滤除cam_z <= 0导致采样出NAN的情况,后面调试部分还会细说。
采样阶段用的双线性插值,对每个体素位置,根据归一化后的pixel坐标在图像特征上采样。这一操作很类似grid_sample,只是维度更高。源码里需要自己处理批量维度和相机维度,把每个体素分配到对应相机上,再进行采样。这部分实现比较绕,但理解它的目标就通了:为每个三维体素位置,从图像特征里找出它能看到的那块局部区域特征。
2.4 为什么融合用注意力而不是直接concat
看到这里你可能会问,明明把图像特征投影到BEV再concat一个通道不就完事了吗,为什么要大费周章搞attention?直接concat的问题在于:激光雷达点云是稀疏的,图像特征在每个像素上都有值,但投影到BEV后很多位置并没有对应的激光点。如果直接concat,图像特征在空区域就会产生大量噪声,干扰后续检测。
用注意力机制的好处是,网络可以学习“在什么情况下采信图像特征”。比如近处物体上,激光点密集,三维结构信息充足,注意力权重可能会倾向于点云特征;远处物体上,激光点稀少,但图像纹理清晰,此时可以增大图像特征的权重。这种按需融合是concat方式做不到的。
而且MA-Fusion的query来自BEV特征,key和value来自图像特征,这样融合后的特征本身就已经是“按BEV位置对齐”的结果,不会产生两套特征坐标系打架的问题。我在实验里也验证过,用注意力融合比直接concat在远距离小目标上的mAP提升明显,尤其是行人和自行车这类外观特征强的类别。
3. Decoder模块拆解:Transformer解码与稠密解码
3.1 transformer_decoder.py的双解码器设计
transformer_decoder.py文件里包含TransFusionDecode这个总入口,内部再分成HeadDecoder和PointDecoder两个类。它们虽然都叫decoder,但分工不同。
HeadDecoder负责从BEV特征生成各个任务头的输出,包括3D检测框、目标属性、速度等。它的输入是融合后的BEV特征,输出是一个dict,里面包含不同任务的head张量。PointDecoder则专门处理点级任务,比如实例分割,输入除了BEV特征还有一组点坐标,输出则是对每个点的特征和预测结果。
我当时读这个文件最先的困惑是,为什么检测也要用transformer decoder来做?这里的transformer decoding并不是像DETR那样做端到端的query和object查询,而是在BEV特征平面上做自注意力增强,然后接不同的卷积head输出。它更像是用transformer来提取全局上下文信息,同时利用BEV特征网格天然的位置信息,缓解单纯卷积感受野有限的问题。
在TransFusionDecode里两个decoder并不是串行关系,而是共享一部分BEV特征输入,分别处理不同的任务。这也启发了我后续改网络时的一种思路:不同任务对特征语义的要求不一样,与其让一个head硬扛所有任务,不如按任务类型拆分解码分支。
3.2 HeadDecoder怎么输出多任务head
HeadDecoder的前向逻辑可以概括为以下几个步骤:
def forward(self, x, bev_embed): # x: 融合后的BEV特征 (B, C, H, W) # 将特征展平成序列,加上位置编码 H, W = x.shape[-2:] x = x.flatten(2).permute(2, 0, 1) # (H*W, B, C) # 通过多层transformer decoder layer for layer in self.layers: x = layer(x, bev_embed) # 恢复成BEV网格形状 x = x.permute(1, 2, 0).reshape(B, C, H, W) # 各个任务head分别输出 head = {} for name, head_conv in self.heads.items(): head[name] = head_conv(x) return x, bev_embed, headbev_embed这个参数值得聊一下。它是从BEV特征生成的embedding,在decoder layer中作为memory参与cross attention。HeadDecoder里大量使用了自写的TransformerDecoderLayer,这些层额外接收bev_embed来辅助解码。为什么需要这个embedding?因为BEV特征经过多次卷积和下采样后,原始的位置信息会被稀释,引入一个和坐标强相关的embedding可以帮transformer更好地恢复空间对应关系。
head张量是一个dict,每个key对应一个任务名,比如center、height、dim、rot、vel等。不同任务head的结构不一样,检测框相关的head通常输出通道数等于类别数或回归参数维度。我在源码里看到很多head都是简单的3x3卷积,因为特征已经被transformer充分编码,head只需要做最后的预测映射。
3.3 PointDecoder在实例分割里的工作方式
PointDecoder针对的是点级预测。它的输入不只是BEV特征x,还有一个point参数,表示一组查询点。前向时需要把查询点投影到BEV网格坐标系,通过bilinear采样获取每个点位置的特征,再在点特征上用MLP预测类别和偏移。
如果是在训练阶段,point通常是ground truth实例中心或前景点;推理阶段则来自一些候选生成逻辑。PointDecoder返回point_feature、point和target_point。我第一次看这里时不太明白target_point的作用,后来才反应过来,这是监督信号。
在实例分割任务中,网络的输出是每个点属于哪个实例以及距离实例中心的偏移量。有了预测偏移和真值偏移,就能算loss;有了预测点特征,后续还可以聚类得到最终实例掩码。所以PointDecoder的任务更像是一个“分割头的前置特征提取器”,真正的loss计算在后面的loss模块里完成。
如果你只跑3D检测任务,PointDecoder不会参与计算。这个模块的存在让MIT-BEVFusion能同时支持检测和分割,框架设计上确实考虑得比较全,但也增加了阅读门槛。建议读代码时先按自己关心的任务来,把无关分支跳过。
3.4 dense_decoder.py里的三个稠密解码器
dense_decoder.py文件里定义了DenseDepthDecoder、DenseLaneDecoder和DenseObjectDecoder三个类。这里的dense指的就是BEV网格上的稠密预测。
DenseDepthDecoder用来从BEV特征回归深度图。有些读者不理解,输入已经是BEV特征了,为什么还要预测深度图?这是辅助监督用的:在训练时给深度估计一个监督信号,帮助backbone更好地学习深度分布。这个深度预测头在推理时不会被用到,可以看作是fuser的“陪练”。
DenseLaneDecoder负责车道线分割,输入BEV特征,输出每个网格属于车道线区域的概率,形状通常是(B, 1, H, W)。DenseObjectDecoder则做车辆目标的稠密预测,输出中心热度图、尺寸、朝向等信息,是CenterPoint型检测head的BEV版本。
这三个decoder其实和HeadDecoder的输出是互补的。HeadDecoder精度高但计算量大,主要服务关键目标;dense decoder计算轻量,能覆盖全图范围,适合车道线这类需要全图一致性的任务。我在实际使用中通常会用dense decoder作为教学信号蒸馏特征,效果还挺好。
3.5 各任务Loss是怎么和decoder输出接上的
decoder输出的head dict不会直接算loss,而是先经过一个loss计算模块,在MIT-BEVFusion里通常体现在train_step或loss相关文件中。检测任务会用focal loss处理center热图,用L1 loss处理尺寸、朝向、速度等回归量。分割任务会用bce loss处理前景背景二分类,以及用L1或L2 loss处理偏移量。
我当时在复现时踩过一个坑:直接把head dict传入loss函数,结果维度对不上。原因是HeadDecoder输出的形状是(B, head_channels, H, W),但loss函数期望的target是(B, num_classes, H, W),两者在通道维度上的含义不同。需要先做sigmoid或softmax,再和target计算。不同任务head的激活函数不同,检测框是直接回归不加激活,分割概率需要sigmoid,这些细节都藏在loss计算代码里。
建议改动head数量或类别数时,先确认loss模块里对应的target channel数是否同步更新。很多改模型后loss维度报错,都是因为head输出通道和target通道不一致。
4. Fuser和Decoder协同工作:一次完整前向的视角
4.1 一次迭代的数据流转顺序
把fuser和decoder放到一次完整前向里看,流程是这样的:
图像 -> image backbone -> camera_feature -> Fuser 点云 -> lidar backbone -> voxel_feature -> Fuser BEV特征由voxel_feature压平得到 -> decoder_feature -> Fuser Fuser输出融合BEV特征 -> HeadDecoder / DenseDecoder HeadDecoder输出head dict -> loss计算其中有意思的是,FusionFrustum的输入中既有decoder_feature也有bev_feature。decoder_feature用来生成query,bev_feature在注意力融合后与结果再做一次cross attention。为什么要分成两个输入?因为query需要的是“带几何位置的BEV特征”,而bev_feature是“经过多层编码的语义特征”。前者重定位,后者重语义,两者职责不同。我用一句话总结就是:bev_feature是最终需要被增强的特征,decoder_feature是为生成查询而准备的辅助特征。
整个前向过程是端到端可微的,从图像和点云进来,到最后loss出来,中间没有任何断开的地方。这也意味着,如果你改了fuser结构,梯度会影响后面的所有层,改动时需要注意数值稳定性。我是建议每次修改一个模块后用一个小数据集跑一遍过拟合测试,确认loss能降下来再看完整训练。
4.2 输入输出形状变化对照表
为了让读者在改代码时有据可循,我把关键模块的输入输出形状整理成一张表。假设batch size为1,图像8路相机,BEV网格180x180,体素深度为40:
| 模块 | 输入形状 | 输出形状 |
|---|---|---|
| FusionModule.forward | bev_feature (1,256,180,180), voxel_feature (1,256,40,180,180), camera_feature (1,8,256,256,704), depth_feature (1,8,64,256,704) | fused_bev (1,256,180,180) |
| FusionFrustum.forward | decoder_feature (1,256,180,180), voxel_feature (1,256,40,180,180), camera_feature (1,8,256,256,704), depth_feature (1,8,64,256,704) | fused_feature (1,256,180,180) |
| HeadDecoder.forward | x (1,256,180,180), bev_embed (1,256,180,180) | x (1,256,180,180), head dict |
| PointDecoder.forward | x (1,256,180,180), point (N,2) | point_feature (1,256,N), point, target_point |
| DenseObjectDecoder.forward | x (1,256,180,180) | 各head张量 (1,C_head,180,180) |
注意不同配置下这些数字会变,尤其是图像输入分辨率和BEV网格大小。你读代码时如果发现形状对不上,先看配置文件里的bev_shape、image_shape、voxel_size等参数,大多数维度问题都是这里不一致导致的。
4.3 修改网络时容易踩的结构耦合问题
MIT-BEVFusion的模块之间耦合得比较紧,有几个地方改动时特别容易出问题。
第一个是voxel_feature的维度顺序。某些版本里体素特征将D维度放在最后,即(B, C, H, W, D),FusionFrustum里会有对应的permute处理。如果你在网络前面改了维度顺序,fusion里忘了改,后面所有shape都会错。
第二个是camera_feature和depth_feature在通道维度上的拼接。depth_feature的bin数必须和深度估计模块输出的bin数一致,否则拼接后通道数变化,key_conv和value_conv的输入通道也得同步改。有一次我改了深度bin数没有同步改fuser里的in_channels,结果训练到一半loss直接变NAN,排查了很久才发现。
第三个是HeadDecoder的heads定义和loss模块的target通道数耦合。你新增了一个head,如果loss里没写对应分支,不会报错,但那个head就没有梯度监督效果,等于白加了。反过来,如果loss里新增了分支但head没有输出,会直接报KeyError。这些都是在改造时最容易忽视的隐性约定。
5. 实战踩坑与调试记录
5.1 投影坐标越界导致attention输出NAN
这是我跑MA-Fusion时遇到的第一个大坑。现象是训练到几百步后loss突然变成nan,而且每次出现的位置不固定。一开始怀疑是学习率太大,调小了仍然会出现。后来一步步打印中间张量才发现,问题出在FusionFrustum的attention计算里。
原因很简单:三维体素坐标投影到图像平面后,有些点落在图像外面,有些点cam_z是负的(位于相机后方)。这些无效点在双线性采样时可能采到空区域或错误区域,导致后续attention中出现极小值或极大值,经过softmax后产生nan。
源码确实做了mask处理,但我用的版本没有在采样前把cam_z <= 0的点过滤干净,导致grid_sample时坐标归一化出问题。解决办法是在投影函数里加一个显式的条件判断,把所有cam_z < 0.1的投影点mask掉,同时把超出图像边界的点也mask掉。加上之后训练就稳定了。
建议所有改过投影部分的人,都在前向时打一次mask的统计信息,看看有效点占比是多少。如果某个相机下有效点长期为0,说明该相机的内外参或者投影写法有问题,这是排查源头的好方法。
5.2 loss不降先查哪里
如果你的模型loss在初期能降一些,但很快停在某个值不动了,先别看网络结构,先查两个地方:一是depth分支的输出有没有正常收敛,二是融合后的BEV特征里,图像和点云特征的比例是否正常。
为什么先查depth?因为MA-Fusion中depth_feature参与构造key和value,如果深度分布学成均匀分布,那attention就没有选择性,融合效果等同于平均池化,后面的检测自然很难提升。我遇到loss不降的时候,会把depth分支输出单独可视化,看它有没有出现清晰的近大远小层次。如果没有,就去检查深度监督信号的权重,有时只是因为它太小,网络根本不在乎这个分支。
另一个快速排查方法是:把fuser的输出分别和只输入点云的BEV特征做对比,看两者在数值统计上差异有多大。如果差异非常小,说明图像信息几乎没有进入融合结果,问题大概率在attention权重上,或者是query和key的特征域不匹配,需要加一个LayerNorm或调整初始缩放。
5.3 显存爆炸的排查经验
BEVFusion系列的显存占用一直是老大难。MIT-BEVFusion相比原版在显存上更紧张,因为attention中间张量非常多。如果我在单卡V100上训练,batch size往往只能开到2甚至1。
遇到显存溢出,我先查的是camera_feature的通道数是不是被意外放大了。有些版本的image backbone输出通道是512,fusion层又把通道拼到更大,显存直接翻倍。如果你不需要特别高的精度,可以先用256通道跑通流程,再往上加。
还有一个容易忽略的地方是attention中的中间张量。query和key做einsum时,如果直接生成(B, H, D, W, W)形状的全局attention矩阵,显存会爆炸。MA-Fusion没有用全局注意力,而是在每个BEV位置对D维度做注意力,避免了这个问题。但如果你在复制代码时不小心把维度搞成全局的,显存立刻爆掉。建议在写attention时先算一下中间张量的大小,再决定是否用分块策略。
小技巧:把计算图里不需要梯度的部分用detach隔开,能省不少中间显存。比如某些辅助监督分支,在推理阶段直接不计算。
5.4 修改类别数或head数量时要同步改的位置
如果你要把检测类别从8类改成自己的自定义类别,需要改的地方不止一个。首先是HeadDecoder里的heads定义,确保center head的输出通道和类别数一致。然后是loss模块里的target,确认target张量的通道数也对应类别数。再次是后处理解析部分,类别数不同会影响置信度阈值和NMS的类别索引。
我第一次改类别时,只顾着改head输出通道,忘了后处理里的类别数量,结果推理时所有框的类别索引全乱套了。当时排查了很久,最后才发现是后处理里hardcode了一个类别数。这个在源码里很容易被忽略,改配置时务必全局搜索一下类别数相关的常量。
分割相关的任务头改动同理,新增一个分割类别,对应的dense decoder和loss都要同步,而且还牵扯到PointDecoder里类别embedding的维度。这类多任务框架改起来牵一发动全身,最稳妥的做法是先读懂每个任务的loss流,再动手。
结尾:一点个人体会
MIT-BEVFusion的代码比原版BEVFusion复杂度高了一个档次,尤其是fuser和decoder这两个模块,包含了很多工程实践上的取舍。我读第一遍时,光是把FusionFrustum的投影和采样部分看懂就花了两三天。但一旦把这块啃下来,再看其他BEV融合方案会轻松很多,因为MA-Fusion本质上就是一个可推广的范式:用三维几何结构把不同模态特征锚定到同一空间,再用注意力机制让网络自己决定如何融合。
如果你想把这个框架用到自己的数据上,我的建议是不要一次性把fuser和decoder都改了。先跑通原版,然后只替换数据加载和类别配置,确认baseline没问题之后,再逐个模块试验自己的改动。改fuser时也要小步快跑,每次只改一个机制,比如先只改投影方式,再改注意力计算,对比效果,这样出了问题能快速定位。
最后分享一个小技巧:读这类多模态融合代码,光看代码很难建立直觉,可以在前向过程中把fuser中间层的attention权重和投影mask打印出来,和输入图像叠加可视化。当你亲眼看到attention权重集中在目标物体上时,对整个网络的信任感和理解深度是完全不一样的。祝你的BEV感知之路顺利。