为什么单一规划永远不够
想象你请一位朋友穿过繁忙的办公室,帮你取一杯咖啡。在迈步之前,他会先在脑中描绘整条路线:出门、走过走廊、绕过打印机、进入茶水间。但他不会一步不差地照着这幅图走。一路上,同事从门口闪出,椅子被往后推,地上出现了水渍——他会实时调整,却始终不会忘记最终要去哪里。这正是移动机器人必须完成的把戏,也是本章的核心。
到目前为止你遇到的一切——把世界变成配置空间、区分自由空间与障碍空间、做碰撞检测,以及在 Dijkstra、A*、路标图或 RRT 之间做选择——都是在为一个能真正穿过杂乱多变房间的机器人做准备。难点在于:没有任何单一规划器能同时把这两件事都做好。在大地图上算一条有远见的路线很慢,而对一把移动椅子的瞬间反应又根本来不及思考整栋楼。
于是真实机器人用最聪明的方式取巧:它们同时运行两个规划器,以两种不同的节奏,再用一张不断刷新的危险地图把它们粘合起来。这套组合就是工程师所说的导航栈,而学会读懂它,正是本章所有内容的回报。
导航栈:分层的流水线
导航栈是一条标准的、分层的流水线,它把诸如「去茶水间」这样的目标转化为车轮指令。可以把它想成一支接力队:一名队员拿着地图,知道机器人在哪里;下一名决定走哪条路线;再下一名不断把这条路线塑造成安全、可达的运动;最后一名把原始的速度指令交给电机。每名队员都职责单一、可替换,正因如此,这种分层设计成了整个领域的默认做法。
这些层往往住在机器人操作系统(ROS)里——这是一套开源的「管道」,让各自独立的程序彼此对话。ROS 为每一层提供了标准的消息传递方式:这里传地图,那里传路线,末端输出速度指令——这样团队就能换上更好的局部规划器,而不必重写其余部分。它与其说是一个单一程序,不如说是一个由协作部件组成的集市。
深入之前先记一个用词。到目前为止,规划器产出的是一条路径——空间中纯粹的形状。而导航栈最终需要的是一条轨迹:同样的形状,但附上了时间与速度,好让车轮不仅知道往哪走,还知道走多快。控制层正是路径最终变成运动的地方。
两个规划器,两种节奏
整个栈跳动的心脏,是全局规划器与局部规划器之间的分工。它们沿着「时间与细节」这条轴线划分同一个问题:全局规划器是战略家,局部规划器是战术家。两者谁也替代不了谁。
全局规划器在整张已知地图上,算出一条从起点到目标的完整路线。它运行一次图搜索——通常是在地图的栅格单元上跑 A* 或 Dijkstra——产出一条又长又有远见的路径。它可以慢,因为只在目标改变、或路线被严重堵死时才需要重算。关键是,它只知道地图上已经画出的障碍;那个刚走到机器人面前的人,对它来说是看不见的。
局部规划器恰恰相反。每秒许多次,它只看机器人周围的一小块窗口,看见传感器刚刚报告的一切,不断重新规划一小段——既贴着全局路线,又躲开任何新出现的东西。因为它紧贴车轮,就必须尊重机器人实际的运动方式:像汽车那样的底盘无法横向平移,所以它的这一小段必须在动力学上可行。这正是完整约束与非完整约束之分在实践中的体现——这个约束,远在天边的全局规划器多半可以无视,而局部规划器永远不能。
代价地图:一张活的危险图
两个规划器真正在其上搜索的是什么?不是原始地图,而是一张代价地图——一个栅格,其中每个单元都带着一个数字,表示占据它有多危险、多「贵」。空地板代价为零;墙壁不可通行(代价相当于无穷大);紧贴墙壁那一圈单元代价很高,但不是无穷大。规划器于是在这片由数字构成的地形上寻找最便宜的路线,自然就偏好走廊空旷的中线,而不是擦着边缘走。
有两个想法让代价地图真正活了起来。第一是「膨胀」:每个障碍都被一圈逐渐升高的代价向外晕染开。这是代价地图记住「机器人不是一个点、它有宽度」的方式。把机器人的尺寸作为余量烤进地图,规划器就能继续把机器人当作一个点来处理,却永远不会擦到拐角。它是在配置空间里给障碍「长胖」那一招的实用表亲。
第二是「衰退」:读数会变旧。片刻前挡住门口的人已经走开,所以一处近来无人确认的传感器标记就该被清除,让那个单元重新变便宜。一张好的代价地图,会在传感器看见障碍的地方不断添加新障碍,又在传感器现在看见空地的地方清掉过时的标记。通常会有两张这样的地图——一张供全局规划器用、大而静态,另一张以机器人为中心、随之滚动的小地图供局部规划器用——两张都从同一条实时传感器数据流刷新。
GLOBAL LOOP (slow, e.g. on new goal): global_path = A_star(costmap_global, start, goal) LOCAL LOOP (fast, ~10-20x per second): costmap_local = update(sensor_scan) # add new, clear stale segment = best_feasible_arc_toward(global_path, costmap_local) cmd_velocity = to_trajectory(segment) # path + speed -> motion send(cmd_velocity)
它会在哪里出问题,以及接下来是什么
这套架构很结实,但它的失败方式是有迹可循的;认得它们,就等于用好它的一半。
- 局部极小值。反应式局部规划器可能把机器人引进一个死胡同——比如一圈摆成 U 形的椅子——在那里,每个方向看起来都比原地不动更糟,于是它僵住了。这正是势场方法臭名昭著的那个陷阱。常见的解法是让全局规划器重新选路,或触发一个恢复行为,比如倒车并原地转身。
- 代价地图滞后。如果传感器数据慢了,或地图更新晚了,机器人就会基于一幅已经过时的世界图行动——为一个早已移开的「幽灵障碍」急刹,或者更糟,漏掉一个真实的障碍。调节单元膨胀与衰退的快慢,是在「神经过敏」与「鲁莽」之间永无止境的权衡。
- 定位出错。整个栈都假设机器人知道自己在地图上的位置。一旦定位漂移,代价地图就会对齐到错误的墙上,于是一个本来完美的规划,反而把机器人开向一堵真实的墙。
前沿方向是软化这些层之间的硬边界。比起手工调好的规划器加手工设计的代价地图,基于学习的规划器训练单一模型,把原始传感器输入直接映射为运动,从经验或示范中同时吸收「找路」与「避让」。它们许诺在人群中做出更顺滑、更像人的导航——代价是更难验证、更难信任。就目前而言,「全局加局部」的分层栈仍是可靠的主力,而习得的方法多半是嫁接在它之上,而非取而代之。