Training Data Format: Frames, Fields, and Directory Conventions, Part 1
压缩省盘。每帧一份真值 JSON,MySim 全量采集 549,852 帧 / 110GB(CP4 报告口径)。JSON 里的 route 字段每帧重复写入整条 55 个点的路线,文本冗余度极高,而 gzip(DEFLATE 算法)对重复文本的压缩率非常好——gzip.open(..., "wt") 一压,体积砍掉一大半。
与官方数据同构。SimLingo 官方发布的训练数据本身就是 json.gz,其 dataloader 读侧统一透明解压。自采数据沿用同一格式,训练代码零改动就能消费——格式对齐比省盘更重要,任何”自创更优格式”都意味着改读侧代码,引入新的不一致风险。
不上数据库。朴素文件方案的工程收益:
diff、可 grep、可 wc -l,质检不需要专用工具“先存 JSON 以后再优化”在小数据上成立,但自动驾驶数据集是 10 万帧起步的量级,存储格式就是接口:一旦训练侧、评测侧、转换器三方都按某个格式写死,事后改格式的成本远高于一开始对齐官方。
measurements 的 16 个核心字段不是平铺的清单,按”谁消费”可以分成四组。这个分组来自 T4.1 冒烟交付的字段映射表(experiments/EXP-T4.1-smoke/notes.md)。
| 组 | 字段 | 消费方 |
|---|---|---|
| 位姿真值(1 个) | ego_matrix | get_waypoints——waypoints 标签由相邻帧矩阵差算出 |
| 控制标签(4 个) | speed、steer、throttle、brake | speed 是模型真实输入(dataset_driving L70-71,DrivingInput);其余三个是可选陪跑标签 |
| 任务指令(4 个) | command、next_command、route、route_original | 语言 prompt 组装 / 路线消费(L420 附近) |
| 冗余兼容(7 个) | x、y、z、yaw、compass、gps、accel | 评测链 agent 直接按键名读,保证训练/评测同构 |
核心 16 键之外还有两对追加:target_point / target_point_next(T4.2 采集时内联计算),以及 augmentation_translation / augmentation_rotation(T4.4 增广口径),合计 20 键。
字段分组不是事后归纳,采集脚本落盘时就是这个结构(tools/t42_collect_full.py):
meas = {
"ego_matrix": M.tolist(), "speed": spd, # 位姿真值 + 控制
"steer": ctl.steer, "throttle": ctl.throttle,
"brake": 1.0 if ctl.brake else 0.0,
"command": 4, "next_command": 4, # 任务指令
"route": route_xy, "route_original": [list(p) for p in route_xy],
"x": tr.location.x, "y": tr.location.y, "z": tr.location.z,
"yaw": tr.rotation.yaw, # 冗余兼容组
"compass": ih.get("compass", 0.0),
"gps": gh.get("data", [0, 0, 0]),
"accel": ih.get("accel", [0, 0, 0]),
"target_point": t1, "target_point_next": t2, # T4.2 追加
"augmentation_translation": 0.0, "augmentation_rotation": 0.0,
}
判断一个字段”该不该存在”时,先看它在哪一组:
KeyError——存在性比对齐精度更要紧ego_matrix 是车辆位姿(pose)的齐次矩阵(homogeneous matrix)表示:
采集侧的实际构造(tools/t42_collect_full.py)正是这个近似:
M = np.eye(4)
yaw_rad = math.radians(tr.rotation.yaw)
M[:2, :2] = [[math.cos(yaw_rad), -math.sin(yaw_rad)], # 2D 旋转块
[math.sin(yaw_rad), math.cos(yaw_rad)]]
M[0, 3], M[1, 3], M[2, 3] = tr.location.x, tr.location.y, tr.location.z
模型监督的主标签是未来路点(waypoints),它不直接落盘,而是读侧从相邻帧的 ego_matrix 现算。当前帧位姿 ,未来第 帧的车位置 ,则该路点在当前车体系下的坐标为
旋转矩阵正交,逆等于转置,所以只需一次矩阵-向量乘。这就是为什么 ego_matrix 是”几何地基”:标签不是独立存储的真值,而是位姿序列的导出量。
因为标签由矩阵导出,ego_matrix 的任何错误都不是局部错误:
冒烟质检把它排在第一查(旋转块正交性 、轨迹步长连续性)就是这个原因——上游错一位,下游 16 个字段里凡是几何相关的全部静默失真。
| 字段 | 来源 | 角色 |
|---|---|---|
speed | hero.get_velocity() 的模长 | 模型输入(DrivingInput 的一部分,dataset_driving L70-71 消费) |
steer | 跟随控制器输出,原样落盘 | 陪跑标签(可选辅助监督) |
throttle | 同上 | 陪跑标签 |
brake | 同上 | 陪跑标签 |
speed 是例外:它是网络的真实输入,不是标签。自车速度不进相机画面,模型无法从图像推断自己开多快,必须显式喂入;闭环评测时 agent 也从仿真器读实时速度填同一个字段,训练/评测口径天然一致。
steer/throttle/brake 是专家(路线跟随控制器)的动作回放。SimLingo 的主监督是路点回归(waypoints regression),这三个控制量只作陪跑标签。采集侧把它们原样落盘(tools/t42_collect_full.py):
ctl = _c.VehicleControl(
steer=float(ctl_raw.steer),
throttle=float(min(0.55, args.throttle_coeff * max(0.0, tgt_spd - spd))),
brake=float(ctl_raw.brake if tgt_spd > 0.5 else 1.0), hand_brake=False)
hero.apply_control(ctl) # 控制量先作用于仿真
...
"steer": ctl.steer, "throttle": ctl.throttle, # 同一份对象原样落盘
"brake": 1.0 if ctl.brake else 0.0,
关键设计是标签和输入来自同一个仿真步的同一个控制对象:apply_control(ctl) 执行的那个 ctl,就是写进 measurements 的那个 ctl。如果改成”从仿真器回读实际控制量”或”下一帧补记上一帧的控制”,就会引入一个时间步的错位——模型学到的是”看到画面 A,执行画面 B 时刻的动作”,这种错位全程不报错,只表现为行为系统性偏差。
这也是数据格式设计的通用原则:输入与标签必须同帧同源,宁可多存冗余字段,也不要依赖跨帧对齐。
command 取自 CARLA agents 框架的 RoadOption 枚举:
| 值 | RoadOption | 含义 |
|---|---|---|
| -1 | VOID | 无指令 |
| 1 | LEFT | 左转 |
| 2 | RIGHT | 右转 |
| 3 | STRAIGHT | 直行 |
| 4 | LANEFOLLOW | 沿车道走(follow road) |
| 5 / 6 | CHANGELANELEFT / RIGHT | 向左/右变道 |
MySim 的采集与评测两侧 command 和 next_command 都写死为 4。
不是偷懒,是口径对齐的结果:
target_point 承担(模型真正的导航输入),command 再提供一套不一致的”指令”只会成为噪声源这是数据格式设计的第一性原则:字段值必须与消费语义对得上,对得上比多样更重要。一个恒为 4 的 command 字段看起来”信息量低”,但它在训练和评测两侧语义一致;一个看起来很丰富、两侧口径却不同的字段,价值是负的。
target_point 是模型真正的导航输入(navigational conditioning):沿路线前方约 7.5 米和 15 米的两个截距点,分别存为 target_point 和 target_point_next。与 command(恒为 4 的粗指令)不同,它给模型提供了连续、精确的几何导航信号。
采集侧的算法(tools/t42_collect_full.py 的 route_targets):先找路线上离车最近的点,然后沿折线累计弧长走到 7.5m / 15.0m 处,段内线性插值取点——是”沿路线走 7.5 米”,不是欧氏直线距离 7.5 米:
for target_d in (d1, d2): # d1=7.5, d2=15.0
acc = 0.0
p = r[idx]
for j in range(idx, len(r) - 1):
seg = np.linalg.norm(r[j + 1] - r[j])
if acc + seg >= target_d: # 截距落在这一段内
p = r[j] + (r[j + 1] - r[j]) * (target_d - acc) / max(seg, 1e-6)
break
acc += seg
取到的是世界系坐标,落盘前必须转到车体系。车朝向角 ,位置 ,则
是旋转矩阵的逆(正交矩阵逆等于转置)。这与官方 carla_garage 数据管线的 inverse_conversion_2d 是同一个公式,其 get_waypoints 算路点标签用的也是同款 。在该口径下车体系 x 轴指向车头方向:正前方的目标点转换后第一坐标为正、横向分量近零,转弯时横向分量显式编码方向——模型学的是相对几何,与地图原点、绝对朝向完全解耦。
MySim 采集算法是无状态的:每帧独立算 7.5/15m 截距。官方评测链的 RoutePlanner.run_step 是有状态的:贪心 pop 掉距 ego 7.5m 以内的点,target 取剩余路线的第 1 点、next 取第 2 点,pop 状态跨帧演进。两者在直线段近似一致,在转弯处几何发散——M4 复盘实测 86–96% 帧的目标点差异超过 0.3m,next_target 系统性偏移约 3–5m。
训练分布与评测分布的这个错配,是 ep22 微调失败剖析的主角:LoRA 把模型掰向了采集分布,推理时喂官方分布的目标点即行为崩溃。修复手段是 tools/t45_recalc_targets.py——直接 import 官方 RoutePlanner 类按路线顺序演进状态,重算全部 55 万帧的目标点。
CARLA 相机回调吐出的原始字节是 BGRA(蓝-绿-红-透明度,四通道)。OpenCV 的世界观恰好也是 BGR——cv2.imwrite 默认把传入数组按 BGR 解释。于是存储路径上一个通道顺序都不用翻,只需裁掉 alpha 通道。采集侧实际代码(tools/t42_collect_full.py):
cam.listen(lambda im: fh.update(img=np.frombuffer(
im.raw_data, dtype=np.uint8).reshape(im.height, im.width, 4)[:, :, :3]))
# ^^^^^^^^^^ 裁掉 alpha,BGRA → BGR
...
cv2.imwrite(os.path.join(rdir, "rgb", f"{frame_idx:04}.jpg"), fh["img"])
# OpenCV 按 BGR 写 jpg,通道顺序天然合拍
两个”恰好”叠加成零转换:CARLA 给 BGRA,OpenCV 要 BGR,裁一列就完事。如果链路上插入一个按 RGB 理解的库(如 PIL),就必须显式 [:, :, ::-1] 翻通道——这类转换错一位不会报错,只会让模型看到红蓝互换的世界,是典型的静默坑。
反过来,M3 的 KID 渲染对比采图用的是 PNG 统一(无损,避免压缩差异污染渲染域差测量)——存储格式要服务于下游口径:训练数据对齐官方用 jpg,像素级对比实验用 png。
CARLA 同步模式(synchronous mode)下,世界只在 world.tick() 被调用时推进一步。传感器回调在 tick 内触发,把结果写进缓存。采集循环的正确时序(tools/t42_collect_full.py):
while tick_n * args.dt < args.max_game_per_route:
world.tick() # 世界推进一步,相机回调刷新 fh["img"]
tick_n += 1
tr = hero.get_transform() # 立刻查询:拿到的是本 tick 的位姿
...
if tick_n % args.save_every == 0 and "img" in fh:
...
cv2.imwrite(..., fh["img"]) # 图像:本 tick 渲染的最新帧
meas = {"ego_matrix": M.tolist(), ...} # 真值:本 tick 查询的位姿
图像是这一步渲染的,真值是这一步查的,两者严丝合缝。要点有两个:
4fps 存储率意味着相邻样本间隔 0.25 秒。若图像与真值错开一个 tick:
(3.8 m/s 是 MySim 训练集的速度中位数,CP4 口径。)模型学到的将是”画面比标签慢一米”的系统性偏差——而且全程没有任何报错:jpg 正常、json 正常、字段齐全,只有行为层面的诡异漂移。这类错误无法靠训练 loss 发现,只能在采集侧靠时序纪律杜绝。
同 tick 原则对多传感器同样成立:IMU、GNSS、碰撞传感器的数据都应在同一个 tick 窗口内采样落盘。仿真器给了同步模式这把锁,采集脚本的责任是不要在锁内夹带异步操作。
CARLA 同步模式固定步长 dt = 0.05s(20 tick/s),每 5 个 tick 存一帧——tools/t42_collect_full.py 里 save_every=5 的注释写得很直白:# 4fps 官方口径:
与官方对齐。SimLingo 官方数据原生 4fps 采集。自采数据同帧率,模型在微调时看到的时间密度与预训练一致,不引入时间分辨率这个额外变量。
预测窗口。模型回归 pred_len(预测长度)= 11 个路点(SimLingo 官方配置原值,注释注明含当前步),每个间隔 0.25s:
即模型学的预测窗口约 2.75 秒量级(若按”含当前步”口径严格算纯未来步,则为 10 步 2.5 秒——引用时注意口径)。帧率变了,同样的 11 个点对应的物理时间窗就变了——帧率是标签语义的一部分。
历史深度。hist_len(历史长度)= 1:只用当前帧决策。这与闭环推理一一对应——评测时 agent 每步也只看到当前画面加测量值,训练/评测的观测结构完全相同。
0.25 秒一帧意味着相邻帧高度相似:速度中位 3.8 m/s 下相邻样本只前进约 0.95 米。一条路线的第 150 号样本对应行进 37.5 秒,背后是约 750 个仿真 tick,每 5 个 tick 才有一帧落盘。这解释了为什么 55 万帧能压缩到 10.6 万训练样本而不损失覆盖度——时间粒度决定了帧间冗余度,也决定了有效信息量远低于帧数表面值。
| 检查 | 具体做法 | 防的故障 |
|---|---|---|
| 几何查 | 旋转块正交性、轨迹步长连续性 | 位姿损坏(坐标系/单位/转置错误) |
| 像素查 | jpg 像素统计,无全黑全白帧 | 相机静默失败(回调没触发、曝光异常) |
| 结构查 | 16 键全覆盖、帧号无跳号 | 写盘竞态(进程崩溃留下半截文件、漏帧) |
T4.1 冒烟 200 帧三查全过(交付记录:ego_matrix 4×4 旋转正交 ✓、轨迹步长连续 ✓、jpg 像素统计正常 ✓),才有后面的 55 万帧放飞。
ego_matrix 左上 块 是旋转矩阵,合法旋转矩阵必满足正交性:
代码上就是算 (Frobenius 范数)并设一个阈值(如 量级)。这个检查能逮住一整类几何错误:转置写反、角度制/弧度制混淆、roll/pitch 误填——这些错误单看数值都不离谱,只有正交性约束能一票否决。轨迹步长连续性同理:4fps 下相邻帧位移有物理上界(速度上限 × 0.25s),跳变即异常。
三类故障的共同点:都不抛异常。位姿算错了 json 照常写,相机回调没触发 jpg 照常存(上一帧缓存),进程崩溃只损失最后几帧。没有显式质检,它们全部以”合法数据”的身份混进训练集。三查的本质是给静默故障各配一个探测器——这也是为什么冒烟要先于放飞:探测器本身的判据,需要在小数据上先校准一遍。