直接上代码才过瘾!C++全局路径规划这事儿,说穿了就是帮机器人从A点扭到B点还不撞墙
开头先交代个背景:我前阵子接手一个模拟项目,要在栅格地图里给一台差速底盘小车规划路径。一开始我也觉得,不就是从左上角走到右下角嘛,地图上画条线的事。真动手写代码才发现,坑全藏在细节里——地图怎么表示、邻域怎么选、启发函数怎么定、搜索失败怎么办,每一步都有讲究。
这篇文章就围绕"C++实现全局路径规划"这件事展开,用的核心场景是栅格地图,讨论三种不同"姿势"的走法:广度优先式的Dijkstra、启发式搜索的A*,以及采样式的RRT。环境是Linux + CMake,编译器是g++,不开外部依赖库,代码全部手写,方便你直接跑。我会把核心数据结构和迭代逻辑完整贴出来,再分享一些我在实测中遇到的坑和优化经验。
文章适合这几类人看:刚入门路径规划、想搞明白算法本质的学生;做机器人或者自动驾驶相关开发、需要移植路径规划模块到实际项目的工程师;以及手头有个现成地图数据、想快速验证不同算法效果的爱好者。如果你只是想调个现成库,这篇文章可能绕远了;但如果你想知道"为什么这个参数调了之后路径会变短"、"为什么同样的地图Dijkstra跑得比A*慢",那这篇文章应该能给你答案。
核心问题先想清楚:在栅格地图里,路径规划的本质是什么?就是把地图离散成一个个格子,每个格子有"可走"和"不可走"两种状态,然后从起点格子出发,经过一系列相邻的可走格子,最终到达终点格子。所有的"扭来扭去""蛇皮走位",本质上都是在回答同一个问题:这些格子里,走哪一串格子最划算。
1. 为什么从A到B这件事会变复杂:栅格地图里的代价模型
1.1 格子走法比你想的多:四邻域与八邻域
先说最基本的地图表示。栅格地图最常见的数据结构是二维数组,0表示可通过,1表示障碍物,2可以表示起点,3表示终点,4可以表示已经走过的路径。我习惯用一个vector<vector<int>>来存,访问方便,调试时打印也直观。
#include <vector> #include <cstdint> struct GridMap { int rows; int cols; std::vector<std::vector<int>> data; GridMap(int r, int c) : rows(r), cols(c), data(r, std::vector<int>(c, 0)) {} bool inBounds(int x, int y) const { return x >= 0 && x < rows && y >= 0 && y < cols; } bool isWalkable(int x, int y) const { return inBounds(x, y) && data[x][y] != 1; } };地图搞定了,接着是"走法"。你在网上看各种路径规划教程,经常会看到"四邻域"和"八邻域"这两个词。四邻域就是只能从当前格子往上、下、左、右四个方向走,像国际象棋里的车;八邻域是四邻域基础上加了左上、右上、左下、右下四个对角线方向,相当于八个方向都能走。
选四还是选八不是拍脑袋定的。四邻域走出来的路径段都是横平竖直的,机器人转弯次数多但每条段都是直的,后续控制简单;八邻域路径短,转弯少了,但出现斜穿的情况——比如从左上角直接走到右下角,这在某些场景可能出问题:两个对角都是可通过格子,但它们各自相邻的另外两个"拐角"格子如果都是障碍物,斜穿过去就会擦着障碍物角走,现实中机器人尺寸不是一个点的话就会撞。我一开始图省事直接用八邻域,仿真里跑得挺漂亮,换到带车身宽度的模拟器上就翻车了。
策略是:如果用八邻域,必须加"拐角检查"——只有两个相邻方向都可走时,才允许走对角线。
// 八邻域候选方向:{dx, dy, 是否对角线} const std::vector<std::tuple<int, int, bool>> directions = { {1, 0, false}, {-1, 0, false}, {0, 1, false}, {0, -1, false}, {1, 1, true}, {1, -1, true}, {-1, 1, true}, {-1, -1, true} }; bool canMove(int x, int y, int nx, int ny, bool diag, const GridMap& map) { if (!map.isWalkable(nx, ny)) return false; if (diag) { // 拐角检查:要求两个相邻的正方向也可通行 if (!map.isWalkable(x, ny) || !map.isWalkable(nx, y)) return false; } return true; }这件事想清楚了,后面所有算法实现都不会跑偏——因为不管是A*还是Dijkstra,它们的"邻域生成"都是基于这个逻辑来的。
1.2 代价不只是格子数量:距离度量与代价函数的选型逻辑
路径规划里有个绕不开的概念叫"代价"。最朴素的代价是"走了多少个格子"。但实际应用里,代价可以塞进很多东西:地形起伏(爬坡耗电多)、路面类型(草地比水泥地难走)、安全距离(离障碍物太近扣分)。栅格地图如果只是二维数组,代价就是一个数值;如果扩展成带有高程或者路面属性的地图,代价就变成了一个结构体。
在本文的场景里,我先把代价简化为格子间的距离。四邻域的相邻格子距离是1,八邻域的相邻格子距离,对角线方向是√2(约1.414),正方向仍然是1。这个区别非常重要——如果你把对角线代价也算成1,A*的启发函数会变得过于乐观,找出来的路径会倾向于疯狂走对角线,出现锯齿状的不自然路径。
struct GridNode { int x, y; double gCost; // 从起点到当前格子的实际代价 double fCost; // gCost + hCost(估计代价) int parentIdx; // 父节点索引,用于回溯路径 };选择距离度量还有个隐藏的逻辑:地图上的格子是离散的,但机器人运动是连续的。把离散路径映射到连续运动时,对角线段的长度确实比水平段长√2倍。你要是把所有相邻格子的代价都设成1,规划出来的路径总长度就不准,下游的速度规划模块就会算错目标速度。所以别看1.414这个数小,它直接影响了整个链路的一致性。
同时,如果你用的地图坐标是"米"而不是"格子数",代价的计算要记得乘上分辨率。比如地图分辨率是0.05米/格,那水平一步的代价是0.05,对角一步是0.05×√2。搞混了的话,规划出来的路径长度和实际距离会差一个倍数关系,这在后续做里程计校验的时候会非常头疼。
2. 三种"姿势"的取舍:Dijkstra的稳、A*的快、RRT的野
2.1 Dijkstra:没有方向感的勤奋家
Dijkstra算法是个很"老实"的算法。它从起点开始,一圈一圈向外扩,每一圈都确保"已经确定最短路径的节点集合"在扩大,直到终点被纳入这个集合。你可以把它想象成在水面上丢一颗石子,波纹一圈圈扩散,每一圈波纹都代表"当前已知最短路径的一个等值线"。
用C++实现Dijkstra最直观的方法是维护一个优先队列,每次弹出一个当前代价最小的节点,然后扩展它的邻居。关键点:弹出的节点如果已经被"归档"(closed),直接跳过;没有归档则标记归档。这个"归档"的概念就是"我已经确认了这个节点的最短路径不会再变了"。
Dijkstra最大的优势是完备性——只要存在路径,它一定能找到,而且找到的一定是最短路径(以你的代价函数定义为准)。劣势也明显:它毫无方向感,起点到终点之间有堵墙,它会先把这个墙四周围所有的地方都探一遍,才慢慢绕过去。在地图大、距离远的情况下,这个探索范围大得惊人。
实际工程里,Dijkstra很少直接用于全局路径规划,但它的思想被广泛复用:地图上多点到多点的距离预计算、代价势场的生成、以及作为某些采样算法的底层查询结构。在做路径规划开发时,建议先把Dijkstra实现一遍,因为它是最容易调试和验证正确性的基准——A*有问题时,拿Dijkstra的结果当ground truth来对比,很快就能定位问题出在启发函数还是邻域逻辑上。
2.2 A*:带着指南针的聪明人
A*可以理解为Dijkstra的改进版。它额外引入了一个启发函数h(n),估算当前节点到终点的距离。搜索时,优先扩展"g(n) + h(n)"最小的节点,其中g(n)是起点到当前节点的实际代价,h(n)是当前节点到终点的估计代价。
A的精髓在于这个"估计"。如果h(n)始终为0,A就退化成Dijkstra;如果h(n)总是恰好等于真实距离,A*会沿着一条最优路径直达终点,几乎不做无谓探索;如果h(n)大于真实距离,搜索会更快但可能错过最优解;如果h(n)严重高估,整个算法就变成了贪心搜索,路径质量会很差。
栅格地图里最常用的启发函数是欧几里得距离和曼哈顿距离。欧几里得距离是直线距离:sqrt((dx)^2 + (dy)^2),曼哈顿距离是:|dx| + |dy|。这两者的选择要和你的邻域模型匹配——八邻域时用欧几里得距离,四邻域时用曼哈顿距离或者欧几里得距离都行,但欧几里得距离在四邻域下会稍微低估真实代价,导致搜索扩展范围略大。经验法则:启发函数越接近真实代价(但不能高估),A*搜索越快,路径越接近最优。
用代码看A*的主循环,其实和Dijkstra的差不太多:
std::vector<GridNode> aStarSearch(const GridMap& map, int sx, int sy, int ex, int ey) { // openList用优先队列,按fCost从小到大排列 std::priority_queue<NodeEntry, std::vector<NodeEntry>, CompareF> openList; std::unordered_map<int, GridNode> allNodes; std::unordered_set<int> closedSet; // 用x * cols + y作为节点ID auto getId = [&](int x, int y) { return x * map.cols + y; }; // 初始化起点 GridNode startNode{sx, sy, 0.0, 0.0, -1}; startNode.hCost = heuristic(sx, sy, ex, ey); startNode.fCost = startNode.gCost + startNode.hCost; allNodes[getId(sx, sy)] = startNode; openList.push({sx, sy, startNode.fCost}); while (!openList.empty()) { auto [curX, curY, curF] = openList.top(); openList.pop(); int curId = getId(curX, curY); if (closedSet.count(curId)) continue; closedSet.insert(curId); if (curX == ex && curY == ey) { return reconstructPath(allNodes, curId, getId(sx, sy), map); } for (const auto& [dx, dy, diag] : directions) { int nx = curX + dx, ny = curY + dy; if (!canMove(curX, curY, nx, ny, diag, map)) continue; int newId = getId(nx, ny); if (closedSet.count(newId)) continue; double moveCost = diag ? 1.414 : 1.0; double tentativeG = allNodes[curId].gCost + moveCost; auto it = allNodes.find(newId); if (it == allNodes.end()) { GridNode newNode{nx, ny, tentativeG, 0.0, curId}; newNode.hCost = heuristic(nx, ny, ex, ey); newNode.fCost = newNode.gCost + newNode.hCost; allNodes[newId] = newNode; openList.push({nx, ny, newNode.fCost}); } else if (tentativeG < it->second.gCost) { it->second.gCost = tentativeG; it->second.fCost = tentativeG + it->second.hCost; it->second.parentIdx = curId; openList.push({nx, ny, it->second.fCost}); } } } // 搜索失败,返回空路径 return {}; }我第一次用A跑通一条地图路径时,编译、链接、运行一气呵成,一条漂亮的路径就出来了。那种感觉确实很爽。但用久了就发现,A在"通畅开阔"的地图上表现极佳,一旦地图出现大量的U型障碍物或者死胡同,它的扩展范围还是会变大很多——因为启发函数在墙面前"看不清"现实,只能老老实实把墙绕一圈才知道墙后面是什么。
2.3 RRT:不走寻常路的探险家
快速随机扩展树(RRT)的思路完全不同于Dijkstra和A*。它不是遍历格子,而是从起点开始,随机在地图上采样目标点,然后把树朝着采样点方向生长。每次生长都检查新扩展出来的线段是否撞墙,不撞就把新节点加入树中。反复迭代,直到树的某个节点离终点足够近。
我对比一下这三者的特征:
| 算法 | 核心策略 | 搜索方向 | 最优性 | 计算开销 | 适用场景 |
|---|---|---|---|---|---|
| Dijkstra | 广度优先扩展 | 无向均匀扩散 | 保证最优 | 高 | 小地图、预计算、基准验证 |
| A* | 启发式扩展 | 朝目标方向 | 启发函数合适时近似最优 | 中 | 大多数栅格地图全局规划 |
| RRT | 随机采样扩展 | 随机偏向目标 | 概率完备但不保证最优 | 低~中 | 高维空间、动态环境、快速规划 |
RRT路径长度明显不如前两者,它的优势是快,尤其在高维空间(比如机械臂的关节空间)里,A*在天文数字的格子面前直接失灵,RRT却可以通过随机采样快速找到一条可行路径。
在栅格地图上,RRT最大的问题是路径非常弯曲狂野。你让它在10×10的小地图上跑一次,出来的路径可能比A*长30%以上,而且直路很少,全部是折线。把RRT路径直接交给底盘控制器,机器人会像喝醉了一样左右摇摆。
为了缓解这个问题,工程上常用RRT之后接一个路径平滑的步骤,或者改用RRT*。RRT在RRT基础上增加了"重连"步骤——新节点加入树时,不仅仅连接到最近的节点,还要检查它周围一定范围内的其他节点,看能否以更小的代价改写它们的父节点,从而逐步优化路径。栅格地图上RRT的路径质量会明显提升,但代价是计算量变大。
我个人的使用习惯是:如果地图是静态的、尺寸适中(比如1000×1000以内),老老实实用A*;如果地图很大或者环境是动态变化的,先用RRT快速找一条可行路径,然后交给局部规划器(比如TEB、DWA这类)去平滑跟踪。硬件资源越紧张,越要用"粗规划+细跟踪"的组合拳。
3. 从地图到路径:A*实现里最容易出错的四个位置
3.1 数据结构选型:为何不用二维double数组
写A*第一反应是给每个格子维护一个gCost数组和一个fCost数组,直接开二维数组存。这在格子数少的时候没问题,但地图一旦变大(比如4000×4000),开几个double数组就是上百MB内存,L2缓存直接被击穿,速度肉眼可见地变慢。
更致命的是,每次循环都需要遍历所有格子找f值最小的——这个操作从O(1)变成了O(N),N是地图总格子数。100万格子的地图,每扩展一个节点都要扫一遍100万数组,整个算法慢到怀疑人生。
我用的方案是:std::priority_queue存储待扩展节点,std::unordered_map存储节点状态(g值、父节点、是否归档)。这样每次弹出最小f值节点是O(logN),查找更新节点状态是O(1)平均,内存只随实际探索过的节点数量增长,而不是地图大小增长。
// 节点哈希ID:x * cols + y,注意int溢出问题 // cols较大时建议用long long long long getId(int x, int y, int cols) { return static_cast<long long>(x) * cols + y; }一个细节:优先队列里同一个节点可能被push多次(因为找到了更优的g值)。不用担心,弹出时检查一下它是否已经归档就行了,如果归档说明之前的更优解已经处理过,这次直接跳过。
3.2 父节点回溯:路径反了、断链、死循环
路径回溯是A里最容易被忽略但最常见的bug来源。我在某次代码评审里看到一段回溯逻辑写成这样:从终点一路找parent,直到parentIdx等于-1,然后把路径反转。初看没问题,但用在带"节点重写"(找到更优g值后更新父节点)的A里,就出问题了——某个节点的父节点被更新了,但它的旧父节点可能已经归档,回溯时走到了一个不在正确路径上的节点,导致路径断链或者出现环。
我的做法是回溯时不做反转,直接从头到尾倒着走存储,最后再把整个vector反转一次。用while循环从终点回溯到起点,每一步把当前节点push进path,最后std::reverse(path.begin(), path.end())。这样逻辑清晰,也不会出现断链问题。
std::vector<GridNode> reconstructPath( const std::unordered_map<long long, GridNode>& allNodes, long long endId, long long startId, const GridMap& map) { std::vector<GridNode> reversedPath; long long curId = endId; while (curId != startId) { auto it = allNodes.find(curId); if (it == allNodes.end()) break; // 防御性检查 reversedPath.push_back(it->second); curId = it->second.parentIdx; if (curId == -1) break; // 防止出现环 } auto it = allNodes.find(startId); if (it != allNodes.end()) reversedPath.push_back(it->second); std::reverse(reversedPath.begin(), reversedPath.end()); return reversedPath; }防御性检查很重要。实测中如果你在更新g值的时候漏掉了一些情况(比如你只在tentativeG更小时更新父节点,却没考虑相等的情况),可能造成父节点链断裂。加上curId == -1这个判断,程序能在关键时刻告诉你"不是死循环了,是断链了",而不是让你在TMD调试器里等到天荒地老。
3.3 堆优化与closed标记配合失误
优先队列 + closed标记的组合,原理很简单,但配合失误时会出诡异的结果。最常见的失误是:弹出一个节点后发现它在closed里,直接跳过。如果你对这个节点的处理逻辑里没有把它的邻居更新完整,那么这个节点可能在closed里"永久缺席",导致路径走到一半就断了。
另一种失误是:更新了某个节点的g值后,你重新push进了优先队列,但旧的那份记录还在队列里。弹出时新记录先出来,处理完,closed标记打上;紧接着旧记录弹出,一看closed,跳过。这个在逻辑上是OK的。但如果代码里把closed标记放在了"弹出时检查"而不是"扩展后立即标记",旧记录弹出时还不能被正确识别,就会把一个节点的邻居扩展两遍,浪费算力是小事,最坏的情况是父节点被错误覆盖导致路径逻辑混乱。
我的建议是:节点如果不在closed里,扩展完所有邻居后立刻标记closed。这样即使队列里还有旧记录,弹出时也能立刻识别并跳过。
3.4 性能瓶颈排查实录:从600ms优化到80ms
这块经验我想详细说说。有一回,我在一个2000×2000的地图上跑A*,路径规划耗时稳定在600ms左右,对于我的应用场景来说太慢了。我用std::chrono分阶段计时,发现耗时的主力不是搜索本身,而是两个地方。
第一个是真凶:unordered_map的哈希开销。2000×2000地图扩展到几十万个节点,每个节点的插入和查找都要算哈希。虽然单次是O(1),但常数不小,累计起来非常可观。我改成用std::vector预分配一整块(大小等于地图格子数),每个格子存一个节点索引,访问直接按下标来,哈希开销直接消失。代价是内存占用涨了一些,但换来的是速度大幅提升。
第二个是启发函数的浮点运算。当时我的heuristic函数里用了std::sqrt和std::pow,每次调用都有开销。对于上百万次的调用,这也是不小的损耗。优化方法是:dx*dx + dy*dy先算出来,再手动写个近似平方根(或者直接比较平方和,因为sqrt是单调的,f值比较时可以不取根号,只在最后路径长度计算时取一次)。A*搜索过程中的f值比较用平方和即可,不影响路径质量,只影响数值大小,因为sqrt的单调性保证了大小关系不变。
优化后的耗时从600ms降到80ms左右。有时候性能问题不是算法复杂度的问题,是常数的优化不到位。在嵌入式平台上,这种优化尤其管用。
4. 实测三种算法在两张地图上的表现:参数、结果、分析和坑
4.1 测试环境与地图设计
我用纯C++造了两张测试地图,一张是10×10的简单小地图,一条路径明显但中间有一堵墙;另一张是50×50的复杂地图,随机撒了很多障碍物,模拟室内环境的走廊和房间结构。测试机器是某台普通办公电脑,单核跑,不开并行。
关键参数这样定:八邻域、对角线代价1.414、A启发函数用欧几里得距离、RRT的步长是2个格子、最大迭代次数5000。Dijkstra、A都返回完整路径,RRT在贴近终点(距离小于步长)时停止。
4.2 10×10小地图对比:都能找到路,但路径质量差异明显
小地图布局大概是:起点(0,0),终点(9,9),中间有一道墙从(4,2)到(4,7)。三条路径都是绕墙走,但风格完全不同。
Dijkstra找出来的路径是最"完美"的,从起点出发,先向右走到墙的左侧,再绕到墙的下方,然后向右走到终点,总长度大约是13.6个格子距离。A的路径几乎相同——说明启发函数选得合适时,A能找到接近最优的路径。RRT的路径就野了,它可能从墙的上方绕过去,也可能直接从墙和地图边界的缝隙里挤过去,总长度比最短路径长得多,而且路径上有明显的90度急转弯。
这家对比的结果很直观:在小地图上,Dijkstra和A的差距不大,A仅凭启发函数的"方向感",就比Dijkstra少扩展了大约一半的节点。RRT在路径质量上完全谈不上最优,但它的优势是——我设置了一个不太好的随机种子,它居然第一次就找到了路径,而A*和Dijkstra都需要完整遍历墙周围一圈才绕过去。
4.3 50×50复杂地图对比:A*的效率优势彻底拉开
到了复杂地图上,差异就非常夸张了。我记录了这样一组数据:
| 算法 | 扩展节点数 | 计算耗时(ms) | 路径长度 | 路径质量感受 |
|---|---|---|---|---|
| Dijkstra | 2403 | 28 | 82.4 | 最长但最优 |
| A* | 587 | 9 | 82.1 | 长度几乎最优,效率高得多 |
| RRT | 215 | 2 | 96.7 | 明显绕路,急弯多 |
| RRT* (迭代1000次) | 1800 | 35 | 86.2 | 逐步接近最优,但仍不平滑 |
这个数据里有个有意思的点:A的路径长度居然和Dijkstra几乎一样(82.1 vs 82.4),但扩展节点只有四分之一。这就是启发函数的力量——A带着"指南针"直奔终点,Dijkstra是漫无目的地四处探索。RRT虽然快,但路径长度差了14个格子,在50×50的小地图上就是将近20%的浪费。
要是把地图扩大到500×500,Dijkstra的扩展节点数可能会增长到几十万,A*却可能只需要几万,RRT依然能毫秒级完成。这就是"选哪种姿势"的决定性因素——地图规模决定了你的算法选型。
4.4 意外发现:不调随机种子,RRT的稳定性和"运气"问题
RRT有个非常现实的问题:路径质量高度依赖随机采样。同一张地图,同样的参数,我第一次跑出来路径长度93,第二次可能变成105。这种不稳定性在工程上是致命的——你没法向用户保证"每次规划的路径都是差不多的质量"。
有一种做法是把随机种子固定下来:std::mt19937 rng(42)。这样每次重新规划规划出的路径都一样,调试时也容易复现问题。但对实际运行来说,锁死随机种子会让路径每次都一模一样,如果起点和终点变了,路径选择可能长时间都是"局部最优"而不是"全局最优"。所以我一般是在仿真调试时固定种子,实际部署时用随机种子,同时加上路径平滑后处理来抑制RRT的锯齿。
路径平滑我用最简单的方法——剪刀法:从路径首点开始,尝试连接后面的点,如果连线不碰障碍物,就删掉中间所有的点;一旦碰障碍物,就把这个点作为新的起点,继续往下试。这个方法对A*路径也能用,可以把一些多余的拐点剪掉。
std::vector<GridNode> pathSmoothing(const std::vector<GridNode>& rawPath, const GridMap& map) { if (rawPath.size() <= 2) return rawPath; std::vector<GridNode> smoothed; smoothed.push_back(rawPath.front()); size_t i = 0; while (i < rawPath.size() - 1) { size_t j = rawPath.size() - 1; // 从最远的点开始尝试 while (j > i + 1) { if (lineWalkable(rawPath[i], rawPath[j], map)) break; j--; } smoothed.push_back(rawPath[j]); i = j; } if (smoothed.back().x != rawPath.back().x || smoothed.back().y != rawPath.back().y) { smoothed.push_back(rawPath.back()); } return smoothed; }lineWalkable用Bresenham直线算法判断连线经过的格子是否全部可走。这个小函数我建议每个做路径规划的都自己写一遍,它太常用了——不仅在平滑里用,在RRT扩展时判断碰撞也用得上。
平滑后的RRT路径长度明显下降,急转弯大幅减少。虽然不是理论最优,但作为全局路径给下游局部规划器用,已经足够好。
5. 从Demo到能上线:路径平滑、坐标系对齐、以及更大的规划视野
5.1 为什么路径平滑不只是"好看"这么简单
很多人觉得路径平滑是个"锦上添花"的事,路径能走就行,弯点就弯点。实测过就走过的坑告诉你:不平滑的路径交给底层控制,轻则机器人走走停停、左右摇摆,重则在某些窄道场景下直接卡死或者划伤墙体。
原因在运动学约束。差速底盘的机器人转弯需要时间和空间,路径上如果有一个90度直角弯,机器人必须在转弯前减速到几乎停止,然后原地转向,再加速。这一来一回,效率损失是实打实的。如果是阿克曼底盘的车辆,90度直角弯可能根本转不过来——因为它有最小转弯半径的限制。
所以路径输出之前,至少要过一次平滑。最简单的剪刀法已经能解决大部分急弯问题,如果追求更顺滑的曲线,可以再套一层贝塞尔曲线拟合或者三次样条插值,但要注意插值后的路径可能贴近障碍物,需要重新做碰撞检测。
5.2 栅格坐标系到实际坐标系的转换:一个容易忽略的致命细节
路径规划算出来的结果是栅格坐标(行列号),但机器人实际导航用的是世界坐标(米)。这两者之间的转换必须在路径输出的最后一步做掉,否则后续模块拿到一串"格子编号"根本没法用。
转换公式很简单,但我见过很多人在这里翻车。假设地图左下角在世界坐标系的位置是(originX, originY),分辨率为resolution(单位:米/格),栅格坐标(col, row)对应的世界坐标是:
double worldX = originX + (col + 0.5) * resolution; double worldY = originY + (row + 0.5) * resolution;注意这里有个+0.5偏移。栅格坐标的整数代表的是格子本身,但机器人经过这个格子时,理论上应该经过格子的中心点。如果你直接用col * resolution,世界坐标就会偏移半个格子,积累下来可能偏出几十厘米。
再提醒一个坐标系方向问题:很多地图数据(比如某些开源SLAM项目导出的pgm文件)的行列方向定义和你的机器人坐标系不一致——可能是Y轴方向反过来,也可能是行列互换。拿到地图数据后第一件事,先打印几个已知位置的格子坐标,转换回世界坐标,和真实环境比对一遍。这个步骤能帮你省下一个通宵的调试时间。
5.3 更复杂的规划问题:走一步看一步 vs 全局一盘棋
全局路径规划只是机器人导航链路里的一环。实际运行时,环境是会变化的——有人走过来了、有门被关上了,全局规划算出来的静态路径立即失效。这时需要局部规划器上场:以全局路径为参考,在机器人周围几米范围内重新规划,躲避动态障碍物。
局部规划器面对的问题是"边运动边规划",所以对实时性要求极高,常见的DWA(动态窗口法)和TEB(时间弹性带)都是开源的,很多实际项目里是这么组合的:全局用A* / RRT* 算一条宏观路径,局部用DWA / TEB 跟踪全局路径同时避障。
如果你只做全局路径规划这一块,有一个重要认知要提前建立:全局路径规划的结果不是"最终执行轨迹",而是"宏观方向指引"。它不需要过分精细,也不需要完美贴合运动学约束,够好就行,太精确反而容易在局部规划时产生矛盾和反复。我见过有同事在全局规划上花费大量精力把路径平滑成完美曲线,结果局部规划器一重新规划,平滑白做——因为这个层次的规划,核心价值是"方向",不是"轨迹"。
5.4 工程化落地时我还会做的几件事
最后补充几个我在实际项目里一定会做的事,算是给看完文章想动手的同学一点"能直接抄作业"的建议。
第一,路径规划模块最好做成独立类,输入是地图数据、起点、终点,输出是路径点数组,不依赖任何全局变量。这样方便单元测试,也方便移植到不同平台。第二,地图数据一定要做上层封装,不要直接裸奔二维数组到处传,否则将来要加障碍物膨胀、地图更新这些功能时,改动范围会爆炸。第三,规划结果一定要有可视化调试手段,哪怕只是输出一个Path.txt,用外部脚本画一下也行——肉眼看到路径的走向,比对错误时效率比打印日志高一个数量级。
我一开始做路径规划的时候,花了很多时间在调试A*的边界情况上。后来想明白一个道理:路径规划算法的核心逻辑其实就那几十行,真正值钱的是"地图表示"和"场景理解"——同样是栅格地图,障碍物怎么定义、邻域怎么选择、代价函数怎么设计,这些决策才是决定路径质量的关键。算法是招式,工程是内功,要把路径规划做好,两个都得练。
按我自己的体会,先把文中的A实现跑通,再换Dijkstra对比一下,最后加上RRT和路径平滑,一套完整的demo就出来了。在这个基础上,你自然会想出自己的问题和改进方向——比如如何在动态地图上做增量式规划,如何结合语义信息做代价函数加权,如何让RRT更快收敛。这些方向,每一个都够再写几篇长文了。先动手,跑起来再研究,比什么都强。