Paired Rendering Capture: Same Photo in Two Worlds, Part 1
跨仿真器采图时,“对齐”有两个强度不同的口径:
MySim 采的是语义对齐。原因很实际:CARLA 0.9.15(UE4)与 0.10(UE5)是两个独立进程,交通流由各自的 TrafficManager(交通管理器)驱动,即使 seed 相同也无法逐帧同步世界状态。硬要像素对齐,得冻结一侧世界再复刻到另一侧,工程量远超收益。
下游要算的是 KID(Kernel Inception Distance,核 Inception 距离)——一个分布级指标。它比较的是两组图片在特征空间里的分布距离,不要求逐张配对:
其中 是图片 经 InceptionV3 提取的特征,MMD(最大均值差异)在两组样本上估计。只要两侧各自是”同等条件下”的独立同分布采样,样本数不等、无配对关系都不影响估计的有效性(实测 UE4 侧 12,636 张、UE5 侧 21,056 张)。
语义对齐真正要钉死的是混杂变量(confounder):路线集、相机内外参、背景交通规模、天气 preset。这些不钉死,分布差异就说不清来自渲染引擎还是来自采样条件。
配对信息不进像素,进文件名与元数据:文件名编码 side / route_id / pass / frame_idx 四段,两侧同名文件即”同路线、同遍数、相近进度”的语义对;每张图另有一行 JSONL 元数据(仿真时间、进度索引、位姿)做校验。对齐靠索引保证,不靠图像内容。
端到端驾驶模型看到的世界,全部来自一个相机。相机参数决定了两件事:
模型在训练时学到的一切几何先验——“车道线大致在画面下方 1/3 处交汇""前车在这个像素大小时距离约 20 米”——都绑定在训练相机的口径上。换一套口径,这些先验全部偏移,等价于在输入端引入一个与渲染无关的分布偏移。
要测的量是”渲染引擎换代的域差距”。如果 UE4 侧用相机 A、UE5 侧用相机 B,测到的差异是:
两项混在一起且无法事后分离——KID 输出只有一个标量,不做来源归因。这就是典型的混杂变量污染:能控的变量必须全部钉死,剩下的差异才有资格叫”渲染差异”。
MySim 的做法是直接抄主模型 SimLingo(CVPR’25)训练配置 config_simlingo_base.py 里的相机定义,并在三处一字不差地复用:
t31_collect_paired.py 的 CAM_SPEC)route_executor.py 的模型输入相机)t41/t42 采数脚本)这份图集有三个消费者:KID 域差距测量、闭环评测的相机基准、微调数据的采集模板。任何一处口径漂移,三个用途同时作废——所以相机规格以常量形式钉死在代码里,注释标注”勿改”。
# SimLingo 训练相机(config_simlingo_base.py,勿改)
CAM_SPEC = {"x": -1.5, "y": 0.0, "z": 2.0, # 外参:位置(米),车顶后视
"roll": 0.0, "pitch": 0.0, "yaw": 0.0, # 外参:姿态(度),平视正前
"width": 1024, "height": 512, # 内参:分辨率(宽画幅)
"fov": 110.0} # 内参:水平视场角(度)
五组参数分两层:位置+姿态是外参(相机装在车的哪里、朝哪看),分辨率+FOV 是内参(相机怎么把 3D 世界投到 2D 像素)。x=-1.5 是相机在车头后 1.5 米(接近车顶中央),z=2.0 是离地 2 米;CARLA 采用左手系,x 朝车前、z 朝上。
仿真器的 RGB 相机是理想针孔模型。水平视场角 与焦距 (像素单位)的关系:
是图像宽度。内参矩阵(camera intrinsic matrix):
主点 (画面中心),方形像素下 。110° 是很宽的视场——人眼有效视角约 60°——宽 FOV 覆盖更多侧向路况(路口转弯必须),代价是边缘拉伸畸变更明显。这些几何性质全部随口径继承给模型。
投影关系是口径的非线性函数:FOV 差 5°, 变化约 3%,画面里所有物体的像素尺寸同步缩放;安装高度差 0.3 米,地平线位置上移、近处地面占比变化。这些变化在特征空间里是真实的分布偏移,却与渲染引擎毫无关系——而 KID 测到的会是两者的总和。所以规格要在训练配置、采图脚本、闭环执行器三处一字不差,任何一处漂移都会作为”假域差距”混进测量。
现代渲染引擎和真实相机一样有自动曝光(auto-exposure):根据画面亮度分布动态调整曝光补偿,让平均亮度落在一个目标区间。问题在于它是一个与场景内容相关的自适应算子:
其中 是曝光补偿(exposure value offset),由引擎根据 的直方图实时算出。UE4 与 UE5 的自动曝光实现完全不同(算法、目标亮度、适应速度都不同):同一个场景,一侧偏亮一侧偏暗。如果放任不管,测到的”渲染差异”里就混入了”曝光差异”——一个本来可以钉死的变量。
把曝光补偿显式设为 0(UE5 侧属性名 exposure_compensation),两侧都用未经自适应调整的原始曝光:
bp.set_attribute("exposure_compensation", "0.0") # 钉死曝光,禁用自适应偏移
这样亮度的差异只可能来自光照模型、材质、后处理管线本身——即真正的研究对象。
UE4(0.9.15)与 UE5(0.10)的相机曝光属性名字不一样,一侧叫 auto_exposure_* 一族,另一侧叫 exposure_compensation。硬编码任何一侧的名字,另一侧就静默失效(set_attribute 对不存在的属性抛错或被忽略)。解法见”属性名探测法”:列候选清单逐个探测,有哪个设哪个,并把实际生效的属性写进 camera_applied.json 留档。
曝光是”数据管线自己制造域差距”的第一个实例,同类的还有存储格式(PNG/JPG)。凡是测量管道里两侧可以不一致的环节,都是潜在的污染源。原则只有一句:能控的变量全部钉死,剩下的差异才配叫渲染差异。
CARLA 相机的传感器属性通过字符串键设置(bp.set_attribute(name, value))。0.9.15(UE4)与 0.10(UE5)之间属性名有静默变化:曝光相关属性一侧是 auto_exposure_max_brightness / auto_exposure_min_brightness / auto_exposure_bias 一族,另一侧是 exposure_compensation。跨版本硬编码任何一个名字,在另一侧要么抛错、要么静默无效。
真实的实现(t31_collect_paired.py):
EXPOSURE_ATTR_CANDIDATES = [
# UE4.26(0.9.15)与 UE5(0.10)的自动曝光属性名,逐个探测
("auto_exposure_max_brightness", "50.0"),
("auto_exposure_min_brightness", "50.0"),
("auto_exposure_bias", "0.0"),
("ev", "10.0"),
("exposure_compensation", "0.0"),
]
applied = {}
for attr, val in EXPOSURE_ATTR_CANDIDATES:
if bp.has_attribute(attr): # 运行时探测:这一侧有没有这个属性
try:
bp.set_attribute(attr, val)
applied[attr] = val # 记录实际生效的属性
except RuntimeError:
pass # 存在但拒绝设置,跳过
三个要点:
has_attribute 运行时询问 blueprint,而不是查文档——文档与版本脱节时(这正是本项目的常态),只有运行时事实是可信的。applied 字典连同完整 CAM_SPEC 追加写入 camera_applied.json,记录”这次采集实际生效了哪些属性”。探测结果可审计,事后发现口径问题时能回溯。只探测不留档,两周后没人记得当时到底锁没锁上曝光;只留档不探测,记录的就是一厢情愿的假设而非运行时事实。这个组合模式在本项目反复出现:车型池探测(probe_vehicle_pool)、GPU 选型校验(nvidia-smi 显存增量)、server 探活——跨版本/跨进程的任何”理所当然”,都要用运行时事实重新证明。
采集口径里,曝光被钉死,色调映射(tone mapping)和运动模糊(motion blur)却各用引擎默认。这不是疏忽,判据只有一条:
UE5 侧(CARLA 0.10)存在一个引擎级阈值:分辨率低于 1080p 时没有运动模糊。本项目相机是 1024×512,恰在阈值之下,因此 UE5 侧的图实际上是”无运动模糊”状态,UE4 侧则按默认渲染。
处理方式值得注意:如实保留,并在报告中声明,而不是为了对称强行把 UE4 侧的运动模糊也关掉。理由:
控制变量不是”把一切调成一样”,而是”让每一个剩下的差异都有名有姓”。钉死的写进口径,保留的写进声明,两者都要落盘(camera_applied.json、报告 limitation 节)——数据的可信度等于每个口径决策可回溯。
CARLA 自带的 TrafficManager autopilot 走自己规划的路径,行为带随机性(变道决策、车速扰动),不满足采图要的确定性跟线——两侧必须沿同一条路线的同一组 waypoints 行驶,画面才可比。所以司机是一个自写的极简控制器,三层结构,每层十几行。
维护一个单调递增的路线索引 idx:每 tick 比较车到当前点和下一个点的距离,离下一个更近就推进。
while self.idx < len(self.wps) - 1:
wx, wy, _ = self.wps[self.idx]
nx, ny, _ = self.wps[self.idx + 1]
if ((x - nx)**2 + (y - ny)**2) < ((x - wx)**2 + (y - wy)**2):
self.idx += 1 # 离下一点更近,推进
else:
break # 单调不回跳,防路口处索引抖回
单调性是刻意的:路口处两条路段靠近,若允许索引回跳,转向目标会在两段之间抖动。这个索引还顺带记录了路线进度,直接写进 meta 做审计。
纯追踪(pure pursuit)的核心:在路线上找一个距车 (前瞻距离,5 米)的目标点,计算车当前航向到目标点的偏差角 ,然后让车沿一段圆弧开过去。几何关系给出转向角:
是轴距(代码取 2.7 米)。代码里 用车体系坐标手算(先把目标点旋进车体系,再取 atan2):
alpha = math.atan2(-math.sin(yaw)*lx + math.cos(yaw)*ly, # 车体系 y(横向)
math.cos(yaw)*lx + math.sin(yaw)*ly) # 车体系 x(纵向)
steer = math.atan2(2.0 * 2.7 * math.sin(alpha),
max(self.lookahead, dist)) / (math.pi / 2.0) # 归一化到 [-1,1]
项的直觉:目标点偏得越多,打得越狠;(已对准)时回正。
巡航目标 5 m/s,误差 进 PID:
系数 (积分项实际为 0,是 PD),积分槽保留防漂移。输出正负分流:u ≥ 0 给油门(限幅 0.6),u < 0 给刹车。
三件套合起来就是一位稳定的老司机:不到四十行,无状态依赖外部系统,两侧引擎跑同一份代码——司机本身不构成对比变量。
背景交通规模是个混杂变量:车多的一侧画面里动态物体占比高,分布天然不同。两侧必须一致,但”一致”取多少有讲究。
原始依据是 UE4 侧官方 leaderboard 的 BackgroundBehavior:2026-08-30 冒烟实测瞬时约 15 辆。这个密度在 Town10 对齐短路线上出了问题——背景车互相堵死,路口排长队,hero 车礼让等待,路线跑不完、墙钟爆炸。UE4 侧的解法是给 vendored srunner 打低密度补丁(junction_source_perc 80→30、max_actors 6→3,瞬时约 7 辆);UE5 侧没有这套动态 source/sink 系统,用固定 spawn 6 辆近似对齐(bg_traffic.py)。两侧规模一致,动态物体密度的分布就可比。
每遍采图重抽一次背景交通:
bg = BackgroundTraffic(world, args.tm_port, n=args.bg_n,
seed=args.bg_seed_base + p, # seed = 100 + pass
pool=pool)
random.Random(seed) 控制两件事的抽样顺序:spawn 点的 shuffle 和车型的 choice。同一遍两侧用同一个 seed,场景不同但完全可复现——发现某张图有问题时,能用同样的 seed 精确重建当时的交通布局。注意这个 seed 与 TrafficManager 的 traffic-manager-seed 相互独立,分别控制 spawn 抽样与驾驶行为。
if d < self.min_dist: # min_dist_to_hero = 25.0
continue # 不堵 hero 起点门口
背景车的 spawn 点与 hero 车出生地至少隔 25 米。没有这条规则,seed 不好时起点门口就停着一辆车,hero 一出生就被堵死,起步画面全是”被堵”样本——这类样本两侧未必对称出现,又是一个分布污染源。
成对采图收官时两侧数量并不相等:UE4 侧 12,636 张、UE5 侧 21,056 张,总计 33,692 张 PNG。差出的八千多张来自两侧跑同一路线的时长不同(UE5 同步模式约 42 FPS,UE4 约 98 FPS,帧率与行为差异让路线耗时不同,2 Hz 采样下张数自然不同)。
如果用的是逐对指标——比如 LPIPS 逐对比较再平均——这个不对称是致命的:必须建配对、数量对齐、多余的丢弃。但 KID(Kernel Inception Distance)完全不受影响,因为它比的是分布,不是样本对。
KID 比较两组图片在 InceptionV3(pool3 层,2048 维)特征空间中的分布,用 MMD²(最大均值差异的平方)配多项式核:
其中 是两侧的特征分布, 是特征维数。无偏估计量对两组样本分别求和:
和 是两个独立的样本量,公式里没有任何一处要求 。样本少的一侧只是估计方差大一点,不产生系统偏差——这点可由 bootstrap 置信区间量化(本项目 overall KID = 0.0743 ± 0.0003,95% CI [0.0712, 0.0775])。
判断 0.0743 是大是小,需要一个刻度:同一侧内部随机劈两半算 KID,得到域内基线 UE4 侧约 0.0001、UE5 侧约 0.0000。跨域值是域内值的约 700 倍——渲染域差距客观存在,且远超估计噪声。这是 KID 的另一个优点:无偏估计量在 时围绕 0 波动(可以为负),天然自带”零刻度”。