我自己早两年搭自动驾驶仿真平台的时候,最头疼的一件事就是“仿真结果太假”。做完感知模型,去验证规控策略,发现 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 API | Python 作为 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、垂向速度 vz | CarSim | → CARLA 车辆线速度 | 速度视觉表现、LiDAR 点云畸变模拟 |
| 横摆角速度 yaw rate | CarSim | → CARLA 车辆角速度 | 车辆转向动作的连续感 |
| 方向盘转角、油门、刹车指令 | Python 算法 | → CarSim + CARLA | 控制闭环 |
| 车辆加速度 ax/ay/az | CarSim | → 数据采集 / 算法层 | 控制策略评价指标 |
从 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 地图里的米制坐标,中间还可能隔着一层地理配准。
我的做法是,在初始化阶段做一次静态对齐。流程如下:
- 从 CARLA 地图选好主车 spawn point,读取
spawn_transform。 - 把 CarSim 仿真起点设为 (0, 0, 0),且航向角为 0。
- 定义映射: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 地图文件这些环境变量最好做成配置文件锁死,否则某天升级一个小版本,你的整个平台可能又要重调一遍。联合仿真最大的敌人不是算法,而是环境的不确定性。