CARLA 仿真器部署

这是整个课程的数据源头和闭环评测场。 本节不只是"装上能跑",还要理解同步模式——这个概念不懂,后面采的数据全是错的。

难度 ★★★☆☆ 时间 约 3 小时 前置 1.1 已完成 产出 可采集数据的仿真环境

本节目标

  • 在 L20 服务器上以无头模式跑起 CARLA(服务器没显示器,且要省显存)
  • 理解同步模式 vs 异步模式的本质差别,以及为什么采数据必须用同步
  • 掌握传感器队列模式——这是保证多相机严格对齐的唯一正确做法
  • 跑通一个完整脚本:生成车辆 → 挂载 6 路相机 → 保存对齐的图像 + 位姿
▸ 版本说明

本课程基于 CARLA 0.9.15 编写。CARLA 迭代较快,你操作时请先到 GitHub Releases 确认当前稳定版本。0.9.x 系列 API 基本兼容,如遇差异以官方文档为准。

选 0.9.15 的理由:生态成熟(BEVFormer/VAD 相关工作大多基于此), 且与后续 ros-bridge 方案兼容性经过验证。

▸ Ubuntu 22.04 系统依赖(先装这些,能省很多坑)

CARLA 基于 Unreal Engine 4,在 22.04 上需要几个运行时库。不装的话表现是启动无报错但直接退出,很难排查。

bash
# 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 包)。

bash
# ---------- 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 包:

bash
# 查看自带的 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)。

bashstart_carla.sh
#!/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
bash
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
▸ 共享 GPU 上的注意事项

CARLA 服务端本身会占 2-4GB 显存。加上你的训练进程,要算好总账:

  • CARLA (Epic 画质) ≈ 3-4GB,(Low 画质) ≈ 1.5-2GB
  • 别人的 safety_monitor ≈ 2.4GB
  • 剩下才是你训练可用的

采数据时用 Epic(画质好),训练时关掉 CARLA 腾显存—— 这两件事不必同时做。

第三步:理解同步模式(本节最重要的概念)

这是很多人踩坑的地方,不理解它采出来的数据是错的,而且错得很隐蔽—— 肉眼看图像没问题,但训练效果就是上不去。

✗ 异步模式(默认)—— 采数据绝对不能用 仿真器自己按最快速度跑,客户端来不及处理就丢帧 仿真 前相机 左相机 时间戳对不齐 前相机拍的是 t=0.12 左相机拍的是 t=0.15 → BEV 投影错位 → 训练怎么调都不对 ✓ 同步模式 —— 客户端说了算 客户端调 world.tick(),仿真才前进一帧;所有传感器在同一帧输出 tick 1 tick 2 tick 3 所有传感器时间戳严格一致 这才是可用于训练 BEV 模型的数据
图 · 异步 vs 同步。异步模式下各传感器各拍各的,多相机时间戳对不齐; 同步模式下客户端驱动仿真步进,一帧内所有传感器数据严格对齐。
▸ 为什么这个坑特别致命

因为它不报错。异步采的数据看起来完全正常——图像清晰、标注也有。 但当你用它训 BEVFormer 时,模型学到的是互相错位的多视角关系, 投影怎么算都对不上,mAP 上不去,而你会以为是模型或超参的问题, 在错误的方向上排查好几天。

回想 BEVFormer 第 4 节讲的空间交叉注意力—— BEV 格子投影到多相机采样,前提是这些相机拍的是同一时刻。时间对不齐,几何基础就崩了。

第四步:连通性测试

先用最小脚本确认客户端能连上服务端:

pythontest_connect.py
#!/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)。

pythoncollect_demo.py
#!/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()
bash
# 确保 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
▸ 清理幽灵 actor 的小工具
pythonclean_actors.py
#!/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 真值生成