1. 障碍物为什么要分三类来做
在仿真环境里搭场景,很多人第一步就卡在"往世界里扔障碍物"这件事上。扔几个箱子谁都会,但真正跑算法的时候你会发现,障碍物的行为模式直接决定了你的仿真结果有没有参考价值。我在做移动机器人路径规划和避障测试的过程中,慢慢把一个看起来很随意的操作拆成了三类:移动障碍物、随机障碍物、固定障碍物。这三类不是随便分的,它们各自对应算法验证中完全不同的测试目标。
固定障碍物解决的是"地图结构"问题。它相当于环境里的静态先验,用来构建代价地图的基础层,验证你的定位、建图、全局路径规划在已知静态环境下是否正常。移动障碍物解决的是"动态响应"问题,考核的是局部规划器的实时重规划能力、预测模块的跟踪精度,以及机器人在狭窄通道内面对对向来物时的决策鲁棒性。随机障碍物解决的是"泛化能力"问题,用可控的随机性批量生成不同的场景布局,避免你的算法过拟合在某一套手工摆出来的场景上。
所以这篇内容想讲清楚的事情很具体:在常见的机器人仿真环境里,怎么用几种不同思路分别把这三类障碍物生成出来,每种方式背后的实现逻辑是什么,参数怎么调,什么时候该用哪一种。适合已经能跑通基础仿真、开始正经做算法评测的读者;如果你只是想摆个箱子看看模型能不能动,前面的基础部分也能直接用。
需要提前说明一点,下面的方法都是我实际操作中反复用过、比较稳的路线,涉及具体参数的地方会给出数值,但这些数值是基于常见机器人尺寸和常见传感器范围的经验值,换到你的场景需要按比例调整。这也是仿真工作最容易被忽略的地方:参数不是抄来的,是算出来的。
1.1 三类障碍物的职责边界对比
先把三类的差异摊在表里看清楚,后面所有操作都是围绕这张表展开的。
| 类型 | 核心职责 | 典型实现方式 | 关键参数 | 适用阶段 |
|---|---|---|---|---|
| 固定障碍物 | 构建静态地图结构、验证全局规划 | 世界文件里直接定义模型 | 位姿、尺寸、朝向 | 建图、全局规划调试 |
| 移动障碍物 | 验证动态避障与重规划 | 控制器插件驱动、外部脚本驱动 | 速度、轨迹点、周期 | 局部规划、预测模块 |
| 随机障碍物 | 批量生成场景、评估泛化性 | 脚本按分布采样后注入 | 数量、分布范围、间距阈值 | 回归测试、参数寻优 |
这张表最值得注意的一列是"关键参数"。固定障碍物的核心是位姿和尺寸,因为它是地图的一部分;移动障碍物的核心是速度和轨迹,因为它是时间维度的东西;随机障碍物的核心是分布范围和间距约束,因为随机不等于随便,两个障碍物叠在一起或者把机器人出生点堵死,这个场景就废了。
我见过不少人在随机生成这一列踩坑:只写了随机采样,没写最小间距和禁入区,结果跑一百次场景里有三十次机器人一开始就被卡在障碍物里面,仿真直接报碰撞,白跑一轮。所以后面讲随机生成的时候,间距检测和出生点保护这两个细节会重点展开。
1.2 先确认你的仿真世界文件结构
不管用哪种方式,最后都落到世界文件的模型列表里。一个典型的世界文件包含物理引擎配置、光照、地面,然后是若干个模型条目。固定障碍物就是在这里硬编码;移动障碍物是模型条目加上控制器配置或者外部驱动的话题;随机障碍物是脚本在启动前生成这个列表,或者运行时动态插入。
理解这个结构很关键,因为它决定了你选哪条路。如果障碍物在仿真开始后完全不需要变化,写进世界文件是最省事的,加载快、可复现、不用额外进程。如果障碍物要动,就必须挂控制器或者由外部节点持续发布状态。如果要随机,就必须有一个生成器在仿真启动前介入,因为世界文件一旦加载,静态部分就固定了。
这三条路的边界清楚之后,就不会出现"我想让固定障碍物随机出现但又想让它动"这种自相矛盾的需求了。真需要动态随机,那是移动障碍物加随机初始化位姿,属于两条路的组合,实现上分两步走。
2. 固定障碍物:世界文件里硬编码的正确姿势
固定障碍物是最容易被低估的一类。很多人觉得不就是往文件里贴几段模型定义吗,但实际上位姿写错一个符号、碰撞体尺寸和视觉模型不匹配,后面调试会浪费大量时间。这一节把从零添加一个固定障碍物的完整过程拆开讲。
2.1 模型条目的最小完整结构
一个能正常参与碰撞计算的固定障碍物,至少需要三部分:视觉部分、碰撞部分、惯性部分。视觉部分决定你在界面里看到什么,碰撞部分决定物理引擎怎么算,惯性部分决定它被撞到时的反应。三者可以不一致,但建议保持几何一致,否则会出现"看起来没碰到但物理上已经撞了"的诡异现象。
一个方块障碍物的典型结构如下,这是最常见的写法,用基本几何体而不是外部网格文件,加载快、不用管路径问题:
<model name="fixed_box_01"> <static>true</static> <pose>3.0 1.5 0.25 0 0 0</pose> <link name="link"> <collision name="collision"> <geometry> <box><size>0.5 0.5 0.5</size></box> </geometry> </collision> <visual name="visual"> <geometry> <box><size>0.5 0.5 0.5</size></box> </geometry> <material> <ambient>0.6 0.6 0.6 1</ambient> <diffuse>0.6 0.6 0.6 1</diffuse> </material> </visual> </link> </model>几个关键点值得单独说。<static>true</static>这一行是固定障碍物的本质,它告诉物理引擎这个物体不参与动力学积分,位置永远不会变,计算开销极低。如果你漏了这一行,它会被当成动态物体,虽然没受力不会动,但每帧都要参与碰撞检测和积分,场景里放几十个之后帧率会明显下降。
<pose>里的六个数字是x y z roll pitch yaw。最容易错的是 z 值。一个边长 0.5 的立方体,中心在 z=0.25 时底面刚好贴地。如果你写 z=0,它会有一半埋在地面以下,视觉上看起来矮了一截,碰撞检测也可能出问题。这个"高度等于尺寸一半"的规则对所有贴地放置的几何体都成立,圆柱是高度一半,球体是半径。
2.2 用循环批量生成固定障碍物的脚本思路
手工写十个八个还行,写几十个就容易出错,而且改布局要一个个改,效率极低。我的做法是用脚本生成世界文件里的模型片段,再拼接到模板里。这样布局参数集中在一处,改一个数就能整体平移或缩放。
下面这段是生成一排墙式障碍物的脚本,逻辑很简单:给定起点、数量、间距、尺寸,输出对应的模型 XML 片段。
def gen_wall(start_x, start_y, count, spacing, size, axis="x"): pieces = [] for i in range(count): if axis == "x": x = start_x + i * spacing y = start_y else: x = start_x y = start_y + i * spacing z = size[2] / 2.0 pieces.append(f""" <model name="wall_{axis}_{i}"> <static>true</static> <pose>{x:.3f} {y:.3f} {z:.3f} 0 0 0</pose> <link name="link"> <collision name="collision"> <geometry><box><size>{size[0]} {size[1]} {size[2]}</size></box></geometry> </collision> <visual name="visual"> <geometry><box><size>{size[0]} {size[1]} {size[2]}</size></box></geometry> </visual> </link> </model>""") return "".join(pieces)调这段代码的时候有个细节:spacing必须大于size[0](沿 x 排列时),否则相邻方块会重叠。重叠的静态物体在物理上不算错,但视觉上会有一道缝,而且如果你的算法按障碍物边界做膨胀,重叠区域的膨胀会算重,影响代价地图的准确性。我一般让间距等于尺寸加 0.02 到 0.05 的缝隙,既保证是独立障碍物,又不会留太宽的通道让机器人钻过去。
还有一个经验:给每个模型起名字的时候带上坐标或者序号,比如wall_x_0、wall_x_1。仿真跑起来之后如果发现某个位置不对,你从日志或者可视化标记里能直接定位到是哪个模型,而不是对着一堆obstacle发懵。
2.3 固定障碍物的碰撞体简化原则
这是我想重点讲的一块,因为它是性能和质量的分水岭。固定障碍物数量一多,碰撞检测就是主要开销。原则是:视觉可以精细,碰撞必须简单。
举例来说,一个复杂的桌子模型,视觉上用网格文件甚至带纹理都没问题,但碰撞体用几个长方体拼出来就够了,不需要跟视觉完全一样。更进一步,如果是贴墙的装饰性物体,碰撞体甚至可以简化成一面薄板。物理引擎算的是碰撞体的相交,和视觉模型无关。
具体操作上,可以在同一个 link 里放多个 collision,用不同几何体拼出近似形状,视觉部分单独用网格。这样做的收益在场景规模上百个物体时非常明显。我做过对比,一个包含约八十个固定障碍物的场景,碰撞体用网格文件时仿真实时率大概在 0.6 左右,全部换成简单几何体后能到 0.95 以上。这个差距直接决定你一次回归测试是跑半小时还是一小时。
另外要注意,静态物体的惯性部分其实可以省略,因为不参与动力学。但如果你的工具链里有检查脚本会报缺失,补一个也行,不影响性能。
3. 移动障碍物:让障碍物按预期轨迹动起来
移动障碍物是三类里最容易做错的一类。错的地方往往不是"动不起来",而是"动得不符合预期":速度忽快忽慢、转弯处瞬移、轨迹和机器人规划器的预测模型对不上。这一节从两种驱动方式讲起,然后说清楚怎么让运动平滑可控。
3.1 运动学驱动与插件驱动的选择
移动障碍物有两条主流实现路线。第一条是运动学驱动,直接把物体设为运动学状态,由外部程序每帧写入新的位姿。第二条是插件驱动,在模型上挂一个控制器插件,插件内部按轨迹或者按速度指令更新位姿。两条路的差别在于控制权在哪。
运动学驱动的好处是简单直接,你在外部脚本里完全掌控位姿,想走什么轨迹就走什么轨迹,甚至可以从录制的真实数据回放。缺点是外部脚本的运行频率如果和仿真步长不匹配,会出现抖动或者位姿跳变。比如仿真跑 1000Hz,外部脚本只有 20Hz,那两次更新之间物体会保持不动,看起来就是一卡一卡的。
插件驱动的好处是更新在仿真线程里,时间同步天然准确,运动平滑。缺点是要写插件或者配置现成插件,灵活性稍差,复杂轨迹需要事先定义好。
我的建议是:做算法评测、需要精确可复现的轨迹时用插件驱动;做快速原型、轨迹来源是外部数据或者临时计算时用运动学驱动。两者不是互斥的,同一个场景里可以混用,比如主要干扰物用插件跑固定轨迹,次要干扰物用脚本随机游走。
3.2 运动学障碍物的位姿写入要点
用外部节点写位姿的时候,最常踩的坑是用了绝对位姿覆盖而不是增量更新,或者更新频率和仿真对不上。下面这段示意代码用定时器按固定周期写入位姿,位置沿直线往返:
import math class KinematicMover: def __init__(self, set_pose_fn, start, end, speed): self.set_pose = set_pose_fn self.start = start self.end = end self.speed = speed self.t = 0.0 self.length = math.dist(start, end) self.period = 2.0 * self.length / speed def on_tick(self, dt): self.t = (self.t + dt) % self.period half = self.period / 2.0 ratio = self.t / half if self.t < half else (self.period - self.t) / half x = self.start[0] + (self.end[0] - self.start[0]) * ratio y = self.start[1] + (self.end[1] - self.start[1]) * ratio self.set_pose(x, y, self.start[2], 0, 0, 0)这里的关键是用累计时间而不是每次加固定步长来算位置,这样即使回调有抖动,位置也不会漂移。另外速度参数要和机器人的最大速度匹配:如果你的机器人巡航速度是 0.5m/s,障碍物也跑 0.5m/s,那两者的相对运动几乎为零,测试不出避障能力。我一般把移动障碍物速度设在机器人速度的 0.3 到 0.8 倍之间,正面对向时取低值,横向穿越时取高值,这样才能覆盖"来得及躲"和"来不及躲"两种边界。
还有一点容易被忽略:移动障碍物的更新必须走物理引擎认可的接口。直接改模型位姿在某些实现里会绕过碰撞检测的更新,导致视觉上物体过去了但碰撞体还在原地。正确做法是确认你用的接口会触发碰撞体同步,或者干脆用插件在物理步内更新。这个坑我踩过一次,排查了两个多小时,最后发现是接口用错了。
3.3 用轨迹点串起复杂运动
直线往返只能测最简单的场景。真正考验局部规划器的是"突然从侧方插入""在狭窄通道内掉头""绕着一个固定障碍物转圈"这类运动。这些用参数化轨迹写起来麻烦,用轨迹点串最直观。
轨迹点的方式是给定一串带时间戳的位姿,运行时按时间插值。优点是轨迹形状完全可控,可以录真实行人轨迹回放;缺点是轨迹之间不连续时需要处理。我一般用线性插值加朝向单独计算,位置插值保证平滑,朝向按速度方向算,避免朝向突变导致雷达点云分布跳变。
轨迹设计上有几个实用套路。侧向插入:让障碍物从机器人规划路径的侧方以垂直方向穿过,时间点设在机器人预计到达该位置前后,用来测试预测模块是否提前减速。对向接近:在狭窄通道里从对面驶来,速度取机器人速度的一半左右,测试让行决策。环绕运动:绕某个固定障碍物做圆周运动,半径略大于机器人安全半径,测试在动态障碍物附近的通过能力。
每种轨迹都建议跑多组参数,比如侧向插入的提前量设三个档位:机器人到达前 0.5 秒、1 秒、2 秒。提前量太小时机器人来不及反应是正常的,这组数据恰好能标定你算法的反应边界,比单跑一组"能过"的场景有价值得多。
4. 随机障碍物:可控的随机才有评测价值
随机生成这一块,最容易出现的问题是把"随机"理解成"乱来"。真正有用的随机生成是带约束的采样:数量在区间内、位置在可行区域内、相互之间保持最小间距、不侵占机器人出生点和目标点、不把通道完全堵死。约束设计得好,一百次随机场景跑下来能覆盖到各种难度;约束设计不好,一半的场景是废的。
4.1 采样区域的划分与禁入区设置
第一步是把世界划分成区域。至少要划出三类:可行采样区、禁入区、通道保留区。可行采样区是障碍物可以出现的地方;禁入区是机器人出生点周围一圈、目标点周围一圈,半径建议不小于机器人外接圆半径加安全距离,我一般取 1.0 到 1.5 米;通道保留区是连接出生点和目标点的走廊,障碍物可以进入但不能完全封死,实现上限制该区域内障碍物的密度或者保证至少有一条宽度大于机器人直径的通路。
具体实现时,用一个矩形列表描述可行区,采样时先在整个包围盒里随机取点,再判断是否落在可行区内、是否在禁入区内。这个"先随机再过滤"的方式比"直接在多边形内采样"好写,效率也够用,一般拒绝率不超过一半就不需要优化。
import random def sample_position(region, forbidden, min_dist, placed, max_tries=200): for _ in range(max_tries): x = random.uniform(region[0], region[2]) y = random.uniform(region[1], region[3]) if any(dist((x, y), f) < f[2] for f in forbidden): continue if any(dist((x, y), p) < min_dist for p in placed): continue return x, y return Nonemax_tries这个参数别设太大,设成 200 左右,返回 None 就说明这个区域在当前数量下已经放不下了,应该考虑减少数量或者扩大区域,而不是硬试。我见过有人设成 10000,结果场景生成阶段卡死,还以为是脚本有 bug。
4.2 数量、尺寸与分布的参数化
随机场景的价值在于批量覆盖,所以参数必须外置成配置,不能写死在代码里。我一般把这几项做成配置:障碍物数量区间、尺寸区间、形状权重、分布偏好。
| 参数 | 建议范围 | 说明 |
|---|---|---|
| 数量 | 8 到 30 | 太少测不出拥挤,太多大部分场景无解 |
| 尺寸 | 0.2 到 1.0 米 | 覆盖小型杂物到大型箱体 |
| 形状权重 | 方块 0.6、圆柱 0.3、其他 0.1 | 方块最常用,圆柱测绕行 |
| 最小间距 | 机器人直径的 1.2 倍 | 防止生成不可通行的窄缝 |
最小间距这个参数特别值得说。它太小,会生成机器人物理上过不去的窄缝,算法表现差但不能说明算法有问题;它太大,场景过于宽松,测不出拥挤环境下的表现。取机器人直径的 1.2 倍是个折中:既允许存在接近极限的通道,又保证理论上是可通过的。如果你的机器人带机械臂或者载货,这个系数要往上调。
数量上限也值得琢磨。三十个障碍物在十米见方的场地里已经很密了,再往上加,大部分随机布局会直接导致起点到终点无解,这时候测试的就不是避障能力而是"能不能找到路"。真要做高密度测试,更好的做法是固定一个走廊式的布局模板,在走廊里随机撒点,保证有解的同时提高难度。
4.3 场景的可复现性与批量回归
随机生成最大的副作用是不可复现。同一个脚本跑两次,场景不一样,出了问题没法复现。解决办法很简单:记录随机种子。每次生成前设定种子并打印出来,出问题的场景用这个种子一跑就能还原。
更进一步,我习惯把生成好的场景布局保存成一个清单文件,包含每个障碍物的类型、位姿、尺寸。这样回归测试的时候直接读清单,不依赖生成脚本,环境完全确定。清单文件的另一个好处是可以做增量:发现某个布局特别难,就把它加入固定测试集,长期保留。
批量回归的组织方式也有讲究。不要一次性生成一百个场景连续跑,而是分成几批:每批二十个左右,跑完统计成功率、平均耗时、最小安全距离这几个指标。指标异常的那批再去逐个看种子。这样既能看到整体趋势,又不会淹没在日志里。我在实际使用中的体会是,随机测试的价值八成来自"认真分析失败场景",而不是"跑了多少次"。
5. 三类障碍物混用时的几个实际问题
单独做每类都不难,混在一起就会出现新问题。这一节讲三个我实际遇到过、文档里基本不会提的情况。
5.1 动态障碍物与随机生成的时序冲突
如果场景里有随机生成的固定障碍物,又有移动障碍物,那么移动障碍物的轨迹必须基于"随机生成之后"的地图来设计,否则轨迹可能穿过随机障碍物。处理方式有两种:一是先随机生成静态部分,再根据生成结果计算动态轨迹,比如让移动障碍物沿着空闲区域走;二是把动态障碍物的轨迹也纳入约束,生成静态障碍物时避开动态轨迹的扫掠区域。
第二种更严谨但实现复杂,需要计算轨迹的扫掠体。第一种简单,适合轨迹本身是固定形状(比如在一条预设走廊里往返)的情况,只要保证走廊不被随机障碍物堵住就行。我一般在走廊区域设置一个更低的最大密度,从生成逻辑上保证动态轨迹可用。
5.2 障碍物数量上升后的仿真步长调整
障碍物一多,碰撞检测的计算量上升,如果仿真步长还是按空旷场景设的,会出现物理不稳定或者实时率暴跌。经验做法是:固定障碍物超过五十个、或者动态障碍物超过五个时,把物理步长适当放宽,同时减小每个步长的最大迭代次数。这两个参数要一起调,只调一个容易出现穿透或者抖动。
调完之后一定要验证:让机器人以最高速度撞向一个障碍物,看是否正常停下而不是穿过去。穿透是步长过大的典型症状。这个验证步骤别省,我吃过亏,场景调完帧率好看了,结果测试结果全是机器人穿墙,一晚上的数据全废。
5.3 障碍物视觉与碰撞不一致导致的误判
最后说一个隐蔽的问题。移动障碍物的视觉模型如果有偏移,比如视觉几何在 link 坐标系里有个平移,而碰撞体没有,那么看起来快撞上的时候实际还有距离,或者反之。这种问题在静态物体上不明显,动态物体上因为一直在动,很容易让人误判"避障算法有问题"。
排查方法很直接:打开碰撞体可视化,对照视觉模型看位置是否重合。不重合就检查视觉和碰撞的 pose 偏移。我的习惯是所有障碍物的视觉和碰撞都用同一个几何定义,不单独设偏移,需要调整外观就用材质,不动几何。这样牺牲一点视觉精细度,换来的是排查问题时少一个变量。
三类障碍物说到底服务于同一个目标:让仿真环境能真实反映算法的能力边界。固定障碍物把地图立起来,移动障碍物把时间维度加进去,随机障碍物把覆盖面铺开。参数怎么定、脚本怎么写都是手段,真正花时间的是想清楚"我要测什么",然后反过来设计场景。我自己在这件事上走过的最大的弯路,就是一开始追求场景好看、复杂,结果跑出来的数据根本没法归因。后来把场景拆小、每类单独验证、再逐步组合,反而效率高得多。