ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CarSim与CARLA联合仿真平台架构与同步机制详解

CarSim与CARLA联合仿真平台架构与同步机制详解

我自己早两年搭自动驾驶仿真平台的时候,最头疼的一件事就是“仿真结果太假”。做完感知模型,去验证规控策略,发现 CARLA 里出来的车辆动力学像玩具车;反过来用 CarSim 跑底盘算法,场景又单调到没法做感知闭环。后来我把 CarSim 和 CARLA 通过一个 Python 环境层(通常叫 python_carsim_env)接在一起,做了一个小型联合仿真平台,解决了状态同步和数据采集的问题。这篇文章就把这套平台的架构设计、同步原理和数据采集细节摊开来讲,重点说说哪些地方容易踩坑,以及为什么我会选择这样的方案。如果你正在做自动驾驶算法验证、强化学习训练,或者单纯想给实验室搭一套更真实的仿真环境,这份经验应该能给你省下几周时间。

1. 为什么非要把 CarSim 和 CARLA 绑在一起?

1.1 CarSim 强在“底盘”,CARLA 强在“环境”

CarSim 是机械动力学里公认的教科书级工具,对车辆底盘、悬架、轮胎、转向传动比、空气阻力的建模粒度很细,你可以直接在软件里调整轮胎的侧偏刚度曲线,或者修改悬架 K 特性来观察车辆过弯表现。但它的渲染引擎和场景编辑器非常弱,想给摄像头生成一帧带交通流的图片几乎不可能,更别说激光雷达点云和毫米波反射模型了。

CARLA 恰恰相反。它基于 UE4 开发,地图里有几百个建筑物、路面材质变化、植被、行人动画,传感器仿真模块也成熟得多,RGB 相机、深度相机、语义分割、不同线束的 LiDAR、多波束雷达都有现成 API。问题是它的默认车辆动力学模型做得很“薄”,底盘动力学基本是简化的质心模型,轮胎、悬架只是象征性存在。你用它跑高速变道还好,一旦做冰雪路面、爆胎、紧急避障这些极限工况,仿真结果和真实车辆的差异会非常大。

1.2 单靠一个仿真器撑不住的几个场景

我做联合仿真之前,用纯 CARLA 训练过一个基于强化学习的纵向车速控制策略。训练时效果不错,但放到实车上就直接崩了,原因不是控制算法本身错了,而是 CARLA 的加速度响应太理想。车辆的油门到加速度是线性映射,没有考虑扭矩曲线、换挡延迟和轮胎打滑。这种 sim-to-real 的落差,很大一块就来自底层动力学模型太简。

反过来也是一样。我在 CarSim 里验证过一套 AEB 自动紧急制动策略,车辆动力学行为那叫一个准,但我只能通过曲线查看制动减速度,看不到前方是否真有行人、有没有误触发,想结合摄像头做融合感知测试也做不了。所以当大家搜索“carsim和simulink联合仿真”或“carla联合仿真”时,真正想要的往往不是单纯搞一个工具链,而是把动力学和场景两条线都打通。

1.3 python_carsim_env 在这个方案里扮演的角色

把一个 Simulink 模型拉起来跑仿真并不难,难的是在 Python 控制循环里反复调用它、读取结果、同时和 CARLA 的世界步进保持同步。python_carsim_env 做的就是这件事:它把 CarSim 模型封装成接近 Gym 风格的reset()和step()接口,往下解决了 CarSim 的加载、参数配置、步进控制和结果读取,往上给强化学习和规控算法提供一个统一的 Python 环境入口。

我实际用的 python_carsim_env 版本不是特别新,但它包含了一套比较完整的示例代码,能直接读 CarSim 的 Simulink 模型输出信号,并且把输出整理成 numpy 数组。有了这层封装,后续 CARLA CoSim Studio 的主循环就变得很干净:算法只管给油门、刹车、方向盘值,平台负责把值同时喂给 CarSim 和 CARLA,再把双方的状态拉回统一帧里。

2. 平台架构设计:一个精准的时钟,两个并行引擎

2.1 整体数据流与模块划分

整个平台可以分成四个模块:场景与传感器层、车辆动力学层、算法决策层、数据采集层。场景层由 CARLA 负责,管理地图、天气、交通流、传感器挂载;车辆动力学层由 CarSim 负责,实时计算主车的六自由度姿态、车速、横摆角速度等;算法决策层是用户自己的 Python 代码,接收平台整合后的状态,输出控制量;数据采集层则把两层的数据按同一时间轴落盘。

数据流向是典型的两进两出。算法决策层将归一化的[throttle, steer, brake]发给控制转换模块,转换后分别送进 CARLA 的vehicle.apply_control()和 CarSim 的输入接口;CarSim 仿真完成后输出车辆状态给状态同步模块,状态同步模块把位姿和速度写入 CARLA 的车辆节点,同时把速度、加速度、横摆角速度等状态分发给数据采集模块和算法决策层。这套架构的核心思想是“各算各的强项”:CarSim 只做动力学解算,CARLA 只做场景渲染与传感器数据生成,两边统一由 Python 主循环调度。

2.2 通信机制选型:共享内存、UDP 还是直接调用

CarSim 和 CARLA 的通信方式有很多种,但适合用的其实不多。CARLA 官方提供 Python API,通过 TCP 端口访问服务器,这是标准姿势,不用改。麻烦的是 CarSim 这侧,常见接入方式有三种:

接入方式原理优点缺点
Simulink 模型生成 DLL / MEX 文件,用 Python ctypes 加载CarSim 模型编译成共享库,直接进程内调用延迟最低,同步性好,无需额外通信开销需要准备 MATLAB 编译环境,跨版本编译头大
CarSim 命令行 + UDP 数据接口CarSim 独立运行,通过 UDP 报文和 Python 交换实时数据解耦干净,CarSim 崩溃不影响 Python 主程序需要自己定义报文结构,传输延迟更大,还有粘包问题
Python 调用 MATLAB Engine APIPython 作为 MATLAB 的客户端启动 Simulink 模型开发快捷,能复用 MATLAB 工作区工具每个 step 都要经过 MATLAB 解释层,延迟高,实际跑高频控制很吃紧

我最后选的是第一种,让 CarSim 模型以共享库形式嵌入 Python 进程,配合 python_carsim_env 的封装。这样做的好处在于,step()调用结束后,车辆状态立刻就在本地内存拿到,不需要等待网络往返。实时的上限主要取决于 CarSim 积分一步的时间,而不是通信延迟。

2.3 同步模式:主循环 50Hz、CarSim 内步 1kHz、CARLA 固定 dt

这里面最核心的约束是时间同步。CarSim 内部模拟的积分步长通常是 1ms 甚至更小,否则动力学解算可能发散;CARLA 作为渲染引擎,一帧的成本远比一次动力学积分要高,所以不可能每 1ms 同步一次。我的做法是设置一个 50Hz 的 Python 主循环,即一个逻辑周期 20ms;每进入一个周期,CarSim 内部连跑多个小步,CARLA 则通过同步模式开启一次world.tick()。

CARLA 0.9.x 的服务器如果开启同步模式,world.tick()会等服务器渲染完一帧再返回,时间步长是固定的fixed_dt_seconds。这个值我通常和主循环一致,设定为 0.05 秒或 0.02 秒。要特别注意一点:如果主循环是 20ms,而 CARLA 的 tick 时间步长也是 20ms,那么一个 step 正好对应 CARLA 的一帧。如果你把 CARLA 的 tick 步长设成 50ms,而主循环是 20ms,时间轴就会错位,采集到的传感器数据和动力学状态会对不上。

CarSim 内部小步长则单独配置,我一般让它以 1ms 积分 20 步,以匹配 20ms 的主循环。这样既保证了动力学精度,又不会让 CarSim 每步的计算量过大,留给 Python 侧做预处理和后处理的余量也足够。如果你做一个 step 的耗时超过 20ms,建议降低主循环频率到 30Hz 或 20Hz,而不是强迫系统跑在 50Hz,否则抖动会非常大。

3. 状态同步的细节:位姿、控制指令与坐标系对齐

3.1 需要同步的数据清单与流向

状态同步看起来只是把 CarSim 算出来的车挪到 CARLA 里,实际要处理的数据远比想象多。这里列一份我常用的同步清单:

数据项来源流向用途
车辆全局位置 (x, y, z)CarSim→ CARLA 车辆位置场景渲染、相机位姿计算
车辆航向角 (yaw/pitch/roll)CarSim→ CARLA 车辆旋转车辆模型姿态显示与传感器朝向
纵向速度 vx、横向速度 vy、垂向速度 vzCarSim→ CARLA 车辆线速度速度视觉表现、LiDAR 点云畸变模拟
横摆角速度 yaw rateCarSim→ CARLA 车辆角速度车辆转向动作的连续感
方向盘转角、油门、刹车指令Python 算法→ CarSim + CARLA控制闭环
车辆加速度 ax/ay/azCarSim→ 数据采集 / 算法层控制策略评价指标

从 CarSim 流到 CARLA 的量主要是车辆运动状态,而从算法层流到两个引擎的量是控制指令。数据采集模块需要额外记录每个量的逻辑时间戳,这样后面做时间对齐才有依据。这里有一个易错点:CARLA 里车辆节点如果保留自己的物理更新,CARLA 引擎会同时根据控制指令和碰撞积分车辆的位置,导致位置和 CarSim 的结果互相覆盖或者“打架”,车辆会出现大幅度抖动。所以要么在 CARLA 里把主车的物理模拟关掉,让渲染引擎只呈现 CarSim 的状态;要么每个 tick 都用 CarSim 状态强写车辆位姿。我最终采用的是后者配合关闭物理模拟的方式,效果最稳。

3.2 把 CarSim 位姿写回 CARLA 的正确姿势

CARLA 的车辆模型支持通过vehicle.set_transform()直接设置位置和旋转,这个操作等效于“传送”。如果每个 step 都直接设置,车辆看起来会一顿一顿的,尤其 50Hz 以下时非常明显。更好的做法是:先设置世界坐标下的线性速度和角速度,再设置位姿,这样渲染器在帧间插值时能尽量推测出车辆的连续运动轨迹。

核心操作大致是这样:

# 将 CarSim 输出的位姿写入 CARLA 车辆 vehicle.set_target_velocity(carla.Vector3D(vx, vy, vz)) vehicle.set_target_angular_velocity(carla.Vector3D(0, 0, yaw_rate)) vehicle.set_transform( carla.Transform( carla.Location(x, y, z), carla.Rotation(pitch=pitch, yaw=yaw, roll=roll) ) )

注意顺序:CARLA 内部在 tick 之后会根据当前速度和位姿推算下一帧位置,所以先给速度再给位姿,下一帧渲染会更平滑。如果你发现 CARLA 画面里主车偶发“瞬移”或轮胎陷入地面,大概率是位姿写入频率和 CARLA 帧率不匹配,或者没有关闭物理模拟。

我实测下来,set_transform()在几十 Hz 频率下调用没有明显性能瓶颈。真正影响性能的是传感器数量,比如每帧同时渲染 4 个相机加 1 个 64 线 LiDAR,压力立刻就上来了。这种时候应该单独开一个 sensor pipeline 异步读取传感器数据,主循环只负责状态同步。

3.3 控制指令的归一化映射

算法层输出的控制量通常是归一化值,比如油门[0, 1]、刹车[0, 1]、方向盘[-1, 1]。CARLA 的apply_control()接收的也是[0, 1]和[-1, 1],看起来挺好办。可 CarSim 要求的方向盘输入是角度单位,比如 ±720 度,油门刹车也有不同的标定,直接把同样数值喂过去,会让 CarSim 里车辆对方向盘输入的响应大小明显不同于 CARLA。

我做的映射很简单:

steer_deg = steer_norm * 540.0 # 假设方向盘满打满算是 540 度 throttle_carsim = throttle_norm * 0.6 # 0.6 对应最大加速踏板开度 brake_carsim = brake_norm * 0.8

这里的系数完全取决于车辆模型。更严谨的标定方式是先在 CarSim 软件里试跑几个典型工况,比如恒定 50% 油门加速到稳速,记录 CarSim 内部输入量的范围,再反推归一化映射表。不要想着所有车辆配置用一套系数,换车型时至少检查一次映射关系。

另外,CARLA 里设置apply_control()后,车辆会通过自带动力学模型响应;但因为我们最终用 CarSim 状态覆盖 CARLA 车辆的位姿,CARLA 自带的动力学实际上只是对视觉效果的预估,并不参与真实状态计算,所以控制指令映射只是让画面中的车轮转向、刹车灯更真实,真正影响车辆运动的只有 CarSim。

3.4 坐标系转换与初始对齐

坐标系不一致是联合仿真里最隐蔽的问题,我最初对接时就吃过亏。CarSim 的车辆位置通常定义在它自己的全局坐标系里,原点可能是仿真开始的车辆位置,X 轴沿车辆初始行进方向;CARLA 则有 UE4 地图坐标,X 向东,Y 向南,Z 向上,Euler 角约定也不一样。实物地图里的纬度和经度,再到 CARLA 地图里的米制坐标,中间还可能隔着一层地理配准。

我的做法是,在初始化阶段做一次静态对齐。流程如下:

  1. 从 CARLA 地图选好主车 spawn point,读取spawn_transform。
  2. 把 CarSim 仿真起点设为 (0, 0, 0),且航向角为 0。
  3. 定义映射:CARLA 全局位置 = 起始点位置 + R * CarSim 位置偏移,其中 R 是根据起始点航向角和 CarSim 坐标系差异构造的旋转矩阵。
def carsim_to_carla(x_sim, y_sim, yaw_sim, origin_loc, origin_yaw): # 先旋转到 CARLA 的朝向基础 theta = np.deg2rad(origin_yaw) x_map = x_sim * np.cos(theta) - y_sim * np.sin(theta) y_map = x_sim * np.sin(theta) + y_sim * np.cos(theta) z_map = 0.1 # 离地高度按模型设置 yaw_map = origin_yaw + yaw_sim return x_map + origin_loc.x, y_map + origin_loc.y, z_map, yaw_map

这里只给了二维旋转,因为我的测试场景是平整公路,车辆垂向跳动影响不大。如果做山区越野场景或带坡道的高架,姿态对齐会更复杂,还需要把 CarSim 输出的 pitch、roll 一并变换,不能只做 yaw 旋转。

4. 数据采集:从双引擎里拿一份对齐好的数据

4.1 采集四类数据,而不是只抓传感器

联合仿真平台建成之后,最值钱的就是能同时输出物理量、场景量和控制量的数据。我通常把数据分成四类:第一类是 CarSim 输出的车辆动力学真值,包括速度、加速度、横摆角速度、质心侧偏角、轮胎滑移率等;第二类是 CARLA 输出的传感器观测,包括相机图像、LiDAR 点云、GNSS、IMU;第三类是场景真值,比如主车和目标车的包围盒、车道线位置、交通标志状态;第四类是控制指令,也就是算法实际输出的油门、刹车、方向盘值。

这四类数据缺一样都不行。你训练感知模型时需要第二类数据;做规控模型时第三类和第四类是关键监督信号;要分析控制稳定性和乘坐舒适性,第一类数据又是基础。

4.2 时间戳对齐策略:把逻辑时间当成唯一锚点

两个仿真器各自有内部时钟,CARLA 传感器数据的时间戳来自服务器渲染时间,CarSim 输出数据的时间戳来自它自己的积分时刻。如果直接用 Python 的time.time()打点记录,会因为调度的不确定性出现错位,严重时会有 10ms 到 30ms 的偏移。对于 60km/h 车速来说,20ms 偏移大约就是 33cm 的位置误差,这已经足够影响传感器融合测试了。

我的策略是在主循环里维护一个逻辑帧计数器global_step,每次循环自增,所有数据在记录时都带上这个整数帧号,而不是 wall clock。第一次调用world.tick()前读取一次系统时间作为 epoch base,之后 CARLA 传感器回调里通过帧号推测逻辑时间戳,CarSim 的输出也按同样帧号记录。

def record_sample(self, frame_id): self.buffer["frame_id"].append(frame_id) self.buffer["timestamp"].append(frame_id * self.dt) self.buffer["veh_vx"].append(self.carsim_state.vx) self.buffer["veh_yaw_rate"].append(self.carsim_state.yaw_rate) # 传感器队列里的数据也按 frame_id 对齐

这里的关键不是让每个传感器数据的生成时刻毫秒不差,而是保证在离线处理时,能明确知道每个传感器帧属于哪个控制周期。只要所有数据都以frame_id为索引,就不会出现“图像和 CarSim 位置差着好几帧”这种问题。

4.3 存储结构设计:HDF5 分组和索引管理

数据量一大,存储结构就很关键。我试过纯 JSON 和 pickle,数据一多加载就慢,而且不方便按帧索引。最终采用 HDF5,单文件、支持分组、读写快。

推荐的目录结构大概是这样:

/control # 每帧控制量 throttle steer brake /dynamics # CarSim 输出 vx vy yaw_rate ax ay ... /sensors/rgb_front # 前视相机图像 /sensors/lidar # 激光雷达点云数组 /sensors/imu # 惯性测量单元数据 /scene/bbox_2d # 目标车包围盒标注 /scene/traffic_lights # 交通灯状态

这还不够,我会在每个 HDF5 组的属性里写入采集配置:CARLA 版本、CarSim 模型参数、传感器挂载位置、主循环频率、地图名称、天气条件。这些元信息在后续复现实验时非常重要。比如你想分析雨天走同一路线时模型的鲁棒性,如果缺少天气元信息,就很难按条件检索数据。

5. 实操记录:从零搭一套可运行的 CARLA CoSim Studio

5.1 版本与依赖到底怎么选

版本搭配严格程度超乎想象。CARLA 0.9.15 和 0.9.14 的 Python API 就有一批函数名和参数默认值的变化,CarSim 2023 导出的 DLL 对 MATLAB Runtime 也有版本要求。我最终稳定的组合是:Windows 10/11 + CARLA 0.9.15 + CarSim 2023 + MATLAB Compiler Runtime 2023a + Python 3.8。

之所以不选 Python 3.9 或更高,是因为 0.9.x 系列 CARLA 官方 Python API 适配最好的是 3.7/3.8,pygame 的兼容性也更稳。Matlab 2023a 之后版本的 Runtime 能不能跑 CarSim 2023 我会犹豫,因为社区里踩坑帖子不少,没必要为了新版本冒险。

先装好 CARLA 本体,再设置环境变量CARLA_ROOT,然后用 pip 安装 CARLA 的 python API(pip install carla或者直接把PythonAPI/carla/dist里的文件加入路径)。python_carsim_env 这边一般需要根据自己工程结构导入,如果项目里自带requirements.txt,建议先创建一个干净的 Python 3.8 venv 再一个个装依赖,避免和你常用的 OpenCV、PyTorch 环境冲突。

5.2 reset 与 step 的代码骨架

下面给出一个简化版的平台骨架,重点展示 reset 和 step 的逻辑顺序。实际工程比这复杂,但主流程就是这个模型。

import time import carla import numpy as np class CarlaCoSimStudio: def __init__(self, cfg): self.client = carla.Client(cfg["carla_host"], cfg["carla_port"]) self.client.set_timeout(30.0) self.world = self.client.get_world() tm = self.world.get_traffic_manager() self.dt = cfg["dt"] # 0.02s # 开启同步模式,固定时间步长 self.world = self.world.apply_settings(carla.WorldSettings( synchronous_mode=True, fixed_delta_seconds=self.dt)) self.carsim_env = CarSimEnv(cfg["carsim_model"]) # python_carsim_env 实例 self.vehicle = None self.frame = 0 def reset(self, route_id): # 1. reset CARLA 场景:选择出生点、生成主车和传感器 spawn_transform = self.world.get_map().get_spawn_points()[route_id] self.vehicle = self.world.spawn_actor(...) # 2. reset CarSim 模型到同一个起点 self.carsim_env.reset() # 3. 同步初始位姿 self.sync_states() self.world.tick() self.frame = 0 return self.get_observation() def step(self, throttle_norm, steer_norm, brake_norm): # 1. 控制映射 carsim_input = self.map_control(throttle_norm, steer_norm, brake_norm) # 2. 同时给 CARLA 车辆也打上控制(用于渲染车轮与灯光) self.vehicle.apply_control(carla.VehicleControl(...)) # 3. 让 CarSim 推进二十个小步(内部 1ms 步长) for _ in range(20): self.carsim_env.step(carsim_input) # 4. 读取 CarSim 状态并写回 CARLA self.sync_states() # 5. 推进 CARLA 一帧 self.world.tick() self.frame += 1 # 6. 采集数据与传感器输出 data = self.collect_data() return data, done

注意这里的CarSimEnv是 python_carsim_env 的抽象表示,真实项目的导入方式和接口命名以你使用的实际版本为准。步骤顺序里最关键的是第 4 步和第 5 步的顺序,先写好位姿和速度,再tick(),CARLA 渲染当前帧时车辆就处于正确状态。

5.3 常见问题与排查速查表

现象可能原因解决办法
CARLA 画面里主车每隔几帧瞬移一下set_transform()写入频率低或 CARLA tick 步长与主循环不匹配统一fixed_delta_seconds与主循环 dt,检查是否关闭了主车物理模拟
车辆姿态乱转、车身倾斜诡异CarSim 与 CARLA 坐标系和欧拉角定义不一致单独做 pitch/roll/yaw 的旋转矩阵变换,不要只转 yaw
CarSim 计算时间超过主循环周期CarSim 小步长太细,或者车辆模型复杂度太高降低主循环频率到 30Hz 或 20Hz,调大 CarSim 输出步长
CarSim 仿真在启动时报错退出MATLAB Runtime 版本不对或 DLL 路径缺失检查 Runtime 版本,确认 DLL 依赖和PATH环境变量
图像和 CarSim 车速对不上时间戳用 wall clock 而非逻辑帧号改用 frame_id 索引所有传感器和状态数据
传感器数据有黑帧或丢帧CARLA 渲染帧超时,或者主循环 tick 后立刻读传感器传感器数据放到采集线程异步读取,不要在 tick 线程里等待

这些排查经验是我一边调一边攒的,前三个问题几乎每个初搭联合仿真平台的人都会遇到。尤其中坐标系那块,建议在刚启动时先在 CARLA 里跑一段直线,看看车辆移动方向和 CarSim 的 X 轴方向是否一致,如果方向反了,尽早检查初始旋转矩阵。

6. 我在实际项目里反复确认的一句话

这套联合仿真平台给我最大的收获不是代码,而是想清楚了一个问题:仿真平台不是“把两个软件连起来”,而是要让两份不同的真值互相负责。CarSim 输出车辆状态,CARLA 必须老实呈现;CARLA 生成传感器观测,算法和后续训练都必须信任这些数据。所有架构设计、同步策略、采集方案,本质上都在维护这份信任。

如果你打算自己搭一套,我建议从小规模做起,别一开始就追求完整传感器阵列。第一版先把 50Hz 的位姿同步跑通,只接一个前视相机和 CarSim 的速度输出,确认踩下油门时画面里的车辆能平滑加速、方向打到位时横摆角速度不跳。基础链路稳了,再逐步加上 LiDAR、交通流和多车交互。最后记住一点:CarSim 的安装包、版本、仿真步长、CARLA 地图文件这些环境变量最好做成配置文件锁死,否则某天升级一个小版本,你的整个平台可能又要重调一遍。联合仿真最大的敌人不是算法,而是环境的不确定性。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进