本节目标
- 在 L20 服务器上以无头模式跑起 CARLA(服务器没显示器,且要省显存)
- 理解同步模式 vs 异步模式的本质差别,以及为什么采数据必须用同步
- 掌握传感器队列模式——这是保证多相机严格对齐的唯一正确做法
- 跑通一个完整脚本:生成车辆 → 挂载 6 路相机 → 保存对齐的图像 + 位姿
本课程基于 CARLA 0.9.15 编写。CARLA 迭代较快,你操作时请先到 GitHub Releases 确认当前稳定版本。0.9.x 系列 API 基本兼容,如遇差异以官方文档为准。
选 0.9.15 的理由:生态成熟(BEVFormer/VAD 相关工作大多基于此), 且与后续 ros-bridge 方案兼容性经过验证。
CARLA 基于 Unreal Engine 4,在 22.04 上需要几个运行时库。不装的话表现是启动无报错但直接退出,很难排查。
# Ubuntu 22.04 运行 CARLA 所需系统依赖
sudo apt update
sudo apt install -y \
libomp5 \
libvulkan1 \
vulkan-tools \
libsdl2-2.0-0 \
xdg-user-dirs \
libpng16-16 \
libtiff5 \
libjpeg-turbo8
# 验证 Vulkan 可用(无头渲染依赖它)
vulkaninfo --summary 2>/dev/null | head -20
# 若 vulkaninfo 报错,说明 Vulkan 驱动有问题
# NVIDIA 驱动 560 自带 Vulkan 支持,通常检查这个文件存在即可:
ls /usr/share/vulkan/icd.d/nvidia_icd.json
第一步:下载与安装
CARLA 分两部分:服务端(仿真器本体,几个 GB)和 客户端(Python 包)。
# ---------- 1. 准备目录 ----------
mkdir -p ~/carla && cd ~/carla
# ---------- 2. 下载服务端(约 12GB,建议用 tmux 挂后台)----------
# 官方 GitHub Release(国内可能较慢)
wget https://github.com/carla-simulator/carla/releases/download/0.9.15/CARLA_0.9.15.tar.gz
# 解压
tar -xzf CARLA_0.9.15.tar.gz
# 解压后目录结构大致如下:
# CarlaUE4.sh ← 启动脚本
# CarlaUE4/ ← 引擎本体
# PythonAPI/ ← 官方示例与 egg 包
# Import/ ← 额外地图导入目录
# ---------- 3. 安装客户端 Python 包 ----------
conda activate ad
pip install carla==0.9.15
# 验证客户端装好了(此时服务端还没启动,只验证能 import)
python -c "import carla; print('carla client ok:', carla.__file__)"
如果 pip install carla==0.9.15 找不到对应版本(某些 Python 版本没有预编译包),
可以直接用服务端自带的 egg 包:
# 查看自带的 egg(注意 Python 版本要匹配)
ls ~/carla/PythonAPI/carla/dist/
# 加到 PYTHONPATH(写进 ~/.bashrc 可永久生效)
export PYTHONPATH=$PYTHONPATH:~/carla/PythonAPI/carla/dist/carla-0.9.15-py3.7-linux-x86_64.egg
第二步:无头模式启动
你的 L20 是远程服务器,没有显示器,而且 GPU 上还有别人的进程。 所以必须用无头模式(off-screen rendering)。
#!/usr/bin/env bash
# CARLA 无头启动脚本 —— 适配共享 GPU 服务器
set -e
CARLA_ROOT="$HOME/carla"
PORT="${PORT:-2000}"
QUALITY="${QUALITY:-Epic}" # Epic | Low —— 显存紧张时用 Low
GPU="${GPU:-0}"
cd "$CARLA_ROOT"
echo "启动 CARLA (端口 $PORT, 画质 $QUALITY, GPU $GPU)..."
# 关键参数说明:
# -RenderOffScreen 无头渲染(0.9.12+),不需要 X server
# -quality-level 画质等级,Low 可显著省显存和算力
# -carla-rpc-port RPC 端口,客户端连这个
# -graphicsadapter 指定 GPU(多卡机器)
./CarlaUE4.sh \
-RenderOffScreen \
-quality-level=$QUALITY \
-carla-rpc-port=$PORT \
-graphicsadapter=$GPU
chmod +x start_carla.sh
# 用 tmux 跑,避免断线中断
tmux new -s carla
./start_carla.sh
# 显存紧张时用低画质(能省约 1-2GB)
QUALITY=Low ./start_carla.sh
# Ctrl+B 然后 D 脱离会话
# ---------- 验证服务端是否起来 ----------
# 方法一:看端口
ss -tlnp | grep 2000
# 方法二:看进程
ps aux | grep CarlaUE4
# 方法三:看它占了多少显存
nvidia-smi | grep -i carla
CARLA 服务端本身会占 2-4GB 显存。加上你的训练进程,要算好总账:
- CARLA (Epic 画质) ≈ 3-4GB,(Low 画质) ≈ 1.5-2GB
- 别人的 safety_monitor ≈ 2.4GB
- 剩下才是你训练可用的
采数据时用 Epic(画质好),训练时关掉 CARLA 腾显存—— 这两件事不必同时做。
第三步:理解同步模式(本节最重要的概念)
这是很多人踩坑的地方,不理解它采出来的数据是错的,而且错得很隐蔽—— 肉眼看图像没问题,但训练效果就是上不去。
因为它不报错。异步采的数据看起来完全正常——图像清晰、标注也有。 但当你用它训 BEVFormer 时,模型学到的是互相错位的多视角关系, 投影怎么算都对不上,mAP 上不去,而你会以为是模型或超参的问题, 在错误的方向上排查好几天。
回想 BEVFormer 第 4 节讲的空间交叉注意力—— BEV 格子投影到多相机采样,前提是这些相机拍的是同一时刻。时间对不齐,几何基础就崩了。
第四步:连通性测试
先用最小脚本确认客户端能连上服务端:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""CARLA 连通性测试 —— 确认服务端可用、看看有哪些资源"""
import carla
import sys
def main(host="localhost", port=2000):
print(f"连接 {host}:{port} ...")
client = carla.Client(host, port)
client.set_timeout(15.0) # 首次连接可能较慢,给足超时
try:
version = client.get_server_version()
except RuntimeError as e:
print(f"✗ 连接失败: {e}")
print(" 排查: 服务端起了吗?端口对吗?防火墙?")
return 1
print(f"✓ 服务端版本: {version}")
print(f"✓ 客户端版本: {client.get_client_version()}")
if version != client.get_client_version():
print(" ⚠ 版本不一致,可能出现诡异问题,建议统一")
world = client.get_world()
m = world.get_map()
print(f"\n当前地图: {m.name}")
# 可用地图列表
maps = client.get_available_maps()
print(f"\n可用地图 ({len(maps)} 个):")
for name in sorted(maps):
print(f" {name.split('/')[-1]}")
# 生成点数量 —— 决定能放多少车
spawn_points = m.get_spawn_points()
print(f"\n生成点数量: {len(spawn_points)}")
# 车辆蓝图
bp_lib = world.get_blueprint_library()
vehicles = bp_lib.filter("vehicle.*")
print(f"车辆蓝图: {len(vehicles)} 种,例如:")
for bp in list(vehicles)[:5]:
print(f" {bp.id}")
# 传感器蓝图 —— 后面要用的
sensors = bp_lib.filter("sensor.*")
print(f"\n传感器蓝图: {len(sensors)} 种,关键的几个:")
for key in ["sensor.camera.rgb", "sensor.lidar.ray_cast",
"sensor.camera.depth", "sensor.other.imu"]:
found = bp_lib.filter(key)
mark = "✓" if len(found) > 0 else "✗"
print(f" {mark} {key}")
# 当前仿真设置
settings = world.get_settings()
print(f"\n当前仿真设置:")
print(f" 同步模式 : {settings.synchronous_mode}")
print(f" 固定时间步长 : {settings.fixed_delta_seconds}")
if not settings.synchronous_mode:
print(" 提示: 当前是异步模式,采数据前记得切同步")
print("\n✓ 连通性测试通过\n")
return 0
if __name__ == "__main__":
h = sys.argv[1] if len(sys.argv) > 1 else "localhost"
p = int(sys.argv[2]) if len(sys.argv) > 2 else 2000
sys.exit(main(h, p))
第五步:完整采集脚本(本节核心产出)
下面这个脚本是可以直接用于 M2 数据采集的雏形。它包含了所有正确做法: 同步模式、传感器队列、异常安全的资源清理。
① 传感器队列:不用 listen(callback) 直接存盘,而是把数据放进队列,
主循环 tick 后统一取——这样才能保证"这一帧的所有传感器数据"配成一组。
② try/finally 清理:CARLA 的 actor 不会自动销毁。 脚本崩了不清理,仿真世界里会残留一堆幽灵车,越积越多直到卡死。
③ 恢复异步:退出时要把世界设回异步, 否则下次别人连上来会发现仿真"卡住不动"(因为在等谁调 tick)。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CARLA 多相机同步采集 —— 最小可运行版本
这是 M2 数据采集的雏形,演示了三个必须掌握的正确做法:
1. 同步模式:客户端驱动仿真步进,保证时间戳对齐
2. 传感器队列:一帧内所有传感器数据配成一组
3. 资源清理:异常安全,不留幽灵 actor
用法:
python collect_demo.py --frames 100 --out ./data/run01
"""
import argparse
import os
import queue
import random
import json
import carla
import numpy as np
# ---------------- 相机布局 ----------------
# 仿照 nuScenes 的 6 路环视布局
# x 向前, y 向右, z 向上;yaw 单位为度,正为顺时针(向右)
CAMERA_RIG = {
"CAM_FRONT": {"x": 1.5, "y": 0.0, "z": 2.4, "yaw": 0, "fov": 70},
"CAM_FRONT_LEFT": {"x": 1.3, "y": -0.5, "z": 2.4, "yaw": -55, "fov": 70},
"CAM_FRONT_RIGHT": {"x": 1.3, "y": 0.5, "z": 2.4, "yaw": 55, "fov": 70},
"CAM_BACK": {"x": -1.5, "y": 0.0, "z": 2.4, "yaw": 180, "fov": 110},
"CAM_BACK_LEFT": {"x": -1.0, "y": -0.5, "z": 2.4, "yaw": -110, "fov": 70},
"CAM_BACK_RIGHT": {"x": -1.0, "y": 0.5, "z": 2.4, "yaw": 110, "fov": 70},
}
IMG_W, IMG_H = 800, 450 # 先用小分辨率快速验证,正式采集再调大
class SyncCollector:
"""同步采集器:管理世界设置、actor 生命周期、传感器队列"""
def __init__(self, host="localhost", port=2000, fps=20):
self.client = carla.Client(host, port)
self.client.set_timeout(15.0)
self.world = self.client.get_world()
self.fps = fps
self.actors = [] # 所有需要销毁的 actor
self.sensors = {} # name -> sensor actor
self.queues = {} # name -> queue,存传感器输出
self._orig_settings = None
self.vehicle = None
# ---------- 世界设置 ----------
def setup_sync(self):
"""切换到同步模式,并记住原设置以便恢复"""
self._orig_settings = self.world.get_settings()
settings = self.world.get_settings()
settings.synchronous_mode = True
settings.fixed_delta_seconds = 1.0 / self.fps
# 关键:让子步长稳定,物理仿真更可靠
settings.substepping = True
settings.max_substep_delta_time = 0.01
settings.max_substeps = 10
self.world.apply_settings(settings)
# Traffic Manager 也必须切同步,否则 NPC 车行为异常
self.tm = self.client.get_trafficmanager(8000)
self.tm.set_synchronous_mode(True)
print(f"[setup] 同步模式已开启, {self.fps} FPS "
f"(每帧 {1.0/self.fps:.3f}s)")
def restore(self):
"""恢复原始设置 —— 退出时必须调用,否则影响他人使用"""
if self._orig_settings is not None:
self.world.apply_settings(self._orig_settings)
print("[cleanup] 已恢复原始仿真设置")
try:
self.tm.set_synchronous_mode(False)
except Exception:
pass
# ---------- 生成主车 ----------
def spawn_vehicle(self, model="vehicle.tesla.model3"):
bp_lib = self.world.get_blueprint_library()
bp = bp_lib.filter(model)[0]
bp.set_attribute("role_name", "ego")
spawn_points = self.world.get_map().get_spawn_points()
if not spawn_points:
raise RuntimeError("地图没有可用生成点")
# 尝试多个生成点,避免"被占用"直接失败
random.shuffle(spawn_points)
for sp in spawn_points:
vehicle = self.world.try_spawn_actor(bp, sp)
if vehicle is not None:
self.vehicle = vehicle
self.actors.append(vehicle)
print(f"[spawn] 主车已生成: {model}")
return vehicle
raise RuntimeError("所有生成点都被占用,无法生成主车")
# ---------- 挂载相机 ----------
def attach_cameras(self, rig=None):
"""按 rig 配置挂载多路相机,每路配一个队列"""
rig = rig or CAMERA_RIG
bp_lib = self.world.get_blueprint_library()
for name, cfg in rig.items():
bp = bp_lib.find("sensor.camera.rgb")
bp.set_attribute("image_size_x", str(IMG_W))
bp.set_attribute("image_size_y", str(IMG_H))
bp.set_attribute("fov", str(cfg["fov"]))
# 传感器输出间隔设为 0 = 每帧都输出(同步模式下由 tick 驱动)
bp.set_attribute("sensor_tick", "0.0")
transform = carla.Transform(
carla.Location(x=cfg["x"], y=cfg["y"], z=cfg["z"]),
carla.Rotation(yaw=cfg["yaw"]),
)
cam = self.world.spawn_actor(bp, transform, attach_to=self.vehicle)
# 关键:用队列而不是直接存盘
q = queue.Queue()
cam.listen(q.put)
self.sensors[name] = cam
self.queues[name] = q
self.actors.append(cam)
print(f"[sensor] 已挂载 {len(self.sensors)} 路相机")
# ---------- 交通流 ----------
def spawn_traffic(self, n_vehicles=30):
"""生成 NPC 车辆,让场景不那么空"""
bp_lib = self.world.get_blueprint_library()
blueprints = bp_lib.filter("vehicle.*")
spawn_points = self.world.get_map().get_spawn_points()
random.shuffle(spawn_points)
count = 0
for sp in spawn_points:
if count >= n_vehicles:
break
bp = random.choice(blueprints)
npc = self.world.try_spawn_actor(bp, sp)
if npc is not None:
npc.set_autopilot(True, self.tm.get_port())
self.actors.append(npc)
count += 1
print(f"[traffic] 已生成 {count} 辆 NPC")
# ---------- 主循环 ----------
def run(self, n_frames, out_dir, warmup=20):
os.makedirs(out_dir, exist_ok=True)
for name in self.sensors:
os.makedirs(os.path.join(out_dir, name), exist_ok=True)
# 主车开自动驾驶(用 CARLA 内置 TM,先保证能跑起来)
self.vehicle.set_autopilot(True, self.tm.get_port())
# 预热:让物理稳定、传感器进入状态,这些帧不保存
print(f"[run] 预热 {warmup} 帧...")
for _ in range(warmup):
self.world.tick()
for q in self.queues.values():
q.get(timeout=5.0) # 丢弃预热数据
print(f"[run] 开始采集 {n_frames} 帧 → {out_dir}")
meta_records = []
for i in range(n_frames):
# 1) 推进仿真一帧,拿到本帧编号
frame_id = self.world.tick()
# 2) 从每个队列取出本帧数据
# 正确性关键:校验 image.frame 与 frame_id 一致
frame_data = {}
for name, q in self.queues.items():
while True:
img = q.get(timeout=5.0)
if img.frame == frame_id:
frame_data[name] = img
break
# 帧号落后就继续取,直到追上(正常情况不会发生)
if img.frame > frame_id:
raise RuntimeError(
f"传感器 {name} 帧号超前,同步逻辑有误")
# 3) 存盘
for name, img in frame_data.items():
path = os.path.join(out_dir, name, f"{i:06d}.jpg")
img.save_to_disk(path)
# 4) 记录自车位姿(后面做 BEV 时必需)
tf = self.vehicle.get_transform()
vel = self.vehicle.get_velocity()
meta_records.append({
"index": i,
"carla_frame": frame_id,
"timestamp": i / self.fps,
"location": [tf.location.x, tf.location.y, tf.location.z],
"rotation": [tf.rotation.pitch, tf.rotation.yaw, tf.rotation.roll],
"velocity": [vel.x, vel.y, vel.z],
"speed_kmh": 3.6 * np.sqrt(vel.x**2 + vel.y**2 + vel.z**2),
})
if (i + 1) % 20 == 0:
print(f" 已采集 {i+1}/{n_frames} 帧")
# 5) 保存元数据 + 相机外参(BEV 投影要用)
meta = {
"fps": self.fps,
"image_size": [IMG_W, IMG_H],
"camera_rig": CAMERA_RIG,
"frames": meta_records,
}
with open(os.path.join(out_dir, "meta.json"), "w") as f:
json.dump(meta, f, indent=2)
print(f"[run] 完成,元数据已写入 meta.json")
# ---------- 清理 ----------
def cleanup(self):
"""销毁所有 actor —— 顺序很重要:先传感器后车辆"""
for name, sensor in self.sensors.items():
try:
sensor.stop()
except Exception:
pass
# 批量销毁比逐个快很多
if self.actors:
self.client.apply_batch(
[carla.command.DestroyActor(a) for a in self.actors])
print(f"[cleanup] 已销毁 {len(self.actors)} 个 actor")
self.actors.clear()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="localhost")
ap.add_argument("--port", type=int, default=2000)
ap.add_argument("--frames", type=int, default=100)
ap.add_argument("--fps", type=int, default=20)
ap.add_argument("--traffic", type=int, default=30)
ap.add_argument("--out", default="./data/run01")
args = ap.parse_args()
collector = SyncCollector(args.host, args.port, args.fps)
try:
collector.setup_sync()
collector.spawn_vehicle()
collector.attach_cameras()
collector.spawn_traffic(args.traffic)
collector.run(args.frames, args.out)
except KeyboardInterrupt:
print("\n用户中断")
finally:
# finally 保证异常时也能清理,这一点非常重要
collector.cleanup()
collector.restore()
if __name__ == "__main__":
main()
# 确保 CARLA 服务端在跑,然后:
conda activate ad
# 先测连通性
python test_connect.py
# 采 100 帧试试
python collect_demo.py --frames 100 --out ./data/run01
# 看看采到了什么
tree -L 2 ./data/run01
# data/run01/
# ├── CAM_FRONT/ 000000.jpg ...
# ├── CAM_FRONT_LEFT/
# ├── ...
# └── meta.json
# 验证各相机帧数一致(不一致说明同步有问题)
for d in ./data/run01/CAM_*; do
echo "$(basename $d): $(ls $d | wc -l) 帧"
done
常见报错与排查
| 报错 | 原因 | 解决 |
|---|---|---|
RuntimeError: time-out of 10000ms |
服务端没起来 / 端口不对 / 启动还没完成 | ps aux | grep CarlaUE4 确认;首次启动要等 30 秒+;加大 timeout |
version mismatch 警告 |
客户端与服务端版本不一致 | 统一版本,或用服务端自带 egg |
脚本卡在 world.tick() 不动 |
上次脚本没恢复异步,或有别的客户端也在同步模式 | 重启 CARLA 服务端;确保 finally 里调了 restore() |
queue.Empty 超时 |
传感器没输出——通常是 sensor_tick 设太大 |
同步模式下设 sensor_tick=0.0 |
| 各相机帧数不一致 | 没用队列,或没校验 img.frame == frame_id |
照抄本节的队列写法 |
| 仿真里堆了一堆幽灵车 | 之前脚本崩了没清理 | 重启服务端,或写个清理脚本遍历销毁所有 actor |
| 显存不足 / CARLA 启动失败 | GPU 被别人占满 | QUALITY=Low 启动;或错峰使用 |
libpng warning 之类刷屏 |
无害警告 | 可忽略,或重定向 stderr |
#!/usr/bin/env python3
"""清理仿真世界里残留的所有车辆与传感器"""
import carla
client = carla.Client("localhost", 2000)
client.set_timeout(10.0)
world = client.get_world()
actors = world.get_actors()
targets = [a for a in actors
if a.type_id.startswith(("vehicle.", "sensor.", "walker."))]
print(f"找到 {len(targets)} 个待清理 actor")
for a in targets:
if a.type_id.startswith("sensor."):
try: a.stop()
except Exception: pass
client.apply_batch([carla.command.DestroyActor(a) for a in targets])
print("清理完成")
# 顺便把世界恢复成异步,避免卡住
s = world.get_settings()
s.synchronous_mode = False
s.fixed_delta_seconds = None
world.apply_settings(s)
print("已恢复异步模式")
验收清单
- CARLA 服务端能以无头模式启动,
nvidia-smi能看到它占用的显存 test_connect.py通过,能列出地图、蓝图、生成点- 能说清同步模式与异步模式的区别,以及为什么采数据必须用同步
- 理解传感器队列的作用——为什么不能直接在 callback 里存盘
collect_demo.py跑通,6 个相机目录帧数完全一致meta.json里有每帧的自车位姿和速度- 脚本 Ctrl+C 中断后,仿真世界干净(没有残留 actor、恢复了异步)
本节小结
- 无头模式
-RenderOffScreen是服务器环境的标配,画质可用Low省显存 - 同步模式是采数据的前提——异步采的数据时间戳对不齐,训 BEV 模型必然出问题, 而且不报错、极难排查
- 传感器队列 + 帧号校验是保证多相机对齐的唯一正确做法
try/finally清理 + 恢复异步是共享环境的基本素养, 也避免自己下次连上来发现世界卡住- 本节的
collect_demo.py是 M2 数据采集的雏形,后面会在它基础上加激光雷达、 语义标注、BEV 真值生成
本节的"多相机时间对齐"直接关系到 BEV 感知的几何基础,而"闭环 vs 开环"是评测方法论的核心:
④ BEVFormer 第 4 节空间交叉注意力:BEV 格子投影到多相机采样的前提,是这些相机拍的是同一时刻 → ③ LSS 第 4 节 Splat:多相机点云在 BEV 网格里求和融合,同样依赖时空一致 → ⑥ VAD 第 6 节:nuScenes 开环评测的局限,正是本课程要用 CARLA 做闭环的原因 →