UE5 Closed-Loop Infrastructure: Building an Evaluator from Scratch, Part 1
UE5 侧闭环评测在一台机器上跑三个进程,边界全部用 TCP 划开:
┌────────────────────────── Windows 11 宿主 ──────────────────────────┐
│ CARLA 0.10 server(UE5,原生 Windows 进程,端口 2021-2023) │
└──────▲──────────────────────────────────────────────────────────────┘
│ CARLA RPC(carla-ue5-api==0.10.0)
┌──────┴─────────── WSL2 Ubuntu ──────────────────────────────────────┐
│ route_executor(conda env: mysim-ue5,py3.11) │
│ └─ AgentSocketServer:bind 127.0.0.1:5555,listen(1) │
└──────▲──────────────────────────────────────────────────────────────┘
│ TCP socket,JSON 文本流,换行分帧(协议 v0.2)
┌──────┴──────────────────────────────────────────────────────────────┐
│ 模型服务(conda env: mysim-simlingo,py3.10 + torch2.7.1+cu128 │
│ + carla==0.9.15):connect 方,加载 SimLingo 权重 │
└─────────────────────────────────────────────────────────────────────┘
两个方向性细节容易搞反:
localhost 不通宿主,必须动态取宿主 IP(ip route show | awk '/default/{print $3}'),禁硬编码。CARLA server 需要三个连续端口(RPC / streaming / secondary)。原约定 2000–2002 被 Windows 的 winNAT TCP 排除段(实测 1921–2020)整个吞掉:server bind 时抛 WSAEACCES,未捕获直接崩(0xe06d7363),崩溃日志里的 AdapterName 还会误导你去查显卡。排除段会随重启动态漂移,所以每次启动失败先查:
netsh int ipv4 show excludedportrange tcp
再查 adapter。最终约定 UE5 用 2021–2023、UE4 用 2031–2033(后又因漂移迁过 2141/2151、2161/2171 等间隙端口)。
单进程的替代方案(executor 里直接 import 模型)被依赖冲突否决(见下一篇)。三进程换来的实测性能基线:UE5 server 同步模式 42.3 FPS(2.1× 实时,Epic 画质 720p),UE4 侧同机 97.6 FPS(4.9× 实时)——这个 2.3 倍差距本身就是渲染管线(Lumen/Nanite vs UE4 前向渲染)的真实差异,也是整个对比实验的 FPS 基线。
两个 Python 包永远不能装进同一个 conda 环境:
| 包 | 用途 | 住在哪个环境 |
|---|---|---|
carla-ue5-api==0.10.0 | 连 UE5 server 的客户端 API | mysim-ue5(py3.11) |
carla==0.9.15 | SimLingo vendored 代码(leaderboard 生态)的硬依赖 | mysim-simlingo(py3.10 + torch2.7.1+cu128) |
冲突不止于”两个同名包”。SimLingo 的推理代码原样 import carla 的 0.9.15 类型(Transform、VehicleControl、leaderboard 的 SensorInterface),而执行器必须用 0.10 的 API 才能跟 UE5 server 说上话——0.9.15 客户端连 0.10 server 是 RPC 协议级不兼容(连握手都过不去,这正是下半集”坑一”的根因)。模型侧还钉着一整套脆弱栈:RTX 5090(sm_120)要求 torch≥2.7+cu128,flash-attn 预编译轮按 cu12torch2.7 匹配,任何解析器顺手升级 torch 都会连环炸。
Python 里隔离依赖的常规选项都不够用——venv 叠装解决不了同名 C 扩展包互斥,子解释器不隔离动态库。剩下的朴素解就是一个进程住一个环境,进程间走 TCP:
mysim-ue5 进程 ──TCP/JSON── mysim-simlingo 进程
(能连 UE5) (能跑 SimLingo)
socket 在这里同时承担三件事:
carla 包各住各家。代价是要自己维护一个协议(握手、逐帧问答、结束挥手),但协议只有三种消息,换来的是整个 UE5 侧评测链路的可行性。
执行器每 tick 采集一条记录,真实定义(tools/ue5harness/route_executor.py):
@dataclass
class TickRecord:
"""单 tick 原始记录(scoring_core 输入)。"""
tick: int
timestamp: float # 墙钟时间(验证 wall/game 比的原料)
sim_time: float # 仿真时间
ego_location: Tuple[float, float, float]
ego_velocity: Tuple[float, float, float]
ego_speed: float # m/s
ego_acceleration: Tuple[float, float, float]
collision_impulse: Optional[float] = None # 碰撞冲量,无碰撞为 None
collision_actor: Optional[str] = None # 碰撞对象 type_id
traffic_light_state: Optional[str] = None # Red/Yellow/Green/Off/Unknown
traffic_light_id: Optional[int] = None
lane_id: Optional[int] = None
road_id: Optional[int] = None
is_junction: bool = False
route_completion: float = 0.0 # 路线完成度 0-100
control: dict = field(default_factory=dict) # throttle/brake/steer
字段按用途分三组:
road_id 上)全靠它们。route_completion 直接决定 DS 的完成度项和 Completed/Failed 判定(阈值 99)。整个结构里没有任何 CARLA 对象引用,只有 float/str/bool。这个约束买来三个能力:
反面教训:如果记录里塞了 CARLA actor 句柄或 waypoint 对象,离线复算就得把仿真器再开起来,“评分”就和”仿真”重新焊死了。
驾驶分数(Driving Score, DS)= 路线完成度 × 罚分连乘:
成功率(Success Rate, SR)则是另一个口径:完全完成(Completed 或 Perfect)的路线占比,不看罚分。
真实实现(tools/ue5harness/scoring_core.py)就是这段话的直译:
# 碰撞:按对象类型分档,每次碰撞乘一次系数
for t in norm_ticks:
if t.get("collision_impulse") is not None:
ctype = _classify_collision(t.get("collision_actor"))
...
score_penalty *= PENALTY_VALUE_DICT[ctype]
# 闯红灯 / 超时同理累乘;最后合成
score_route = norm_ticks[-1].get("route_completion", 0.0)
score_composed = max(score_route * score_penalty, 0.0)
target_reached = score_route >= 99.0 # 完成度阈值
scoring_core.py 不 import 任何 CARLA 的东西:输入是 tick 记录列表(dict 或 dataclass),输出是 RouteScore(DS、SR、各违章分项、状态字符串)。这个约束的工程收益:
collision_impulse=350, collision_actor="walker.pedestrian.0001",断言 score_penalty == 0.5。vehicle_blocked(堵路)和 route_dev(偏离路线)在 Bench2Drive 口径里不直接产生罚分系数,它们的作用是终止路线——车一旦被判堵死/偏离,这条路线以当前完成度结算。所以低完成度本身就是惩罚,不再额外乘系数。超时(route timeout)则两者兼有:终止路线 + 乘 0.7。
罚分系数整张表照抄 Bench2Drive 的 leaderboard/utils/statistics_manager.py:
PENALTY_VALUE_DICT = {
"collision_pedestrian": 0.5, # 撞行人:罚一半
"collision_vehicle": 0.6, # 撞车:罚四成
"collision_static": 0.65, # 撞静态物
"red_light": 0.7, # 闯红灯:罚三成
"stop_infraction": 0.8, # 闯 stop 标志
"scenario_timeout": 0.7, # 场景/路线超时
"yield_emergency": 0.7, # 未让行应急车辆
}
因为 DS 的罚分项是连乘,系数的小数点直接决定分数语义:撞一个行人再撞一辆车,,满分路线也只剩 30 分。这套系数如果 UE4、UE5 两侧各写一份、差了 0.05,整个对比实验的分数就失去可比性——所以它是”唯一事实源”:UE4 侧用 Bench2Drive 官方统计器,UE5 侧这份字典与其逐字一致。
原计划走”平凡对照”(trivial control):用官方 npc_agent 跑一批路线,比对官方评分器和自建评分器的输出。结果对照组自己不可靠——官方 npc_agent 在同步模式下 TickRuntime 频发,6 条路线只跑完 1 条,样本量根本撑不起比对。
最后改用两层静态验证:
tools/ue5harness/test_scoring_core.py)。PENALTY_VALUE_DICT 与 Bench2Drive 源码的同名字典做 diff 级比对,连同违章命名(collisions_pedestrian 等 12 个分项名)一起对。教训比结论更值钱:验证方法本身也必须可靠。拿一个 6 跑 1 崩的对照组去”验证”评分器,通过与否都说明不了任何东西。
tick 消息是模型每帧看到的全部世界。执行器侧组包的真实代码(route_executor.py,协议 v0.2):
payload = {
"type": "tick",
"tick": self.tick_count, # 执行器侧帧号
"speed": speed, # 车速 m/s
"timestamp": record.timestamp,
"rgb_b64": base64.b64encode(rgb[1].tobytes()).decode("ascii"),
"rgb_shape": [h, w, c], # [512, 1024, 3]
"rgb_frame": int(rgb[0]), # 相机帧号(与仿真帧对齐用)
"gps": [lat, lon, alt], # GNSS 传感器原始输出
"compass": float(imu[1][-1]), # IMU 的航向角
}
# AutoMoT 版(协议 v0.3)另附激光点云:
# "lidar_b64": base64 的 float32 Nx4 点云,"lidar_n": 点数
模型的回复只有三个数,执行器包成 carla.VehicleControl 直接下发:
control = carla.VehicleControl(
throttle=float(reply.get("throttle", 0.0)),
brake=float(reply.get("brake", 0.0)),
steer=float(reply.get("steer", 0.0)),
)
self.ego.apply_control(control)
RGB 相机的配置必须与训练相机完全同源:
cam_bp.set_attribute("image_size_x", "1024")
cam_bp.set_attribute("image_size_y", "512")
cam_bp.set_attribute("fov", "110")
cam_tf = carla.Transform(carla.Location(x=-1.5, y=0.0, z=2.0), carla.Rotation())
端到端模型对输入分布极度敏感:分辨率、视场角、安装位姿任何一项偏离训练分布,都构成一个未被声明的变量,实验结论就说不清是”渲染差异”还是”输入规格差异”。颜色通道同理——CARLA 相机原始数据是 BGRA,代码切片成 BGR(arr.reshape(h, w, 4)[:, :, :3]),与 leaderboard 喂给模型的格式逐字节同构。
一帧 的 raw 像素约 1.5 MB,base64 编码膨胀 4/3 后约 2 MB——一条消息两兆,所以”换行分帧”的缓冲逻辑不能省(见 B17)。本地回环上这个开销可接受,换来的是协议全程明文可读、tcpdump 抓包即可调试。
TCP 是字节流协议:一次 sendall 发出的”一条消息”,对端可能一次 recv 收全,也可能半个包、或两条粘在一起。分帧(framing)必须由应用层自己做。这套协议选的是最朴素的方案:每条 JSON 后面加一个 \n,也就是 NDJSON/JSON Lines 格式。
接收方(模型服务侧,对称实现)维护一个缓冲区,按换行符切分:
buf = b""
while True:
chunk = conn.recv(1 << 20) # 一次可能收半条,也可能收两条
if not chunk:
break # 对端断开
buf += chunk
while b"\n" in buf: # 缓冲区里每凑够一条就消费一条
line, buf = buf.split(b"\n", 1)
if not line.strip():
continue
msg = json.loads(line.decode("utf-8"))
...
conn.sendall(json.dumps(reply).encode("utf-8") + b"\n")
执行器侧的一问一答用对偶写法:循环 recv 直到缓冲区以换行结尾(route_executor.py 的 send_tick):
data = json.dumps(payload).encode("utf-8") + b"\n"
self.client.sendall(data)
buf = b""
while not buf.endswith(b"\n"): # 回复没凑齐就继续收
chunk = self.client.recv(4096)
if not chunk:
return None
buf += chunk
reply = json.loads(buf.decode("utf-8").strip())
需求只有”一问一答”三种消息。HTTP 要引入请求生命周期和 header 解析,gRPC 要引入 protobuf 定义和代码生成——而 JSON 行协议 50 行写完,抓包全文可读。画面帧一条消息约 2 MB(base64 后),传输效率不是瓶颈,可调试性才是稀缺资源:12 轮修坑里有好几轮就是靠直接读 socket 明文定位的。
SimLingo 的 LingoAgent 原本活在 CARLA leaderboard 评测框架里。它不从传感器直接拿数据,而是期望框架每帧递来一个 input_data 字典:键是传感器名,值是 (帧号, 数据) 的二元组。这个结构由 leaderboard 的 SensorInterface 产生。
自建 socket 服务的核心技巧就是:把 socket 消息翻译成这个格式,然后原封不动调 run_step——模型推理代码一行不改,行为才能与官方评测对齐。真实代码(tools/t33_simlingo_agent_server.py):
def on_tick(self, msg):
buf = np.frombuffer(base64.b64decode(msg["rgb_b64"]), dtype=np.uint8)
h, w, c = msg["rgb_shape"]
rgb = buf.reshape(h, w, c) # BGR,与 leaderboard 同构
frame = msg.get("rgb_frame", 0)
input_data = {
"rgb_0": (frame, rgb), # 画面
"gps": (frame, list(msg["gps"])), # 经纬度
"imu": (frame, [0.0] * 6 + [float(msg["compass"])]),
"speed": (frame, {"speed": float(msg["speed"])}), # 车速包成 dict
}
control = self.agent.run_step(input_data, frame) # 官方推理路径
return {"throttle": float(control.throttle),
"brake": 1.0 if control.brake else 0.0,
"steer": float(control.steer)}
注意四种传感器值的数据形态各不相同:rgb_0 是 ndarray,gps 是 list,speed 还要再包一层 {"speed": ...} dict——这些形态全部由 leaderboard 的传感器注册代码隐式定义,没有文档。imu 的前 6 个加速度计/陀螺仪分量模型不用,直接填零,只保留航向角。
这个约定后来成为 AutoMoT 接入时”坑五”的翻车点:新加的 LIDAR 键忘了套 (frame, points) 元组,塞了裸数组,agent 内部按 lidar[1] 索引解包直接崩(详见下半集)。接口约定要对每一个键成立,不是对大多数键成立。
替代方案是 fork 一份 LingoAgent、把 run_step 的入参改成 socket 消息格式。被否决的原因:任何一行推理路径的改动都引入一个新的对齐风险——之后 UE4(官方 leaderboard 复现)与 UE5(自建 socket)两侧跑的就不是同一份代码,对比实验的内部效度受损。伪造输入让两侧共享同一字节序列的模型代码,差异被压缩到只剩仿真器本身。
路线关键点存在 CARLA 世界系(米制 xyz),而 leaderboard agent 的路线计划走 GPS 系(经纬度)。agent 内部(_init)会用数值求解器把 GPS 逆变换回世界系坐标。于是出现一条隐蔽的自洽要求:服务侧把世界系关键点转成 GPS 时,用的公式必须与 agent 逆变换所假设的正变换逐位一致。正逆变换差一位小数,模型就在错误的坐标系里做路线规划——症状不是崩,而是车”开得有点歪”,极难定位。
服务侧复刻的是 CARLA GNSS 传感器的换算(0.9.15 与 0.10 一致),基准点取零:
EARTH_R = 6378137.0 # WGS84 地球赤道半径(米)
def carla_to_gps(x, y, lat_ref=0.0, lon_ref=0.0):
"""与 CARLA gnss 传感器相同的换算,保证 agent 内部逆变换自洽。"""
lat = lat_ref + y * (180.0 / math.pi) / EARTH_R
lon = lon_ref + x * (180.0 / math.pi) / (EARTH_R * math.cos(lat * math.pi / 180.0))
return lat, lon
写成公式,即把局部位移按地球半径折算成角度(等距圆柱投影的小区域近似):
其中 是世界系坐标(米), m。经度项的 修正的是纬线周长随纬度收缩——同样一米的东向位移,在高纬度对应更大的经度差。
agent 的 _init 用 fsolve 数值反解基准点:给定一串 GPS 路线点,反推出”使得正变换还原这些点”的 。因为生成 GPS 时基准就取 ,求解器会自洽地解出 ——不需要任何额外约定把基准点传过去。路线计划和自车位置(来自同公式 GNSS 传感器)天然落在同一个坐标系里。
这类”正逆运算配对”是仿真工程的通用雷区:坐标系、单位、颜色通道、字节序,凡是需要一正一反两次转换的地方,两次转换必须出自同一份定义,否则误差被数值求解器静默吸收,表现为行为漂移而非报错。
SimLingo 的冷加载(读 InternVL2-1B 权重、构图、预热)约 90 秒。如果每条路线起一个进程,40 条路线光加载就烧掉约一小时。解法是模型服务常驻:首路线冷加载,后续路线只做毫秒级的软重置——清掉路线相关状态,模型权重原地复用。
软重置的真实清单(t33_simlingo_agent_server.py 的 on_route_start):
if self.agent is None:
self.agent = self._new_agent() # 首路线:冷加载(~90s)
else:
a = self.agent
a.step = -1 # 帧计数归零
a.initialized = False # 令首帧走 _init(),重建 RoutePlanner
a.DrivingInput = {}
a.last_command = -1
a.last_command_tmp = -1
a.stuck_detector = 0 # 卡死检测器
a.force_move = 0
a.target_point_prev = [1e5, 1e5, 1e5]
a.commands = deque(maxlen=2) # 命令队列:注入两个 LANEFOLLOW(枚举值 4)
a.commands.append(4)
a.commands.append(4)
a.metric_info = {}
# 重建控制器:速度用普通 PID,转向必须用 LateralPIDController
a.speed_controller = t_u.PIDController(
k_p=a.config.speed_kp, k_i=a.config.speed_ki,
k_d=a.config.speed_kd, n=a.config.speed_n)
a.turn_controller = LateralPIDController(inference_mode=False)
if hasattr(a, "image_buffer"):
a.image_buffer.clear()
# 然后注入新路线的双版计划(GPS 版 + 世界系版)
self.agent._global_plan = gps_plan
self.agent._global_plan_world_coord = world_plan
软重置的正确性没有一个”重置 API”可调用——清单只能靠逐行审计 agent 源码,把”上一路线留下的状态”一个个揪出来:帧计数、命令队列、卡死检测器、控制器内部积分项、图像缓冲。漏清任何一个,第二条路线就带着上一条的残 state 起跑。
这个清单里每一个字段背后都是一个真实的坑。最痛的一次是 turn_controller:软重置时顺手把转向控制器建成了和速度控制器同一个普通 PID 类,而 SimLingo 的转向控制是另一个签名不同的 LateralPIDController。首路线走冷加载路径一切正常,从第二条路线起 tick 全线崩——首条路线正常不能证明软重置正确,错误只在状态重建路径上暴露(这个案子下半集展开)。