Paired Rendering Capture: Same Photo in Two Worlds, Part 2
出生朝向由路线前两个 waypoint 的连线方向计算。修复前(git 修复提交 91f98b7):
# 修复前:参数序转置 + 魔法数补偿
yaw = math.degrees(math.atan2(wps[1][0] - wps[0][0], # dx 放到了第一位
wps[1][1] - wps[0][1])) # dy 放到了第二位
yaw -= 90 # 魔法数:-90° 补偿
# 修复后:标准方位角公式
yaw = math.degrees(math.atan2(wps[1][1] - wps[0][1], # atan2(dy, dx)
wps[1][0] - wps[0][0]))
atan2(dx, dy) 不是 atan2(dy, dx) 的随机错乱——它有一个确定的几何含义(镜像到对角线,见 B05 图解),所以算出来的朝向系统性偏转一个角度而不是乱指。车出生朝向歪了,但纯追踪控制器每个 tick 都在纠偏:慢速起步、打舵、回到线上。从外面看:
这个症状被整整容忍了两个里程碑(M3 采图 → M4 采数),直到 T4.1 冒烟逐帧检查起步画面才发现出生朝向就是歪的。波及范围:采图(t31)、冒烟(t41)、采数(t42)三个脚本——M3 的三万张图集和 M4 的五十五万帧专家数据,全部是在”歪着出生”的车上拍的。
它同时踩中三条”安静 bug”的特征:
教训(复盘文档原话):几何公式要用向量定义验证,不能用”跑通了”当正确。验证成本极低——拿一个已知方向的例子手算一遍即可,比如 dx=1、dy=0 时 yaw 必须是 0。
从点 指向点 的方位角(bearing/heading),是以 x 轴正方向为零点、逆时针为正的方向角:
atan2 的双参数形式纵坐标差在前、横坐标差在后——这是从 C 标准库到 Python、MATLAB 全世界一致的约定。用普通 会丢失象限信息( 时错 180°,且 除零),atan2 按两个参数各自的符号恢复正确象限,值域 。
把参数写成 atan2(dx, dy),几何上不是”角度错了一些”,而是一个确定的变换。设真角为 ,则:
(模 意义下)。这相当于把方向向量关于对角线 镜像再量角。原代码再补一个 的魔法数:
最终效果是朝向变成了正确朝向的镜像()——不是固定偏 90°,而是随路线方向变化的系统性镜像。直线路段上偏得少,斜线路段偏得狠。这也解释了为什么症状是”出生朝向歪一大截但幅度不一”。
向量定义验证的成本低到没有理由不做。取四个已知方向代入公式:
| 期望 | atan2(dy,dx) | atan2(dx,dy)-90° | ||
|---|---|---|---|---|
| 1 | 0 | 0° | 0° ✓ | −90° ✗ |
| 0 | 1 | 90° | 90° ✓ | −90° ✗ |
| −1 | 0 | 180° | 180° ✓ | 90° ✗ |
第一行就露馅。这个 bug 潜伏两个里程碑不是因为难查,而是因为没人做过这三十秒的检查——“跑通了”被当成了”对了”。
UE5 侧 spawn 车全部失败:blueprint 库里找不到车型,hero 车、背景车一辆都生成不了。报错明确——bpl.find("vehicle.lincoln.mkz_2020") 返回空。根因不是少了几个车型,而是 0.10(UE5)换了一整套命名约定:UE4 时代带年份后缀的命名全部作废,从 0.9.x 抄来的名字一个都查不到。
两侧实际声明的常量(bg_traffic.py,2026-09-02 实探口径):
# UE4 侧(0.9.15)池:抄自 leaderboard BackgroundBehavior 实测 Counter
UE4_POOL = [
"vehicle.lincoln.mkz_2020", "vehicle.chevrolet.impala",
"vehicle.ford.mustang", "vehicle.audi.tt",
"vehicle.dodge.charger_2020", "vehicle.nissan.patrol_2021",
"vehicle.mini.cooper_s_2021", "vehicle.mercedes.coupe_2020",
]
# UE5 侧(0.10)池:先探测再取交集(实探共 11 款可用)
UE5_POOL = [
"vehicle.lincoln.mkz", "vehicle.dodge.charger", "vehicle.dodgecop.charger",
"vehicle.mini.cooper", "vehicle.nissan.patrol", "vehicle.sprinter.mercedes",
"vehicle.taxi.ford", "vehicle.carlacola.actors", "vehicle.ambulance.ford",
"vehicle.firetruck.actors", "vehicle.fuso.mitsubishi",
]
命名规律的变化肉眼可见:UE4 是”厂牌.车型_年份”(lincoln.mkz_2020、nissan.patrol_2021),UE5 基本是”厂牌.车型”两段式(lincoln.mkz、nissan.patrol),还混进 dodgecop.charger、carlacola.actors 这种新类别。hero 车同理:UE4 侧 vehicle.lincoln.mkz_2020,UE5 侧 vehicle.lincoln.mkz。
官方没有发布任何 0.9→0.10 的资产名映射文档。命名规律的变化只能靠枚举 blueprint 库实探(world.get_blueprint_library().filter("vehicle.*"))跑一遍才知道。实探结果:UE5 侧一共 11 款车可用。
这个坑虽然拦路,但报错即发现——spawn 返回空、异常明确,当天就能修。与坑一(atan2 转置,潜伏两个里程碑)相比便宜得多。这正是不安静 bug 与安静 bug 的成本差:崩溃是免费的警报,坏数据没有警报。
核心是一个 probe 函数:拿候选清单逐个在 blueprint 库里 find,查不到的直接过滤,绝不硬编码”它应该在”:
pool = [bp for bp in (bpl.find(n) for n in self.pool) if bp is not None]
if not pool:
raise RuntimeError("背景车型池在当前 server 全部不可用,先探测再传入")
t31_collect_paired.py 启动时先探测、打印命中数再开跑:
pool = UE4_POOL if args.side == "ue4" else UE5_POOL
pool = probe_vehicle_pool(world, pool)
print(f"[t31] 车型池可用: {len(pool)}/{len(UE4_POOL if ...)} -> {pool}")
探测日志本身就是留档:每侧实际可用哪些车、命中率多少,采集日志里白纸黑字。
探测之后,两侧的车型集合不可能完全一致(UE5 实探只有 11 款,与 UE4 池交集有限)。处理方式是:
UE4_POOL / UE5_POOL 写死在代码里,带日期和出处注释;跨版本的资产名,永远不要硬编码。 本项目的 exposure 属性名、车型名、乃至 -graphicsadapter 的 GPU 索引(UE5 侧 =2 选 5090、UE4 侧 =0 才是 5090,且索引随重启漂移),全是同一类问题:上游没有任何兼容性承诺,一切”理所当然”都要运行时重新证明。探测的成本是一次 find,不探测的代价是一侧数据全部作废。
33,692 张 1024×512 的图,PNG 存下来是几十 GB 级。JPG 能省约十倍磁盘,很诱人。但 MySim 的口径是全部存无损 PNG——因为这批图的第一消费者是 KID 域差距测量,而 JPG 是有损压缩。
JPEG 压缩不是随机噪声,它有两个确定性的信息损失机制:
关键是:这些伪影的形状由编码器和质量参数决定,与图像内容的来源无关。
假设 UE4 侧存 PNG、UE5 侧存 JPG(比如为了省磁盘”就改一侧”),那么:
测量管道里的每一步都可能自己制造域差距。存储格式这种”工程细节”恰好是最阴险的一类:它不报错、不显眼,省下的磁盘是真的,污染的测量也是真的。所以口径定为两侧统一无损 PNG,磁盘贵,但没有结论贵。
KID 的全称是 Kernel Inception Distance,它测的是两个分布在 InceptionV3 特征空间里的 MMD²:
是 InceptionV3 pool3 层的 2048 维特征提取器, 是多项式核。整个定义里没有任何环节区分”差异的来源”——它只回答”两个分布差多少”,不回答”差在哪、为什么差”。
输入到 KID 的差异实际是两股:
InceptionV3 的卷积层对高频纹理和边缘统计极其敏感,块效应的 8 像素网格、色边模糊都会真实地改变特征分布。两股差异各自贡献一个分布偏移,而 MMD² 输出只有一个标量:
事后没有任何办法把这个数分解回两项——这不是估计噪声,是结构性混杂,增大样本量也救不回来。
由此得到一条适用于一切分布级测量的原则:测量前的每一步预处理,两侧必须逐位一致。存储格式、缩放算法、色彩空间转换、归一化方式——任何一侧独有的处理,都会作为一个无名的分布偏移进入最终结果。本项目实测的域内基线 KID ≈ 0.0001(同侧随机劈半),而跨域值 0.0743 是其约 700 倍——如果混入压缩差异,这个干净的 700 倍关系就说不清了。
哨兵值(sentinel value)是用一个”明显异常”的合法数值来标记特殊状态的做法——比如用 −1 表示”未找到”。它的问题在于:哨兵值对机器合法,对统计致命。消费方如果不知道这个约定,就会把标记当成真数据。
CARLA 的 LiDAR 传感器对”没打中任何东西”的射线,两代版本行为不同:
不是随机挑的:float32 的最大值约 ,用一个接近类型上限的数做哨兵,意图是”任何正常距离都不可能这么大”。实测点云里混着这样的行:
命中点: [ 3.21, -1.10, 0.43, 0.72 ] ✓ 正常 (x,y,z,intensity)
未命中: [ 1.0e+38, 1.0e+38, 1.0e+38, 0.0 ] ✗ sentinel
格式本身没变(xyz + intensity,4 通道 float32,逐点连续排布),所以数据能正常解析、不报错、不崩溃——又是安静的坑。
下游是 AutoMoT 的 BEV 直方图链(lidar_to_histogram):点云按 x/y 投到 ±32 米的网格里分箱计数。一个 的点落进任何网格都意味着坐标轴飞出天际——直方图统计瞬间被拉爆,一个脏点毁掉整帧 BEV 输入。做统计描述(点数、距离直方图)时同理:均值、最大值全部失去意义。
清洗只需判断每个数是否为有限值(np.isfinite),位置放在传感器回调里——数据管线的第一站。原则是源头清洗:回调之后所有消费者(模型输入、直方图、落盘)拿到的都是干净点云,脏数据不流向下游。
哨兵值清洗是数据管线的标准动作:上游的约定随时可能变(这里就是 0.9→0.10 静默变了),下游要么在入口处防御,要么继承一切惊喜。
真实代码在 UE5 闭环执行器的传感器装配处(tools/ue5harness/route_executor.py L440-443):
def on_lidar(ev):
# raw_data 是裸内存:N×4 个 float32,每点 (x, y, z, intensity)
pts = np.frombuffer(ev.raw_data, dtype=np.float32).reshape(-1, 4)
pts = pts[np.isfinite(pts).all(axis=1)] # 0.10 未命中 sentinel 清洗
self.agent_data["lidar"] = (ev.frame, pts)
np.frombuffer(ev.raw_data, dtype=np.float32):CARLA 的 raw_data 是一段连续字节缓冲,没有结构体、没有点云库,就是裸内存。按 float32 解释后 reshape(-1, 4) 成行优先的 数组——每行一个点,四通道 x/y/z/intensity。这个格式 0.9.15 与 0.10 完全同构,所以解析代码两侧通用。np.isfinite(pts).all(axis=1):核心清洗,行语义——一个点的四个通道里任何一个非有限(NaN / ±inf / 溢出),整行丢弃。这里有个值得停下来想的细节:名义上的哨兵值 其实仍小于 float32 上限(),单看数值是”有限”的;0.10 实际写入的未命中点在实测中被这一行清洗滤掉(T3.4 链路验证、40/40 满分收官),所以按项目口径它就是有效的哨兵清洗。若哪天上游把哨兵改成一个”恰好可表示”的大数,isfinite 就会漏防——更防御性的写法是再加一道量级阈值,例如 np.abs(pts[:, :3]).max(axis=1) < 1e3(正常 LiDAR 距离不过百米级)。哨兵约定是上游给的承诺,多一道校验就是少一类惊喜。(ev.frame, pts):包装成 [frame, points] 元组交给下游。这是 leaderboard 系 agent 的隐式约定(AutoMoT 的 input_data['LIDAR'] 需要这个形状),不是随便定的格式。回调(callback)是传感器数据进系统的第一站。在这里清洗意味着:
反过来,如果把清洗放在某个下游消费者里,其余消费者就会踩坑;放在回调里,防御只需要写一次。
症状很直白:CARLA server 启动即崩,bind RPC 端口时抛 WSAEACCES(权限拒绝),未捕获直接退出(异常码 0xe06d7363)。真正的坑在崩溃报告会误导方向:crash XML 里的 AdapterName 字段让人第一反应去查显卡/驱动——查了三天可能都查不到,因为根因根本不在 GPU。
Windows 的 winNAT(容器/Hyper-V 网络地址转换服务)会向系统申请动态 TCP 排除端口段:落在段内的端口,任何进程 bind 都会被系统拒绝,且不提示原因。查看命令:
netsh int ipv4 show excludedportrange tcp
关键在于这些段会漂移:重启、Docker/Hyper-V 状态变化后,保留段的范围会变。MySim 实测三次被吞,端口三次搬家:
期间实测过的保留段还有 2269–4289、50000–50059 等,每次都不一样。
本项目沉淀出的顺序,写进了 AGENTS.md 坑录:
netsh int ipv4 show excludedportrange tcp,看目标端口是否落在任何排除段内——十秒钟的事;同族教训还有:-graphicsadapter 索引随重启/会话漂移,任何启动流程必须带 nvidia-smi 显存增量校验。Windows 宿主上的仿真基建,一切环境假设都有保质期。