Sensors in Depth: Camera Intrinsics to LiDAR Point Clouds, Part 1
CARLA 里挂一个传感器永远是同一段套路,MySim 的 _setup_agent_sensors(tools/ue5harness/route_executor.py:381)把四个传感器全部收在一个函数里:
sensor.camera.rgb、sensor.lidar.ray_cast。set_attribute("fov", "110")。carla.Transform 位姿,把传感器实例化进世界,并用 attach_to=self.ego 挂到英雄车上——之后传感器跟随车辆运动,不用每帧手动同步位姿。cam_bp = bpl.find("sensor.camera.rgb")
cam_bp.set_attribute("image_size_x", "1024")
cam_bp.set_attribute("fov", "110")
cam_tf = carla.Transform(carla.Location(x=-1.5, y=0.0, z=2.0), carla.Rotation())
self.agent_cam = self.world.spawn_actor(cam_bp, cam_tf, attach_to=self.ego)
self.agent_cam.listen(on_img) # on_img 在每帧图像就绪时被调用
四个传感器各自异步回调,回调里只做一件事:把 (帧号, 数据) 写入 self.agent_data 字典(键为 rgb/gps/imu/lidar)。主循环 run_route 每 tick 打包当时的最新帧组——这是异步多传感器最朴素的同步策略:不做硬件级对齐,靠帧号做最近邻匹配,容忍微小时差。
这个结构的关键点在于采集与消费解耦:传感器按自己的节奏吐数据,消费方按 tick 节奏取快照,中间只隔一个字典,没有阻塞队列。代价是同一 tick 内四路数据可能来自相邻的仿真帧,对 20 FPS 的闭环控制来说这个误差可忽略。
MySim 英雄车前视相机的完整规格(route_executor.py 中 cam_bp 的设置):
| 参数 | 值 | 含义 |
|---|---|---|
image_size_x | 1024 | 图像宽度(像素) |
image_size_y | 512 | 图像高度(像素) |
fov | 110 | 水平视场角(度) |
| 位置 | x=−1.5, z=2.0 | 车顶略靠后(米) |
| 朝向 | 全零 | 无俯仰无偏航,平视 |
这套数字不是工程审美,是 SimLingo 的训练相机规格。端到端模型对输入分布极端敏感:数据采集、训练、闭环推理三侧的相机参数必须一字不差,否则画面构图变了,等于给模型喂了一种它没见过的相机。
1024×512 的扁宽比例是刻意选择:
模型侧还有一道二次加工:SimLingo 的预处理先把画面底部约 30%(4.8/16 的高度,近处机盖区域)裁掉,再把剩余画面切成左右两块,各自缩放到 448×448 喂给视觉语言模型(VLM,InternVL2-1B)。所以 1024×512 是”采集分辨率”,448×448 是”模型真正看到的分辨率”,中间的裁切和切分逻辑同样属于训练分布的一部分,推理时必须复刻。
小孔成像(pinhole camera)模型里,图像宽度 、水平视场角 与焦距 (单位是像素)的关系是:
推导一步就能看懂:从光心向画面左右边缘各引一条射线,夹角就是 ;把画面沿中线劈成两半,得到一个直角三角形——对边是半个画面宽 ,邻边是焦距 ,半角的正切 ,移项即得上式。
这行公式的作用是把「几何视野」(度)翻译成「像素放大率」(像素): 表示世界中 1 个单位的角度变化在图像上摊到多少个像素。
MySim 的前视相机 ,:
对照:CARLA 默认相机 时,,。FOV 越大, 越小——同样 1024 个像素要覆盖更宽的世界,每个像素的”张角”变大,画面自然更广角、物体显得更小。
fov 属性就是水平方向,和图像宽度 配对;若要算 ,得用垂直视场角和高度 ,或者直接用宽高比换算:。把 1024×512、fov 110° 这台相机的四个数摆进矩阵,就是相机内参矩阵(camera intrinsic matrix):
内参矩阵定义了相机坐标系下三维点 到像素坐标 的映射:
即 ,。注意左侧的 :透视投影天然带深度除法,离得越远投影越靠近主点——这就是”近大远小”的代数形式。
内参矩阵 描述”相机怎么成像”,位姿(pose)描述”相机放在哪、朝哪看”——后者叫外参(extrinsics)。MySim 前视相机的外参:
cam_tf = carla.Transform(
carla.Location(x=-1.5, y=0.0, z=2.0), # 位置(米)
carla.Rotation(), # 朝向:pitch/yaw/roll 全零
)
CARLA 车辆的原点在底盘中心,坐标轴:x 朝车头、y 朝右、z 朝上(沿用 Unreal 的左手系)。因此:
x = -1.5:沿车头反方向挪 1.5 米 → 相机在车顶靠后位置;z = 2.0:抬到 2 米高 → 车顶视角;外参常被当成”安装细节”而忽略,但对学习类模型,它和 FOV、分辨率同等重要:模型学到的是特定位姿下的像素-控制映射。跨版本迁移仿真器时,blueprint 默认值只管内参类属性,挂点位姿是用户代码给的——这部分不会”自动错”,但也最容易在重构时被随手改动。
CARLA 的 RGB 相机回调拿到的 image.raw_data 是一块连续内存,布局为 BGRA——每像素 4 字节,按蓝、绿、红、Alpha 顺序排列。注意不是 RGBA:这是 Unreal 渲染目标的原生通道序,也是 CARLA 两代版本(0.9.15 / 0.10)一致的约定。
MySim 的回调(route_executor.py 的 on_img)两行搞定解码:
arr = np.frombuffer(image.raw_data, dtype=np.uint8) # 一维字节流
arr = arr.reshape(image.height, image.width, 4)[:, :, :3] # 切掉 Alpha → BGR
frombuffer:零拷贝地把字节流解释成 uint8 一维数组;reshape(H, W, 4):还原成 高×宽×通道 的三维数组;[:, :, :3]:按通道切片丢掉 Alpha,得到三通道 BGR。cv2)处理恰好同序——OpenCV 的默认通道序就是 BGR。只有在喂给 VLM 之前才需要 cv2.cvtColor(arr, cv2.COLOR_BGR2RGB) 转成 RGB。顺序搞反不会报错,只会让模型看到红蓝互换的世界,属于典型的”静默错”。[:, :, :3] 跨步访问原内存,不做拷贝;某些下游操作(如序列化、某些 CUDA 上传路径)要求连续内存,需要 .copy() 或 np.ascontiguousarray。Unreal 的相机自动曝光(auto exposure)参数,在 UE4.26(CARLA 0.9.15)和 UE5(CARLA 0.10)里暴露给 blueprint 的属性名不一样。写死一个名字,必然在另一侧 set_attribute 静默失败或抛错。
解法是探测式设置(probing):准备一串候选属性,逐个用 has_attribute 试探,存在才设。MySim 采集脚本 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"),
]
在 0.10 上实际命中的是 exposure_compensation = 0.0。
这一手针对的是自动曝光还活着的情况:UE 的自动曝光在 [min_brightness, max_brightness] 区间内动态调整曝光值。把上下限设成同一个数(50),调整区间坍缩成一个点——曝光算法还在跑,但没有任何可调空间,等效于锁死。这是一种”不关闭功能、只没收自由度”的温和做法,兼容性比找”关闭开关”属性好。
自动曝光会追着画面亮度逐帧调整:进树荫整帧变暗,出树荫又被拉亮。这对人眼是福利,对对比实验是灾难:
锁死曝光后,画面亮度只剩场景光照本身这一个来源,变量才干净。
探测式设置的副作用是”到底哪些属性生效了”不直观。采集脚本把实际命中的属性集合连同相机规格、仿真器侧别一起追加写入 camera_applied.json,UE4/UE5 两侧各留一份——对比实验的可审计性就靠这种落盘习惯。
SimLingo 闭环推理的图像预处理里有一步看起来很多余:拿到 CARLA 的无损原始帧后,先用 cv2.imencode(".jpg", ...) 压一遍 JPEG,再解码回来继续用:
_, enc = cv2.imencode(".jpg", camera) # BGR → JPEG 字节流
camera = cv2.imdecode(enc, cv2.IMREAD_UNCHANGED) # 立即解码回像素
rgb = cv2.cvtColor(camera, cv2.COLOR_BGR2RGB)
不是保存文件,是压完立刻解开——目的就是要 JPEG 的压缩伪影本身。
SimLingo 的训练数据当年是按 JPEG 存储的。JPEG 是有损压缩:把图像切成 8×8 块,每块做离散余弦变换(DCT)后量化丢弃高频系数,留下特征性的**块状伪影(blocking artifacts)**和振铃。模型训练时每一张图都带着这些伪影,它的特征提取器是在”带 JPEG 指纹的图像分布”上学出来的。
测试时如果直接喂仿真器的无损帧,等于给了模型更干净的输入——听起来是好事,实则踩了机器学习最基本的禁忌:训练/测试分布不一致(train/test distribution mismatch)。干净图像落在训练分布之外,模型表现会莫名变差,而且没有任何报错提示。
一行 imencode 把测试分布拉回训练分布,成本几乎为零。
jpg 重注入和锁曝光是同一种思路的两面:传感器配置的终极目标不是”画面好看”,而是让推理时的输入分布与训练分布重合。曝光漂移是亮度维度的分布偏移,丢掉 JPEG 伪影是纹理维度的分布偏移——配错任何一步,下游指标的变化都无法归因到实验变量上。
CARLA 的 GNSS 传感器(sensor.other.gnss)回调只给三个数:纬度、经度、海拔,都来自事件对象的原生字段。它不是真卫星信号——没有大气延迟、没有多径效应,是仿真世界坐标的直接换算结果。换算公式(MySim t33_simlingo_agent_server.py 中的 carla_to_gps,与 CARLA gnss 传感器内部实现逐字一致):
EARTH_R = 6378137.0 # WGS84 椭球长半轴(米)
def carla_to_gps(x, y, lat_ref=0.0, lon_ref=0.0):
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
写成数学形式:
其中 是 CARLA 世界系下的米制坐标, 米。
地球是球面:纬线圈越靠近极点越短。纬度 处的纬线圈半径是 ,同样走 米,在高纬度跨过的经度数更大。纬度方向没有这个问题——经线圈处处等大,所以 一行里没有 。
想在仿真里手工构造 GPS 轨迹,用这条公式正算过去即可。
服务器侧自己实现这条公式,是为了和模型内部的逆变换咬合:SimLingo 的 agent 初始化时会用 fsolve 把 GPS 参考点反解回米制局部坐标。路线关键点先由 carla_to_gps 正着算成 GPS 喂进去,agent 再反着解回米制——只要两边用同一条公式、同一个基准面(这里 自定),一来一回自洽抵消,fsolve 恰好解出 。两边公式差一个常数,逆变换就会解出一个错误的原点,整条路线平移,且不会报任何错。
CARLA 的 IMU 传感器(sensor.other.imu)每帧回调一次,MySim 的回调原样取出七个数:
self.agent_data["imu"] = (ev.frame, [
ev.accelerometer.x, ev.accelerometer.y, ev.accelerometer.z, # 加速度 m/s²
ev.gyroscope.x, ev.gyroscope.y, ev.gyroscope.z, # 角速度 rad/s
ev.compass, # 罗盘航向 rad
])
前六个轴都是车体坐标系下的瞬时值(x 朝车头、y 朝右、z 朝上),不是世界系。
真实 IMU 的核心麻烦是零偏(bias)与漂移(drift):加速度计积分成速度、再积分成位置,误差随时间平方累积,几十秒后位置就完全不可信。CARLA 的 IMU 是理想读数,没有噪声模型——这意味着仿真里验证过的”IMU 兜底”逻辑,迁移到实车时还需要补上滤波(如卡尔曼滤波)这一课。做仿真研究时要意识到:这里的 IMU 是”无噪声上界”,不是真实传感器。