ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

DualBEV双视角变换:统一前向与后向投影的BEV感知实践

DualBEV双视角变换:统一前向与后向投影的BEV感知实践

1. 先把痛点说明白:双视角变换到底在争什么

BEV 感知这条线上,多相机图像怎么变到鸟瞰图,十年下来其实只沉淀了两条路:一条从像素往地面"抬",一条从地面网格往图像"捞"。前者以深度分布加几何投影为代表,后者以 BEV 查询加空间交叉注意力为代表。两派打了很多年,各自的粉丝都能列出一堆理由。DualBEV 这个工作的价值不在发明了第三条路,而在于它证明了一件事——这两条路在数学上是同一个东西的两种归一化方式,既然如此,就没必要选边站,把它们缝起来用。

我第一次读到这个思路的时候,反应是"这也行?"因为在工程直觉里,这两种特征构建方式的实现代码长得完全不像:一个要算深度分布、要做体素池化、要处理大量空网格;另一个要算参考点、要采样、要写 attention。但你把两边都写成矩阵形式之后,会发现它们共用一个东西——图像像素和 BEV 网格之间的对应关系矩阵。前向投影用这个矩阵的列去聚合,后向投影用这个矩阵的行去采样。这个视角一转换,很多原来觉得"互斥"的设计立刻变成了"互补"。

这篇东西我打算按我自己的理解顺序来写:先讲两种视角变换各自真实的短板(不是论文里那种客套话,是你在部署时会真正疼的地方),再拆 DualBEV 是怎么把它们统一到概率对应上的,然后给一段可以直接照着改的工程落地路径,最后把我在调这类模型时反复踩到的坑整理出来。适合已经能跑通一个 BEV 检测基线、想搞清楚"为什么改这么一点点就能涨点"的同学,也适合刚入门、正在纠结该学哪一派的新手——看完你会发现,这题不用二选一。

1.1 前向投影:深度先验很香,但深度估不准就是灾难

前向投影的典型流程,我按自己写代码的顺序复述一遍:骨干网络出多尺度图像特征,接一个深度头,为每个像素预测 D 个深度 bin 上的概率分布;然后对每个像素、每个深度 bin,用相机内参把像素坐标反投影成相机坐标系下的三维点,再用外参转到自车坐标系,落到 BEV 网格上;最后所有落在同一个网格上的像素特征做加权求和。

这个流程的好处非常直接:它是一个稠密的、一次性的、纯几何的操作。没有迭代,没有查询,没有注意力,所有像素都被用上了,不存在"某个 BEV 网格没有被采样到"的问题。而且深度分布是显式建模的,你能把它可视化出来看,能算交叉熵监督,能在训练早期就获得比较稳定的梯度。收敛速度上,这类方法普遍比查询式快一大截,对训练轮数不敏感的团队特别友好。

但它的问题也在这里。深度是从单目图像里猜的,猜错了,整个特征就会沿着错误的射线"涂抹"到一串 BEV 网格上。业内管这个叫特征拖影或者特征扩散。你去看可视化,一辆车的特征会被拉成一条长长的带子,横跨好几个网格。后面的检测头要在这种糊掉的图上框出准确位置,难度可想而知。

更烦的是,这个误差是不可逆的。一旦特征被池化到 BEV 网格,你没法回头说"这个网格其实不该收到这个像素的贡献"。这也是为什么很多前向投影方案要靠额外的深度监督、时序融合、多尺度融合来救——本质上都是在给一个已经发生的信息毁损做补偿。

1.2 后向投影:查询驱动很灵活,但采样稀疏且没有深度感

后向投影反过来做。先铺一张 BEV 查询网格,每个查询对应一个自车坐标系下的参考点;把参考点投影回各个相机的图像平面,得到参考像素;在参考像素附近撒若干个采样点,取图像特征做加权求和。

它的优势是精度上限高。因为它是从"我想知道这个网格是什么"出发的,采样位置可以根据参考点动态调整,几层可变形注意力叠起来之后,感受野能扩展到很大的范围。而且它天然支持多尺度、支持跨相机,同一个 BEV 查询能同时从几个有重叠的相机里取特征。

代价是采样太稀疏。一个 BEV 查询只撒四到八个点,图像上那么大一片区域,绝大部分像素根本没被看到。这就导致两个后果:一是训练收敛慢,模型得花很多轮才能学会把采样点挪到该去的地方;二是对参考点的初始位置很敏感,参考点一旦偏了,采样就偏了,后面全歪。

更深层的问题是它没有深度先验。前向投影好歹有个深度分布头在显式地学"这个像素有多远",后向投影的采样偏移量是隐式学出来的,你很难解释它到底学到了什么几何含义。在遮挡、远距离、小目标这些场景下,缺少深度约束的采样很容易糊。

1.3 DualBEV 真正想解决的那一句话

把上面两段压缩成一句:前向投影的问题是"权重给得太满",后向投影的问题是"权重给得太少",而它们用的其实是同一张权重表。

DualBEV 干的事情,就是先把这张权重表显式地算出来、归一化好,然后拿它同时去做两件事:一方面用它去增强图像特征(让后向投影采样更准),另一方面用它去修正注意力权重、并用它指导稀疏区域的补采样(让前向投影的拖影问题被抑制)。整个过程几乎不增加计算量,因为这张表本来就要算。

2. 整体设计思路拆解:把两种投影写成同一个矩阵

这一节是整个方案的骨架,我尽量不用太多符号,但必要的符号还是得有,不然说不清楚。

2.1 对应关系矩阵:一切的起点

假设有 N 个相机,每个相机输出 H×W 的图像特征,BEV 网格是 BH×BW。定义一个四维张量,前两维是相机和像素索引,后两维是 BEV 网格的横纵索引。这个张量的含义是:第 n 个相机的第 (i,j) 个像素,对第 (u,v) 个 BEV 网格的贡献概率是多少。

这个张量怎么来?对每个像素,沿它的深度分布加权;对每个深度 bin,反投影到自车坐标系得到一个三维点;把这个点拍扁到 BEV 平面,落在哪个网格就记在哪个网格上。把深度概率当作权重,就得到了概率形式的对应关系。

工程上要注意,这个张量是极度稀疏的。一个像素沿着射线只会命中很少几个网格,绝大部分位置是零。所以实际实现里绝不能真的去构造一个稠密的 N×H×W×BH×BW 张量——那显存会直接炸掉。正确做法是仍然沿用原来的体素池化/散射操作(scatter),只是在做散射之前先把这个"概率"的性质处理干净。这一点后面实操章节会讲具体的写法和坑。

有了这张表,两种视角变换就都能用矩阵乘法写出来:

  • 前向投影:把图像特征按表的列方向聚合,得到 BEV 特征。写出来就是 BEV = Mᵀ · F 这一类形式。
  • 后向投影:从表的行方向取 top-k,得到每个 BEV 网格对应的采样位置和权重,再做加权求和。

同一张表,一个按列用,一个按行用。这就是统一的基础。

2.2 行归一化和列归一化,藏着两种方法的本质差异

既然用的是同一张表,为什么两种方法表现差那么多?原因在归一化。

行归一化:固定一个图像像素,把它对所有 BEV 网格的贡献概率归一化成和为 1。这恰好就是深度分布本身——因为一个像素只能落在一条射线上,深度分布的 D 个 bin 加起来是 1,落到 BEV 上的概率自然也是 1。所以前向投影天然满足行归一化。

列归一化:固定一个 BEV 网格,把它从所有像素收到的概率质量归一化。这个量的物理意义是"这个网格到底被多少个像素、以多大的置信度覆盖到"。近处的网格被大量像素覆盖,远处的、被遮挡的网格可能只有零星几个像素覆盖,甚至完全没有。

我个人的理解是这样:行归一化告诉我们"这个像素靠不靠谱",列归一化告诉我们"这个网格饱不饱"。前向投影只用了行归一化的表,直接把所有贡献加起来了,所以每个网格收到的总权重是不均匀的——近处过饱和、远处欠采样,加上深度误差,就出现了拖影。后向投影只用了稀疏采样的几个点,等于把列的信息扔掉了。

DualBEV 的两个核心模块,就是分别把行和列的信息用起来。

2.3 两个模块分别补哪块短板

第一个模块,我按功能叫它视角一致性特征增强。它用的是行方向的统计量:对每个图像像素,把它对 BEV 的贡献分布压成一个标量权重,再拿这个权重去加权图像特征。直观理解是——如果一个像素的深度分布很尖(置信度高),说明它大概率落在某一个确定的网格上,这个像素的特征值得被强化;如果深度分布很平(置信度低),说明这个像素可能会被涂抹到一大片区域,那就该被压制。这一步做在图像特征上,等于是给后面的后向投影提供了一个"几何上更干净"的输入。

第二个模块,我按功能叫它概率对应加权。它把对应关系矩阵直接当作注意力的先验,去修正原本的空间交叉注意力打分。同时,它用列方向的质量分布来判断哪些 BEV 网格"吃不饱",对吃不饱的网格额外补采样点。这一步补的是后向投影采样稀疏的问题。

两个模块的方向是相反的:一个从图像侧约束 BEV,一个从 BEV 侧回补图像。合起来,就形成了双向的闭环。这也是"Dual"这个名字的由来。

3. 核心细节解析:概率对应到底怎么算

3.1 反投影的完整链路与关键参数

这一块我尽量写细,因为这是整个方案里最容易写错的地方,错一个符号、错一个坐标系约定,模型照样能训,但涨点全无。

单个像素 (u, v) 在第 d 个深度 bin 上的反投影,标准链路是这样的:

  1. 像素坐标去畸变、归一化:x = (u - cx) / fx,y = (v - cy) / fy,其中 fx, fy, cx, cy 是相机内参。
  2. 乘以深度值:p_cam = [x·d, y·d, d],注意这里的 d 是沿相机光轴的深度,不是欧氏距离。如果你用的深度 bin 是按欧氏距离划分的,这里要除以射线方向余弦,否则远处会系统性偏移。
  3. 相机系转自车系:p_ego = R · p_cam + t,R 是 3×3 旋转,t 是 3×1 平移。
  4. 拍扁:忽略 z 分量,取 (x_ego, y_ego)。
  5. 落网格:grid_u = floor((x_ego + range_x) / res),grid_v = floor((y_ego + range_y) / res)。
  6. 边界检查:落在 [-range, range] 之外的点直接丢弃。

常见参数组合参考:感知范围取 51.2 米,分辨率 0.8 米,得到 128×128 的 BEV 网格;深度区间取 1 到 60 米,用 59 或 88 个 bin。这些数字不是随便定的——分辨率 0.8 米大约对应一个成年行人下半身的宽度,再细意义不大但计算量翻倍;深度 bin 数量要和特征图分辨率匹配,bin 太多会导致每个 bin 分到的梯度不足。

我踩过的一个坑是:内参在数据增强之后要跟着更新。如果你做了随机缩放、裁剪、翻转,相机内参必须同步变换,否则反投影全错。很多基线代码里这一步是写死在外参处理里的,加新数据增强的时候特别容易漏。

3.2 行归一化统计量的计算与使用

拿到稀疏的对应关系之后,按图像像素索引做一次聚合,就能得到每个像素的总概率质量。理想情况下这个值应该严格等于 1(因为深度分布和为一),但实际会因为边界丢弃、网格越界而小于 1。

这个"小于 1"的残差恰恰是一个非常有用的信号:它意味着这个像素投影出去的点全都跑到感知范围外了,或者落到了无效网格上。这种像素的特征,对 BEV 只有负贡献。所以我在实现里会额外把残差也当成一个门控因子,直接把这类像素压掉。这一步不在论文的核心公式里,是我自己加的工程优化,实测在城市场景下对小目标的召回有小幅帮助。

具体用法上,把每个像素的这个标量权重过一个小的 MLP 或者简单的 Sigmoid,得到 0 到 1 之间的门控值,然后做逐元素乘到图像特征上。注意这里要保证门控值的均值不要偏离 1 太远,否则会破坏后面模块的数值尺度。我的做法是在损失里加一个极弱的正则,约束门控值的均值接近一个超参(比如 0.8),效果比完全放任要稳。

3.3 列方向质量分布与稀疏区识别

列方向的统计量,是把对应关系按 BEV 网格索引聚合,得到每个网格收到的总概率质量。这个量在近处会非常大,在远处和遮挡区会非常小,直接拿来当权重会被数值尺度带偏,所以要先做一次对数压缩或者分位数归一化。

真正有价值的是它相对于邻域的相对大小。一个网格如果周围邻居都收到了几百的质量,它只收到个位数,那它就是典型的"吃不饱"网格——大概率是被遮挡、或者处在两个相机的视野缝隙里。这类网格恰恰是后向投影最容易出问题的地方,因为采样点撒在图像上可能落在毫无信息的背景上。

所以我在实现时用的是局部对比度而不是绝对值:对质量图做一个 3×3 或 5×5 的均值池化,用原图除以池化结果,得到一个相对稀疏度指标。这个指标大于某个阈值的网格,就标记为需要补采样。

3.4 视角一致性特征增强的实操写法

按上面两节的思路,这个模块的代码骨架大致是这样:

import torch import torch.nn as nn class ViewConsistencyEnhance(nn.Module): def __init__(self, feat_channels, hidden=64): super().__init__() # 输入是逐像素的统计量:总质量、残差、深度熵 self.mlp = nn.Sequential( nn.Conv2d(3, hidden, 1), nn.ReLU(inplace=True), nn.Conv2d(hidden, feat_channels, 1), nn.Sigmoid() ) def forward(self, img_feat, stat_maps): # img_feat: [N, C, H, W] # stat_maps: [N, 3, H, W] 逐像素统计量 gate = self.mlp(stat_maps) # 用 1 + gate 残差形式,避免训练初期把特征压死 return img_feat * (1.0 + gate)

三个统计量我是这么选的:总质量(行和,反映该像素是否有效)、深度熵(反映深度分布是尖还是平)、以及该像素命中的 BEV 网格数量(反映涂抹长度)。用残差乘法的形式而不是直接相乘,是因为训练初期门控网络是随机的,直接乘会把特征分布打乱,残差形式能让它从恒等映射开始慢慢学。

注意:这个模块一定要放在深度头之后、后向投影之前。放到深度头之前,统计量还不存在;放到后向投影之后,就失去意义了。

3.5 概率对应加权与补采样的组合方式

后向投影原本的注意力打分是查询特征和采样特征的相似度,经过 Softmax 得到权重。DualBEV 的做法是把对应概率作为先验加进去,通常有两种加法:一种是加在 Softmax 之前的 logits 上(相当于乘性先验),一种是加在 Softmax 之后作为权重(相当于后验修正)。我两种都试过,前者在小目标上更稳,后者在训练初期更稳,最终我选的是前者,因为 PBN(概率对应先验)和 attention logits 的数值尺度比较容易对齐。

补采样的实现是:对稀疏度超阈值的网格,在原本 K 个采样点之外再加 K' 个点,这 K' 个点的位置不是学出来的偏移,而是直接用对应关系矩阵里质量最大的那几个像素位置。这样等于用几何信息兜底,保证每个网格至少能看到一些有效像素。

采样点数量上,我的经验是 K 取 4 到 8,K' 取 2 到 4。K 太大带来的收益递减很快,而且显存吃紧;K' 只需要覆盖"原本完全没采到"的情况,不需要多。

4. 实操落地:从零改造一个 BEV 检测基线

4.1 基线选择与环境准备

我的建议是先找一个同时具备深度头和 BEV 查询的基线。市面上大部分开源 BEV 框架是二选一的,但有两类可以直接用:一类是本身就在做混合的框架,另一类是模块化程度高、能把两个 head 拆开拼的框架。如果没有合适的,就选一个前向投影的基线,因为它本身就有深度分支,你只需要补一个轻量的后向投影分支。

环境上没什么特殊要求。训练分辨率 256×704、批大小 4 卡、单卡 batch 2 的配置,在 24G 显存的卡上跑得动。如果你要上 900×1600 的高分辨率,那得先把深度头的输出通道数压下来,否则中间激活值会先把你干掉。

迁移已有的检测权重时要注意:新增的两个模块是随机初始化的,直接加载旧权重会让损失在头几个 iteration 剧烈抖动。我的做法是给新模块的最后一层初始化为接近恒等(Sigmoid 的 bias 设成一个负值,让初始门控接近 0),然后把学习率单独调高一点,让它们快速跟上。

4.2 关键代码:稀疏对应关系的构造

下面这段是我实际用的构造逻辑,去掉了具体框架的依赖,只保留数学部分:

import torch def build_correspondence(depth_prob, points_ego, bev_range, bev_res, feat_hw): """ depth_prob: [N, D, H, W] 已 softmax 的深度分布 points_ego: [N, D, H, W, 3] 每个 (像素, 深度bin) 反投影后的自车系坐标 bev_range: float, 感知半径 bev_res: float, 网格分辨率 return: 稀疏表示 (indices, weights) 以及行列统计量 """ N, D, H, W, _ = points_ego.shape bev_size = int(2 * bev_range / bev_res) x = points_ego[..., 0] y = points_ego[..., 1] gu = torch.floor((x + bev_range) / bev_res).long() gv = torch.floor((y + bev_range) / bev_res).long() valid = (gu >= 0) & (gu < bev_size) & (gv >= 0) & (gv < bev_size) \ & (points_ego[..., 2] > 0) flat_grid = gu * bev_size + gv flat_pix = torch.arange(H * W, device=depth_prob.device).view(1, 1, H, W) flat_pix = flat_pix.expand(N, D, H, W) # 行列统计量:直接对概率质量聚合,避免构造稠密大矩阵 row_mass = depth_prob.masked_fill(~valid, 0).sum(dim=1) # [N,H,W] col_mass = torch.zeros(N, bev_size * bev_size, device=depth_prob.device) col_mass.scatter_add_(1, flat_grid[valid].view(N, -1), depth_prob[valid].view(N, -1)) return dict(grid=flat_grid, pix=flat_pix, valid=valid, weight=depth_prob, row_mass=row_mass, col_mass=col_mass)

这段代码有三个刻意的地方。第一,全程没有构造 N×D×H×W×BH×BW 的张量,只保留了索引和权重,显存占用可控。第二,列统计量用scatter_add_一步算完,避免多做一次全量遍历。第三,points_ego[..., 2] > 0这条判断不能省——它过滤掉落在相机后方的点,这类点反投影出来的坐标是对称的假点,不加过滤会引入严重误差。

4.3 两个模块的插入位置与训练配置

改造的插入点一共两个,我按数据流顺序说:

第一个插在骨干网络输出之后、后向投影的查询生成之前,用来做图像特征增强。注意这一步要作用在后向投影所使用的那一份特征上,如果框架里前向和后向用了不同的特征层,别忘了都加上,或者至少在共享的那一层加。

第二个插在后向投影的注意力计算内部,替换掉原始的 Softmax 前 logits 计算,把对应概率作为加性先验并进去。

训练配置上,我用的是 AdamW,骨干学习率 2e-4,新增模块 1e-3,权重衰减 0.01,warmup 500 iteration,总轮数 24。学习率曲线用余弦退火。相比原基线,我给分类损失降了一点权重(因为几何先验变强之后,模型更容易过拟合到位置而忽略语义),给深度监督保持原样。

实测下来,收敛速度是这套方案最直接的好处——在原基线上要跑到第 12 轮才稳定的指标,改造后一般第 8 到 9 轮就能达到。这个差异在算力紧张的时候非常实在。

4.4 计算开销的量化对比

很多人关心加了这么多东西会不会变慢。我把关键模块的开销列一下,用相对值表示,参考硬件是一张消费级 24G 卡、输入分辨率 256×704、6 相机:

环节相对开销说明
深度头1.00x基线已有,未改动
对应关系构造0.18x主要是 scatter_add 和索引运算
视角一致性增强0.05x三个 1x1 卷积,可忽略
概率加权注意力0.12x相比原始 attention 增量很小
补采样分支0.09xK' 取 2 时基本无感
总增量约 0.35x相比基线整体前向耗时

参数量增量大概在 2% 以内,因为新增的全是小卷积和逐元素运算,没有大矩阵。真正吃显存的是补采样引入的额外采样点,K' 每加 1,注意力部分的激活值大约涨 8%,所以别贪多。

5. 常见问题与排查技巧实录

这套东西改起来不难,难在调通。下面这张表是我和同事在好几个项目里反复遇到过的症状,配上定位思路:

症状高概率原因处理方式
训练损失能降但指标完全不涨对应关系矩阵的行列索引把 H 和 W 搞反了打印一个已知像素的投影结果,人眼核对坐标
近处目标变好、远处明显变差深度 bin 按欧氏距离划分但反投影用了光轴深度统一深度定义,或者在反投影时补方向余弦
训练初期 loss 爆炸新增门控模块初始化太激进最后一层 Sigmoid 的 bias 设负值,从恒等映射起步
显存 OOM 但参数量不大补采样点数太多,或 scatter 中间张量未释放K' 降到 2,中间张量用完立刻 del
多相机重叠区出现重影列方向的概率质量在重叠区被重复累加在列归一化前对重叠区做归一或取最大值
模型对数据增强非常敏感内参没有随增强同步更新检查增强管线的内参、外参同步逻辑
单帧指标正常,时序融合后抖动时序对齐用的位姿与 BEV 网格原点不一致统一到同一自车坐标系,检查位姿插值

挑三个展开说说。

关于行列索引搞反。这是最高频、也最隐蔽的错误。因为反了之后模型照样能训、损失照样降,只是指标上不去。定位方法很简单:取一个正前方的固定像素,比如图像中心点,手动算一遍它深度取 10 米时的 BEV 坐标,应该落在自车正前方 10 米、横向偏移接近 0 的位置。如果算出来是横向 10 米,那就是反了。我现在的习惯是在写反投影的第一版代码时就把这个手算对照用例写成单元测试,跑一次省三天。

关于多相机重叠。相邻相机有视野重叠,一辆车可能同时出现在两个相机的画面里。前向投影时,这两个相机都会往同一个 BEV 网格里投特征,导致该网格的总质量是单个相机的两倍。如果不处理,这个区域的响应会异常高,检测框容易出重叠框。最简单的处理是在列归一化阶段按相机数做除法,更稳的做法是根据重叠区域的实际像素数做加权。

关于时序融合抖动。加了时序之后指标掉,八成是坐标系对齐的问题。BEV 网格的原点是在自车中心还是后轴中心,不同框架不一样;位姿是这一帧的绝对位姿还是相对上一帧的增量,也不一样。我一般会在时序模块前后各打印一次同一个静态物体的位置,看它跨帧漂不漂,一测就知道。

提示:调这类几何强相关的模块,最重要的习惯是少看指标,多看可视化。把 BEV 特征图、深度分布图、列质量图分别 dump 出来看。指标涨了 0.3 你可能花一周才搞明白为什么,图里一眼就能看出是不是深度分布糊成一片。

6. 我个人在工程里踩出的几条经验

最后说几条不太容易写进文档、但很影响成败的东西。

别迷信"零成本"这个说法。论文里说几乎不增加计算量,指的是 FLOPs 层面。实际部署时,真正的瓶颈往往是那些 scatter 和 gather 操作带来的不规则访存,这些在 FLOPs 统计里看不到,但在边缘设备上能耗比很差。如果你的目标是车端部署,一定要在真实硬件上 profile 一遍,别只看论文表格。

深度监督的权重比你想的重要。整套方案的上限,很大程度上取决于深度分布本身的质量。如果深度头训得很差,行归一化的统计量就是噪声,后面所有模块都在放大噪声。我在一个项目里花了整整两周调两个新模块,最后发现问题出在深度监督的正则项上——关掉它之后涨点立即回来了。经验是:先单独把深度分支调到能可视化看出车轮廓,再上这两个模块。

补采样不要做成固定阈值。我一开始用固定阈值判断稀疏网格,结果在夜间场景下大量网格被判为稀疏(因为图像整体偏暗,特征响应弱),补采样直接爆炸,延迟翻倍。后来改成按每帧的分位数动态取阈值,取质量分布的 15% 分位作为线,稳定性好很多。这个调整思路其实很通用:凡是涉及"绝对量"的判断,尽量换成"相对分位"。

能复用就复用,别重复算。对应关系矩阵在视角增强和后向投影里都要用,一定要缓存。我见过有人在两个模块里各构建了一次,白白多花 30% 的时间。缓存的做法是把稀疏索引和权重存下来,后面的模块只读不改。

最后一点,也是我觉得最值得说的:统一视角这件事,价值不只在检测上。这套对应关系的思路,换成占用预测、换成地图构建、换成多模态融合,逻辑都是通的——本质是给"图像证据"和"空间证据"建立一张可解释的桥。我现在做新任务时,会先把这张桥搭出来看看,往往会发现原本以为是模型容量不够的问题,其实是几何约束没给到位。

返回列表