1. 行人自动控制这件事,先想清楚要解决什么
在 Carla 里让一个行人自己走起来,听起来是个很小的需求,但真做起来,很多人的第一版脚本都会卡在同一个地方:车已经跑得挺顺了,行人却不挪窝。我最初接触这个方向时的场景很具体——要做一个城市路口的人车混行仿真,车辆用自带 AI 跑起来没问题,但路口需要有人来回穿行,才能把“行人突然横穿”这类场景构造出来。当时我以为给行人挂个控制器、给个目标点就完事了,结果调试了大半天,最后发现问题出在一个特别不起眼的地方:world.tick()。这件事让我意识到,Carla 里的行人自动控制,难点根本不在“调用哪个函数”,而在于你要理解这套系统内部的分层和时序。
所谓“自动控制行人行为”,说白了就是用程序驱动虚拟行人完成“从 A 点走到 B 点”这个过程,中间还要处理拐弯、避让、速度变化、到达判定、卡住恢复这些琐事。它和手动set_transform的区别在于:手动设置位置是“瞬移”,行人没有行走过程,动画不会播放,也不会和周围环境产生任何交互;而自动控制是“导航”,行人会沿着可行走区域走过去,速度、朝向、动画状态都由控制器在后台逐帧计算。这两种方式适用于完全不同的场景,用错了就是给自己找麻烦。
这套东西适合谁?如果你是做自动驾驶感知算法验证的,需要大量行人样本和真值轨迹;如果你在做行为预测或轨迹预测研究,需要一个可控、可重复的行人生成器;如果你在做交通流仿真或城市规划的可视化验证,想看看某个路口在高峰人流下的表现——这些场景都会用到。反过来说,如果你只是想在画面里摆几个静止的“人形道具”,那完全没必要碰 AI 控制器,直接生成后把物理和 AI 都关掉就行,省算力也省心。
1.1 车辆好搞、行人难搞,难在哪
先说清楚一件事:Carla 里车辆的行人控制体系是完全不同的两套东西。车辆有 Traffic Manager,背后是一套成熟的规则驱动逻辑,vehicle.set_autopilot(True)一行代码就能让它跑起来,速度、跟车、变道、红绿灯识别全给你打包好了,参数也丰富。而行人这边,WalkerAIController提供的能力相当“朴素”——它只负责一件事:把行人往一个三维坐标点送过去,路上绕开静态障碍。至于红绿灯、人行横道、结伴行走、对向避让,统统没有。
这个不对称很容易让人产生误判。我见过不少人想当然地给行人调set_autopilot,然后发现这个接口对 walker 根本不生效——Carla 的 autopilot 是给车辆准备的,行人要的是controller.ai.walker这个蓝图实例,然后手动调用它的一系列导航方法。这不是命名相似能不能混用的问题,而是两条完全独立的技术路径。
第二个难点是行人本身没有“底盘逻辑”。车辆有轮子、有轴距、有转向角,物理引擎在帮你处理很多细节;行人的碰撞体是胶囊体,骨架是骨骼网格,运动完全靠控制器喂方向和速度。这导致物理参数稍微调偏一点,行人就会抖、会滑步、会穿模。尤其是滑步这个现象,本质是“位移速度和动画播放速度不匹配”,看起来特别出戏,而 Carla 默认的参数在低速下还凑合,一旦你把set_max_speed调到 3 m/s 以上,滑步就非常明显了。
第三个难点是视角不同带来的问题。车辆我们通常从外部视角观察,行人往往需要第一人称或者近距离视角,一旦镜头靠近,动画瑕疵、脚部悬空、转弯生硬这些细节就全暴露出来了。所以如果你的仿真是给感知模型做训练数据,行人的视觉真实度要求其实比车辆更高——模型会盯着行人的姿态去学特征,滑步和僵硬转弯会直接影响数据质量。
1.2 三条控制路线,别一上来就用最重的那条
实际项目里,控制行人行为大致有三条路线,复杂度差异极大,选错了要么做不出效果,要么白白消耗大量算力。
第一条是纯位置控制,也就是每个仿真步直接算好下一个位置然后set_transform。这条路最轻,性能最好,几百上千行人都扛得住,而且位置绝对精确、完全可复现。缺点是没有任何导航和避障能力,动画基本是废的(除非你自己算位移去驱动动画混合空间),行人之间会重叠穿模,视觉上一眼假。它适合的场景其实很明确:纯数据生成,你只需要行人在某一时刻出现在某一坐标,用于验证感知算法的检测框和真值对齐,不看画面。
第二条是物理速度控制,用walker.set_target_velocity()给行人一个目标速度向量,让物理引擎去积分位置。这条路比纯位置控制真实,有惯性和碰撞响应,行人被车撞了会真的飞出去。但它同样没有导航,你得自己写避障和路径跟随逻辑,工作量不小。它比较适合做“被撞”这类交互实验,或者你想完全自定义运动模型的时候。
第三条是AI 控制器导航,用WalkerAIController的go_to_location。这条路最省事,控制器内部基于导航网格做寻路,行人会自己绕开建筑和路沿,动画也会跟着速度自动切换。代价是性能开销大——每个行人需要一个独立的控制器实例,还要持续参与寻路计算,上百人就开始明显掉帧。同时,控制器的行为逻辑是黑盒的,你能调的就是目标点和最大速度,中间过程干预不了。
提示:这三条路线不是互斥的,实际项目里最常见的是混合方案——大部分行人用 AI 控制器走,少数关键角色(比如要精确控制横穿时机的那一个)用速度控制,纯背景人物用位置控制。按重要性分级,算力才不会浪费。
我自己的选择标准很粗暴:如果最终要出画面、要评估感知算法,那至少得用 AI 控制器,否则动画和遮挡关系不可信;如果只是跑离线数据、做统计分析,位置控制完全够用,速度快十倍不止。
2. 跑通之前:那些不写进文档但决定成败的前置条件
很多人调行人卡住,第一反应是怀疑 API 用错了,翻文档翻半天。但按我的排查经验,八成问题出在三个前置条件上:导航网格没覆盖、蓝图选错了、仿真模式没设对。这三样任意一个出问题,你的代码写得再漂亮,行人也是站着不动。所以我在这一节先把地基打牢,再谈上层逻辑。
2.1 NavMesh 决定了行人能不能走,这是第一优先级
Carla 的行人导航完全依赖导航网格。控制器内部做的所有寻路计算,起点和终点都必须落在网格覆盖范围内,否则go_to_location调用会“成功返回”,但行人纹丝不动——不报错、不警告,静默失败,特别坑人。
导航网格在 Carla 里不是自动生成的,它取决于地图里有没有放置导航网格边界体,以及是否在编辑器里执行过构建。官方自带的那几张城镇地图一般是有覆盖的,但覆盖范围常常只包括人行道和部分车行道,有些地图的某些区域压根没建网格。我自己就遇到过一块新做的支路完全没有导航数据,行人在那附近怎么点都不动,查了两小时才发现是地图问题不是代码问题。
判断某点是否可行走,最直接的办法是拿world.get_random_location_from_navigation()试探。这个方法返回的坐标保证在网格上,你可以拿它的返回值和你自己算的目标点做距离比对,如果偏差过大,说明你给的坐标附近没有网格。更稳的做法是:所有目标点都从导航系统里取,而不是硬编码坐标。
# 从导航网格里取一个可行走点,这是最稳的目标点来源 target = world.get_random_location_from_navigation() if target is not None: controller.go_to_location(target)如果确实需要指定坐标(比如让人走到某个店门口),我的做法是:先调用get_random_location_from_navigation()拿到附近一个合法点,再用它作为目标;或者自己写一个简单的采样函数,在目标坐标周围一圈半径内多次采样,取第一个能返回非 None 的点。这样虽然落点有偏差,但至少人能动起来。
注意:
get_random_location_from_navigation()返回的是一个在所有网格点上均匀采样的坐标,你没法指定区域。想控制区域,要么换地图,要么自己维护一份可行走点池——后面第 4 节会讲怎么维护点池。
另外,网格是分层的。多层建筑、天桥、地下通道这些结构如果没有正确构建网格,控制器会做出非常诡异的判断,比如让行人在不知从哪冒出来的一个位置拐弯。如果你的场景有立体结构,建议先用很少的几个人做可视化验证,确认路径合理再上规模。
2.2 蓝图选型与动画资源
行人的外观和动画是绑在蓝图上的,walker.pedestrian.*这个过滤器会返回一大批蓝图,编号不同对应不同的骨骼网格和动画蓝图。有些蓝图带背包,有些是老人在走,有些是小孩,体型差异明显。这些差异不只是视觉上的——骨骼网格不同,胶囊体尺寸也可能不同,直接影响到碰撞和物理响应。
选蓝图的时候有三个实操建议。第一,不要硬编码蓝图 ID,用过滤器批量取,然后随机选,这样即使版本升级了蓝图编号变了,代码也不会崩。第二,把age这类属性打出来看看,部分版本的蓝图带有年龄或性别属性,可以用has_attribute判断后做筛选,但一定要先打印现网数据,不要凭印象写死。第三,控制同屏蓝图的种类数,每多一种骨骼网格就多一份动画资源占用,几十种混着用会明显拖慢帧率,我一般控制在 5 到 8 种,视觉上已经足够丰富。
bp_lib = world.get_blueprint_library() walker_bps = bp_lib.filter('walker.pedestrian.*') # 先打印一下现网有哪些属性,别凭记忆写 for bp in walker_bps[:3]: print(bp.id, [attr.id for attr in bp])动画这块有个细节值得单独说。行人的动画状态是由位移速度驱动的,控制器在后台更新速度,动画蓝图根据速度自动在“站立—行走—快走—跑”之间切换。这意味着你没法直接指定“播放走路动画”,你只能通过改速度间接影响动画。如果你发现行人在滑行但动画是站立的,那基本可以断定位移速度没有被正确写入,常见原因是物理模拟没开,或者控制器根本没启动。
2.3 同步模式与时间步,行人不动的头号元凶
这是我最想强调的一点。Carla 默认是异步模式,服务端自己按自己的节奏跑,客户端拿到的是“最近一帧”。在这种模式下,你调用go_to_location之后立刻去查位置,会发现行人压根没动——因为中间还没经过任何仿真步。更麻烦的是,异步模式下每次 tick 的时间间隔不固定,行人的移动速度、动画状态都会有抖动,做数据采集时非常难受。
做行人控制几乎一定要切同步模式,让客户端明确控制仿真步进节奏,每一步都推进固定的时间。
settings = world.get_settings() settings.synchronous_mode = True settings.fixed_delta_seconds = 0.05 # 20 FPS 的仿真步长 # 行人这种低速运动体,0.05 比较合适; # 时间步太大行人会瞬移,太小则算力浪费 world.apply_settings(settings)切同步模式之后,每一次world.tick()才代表仿真前进了一步。生成的命令批量下发给服务端,服务端在这一 tick 内创建 actor;你再 tick 一次,控制器才开始真正计算路径;再 tick 几次,行人才迈步。很多新手写完 spawn 后立刻go_to_location然后 sleep 等结果,在同步模式下永远不会动,因为你不 tick 它就不推进。
设完同步模式,还有两件事要配套做:一是采集循环里要用world.tick()而不是wait_for_tick();二是在程序退出时把同步模式关掉,否则下次启动时世界会一直卡在等待 tick 的状态,需要重启服务端才能恢复。这个坑我踩过不止一次,尤其是脚本异常退出的情况,善后代码没执行,整个服务端就僵在那里。
关于fixed_delta_seconds的取值,我给个经验值:做行人视觉仿真,0.05(20 FPS)够用,动画不会明显卡顿;做轨迹精度要求高的数据采集,用 0.02 到 0.025(40 到 50 FPS),行人位置更平滑;再往下就没必要了,因为行人步速本身慢,精度提升的边际收益很低。注意这个值和渲染帧率是两回事,仿真步进频率高不代表你看到的画面流畅,画面流畅度还受渲染性能限制。
3. 核心 API 拆解:WalkerAIController 到底管了什么
把地基打好之后,终于可以聊控制器本身了。这部分的 API 数量其实很少,一只手数得过来,但每个方法背后的语义和调用时机都有讲究,用错时机就会出现“命令发出去了但不生效”的情况。我见过最常见的误解是把它当成一个持续运行的状态机,实际上它更像一个“任务队列”——你给它下一个指令,它慢慢执行,执行完了就停在原地等你给下一个。
3.1 一个行人 + 一个控制器的绑定关系
这里的关系是一对一:每个需要自动行走的行人,都要配一个独立的 AI 控制器 actor。控制器不是挂在行人身上的组件,而是一个独立的 actor,通过start()方法绑定到某个行人身上。这个设计初看有点绕,但理解之后就明白它的好处了——控制器可以独立销毁、独立暂停,行人也能在控制器销毁后继续存在(只是不再移动)。
绑定的顺序必须是:先生成行人,拿到行人的 actor 引用,再生成控制器,再调用controller.start()。start()内部会去找它附近的行人进行绑定,官方示例里的做法是生成时就放在行人的位置,这样绑定不会出错。如果你把控制器生成在离行人很远的地方,可能会绑到别的行人身上,或者绑不上。
import carla # 第一步:生成行人本体 walker_bp = random.choice(world.get_blueprint_library().filter('walker.pedestrian.*')) spawn_point = carla.Transform(world.get_random_location_from_navigation()) walker = world.try_spawn_actor(walker_bp, spawn_point) # 第二步:生成控制器,位置和行人一致 ctrl_bp = world.get_blueprint_library().find('controller.ai.walker') controller = world.try_spawn_actor(ctrl_bp, carla.Transform(), attach_to=walker)注意try_spawn_actor的attach_to参数——把控制器挂到行人身上,销毁行人时控制器会一起销毁,省得你手动清理漏掉。这是我在做长时运行脚本时学到的教训:早期我手动管理销毁,跑几个小时之后场景里积压了一堆孤儿控制器,帧率一路掉到个位数。
还有一点,生成行人时要防止重叠。用get_random_location_from_navigation()拿到的点可能已经站着人,直接生成会触发碰撞检测失败,try_spawn_actor返回 None。我的处理方式是生成失败就重试,同时重试次数要设上限(比如 5 次),否则某些拥挤区域会陷入死循环。
3.2 go_to_location / set_max_speed / stop 的调用时机
这三个方法是控制器的核心,用熟了基本就能覆盖 90% 的需求,但每一个都有个“必须在前置状态满足时才生效”的前提。
start()是入口,必须在world.tick()之后调用,也就是行人 actor 已经真正存在于世界里之后。它的作用是让控制器开始参与仿真,绑定目标行人,激活内部的寻路逻辑。没调start(),后面的所有指令都是空谈。
go_to_location(location)下发一个导航目标,控制器会自己规划路径。这个调用是异步的,返回之后行人不会立刻动,要等若干仿真步才开始加速。目标点必须是三维向量,而且最好落在导航网格上。重复调用会覆盖上一个目标,所以你做定时重规划的时候直接覆盖就行,不需要先 stop。
set_max_speed(speed)设置速度上限,单位是米每秒。这个值的语义是“上限”而不是“实际速度”——控制器会根据路况、转向角度自动降速,比如拐弯的时候会慢下来,所以实际速度通常低于你设的值。这一点很重要,很多人以为设了 1.4 就是匀速 1.4,然后发现行人拐弯时慢得像在挪,其实是正常行为。
stop()让行人立刻停下,但不断开绑定,之后还可以再go_to_location继续走。它和销毁控制器是两回事,别搞混。
# 典型的启动序列 world.tick() # 确保行人 actor 已生成 controller.start() # 绑定并激活 controller.set_max_speed(1.4) world.tick() # 让 start 生效 controller.go_to_location(world.get_random_location_from_navigation()) # 之后的循环里持续推进 for _ in range(200): world.tick()注意:
start()和go_to_location()之间最好隔一个 tick。我实测下来,同一帧内连着调用偶尔会出现目标被吞掉的情况,隔开一步就稳定了。这个现象不一定在每台机器上都复现,但加上这个 tick 几乎没有成本。
3.3 速度参数怎么定:从真实步速倒推
set_max_speed的值不能随便填。填 0.5 行人像在泥里走路,填 5 行人像在滑行,动画也跟不上。正确做法是从真实世界的步速反推。
普通成年人正常步行速度大约是 1.2 到 1.5 米每秒,赶时间快走能到 1.8 到 2.0,慢悠悠散步大概 0.8 到 1.0。小孩和老人普遍偏慢,在 0.9 到 1.2 之间。真实场景里的人群速度是一个分布,不是单一值。
所以我会给每个行人从一组区间里随机取值,形成自然的快慢差异:
| 人群类型 | 建议 max_speed 区间 | 说明 |
|---|---|---|
| 快步赶路 | 1.6 ~ 2.0 m/s | 明显的追赶姿态,动画切换快 |
| 正常通勤 | 1.2 ~ 1.5 m/s | 最常见,视觉最自然 |
| 悠闲散步 | 0.9 ~ 1.2 m/s | 适合公园、景区类场景 |
| 老人儿童 | 0.8 ~ 1.1 m/s | 过高会明显滑步,注意调低 |
| 特殊行为(奔跑) | 2.5 ~ 3.5 m/s | 少量用于构造突发场景 |
反过来算一下距离和时间的关系,能帮你判定目标点是否合理。假设一段人行道 60 米,max_speed 设 1.3,那么走完至少需要 46 秒仿真时间。如果你的采集窗口只有 20 秒,那这个行人根本走不到头,你采集到的永远是“走到一半”的轨迹。这种低级错误在批量生成时特别常见——你以为生成了 100 个完整过街样本,实际上一半是半截轨迹。
还有一个连带影响:速度设得越高,滑步越明显。因为动画蓝图的速度区间是有限的,超过某个阈值之后动画播放速度就跟不上位移了。我实测在 2.0 m/s 以内基本看不出来,2.5 以上开始明显,3.5 就是“滑冰”了。如果你的场景需要大量奔跑的行人,考虑换成体格特征更符合的蓝图,或者接受一定程度的滑步——毕竟大部分感知算法不看脚。
4. 从零跑通:一份能直接抄的完整实现
理论讲完,这一节上代码。我会给出一个完整可运行的流程,从批量生成到行为控制,最后加上卡死恢复。代码我尽量写得直白,每一步都带注释说明意图,你可以直接复制到自己的项目里改参数。
4.1 批量生成行人与控制器
批量生成的关键在于用命令批处理而不是逐个 spawn。原因有两点:一是性能,服务端一次批量处理比来回通信快得多;二是原子性,一批命令要么都成功要么都失败,不会出现“生成了一半”的中间状态。
整体流程是三步:先在客户端算好所有生成位置,再批量生成行人,再批量生成控制器。注意第二步和第三步之间要隔一个 tick,因为控制器的attach_to需要行人 actor 已经真实存在。
import carla import random def spawn_pedestrians(world, count=60): bp_lib = world.get_blueprint_library() walker_bps = bp_lib.filter('walker.pedestrian.*') ctrl_bp = bp_lib.find('controller.ai.walker') # 第一步:先在客户端把位置全算好,减少与服务端的往返 transforms = [] for _ in range(count): loc = world.get_random_location_from_navigation() if loc is None: continue # 稍微抬高一点,避免生成时和地面重叠导致失败 loc.z += 0.2 transforms.append(carla.Transform(loc)) # 第二步:批量生成行人本体 spawn_cmds = [] for tf in transforms: bp = random.choice(walker_bps) spawn_cmds.append(carla.command.SpawnActor(bp, tf)) walker_ids = [] results = client.apply_batch_sync(spawn_cmds, True) for r in results: if not r.error: walker_ids.append(r.actor_id) world.tick() # 必须推进一帧,否则控制器绑不上 # 第三步:批量生成控制器并挂靠 ctrl_cmds = [] for wid in walker_ids: walker = world.get_actor(wid) ctrl_cmds.append( carla.command.SpawnActor(ctrl_bp, carla.Transform(), attach_to=walker) ) ctrl_ids = [] results = client.apply_batch_sync(ctrl_cmds, True) for r in results: if not r.error: ctrl_ids.append(r.actor_id) return walker_ids, ctrl_ids这段代码有两个细节值得展开。第一个是loc.z += 0.2这个抬高。行人在地面上生成时,胶囊体和地面可能会重叠,导致物理引擎把它弹开或者直接判定生成失败。抬高一点让它自然落下来更稳。抬高多少取决于你的时间步,步长大了抬太高会看到明显的“掉落”过程,0.1 到 0.3 之间试一下就行。
第二个是apply_batch_sync的第二个参数True,表示同步执行,返回结果列表。如果传 False,你拿不到返回的 actor id,后续就不好管理。批量生成时一定要用 True。
生成完之后,你会得到两份 id 列表。这两份列表的顺序不一定一一对应,因为失败的会被跳过。所以绑定的时候要用控制器的get_actor_id之类的方式确认绑定关系,或者干脆在生成后用controller.get_actor()之类的接口查一下。我在早期版本里直接按下标对应,结果某次一批里有一个行人生成失败,后面的全部错位,控制器绑到了错误的行人身上,出现了两个行人在同一位置打转的诡异现象,排查了很久。
4.2 行为脚本:过马路—等待—再走
单个行人的行为控制,最简单的实现是“走到底再给新目标”,但这样看起来呆板。稍微好一点的方案是给每个行人维护一个状态,按状态给指令。我常用的三板斧是:行进中、等待中、重新规划。
行进中的判断逻辑是查距离:如果行人距离当前目标点小于阈值(比如 1.0 米),就认为到达了,切换到等待状态。等待状态持续随机的若干秒(模拟在人行横道前等红灯,或者停下来打电话),然后切换到重新规划,给一个新目标点,回到行进中。
import math class PedestrianAgent: def __init__(self, controller, world): self.controller = controller self.world = world self.state = 'idle' self.target = None self.wait_ticks = 0 self.speed = random.uniform(1.2, 1.5) def start(self): self.controller.start() self.controller.set_max_speed(self.speed) self.state = 'moving' self._pick_new_target() def _pick_new_target(self): self.target = self.world.get_random_location_from_navigation() if self.target is not None: self.controller.go_to_location(self.target) def update(self): if self.state == 'waiting': self.wait_ticks -= 1 if self.wait_ticks <= 0: self.state = 'moving' self._pick_new_target() return if self.state == 'moving' and self.target is not None: loc = self.controller.get_actor().get_location() dist = loc.distance(self.target) if dist < 1.0: # 到达目标,进入随机等待 self.state = 'waiting' self.wait_ticks = random.randint(60, 240) # 3~12秒(按20FPS) self.controller.stop()注意wait_ticks的单位是仿真步,不是秒。在 0.05 秒步长下,1 秒等于 20 个 tick,所以想等 3 到 12 秒就是 60 到 240 个 tick。这个换算很容易搞混,我建议在代码里统一定义一个TICKS_PER_SECOND = 1 / fixed_delta_seconds,所有时间都以秒为单位写,用的时候乘一下,可读性好很多。
这个状态机虽然简单,但已经能构造出相当自然的人流效果:有人快步穿过,有人走到路口停下来等一会,有人走走停停。关键在于每个行人的等待时长、速度、目标点都随机化,整体看起来就有了“非同步”的真实感。如果所有行人都同步走同步停,那种整齐划一的画面比静止还假。
4.3 卡死检测与目标重分配
上面那套逻辑有一个致命弱点:行人卡住了,永远不会“到达”,于是永远停在行进状态,一动不动。卡住的原因很多——目标点在某栋楼里面、路上有两拨人互相顶牛、目标点落在网格边缘导致寻路失败。
解决办法是加一个卡死检测。思路很简单:记录最近一段时间的位置,如果位移始终小于阈值,就判定卡死,强制重新分配目标。
class StuckDetector: def __init__(self, window=200, threshold=0.5): self.window = window # 观察窗口(tick 数) self.threshold = threshold # 位移阈值(米) self.history = [] def update(self, location): self.history.append((location.x, location.y)) if len(self.history) > self.window: self.history.pop(0) if len(self.history) < self.window: return False xs = [p[0] for p in self.history] ys = [p[1] for p in self.history] span = math.hypot(max(xs) - min(xs), max(ys) - min(ys)) return span < self.threshold阈值的选择要结合实际速度。如果一个人 max_speed 是 1.4,200 个 tick(10 秒)应该走 14 米左右,现在只走了不到 0.5 米,那肯定是卡了。所以window=200, threshold=0.5这个组合对正常速度的行人是安全的,不会误判。
提示:判定卡死后的处理不要只是重新给目标点,最好先把控制器 stop 再 start 一次。我遇到过控制器内部状态混乱的情况,光换目标点它还是不动,stop/start 之后才恢复正常。这一步多做一次开销很小。
卡死检测还有个附带好处:它能帮你发现地图和网格的问题。如果你的行人频繁卡死,说明你选的目标点区域网格质量差,这时候应该去查地图而不是继续调代码。我有个项目里 30% 的行人都在同一片区域卡死,最后发现那片区域的网格是断裂的,中间有一条缝,行人过不去。
5. 让人群看起来像人:行为增强与场景联动
基础功能跑通之后,接下来的问题是“像不像”。一个人走路和一百个人走路的区别,不只是数量,还有群体行为——有人横穿、有人结伴、有人在路口等车过。这一节聊几个能让场景真实度上一个台阶的做法。
5.1 横穿概率与随机种子
Carla 的控制器本身不管红绿灯,行人只要收到目标点就会直接走过去,包括横穿车行道。这在很多场景下是好事——你正好需要构造“行人横穿”的危险场景。但如果你希望大部分行人在人行道上规规矩矩走,只有少部分横穿,就需要控制横穿比例。
Carla 提供了一个全局的行人横穿参数,可以设置允许横穿的行人比例。这个值的语义是 0 到 1,越大横穿越频繁。
# 只让 10% 的行人允许横穿车行道 world.set_pedestrians_cross_factor(0.1)这个设置是全局的,影响所有由 AI 控制器驱动的行人,而且需要在生成行人之前设置。我一般会根据场景需求调:做常规交通流仿真用 0.05 到 0.1,做危险场景构造用 0.3 以上,做纯人行道场景直接设 0。
另一个容易被忽略的是随机种子。Carla 的行人生成、随机位置采样、部分内部行为都受随机种子影响。如果在做对照实验,比如 A 组和 B 组只有算法不同,其他条件必须一致,那一定要固定种子,否则两组的行人分布不同,对比结果没有意义。
world.set_pedestrians_seed(42)不过要注意,种子只能固定 Carla 内部的那部分随机性,你自己代码里用random生成的数值不受它控制,需要你单独random.seed()。我吃过这个亏——以为设了 Carla 的种子就万事大吉,结果自己代码里的速度随机值每次都不同,实验可复现性很差。
5.2 与信号灯、车辆的联动
控制器不管红绿灯,所以“行人等红灯”这个行为必须你自己实现。有两种做法,复杂度差别挺大。
第一种是区域判定法,简单粗暴。在路口划定一个矩形区域,行人进入这个区域后,检查当前信号灯状态;如果是红灯(对行人来说的禁止通行状态),就调用stop()让行人停下,等到绿灯再go_to_location继续。这种方法实现快,缺点是行人会在区域边界处突然停住,看起来有点机械,而且多个行人会在同一位置挤成一堆。
第二种是目标点法,更自然。把人行横道两端的等待点预先定义好,行人走到等待点时不是立刻过街,而是先检查信号灯状态,绿灯才下发过街的目标点。这样行人会自然地在等待点停下来,视觉上自然很多,也能形成排队效果。
第二种做法还带来一个额外好处:可以构造“抢跑”和“滞留”行为。比如设定 5% 的行人在红灯末期就提前起步,或者 10% 的行人在绿灯快结束时还没走完导致滞留,这些都是真实交通场景里常见的危险样本,对感知和预测算法的测试价值很高。
车辆联动就更复杂了。理论上你可以检测车辆和行人的距离,距离过近时让行人停下避让。但 Carla 的车辆 AI 本身就有行人避让逻辑,它会检测前方行人然后刹车。所以你不需要额外做什么,车会自己停。反过来说,如果你发现车不让人,那可能是车辆 AI 的参数问题,或者行人的碰撞检测没生效。
5.3 分组行走与真值数据配合
单人行走看多了会觉得很假,真实世界里人经常是两个人并排走、一家三口一起走、一群人跟着人流走。实现结伴行走的思路是:给一组行人设定相同或相近的目标点,速度也设成接近,让他们自然走到一起。
def spawn_group(world, controllers, group_size=3): # 一组人朝同一个目标走,速度略有差异但不悬殊 target = world.get_random_location_from_navigation() if target is None: return base_speed = random.uniform(1.1, 1.3) for ctrl in controllers[:group_size]: ctrl.set_max_speed(base_speed + random.uniform(-0.1, 0.1)) ctrl.go_to_location(target)这里速度差异不能大,否则走一会儿就散开了。控制在正负 0.1 以内,基本能保持队形几十米。另外目标点要一致,不能各自随机,否则一出发就分道扬镳。
如果你的目的是给感知算法做真值,还有一件事必须做:记录每个行人的完整状态。至少要存时间戳、位置、朝向、速度、是否在行走、所属分组。这些数据要和相机、激光雷达的时间戳严格对齐,否则训练出来的模型会有系统性偏差。
我一般会在每个 tick 结束后遍历所有行人 actor,把状态序列化后追加到列表里,最后统一写文件。注意不要在采集循环里做磁盘 IO,会严重拖慢仿真速度。用内存列表攒着,跑完一次性落盘。
| 真值字段 | 类型 | 说明 |
|---|---|---|
| timestamp | float | 仿真时刻,单位秒 |
| actor_id | int | 行人唯一标识 |
| location | (x, y, z) | 世界坐标 |
| rotation | (pitch, yaw, roll) | 朝向,yaw 最关键 |
| speed | float | 当前位移速度 |
| state | str | moving / waiting / stuck |
| group_id | int | 所属分组,-1 表示独立 |
这张表看着简单,但每个字段都有用武之地。朝向 yaw 是很多轨迹预测模型的关键输入,速度可以用来判断是走还是跑,state 和 group_id 在分析行为模式时会省下大量后处理工作。我从项目一开始就加上这些字段,后来做行为聚类分析的时候,直接拿来用,不用回头重跑数据。
6. 常见问题排查与性能调优实录
这一节是我这几年调行人攒下来的问题清单。大部分问题都表现为“行人不动”,但原因千差万别,所以我把排查顺序整理出来,按这个顺序查,基本能在五分钟内定位。
6.1 症状对照表
先上一张速查表,按症状找原因,比从头读文档快得多。
| 症状 | 最可能的原因 | 排查动作 |
|---|---|---|
| 完全不动,位置一直不变 | 没调start(),或没 tick | 检查启动序列,确认 tick 推进 |
| 不动但目标已设置 | 目标点不在导航网格上 | 改用get_random_location_from_navigation() |
| 动了但很慢,像在挪 | set_max_speed值太低 | 检查是否用了默认值 |
| 原地抖动,左右晃 | 时间步太大或物理参数冲突 | 降低fixed_delta_seconds |
| 滑步严重,脚底打滑 | 速度超过动画上限 | max_speed 压到 2.0 以下 |
| 两个行人叠在一起 | 生成位置重叠,或绑定错位 | 检查 id 与控制器对应关系 |
| 频繁卡死在同一区域 | 该区域导航网格断裂 | 换地图或换目标点池 |
| 帧率随人数骤降 | 控制器数量过多 | 降人数,或分级控制 |
| 程序异常退出后世界僵住 | 同步模式没关 | 重启服务端或加重置逻辑 |
| 行人被车碰到直接穿过去 | 物理模拟未开启 | 检查 simulate_physics 设置 |
这张表里我特别想聊“原地抖动”和“绑定错位”两个,因为它们最难凭直觉想到。
原地抖动的本质是数值震荡:控制器想走,物理引擎因为时间步太大算出过大的位移,下一帧又因为碰撞被推回来,来回震荡。降低时间步能解决大部分情况。还有一种可能是行人的胶囊体和其他物体有初始重叠,物理引擎在持续尝试分离,表现也是抖动,这种要把生成位置抬高一点。
绑定错位更隐蔽,症状是“有两个人在同一位置打转”或者“某个行人怎么都收不到指令”。根源就在批量生成时按下标对应 id 这个坏习惯,只要有一批里出现生成失败,后面全错位。解决办法是生成完后用controller.get_actor()实际查一遍绑定的对象,或者维护一个以 controller id 为键的字典,不要依赖数组下标。
6.2 性能:行人数量的天花板在哪
行人的性能开销比车辆大得多,这是很多人都没想到的。原因是每个行人需要一个独立的 AI 控制器,控制器要持续跑寻路计算,而行人的骨骼动画开销也不小。我实测在同一台机器上,车辆能跑 100 辆不卡,行人跑到 80 个就开始明显掉帧。
给一个粗略的经验范围,具体数值取决于硬件和场景复杂度:
- 50 人以内:基本无感,帧率稳定
- 50 到 100 人:开始有感知,需要关注帧率
- 100 到 150 人:明显吃力,动画开始掉帧
- 150 人以上:需要做分级控制,不然没法用
分级控制的思路是按重要性分层。处于场景核心区域、需要参与交互的行人(比如正在横穿的那个)用完整的 AI 控制器;远处的、背景里的行人关掉控制器,改成定时更新位置,或者干脆静止;非常远的直接关闭渲染和 AI,只保留数据占位。这样能显著提升同屏人数上限。
# 简化示例:按距离分级 def update_lod(world, agents, camera_loc): for a in agents: loc = a.controller.get_actor().get_location() dist = loc.distance(camera_loc) if dist > 150: a.controller.stop() # 远处停下,省算力 elif dist > 80: a.controller.set_max_speed(a.speed * 0.8) # 中距离降速 else: a.controller.set_max_speed(a.speed)这个 LOD 逻辑不用每帧都执行,每 20 到 30 个 tick 跑一次就够,帧率提升明显但视觉上几乎看不出差别——毕竟远处的人你根本看不清他在做什么。
还有一个性能陷阱是动画资源切换。如果你随机选蓝图的策略是“每个行人都从全部蓝图里随机”,同屏可能出现十几种不同的骨骼网格,每一种都要加载独立的动画资源,内存和 CPU 开销都会上去。改成先选出 5 到 8 种蓝图,然后从这个小集合里随机,效果一样但开销小很多。
6.3 我踩过的几个坑
最后聊几个具体到操作层面的教训,都是文档里不会写、但会实实在在浪费你半天时间的。
第一个坑是销毁清理不彻底。行人销毁时如果不显式销毁控制器,即使用了attach_to也会留下一些残骸。我的做法是在程序退出前统一遍历所有控制器和行人,先stop()再destroy(),最后把同步模式关掉,恢复世界设置。这段善后代码一定要放在try/finally里,否则脚本一崩就全废了,下次启动还得重启服务端。
第二个坑是目标点池没维护。一直用get_random_location_from_navigation()会在整个地图上均匀采样,结果你的行人经常要横穿大半个地图,几十秒都走不到,看起来稀稀拉拉。正确做法是预先在地图上采一批点,按区域分组存成点池,然后让行人在自己所在的区域附近选目标,这样人流的密度和分布才符合直觉。
第三个坑是忽视了行人的碰撞响应。默认情况下行人有物理模拟,被车撞了会飞出去,这在某些场景下是你要的效果,但在采集数据时是灾难——被撞的行人会脱离轨迹,污染你的样本。如果不需要碰撞交互,在生成后立刻关掉物理模拟,行人就不会被撞飞,但也会穿过车辆。这是个权衡,看你更在意数据干净还是物理真实。
第四个坑是我最想说的:不要迷信默认参数。行人控制器的速度上限、转向灵敏度这些内部参数都是写死在引擎里的,你改不了。所以当默认行为不符合预期时,唯一的办法是在外层做补偿——比如你觉得拐弯太急,就在拐弯处多设几个中间目标点,让它走得柔和一些;比如你觉得停下太突兀,就在到达前提前降速,制造一个平滑减速过程。这种“绕路实现”的方式看起来笨,但比我见过的各种折腾内部参数的方法都可靠。
提示:给行人加中间路径点是个很实用的技巧。直接给终点,控制器会走它自己规划的最短路径,可能贴着墙根走或者抄奇怪的小道;给几个中间点,就能把行走路径“引导”到你希望的位置,比如沿人行道中间走、绕过某个障碍物。中间点间隔 5 到 10 米比较合适,太密了行人会频繁转向,看起来很别扭。
最后分享一个实际体感:单个行人的问题好查,几十个行人的问题就变成了系统性问题。我的经验是,任何时候扩充规模之前,先用 3 到 5 个行人把完整流程跑通,包括生成、行走、等待、重规划、销毁这一整条链路,确认没有隐藏问题再放大。因为规模一上去,日志被淹没,单个异常行人混在一堆正常行人里根本发现不了,只能靠肉眼在画面里找,那时候的清场和定位成本会翻好几倍。这套方法我在好几个项目里都沿用下来,每次都能省下大量返工时间。