Town10 and the Aligned Route Set, Part 2
路线候选的第一路来源是”随机撒网”:随机抽一对 spawn 点当起终点,让全局规划器连出一条合法路径(tools/t23_generate_aligned_routes.py):
random.seed(42) # 固定种子:今天跑明天跑都是这批候选
grp = GlobalRoutePlanner(tmap, 2.0) # 2 m 路点分辨率
for _ in range(1000): # 抽 1000 对
i = random.randint(0, n_spawn - 1)
j = random.randint(0, n_spawn - 1)
if i == j:
continue
route = grp.trace_route(spawn_points[i].location,
spawn_points[j].location)
# 长度过滤:只留 100–200 m 的短路线
dist = sum(route[k][0].transform.location.distance(route[k+1][0].transform.location)
for k in range(len(route) - 1))
if not (100.0 <= dist <= 200.0):
continue
GlobalRoutePlanner 是 CARLA agents 包自带的路网级规划器:在 OpenDRIVE 车道图上按 2 m 分辨率建图,用图搜索找合法路径。trace_route 返回 (waypoint, RoadOption) 二元组序列,自动绕开单行道和不可达区域——返回不足 2 个点说明两点间根本无路可走,直接丢弃。
每个路点附带的 RoadOption 动作标签(LANEFOLLOW / LEFT / RIGHT / STRAIGHT / CHANGELANELEFT / CHANGELANERIGHT / VOID)是下一环节”路线类型分类”的直接原料。
一条路线是”左转”还是”直行”,判定方法是给整条路线的 RoadOption 序列计票(t23_generate_aligned_routes.py 原文):
def get_route_type(road_options):
cnt = Counter(road_options)
# 出现过变道标签,一票定为变道,优先级最高
if cnt.get("CHANGELANELEFT", 0) > 0 or cnt.get("CHANGELANERIGHT", 0) > 0:
return "lane_change"
# 左右转比票数
if cnt.get("LEFT", 0) > cnt.get("RIGHT", 0):
return "left"
if cnt.get("RIGHT", 0) > cnt.get("LEFT", 0):
return "right"
# 都没有就看有没有直行
if cnt.get("STRAIGHT", 0) > 0:
return "straight"
return "lanefollow"
这个分类器没有任何学习成分,简单、可解释、可复算——给同样的 RoadOption 序列,任何人重算都得出同样的类型标签。这一点很重要:类型标签后续要用于分层筛选、跨侧映射和 KID 场景分组,如果分类本身带随机性,下游所有分组统计都会引入额外噪声。代价是边界路线会”错分”(比如左转过小弯贴着 STRAIGHT 边界),这类边界错位如实登记在映射表里,不掩饰。
随机配对产出的候选里,直行占绝对多数——100–200 m 的短路线大概率不经过路口,或经过也是直行穿过。而 KID 分组结果(直行 0.094 vs 右转 0.196)恰恰要求转弯路线充足。于是生成器加了第二路:路口定向生成(generate_junction_candidates)。
topology = tmap.get_topology() # 拿整张地图的拓扑
junction_wps = [wp[0] for wp in topology if wp[0].is_junction] # 枚举所有路口路点
for i, sp in enumerate(spawn_points[:50]): # 只看前 50 个 spawn 点,控计算量
# 1. 找该 spawn 点 80 m 内最近的路口
nearest_junction = argmin over junction_wps
if min_d > 80.0:
continue
# 2. 终点要求距路口 ≥ 30 m(太短不算真过路口)
if junction_loc.distance(end_loc) < 30.0:
continue
route = grp.trace_route(start_loc, end_loc)
# 3. 路径必须真穿过路口:任一路点距路口 < 10 m
passes_junction = any(
r[0].transform.location.distance(junction_loc) < 10.0 for r in route)
三个距离阈值各管一件事:80 m 保证起点离路口不太远(不然路线主体还是直道);30 m 保证终点在路口之外(起终点夹着路口);10 m 验证规划出来的路径确实路过路口而不是绕行。最后还过滤掉分类为 lanefollow 的候选——穿过路口但没转弯的路线对”钓转弯”没有贡献。
定向候选与随机候选合并后一起进分层筛选器。“撒网(随机保底多样性)+ 捕捞(定向补转弯)“的组合,保证了配额表里过半的转弯题型有货可选。
筛选的目标是凑一套覆盖题型的 20 条卷子,按类型分层配额(select_routes 内硬编码):
target = {"straight": 4, "left": 5, "right": 5, "lane_change": 3, "lanefollow": 3}
# 合计 20:转弯(left+right+lane_change)占 13/20,过半
设计意图很明确:直行开得好不等于会开车,路口才是模型能力的分水岭;加上 KID 分组显示转弯处渲染差异最大,转弯题型必须过半。
for rtype, count in target.items():
pool = by_type.get(rtype, [])
pool.sort(key=lambda x: x["distance"])
take = pool[:count * 2] # 多取一倍备选
for c in take:
key = (c["start_spawn_idx"], c["end_spawn_idx"])
if key in used_spawn_pairs: # 同起终点对全局去重
continue
selected.append(c)
used_spawn_pairs.add(key)
两个机制:
(起点 spawn, 终点 spawn) 为键全局去重——20 条路线各有各的起终点,不重复考同一段路。配额没凑够时进入回填:从剩余候选里按距离排序补足 20 条。注意这个回填只看去重、不看配额——哪类候选多就往哪类倾斜。这不是 bug 而是简化,但它正是”配额没凑齐”的技术原因之一(另一个原因是候选池里根本没有变道路线)。设计配额与实际产出的偏差见下一块的完整对账。
设计配额是直行 4 / 左转 5 / 右转 5 / 变道 3 / 跟车道 3,实际产出(记入 meta,未美化):
| 类型 | 设计 | UE5 实际 | UE4 实际 |
|---|---|---|---|
| 直行 straight | 4 | 8 | 7 |
| 左转 left | 5 | 10 | 9 |
| 右转 right | 5 | 2 | 4 |
| 变道 lane_change | 3 | 0 | 0 |
| 跟车道 lanefollow | 3 | 0 | 0 |
原因一:候选池里没有变道路线。 变道标签(CHANGELANELEFT/RIGHT)由规划器在”需要换道才能走通”时才产生,而 100–200 m 短路线在 Town10 的规则路网上几乎不需要变道;路口定向生成又主动过滤了 lanefollow 以外的非转弯路线,进一步压缩了变道候选的空间。lane_change 和 lanefollow 两类的配额池直接是空的。
原因二:回填逻辑偏贪心超配。 配额不足时的回填代码只看 (起点, 终点) 去重、按距离升序补到 20 条,不回看配额表——直行和左转候选池最深,于是被回填大量超配:直行从 4 涨到 7–8,左转从 5 涨到 9–10,右转反而从 5 缩到 2–4。
结果原样写进 meta 与 CP5 报告的 limitation:**“对齐路线集从 20 条收缩为 10 条分层子集(用户拍板提速),路线级样本 30/模型,检验力受限”**一节连同题型偏斜一并登记。转弯占比虽低于设计(UE5 侧 left+right = 12/20 仍过半),三类直行/左转/右转的覆盖保住了,核心实验目标(转弯题型不缺位)没有塌。把偏差登记进报告,比悄悄重新生成一套”完美配额”的路线更诚实——后者会让已跑完的评测数据全部作废,且读者无从得知真实过程。
t23 生成器两侧各跑一遍后,最关键的产出是 data/routes/aligned_routes_mapping.json:同 route_id 的两侧路线一一对应,每条记录的类型和距离都是各侧各自实测(UE4 侧在 Town10HD 上量,UE5 侧在 Town10HD_Opt 上量),谁也不抄谁。
坐标是各侧独立生成的:两侧的 spawn 点列表独立、规划器独立、路点独立,同 id 路线的起点坐标在数值上完全两样。所以映射表的对齐语义是:
实际登记在表的偏差:20 条里 3 条类型错位(比如一侧判 left、另一侧贴着边界判成 straight),距离差均值约 3 m。一条都不藏。
关键论证是量级比较:这些对齐容差(米级距离差、3/20 类型错位)远小于模型自身逐帧的行为噪声。M3 噪声标定实测:SimLingo 重跑噪声 DS 达 12.91(UE4)/ 20.74(UE5),由”卡死↔完成”的二元翻转主导。当测量噪声本身是十几分 DS 的量级时,考卷间 3 m 的距离差对分数的影响完全淹没在噪声带里——对齐服务于对比,不服务于复刻。
所有报告里的跨侧对照(CP3 三档对比表、KID-vs-ΔDS 相关性)都引用这张映射表做配对,它是”两侧做的是同一张卷子”这句话的可审计证据。
Bench2Drive(B2D, NeurIPS’24 D&B)有现成的 220 条短路线 + 44 种交互场景,是 0.9.15 生态的事实标准基准。直接衍生看似省力,实际有三堵墙,任何一堵都足以让搬运计划当场破产。
B2D 的 220 条路线横跨 Town01–15 全套城镇地图。而 0.10 只有 Town10 重制版一张城市图——十分之九的路线在 UE5 侧无处安放,剩下那一成也不是”拿来即用”(见墙二)。
0.10 的 Town10HD 是重制版:资产全部重建,坐标刻度与 0.9 版不同。硬抄 0.9 侧的路线坐标,车会悬在楼里或飘到路外——路线文件里的坐标只对生成它的那张地图有效,跨地图复用必须重新规划,“衍生”也就退化成了”重新生成”。
44 种交互场景的触发器全部依赖 ScenarioRunner,而 ScenarioRunner 不兼容 0.10(官方 issue #1164,无发布计划)。即便路线能搬,场景这一半也只能留在 UE4 侧,两侧卷子从结构上就不一样。
就算三堵墙都翻过去,也不该搬。B2D 是难度基准:靠 44 种交互场景考倒模型,比谁更强;本项目要的是同卷对比:只换渲染引擎,对渲染差异灵敏,不追求难度。所以路线集声明”纯驾驶、无场景触发器、不对标 B2D 难度”,同时 schema 保持 leaderboard 兼容——UE4 侧官方管线零改动消费同一套 XML,UE5 侧自建 executor 同 schema 解析。自研的是内容,复用的是格式。
闭环评测不是确定性的:背景交通(TrafficManager 控制的 NPC 车辆)每次跑的走位由随机种子决定。设计口径是 seed 同时喂给 TrafficManager 和背景交通生成器,同一条路线跑 3 个 seeds = 三种背景车走位 = 三次独立抽样。设计上每模型 20 条 × 3 seeds = 60 个 route-run。
光有 3 seeds 还不够,还要知道同一个 seed 原样重跑差多少。项目额外跑了一批 seed-0 重跑,专门标定重跑噪声(rerun noise)。M3 实测结果(CP3 报告):
| 模型 | UE4 重跑噪声 | UE5 重跑噪声 |
|---|---|---|
| SimLingo | 12.91 DS | 20.74 DS |
| AutoMoT | 1.90 DS | 0.00 DS |
用途很直接:之后观察到任何 UE4→UE5 的分数差,先和这个噪声带比——差值落进噪声带内,就不能声称”掉了分”。
SimLingo 的重跑噪声由 2/10 条路线的”卡死↔完成”二元翻转主导(路线 10000 两次跑差 +64.7,10011 差 −44.3),其余 8 条 |Δ| ≤ 10.7。也就是说同一路线、同初始条件,一次在路口卡死耗尽预算(DS 接近 0),一次顺利通过(DS 大几十)——闭环系统是混沌的,微小扰动经多步决策放大成完全不同的结局。
两个推论:
SimLingo 是 VLM 逐帧生成式推理,闭环实测只有 0.04–0.065× 实时(约 0.7–1 s/帧)。冒烟外推:3 条路线游戏内 93 s,墙钟 1758 s(约 19×)。按这个比率,每模型 60 个 route-run(20 条 × 3 seeds)的墙钟撑不住,双模型双引擎再乘四倍更不可能。
用户拍板砍半提速:从 20 条里选 10 条分层子集——直行、左转、右转三类题型一条不少,只减条数不减题型。收缩后的样本口径:
收缩改变的是检验力,不是效度;只要结论按”当前检验力下是否显著”的口径措辞(而不是硬说”无差异”),科学性是保住的。
MDE(Minimum Detectable Effect,最小可检出效应):给定样本量、噪声和显著性水平,一个统计检验有能力发现的最小真实效应。配对设计下近似为:
其中 是配对差值的标准差(重跑噪声直接标定它), 是配对样本数(路线数),、 分别是显著性水平 与检验力 对应的标准正态分位数。样本量翻倍,MDE 只缩小 倍——加样本的边际收益递减,所以必须有”加多少算够”的判据。
样本量不能拍到事后,本项目把它做成预先声明(pre-registration)的规则,写在执行方案 v5 的 CP3 判据④(R4-N1)里:
| 模型 | 观测 Δ | MDE(n=10 配对) | 判定 |
|---|---|---|---|
| AutoMoT | +32.90 | 1.89 | MDE ≪ 观测,检验力充足,“显著提升”成立 |
| SimLingo | +6.76 | 20.63 | MDE > 5 DS 且吃掉观测 → 触发扩容讨论 |
SimLingo 触发了扩容分支,但 CP3 审议发现噪声本质是行为混沌(卡死↔完成翻转)而非高斯噪声:扩 seeds 边际收益低,扩路线(10→20)才对症——治理条款没有机械执行,而是触发了一次有依据的扩容讨论,这正是预先声明规则想要的效果:把”要不要加样本”从事后冲动变成事前约定的决策点。