【VLA与模仿学习】UniAD代码导读
配套:UniAD 论文精读。
本文以本地 HEAD 的标准 stage2 配置为准;论文里的模块名与 Python 类名并不总相同。以下张量形状来自源码推导,未以实际 batch 运行验证。B 是批量大小;标准配置每 GPU 为 1;A 是筛选后的目标数量,随场景变化;特征维度 D=256。源码部分旧注释仍写 300 个检测 Query,应以当前 num_query=900 和实际分配为准。
1. 先建立整体认识
UniAD 用相机和车辆状态,输出自车未来的位置序列,同时预测周围物体、道路和未来占用。
- 感知:现在有什么、在哪里、同一目标跨帧是否仍是同一个。
- 预测:其他交通参与者未来怎么走,哪些地面区域未来会被车占用。
- 规划:结合场景与导航意图,给自车规划未来怎么走。
- 控制:把轨迹变成方向盘、油门、刹车;UniAD 开源实现这里的规划头没有直接输出这些控制量。
网络不是几个完全独立的头:后面的头会读取前面产出的高维 Query,也会读取框、位置、匹配索引等结构信息。不能简化为“模块之间只传 Query,从不传框”。总调度在 uniad_e2e.py:88 与 uniad_e2e.py:235。
2.1 真正参与前向预测的输入
| 输入 | 形状或内容 | 实际用途 |
|---|---|---|
| 当前 6 路环视图像 | 单帧 [B,6,3,H,W] | 提取视觉特征 |
| 训练图像队列 | stage2 [B,3,6,3,H,W];stage1 队列长 5 | 学习时序融合与跨帧跟踪;含当前帧,并非当前帧外再加 3 帧 |
相机标定和图像元信息 img_metas | lidar2img、图像尺寸、场景 token 等 | 把空间采样点投影到各相机 |
自车状态 can_bus | 18 维,位置/姿态/运动状态信息 | BEV 对齐、状态嵌入 |
timestamp、l2g_r_mat、l2g_t | 时间戳、局部坐标到世界坐标变换 | 对齐跨帧位置、更新目标参考点 |
高层指令 command | 0=右转,1=左转,2=直行 | 告诉规划头希望往哪个方向走 |
| 历史网络状态 | 上一帧 BEV、跟踪 Query/ID、目标记忆 | 视频推理时跨帧复用 |
图像原尺寸通常为 900×1600;标准 pipeline 归一化后补齐到 32 的倍数,所以应写 H_pad,W_pad,不能把固定原图尺寸当作最终张量尺寸。标准 FPN 输出 4 个尺度,各为 [B,6,256,H_l,W_l]。
lidar2img 中的 LiDAR 表示采用的空间坐标系和标定变换,不表示模型读取了激光点云。配置 use_lidar=False、use_map=False:不把激光或现成高精地图送入视觉网络;训练和评测依然会使用数据集的框、地图、未来轨迹标注。
代码:配置 base_e2e.py:10;图像特征 uniad_track.py:153;时序训练 uniad_track.py:493;指令标签 trajectory_api.py:226。
特别注意:离线数据集用未来真实轨迹的横向位移生成左/右/直指令。这是 benchmark 的指令来源,不等于实车能预知未来;实际部署须由导航系统提供指令。
2.2 输出什么
| 输出 | 内容或形状 | 预测时间 |
|---|---|---|
| 检测与跟踪 | 3D 框、10 类类别分数、速度、跟踪 ID | 当前时刻 |
| 在线地图 | 分隔线、斑马线、道路边界实例与可行驶区域 mask | 当前局部地图 |
| 运动预测 | 最终层 [B,A,6,12,5] 的轨迹分布参数,以及 [B,A,6] 模态 log 概率 | 12 步,每步 0.5s,共 6s |
| 未来占用 | 实例 logits [B,A,5,200,200];合并占用 [B,5,1,200,200] | 当前 + 未来 4 步,共预测至 2s |
| 自车规划 | sdc_traj / sdc_traj_all:[B,6,2] | 未来 6 步,每步 0.5s,共 3s |
轨迹的 5 个数是 x,y 和三个二维高斯分布相关参数,实际激活与 NLL 参数化看 bivariate_gaussian_activation / traj_nll,不要把全部 5 维当作 5 个位置坐标。最终位置使用前两维。MotionHead 内部还联合预测自车的六模态轨迹,再把自车 Query 单独取出供规划;A 在不同内部阶段可能包含自车,进入 OccHead 前已剔除自车并筛选车辆类。
地图输出是局部语义/实例 mask,并非 SLAM 的长期全局地图。SLAM 指边定位边建持久地图,这不是 MapFormer 在此承担的任务。
3. 整体结构和数据流 Mermaid
读图时分清:Track 与 Map 共享 BEV、平行提取当前场景;Motion 同时读取两者;Occ 与 Plan 读取 Motion 特征;Occ 到 Plan 的直接路径主要是推理时避碰优化。训练 PlanningHead.forward_train() 将 occ_mask=None,并不把预测占用图直接送进规划解码器。代码 uniad_e2e.py:88、planning_head.py:139、planning_head.py:203。
4. Query 是什么,各自怎么生成
Query 可以理解为一张可训练的信息卡片:起初只有一个向量,经注意力读取特征后,逐步装入某个位置、目标或轨迹的信息。这里的任务 Query 与注意力公式中的 Q 有联系,但不能混为一个概念:任务 Query 经过线性投影才形成注意力计算的 Q,其他信息提供 K/V(用于检索和读取的特征)。
通常可写 Attention(Q,K,V)=softmax(QKᵀ/√d)V;可变形注意力则围绕参考位置采样少量点,不必让每张卡片读取所有像素。
| Query 家族 | 数量/维度 | 从哪里来 | 负责什么 | 实现 |
|---|---|---|---|---|
| BEV Query | 200×200=40000 个,256 维 | nn.Embedding(H*W,D),加二维位置编码和车辆状态嵌入 | 每个地面格读取图像与历史信息 | track_head.py:130、transformer.py:101 |
| 初始检测/Object Query | 900 个槽,初始化表每行 512 维 | nn.Embedding(901,512) 的前 900 行,拆成 256 维位置和 256 维内容 | 寻找新目标、回归类别和 3D 框 | uniad_track.py:117、uniad_track.py:178 |
| 跟踪/Agent Query | 有效目标数动态变化,输出特征 256 维 | 前一帧已识别目标的特征,经记忆和 QIM 更新继续使用 | 把同一个目标跨帧联系起来 | modules.py:115、modules.py:7、uniad_track.py:619 |
| 自车/Ego/SDC Query | 1 个初始槽;解码后 256 维 | 同一张 901 行嵌入表的最后一行,下标 900;特殊 ID -2 | 表达自车运动状态,并参与 MotionFormer | uniad_track.py:117、uniad_track.py:358 |
| 地图实例 Query | 300 个,初始化 512 维,解码后 256 维 | PansegformerHead.query_embedding | 分隔线、斑马线、道路边界等实例 | panseg_head.py:121、panseg_head.py:182 |
| 地图 Stuff Query | 1 个,初始化 512 维 | stuff_query=nn.Embedding(num_stuff_classes,2D) | 可行驶区域这种连续区域 | panseg_head.py:121、seg_mask_head.py:310 |
| Motion / Trajectory Query | 每个 Agent 6 个模式,[B,A,6,256] | 4 个类别组×6 个学习模态嵌入;加离线聚类轨迹锚点的终点位置嵌入,再与目标特征交互 | 同时表达直行、转向等多种未来可能 | base_motion_head.py:33、base_motion_head.py:47、motion_head.py:212、modules.py:20 |
| Occupancy Instance Query | 每个有效车辆 1 个,[B,A,256] | 最后 Motion 层的 6 模态特征经 MLP/max 汇聚,再拼接 track feature 与位置嵌入,用 MLP 融合 | 生成该车辆在各时间的占用 mask | occ_head.py:271 |
| Occupancy Dense Query | 注意力内部约 25×25=625 个空间 token | 主干 BEV 先重采样至占用网格,卷积降采样至 1/8 | 读取车辆实例信息,更新未来空间状态 | occ_head.py:198 |
| Planning Query | 融合后 1 个,[B,1,256] | 自车 6 模态轨迹 Query + 自车 Track Query + 指令 embedding,拼接 MLP 后对 6 模态取 max | 从 BEV 提取与自车规划相关的信息 | planning_head.py:139 |
几个关键区别:
- 检测槽与跟踪 Query 不是两张完全独立的固定学习表。“跟踪 Query”主要指已携带前帧目标信息的动态状态。每帧组合初始槽和保留的 active tracks,实际进入解码器的数量可变化,不能一直写死为 901。
- 初始参考点由 Query 的位置半部经过线性层得到,再 sigmoid;检测层逐层细化。后续帧还结合速度、时间间隔与坐标变换更新位置。
- 地图实例 mask Query 从地图定位 Decoder 的输出中筛出;区域 Stuff Query 使用单独学习表。送给 MotionFormer 的
lane_query来自args_tuple中的定位 Decoder Query,不能直接说是拼接了全部 Thing/Stuff mask Query。 - 轨迹锚点是训练数据离线聚类得到的“典型走法”,从
data/others/motion_anchor_infos_mode6.pkl读取,不是运行时随机生成,也不直接等于最终预测轨迹。4 类组是机动车、两轮车、行人、障碍物组;每个目标依据类别选择 6 个模态。 - OccFormer 的 cross-attention 中,Q 是稠密空间状态,K/V 是车辆实例特征。最后才以实例向量与空间特征的点积生成实例 mask;这与“实例 Query 直接去图上找 mask”的笼统表述有区别。
- Planning Query 不是一张独立 6 行轨迹学习表。它由上游自车特征和指令组合,再汇成 1 个 token;最后一次回归全部 6 个未来点。
5. 图像如何映射成 BEV
BEV(Bird’s-Eye View,鸟瞰表征)是统一地面坐标上的特征网格,不是 RGB 图片,也不是已经完成分类的占用图。每格有 256 个可学习特征值。
5.1 从 BEV 位置反查图像,而非先估计每个像素的深度
- 提取图像特征。 六个相机共享 ResNet101/DCNv2,再经 FPN 生成 4 个尺度,便于同时处理远处小物体和近处大物体。uniad_track.py:153
- 给每个地面格建一根虚拟竖柱。 在已知格子
(x,y)上选 4 个高度参考点。高度范围来自pc_range的[-5,3]m;具体归一化采样看get_reference_points。这不是深度相机观测。encoder.py:49 - 投影到相机。 对空间点
p=[x,y,z,1]ᵀ,计算p_img=T_lidar2img·p,再除以投影深度,得到像素(u,v),按图像尺寸归一化。相机后方与视野外的点用 mask 去掉。encoder.py:91 - 空间交叉注意力 SCA。 BEV Query 在这些投影位置附近,通过学习的采样偏移和权重,从各相机、多尺度特征中读取信息,再聚合可见相机。标定提供位置基础,网络学习怎样取特征。spatial_cross_attention.py:31
- 时间自注意力 TSA。 先按自车运动对历史 BEV 旋转和平移对齐,再融合历史与当前信息。每层按 TSA → SCA → FFN 顺序处理;FFN 是对每个 token 进一步变换特征的前馈网络。共重复 6 层。transformer.py:101、base_e2e.py:140
它解决单目深度歧义的方式是几何参考 + 多视角 + 多时刻 + 任务监督,不能理解成一次相机投影就知道了所有物体的真实深度。此路径没有单独预测深度图再做 LSS 式 lift/splat。
5.2 BEV 分辨率与范围
| 用途 | 网格 | XY 覆盖 | 每格实际大小 |
|---|---|---|---|
| 主干共享 BEV / Map | 200×200 | X/Y 都是 [-51.2,51.2]m,共 102.4m×102.4m | 0.512m × 0.512m |
| Occ 输出与规划碰撞栅格 | 200×200 | X/Y 都是 [-50,50]m,共 100m×100m | 0.5m × 0.5m |
| Occ 中间状态 | 50×50,注意力内部再到 25×25 | 对应占用范围 | 降采样计算,最终恢复为 200×200 |
102.4/200=0.512。配置的 voxel_size=[0.2,0.2,8] 不是主干 BEV 输出每格 0.2m。OccHead 使用 BevFeatureSlicer 按坐标重新采样,不能只因形状相同就把两种网格当成同一物理范围。配置 base_e2e.py:10;占用处理 occ_head.py:198。
6. 到底有多少 Transformer
按功能讲,是 6 个部分:BEV 编码器、TrackFormer、MapFormer、MotionFormer、OccFormer、规划 Decoder。 论文的四个“Former”是任务模块名,不意味着只有 4 个 Transformer 层或只有 4 次注意力。
按源码展开,有下面 9 组主要结构;Motion 那一组内部还要展开:
| 结构 | 标准配置的层数 | 注意力在问什么 | 监督来源 |
|---|---|---|---|
| BEVFormer Encoder | 6 | 该地面格从哪些图像位置、哪些历史位置取信息? | 下游任务间接监督;stage2 默认冻结 |
| Track Detection Decoder | 6 | 目标槽之间相互去重/交流,再从 BEV 读取目标信息 | 类别、3D 框、跨帧 ID、辅助轨迹 |
| Map Encoder | 6 | 对共享 BEV 再编码,提取地图相关空间上下文 | 地图头间接监督 |
| Map Location Decoder | 6 | 每个地图实例在哪里、是什么? | 类别、2D 包围框、GIoU,逐层辅助监督 |
| Map Thing Mask Head | 4 | 该地图实例覆盖哪些格子? | mask Dice,以及对应分类/框辅助损失 |
| Map Stuff Mask Head | 6 | 可行驶区域覆盖哪些格子? | 区域 mask Dice / 分类 |
| MotionFormer | 3 轮;另有 1 层 intention interaction | 与哪些车、道路、目标位置发生关系? | 每轮多模态轨迹损失 |
| OccFormer Decoder | 总计 5 层,分给 5 个时间块,各 1 层 | 该未来空间格从哪些车辆特征获取占用信息? | 当前及未来实例 mask 主/辅助监督 |
| Planning Decoder | 3 | 结合导航意图,自车应从 BEV 读取什么? | 自车真实未来轨迹及配置中的碰撞项 |
MotionFormer 每轮包含 1 个 Agent interaction DecoderLayer + 1 个 Map interaction DecoderLayer + 1 个 BEV 可变形 AttentionLayer。另在循环外用 1 个 Intention interaction EncoderLayer 让同一目标的 6 种意图交流。因此这里的“3 轮”内部有 1+3×3=10 个这类交互层,而非只 3 个普通 TransformerDecoderLayer。modules.py:20
若明确采用“每个 encoder/decoder/自定义 mask block 算 1 层,Motion 展开到其内部交互层”的口径,上表为 6+6+6+6+4+6+10+5+3=52 个主要 block。52 是这一特定计数口径,不是项目官方统一层数:不含两条 Map Mask Head 的末尾 AttentionTail,也不含跟踪 MemoryBank/QIM 的额外注意力与 FFN;这些可见 seg_mask_head.py:310、modules.py:7、modules.py:115。同一层又可能含 self/cross 两次 attention,所以 52 也不是 attention 算子数量。
配置与实现:base_e2e.py:140、base_e2e.py:246、base_e2e.py:399、base_e2e.py:334、planning_head.py:64。
7. 各部分如何监督,loss 是什么
监督就是用数据集“正确答案”与预测比较,把误差反传给参与训练的参数。GT(ground truth)表示真实标注;mask 表示哪些时间/像素有效。
7.1 总损失
默认五个任务权重均为 1:
L = L t r a c k + L m a p + L m o t i o n + L o c c + L p l a n n i n g . L=L_{track}+L_{map}+L_{motion}+L_{occ}+L_{planning}. L=Ltrack+Lmap+Lmotion+Locc+Lplanning.
每项内部已带不同 loss 权重,还包含中间 Decoder 层的辅助损失。UniAD.forward_train() 给键名加 track. / map. / motion. / occ. / planning. 前缀。注意 Motion 字典同时返回 min_ade、mr 等日志值;不能把输出字典中每个标量都再次加进优化目标,MMDetection 按 loss 键命名解析。uniad_e2e.py:88
7.2 Track:分类、框与跨帧身份
2.0 × FocalLoss:判断目标属于哪一类,降低大量简单背景的主导作用。0.25 × L1:回归编码后的 3D 框、方向、速度等;速度对应 code weights 为 0.2。- stage2 默认还有
loss_past_trajs,为带有效 mask 的轨迹 L1,权重默认 1。该分支输出past_steps+fut_steps=8个点,名称里有 past,但不能直接等同于 MotionHead 的 12 步未来预测。 - stage1 明确把
loss_past_traj_weight=0.0;不能把 stage1 的禁用状态套给 stage2。 - 配置里检测
GIoULoss权重为 0,不应说 Track 的主要监督包含有效 3D IoU loss。
匹配先利用已跟踪目标的真实 ID 继承对应关系,再用 Hungarian matching 给未匹配槽和新目标做一对一分配。Hungarian matching 就是给多张预测卡片与多个真实目标找一个总误差尽量小、互不重复的配对。不是每帧把所有目标当作全新对象重新匹配,也不是这里靠单独 ID 分类 CE 学跟踪。
自车特殊槽也有自车类别/框监督。检测 Decoder 中间各层同样有监督;跨帧维护由 ClipMatcher、MemoryBank、QIM 配合。实现 track_loss.py:62、track_loss.py:352;stage1 差异 base_track_map.py:145。
7.3 Map:类别、包围框和区域 mask
监督来自 nuScenes 地图,栅格化成分隔线、斑马线、道路边界等实例,以及可行驶区域。GT 地图用于训练和评分,不是网络推理的地图输入。
主要配置:2×FocalLoss + 5×L1_bbox + 2×GIoU + 2×Dice_mask。Dice 衡量预测区域与真实区域的重合程度,适合线条和稀疏区域;GIoU 衡量地图实例 2D 包围框的位置与重叠。Thing/Stuff 分支有自己的 loss 汇聚及比例系数,不能把上述式子当作无条件重复一次的全部最终实现。
地图实例通过 Hungarian 配对;中间定位层、mask 层也有辅助监督。代码 base_e2e.py:246、panseg_head.py:281;类别标签 vector_map.py:7。
7.4 Motion:六种未来里选最佳模态进行训练
对每个匹配目标,拿 6 条预测与真实轨迹比较:
k
∗
=
arg
min
k
A
D
E
k
,
k^*=\arg\min_k ADE_k,
k∗=argkminADEk,
L
m
o
t
i
o
n
,
l
a
y
e
r
=
0.5
(
−
log
p
k
∗
)
+
0.5
L
N
L
L
,
k
∗
+
0.25
min
k
F
D
E
k
.
L_{motion,layer}=0.5(-\log p_{k^*})+0.5L_{NLL,k^*}+0.25\min_k FDE_k.
Lmotion,layer=0.5(−logpk∗)+0.5LNLL,k∗+0.25kminFDEk.
ADE:沿整条轨迹各时刻位置误差的平均值。FDE:最后一个有效未来时刻的位置误差。NLL(负对数似然):让预测分布给真实未来更高概率,并学习其不确定性;具体以源码traj_nll的参数化为准。- 源码先算 minFDE,随后用 minADE 重写选中的
inds,所以最佳模态的分类/NLL 选择依据最终是 minADE。 loss_weight_minade=0,表示 minADE 不额外单独加进 loss;它仍参与模态选择。minFDE 权重为 0.25。- 3 轮 Motion 输出都有上述监督;GT 从跟踪匹配索引取得。默认启用 nonlinear smoother,会结合预测起点与 GT,对非自车的训练目标轨迹做可行性平滑;自车走单独处理。
训练时用 GT 找到最佳模态是为了学习;推理没有 GT,因此返回全部 6 模态及其预测概率,不能用“找最靠近真值的轨迹”作为实际预测方法。traj_loss.py:16、motion_head.py:383。
7.5 Occ:当前与未来实例占用 mask
GT 由未来真实车辆框投影/栅格化成占用及实例 ID。当前配置 only_vehicle=True,不应把结果理解成对所有行人、任意静态障碍和道路的完整场景占用预测。实例 Query 用跟踪匹配关联真实实例,不是另起一套无关的 mask 配对。
主要为 5×Binary cross entropy + 1×Dice,附加同类辅助 mask loss,辅助权重 1。BCE 对每格做“是否属于该实例”的二分类;只取损失最大的 25% 像素,未来时间乘 0.95^t 折扣;无效帧/255 标签按 mask 处理。监督覆盖当前帧 + 4 个未来帧。
虽然 dataset 为规划准备了 6 个未来时刻的 GT,占用网络 OccHead.n_future 默认仍为 4,不能由 occ_n_future_max=6 推断网络输出 7 帧。代码 base_e2e.py:334、occflow_loss.py:15、occ_head.py:286、occflow_label.py:11。
7.6 Planning:真实轨迹 + 碰撞项
PlanningLoss:有效时间内,预测点到 GT 点的欧氏距离平均值。这里叫 L2,但不是把所有平方误差直接求平均的 MSE。- 配置 3 项
CollisionLoss:delta=0/0.5/1.0,对应权重2.5/1.0/0.25,扩张自车框后惩罚它与未来 GT 框的重叠。 - 具体实现以框角点的轴对齐范围求重叠近似,不能当成精确的旋转矩形碰撞几何。
- 训练碰撞项用的是 GT future boxes,测试避碰优化用的是 预测 occupancy,二者不是同一个计算。
代码 base_e2e.py:444、planning_loss.py:16、planning_loss.py:30、planning_head.py:241。本次是静态解释配置及源码计算,没有运行 autograd 或训练,因此没有验证每个碰撞实现细节的梯度有效性。
7.7 “端到端”不意味着所有参数每阶段都更新
stage1:队列长 5,训练 6 epochs,先学 Track 与 Map;图像 backbone 冻结,FPN 未冻结,BEV Encoder 参与训练。stage2:载入 uniad_base_track_map.pth,队列长 3,训练 20 epochs,联合训练五个任务头,backbone、FPN、BN、BEV 编码路径默认冻结。
此外,历史 BEV 生成使用 no_grad;规划头对 sdc_track_query.detach();检测/轨迹参考位置迭代也有 detach。detach 表示这条支路保留数值,但切断梯度。因此应理解为“一个统一网络、有任务间特征依赖与联合训练”,而非“所有 loss 都无阻断地回传到全部网络参数”。实现 uniad_track.py:337、planning_head.py:139、modules.py:20。
8.1 一帧的实际推理顺序
- 按场景检查并复用/清空历史状态;6 张图通过 backbone/FPN。
- 相机特征 + 自车状态 + 历史 BEV,经 BEV Encoder 得到共享特征。
- 检测/跟踪 Query 读取 BEV,6 层逐步更新类别与框;目标置信度、历史 ID、速度更新共同维护 tracks。
- 地图头读取同一 BEV,预测局部地图;取 Map Query 供 Motion 使用。
- Motion 为每个目标/自车构造 6 种意图,做 3 轮交互,每轮更新参考轨迹。回归头输出各时刻位移,
cumsum累加成相对于当前时刻的位置轨迹,分类头给各模态 log 概率。 - Occ 将非自车车辆的多模态特征汇聚,演化 5 个时间块,通过实例向量与稠密特征点积生成
[B,A,5,200,200]logits。 - Occ 的 sigmoid 概率乘 track score,再对实例取 max,按阈值
0.1合并为二值占用;实例 ID 用于时序一致性评测。这里的阈值与 attention mask 阈值 0.3 各有不同用途。 - Plan 拼接自车轨迹特征、detach 后的自车跟踪特征和指令向量,经 MLP、六模态 max,得到 1 个规划 Query;对 BEV 做 3 层 Decoder。
- MLP 一次输出
6×2位移,cumsum得到 6 个自车未来位置。不是 Transformer 自回归地一个点一个点生成。 - 默认测试开启非线性避碰优化,用预测占用将原始规划轨迹向附近空闲位置调整。Occ 只到 2s,而 Plan 到 3s:源码
cur_t=min(t+1,occ_horizon-1),后两个未来规划时刻复用最后一张占用预测,并没有额外产生 2.5/3s 占用。 - 保存跟踪/BEV 状态供下一帧使用,返回最终轨迹及各任务结果。
主入口 uniad_e2e.py:235;目标解码 track_head.py:164;Motion motion_head.py:212;Occ occ_head.py:408;Plan 与优化 planning_head.py:139、planning_head.py:203。
9.1 不是一个 loss 或一个总分
训练 loss 用于更新参数;evaluation metric 用于在验证集上衡量结果。两者可能都用距离,但统计范围、匹配、过滤和聚合不同。整个标准评测先顺序推理验证集,再分别统计任务指标。
| 任务 | 主要指标 | 白话理解 | 入口 |
|---|---|---|---|
| 检测 | mAP、NDS 及位置/尺寸/朝向/速度等误差 | 找到多少目标、框和状态是否准确 | nuscenes_e2e_dataset.py:981 |
| 跟踪 | AMOTA、AMOTP、Recall 等 nuScenes tracking 指标 | 跨帧身份和轨迹是否连续,漏检/误检/ID 错误如何 | nuscenes_e2e_dataset.py:981 |
| 地图 | drivable/lanes/divider/crossing/contour IoU | 预测区域和 GT 区域的交并比 | nuscenes_e2e_dataset.py:981 |
| 运动预测 | minADE、minFDE、MR;另有 motion mAP、EPA | 六种猜测里最佳轨迹的误差;检测与预测联合质量 | nuscenes_eval_motion.py:640、metric_utils.py:64 |
| 占用 | IoU、时序一致 panoptic 指标,常称 VPQ;代码汇总为 pq/sq/rq | 占用是否准确,是否把同一辆车跨时间识别一致 | test.py:40、metrics.py:73 |
| 规划 | L2、obj_col、obj_box_col,各 0.5~3s | 位置误差,以及点/车辆轮廓是否撞到 GT 占用 | planning_metrics.py:15 |
9.2 运动预测指标怎样算
对第 k 条候选:ADE_k = mean_t ||p(k,t)-GT(t)||₂,FDE_k 为最后有效时刻距离。
minADE=min_k ADE_k;minFDE=min_k FDE_k,二者最优模态可能不同。- 本地
miss_rate是先对每个模式求所有有效时刻的最大距离,再对模式取最小;若仍超过 2m 则记 miss。不能把这里所有 MR 都泛化成“末点超过 2m”。 - 评测先考虑检测与 GT 匹配,不能只拿 GT 目标框启动轨迹头,然后把指标当完整检测+预测结果。
EPA=(N_det_traj_tp-0.5×N_det_fp)/N_gt;此实现用 2m 检测中心距离与 2m 轨迹 FDE 门槛联合匹配,专门惩罚漏检/误检与预测错误。nuscenes_eval_motion.py:640
9.3 占用指标怎样算
custom_multi_gpu_test 对30m×30m 和 100m×100m 两种区域分别统计:前者取 70:130 的中心 60×60 格,后者取完整 200×200。IoU 看车辆前景区域交并比;panoptic 指标进一步考虑实例 ID 和跨时间一致性。存在无效未来帧的样本会按当前代码过滤,并报告参与评测的比例。test.py:40
9.4 规划:必须注明单时刻还是累计平均
标准配置 planning_evaluation_strategy="uniad":
- 3s L2 = 3s 这一时刻的预测位置误差,在样本间平均。
"stp3":3s L2 = 从 0.5s 到 3s 所有时刻误差的平均。- 统计点为
0.5/1/1.5/2/2.5/3s;论文常展示 1/2/3s。比较论文时必须使用一致口径。
PlanningMetric 的碰撞检查使用 GT 未来车辆占用;预测占用用于规划优化,不能拿预测占用自己判断自己是否安全。obj_col 检查预测轨迹点;obj_box_col 检查固定车辆 footprint。代码对 GT 轨迹本身已碰撞的时间点屏蔽碰撞分子,最后仍按样本总数归一化;该指标不是实车发生事故概率。
这属于开环评测:给定录制数据预测未来,与记录的未来比较。闭环是让车辆执行预测动作、环境继续变化,再统计驾驶表现。这里的 nuScenes 开环低 L2/低碰撞率不能直接证明可以上车可靠驾驶。
实现 planning_metrics.py:15;两种聚合的实际切换 nuscenes_e2e_dataset.py:981。官方也提示过规划评测定义差异,参考 官方仓库。
9.5 如何运行本地标准评测
先准备好环境、nuScenes 验证集与 stage2 checkpoint。示例使用 1 张 GPU,仅演示入口,不代表实验环境已跑通:
cd ./UniAD
./tools/uniad_dist_eval.sh \
projects/configs/stage2_e2e/base_e2e.py \
/实际路径/uniad_stage2_checkpoint.pth \
1
脚本通过 torchrun → tools/test.py → custom_multi_gpu_test → dataset.evaluate 执行。uniad_dist_eval.sh:7、test.py:40、nuscenes_e2e_dataset.py:981。不同 GPU 数的序列划分可能影响时序结果,复现时须注明;标准 dataset 配置 eval_mod 列出 det/map/track/motion,Occ 与 Planning 在自定义 test loop 中另外累计,没有列在 eval_mod 不代表没有评测。
本地 mini/stage2_e2e_mini.py 继承标准 stage2,只改数据加载 worker、训练轮数、warmup 和日志/评测周期;它没有改 BEV 大小、Query 数和 Transformer 层数。mini 的小数据结果不能当 full nuScenes val 复现成绩。
10. 建议读代码的顺序
- base_e2e.py:10:先认清所有固定数值与开关。
- uniad_e2e.py:88、uniad_e2e.py:235:看五个任务如何接线,尤其 GT 与预测各走哪里。
- uniad_track.py:117、track_head.py:130、panseg_head.py:121:看三种初始学习表,明确 Query 从哪来。
- transformer.py:101、encoder.py:91、spatial_cross_attention.py:31:看 BEV 如何读图像和历史。
- motion_head.py:212、modules.py:20:看六模态意图与三轮交互。
- occ_head.py:271、occ_head.py:198:看实例 Query 融合与稠密时空状态演化。
- planning_head.py:139、planning_head.py:203:看单轨迹回归和测试避碰。
- track_loss.py:62、traj_loss.py:16、occflow_loss.py:15、planning_loss.py:16:分别看 loss。
- test.py:40、nuscenes_e2e_dataset.py:981、planning_metrics.py:15:最后看评测口径,避免拿训练日志代替验证集结论。
11. 证据边界与参考
- 本次核对本地 commit
609ee083ea51c3521c323f1279dfc4cee0e60467的配置、模型调用链、Query 构造、损失和评测实现。 - 没有执行模型推理、训练、梯度检查或 nuScenes 评测;本文不报告实验环境性能或复现成功。
- 本地已有的未跟踪 mini 配置和准备脚本保持原样;源码未改。
- UniAD 论文:任务层级与统一 Query 接口的原始设计。
- OpenDriveLab/UniAD 官方仓库:版本说明、模型与评测说明;具体参数以本文所链接的本地源码为准。
12. 时序信息:究竟记住了什么
存在时序,而且同时记住“场景历史”和“目标历史”。 这里“时序”指多帧按时间先后传递信息,而不是把每张相机图片独立预测。它与“预测未来多个点”不同:未来预测本身不证明输入使用了历史帧。
12.1 四个时间相关位置
| 位置 | 传递的信息 | 怎样用 | 代码 |
|---|---|---|---|
| BEV 时序融合 | 历史整张场景特征 prev_bev | 结合自车位移/旋转对齐,通过 TemporalSelfAttention 融合 | transformer.py:101、encoder.py:200 |
| 目标跟踪 Query | 前帧目标 embedding、ID、位置与速度 | QIM 更新有效 Query;速度和时间间隔预测当前参考位置 | uniad_track.py:358、modules.py:115 |
| MemoryBank | 每个目标至多 4 个历史特征 | 当前目标 Query 注意历史特征,补充运动/外观信息 | modules.py:7 |
| Occ 时间块 | 当前状态依次演化成未来状态 | 5 个时间块依次预测当前和未来 4 帧;属于未来状态演化 | OccHead.forward,见第 8 节 |
MemoryBank 的 4 指保存的 embedding 槽数量,不能直接说是“最近连续 4 帧”:推理写入还有分数和 save_period=3 的间隔条件。训练与推理的更新条件也不同。
MapHead 和 PlanningHead 并没有额外独立的跨实际帧隐藏状态;它们可以通过共享 BEV、跟踪与 Motion Query 间接使用过去的信息。MotionFormer 的 3 轮是同一次前向中的特征细化,不是 t-2、t-1、t 三个历史帧;Occ 的未来时间块也不是读取未来图像。
12.2 输入的历史有多长
标准 stage1:queue_length=5,数据样本为 [t-4,t-3,t-2,t-1,t],约覆盖 2s。
标准 stage2:queue_length=3,数据样本为 [t-2,t-1,t],约覆盖 1s。
这里按 nuScenes 相邻标注帧约 0.5s 解释,时长是 (L-1)×0.5s,不是 L×0.5s。当前代码明确 enbale_temporal_aug=False,取同一 scene 内连续索引;不满足队列起止同场景的样本返回 None,由数据集重选。不能照其他版本说这里默认随机跳帧。实现 nuscenes_e2e_dataset.py:169。
union2one() 将图像堆成 [L,6,3,H,W],并把各帧 can_bus 的平移和航向改为相邻帧差值,便于对齐;第一项差值归零。各帧检测框、ID 与辅助轨迹标注保留为列表,Motion 的未来轨迹等取最后一帧的标注。nuscenes_e2e_dataset.py:263
12.3 训练与推理怎样保存历史
训练:有限队列,队列间重新初始化 tracks。 forward_track_train() 每个样本开头 _generate_empty_tracks(),然后依序处理 L 帧,在这个样本内部传递目标状态;不是把上一个随机 batch 的 tracks 接到下一个 batch。
历史 BEV 的实现比简单画一个 RNN 箭头更具体:对队列第 i 帧,prev_img=img[:,:i],由 get_history_bev() 对前缀图像重算历史 BEV;此过程用 eval() + torch.no_grad(),没有对历史 BEV 链做完整反传。源码也保留了 “Generate prev_bev in an RNN way” 的 TODO,说明训练这里并非直接复用上一轮主分支输出 BEV 的高效实现。i=0 时实际传入首帧自身作为 prev_img,因此不能逐字把首帧实现描述成完全未调用历史构造。uniad_track.py:493、uniad_track.py:316
推理:按场景顺序处理,每次输入当前六张图。 保存 self.prev_bev、self.test_track_instances、上帧时间戳与坐标变换;下一帧直接读取。遇到新 scene_token 时清空历史,不能把一个场景的状态带进另一个场景。uniad_track.py:707
推理不是每次重新送入一个固定三帧 batch。虽然显式保存一张 prev_bev,它已融合过更早信息,所以历史影响可以递归传递;这不意味着无限时长无损记忆。
13.1 两个训练阶段,外加前置预训练权重
这里的 epoch 表示大致遍历一轮训练数据;iteration 表示处理一个 batch 并进行一次优化更新。
| 项目 | Stage1:先学感知 | Stage2:联合任务头 |
|---|---|---|
初始化权重 load_from | ckpts/bevformer_r101_dcn_24ep.pth | ckpts/uniad_base_track_map.pth |
| 主要任务 | Track + Map | Track + Map + Motion + Occ + Planning |
| 图像队列长度 | 5 | 3 |
| 图像 backbone 参数 | 冻结 | 冻结 |
| FPN | 训练 | 冻结 |
| 当前帧 BEV 编码路径 | 训练 | no_grad,冻结 |
| 跟踪/地图头 | 训练 | 继续训练 |
| Motion/Occ/Planning 头 | 未配置 | 训练 |
| Track 辅助 past trajectory loss | 权重 0 | 默认权重 1 |
| epoch | 6 | 20 |
| 默认 checkpoint 保存 | 每 epoch | 每 epoch |
| 默认评测间隔 | 6 epochs | 20 epochs |
配置 base_track_map.py:49、base_e2e.py:41;初始化 base_track_map.py:587、base_e2e.py:702。
前面的 BEVFormer checkpoint 是已有预训练权重;标准两阶段 UniAD 训练并非所有部分从随机数开始。Stage2 也不是把 Stage1 的所有模块锁住只训练新头:Track/Map 会继续训练。
load_from 主要载入模型参数;新阶段按该阶段配置建立 optimizer 与训练进度。resume_from 则用于中断续训,恢复 runner/optimizer 等状态。当前训练 API 的优先级是:存在的 resume_from 优先,否则使用 load_from。两者不是同一种“加载”。mmdet_train.py:190
13.2 一个 Stage2 batch 的完整顺序
以单 GPU batch size 1、队列 [t-2,t-1,t] 为例:
- DataLoader 取训练样本。 读取连续三帧,每帧 6 路相机;执行颜色扰动、归一化、padding,准备框、ID、局部地图、未来轨迹、未来占用及规划 GT。未来图像不作为预测输入。高层指令由离线轨迹标签派生,这是 benchmark 输入条件。
- 初始化本队列跟踪状态。 建立 900 个初始目标槽与 1 个自车槽。
- 处理第 1 帧。 构造其 BEV,做 6 层目标解码;与该帧 GT 匹配,计算 Track loss;维护 MemoryBank 和 QIM,得到下一帧目标状态。
- 处理第 2 帧。 无梯度重算前缀历史 BEV,再编码本帧;带着第 1 帧目标 Query 做检测/跟踪,计算本帧 Track loss,并更新状态。
- 处理第 3 帧。 使用前两帧历史与跟踪状态,完成当前帧 Track,得到最后一帧 BEV、目标/自车特征;累计第 3 帧 Track loss。
- 只对最后一帧做 Map。 由其 BEV 预测地图,与当前地图 GT 计算 Map loss。
- 只对最后一帧做 Motion。 读取目标、自车、地图 Query,预测从该帧开始的 6s 六模态轨迹,用匹配后的未来轨迹 GT 监督。
- 只对最后一帧做 Occ。 从该帧开始预测当前 + 未来 4 帧车辆实例占用,与 mask/ID GT 比较。
- 只对最后一帧做 Planning。 融合指令和自车特征,预测 3s 轨迹,计算轨迹距离和配置碰撞项。训练不启用测试用的占用避碰优化。
- 合并 loss。 汇总三帧 Track loss 与最后一帧另外四个任务 loss,包括各自辅助层损失。不是每处理一帧就
optimizer.step()。 - 反向传播并更新一次。 runner/optimizer hook 清梯度,调用反向传播,分布式训练时同步梯度,裁剪梯度,用 AdamW 更新参与训练的参数。
- 继续下个 batch。 按调度器调整学习率;按 epoch 间隔保存 checkpoint、运行验证。
队列迭代 uniad_track.py:493;单帧计算和匹配 uniad_track.py:358;最后一帧接四个头 uniad_e2e.py:88;runner/optimizer hook 注册 mmdet_train.py:90。
因此,一个 Stage2 iteration 的监督形状可概括为:
L b a t c h = ∑ i = 0 L − 1 L t r a c k ( i ) + L m a p ( L − 1 ) + L m o t i o n ( L − 1 ) + L o c c ( L − 1 ) + L p l a n n i n g ( L − 1 ) . L_{batch}=\sum_{i=0}^{L-1}L_{track}^{(i)}+L_{map}^{(L-1)}+L_{motion}^{(L-1)}+L_{occ}^{(L-1)}+L_{planning}^{(L-1)}. Lbatch=i=0∑L−1Ltrack(i)+Lmap(L−1)+Lmotion(L−1)+Locc(L−1)+Lplanning(L−1).
这表示计算位置;具体实现还含各任务既有的权重、内部归一化和辅助项。Stage1 则是队列每帧 Track 加最后一帧 Map。
图中是一套权重贯穿各帧,不是给每帧独立训练一个模型。
13.3 哪些历史信息不反传梯度
- 历史前缀 BEV:
get_history_bev()中no_grad,只提供已算出的历史特征。 - Stage2 当前 BEV 编码也在
no_grad中,视觉表示固定,任务头仍能读取并学习。 - Track Query、MemoryBank、QIM 负责队列内信息传递;不能因历史 BEV 无梯度就说“整个模型没有跨帧学习”。
- 一些几何参考点和轨迹迭代使用
detach;规划头显式 detach 自车 track query。也不能反过来说“所有时序分支都做了完整 BPTT”。BPTT 指沿整个时间链反向传播,这里不是完全无截断的形式。
13.4 优化超参数与代码入口
标准两阶段都配置 AdamW:基础学习率 2e-4,weight decay 0.01;梯度最大范数 35;前 500 iterations 线性 warmup,从 1/3 倍基础学习率起步;之后 cosine annealing,最低比例 1e-3。warmup 是起初逐步提高学习率,避免突然的大更新;weight decay 用来约束参数幅度;梯度裁剪限制一次更新前的梯度规模。base_e2e.py:672
数据每 GPU 的样本数为 1,但每个样本内部含 L 帧×6 相机。使用 G 张 GPU 时,默认每次全局处理约 G 个这样的样本,而不是把时间维或相机维当 batch。--autoscale-lr 是可选参数;不能假定换 GPU 数时学习率必然自动改变。
启动链:tools/uniad_dist_train.sh → tools/train.py → custom_train_model → custom_train_detector → EpochBasedRunner/OptimizerHook。模型和数据构造 train.py:209;optimizer 与 hook mmdet_train.py:90。
当前说明来自本地标准配置与源码,没有实际运行训练、检查梯度或验证收敛。mini 配置会覆盖 epoch、warmup 和评测周期,不能套用本节标准轮数。
14.1 二分图匹配到底配什么
“二分图”就是左右两组对象及它们之间可能的连线:左边是网络的 N 个预测,右边是 M 个 GT(真实标注)。匹配要选出互不冲突的连线,使总代价最小。每个真实目标最多配一个预测,每个预测最多配一个真实目标;通常 N 远大于 M。
例如有三个 Query 产生预测 P0、P1、P2,真实标注是汽车 G0、行人 G1。下表仅是演示代价,越小越像:
| 预测 | 汽车 G0 | 行人 G1 |
|---|---|---|
| P0 | 1 | 9 |
| P1 | 2 | 8 |
| P2 | 7 | 1 |
最小总代价配法为 P0→G0、P2→G1,总代价 2;P1 未配对,按背景/无目标监督。即使 P1 也很像汽车,也不能和 P0 同时占用同一个 GT。这样训练会鼓励多个 Query 分工,而不是全部重复预测同一辆车。
当前 Track matcher 的真正步骤:
- 从分类 logits 和 GT 类别计算分类匹配代价。
- 从编码框的前 8 维计算 L1 匹配代价;这里不把速度两维用于配对成本。
- 按配置,代价约写成
C=2.0*C_focal+0.25*C_bbox_L1;权重已在 cost 对象内部应用,源码直接相加,不应再重复乘一遍。 cost.detach().cpu(),把成本矩阵从计算图取出。scipy.optimize.linear_sum_assignment(cost)返回所选行、列,即预测下标与 GT 下标。- 按这些对应关系计算分类/框等训练 loss。配对动作不反传,但配好对后的预测 loss 仍反传。
实现 hungarian_assigner_3d_track.py:40。注意类注释提到 IoU、示例二维框等内容是沿用旧注释,实际这段 Track assigner 只有分类和前 8 维 L1 cost,应以函数体为准。“Hungarian matching”是通用的最优一对一指派称呼;这里调用的是 SciPy 的线性指派接口,不是在仓库手写 Hungarian 求解循环。
UniAD 跟踪还加了一步:有已知目标 ID 的历史 tracks,先按真实 ID 继承匹配;只把剩余空槽与尚未被跟踪的 GT 送入上述矩阵匹配。自车槽单独处理。实现 track_loss.py:352。
这是训练监督时配预测与 GT。推理没有 GT,不进行这套 GT 匹配;推理通过跨帧 Query 和状态管理维持 ID。
14.2 QA、QM 是什么,为什么 Query 很多
论文通常写 $Q_A$、$Q_M$:Q 表示 Query 特征;A 表示 Agent(交通参与者),M 表示 Map(地图)。这里的 QA 不是 Question Answering。它们通常代表一组向量组成的矩阵,不是一个数字,也不是类别标签。
$Q_A∈R^{A×256}$:A 个有效交通参与者,每个一个 256 维信息向量;batch 维省略。$Q_M∈R^{M×256}$:M 个地图 Query,每个也是 256 维;当前定位解码器初始有 300 个槽。- 自车 Ego/SDC Query:单独一张表示自车的信息卡片。SDC 是 Self-Driving Car。
- 不同模块的 Query 服务不同问题;数量多,不代表场景里有那么多真目标。
| Query | 数量为什么这样设 | 怎样生成 | 怎样变成有意义的信息 |
|---|---|---|---|
| BEV | 40000,每个地面格一张卡 | Embedding(200*200,256) | 读取图像/历史,装入该格的场景信息 |
| 初始检测 | 900 个候选槽,用来覆盖未知数量的目标 | 学习表前 900 行 | 经 6 层解码、训练匹配后逐渐学会表达目标或背景 |
| Track / QA | 只保留有效目标,数量动态变化 | 上一帧有效目标 Query 更新,再在当前帧解码筛选 | 每个向量携带目标外观、类别、位置与运动相关信息 |
| Ego | 1 个,始终专门表示自车 | 同一学习表第 901 行,下标 900 | 自车框/类别与轨迹监督帮助其学成自车表示 |
| Map / QM | 300 个候选地图实例槽 | 独立 Embedding(300,512) | 经地图编码/定位解码与地图 GT 监督,得到地图实例特征 |
| Map Stuff | 1 个可行驶区域槽 | 独立学习表 | 用区域 mask 监督,表达连续区域 |
“学习表”不是人工写入 900 个物体的坐标,而是一块初始化后可训练的参数。训练不断更新它,部署时加载 checkpoint 中学好的值;推理每帧不会重新随机生成这些 Query。初始学习表每行 512 维,拆为 256 维的位置相关部分与 256 维内容部分;解码后的目标特征为 256 维。
关键流程:初始参数向量 → 读取 BEV → 解码后的目标特征 → 筛选有效目标 → QA。因此“900 个初始检测槽”和“最终 QA 有多少目标”不是同一个数量。900/300 是此模型的容量超参数,不是 Hungarian 算法规定的数量;也不是每行永久绑定一个具体类别,比如第 1 行只能检测汽车。
代码:检测/自车学习表 uniad_track.py:117;有效 QA uniad_track.py:476;地图学习表 panseg_head.py:152;地图输出特征 panseg_head.py:182。
14.3 “终点附近采样”到底是什么意思
这里指 MotionFormer 的 agent-goal 交互从 BEV 特征中取信息,不是在真实地面随机选落脚点,也不是拿真实未来终点作为输入。
例子:某个目标的“向右转”模态当前预测,6s 后会到 (10,5)m。网络要判断那附近有没有道路、路口或有用的场景线索,于是从 BEV 中 (10,5) 附近读取少量特征,帮助细化这条模态。
源码中:
sample_index=-1选参考轨迹的最后一个点。- 第一轮参考来自轨迹锚点;后续轮参考来自上一轮预测并 detach 后的轨迹。
- 把相对目标的终点转换到当前场景坐标,再按 BEV 范围归一化。
- Query 经过线性层产生学习的采样偏移
sampling_offsets和采样权重。 - 采样位置约为
归一化终点 + offset/[BEV宽,BEV高],插值读取特征,再加权融合。
例子中的附近位置可以想象为 (9.5,5)、(10,5.5) 等,但这只是示意:偏移由网络预测,不保证严格限制在终点某个固定半径内。当前配置 8 个注意力 head、每 head 每未来 step 4 个点、1 个 BEV level。由于代码为 12 个 step 分别预测 offsets/weights,不能把整个模块简化成“总共只取 4 个点”;这些组围绕所选终点参考展开。
实现 motion_deformable_attn.py:431、motion_deformable_attn.py:417;逐轮更新轨迹 modules.py:20。
14.4 “推理时牛顿法避障”应怎样理解
前文称“牛顿法避障”过于简化,准确说法是:推理时用 CasADi 建模、调用 IPOPT 做非线性轨迹优化。 IPOPT 官方定义其为内点法求解器;它内部的数值步骤与 Newton 类方法有关,但此源码不是一个手写的朴素牛顿更新循环。IPOPT 官方说明
网络先给 6 个轨迹点 p̂_t。假如某点靠近占用预测中的一辆车,就把这些点作为待调整变量,最小化:
J ( p ) = ∑ t ∥ p t − p ^ t ∥ 2 2 + α ∑ t , o ∈ O t c exp ( − ∥ p t − o ∥ 2 2 2 σ 2 ) . J(p)=\sum_t\|p_t-\hat p_t\|_2^2+\alpha\sum_{t,o\in O_t}c\exp\left(-\frac{\|p_t-o\|_2^2}{2\sigma^2}\right). J(p)=t∑∥pt−p^t∥22+αt,o∈Ot∑cexp(−2σ2∥pt−o∥22).
第一项让轨迹不要随意远离网络结果;第二项让轨迹远离预测车辆占用格子的中心。靠近占用点时指数惩罚大,远离时惩罚小。用网络轨迹作为初始解,调用求解器得到修改后的点。
例如原始点在 (10,0),附近占用在 (10,0.3),求解器可能把轨迹稍向另一侧调整;这是说明优化意图的例子,不是本次执行出的数值结果。
当前实现只挑原轨迹点附近 5m 内的占用点;若一个也没有则直接返回原轨迹。它是软惩罚后处理,不是证明绝不碰撞的约束规划器。此碰撞优化器主要优化 XY 点,不能把训练用 smoother 的完整运动学约束套给它。默认只在测试时开启,不对这一求解过程做常规网络反传。代码 collision_optimization.py:16、planning_head.py:203。
14.5 “平滑真值轨迹”为什么要修改正确答案
GT 记录的是真实车辆从真实起点、真实朝向出发的未来运动。但网络感知的当前位置/朝向有误差;如果硬要求从偏了的起点立即接上原始未来点,就可能出现瞬间横移、急转或不合理加速度。
所以训练 MotionHead 时,会在适用样本上求一条从预测状态出发、尽量接近真实未来、又满足运动约束的参考轨迹,用它替代原始轨迹作为监督。这更准确叫“适配预测起始状态的非线性平滑”,不只是把锯齿做均值滤波。
- 状态变量包括位置、朝向、速度;控制变量包括曲率和加速度。
- 约束描述车如何随速度、朝向前进,并限制曲率、加速度、速度、转向角速度、横向加速度。
- 目标包含贴近原 GT 的位置/朝向、保留终点,同时减少急转、加减速突变等。
jerk指加速度变化率;直觉就是“加速/刹车是否突然”。曲率表示拐弯有多急。- 当前训练默认对非自车目标做此处理,自车 GT 单独保留。
并非每条 GT 都平滑:代码要求有效未来点足够、目标位移超过 2m、预测与 GT 初始位置相差不超过 2m、朝向相差不超过 30°。若优化结果与原参考平均偏离达到 1.5m,也退回原始参考的相对位移表示。
两种优化不要混淆:
| 项目 | 训练真值平滑 | 推理避碰优化 |
|---|---|---|
| 修改谁 | 非自车运动预测的监督参考 | 网络输出的自车规划轨迹 |
| 什么时候 | 训练时 | 推理时 |
| 依据什么 | 预测初始状态 + GT 未来轨迹 + 运动约束 | 网络轨迹 + 预测占用 |
| 目的 | 让监督与感知误差、可行运动相容 | 调整规划轨迹,减少接近占用 |
| 部署是否需要未来 GT | 不执行此训练步骤 | 不需要未来 GT |
代码入口 motion_head.py:445、motion_utils.py:13;优化约束和目标 motion_optimization.py:17。这些是源码行为说明,未在本次运行求解器。
14.6 导航指令是什么,从哪里来
导航指令是在路口等歧义场景告诉模型“希望右转/左转/直行”的高层意图。看见一张十字路口照片,并不能唯一决定目的地方向;感知负责看路,指令负责选大方向,规划头决定具体轨迹。
它不是方向盘角度、不是目标坐标,也不是自然语言文本。当前实现是整数:
| command | 意义 |
|---|---|
| 0 | 右转 RIGHT |
| 1 | 左转 LEFT |
| 2 | 直行 FORWARD |
本地 nuScenes 离线数据集里,训练与评测输入的 command 由 get_sdc_planning_label() 根据记录的真实未来自动生成:把未来自车位置转换到当前局部坐标,取最后一个有效点的横向 x;x≥2m 记右转,x≤-2m 记左转,中间记直行;没有有效点也默认直行。这是坐标约定下的简单标签规则,不是实时路线规划算法。trajectory_api.py:226
模型内部用 nn.Embedding(3,256) 把整数变为一个 256 维指令向量,再与 Ego Track/Trajectory Query 融合生成 Planning Query。planning_head.py:139
离线评测的指令来源带有未来记录信息,这是一项已提供的输入条件。 实际部署不能读取未发生的未来,应由外部导航/路线规划系统根据目的地与路线提供类似指令。这个外部目的地导航器不是当前 PlanningHead 自动完成的部分。
14.7 一段话串起来
模型先用初始可学习 Query 从 BEV 中提取目标和地图。训练时通过最优一对一匹配,知道每张目标卡片应向哪个 GT 学习;推理时保留有效目标形成 QA,并用跨帧状态延续身份。QA、QM 和轨迹锚点帮助 MotionFormer预测多种未来,再到候选终点附近读 BEV 细化。自车 Query 与导航指令融合,生成自车轨迹;测试时根据预测占用再优化。训练中的 GT 平滑则解决另一件事:让运动监督适配有误差的感知起点。
15.1 匹配表的数字不是“汽车数”或“行人数”
前面的表是人为举例的匹配代价矩阵。每个格子的含义是:“把这一行预测,认作这一列真实目标,要付出多少不匹配代价?”
例如 P0/真实汽车这一格为 1,意思是 P0 的类别和框与这辆真实汽车比较接近;P0/真实行人为 9,意思是把 P0 当作那个行人很不合适。1、9 都不是米,不是概率,也不是物体数量。 它们是分类成本和编码框 L1 成本加权后的评分;表中 1/9/2/8/7/1 只是帮助理解的演示数值,不是这次从模型运行中读出的值。
列标题“真实汽车”表示数据集已经标注的某个具体目标,包含类别和真实框;不是泛指所有汽车的类别集合。如果有两辆真车,应分别有“汽车 A”和“汽车 B”两列。
代价由网络输出和 GT 自动算,不是人工给每辆车填写。真实代码成本也不必为正整数,不能把表格数值当成固定取值范围。越小表示按照这套成本更适合匹配。
15.2 六个相机究竟怎样共同形成一张 BEV
理解方向很关键:先问“BEV 这个空间位置应该看哪张图、看图哪里”,再取对应特征。不先把六张原始 RGB 图片拼成一张俯视照片。
以地面 (x=10m,y=2m) 的一个 BEV 格为例:
- 六张图片分别经过同一套 backbone/FPN,得到每路相机的多尺度特征。
- 沿这格上方的虚拟竖柱取 4 个高度点
(10,2,z_i)。因为地面上方可能有车身、行人或建筑,不应只取地面高度。 - 对每个相机,用已知内外参组成的
lidar2img矩阵,把这些 3D 点投影到像素。内参描述镜头怎样成像,外参描述相机装在车的哪里、朝哪边。 - 投影在相机后方或画面外就跳过;例如这个格可能只被前相机和前右相机看到,其他四路不参与该格融合。
- 每个可见相机内部,BEV Query 产生学习偏移/权重,在投影点附近、各尺度图像特征上插值采样并加权汇总。
- 将相机各自得到的 256 维结果相加,再除以可见相机数量,随后线性变换、残差更新 BEV Query。如果两个相机都看到,粗略写作
f=(f_front+f_front_right)/2;相机内部的采样权重仍是学习的。 - 结合历史 BEV 的时间融合,在 6 层编码中逐步形成更好的共享 BEV 表征。
几何公式可以写为:[u',v',d',*]ᵀ = lidar2img · [x,y,z,1]ᵀ,再 u=u'/d'、v=v'/d'。这里 d’ 是投影计算中的深度,不是模型直接得到的真实物体深度。
参考点与标定把采样引向相关位置,任务训练让网络学会如何使用这些特征。遮挡并非投影矩阵自动解决:源码主要检查图像范围和正深度,多视角与学习特征帮助处理歧义。
投影代码 encoder.py,第 91 行;相机汇聚实际代码 spatial_cross_attention.py,第 171 行。
15.3 “1 个区域 Query”如何输出整张图
当前 Stuff 类只有 1 类:可行驶区域,所以有 1 个专门负责它的 Query。它不是 1 个格子、1 个矩形,也不限制只能有一块连通区域。
可以把这个 256 维向量理解成一个查询:“整张图里哪些位置属于可行驶区域?” 它读取地图特征后,与全图每个空间位置的特征建立关系,输出 200×200 个区域分数。最终阈值化得到可行驶区域 mask。
形状上:
1 个 Query [1,256]
地图空间特征 [40000,256](示意,省略 batch)
Query 与各位置的关系
→ 40000 个区域分数
→ reshape 成 [1,200,200]
本代码中 mask 由自定义 attention 的各位置相关分数经过小型网络聚合得到,所以上面是结构示意,不是说源码只做一个未经变换的裸点积。学习表初始化仍为位置/内容合计 512 维,拆分后内容 Query 为 256 维。panseg_head.py,第 154 行、seg_mask_head.py,第 310 行
一张输出 mask 可以同时包含左边、前面等多块互不连接的可行驶区域。Query 数量、mask 像素数量和区域连通块数量,是三个不同概念。
15.4 Thing 4 层、Stuff 6 层里的“层”是什么
Thing 表示需要区分实例的地图元素:当前有分隔线、斑马线、道路边界等类型;Stuff 表示按类别整体分割的连续语义区域:当前为可行驶区域。这里说的是 MapFormer 的两个 mask 分支,不是在数检测到几辆车。
- Thing 4 层:地图实例 Query 连续经过 4 个不同参数的 mask block,逐层读取地图特征、更新表示并生成 mask。
- Stuff 6 层:区域 Query 连续经过 6 个不同参数的 mask block,逐层更新区域表示和 mask。
- “4/6”不是 Query 个数、地图类别数、相机数或历史帧数,而是网络的计算深度。
- 本地每个 block 主要有 cross-attention、前馈网络、残差与归一化;Stuff 分支还开启了 self-attention。
- 两个分支在这些 block 后还各接一个末尾
AttentionTail,所以 4/6 特指配置的num_decoder_layers,不是穷尽全部 attention 算子。
Thing:多个实例 Query → block1 → block2 → block3 → block4 → 实例 masks
Stuff:1 个区域 Query → block1 → block2 → … → block6 → 区域 mask
这是同一次前向里顺序经过的层,不是同一个模型训练 4/6 次。4 和 6 是作者选定的结构超参数;仅凭源码不能推断它们一定是最优值。配置 base_e2e.py,第 314 行;block 定义 seg_mask_head.py,第 214 行。
15.5 每个目标的 6 模态轨迹 Query 怎么做出来
“模态”在这里表示一种可能的未来走法,不是相机、雷达这种传感器模态。6 模态是同时给出 6 种未来猜测,不是 6 个未来时间点,也不是固定六类动作标签。
假设 QA 中一个向量表示前车。现在为同一前车建立 6 个不同的未来意图槽:它们共享同一辆车的观测信息,但有不同的运动先验,因此可能学成不同的候选轨迹。直行、减速、左右偏移等只是帮助理解的例子,不能把 6 个槽硬对应到固定动作名。
构造步骤:
- 读取离线轨迹锚点。 从训练轨迹聚类得到 4 个类别组各 6 条典型轨迹,每条 12 个未来点;运行时从 pkl 加载。
- 按目标类别选组。 汽车选机动车组的 6 条,行人选行人组的 6 条;不是运行时每辆车重新做 K-means。
- 读取 6 个不同的可学习模态向量。 学习表整体大小
4×6=24行,每行 256 维。按类别组选择后,一辆车使用其中 6 行。 - 加入几何意图。 将轨迹锚点的终点在目标局部、场景等坐标表达下编码为 256 维向量;同一类别的锚点放到不同目标位置/朝向后,场景终点也不同。
- 融合构造 6 个初始模态特征。 代码把上述位置先验、学习向量和迭代特征经加法/拼接/MLP 融合。内部
query_embed最初为零,再由意图特征填充,不是只把 QA 复制 6 份就完成。 - 与实际场景交互。 6 个模态分别读取 QA 中的其他目标、QM 地图特征与 BEV 终点附近特征;共享这辆车的 Track 信息,做 3 轮细化。
- 输出 6 条轨迹和 6 个分数。 每条有 12 个未来点及分布参数;训练用真实未来选择应鼓励的模式,推理返回所有候选和预测分数。
因此可简写为:
该目标 Track 信息
+ 六种典型轨迹的几何先验
+ 六个不同的学习模态向量
+ 当前道路与周围目标信息
→ [6,256] 的轨迹 Query
→ 6 条不同未来轨迹及概率
第一轮开始时,并不是仅凭随机向量就已知道前车会左转。不同先验使六个槽有区别,场景交互与训练监督让这些槽学出有用候选。base_motion_head.py,第 58 行、motion_head.py,第 252 行、modules.py,第 101 行
自车也参与这套六模态特征建模,但 PlanningHead 随后结合导航指令融合自车的六个模态特征,再回归一条最终自车轨迹。它并非一定直接挑选六条预测中的原封不动一条。planning_head.py,第 170 行
15.6 为什么代码链接打不开,还像要新建路径
此前代码定位采用 。某些代码聊天界面能解析这种写法,但 Obsidian 或其他 Markdown 客户端可能把它当作普通文件/笔记路径;:行号 并不是实际文件名的一部分,所以可能找不到文件,甚至弹出创建笔记/路径的界面。这不能直接归因于源码不存在,也不是 UniAD 正在重新生成代码。
本次把本文已有代码定位统一改成 GitHub 固定 commit 的 HTTPS 链接 + #L行号,适合从 Obsidian 浏览器打开;保留本地根目录文字 方便自行打开。没有复制或重新生成一份源码,也没有上传本地代码。
本地阅读可在 VS Code 已打开 UniAD 目录时,用 Ctrl+P 输入相对文件路径,再 Ctrl+G 输入行号。Linux 系统路径和 Obsidian 笔记链接应分开使用。
若“重新生成路径”指的是车辆规划轨迹,则每次收到新帧,模型确实会根据新观测重新输出未来 3s 的局部轨迹,测试避碰优化还可能调整它。它不是每帧自动重新计算整条目的地全局路线;高层路线和导航指令由外部系统负责。
16. 中间监督:哪些地方有答案,哪些没有
有中间监督,不是只用最后的自车轨迹训练。 监督指把某个预测与 GT(真实标注)比较得到 loss。应分两层:各任务的显式监督,以及同一解码器内部不同层的辅助监督。
16.1 各任务输出上的监督
| 位置 | 预测什么 | 对照的真实标注 | 主要 loss |
|---|---|---|---|
| Track | 类别、3D 框、方向/速度、辅助轨迹 | 每帧框、类别、跨帧 ID、辅助轨迹 | Focal、框 L1、轨迹 L1;ID 用于对应关系继承 |
| Map Thing | 各地图实例类别、包围框、mask | 分隔线、斑马线、道路边界等 GT | Focal、L1、GIoU、Dice |
| Map Stuff | 整张可行驶区域 mask | 每格是否属于可行驶区域 | Dice 等 mask 监督与区域分类 |
| Motion | 每个目标六种未来及分数 | 匹配目标的真实未来轨迹、有效时间 mask | 最佳模态分类、NLL、minFDE |
| Occ | 当前+未来各车辆实例占用 | 真实未来车辆框栅格化的 mask、实例 ID | 二分类 BCE、Dice,含辅助 mask |
| Planning | 自车未来六个位置点 | 真实自车未来轨迹、未来目标框 | 轨迹距离、配置中的碰撞项 |
例如 Stuff 只有 1 个 Query,但它输出 200×200 个区域分数;可以用整张 GT mask 监督这一个 Query 产生的输出,并不是“一个 Query 只有一个标注数”。
QA/QM 本身是 256 维隐藏特征,通常没有人工给出“正确的 256 个数”。先把 Query 解码成框、类别、mask 或轨迹,再比较 GT;梯度让 Query 学成对任务有用的特征。
16.2 解码器中间层的辅助监督
- Track 的 6 层 Decoder:每层都输出类别、框和辅助轨迹,按各层匹配计算 loss;当前训练在每个 dec level 调用 ClipMatcher。监督不只落在最后一层。
- Map 的 6 层 Location Decoder:中间层有分类、框 L1/GIoU 辅助监督,最后层还连接 mask 分支。Thing 4 层、Stuff 6 层的 mask block 都有中间预测,计算对应辅助 mask loss;Thing 还带对应分类/框项,Stuff 有区域分类项。
- Motion 的 3 轮:每轮输出轨迹和模态分数,都计算轨迹 loss,不只是第三轮。
- Occ:用于构建注意力掩码的辅助 mask 预测和最终实例占用预测都计算 BCE/Dice;当前及四个未来时刻都参与有效标签范围内监督。这里不是每层都要人为提供注意力权重。
- Planning 的 3 层:当前实现只拿最后输出回归轨迹、算规划 loss,没有给三层分别挂独立轨迹辅助 loss。
“辅助监督”表示中途也要求先给一个可检查的答案,帮助训练更早的层;不表示增加新的传感器或人工为每层画一份不同标签。同一套任务 GT 可以监督不同层的预测。
16.3 相机融合、BEV 和注意力有没有直接监督
当前这条标准路径没有单独的“正确 BEV 特征向量”、深度图 loss、相机融合权重 GT,也没有给每个采样偏移一个正确位置标签。相机标定是已知几何输入,不是让网络通过标定 loss 自己猜。
这些特征/注意力主要从任务 loss 获得间接监督:比如地图 mask 预测错了,地图 loss 的梯度会要求参与训练的前面特征提取步骤改变。但要区分阶段:stage1 当前 BEV 编码路径参与训练;stage2 默认冻结视觉和 BEV 编码路径,因此这些参数不会因为下游 loss 再更新。历史 BEV 生成本身也使用 no_grad。
代码(固定本地对应 commit):
本节为源码核对,未运行训练或梯度检查。
17. Dice:预测区域与真实区域重合多少
Dice(常译为戴斯系数)衡量两个区域的重合程度。这里名字来源于统计学家的姓氏,不是说训练中掷骰子。
设 P 是预测为前景的格子集合,G 是真实前景格子集合:
D i c e ( P , G ) = 2 ∣ P ∩ G ∣ ∣ P ∣ + ∣ G ∣ . Dice(P,G)=\frac{2|P\cap G|}{|P|+|G|}. Dice(P,G)=∣P∣+∣G∣2∣P∩G∣.
| 情况 | Dice | 含义 |
|---|---|---|
| 两个非空区域完全一致 | 1 | 重合最好 |
| 两个非空区域完全没有交集 | 0 | 重合最差 |
| 只部分重合 | 0 到 1 之间 | 越大越接近 |
例如真实车辆占 10 格,预测占 8 格,其中 6 格重合:Dice=2×6/(10+8)=2/3≈0.667。这里的 10、8、6 是区域格子数量,与前面的匹配代价数字不同。
训练要最小化误差,所以常使用 DiceLoss=1-Dice,本例约为 0.333。Dice 分数越大越好,Dice loss 越小越好;不要把两者方向弄反。
17.1 训练时使用概率,避免先硬阈值化
训练中的预测 p_i 通常是每格 0~1 的前景概率,真实标签 g_i 是 0 或 1。经典 soft Dice 可写为:
D s o f t = 2 ∑ i p i g i + ϵ ∑ i p i + ∑ i g i + ϵ , L = 1 − D s o f t . D_{soft}=\frac{2\sum_i p_i g_i+\epsilon}{\sum_i p_i+\sum_i g_i+\epsilon},\qquad L=1-D_{soft}. Dsoft=∑ipi+∑igi+ϵ2∑ipigi+ϵ,L=1−Dsoft.
用连续概率可以反向传播;若先硬切成 0/1,会丢失用于优化的连续变化。epsilon 是很小的平滑常数(具体实现不一定很小),用于数值稳定和处理空区域;不同实现中放的位置也可能不同,所以应区分概念公式与源码。
17.2 为什么 UniAD 使用它
在 200×200 地图上,一条细分隔线或一辆远处车辆可能只占很少的格子。只看整体分类准确率,大量背景容易掩盖前景完全没学好的问题。Dice 直接比较目标前景的整体重合,而不是主要由正确背景数量主导。它仍会惩罚漏掉前景及预测过大的区域;并非只看交集、不管预测区域大小。
在 UniAD:Map 的道路/区域 mask、Occ 的实例占用 mask 使用 Dice。Occ 同时配合 BCE(二分类交叉熵):BCE 逐格判断对不对,Dice 衡量整体区域重合;两者互补,不是所有分割任务只用 Dice 就足够。
17.3 当前代码的两种写法
- Map 自定义
DiceLoss使用平方分母:1-2*sum(p*g)/(sum(p*p)+sum(g*g)+2*eps);默认此类 eps=1e-6。对二值非空区域、忽略 epsilon 时,和集合公式一致;软概率时数值与经典线性分母不同。 - Occ 的
DiceLossWithMasks在当前配置开启naive_dice=True,使用线性分母和分子/分母加 epsilon,配置 eps=1.0;还过滤无效标签/帧。
代码:Map dice_loss.py、Occ occflow_loss.py、Occ 配置。
Dice 与 IoU 的区别:IoU=交集/并集;Dice=2×交集/(预测面积+真实面积)。二值情况下二者相关,但数值不一样,不应直接混用指标。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)