把 BEVFormer 掰开揉碎:从 6 张平视照片,到一张俯视特征图。空间交叉注意力、时序自注意力、和雷达的关系——每一步数据怎么流动,全部讲清楚。
车上 6 个摄像头拍的是平视照片——跟你眼睛看到的一样:近处的车大、远处的车小,还互相遮挡。这种"透视图"对人脑友好,但对规划模块是噩梦。
规划模块要回答的是:"前方 15 米偏左 3 米有没有车"。它需要的是俯视的、带真实距离的地图,而不是一张"越远越小"的照片。所以必须把 6 张平视图"拼接 + 压平 + 摆正"成一张俯视图。这张俯视图,就是 BEV(Bird’s-Eye-View)。
你玩赛车游戏,屏幕角落那个"小地图"就是 BEV。你的第一视角(挡风玻璃视角)能看清细节但判断不了全局位置;小地图用俯视角告诉你"对手在你左后方 20 米"。BEVFormer 做的事,就是实时把 6 个第一视角摄像头,转成那张小地图。
难点在于:从"平视"到"俯视"这一步,不是简单的图像旋转。一个像素在照片里的位置,要对应到现实世界俯视图的哪个格子,取决于这个像素到底是远处的物体还是近处的物体——而单张照片丢失了深度信息。BEVFormer 用一套巧妙的注意力机制解决了这个问题,下面一步步看。
BEVFormer 整体是一个"喂进 6 张图,吐出 1 张 BEV 特征图"的管道。中间叠了 6 层结构一样的编码器。先记住这张总图,后面每一节都是在放大其中一块。
三个关键部件,后面三节逐一拆解:
作为工程师,看懂数据的 shape 怎么变,比看懂公式更重要。下面是一帧数据从输入到输出的完整形状变化(以 nuScenes 标准配置为例)。
你在 Project 2 第 3 章训练时,显存爆掉八成是栽在这里:6 路图像特征 + 40000 个 query + 6 层编码器,中间激活值非常大。这就是为什么我们用 batch_size=1 + 梯度累积 + FP16。看懂上图你就知道显存到底被谁吃了。
这是整个 BEVFormer 最反直觉、也最关键的设计。传统做法是"把像素推到 BEV 空间"(从图像出发)。BEVFormer 反过来:先准备一张空白的俯视格子地图,让每个格子主动去图像里找自己需要的信息(从 BEV 出发)。
BEV query 是一组 可学习的参数,形状 200×200×256。你可以把它想成 40000 个小探头,每个探头钉在俯视地图的一个格子上(比如"自车正前方 10 米"那一格),它的任务是:"我负责的这块地,现实里有什么?我去 6 张照片里问问。"
想象一张大棋盘(200×200 格)铺在马路上空,自车停在棋盘正中。每个格子里站着一个小侦察兵,手里拿着 6 张照片(6 个摄像头拍的)。每个侦察兵只关心自己脚下这块地:他抬头在 6 张照片里找"我这块地对应照片的哪个位置",然后把那里的信息记下来。40000 个侦察兵同时工作,填满整张棋盘,就得到了 BEV 特征图。
现实世界是 3D 的,而 BEV 格子只有 2D 坐标 (x, y)。一个格子上方可能是路面、可能是车顶、可能是红绿灯。所以每个格子不是只取 1 个点,而是沿高度方向取 4 个参考点(论文中从 -5m 到 3m 每隔约 2m 取一个),组成一根"柱子(pillar)"。
"6 个摄像头怎么融合成 BEV?"——答案就在这里:不是把图像拼接,而是每个 BEV 格子拿着自己的 3D 坐标,用相机标定参数反向投影到各个相机,去采样对应位置的特征。一个格子可能同时落在 2 个相机的重叠视野里,那就两个相机的特征加权平均。融合是在"每个格子各自查询"这个过程中天然完成的。
先给结论:是的,完全是一回事。BEVFormer 的 256 维,和 Transformer / BERT / GPT 里的 d_model(隐藏维度)是同一个概念——只不过把"句子里的词"换成了"地图上的格子"。下面把这件事讲透。
这个 256 不是某个公式的结果,而是作者设计选定的超参数,在 BEVFormer 代码里叫 embed_dims。它的作用是:让模型内部所有东西都统一成 256 维向量,这样它们才能互相做注意力运算——query、key、value 维度必须对齐,否则矩阵乘法都做不了。这是注意力机制能跑起来的硬前提。
| 概念 | 原版 Transformer | BERT-base | GPT 类 | BEVFormer |
|---|---|---|---|---|
| 隐藏维度的名字 | d_model | hidden_size | n_embd | embed_dims |
| 典型取值 | 512 | 768 | 768~12288 | 256 |
| 序列里每个"token"是 | 一个词 | 一个词片 | 一个词片 | 一个 BEV 格子 |
| 序列长度 | 句子长度 | ≤512 | 上下文长度 | 40000 (200×200) |
把这张表横着读:200×200×256 摊平就是 40000×256——和 BERT 处理一个 [序列长度 × 768] 的句子,在数据结构上一模一样。
这是你问得最关键的一点。先说共同的本质:无论 768 还是 256,这些维度不是人为规定每一维代表什么的。没有"第 5 维 = 是不是动物""第 200 维 = 颜色"这种事先约定。它们是训练中自己学出来的分布式表示——语义被"打散"编码在所有维度的组合里。
不是一维管一个意思,而是一个语义由很多维度协同表达,一个维度也参与表达很多语义。就像"猫"的含义不写在某一维上,而是散落在 768 个数的整体模式里。这就是为什么你没法指着某一维说"这维是啥意思"——它对人不可读,但对模型是高效的。
| 维度 | NLP 词向量(768维)隐式编码 | BEV 格子向量(256维)隐式编码 |
|---|---|---|
| 语义内容 | 词义、近义/反义关系、一词多义的当前义 | 这块地有没有物体 |
| 语法角色(主语/谓语/宾语) | 是什么类别(车/人/锥桶/路面) | |
| 上下文关系(它指代谁、修饰谁) | 物体朝向、尺寸 | |
| 情感、语气、领域风格 | 运动速度(来自时序融合) | |
| 为何更宽/更窄 | 语言信息密度极高,768 起步,大模型上万 | 单格语义相对简单,256 够用 |
这是理解表示学习的关键。词向量和 BEV 格子向量,语义来源路径不同,但机制相同——都来自"任务监督下的梯度下降"。
初始时每个词就是一串随机数(毫无意义)。训练时模型被要求完成任务(预测下一个词 / 完形填空)。为了把任务做对,模型被迫调整这些数字——逐渐让"猫"和"狗"的向量靠近(都是动物)、让"猫"和"汽车"的向量拉远。语义是任务逼出来的副产品。
每个格子的 256 维,初始也是随机的可学习参数(就是第 3 节那个"空白探头")。前向传播时,它通过空间交叉注意力从 6 路图像采样特征,通过时序自注意力融合上一帧记忆。这一步把"原始视觉信息"灌进了这 256 维。
光采集还不够,模型不知道该把信息编码成什么样。下游任务的损失函数是真正的老师:检测头要求"这格有车就得能解码出车的框",分割头要求"这格是车道线就得能分出来"。这些 loss 反向传播回来,逼着这 256 维去编码"对检测/分割有用的语义"。
训练收敛后,这 256 维就自发地组织出了"有无物体、类别、朝向、速度"这些信息——不是因为我们规定它这么做,而是因为只有这么编码,下游任务才能做对、loss 才能降下来。
词向量的语义来自语言任务(预测词)塑造;BEV 格子的语义来自感知任务(检测/分割)塑造。两者机制完全相同:随机初始化 → 前向采集信息 → 任务 loss 反向传播 → 维度被逼着编码出有用的语义。维度本身只是"容器",装什么由任务决定。这就是"表示学习"四个字的真正含义——表示不是设计的,是学出来的。
还记得你 VLA 调试时栽过的"hidden_size 必须动态解析,硬编码会出错"吗?那个 hidden_size 和这里的 256 是同一个东西——模型的隐藏维度。不同模型这个值不同(Qwen-3B 一个值、7B 另一个值、BEVFormer 是 256),因为它是每个模型自己选的超参数,不是固定常数。你当时踩的坑,根源就是没意识到"隐藏维度是模型相关的设计选择"。现在这个概念彻底通了。
上一节讲了"投影采样"的直觉,这一节讲它的正式机制:Spatial Cross-Attention(SCA)。名字拆开看就懂——"空间"指的是把图像空间信息搬到 BEV 空间;"交叉"指 query 来自 BEV、key/value 来自图像(两个不同来源,所以叫 cross);"注意力"指用注意力机制决定采哪里。
普通注意力(global attention)会让每个 query 跟图像里所有像素算一遍关系。6 路图像特征有几万个像素,40000 个 query 全连一遍——计算量爆炸,根本跑不动。
BEVFormer 用可变形注意力(Deformable Attention):每个 query 不看全图,只在"投影落点"附近采几个点(稀疏采样)。这就是为什么图 3 里每个格子只投影到几个相机的几个位置。
格子 (x,y) 沿高度取 4 个 3D 点。这 4 个点是这个 query 的"关注锚点"。
用相机内参 K 和外参 [R|t],把每个 3D 点投影成某张照片上的 2D 坐标 (u,v)。一个点可能投到前视相机,另一个点可能落在前左相机。投不到的相机(背对着的)直接忽略。
在每个 (u,v) 落点附近,用双线性插值采样图像特征。可变形注意力还会学习一个偏移量,自动微调采样位置(因为标定不完美、物体有形变)。
把所有命中相机、所有高度点采到的特征,按注意力权重加权求和,写回这个格子的 256 维向量。一个格子的"采购"就完成了。
# 空间交叉注意力的本质(伪代码)
def spatial_cross_attention(Q_p, image_features):
# Q_p: 某个 BEV 格子的 query 向量 (256,)
total = 0
hit_cameras = 0
for cam in all_6_cameras: # 遍历 6 个相机
for j in range(4): # 遍历柱子上 4 个高度点
uv = project(p, cam, height_j) # 3D点 → 2D像素坐标
if uv in cam.image_bounds: # 这个相机能看见才采
feat = deform_attn(Q_p, uv, image_features[cam])
total += feat
hit_cameras += 1
return total / hit_cameras # 命中的相机做平均(融合!)
注意最后一行 total / hit_cameras——这就是多相机融合发生的地方。一个格子如果同时被前视和前左两个相机看到,它的特征就是这两个相机的平均;只被一个相机看到,就用那一个。这种"按可见性自适应融合"远比硬拼接鲁棒。
下面这个演示把前面讲的"投影找特征"完全跑活了。点击(或拖动)左边俯视网格里的任意格子,右边会实时显示:这个格子命中了哪几个相机、在每张照片上的具体像素落点、以及它为什么跳过另几个相机。自车永远在网格正中,车头朝上。
① 正前方的格子只命中前视相机一张;② 斜前方的格子常常同时命中"前视+前左/前右"两张(视野重叠区,这里就是融合发生的地方);③ 把格子点到自车正侧面或正后方,会看到前视相机变灰(跳过),后方相机亮起。这正是"该用哪张图由几何自动决定,不是人为分配"的直观体现。
只看当前一帧有两个致命问题:① 算不出速度(一张静止照片看不出车在动多快);② 怕遮挡(前车被卡车挡住这一帧,模型就"瞎了")。人类开车靠的是连续观察——上一秒看到的车,这一秒就算被挡住,你也知道它还在。BEVFormer 用时序自注意力(Temporal Self-Attention, TSA)复现这个能力。
BEVFormer 不重新处理历史所有帧(太贵),而是只保留上一帧算好的 BEV 特征,递归地往前传。当前帧的每个 query 会同时看两个东西:① 自己当前帧的初始 query;② 上一帧对应位置的 BEV 特征。
就像你开车进隧道,前车被大货车挡了一下。你不会慌——因为0.5 秒前你看到它在那,你的大脑自动"补"出它现在大概的位置。时序自注意力就是模型的这个"短期记忆 + 脑补"能力。论文里专门强调:它对速度估计和重度遮挡物体检测帮助巨大,而几乎不增加计算量。
对齐那一步很关键:自车本身在移动。上一帧的 BEV 是以"上一帧自车位置"为中心的,这一帧自车往前开了几米。所以要先用自车位移(里程计/IMU 给的 SE(3) 变换)把历史 BEV"挪正",再做注意力。否则两帧坐标系对不齐,记忆就成了噪声。这也是你 Project 2 里"驾驶指令要用帧间 yaw 差,不是绝对 yaw"那个教训的同源逻辑。
这是你问得最好的一个点,也是最容易被各种博客讲混的地方。直接给结论:
原版 BEVFormer(2022,ECCV)是纯相机方案——只吃 6 路图像,不碰任何雷达。但 BEVFormer 的作者后来出了期刊扩展版(2024,T-PAMI),把它升级成了多模态:激光雷达也能通过"空间交叉注意力"融进同一个 BEV 空间。
为什么 BEV 这个表示天然适合融合多种传感器?因为BEV 是一个统一的"世界坐标"空间。不管信息来自相机、激光雷达还是毫米波,只要能投影到"自车周围 100m×100m 的俯视网格",就能在同一张图上叠加。
| 传感器 | 给 BEV 贡献什么 | 怎么融进去 | 谁在用 |
|---|---|---|---|
| 相机 ×6 | 颜色、纹理、语义(红绿灯、车道线、是轿车还是卡车) | 空间交叉注意力,投影采样(本文主线) | Tesla、小鹏(纯视觉) |
| 激光雷达 | 精确的 3D 距离和形状(几何信息最准) | 点云先编码成 BEV 特征,再和相机 BEV 拼接/交叉注意力 | 华为、理想、Waymo |
| 毫米波雷达 | 直接测速度(多普勒)、全天候(雨雾不怕)、远距离 | 稀疏点投影到 BEV 网格,作为额外通道或 query 补充 | 几乎所有量产车的兜底 |
你 Project 2 跑的是纯相机 BEVFormer(对标小鹏/Tesla 路线),这是入门最干净的起点。Project 3 第 5-6 章会加 LiDAR(PointPillars)+ Camera 融合,本质就是上图——把点云也编码成 BEV,和相机 BEV 拼起来。看懂这张图,你 Project 3 的融合章节就有了全局认知,不会一头雾水。
BEVFormer 不是终点,而是现代自动驾驶感知栈的"地基层"。它输出的那张 BEV 特征图,是后面一切任务的共同输入。
# 现代端到端自动驾驶的典型数据流(以 UniAD 为例)
6路相机 ─► BEVFormer ─► BEV特征 ┬─► TrackFormer # 检测+跟踪
├─► MapFormer # 在线建图(车道线)
├─► MotionFormer # 预测别人怎么动
├─► OccFormer # 占据栅格(哪有障碍)
└─► Planner # 规划自车轨迹
# BEVFormer 是"眼睛",上面 5 个模块都靠它喂的 BEV 特征工作
| 厂商 | 路线 | BEV 怎么用 |
|---|---|---|
| Tesla FSD | 纯视觉 + Occupancy Network | 8 相机 → BEV → 占据网络,完全不用 BEV 高精地图,端到端到控制 |
| 小鹏 XNGP | 纯视觉 + 轻地图 | BEV 感知 + 神经网络规划,"无图"全国可开 |
| 华为 ADS / 理想 | 激光雷达 + 相机融合 | 多模态 BEV,几何最准,城区复杂场景更稳 |
纯检测框正在被占据网络(Occupancy)取代——不再只输出"框",而是输出整个 3D 空间的占据情况(连没见过的异形障碍物也能识别)。同时端到端把感知-预测-规划拧成一个网络。但无论怎么演进,"把多传感器压成统一 BEV 表示"这个核心思想没变,BEVFormer 开创的范式仍是地基。
这套理解打通后,回去看你 Project 2 第 3 章的 BEVFormer 训练脚本,那些 bev_h=200、bev_w=200、num_points_in_pillar=4、encoder layers=6 的配置项,现在每一个你都知道它对应本文的哪张图了。带着这张"地图"去读代码,比硬啃快 10 倍。