Training Data Format: Frames, Fields, and Directory Conventions, Part 2
一切目录约定的源头是 SimLingo dataset_base 第 190 行的一条 glob(t40 转换器头注里有完整考古记录):
{data_path}/data/simlingo/*/*/*/Town*
data/simlingo 之下三层任意目录,第四层必须以 Town 开头。匹配上的每个目录被当作一条路线,里面再找 rgb、measurements、boxes 子目录。
glob 匹配不到任何路径时的行为是:返回空列表。不报错、不警告、不提示”你是不是路径写错了”。于是差一层目录的后果是:
这类”沉默接口”的排查方法只有一个:读源码把那一行 glob 找出来。找到之后,转换器的目标结构就不用设计了——把 glob 模式倒推成目录模板即可。MySim 的采集目录是两层(route_10000_s37/rgb),官方期待五层且 Town 结尾,转换器 t40 就是把这个 gap 补上:
route_10000_s37/ → data/simlingo/routes_training/town10/r010000_s37/Town10HD/
└─ 标记层 ──┘ └─town─┘ └── 路线号+seed ──┘ └Town开头┘
目录层级不是文件管理风格问题,是接口契约:生产侧(采集/转换)与消费侧(dataloader)靠它对接,而它既没有类型检查也没有 schema 校验,唯一的”编译器”就是那条 glob 本身。
SimLingo 官方数据量太大,训练时按场景/路线把样本分组存成 pkl 桶文件(buckets_paths.pkl),dataset 启动时按配置加载指定的桶,决定哪些 measurements 目录参与训练。这是给”多数据源混训、按需配比”设计的机制。
MySim 的自采数据只有一种场景(Town10HD 对齐路线),没有分组需求。硬仿一套桶结构意味着:生成 pkl、维护分组、对齐格式——全套伪造只为满足一个自己用不到的机制。
读源码发现 dataset_base L143 附近有特判:bucket_name == 'all' 时根本不加载桶文件,直接全量扫描。于是绕过方案只有一行配置(tools/t40_verify_dataloader.py 实测路径同款):
cfg["bucket_name"] = "all" # 绕过 bucket pkl:L143 特判,不加载 buckets_paths.pkl
转换器侧因此完全不用生成任何桶文件(tools/t40_convert.py 头注有同一结论的记录)。
这是”先读源码再写代码”的典型收益:不写这行配置,就得伪造整套桶结构;读到这个特判,省掉约一天的工作量,还少了一个可能出错的对齐面。官方代码里的特判分支,往往就是留给”非标准使用方式”的合法入口。
路线关键点从路线 XML 解析出来是三维坐标(x, y, z),但消费侧(dataset_base L420 附近)只按二维索引。因此落盘前必须裁掉高度维 z,存成 的 xy 二元组序列。采集侧的做法(tools/t42_collect_full.py):
wps = [(kp[0], kp[1], kp[2]) for kp in rc.keypoints] # XML 解析:三维
route_xy = [[p[0], p[1]] for p in wps] # 裁 z,落盘用 (N,2)
route 与 route_original 两个字段都按这个形状落盘,每帧重复写入全量 55 个点。
不抛异常。消费侧按两列取数时,列的含义悄悄错位——取到的”y”可能实际是 z。路线标签整体失真,而训练 loss 不会告诉你原因:数据格式全部合法,只有语义错了。
这类坑的识别特征:
既然形状是接口,就把它当接口对待:
len(route[0]) == 2 一行断言就能挡住整个故障类截距点先在世界系下算出,再转入车体系(ego frame,车头坐标系)。车位置 、朝向角 :
即”减车位姿、左乘旋转转置”。旋转矩阵正交,,所以这是标准的世界→局部坐标变换。官方 carla_garage 管线的 inverse_conversion_2d(transfuser_utils.py)逐字就是这个式子,MySim 采集侧(t42 route_targets)与之一致。
c, s = np.cos(ego_yaw), np.sin(ego_yaw)
R = np.array([[c, -s], [s, c]])
return [(R.T @ (p - ego_xy)).tolist() for p in out] # 世界系 → 车体系
训练时这个字段由采集算法算,评测时由官方 RoutePlanner 算——两者必须语义一致,否则就是训练/评测分布错配。MySim 的教训正在这里:采集算法无状态(每帧独立取 7.5/15m 截距),官方 planner 有状态(贪心 pop 近点、顺序演进),实测 86–96% 帧差异 >0.3m(M4 复盘),足以让 LoRA 微调后的模型行为崩溃。坐标系转对了只是及格,生成语义对齐才算对上。
每条路线一份 results.json.gz,记录该路线的得分与违章。官方 filter(filter_infractions_per_route)靠它决定路线去留:score_composed < 100 的路线被剔除出训练集。MySim 实测:质检发现自建专家 73% 路线带重碰撞,靠碰撞传感器真值打分 + 官方 filter 剔除了 2,543 条污染路线,834 条干净路线入库(CP4 报告)。
转换器对每条路线有两种 results.json.gz 来源:采集侧评分管道写的真值,和转换器兜底的满分占位。正确顺序是”有真值先链真值,没有再写占位”——tools/t40_convert.py 的实际代码:
src_res = os.path.join(src_dir, "results.json.gz")
if os.path.exists(src_res): # ① 有真值:硬链接真值
dst_res = os.path.join(dst_dir, "results.json.gz")
if not os.path.exists(dst_res):
try:
os.link(src_res, dst_res)
except OSError:
shutil.copy2(src_res, dst_res)
elif score_placeholder and not os.path.exists(...): # ② 无真值才写满分占位
res = {"scores": {"score_composed": 100.0, "score_route": 100.0}, ...}
顺序反过来的后果:占位满分先落盘,真值后到(os.path.exists 守住建成的占位,不覆盖),filter 一看每条都是满分——污染路线全放行,上一步质检白做。这正是”占位满分会被 convert 覆盖”教训固化成的代码形态(M4 坑账)。
“兜底默认值”是数据采集管线的危险物:它让流程永远能走完,代价是把失败伪装成成功。两个防御要点:
转换器要把 549,852 帧 / 110GB 的采集数据”搬”进官方五层目录结构。逐字节复制意味着磁盘占用翻倍、耗时以小时计——而文件内容一个字节都不需要改。
os.link(src, dst) 创建硬链接:让两个路径指向同一个 inode(文件系统里记录数据块位置的元数据结构)。效果是:
转换器实际代码(tools/t40_convert.py):
try:
os.link(s, d) # 同盘:硬链接,零拷贝
except OSError:
shutil.copy2(s, d) # 跨盘/不支持:退化为真实复制兜底
硬链接的两个硬限制:
/mnt/c)时 os.link 抛 OSError,代码退化为 copy2 真实复制——兜底分支保证了转换器在任何布局下都正确,只是慢硬链接的共享 inode 还带来一个顺手的好处:转换后的”数据库”目录随时可以整个删掉重转(删链接不删数据,引用计数归零才释放),源采集目录不受影响——幂等重跑因此既快又安全。
转换器必须满足:跑一百遍,结果和跑一遍完全一样。这是长跑任务的基础设施——采集要跑 9 小时以上,中间崩溃、重启、补采都是常态,不可重跑的转换器等于一次性用品。
秘诀在 tools/t40_convert.py 主循环里(L82 附近):
dst_dir = os.path.join(args.out, "data", "simlingo", "routes_training", "town10",
f"r{int(rid):05d}_s{int(seed)}", "Town10HD")
if os.path.isdir(dst_dir):
skipped += 1
continue # 增量模式:已完成路线整条跳过
原子单位是路线目录,不是单帧:一条路线要么全转,要么不转,不存在”转了一半”的中间态被误认成完成态(Town10HD 目录只在 convert_route 建齐子目录后出现——帧级中断的路线下次会重新进入转换,已存在的单帧靠逐文件 os.path.exists 跳过补齐)。
采集侧和转换侧用了同一套原子单位,两条流水线因此互不干扰、各自自愈:
| 侧 | 跳过条件 | 含义 |
|---|---|---|
| 采集(t42) | 路线目录下 measurements 超 50 帧 | 这条路线采过了 |
| 转换(t40) | 目标 Town10HD 目录存在 | 这条路线转过了 |
协议简单到一句话:目录即原子。崩溃重启接着转,补采的新路线下次自动被转换器捡走,索引(dataset 启动时自扫描)天然反映新增量——不需要任何中央状态文件来记录”跑到哪了”,文件系统本身就是进度条。
幂等设计的核心不是”跳过逻辑”,而是选对原子粒度:粒度太细(单帧),中断点状态复杂;太粗(整个数据集),重跑代价大。路线目录恰好是业务上天然的最小完整单元——一条路线内的帧才有时序依赖,路线之间完全独立。
转换器跑完了,怎么证明官方训练代码真能读?写一个”自己读 json 校验字段”的脚本没有意义——它验证的是你自己理解的格式,不是官方代码的格式。真正的门禁(tools/t40_verify_dataloader.py)直接用官方 Data_Driving 本尊实读:
from simlingo_training.dataloader.dataset_driving import Data_Driving
import simlingo_training.dataloader.dataset_base as _db
_db.get_original_cwd = lambda: os.getcwd() # 非 hydra 运行:给 cwd 解析打补丁
cfg["data_path"] = args.data
cfg["bucket_name"] = "all" # 绕过桶文件
cfg["img_augmentation"] = False # 关增广,输出确定可断言
cfg["img_shift_augmentation"] = False
ds = Data_Driving(**cfg)
print(f"dataset 样本数: {len(ds)}") # 全量库实测 106,244
item = ds[10] # 真取一个样本,触发全链路
一次 ds[10] 同时验证四件事:glob 命中(目录层级对)、字段解析(json.gz 键齐)、桶绕过生效(all 特判工作)、路径拼接(rgb/measurements/boxes 都能找到)。任何一环断了,这里当场炸。
SimLingo 训练用 hydra 管理配置,hydra 会改变进程工作目录并提供 get_original_cwd() 拿启动目录。脱离 hydra 直接跑时这个函数不可用,补丁把它固定为当前目录——这也是”隐藏约定:相对路径锚定 cwd”的代码现场:data_path 相对 simlingo 仓根解析,从别的目录启动,glob 空手而归,又是零样本静默失明。规矩因此只有一条:一律 cd 到仓根再启动。
“dataset 样本数 106,244、__getitem__(10) 成功、image_ff/speed/target_point/waypoints 形状全对、PASS”——这一行输出值一整天的排错时间。数据集接口的错误几乎全是静默型(空列表、错位、键缺失),唯一的通用探测器就是让消费方本尊走一遍真实路径。
| 口径 | 样本数 | 含义 |
|---|---|---|
| filter 关闭 | ~47 万 | 全量转换后 dataloader 扫描到的样本,不做路线级过滤 |
| filter 开启 | 106,244 | 剔除污染路线后的训练样本(T4.0 实读验证、CP4 入库净量) |
差距来自上一步质检:碰撞传感器真值打分 + 官方 filter 剔除了 2,543 条带重碰撞的污染路线(CP4:73% 路线带重碰撞,净化后 834 条干净路线入库,其中 train 751 条 / val 83 条)。
再往上一层还有第三组数:采集原始总量 549,852 帧 / 3,511 条路线 / 110GB。从 55 万帧到 47 万样本再到 10.6 万样本,是”采集 → 入库 → 过滤”三个不同环节的三个口径。
“10.6 万样本训练出的模型”和”47 万样本训练出的模型”是两个完全不同的实验陈述。数字脱离口径引用,轻则读者误解数据规模,重则实验不可复现。T4.0 任务记录里这组数字就是以口径对的形式登记的:47 万→10.6 万(filter 后)样本实读。
车辆出生朝向按路线方向计算,正确公式是 ——先 y 后 x。代码里写反成 atan2(dx, dy),两个参数一转置,车出生就偏 90°。
症状不是崩溃,是专家慢速:车一出生朝侧面,控制器先纠偏再跟线,每个路线的起步段都在浪费时间。这个症状被容忍了两个里程碑才回溯到根因(M4 复盘:波及 t31/t41/t42,是 M3 采图以来专家慢速的根因)。修复后的正确写法(tools/t42_collect_full.py 现行版本):
yaw = math.degrees(math.atan2(wps[1][1] - wps[0][1], # dy 在前
wps[1][0] - wps[0][0])) # dx 在后
教训:几何公式要用向量定义验证,不能用”跑通了”当正确。atan2 参数写反不抛任何异常,车照样动、数据照样产,只有速度分布统计偏低这一个微弱信号。冒烟时画一张”出生朝向 vs 路线方向”的叠加图,一眼就能否决。
多 worker 分片用 hash(filename) % N 决定文件归哪个 worker。但 Python 的 str/bytes 哈希默认进程级随机化(PYTHONHASHSEED 未固定时每次启动不同种子)——同一文件名在不同 worker 进程里哈希值不同,分片结果不一致,同一文件被分进不同桶,覆盖出现大洞(M4 复盘称”隐形 66% 覆盖陷阱”)。
修复:弃用 hash,改用稳定键分片——文件名排序后按 index 取模:
files = sorted(all_files) # 排序给出全进程一致的顺序
mine = [f for i, f in enumerate(files) if i % N == worker_id]
排序 + index 分片的结果与进程、机器、启动次数全部无关,这才是分布式分片该有的性质。