去年调一台室内送餐机器人,地图上A规划出来的路径明明很正常,可机器人一过门就来回打方向,站在旁边看着都替它着急。后来我才意识到,问题根本不在A,而是我把离散的路径点直接喂给了底层速度控制器。那次之后,我把运动规划这条链路从头捋了一遍:A负责在地图上找出折线路径,RRT负责在连续空间里做同样的搜索,DWA负责在局部速度空间里实时避开障碍物,Minimum Snap负责把路径拟合成加速度连续的光滑轨迹。这四个算法基本覆盖了从离散搜索到连续优化的全过程,也是目前大多数自主移动机器人系统里的核心组合。这篇内容想把它们放在同一张图里讲清楚,适合刚接触导航、被“路径规划”和“轨迹规划”两个词绕晕的同学,也适合已经能跑通Demo、但想搞清楚每个模块到底该调什么的工程师。
1. 先搞清楚:路径规划不等于轨迹规划
1.1 四个算法在整条链路里的位置
很多人第一次接触运动规划时,最容易踩的坑就是把这几个算法当成互相替代的关系:A* 和 RRT* 都能“找路”,DWA 也能“走路”,Minimum Snap 看起来不过是在做平滑。但实际上,它们解决的问题根本不是一个层面上的。
路径,是一串不含时间信息的几何点序列,比如“从A点经过B、C两点到D点”。轨迹,则是在路径之上叠加了时间信息,不仅包括“经过哪”,还包括“什么时候到、到达时速度是多少、加速度怎么样”。A* 和 RRT* 输出的是路径,DWA 输出的是速度指令,Minimum Snap 输出的才是真正的轨迹。
从算法链路来看,它们的定位非常清晰:
| 算法 | 搜索空间 | 输入 | 输出 | 典型运行频率 |
|---|---|---|---|---|
| A* | 离散栅格图 | 栅格地图、起点、终点 | 离散路径点 | 0.1~1 Hz |
| RRT* | 连续状态空间 | 障碍物几何、起点、终点 | 折线路径 | 0.1~1 Hz |
| DWA | 速度采样空间 | 局部地图、局部目标 | 线速度、角速度 | 10~20 Hz |
| Minimum Snap | 多项式系数空间 | 航路点序列、时间分配 | 连续光滑轨迹 | 离线/在线均可 |
我在实际项目里,内部通常把 A* / RRT* 称为“前端规划器”,把 DWA 称为“局部执行器”,把 Minimum Snap 称为“后端优化器”。前端走低频,后端走高频,互相之间的数据通过航路点来衔接。这样分工有个明显好处:前端可以慢慢找最优解,后端必须每帧都算完,谁也拖累不了谁。
1.2 为什么采用“前端搜索 + 后端优化”的分层设计
很多人会问,直接让 A* 输出一条足够密的路径,再让底层控制器沿着点走不就行了?这种做法在小车原地转圈、速度极慢的演示场景里可能凑合,但一旦速度提上来或者路径里有急转弯,问题就非常明显。
原因在于,A* 走出来的路径是折线段,在每一个拐点处路径方向发生突变。对底层控制器来说,方向突变意味着期望速度方向瞬间变化,但机器人有质量和转动惯量,速度不能突变。于是控制器会先减速到接近零,再转向,再加速,表现就是“一顿一顿”的。如果控制周期短、增益高,还可能出现振荡甚至超调。
所以工程上普遍的做法是分层:离散搜索解决“能不能到达”的组合问题,连续优化解决“怎么走得稳”的可行性问题。这就像我们人走路时,大脑先规划“绕过那个水坑往右边走”,这是路径级决策;至于每一步迈多大、脚抬多高、重心怎么移,这是轨迹级控制,大脑皮层和小脑分开干活,不在同一个尺度上处理。
把这种分层思想落到代码层面,就是常见的三层结构:
- 全局规划层:负责在较大范围内找一条无碰路径,允许耗时较长。
- 局部规划层:加载全局路径上的某一段作为参考,结合实时传感器信息避障,输出速度指令。
- 轨迹优化层:在局部决策之上做平滑,或者直接替代局部规划器输出的原始速度曲线。
其中Minimum Snap 可以和 A* 串接,也可以放在局部规划之后。比如在无人机场景里,路径搜索得到折线路径后必须做Minimum Snap,因为飞控需要连续可导的位置输入,折线路径在导数上不存在,直接使用会对飞控造成极大的冲击。
2. A* 与 RRT*:两种主流的全局搜索思路
2.1 A* 的工程细节:启发式搜索为什么能在栅格地图上高效
A* 的本质是在一个有向图里做带启发信息的图搜索。它维护两个集合:open list 存放待考察节点,closed list 存放已确定代价的节点。每个节点有两个值:g 代表从起点到当前节点的实际代价,h 代表从当前节点到终点的估计代价,f = g + h。每次从 open list 里取 f 最小的节点展开,直到到达终点。
够基础,但工程里真正决定 A* 效率的不是这套框架,而是几个细节:
启发函数必须满足“一致性”才能让 A* 保证最优。在栅格地图上,如果允许 8 邻域移动,那么欧氏距离是对角距离的近似;如果允许 4 邻域移动,则曼哈顿距离更合适。我见过不少代码直接用欧氏距离做 4 邻域地图的启发函数,结果搜索效率严重降低,因为 h 值偏低,导致展开的节点远多于必要数量。
地图膨胀这一步几乎不能省。机器人不是质点,有实际宽度。在把地图交给 A* 之前,应该以机器人半径加安全距离作为半径做障碍物膨胀。我踩过的坑是:直接用原始栅格地图给 A*,规划出的路径贴着墙走,局部规划器稍微有点误差就直接撞墙。膨胀半径还要和栅格分辨率匹配,膨胀层至少覆盖 3~5 个栅格,否则膨胀效果被离散化吃掉。
栅格分辨率的选择也很关键。分辨率太细,地图内存和搜索时间爆炸;太粗,路径质量差,无法穿过狭窄通道。我做室内轮式机器人时常用 0.05m 分辨率做代价地图,但用于全局路径搜索时会把栅格降采样到 0.1~0.2m,搜索速度快得多,再用后续的轨迹优化把路径拉平。
如果对实时性有要求,A* 还有一个性价比很高的加速手段是 JPS(Jump Point Search)。核心思想是在可通行区域中剪掉大量“对称路径”,只在拐点处扩展节点。在开阔环境下 JPS 可以比普通 A* 快一个数量级,而且不损失最优性。在一张 2000×2000 的栅格地图上,普通 A* 可能要搜索几十万节点,JPS 可能只展开几千个,效果非常明显。
2.2 RRT*:在连续空间里渐进逼近最优路径
A* 依赖离散栅格,这在二维平面环境里很自然,但放到高维就麻烦了。机械臂有六七甚至更多自由度,状态空间维数高,栅格离散化要么内存爆炸,要么分辨率低到不可用;无人机在三维空间中飞行,障碍物也不能总用栅格模型描述。这时采样类算法是更合理的选择。
RRT 的基本流程是:随机采样一个状态,找到当前树中离它最近的节点,然后沿着两点连线方向扩展一段距离,如果这段路径没有碰撞,就把新节点加入树中。反复迭代,直到树连接到终点。
RRT* 的改进在于两点:choose parent 和 rewire。每次插入新节点时,不再直接把最近邻作为父节点,而是在新节点附近一个半径内的所有节点中,找一个能让路径代价最小的父节点,这叫 choose parent;随后检查这个新节点是否可以作为附近节点的父节点来缩短它们的路径代价,叫 rewire。正是这两个操作让 RRT* 具有渐进最优性。
我用一个简化版伪代码来展示核心逻辑,方便理解:
def choose_parent(new_node, near_nodes): best_parent = None best_cost = float("inf") for node in near_nodes: if not collision_free(node, new_node): continue cost = node.cost + distance(node, new_node) if cost < best_cost: best_parent = node best_cost = cost return best_parent def rewire(new_node, near_nodes): for node in near_nodes: if not collision_free(new_node, node): continue new_cost = new_node.cost + distance(new_node, node) if new_cost < node.cost: node.parent = new_node node.cost = new_cost为什么要反复调用这两个函数?因为 RRT* 的“最优”不是一次搜索就能保证的,它随迭代次数增加逐步收敛。实际工程中,我们通常设定最大迭代次数或搜索时间上限,比如 1 秒到 5 秒,取当前已找到的路径中最短的一条作为输出。
RRT* 的参数直接影响路径质量。步长设得太短会造成树增长过慢,我一般设为环境特征尺寸的 5%~10%;目标偏向概率很关键,设得过高容易让搜索退化到贪心算法,我常用 5%~10%。对于 rewrie 半径,随着节点数增加理论值应该按 \( r = \gamma (\log n / n)^{1/d} \) 衰减,但实际工程里很多人直接固定一个常数,比如 1.5 到 2 倍步长,效果也足够好。
2.3 A* 和 RRT* 到底该选谁
有一种误解是 RRT* 一定比 A* 高级,其实不对。A* 在有栅格地图的二维环境里可以给出确定性的最短路径,这对扫地机、AGV 这类场景非常重要;RRT* 的优势在于不用栅格化、支持高维、支持复杂约束,但它的搜索结果有随机性,而且在实际使用中要想达到与 A* 相当的路径质量,通常需要额外的后处理。
我自己做选型时会看三个条件:维度、环境表示方式、对最优性的要求。如果环境已经给成栅格地图,且维数不超过三维,我优先选 A*;如果环境是几何体表示、或者维数高于三维,我优先选 RRT*;如果有全局最优的强需求且算力充足,我甚至会把两者结合,先用 A* 在粗分辨率格子上找一条初始路径,再用 RRT* 在初始路径附近局部优化。
3. DWA:在速度空间里做局部避障
3.1 动态窗口法的搜索空间与轨迹预测
DWA 全称 Dynamic Window Approach,直译是动态窗口法。它跟前两个算法最大的不同是:它不去搜索位置空间,而是直接在速度空间里穷举可能的 (v, ω) 组合。
为什么叫“动态窗口”?因为每一步采样时,可行速度不是整个速度区间,而是“当前速度在给定时间周期内能到达的速度区间”。假如当前线速度是 0.5m/s,最大线加速度是 1m/s²,控制周期是 0.1s,那么下一时刻可行的线速度就在 [0.4, 0.6] m/s 之间。这个由电机物理性能决定的窗口,随当前速度变化而变化,所以叫动态窗口。
DWA 的采样空间至少要满足三层约束:
- 速度边界约束:v 和 ω 不能超过机器人硬件允许的最大值。
- 加速度约束:速度变化量不能超过电机的加减速能力。
- 安全观测约束:以当前速度行驶时,必须在碰到障碍物之前能够刹住,也就是 \( v^2 / (2a) \) 要小于到障碍物的距离。
对每一个采样出来的 (v, ω),DWA 会假设机器人在预测时间内沿圆弧运动,生成一条预测轨迹,然后检查这条轨迹是否碰撞障碍物,最后用评价函数打分,选最优的一组速度下发。
我常用的预测参数是:预测时间 2~3 秒,v 采样分辨率 0.01m/s,ω 采样分辨率 0.1rad/s。这样一轮采样大约几百个组合,在嵌入式控制器上也能跑 10Hz 以上。需要注意的是预测时间太长会过度依赖于当前传感器数据,容易误判;太短又看不到前方障碍物,我一般根据机器人最大速度和刹车距离来定。
3.2 评价函数的三个权重怎么调
DWA 的导航行为完全由评价函数决定,一般包含三个项:
- 方位角偏差:预测轨迹末端朝向与目标方向的夹角,越小越好。
- 障碍物距离:预测轨迹与障碍物的最小距离,越大越好。
- 速度大小:总速度越接近期望速度越好,鼓励机器人别磨蹭。
每个项会先做归一化,再乘权重相加。网上常看到的heading=0.8, dist=0.1, velocity=0.1这类参数只能作为起步值,实际调试时必须按照机器人的表现来调整。
我的经验是:如果机器人总在障碍物前犹豫不决、频繁转向,说明障碍物距离项权重过高,或者安全距离阈值设得太大;如果机器人总是冲到障碍物附近才急刹车,说明障碍物代价太低;如果机器人绕远路、偏离全局路径,则需要调高“朝向目标”之外的“全局路径跟踪项”——很多工程实现里会额外加一项“到全局路径最近点的距离代价”,这几乎是我解决 DWA 绕路问题的首选手段。
还有一个非常实际的坑:目标点刚好在机器人正后方时,角度误差会从很小的负角度跳变到很大的正角度,导致机器人原地摇摆倒退。原因是角度值在 ±π 处不连续,计算误差时必须先做 wrap 处理。我在代码里永远先写一个normalize_angle()函数,把任意角度映射到 [−π, π],再算误差。
DWA 本质上是个局部算法,它对全局地图一无所知,因此面对 U 形障碍物或者很长的走廊时非常容易陷入局部最小值。工程上不能只靠调权重解决这种问题,正确姿势是配合全局路径做参考:让局部目标点始终取全局路径上的某个下一点,而不是直接指向最终目标,这样即使局部路径被障碍物挡住,DWA 也会贴着全局路径恢复。
4. Minimum Snap:把离散路径变成连续光滑的轨迹
4.1 为什么要最小化加加速度的导数
Minimum Snap 这个名字里的 Snap 指位置的四阶导数,也就是“加加速度(jerk)的导数”。如果大家开车时追求“乘客不晕车”,关心的是加速度的变化率,也就是 jerk;而 Snap 则是 jerk 的变化率,它对应到电机执行层面就是力矩的剧烈抖动。
为什么要在轨迹优化里最小化它?一个很重要的原因是执行器的平滑性需求。对于四旋翼这类高动态平台,姿态和推力与轨迹的二阶、三阶、四阶导都有耦合关系,不控制高阶导数意味飞行器会一直处于“猛拉”状态,不仅能耗高,飞行质量也差。对于机械臂,最小化 Snap 可以减少关节扭矩波动,延长谐波减速器寿命。
Minimum Snap 的数学形式很简单:我们要求解一条分段多项式轨迹,使它的四阶导平方积分最小。如果只要求到三阶导,就是 Minimum Jerk,很多移动底盘项目用 Minimum Jerk 就已经足够。目标函数写成:
[ J = \int_{0}^{T} \left( p^{(4)}(t) \right)^2 dt ]
其中 \( p(t) \) 是位置轨迹。这个目标函数是二次的,约束是线性的,因此整个问题是一个标准的二次规划 QP,求解非常成熟可靠。这也是 Minimum Snap 能在实际系统里大规模使用的根本原因:它不光好写好算,而且在数学上有可证明的平滑性保证。
4.2 从航路点到 QP 问题的构造
构造 Minimum Snap 轨迹的完整步骤,我在项目里通常走五步。
第一步,拿到航路点序列。这些点可以来自 A* 输出、人为指定或上层任务规划。拿到后我会先做一次冗余点剔除,把共线或距离过近的点合并,否则每增加一个航路点就增加一段多项式,计算量变大,轨迹也可能在无意义的小波动上浪费能量。
第二步,确定每段轨迹的时间分配。先按各段路径长度比例做一个初始分配,再根据执行器速度上限做一次缩放,得到一个“大概合理”的 \( T_1, T_2, \ldots, T_M \)。
第三步,选择多项式阶数。Minimum Snap 一般用 7 阶多项式,Minimum Jerk 用 5 阶多项式。阶数不是拍脑袋定的,它需要满足所有约束后仍有余量:每段多项式有 8 个系数,要满足位置、速度、加速度、加加速度的端点约束和段间连续约束,7 阶是刚刚有拓扑余量的选择。
第四步,构造目标函数中的 H 矩阵。每一段轨迹的目标函数是对四阶导平方做积分,把每一段的多项式系数向量记为 \( p_k \),目标函数可以写为 \( p_k^T H_k p_k \),其中 \( H_k \) 由时间段 \( T_k \) 和各阶系数积分关系计算得到。把所有段的 H 矩阵拼成一个大 block-diagonal 矩阵,这就是 QP 里的代价矩阵。
第五步,构造线性约束。约束包括:每段起点和终点位置固定、段与段连接处速度/加速度/加加速度连续、起点终点速度加速度给定(通常为零)。这些约束全部可以用形如 \( A_{eq} p = b_{eq} \) 的等式方程表示。
在实际求解时,我并不建议自己手写 QP 求解器。工程中最常用的是 OSQP、qpOASES,它们都有 C/C++ 版本,也可以在 ROS 里直接集成。仿真阶段也可以用 Python 的 scipy.optimize.minimize,只是这种通用求解器在大规模问题上效率不如专用 QP 求解器。
写代码时有一个很小的但坑很多的地方:多项式系数在数值上非常病态,时间越长系数绝对值越大。解决办法是做时间归一化,每段都用 \( \tau = t / T_k \) 替换原始时间变量。如果不做归一化,即使只用 5 阶多项式,T 等于 10 秒时系数也容易差到 8 个数量级,常规 QP 求解器会直接报数值错误。
4.3 时间分配:最容易被忽略的生死线
Minimum Snap 里最常被忽略的参数不是多项式阶数,而是时间分配。同一个航路点序列,时间给得不一样,轨迹形态会差很多。
如果各段时间给得太短,等价于要求机器人极短时间走过很长的距离,优化器只能拉高速度,随之而来的是加速度和加加速度大幅超限,执行器根本跟不上;如果时间给得太长,轨迹会变得非常平缓,效率低下,在狭窄空间里反而容易产生诡异的绕弯,因为多项式为了满足平滑性宁愿走更远的路。
我在项目中实际用过的初始化方法是:每一段的时间按路径长度比例分配,并假设一个期望平均速度 \( v_{ref} \),比如室内轮式机器人取 0.5m/s,飞行器取 3m/s。初值给完之后,跑一次 Minimum Snap,检查各段的速度峰值和加速度峰值是否超出执行器限制,超限就把对应段时间拉长 20%,再跑一次。两三轮迭代之后基本能得到一组可执行的时间分配。
如果要做更严格的轨迹优化,可以把时间分配当作优化变量加入问题,但这样目标函数不再是简单的二次型,求解难度会明显上升。实际部署中我尽量先尝试固定时间分配的迭代法,绝大多数场景都够用。
另外一个工程要点是,Minimum Snap 只约束航路点附近的位置,不约束航路点之间的区域。如果障碍物在航路点之间,轨迹完全可能从障碍物内部穿过去。解决办法是构建一个凸的安全走廊(Flight Corridor),把每一段轨迹的中间位置约束在某个无障碍凸多面体内,然后再在 Minimum Snap 的 QP 中加入这些不等式约束。这一步是从“仿真小样”走向“真实部署”的分水岭。
5. 工程实战:常见问题与排查速查手册
5.1 轨迹抖动、过冲、加速度超限
这是我在做 Minimum Snap 时遇到最多的一个现象:轨迹曲线看起来很平滑,但机器人走起来加速过猛,甚至在某个点出现明显的“抻一下”的感觉。
排查时先从数据入手,把每一轴的速度、加速度、加加速度曲线打印出来,找出超限的具体位置。常见的几个原因和对应的解决手段我整理成一个表:
| 现象 | 可能原因 | 解决手段 |
|---|---|---|
| 速度峰值集中在某一段 | 该段时间分配过短 | 拉长时间或重新分配 |
| 加速度整体偏大 | 平均速度目标设置过高 | 降低参考速度 |
| 尖峰出现在航路点附近 | 冗余点未剔除 | 合并过近航路点 |
| 轨迹整体振荡 | 多项式阶数不足或约束多了冲突 | 提高阶数或去掉弱约束 |
| 求解器报数值错误 | 未做时间归一化 | 使用归一化时间 \( \tau \) |
还有一类抖动来自规划层与执行层频率不匹配:Minimum Snap 输出轨迹有 50Hz,但底层控制器只有 20Hz,相当于控制器每个周期都在跳目标点。这时不一定要改控制器,也可以对轨迹做等间隔重新采样后再下发给控制器,效果立竿见影。
5.2 DWA 局部规划与全局路径不匹配
DWA 常见问题中,最影响使用体验的是“规划出来的局部路径和全局路径打架”。比如全局路径明明贴着走廊右侧走,DWA 为了避障从左边绕了一下,然后就一直回不到右侧,结果在较宽走廊里走成 S 型。
这种问题有几种处理思路。先检查局部代价地图和全局地图是否对齐,尤其要确认 SLAM 的坐标变换没有漂移;再检查全局路径本身的参考点选择逻辑,每帧都应该取当前机器人位置前方 1~2 米处的路径点作为局部目标,而不是取最近的点;最后再调评价函数里的全局路径跟踪项权重。
我在调车时最常用的一个实操技巧是:把 DWA 的障碍物距离项先单独设为 0,让机器人明显表现出“直线冲向目标”的行为,确认代价地图和坐标变换正确;再把障碍物项权重从零开始逐步加回,每次只加一点点,直到避障行为出现。这种“从极限往回收”的调法比从网上抄一组权重靠得住得多。
5.3 四个算法怎么组合选型
很多人在项目一开始就想着把四个算法全部接上,以为算法越多越可靠。实际上每多一个模块就多一组参数和一串故障源。我更推荐按机器人形态和应用场景决定组合:
| 应用场景 | 推荐组合 | 理由 |
|---|---|---|
| 扫地机器人/室内轮式服务机器人 | A* + DWA | 计算量小,地图栅格化天然匹配 |
| 仓储 AGV 产线搬运 | A* + TEB | TEB 对路径跟踪的平滑性更强 |
| 室外地面无人车 | RRT* + DWA/MPC | 环境无法栅格化,连续空间更合适 |
| 无人机/机械臂 | RRT* + Minimum Snap | 需要高动态平滑轨迹,支持高维空间 |
个人经验是,第一次搭建系统时先用“A* + DWA”跑通完整闭环,至少稳定运行一周没问题,再考虑加入 Minimum Snap。如果一开始就把 Minimum Snap 接上,遇到问题你很难判断是搜索阶段选点不好,还是优化阶段时间分配不对。
还有一点关于重规划的冷却时间:全局路径规划不建议每帧都重跑,既浪费算力,又容易因为地图噪声让路径在两次规划之间来回跳动。我在实际代码里会给全局规划设一个冷却时间,比如 2~5 秒,只有机器人偏离全局路径超过阈值时才触发重规划,其余时间沿用旧路径,稳定性会显著提升。
如果硬要说这几年在运动规划上最深的体会,大概是:先把“离散路径”和“连续轨迹”这两层彻底分开理解,再用最小能跑的方案验证整条闭环,最后才是逐步加复杂度。调 DWA 就先看速度波形,调 Minimum Snap 就先看加速度曲线,别在公式里埋头太久,车是要跑起来的,多观察数据比多背公式有用得多。我自己第一次把 Minimum Snap 接到 A* 后面时,最大的坑就是时间分配,后来养成先看速度曲线再调参数的习惯,效率翻了不少。希望这篇内容能让你少走几段我当年走过的弯路。