状态:源码解读。 2026-08-25 对
valeoai/DrivoR@main逐行核对, 形状与时延数字在 timm 1.0.22 + torch 2.2 上实跑复现。
一眼版
4 路相机 × 1148×672 RGB
↓ 归一化 → (4, 3, 672, 1148)
↓ patch_embed Conv2d(3, 384, 14, 14)
3,936 patch / 图 48 × 82 = 3,936
↓ 拼上 1 cls + 4 reg + 16 scene token → 3,957
↓ 12 层 ViT(DINOv2 权重冻结 + LoRA r=32)
↓ 丢掉全部 3,936 个 patch
16 token / 图 压缩 246×
↓ Linear(384 → 256)
(B, 64, 256) 4 路 × 16
| 数字 | |
|---|---|
| 输入像素 | 4 × 3 × 672 × 1148 = 9,257,472 |
| 输出数值 | 64 × 256 = 16,384 |
| 整体压缩 | 565 × |
| 骨干参数 | 23.0 M(ViT-S/14) |
| LoRA 可训练参数 | 0.59 M = 主干的 2.6% |
| 4 路一次前向(CPU) | 4,882 ms |
输入长什么样
官方素材,原样引用——valeoai/DrivoR 仓库里的 assets/navsim_cameras.gif:

来源:valeoai/DrivoR ·
assets/navsim_cameras.gif· Apache-2.0
3×3 宫格:外围八格是 NavSim 的八路环视相机,中间那格是 BEV 俯视图(画的是标注,不是模型输入)。 框是数据集标注,也不进模型。
注意 DrivoR 只用其中四路——drivoR.yaml 里 cam_f0 / cam_l0 / cam_r0 / cam_b0 有值,
cam_l1 / l2 / r1 / r2 全是空列表。也就是上中、中左、中右、下中这四格;
四个斜角的相机加载都不加载。
这四张 RGB 就是整条视觉支路的全部输入。
一、输入侧:从数据集到张量
1.1 哪几路
NavSim 的 SensorConfig 有 8 个相机位:
cam_f0 前
cam_l0 cam_l1 cam_l2 左前 / 左侧 / 左后
cam_r0 cam_r1 cam_r2 右前 / 右侧 / 右后
cam_b0 后
drivoR.yaml 里只开了四路,其余留空:
cam_f0: [3] cam_l0: [3] cam_r0: [3] cam_b0: [3]
cam_l1: [] cam_l2: [] cam_r1: [] cam_r2: []
[3] 是帧索引,不是路数。 它告诉数据加载器"这一路只加载第 3 帧"——也就是 4 帧历史里的最后一帧。
配合 full_history_status: false,整个模型是单帧的。
而模型侧数 num_cams 只看列表空不空:
if len(self._config["cam_f0"]) > 0: self.num_cams += 1
所以改成几路相机是纯配置改动。
1.2 六步预处理
drivor_features.py::_get_camera_feature,每一路都走同样六步:
① 取末帧 cameras = agent_input.cameras[-1]
② 缩放 im.resize(image_size) → 1148 × 672
③ 内参跟着缩放 cam_K[0,0] *= W_new / W_old …
④ 归一化 im = (im/255 − ImageNet_mean) / ImageNet_std
⑤ 换轴 permute(2,0,1) → (3, 672, 1148)
⑥ 堆叠 torch.stack(images) → (N, 3, 672, 1148)
1148 = 82 × 14、672 = 48 × 14——分辨率是按 patch-14 倒推挑的,不是随便取的。
1.3 内外参算了,但模型不用
第 ③ 步认真地把内参按缩放比例改写了,还算了 lidar2cam 外参矩阵:
data = {"image": ..., "cam_K": ..., "world_2_cam": ...}
但在 drivor_model.py 的 forward 里,只有 features["image"] 被取用,
cam_K 和 world_2_cam 从头到尾没人碰。
DrivoR 不做几何投影。 它不把相机像素反投到 BEV、不建体素、不用相机位姿—— 四张图对它就是四张图,空间关系全靠注意力从数据里学。
(这是它和 BEVFormer / LSS 那一系最根本的区别:那些方法的核心就是用内外参把 多视角特征"抬"进统一的 BEV 空间,DrivoR 干脆不抬。)
二、骨干:四个东西的分工
配置里一行藏了四件事:
image_backbone:
model_name: timm/vit_small_patch14_reg4_dinov2.lvd142m
use_lora: true
finetune: false
lora_rank: 32
| 是什么 | 在这里的角色 | |
|---|---|---|
| ViT | 12 层 Transformer 骨架,patch_embed 是 Conv2d(3, 384, 14, 14) |
结构。只认规整网格 |
| DINOv2 | Meta 用 1.4 亿张图自监督训出的权重 | 数值。finetune: false 冻结不动 |
| LoRA | 低秩旁路 | 适配。眼睛不换,只调眼神 |
| Register | 16 个空白可学习 token | 压缩。论文标题里那个东西 |
2.1 Register 怎么工作
16 个空白 token 拼在 3,936 个 patch 前面,一起穿过 12 层自注意力。 每一层它们都能"看"到全部 patch,把信息吸进来;散会时只带走这 16 份摘要,patch 全扔。
拼接顺序在 _pos_embed 里:
[cls] [reg ×4] [patch ×3936] ← 先加位置编码
[scene ×16] [cls] [reg ×4] [patch ×3936] ← 再把 scene token 拼到最前面
共 3,957
注意 scene token 不带位置编码——它们本来就不对应任何空间位置。
出口 tokens[:, :16] 正好把它们切出来。
还有一处:pos_embed 的形状是 (1, 3936, 384),只覆盖 patch,不含 cls/reg
(no_embed_class = True),所以位置编码是在拼 cls/reg 之前加的。
2.2 每路相机有自己的 register,但共用一个 ViT
self.image_backbone = ImgEncoder(...) # 只有一个实例
self.scene_embeds = nn.Parameter(torch.randn(1, num_cams, 16, D)*1e-6) # 每路一组
forward 里 (B, N, C, H, W) 被 rearrange 成 (B*N, C, H, W) 走同一个骨干。
参数共享,但每一路问的问题不同——那 16 个可学习 token 是分路的。
(相机和激光则是两个独立的 ImgEncoder 实例,不共享参数。)
2.3 LoRA 只挂在 q 和 v
LoRA_ViT_timm 给 12 层的每一层 attention 挂旁路,但:
w_a_linear_k = nn.Identity() # k 不挂,除非 use_qkv=True(默认 false)
...
qkv[:, :, : self.dim] += new_q
qkv[:, :, -self.dim :] += new_v # 只改 q 和 v
r=32、只挂 q/v、12 层 → 可训练参数 0.59 M,占主干的 2.6%。
2.4 GridMask 只在训练时生效
self.grid_mask = GridMask(True, True, rotate=1, offset=False, ratio=0.5, mode=1, prob=0.7)
self.use_grid_mask = True # 写死,不在配置里
随机把图像抠成网格状空洞,ratio=0.5、70% 概率触发。
GridMask.forward 第一行是 if np.random.rand() > self.prob or not self.training: return x
——推理时自动跳过。
三、输出侧
tokens = self.model.forward_features(img, scene_tokens) # (B*N, 3957, 384)
tokens = tokens[:, :self.num_prefix_tokens] # (B*N, 16, 384)
tokens = self.neck(tokens) # Linear(384→256)
tokens = rearrange(tokens, '(b n) t c -> b (n t) c') # (B, N*16, 256)
四路 → 64 个 token,每个 256 维。 到这里图像就没了,后面所有计算都只在这 64 个向量上做。
实跑复现(timm 1.0.22 / torch 2.2 / CPU):
grid (48, 82) → 3936 patch/图 | prefix 5 | pos_embed (1, 3936, 384)
forward_features 输出 (4, 3941, 384) ← 3936 + 5,未拼 scene token 时
4 路一次前向 4,882 ms | 参数 23.0M
一个可选分支:focus_front_cam
配置里 focus_front_cam: false。打开的话前相机保留全部 token,
其余相机只留前 16 个:
front = tokens[:, 0, :, :] # 0 号相机(f0)全要
others = tokens[:, 1:, :self.num_prefix_tokens, :] # 其余只要 register
tokens = torch.cat([front, others], dim=1)
代价是 token 数从 64 涨到 3,957 + 48。默认关着。
四、往下游走
相机 4×16 = 64 token ──┐
├─ torch.cat(dim=1) ──→ scene_features
(激光支路 16 token)───┘ (默认关闭)
↓
64 条 proposal query(ego token 加在上面)
↓ 交叉注意力 × 4 轮 refine
traj_head → (B, 64, 8, 3) 连续回归,8 个位姿 (x, y, heading)
↓
scorer → 6 个 PDM 子分 → argmax → 选出 1 条轨迹
两处容易记错的:
- ego token 不是第 65 个上下文 token。源码是
traj_tokens = ego_token + init_feature.weight——它被加到 64 条 proposal query 上,上下文始终只有 64 个 scene token。 - 轨迹不是自回归生成的,也没有分箱词表。
traj_head直接连续回归 8×3,state_size = 3意味着 heading 是预测量,不是由位移方向反推。
五、三条值得记的
① 单帧,不是多帧。 cameras[-1]、full_history_status: false、cam_*: [3]
三处一致——模型只看当前这一瞬间。仓库里没有任何时序融合模块。
② 分辨率是被 patch 尺寸绑住的。 1148 × 672 能被 14 整除是设计出来的。
换骨干就得换分辨率:patch-16 的 DINOv3 需要 16 的倍数,两套尺寸不通用。
③ 算力几乎全在这一层。 3,936 patch/图 × 4 路 = 15,744 个 token 进 12 层注意力; 过完这一层之后,整个决策(proposal + refine + scorer)只在 64 个向量上做。 ② 之前是重活,之后是轻活。
参考
navsim/agents/drivoR/drivor_features.py::_get_camera_featurenavsim/agents/drivoR/layers/image_encoder/dinov2_lora.py(timm_ViT·ImgEncoder·LoRA_ViT_timm)navsim/agents/drivoR/layers/image_encoder/grid_mask.pynavsim/agents/drivoR/drivor_model.py·drivor_agent.py::get_sensor_confignavsim/planning/script/config/common/agent/drivoR.yaml- Kirby, Boulch, Xu et al. Driving on Registers, CVPR 2026
