CARLA Sensors: Cameras, LiDAR & RADAR
CARLA 把传感器按采集维度和输出形态分成三类。理解这个分类的工程意义在于:不同类别的传感器,回调里拿到的数据结构、同步对齐方式、需要的后处理都不一样。
| 类别 | 输出形态 | 是否参与渲染 | 典型 |
|---|---|---|---|
| 相机族 | 2D 像素阵列(carla.Image) | 是(走 UE 渲染管线) | RGB / Depth / Semantic / Instance / DVS / Optical Flow |
| 距离传感器 | 3D 点云(carla.LidarMeasurement / carla.RadarMeasurement) | 部分(几何投影) | LiDAR / RADAR |
| 物理/事件 | 标量或事件对象 | 否 | GNSS / IMU / Collision / Lane Invasion / Obstacle |
所有相机族传感器共享同一套 API:
cam = world.spawn_actor(cam_bp, transform, attach_to=ego)
cam.listen(lambda image: ...) # image: carla.Image
carla.Image 是个 raw 字节缓冲,具体含义由 sensor 的语义决定——同一份数据结构在 RGB 相机里是 3 通道颜色、在语义相机里是 1 通道类别 ID、在深度相机里是 1 通道浮点距离。这就是为什么 CARLA 文档把它们归为”camera family”:底层数据通路完全一样,差异只在编码语义。
SparseDriveV2 是纯视觉方案:6 个环视 RGB 相机(CAM_FRONT、CAM_FRONT_LEFT、CAM_FRONT_RIGHT、CAM_BACK、CAM_BACK_LEFT、CAM_BACK_RIGHT)是模型的全部输入。
其余传感器(深度、语义、LiDAR、RADAR)在推理时根本不接入——它们的价值在于训练阶段,提供像素级完美 Ground Truth,监督感知模型的训练。这是 CARLA 区别于真实世界的关键优势:真实车上拿不到逐像素的”这辆车在图上是哪些像素”标签,但 CARLA 渲染时天然知道每个像素属于哪个 Actor。
每个传感器都有 sensor_tick 属性(仿真秒):
0.0:每个 world.tick() 都采一次(默认)。0.1:每 0.1 仿真秒采一次。同步模式下 fixed_delta_seconds=0.05 时,sensor_tick=0.1 等于每 2 帧采一次——这是一个纯仿真时间的节流,和墙钟时间无关。SparseDriveV2 推理频率 10 Hz,所以相机通常设 sensor_tick=0.1,对齐模型推理节奏。
配 RGB 相机本质是配两套参数:
image_size_x/y、fov 决定的针孔相机模型。carla.Transform 决定的相机在车体坐标系下的位置和朝向。SparseDriveV2 在做”多视角图像 → BEV / 3D 锚点投影”时,必须同时知道这两套参数才能把 2D 像素和 3D 空间对应起来。这也是为什么环视感知的数据流里要把内参矩阵和每帧的相机外参一起喂给模型。
CARLA 的 RGB 相机用标准针孔模型。给定水平视场角 和图像宽度 ,焦距:
内参矩阵:
其中 是主点, 由垂直 FOV 推出(CARLA 给定水平 FOV,垂直 FOV 由宽高比确定)。3D 点到 2D 像素的投影就是:
这里的 是相机外参——carla.Transform 给的位置和旋转。SparseDriveV2 的 Deformable Aggregation 把 3D 关键点投影到各相机图像,用的就是这套公式。
标准环视布局,挂载在车顶相对车体坐标系(单位米):
cam_transforms = {
'CAM_FRONT': carla.Transform(carla.Location(x=1.5, z=2.4)),
'CAM_FRONT_LEFT': carla.Transform(carla.Location(x=1.0, y=-0.5, z=2.4), carla.Rotation(yaw=-60)),
'CAM_FRONT_RIGHT': carla.Transform(carla.Location(x=1.0, y= 0.5, z=2.4), carla.Rotation(yaw= 60)),
'CAM_BACK': carla.Transform(carla.Location(x=-1.5, z=2.4), carla.Rotation(yaw=180)),
'CAM_BACK_LEFT': carla.Transform(carla.Location(x=-1.0, y=-0.5, z=2.4), carla.Rotation(yaw=-120)),
'CAM_BACK_RIGHT': carla.Transform(carla.Location(x=-1.0, y= 0.5, z=2.4), carla.Rotation(yaw= 120)),
}
yaw 正方向是逆时针(向左转)。def callback(image):
img_array = np.frombuffer(image.raw_data, dtype=np.uint8)
img_array = img_array.reshape((image.height, image.width, 4)) # BGRA!
img_bgr = img_array[:, :, :3]
img_rgb = img_bgr[:, :, ::-1] # 转成 RGB
image.raw_data 是 BGRA 四通道字节流(不是 RGB,也不是 RGBA),这是 UE 渲染目标的内存布局。直接把 raw_data 当 RGB 喂给模型会导致颜色通道错位,是新手最常踩的坑。注意通道顺序约定每个数据集不同——nuScenes 是 JPEG 解码后的 RGB,SparseDriveV2 训练时按对应数据集的约定来。
fov 是水平 FOV:垂直 FOV 自动按宽高比算。把 1920×1080 配 90° FOV 时,垂直方向只有约 58.6°,看不到正上方的交通标志。listen 回调里的对象不持久:image 对象在回调返回后可能被回收,要持久保存就 np.copy 出来。image.frame 把同一仿真帧的图像归到一组,不能按到达顺序拼。真实世界拿不到逐像素的”这辆车的距离”或”这像素是行人还是路面”。CARLA 因为渲染时就知道每个像素对应的几何和 Actor,所以能几乎零成本地同时输出这些 GT——这是仿真训练端到端模型的最大优势。
深度相机输出 1 通道的 carla.Image,每个像素是一个按相机坐标系计的深度值。CARLA 把深度编码进 BGRA 4 字节里,解码公式:
其中 cm、 m(默认值),即把 24 位整数归一化到 范围:
def decode_depth(image):
arr = np.frombuffer(image.raw_data, dtype=np.uint8).reshape(-1, 4)
arr = arr.astype(np.float32)
# 注意 CARLA 这里通道顺序是 BGRA,且 depth 专用解码
depth = (arr[:, 0] + arr[:, 1] * 256 + arr[:, 2] * 65536) / 16777215.0
depth = 1000.0 * depth # 归一化 [0,1] 再乘 far plane → 单位米
return depth.reshape((image.height, image.width))
读深度最容易出错的两个点:(1) 通道顺序(同样是 BGRA,按 RGB 解会把距离算错);(2) 单位(CARLA 内部用厘米,归一化要乘 1000 转米或保持厘米)。
语义分割相机直接给每个像素一个 Cityscapes 风格的类别 ID,不做任何聚类或推理。CARLA 默认 28 类,常见:
0 Unlabeled 10 sky
1 Building 13 Truck
4 Pedestrian 14 Motorcycle
5 Pole 18 Traffic Light
6 RoadLine 19 Static
7 Road 20 Dynamic
8 Sidewalk 21 Water
9 Vegetation 24 Traffic Sign
...
像素值就是类别 ID 直接存的——carla.Image.raw_data 里每像素的 1 字节就是类别号(不是 BGRA)。这种”白嫖”的 GT 让训练感知模型时不用人工标注,直接做监督。
语义分割的局限:两辆并排的车,所有像素都是”Car”(13)类,分不出哪是 A 哪是 B。实例分割相机补上这一层——给每个 Actor 实例一个独立 ID。这对 MOT(多目标跟踪)训练尤其关键:模型必须从像素里学会”区分实例边界”。
虽然 GT 相机能给完美监督,但推理和闭环评测时模型完全看不到它们:
GT 是训练时的老师,不是考试时的答案。
CARLA 还提供 DVS(动态视觉传感器) 和 Optical Flow 两类相机。前者模拟生物视网膜,只在像素亮度变化时输出”事件”,事件率上 kHz;后者输出每个像素的二维运动矢量。两者都是研究级传感器,SparseDriveV2 不使用,所以本集不展开。
CARLA 的 LiDAR 模拟机械旋转式 LiDAR:一个旋转的多线扫描头,每个激光发射点得到一个 3D 距离点。关键配置:
lidar_bp = bp_library.find('sensor.lidar.ray_cast')
lidar_bp.set_attribute('channels', '64') # 64 线,垂直方向 64 个发射点
lidar_bp.set_attribute('range', '100') # 探测距离 100 米
lidar_bp.set_attribute('rotation_frequency', '10')# 10 Hz 旋转
lidar_bp.set_attribute('points_per_second', '1000000')
lidar_bp.set_attribute('upper_fov', '10') # 仰角 +10°
lidar_bp.set_attribute('lower_fov', '-30°') # 俯角 -30°
points_per_second:点云密度,和 rotation_frequency、channels 联动决定每帧点数。upper_fov / lower_fov:垂直 FOV 决定能看到多高多低。回调拿到的是 carla.LidarMeasurement,本质是 carla.Location 列表(每个点的 xyz):
def callback(data):
points = np.frombuffer(data.raw_data, dtype=np.float32).reshape(-1, 3)
CARLA 的 LiDAR 是射线投射模型——从原点发射射线,碰到 UE 场景的几何体就记录交点,不做散射、不模拟雨雾衰减、不模拟运动畸变。这是简化,真实 LiDAR 的噪声和雨雾效应比这复杂得多。
RADAR 用锥形探测区域(不是 LiDAR 那种密集射线),每个返回点带相对速度和方位角:
radar_bp = bp_library.find('sensor.other.radar')
radar_bp.set_attribute('horizontal_fov', '30') # 水平 30°
radar_bp.set_attribute('vertical_fov', '10') # 垂直 10°
radar_bp.set_attribute('range', '100') # 100 m
每个返回点 4 维:——高度角、方位角、距离、相对速度。RADAR 的优势是直接给多普勒速度,不用做帧间差分。劣势是角分辨率粗(点稀疏)。
| 维度 | LiDAR | RADAR |
|---|---|---|
| 探测方式 | 密集旋转射线 | 稀疏锥形 |
| 输出维度 | xyz 点云 | (alt, az, dist, vel) |
| 速度感知 | 无(要靠帧间匹配) | 直接多普勒 |
| 角分辨率 | 高(cm 级) | 低(度级) |
| 雨雾表现 | 衰减明显 | 穿透好 |
| 典型线数 | 64/128 | — |
SparseDriveV2 走纯视觉路线,输入只有 6 路 RGB。背后的设计动机是:
代价是:纯视觉的深度估计是隐式的(模型要从图像线索学),距离精度不如 LiDAR。SparseDriveV2 的设计是用稀疏显式几何锚点 + 多视角采样来弥补这一点。
GNSS(全球导航卫星系统)和 IMU(惯性测量单元)给的是位姿——前者是经纬度位置,后者是加速度和角速度。
gnss_bp = bp_library.find('sensor.other.gnss')
gnss_bp.set_attribute('noise_alt_stddev', '0.1') # 高度噪声标准差
gnss_bp.set_attribute('noise_lat_stddev', '3e-6') # 纬度噪声,单位度
gnss_bp.set_attribute('noise_lon_stddev', '3e-6') # 经度噪声
GNSS 输出 (latitude, longitude, altitude)。noise_lat_stddev 单位是度(不是米), 度大约对应赤道上的 米——这个换算经常被忽略。CARLA 的噪声模型是高斯加性,真实 GNSS 还有跳变、多径、遮挡等问题,仿真里是简化版。
IMU 输出 9 维:accelerometer (xyz) + gyroscope (xyz) + compass(一个标量,偏航角)。compass 是 Ego 车朝向,是位姿估计的关键约束。
| 传感器 | 触发条件 | 回调数据 |
|---|---|---|
| Collision | Ego 车碰撞 bbox 和任何 Actor 相交 | other_actor、normal_impulse(冲量向量) |
| Lane Invasion | Ego 车轮子压过 OpenDRIVE 车道线 | crossed_lane_markings 列表 |
| Obstacle | 前方锥形区域有障碍物 | other_actor、distance |
collision_bp = bp_library.find('sensor.other.collision')
collision = world.spawn_actor(collision_bp, carla.Transform(), attach_to=ego)
collision.listen(lambda event: record_violation('collision', event.other_actor))
注意 Collision 检测器只在”真实碰撞发生”时触发——它不做预测,碰撞已发生就是既成事实,分数已经被扣了。要做主动避障得用 Obstacle 检测器或感知模型。
一个反直觉的设计:没有 “Red Light Violation” 这个传感器。闯红灯的判定走的是协议层——Bench2Drive 在每帧检查:
为什么这么设计?因为”闯红灯”是个复合语义:它依赖”红绿灯是否管辖 Ego 车当前车道”这种 OpenDRIVE 拓扑信息,单个传感器没法判断。所以放在协议层,把红绿灯状态(traffic_light.state)和 Ego 车位置(waypoint 查车道归属)合起来判。
每个违规都有一个乘法惩罚系数(详见 EP06 的扣分公式):
所以一次碰撞行人 () 比一次压线(按时间累乘 )惩罚重得多。事件检测器和协议层判定的违规,最终都进这个累乘。
carla.Transform() 是车体原点,碰撞触发逻辑用的是 Ego 车 bbox,不是传感器位置——位置不影响触发。noise_lat_stddev 是度不是米,要换算。Ground Truth(GT,真值)在 CARLA 仿真里指仿真器天然知道的、像素级完美的标签:每个像素属于哪个 Actor、每个目标的 3D 包围盒、每帧的真实轨迹。这是仿真相比真实数据的核心红利。
但 GT 在两个阶段扮演的角色完全相反:
| 阶段 | 模型看什么 | GT 的角色 | 类比 |
|---|---|---|---|
| 训练 | RGB 图像 + GT | 监督信号(loss 的目标) | 教科书 + 答案 |
| 推理 / 闭环 | 只有 6 路 RGB | 完全不可见 | 考卷,闭卷 |
这个区分不是 CARLA 的限制,而是自动驾驶部署的硬约束——真实车上拿不到 GT,所以推理时必须假装它不存在。
感知模型的训练目标是让网络输出逼近 GT。例如检测任务里,模型预测每个目标的 ,GT 来自 CARLA 已知的 Actor 状态:
规划任务里,GT 是 CARLA 记录的真实未来轨迹(Ego 车实际走过的路径),模型预测的轨迹和它做回归:
GT 是”标准答案”,模型靠梯度下降学会逼近它。
到推理和闭环评测,所有 GT 通道都断开:
这是端到端范式与模块化方案的实质区别:模块化方案在推理时仍依赖 HD Map 和上游感知输出(这些是”半 GT”),而纯端到端只看原始像素。
有人会想”既然 CARLA 知道答案,为什么不直接把 GT 当规划输出?” 这混淆了两个角色:
仿真 GT 完美无噪,真实标注有标注误差。模型如果过拟合到完美 GT,部署时会”惊讶”于真实数据的偏差。SparseDriveV2 的解法之一是用真实数据(nuScenes / NAVSIM)做主训练,CARLA 主要用于闭环评测,避免在完美 GT 上过拟合。
同步模式下,world.tick() 触发后,6 个相机都在同一仿真帧内被渲染。但每个相机的 listen 回调是独立异步到达的——Server 通过 6 条独立的 streaming 通道推数据,谁先到 Client 不保证。
唯一可靠的对齐依据是 image.frame:同一仿真帧的所有图像共享同一个 frame ID。
正确的数据组织方式是按 frame ID 分桶聚合,不能简单按到达顺序拼:
from collections import defaultdict
import queue
frame_buffer = defaultdict(dict)
ready_queue = queue.Queue()
def make_callback(cam_name):
def cb(image):
frame_buffer[image.frame][cam_name] = np.frombuffer(
image.raw_data, dtype=np.uint8
).reshape((image.height, image.width, 4))[:, :, :3][:, :, ::-1]
# 6 路齐了才入队
if len(frame_buffer[image.frame]) == 6:
ready_queue.put((image.frame, frame_buffer.pop(image.frame)))
return cb
for name, tf in cam_transforms.items():
cam = world.spawn_actor(cam_bp, tf, attach_to=ego)
cam.listen(make_callback(name))
这里两个关键细节:
np.copy 出来防止 raw_data 被回收。模型推理需要的是形状固定的张量。6 路 RGB 通常 stack 成 (N_cam, H, W, 3) 或 (N_cam, 3, H, W)(PyTorch 习惯):
batch = []
while True:
frame_id, images = ready_queue.get(timeout=1.0) # 等下一帧
# 按 FIXED_CAM_ORDER 固定相机顺序,不能依赖 dict 迭代序
stacked = np.stack([images[name] for name in FIXED_CAM_ORDER]) # (6, H, W, 3)
stacked = stacked.astype(np.float32) / 255.0 # 归一化到 [0,1]
mean = np.array(IMAGENET_MEAN).reshape(1,1,1,3)
std = np.array(IMAGENET_STD ).reshape(1,1,1,3)
stacked = (stacked - mean) / std # ImageNet 标准化
run_inference(stacked)
两个易错点:
[FRONT, FRONT_LEFT, FRONT_RIGHT, BACK, BACK_LEFT, BACK_RIGHT] 学,推理时也必须严格这个序,错位会让模型把后视当右前视,输出全乱。[0.485, 0.456, 0.406] / [0.229, 0.224, 0.225]),换 backbone 要换参数。完整的一次前向:
中间经过 Image Encoder(ResNet-34 + FPN)→ Sparse Perception(检测/建图)→ Scoring-Based Planner(在 26 万候选里打分选最优)。这就是纯视觉端到端的最小数据流闭环——传感器数据的”原料”和决策的”产品”。
ready_queue 会无限堆积,内存爆掉。要做背压:队列满了就丢最老的帧或暂停 tick。