☰
Carla自动驾驶仿真:行人行为控制从AI控制器到可复现状态机
2026/10/26 19:41:41 网站建设 项目流程

做自动驾驶仿真的朋友大概都有过这样的经历:车辆侧的逻辑调得七七八八了,一到加行人就翻车。要么行人站着不动像根电线杆,要么一股脑往墙上走,要么在路口和车玩起了"谁先眨眼"的游戏。这篇就聊聊在 Carla 里自动控制行人行为的完整思路,从最基础的 WalkerAIController 用法,到把"随机乱走"改造成"可编排、可复现、能和车辆联动"的行为系统。内容适合已经能跑通 Carla 基本示例、想进一步做行人场景测试的同学,也适合做交通流仿真、感知数据采集、场景库搭建的从业者参考。我不会只贴 API,重点讲清楚每一步为什么这么设计,以及那些文档里不会写的坑。

1. 先把问题想清楚:行人控制到底难在哪

1.1 三条技术路线的取舍

在 Carla 里让行人动起来,本质上只有三条路,选哪条取决于你要的是"看起来像"还是"可控可测"。

第一条是AI 控制器路线,也就是controller.ai.walker这个蓝图加WalkerAIController。它的底层是导航网格(Nav Mesh)加路径跟随,你给一个世界坐标目标点,它自己算出一条可行走的路径走过去。优点是接入成本极低,几十行代码就能让一屏行人动起来,而且路径会自动绕开建筑和障碍。缺点是控制粒度粗,你没办法直接干预它的每一步方向,中途改目标也只能重新调用go_to_location。

第二条是直接控制路线,用walker.apply_control(carla.WalkerControl(direction, speed, jump))每帧下发方向和速度。这条路完全自己做主,方向想怎么给就怎么给,适合做精确轨迹复现、行人横穿马路的定时脚本、或者研究行人微观运动模型。代价是导航全得自己管,撞墙卡死是家常便饭。

第三条是混合路线,我实际项目里用得最多的也是这条:用一个 AI 控制器负责"走到大致区域",在关键节点(比如斑马线前、车辆接近时)切换到直接控制,做精确的停走和避让。这个思路的好处是把导航的脏活丢给引擎,把行为决策的细活留在自己手里。

路线接入成本控制粒度导航能力典型用途
AI 控制器低粗引擎自带,能绕障背景人流、大范围随机行走
直接控制高精细需自己实现精确轨迹、定时横穿、运动学研究
混合中可按需切换引擎加自研场景测试、交互验证

我个人的判断标准很简单:如果你只是想让场景"有点人气",别折腾,AI 控制器就够了;如果你的测试用例需要"行人在第 3.2 秒迈出第一步",那必须上直接控制或者混合方案。

1.2 场景目标决定了控制粒度

很多人一上来就问"怎么让行人避让车辆",这个问题其实没法直接回答,因为"避让"在不同场景下含义完全不同。

做感知算法验证时,你需要的是一批轨迹确定、可重复的行人,车做什么反应不重要,重要的是行人的位置在每一帧都能对得上。这时候你会希望行人速度恒定、路径固定,甚至用set_transform硬搬都行。

做决策规划测试时,你需要行人对车辆有反应,比如车靠近到 10 米时行人停下等待,车通过后再继续走。这时候就得上状态机,得检测距离、得判断车辆朝向。

做交通流仿真时,你需要几十上百个行人在街区里长时间游走,不能卡死、不能穿墙、不能全挤在一个角落。这时候稳定性压倒一切,AI 控制器是唯一现实的选择。

先把这层想明白,后面代码才不会写了又推翻。我见过不少项目,一开始想做大而全的行人行为框架,最后连"行人能正常走到马路对面"都没跑通。

1.3 几个容易忽略的前置条件

动手之前有几个前提必须先确认,否则后面调半天都不知道问题出在哪。

导航网格是否已烘焙。Carla 内置的标准地图都带好了导航网格,world.get_random_location_from_navigation()能正常返回点。但如果你用的是自己导入的地图,或者改了地图几何,就必须在编辑器里重新放置 Nav Mesh Bounds Volume 并烘焙,否则所有 AI 控制器都会原地转圈。判断方法很直接:调用一次随机取点,如果返回的坐标永远是同一个或者直接报错,基本就是导航网格的问题。

行人蓝图是否正确。蓝图库里walker.pedestrian.*是一大类,属性里有speed(默认移动速度)、is_invincible(是否无敌)、generation(外观资源)。注意is_invincible设成true之后车辆撞上去行人不会倒,但碰撞事件照样触发,做碰撞测试时别被这个属性坑了。

同步模式是否开启。异步模式下物理和 AI 更新步长不固定,行人位置在两次读取之间可能跳变,做轨迹记录会很难受。凡是涉及行为验证的场景,我一律建议开同步模式配固定步长。

注意:开启同步模式后必须持续调用world.tick(),否则整个世界会冻住。这一点在写循环时特别容易忘,尤其是加了time.sleep之后。

2. 环境与核心接口梳理

2.1 客户端连接与同步模式设置

先把连接和世界设置这部分固定下来,后面所有示例都基于这个前提。同步模式下固定步长我一般取 0.05 秒,也就是 20 帧每秒,这个精度对行人行为验证足够了,同时不至于让渲染压力太大。

import carla import random import time client = carla.Client('127.0.0.1', 2000) client.set_timeout(15.0) world = client.get_world() bp_lib = world.get_blueprint_library() settings = world.get_settings() settings.synchronous_mode = True settings.fixed_delta_seconds = 0.05 world.apply_settings(settings) # 每次改完设置先 tick 一次,让世界状态同步 world.tick()

这里有个细节值得说:apply_settings之后世界并不会立即按新设置运行,需要一次tick才会生效。异步模式切同步模式尤其要注意,如果先 spawn actor 再改设置,可能出现 actor 生成后位置错乱的情况。我的习惯是开头就把模式定好,后面不再改。

如果是从别的脚本继承过来的世界,最好在开头把已有的行人和控制器清一遍,避免残留 actor 干扰统计:

for actor in world.get_actors().filter('walker.*'): actor.destroy() for actor in world.get_actors().filter('controller.ai.walker'): actor.destroy() world.tick()

注意销毁顺序上,父级 actor 销毁时子级会跟着销毁,但显式两个都清一遍更保险。

2.2 行人、控制器与导航网格三者的关系

这是理解 Carla 行人机制的关键。walker本身只是一个带骨骼动画的物理实体,它自己不会走路。controller.ai.walker是一个独立的 actor,通过attach_to挂到行人身上,负责读取导航网格、计算路径、然后把方向和速度下发给行人。导航网格则是地图资源,属于世界的一部分,控制器只读不写。

这个结构带来的第一个后果是控制器必须和行人成对出现,一个控制器只能挂一个行人。第二个后果是控制器的生命周期要手动管,行人销毁了控制器不一定自动停。第三个后果是控制器的位置跟随行人,所以生成控制器时传的carla.Transform()是相对于父级的偏移,一般给个零变换就行。

很多新手会问,为什么不直接在 walker 上调用类似go_to的方法?因为设计上做了分离,好处是一个通用的 AI 控制器逻辑可以复用到不同类型的目标上,坏处就是多了一层要管。理解了这层,后面遇到"控制器没反应"时就知道该查哪里了。

2.3 常用接口速查表

把高频接口列一下,方便对照。这些是我实际写脚本时反复用到的部分,文档里散落在各处。

接口作用使用要点
bp_lib.filter('walker.pedestrian.*')取行人蓝图列表随机挑一个避免外观重复
bp_lib.find('controller.ai.walker')取 AI 控制器蓝图全局只有这一个
world.spawn_actor(bp, transform)生成行人位置要在地面附近,别悬空
world.spawn_actor(ctrl_bp, carla.Transform(), attach_to=walker)生成并挂载控制器变换用零值
controller.start()启动控制器必须调用,否则不工作
controller.go_to_location(loc)设定目标点返回布尔值,失败通常是导航网格问题
controller.set_max_speed(v)设置最大速度单位是米每秒
controller.stop()停止移动行人会原地站住但保留物理
world.get_random_location_from_navigation()取导航网格随机点开销不小,可缓存
walker.apply_control(WalkerControl)直接控制方向速度与 AI 控制器互斥
walker.get_location()/get_transform()取当前状态建议每帧缓存

这张表里我认为最容易出问题的是go_to_location的返回值。它返回False的意思不是"走不到",而是"压根没算出路",绝大多数情况是起点或终点不在导航网格上。所以我在用之前会先把目标点做一次合法性校验,具体做法后面讲。

3. 最小可运行:让一个行人自己走起来

3.1 生成与绑定控制器

先跑通最简单的版本,一个行人,一个控制器,一个随机目标。这段代码建议先单独跑通再往上叠功能。

walker_bp = random.choice(bp_lib.filter('walker.pedestrian.*')) walker_bp.set_attribute('is_invincible', 'false') # 取一个地图自带的生成点作为起点,保证落在地面上 spawn_point = random.choice(world.get_map().get_spawn_points()) walker = world.spawn_actor(walker_bp, spawn_point) ctrl_bp = bp_lib.find('controller.ai.walker') controller = world.spawn_actor(ctrl_bp, carla.Transform(), attach_to=walker) # 启动必须在这一步做,且要 tick 一次让状态生效 controller.start() world.tick() controller.set_max_speed(1.3)

这里有两个经验点。第一,起点别自己随便写坐标,用地图的 spawn points,那些点在地图制作时就保证了在可行走区域上方,能省掉大量的"生成即穿模"问题。第二,start()之后一定要world.tick(),我在不止一个版本上遇到过不 tick 就调go_to_location完全没反应的情况,排查了半天才发现是时序问题。

速度设 1.3 米每秒是有依据的,正常人步行速度区间大概 1.1 到 1.5,取中间值看起来比较自然。设太快骨骼动画会跟不上,出现"滑步";设太慢又像在挪。如果你要模拟不同人群,可以按正态分布采样,均值和方差自己按需定。

3.2 设定目标点与速度的时序问题

目标点这块坑最多,我按重要性排一下。

第一,目标点必须在导航网格上。get_random_location_from_navigation()返回的点理论上都在网格上,但它可能跑到很远的地方,甚至跨过整个地图。做单点测试时更稳妥的做法是从地图 spawn points 里挑一个,或者自己维护一份"合法行走点"的清单,每次从中随机取。

第二,先设速度再设目标。这个顺序不是绝对的,但先设速度能避免第一帧用默认速度冲出去。默认速度是 1.0 左右,如果你要 1.4,那第一段路就会明显偏慢,做轨迹统计时会有误差。

第三,到达接近目标时没有回调。Carla 没提供"已到达"事件,只能自己每帧比距离。我的做法是设一个 1.5 米的阈值,进入阈值就算到站,然后从队列里取下一个目标。

target = world.get_random_location_from_navigation() ok = controller.go_to_location(target) if not ok: print('目标点不可达,换一个')

go_to_location返回False的情况我统计过,主要有三种:目标点在地图外、目标和起点之间不存在连通路径、控制器还没start。前两种换点就行,第三种是写代码顺序错了。

3.3 行走队列与生命周期管理

单个目标走完就完了,实际场景里行人得一直走。这里给一个简单的队列实现,逻辑是维护一个目标列表,每帧检查是否接近队首,接近了就弹出一个并设新目标。

import math def dist(a, b): return math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2 + (a.z - b.z) ** 2) targets = [world.get_random_location_from_navigation() for _ in range(5)] controller.go_to_location(targets[0]) while True: world.tick() loc = walker.get_location() if targets and dist(loc, targets[0]) < 1.5: targets.pop(0) if not targets: targets = [world.get_random_location_from_navigation() for _ in range(3)] controller.go_to_location(targets[0])

这个循环里有个隐患要注意:如果行人被卡住了,永远到不了阈值,队列就永远不推进,表现为"行人站在原地抖"。所以真实项目里我会加一个超时机制,比如每个目标最多给 60 秒,超时就强制换下一个。这个细节看着不起眼,但在长时间运行的仿真里能救命。

清理环节也要写对,顺序是先停控制器再销毁:

controller.stop() controller.destroy() walker.destroy() world.tick()

stop()的作用是让控制器停止下发指令,行人保留物理状态。如果直接 destroy 控制器而不 stop,某些版本上会残留一个僵直的行人,物理还在但没人控制,看着像喘气。

4. 进阶:把随机游走改造成可控行为

4.1 状态机驱动的过街行人

背景人流用 AI 控制器没问题,但一旦要测"车辆礼让行人",就必须让行人的行为有明确的阶段划分。我用得最多的是一个四状态机:接近、等待、通过、离场。

接近态:行人在人行道一侧向路口走,用 AI 控制器驱动,目标是路口边上的一个固定点。这个阶段不需要精确,能走到大致位置就行。

等待态:行人到达路口边缘后停下,controller.stop(),然后循环检测最近车辆的距离和速度。判断条件我一般用两个:距离小于 15 米,且车辆朝向我方(速度方向与行人到车辆的向量夹角小于 60 度)。两个条件同时满足才认为"有车在接近"。

通过态:没有接近车辆时,切到直接控制,给一个指向对面的恒定方向,速度抬到 1.5,走斑马线。这个阶段必须用apply_control,因为要精确控制走直线。

离场态:走过对面后重新controller.start(),切回 AI 控制器继续随机游走。

状态切换的判定要放在每帧循环里,而且要加阻尼,避免在边界反复横跳。我用的办法是给每个状态加一个最短停留时间,比如等待态至少 0.5 秒才能切出去。

def vehicle_too_close(world, walker_loc, threshold=15.0): for v in world.get_actors().filter('vehicle.*'): v_loc = v.get_location() if dist(v_loc, walker_loc) < threshold: vel = v.get_velocity() speed = math.sqrt(vel.x ** 2 + vel.y ** 2 + vel.z ** 2) if speed > 1.0: return True return False

这个检测写起来简单,但每帧遍历所有车辆在车多的时候有开销,实际项目里我会维护一个"附近车辆"的列表,每隔若干帧更新一次。

4.2 群体与流量的编排

要做街道人流,单个行人一个控制器往上堆是最直接的办法,但性能会很快成为瓶颈。我实测下来,一百个带控制器的行人在普通机器上帧率掉得明显,尤其是同步模式下每帧都要算所有人路径。

几个优化方向,效果从大到小排:

一是按可见性裁剪控制器。远处的行人只保留位置,不挂控制器,隔一段时间用set_transform批量挪动位置模拟移动。这个做法的代价是远处行人看起来在瞬移,但如果距离够远、镜头不动,很难看出来。

二是给背景行人做简化动画。行人骨骼动画本身有开销,如果只是要个人影,可以考虑不播动画或者降低更新频率。

三是控制器复用思路。Carla 不支持一个控制器管多个人,但可以自己写一个"批量调度器",用一个循环统一处理所有人的目标切换和状态机,避免每人都开一个 Python 线程。事实上所有行人的逻辑都必须放在主循环里,开线程会导致 RPC 调用时序混乱。

四是控制密度而不是控制人数。人行道五米一个人和五十米一个人,观感差不了多少,但性能差一个量级。规划行人分布时按区域密度来铺,别均匀撒满。

还要注意一个现实问题:AI 控制器在人多的时候会互相穿过。Carla 内置的导航没有行人之间的避让,两个人走对头会直接穿模。要解决得自己加分离力,简单做法是每帧计算每个行人与最近邻居的排斥向量,叠加到apply_control的方向上。这个我一般只在关键镜头的几个人身上做,全场做性能受不了。

4.3 与车辆联动的触发逻辑

行人行为要真正发挥作用,必须能反过来影响车辆测试。这里有几个实用的触发点。

触发紧急制动测试:让行人在车辆接近到某个距离时突然从路边迈出,检验车辆的 AEB 响应。实现方式是在等待态里检测到车辆距离小于阈值后,直接切通过态并给一个朝向车道中央的方向。这里要注意方向必须归一化,否则WalkerControl的速度会被方向向量的长度放大,行人会像被弹射出去一样。

触发路径规划绕行:让一群行人横在路中间缓慢移动,看车辆能不能规划出绕行路径。这种场景下行人的速度要慢但不停,停下来车辆可能就直接撞上去了。

触发跟车与礼让:让行人在斑马线上来回走,车辆必须停在停止线前等待。这个测试的关键是行人的轨迹要可复现,否则每次跑结果都不一样,没法对比算法版本。所以涉及这类测试时我会用固定随机种子,代码里所有random调用前统一random.seed(42)。

direction = carla.Vector3D(1.0, 0.0, 0.0) walker.apply_control(carla.WalkerControl(direction, 1.2, False))

方向向量的 z 分量一般给 0,因为行人是在地面上走的。如果给了非零的 z,某些版本上行人的动画会出问题,看起来像在漂浮。

5. 踩坑实录与问题排查

5.1 原地不动、抖动与穿模

这三类问题占了行人调试的八成,逐个说。

原地不动最常见的原因是控制器没start,或者start之后没 tick。其次是目标点不在导航网格上,go_to_location返回了False但代码没检查返回值。还有一种少见情况是行人被生成在了建筑内部,导航网格算不出从内部出发的路径,人会卡死在原地。排查顺序建议是:先打印go_to_location的返回值,再打印起点和目标的坐标,最后确认两者之间能连成一条路径。

原地抖动通常是两个原因叠加。一个是目标点太近,行人进入了"到达阈值"但队列没更新,一直在设同一个目标;另一个是在同步模式下外部不断用set_transform干预位置,和 AI 控制器抢控制权。第二种情况在写录像回放功能时特别容易出现,解决办法是回放期间把控制器停掉。

穿模分两种。行人和建筑穿模,说明起点或终点在导航网格外,AI 走了直线。行人和行人穿模,是导航本身不做行人互避,需要自己加分离力。还有一种行人和车穿模,那不是行人的问题,是车辆碰撞检测没开或者行人is_invincible设成了 true 导致的视觉异常。

5.2 性能、同步与回放

同步模式下的死锁:开启同步模式但主循环里某处调用了阻塞操作(例如等待某个传感器数据),会导致世界不 tick,表现为整个仿真卡住。我的规范是主循环里只做 tick 和轻量逻辑,耗时的数据处理放到独立线程并加队列。

异步模式下的轨迹跳变:异步模式帧率不固定,行人位置在两次读取之间可能跳几十厘米。如果你的场景测试依赖精确位置,必须切同步。

回放不一致:记录的行人轨迹回放时和原始不一致,通常是记录频率和 tick 频率不匹配。建议在每次world.tick()之后立刻记录所有行人的 transform,不要按时间戳记录,因为时间戳在同步模式下是等间隔的,但异步模式下会飘。

内存泄漏:长时间运行后内存缓慢上涨,八成是 actor 没销毁干净。每次销毁完调一次world.get_actors().filter('walker.*')确认数量,这个习惯能帮你早点发现问题。

5.3 常见问题速查表

现象最可能原因处理方式
行人完全不动控制器未 start,或 start 后未 tick补start()与world.tick()
行人朝错误方向走目标点不可达,路径失败走了直线检查go_to_location返回值
行人抖动目标点在阈值内反复重设到达后推进队列,加最短停留
行人穿墙导航网格缺失或目标在网格外校验坐标,重烘焙导航网格
行人间互相穿过导航不做行人互避自研分离力或分区域限流
动画滑步速度与动画不匹配速度控制在 1.1 到 1.5 之间
回放位置对不上记录频率与 tick 不同步tick 后立即采样记录
行人被车撞飞后消失物理飞出地图边界加边界检测,越界重置

这张表我贴在项目文档首页,新加入的同学遇到问题先自己对一遍,能省掉大量来回沟通。

6. 数据侧:记录、回放与可复现

6.1 记录关键事件而不只是坐标

做场景测试时,光记坐标不够用。真正有价值的是事件序列:行人在第几帧进入等待态、第几帧开始通过、车辆在第几帧开始制动。这些事件才是后续分析算法响应的依据。

我的做法是给状态机加一个事件日志,每次状态切换时记录帧号、时间戳、行人 ID、新旧状态、当时的车辆距离。日志用一个列表缓存,跑完场景统一落盘成 CSV 或者 JSON。这个数据量不大,但排查问题时非常好用,尤其是对比两个算法版本时,一眼就能看出响应时机差了多少帧。

如果要和车辆数据对齐,建议统一用帧号做主键,因为同步模式下帧号是最稳定的时间基准,时间戳反而会因为浮点累加出偏差。

6.2 固定随机种子与场景复现

场景要能复现,随机种子必须固定。这里有个细节:Carla 内部的行人 AI 也有随机性,虽然很小,但长跑下来仍然会导致差异。完全复现的做法是在关键节点用脚本强制设定状态,而不是依赖 AI 自己走。

具体说,就是在场景开始时记录每个行人的初始位置和第一个目标,跑完之后按帧回放这些设定,把 AI 的自由度压到最低。这样即使引擎内部有微小抖动,轨迹也能基本重合。对于严格的回归测试,这一步是必须的。

random.seed(20240501) np.random.seed(20240501)

两行都要写,因为有些不规范的库用的是 numpy 的随机。

6.3 行人作为传感器载体

最后一个容易被忽略的点:行人也能挂传感器。做行人视角的感知数据采集,或者做车辆对行人的检测验证时,可以在行人身上挂一个 RGB 相机或者深度相机,采集第一人称视角的数据。接口和车辆完全一样,attach_to指向行人即可。

需要注意的是行人骨骼动画会带动挂载点抖动,相机会跟着轻微晃动。如果要做稳定的数据采集,建议把相机挂在一个隐藏的辅助 actor 上,位置每帧跟随行人但不参与动画。

这一点我在做一组行人视角数据集时踩过,当时没注意,采出来的视频晃得没法用,后来改成独立载体才解决。经验就是:凡是涉及数据质量的地方,宁可多一个 actor,也别让动画去影响传感器。

最后分享一个我在实际项目里反复验证过的做法:先把行人的行为拆成"导航层"和"决策层"两层,导航层永远用引擎能力,决策层自己写状态机,两层之间只通过目标和速度两个量通信。这样无论后面加多少行为分支,导航部分都不用动,改动风险可控。踩过几次推倒重来的坑之后,我现在开新场景都是先把这个骨架搭好,再往里填具体行为,比一开始就追求大而全的框架靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询