XYT·星野图
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面
返回上一级

DrivoR 怎么处理相机:4 张图到 64 个 token

写作时间:2026-08-25 18:10
# 端到端
# 自动驾驶
# ViT
# DINOv2
# DrivoR

状态:源码解读。 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:

NavSim 八路环视相机(DrivoR 官方素材)

来源: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_feature
  • navsim/agents/drivoR/layers/image_encoder/dinov2_lora.py(timm_ViT · ImgEncoder · LoRA_ViT_timm)
  • navsim/agents/drivoR/layers/image_encoder/grid_mask.py
  • navsim/agents/drivoR/drivor_model.py · drivor_agent.py::get_sensor_config
  • navsim/planning/script/config/common/agent/drivoR.yaml
  • Kirby, Boulch, Xu et al. Driving on Registers, CVPR 2026
avatar

XYT

以文字为星,以思考为野,绘一幅属于自己的星野图。

RECOMMENDED

想法博客

2026-08-27 23:50

机器人姿态输入对比:DrivoR 的 11 维与 TD25A 的 4 维

2026-08-26 15:30

覆盖染色图:输入契约、逐层解析与在 DrivoR 里的接法

2026-08-25 00:40

Table of Contents