凡是搞过机器人路径规划的人,最后基本都会撞上同一个坎:机器人不是质点。市面上大量demo演示的是“一个点怎么避让一圈圆”,但一旦落地到实际项目,车有长宽高,机械臂有形状,这时候再拿质点规划的结果去执行,轻则贴着障碍物蹭过去,重则直接卡死在半路。这篇从C-Space到A算法的多边形机器人避障路径规划研究,就是来解决这个问题的,配套的Matlab代码实现给出了从构型空间建模、网格化地图,到A搜索出可行路径的完整链路。C-Space(构型空间)是这套方案的灵魂,它把“机器人有形状”这件事提前消化掉,让后续搜索算法回到普通网格地图上运行。如果你正在做移动底盘、机械臂或者任何非质点机器人,而这套链路还没有完整跑通,这篇文章值得你从头到尾读一遍,尤其是后面几节的工程细节和排查思路,属于不跑几遍代码根本发现不了的那种。
1. 从几何地图到构型空间:避障问题的底层逻辑
1.1 为什么非要引入C-Space
先想一个最基本的问题:一张带障碍物的栅格地图,机器人是1米乘1米的正方形,障碍物是一个0.5米见方的箱子,机器人从左边地图走到右边,能不能直接把障碍物标成不可通行,然后用A*搜?
答案是不行。因为机器人有尺寸,它的中心点如果离障碍物太近,哪怕障碍物格子看起来和它没有重叠,机器人的“身体”也会撞上。反过来,机器人对障碍物的实际禁行范围,比障碍物本身要“胖”一圈。这一圈有多大?取决于机器人的几何形状和朝向。所以传统的做法是,把机器人的位形(位置加姿态)作为搜索空间,凡是会导致机器人本体与障碍物发生碰撞的位形全部标成障碍,剩下的才是机器人可以到达的区域。这个“机器人位形空间”就是C-Space,中文常叫构型空间。这就好比你搬一个大衣柜上楼,你不能只看衣柜的重心能不能走,你得看衣柜的四角会不会刮到楼梯转角,这个“占位范围”必须合并进地图里一起考虑。
为什么说C-Space是这类问题的最优解?因为一旦把机器人膨胀成障碍物边界,原来的“有形状机器人避障”问题就退化成了“质点避障”问题。你可以继续用A*、Dijkstra、RRT这些成熟算法,不用在搜索过程中反复做几何求交。省下来的计算量非常大,尤其是地图大、障碍物多的时候。
1.2 多边形机器人与障碍物的表示
模型里最常见的设定是:机器人本体是一个凸多边形,障碍物也是若干多边形。为什么特别强调凸多边形?因为凸多边形的碰撞检测和膨胀计算都有闭式解法。凹多边形一般先拆成多个凸多边形,或者直接取它的凸包做保守近似。在Matlab里面,多边形的存储格式就是一组按顺序排列的顶点坐标,例如:
robot = [ 0.5, -0.5; 0.5, 0.5; -0.5, 0.5; -0.5, -0.5 ];这是以一个参考点为原点构造的机器人轮廓,我习惯把参考点设在机器人的几何中心,后续所有坐标变换都以这个点为准。障碍物同理,也是若干多边形顶点序列,可能还有圆形障碍物,但在Matlab里可以离散成高边数多边形,统一走同一套计算。
1.3 网格化离散与分辨率权衡
C-Space本身是一个连续空间,但A*搜索需要离散节点。工程上最省事的做法就是把C-Space栅格化,每个栅格节点代表机器人参考点的一个可达候选位置。栅格分辨率直接决定两条路线的取舍:分辨率高,路径精度高,但节点数量爆炸,搜索慢;分辨率低,速度快,但可能漏掉窄通道。我自己的经验是,分辨率至少要让机器人本体占3到5个栅格以上,否则膨胀细节会丢失,容易造出实际上不可行的路径。比如机器人边长1米,栅格就取0.2米到0.3米左右,再小的栅格就可以考虑了,但那时地图尺寸如果超过几百米,节点数量就会非常可观。
2. 多边形机器人C-Space的构造与实现
2.1 位形参数化:先定姿态,再算空间
一个平面刚体机器人有3个自由度:x坐标、y坐标和朝向角θ。如果直接把三维空间栅格化,搜索节点是(x,y,θ),维数上去以后计算量成倍增长,这也是很多入门项目做不下去的原因。更合理的做法是把θ离散成若干个固定角度,比如0度、90度、180度、270度(或者更细的15度间隔),对每一个固定朝向分别算一张C-Space栅格图。多张图之间通过坐标变换或查表衔接,相当于把三维搜索降维成多层二维搜索。
这么做有一个显而易见的好处:每一层C-Space都可以像普通二维栅格一样被A*处理,工程实现难度低,直观。代价是分辨率太高时内存占用不小,比如地图是200乘200,朝向数取24,就是96万个节点,还是在可接受范围内的。
2.2 顶点扫过区域与Minkowski和
固定朝向之后,问题变成:给定一个形状固定的机器人多边形R和一个障碍物多边形O,机器人参考点在哪些位置会导致R和O相交?
这里最标准的数学工具是Minkowski和,直观理解就是“用机器人轮廓去扫过障碍物轮廓所覆盖的全部区域”。对于两个凸多边形,Minkowski和的边界就是把所有障碍物顶点和机器人顶点逐一相加再取凸包。更贴近工程的做法是“绕障碍物转一圈”:把机器人参考点沿着障碍物多边形边缘平移一周,机器人本体扫过的所有位置的边界,就是C-Space障碍区域的边界。
Matlab里可以用两步实现:先把机器人多边形关于参考点取反(每个顶点坐标取负),然后用polyshape做Minkowski和,或者直接基于顶点序列生成所有组合后用convhull求凸包。我自己常用的是后者的变体:
function cObs = minkowskiSum(obs, robot) % obs: 障碍物凸多边形顶点 n x 2 % robot: 机器人凸多边形顶点 k x 2 X = obs(:,1) + robot(:,1)'; Y = obs(:,2) + robot(:,2)'; pts = [X(:), Y(:)]; k = convhull(pts(:,1), pts(:,2)); cObs = pts(k, :); end这个函数对所有顶点做了加法组合,再取凸包。注意前提是机器人和障碍物都必须是凸的,如果障碍物是凹的,需要先拆分成凸多边形再逐块计算,最后union起来。
2.3 旋转带来的边缘膨胀问题
机器人不是圆形,它在不同朝向下扫出的边界不一样,所以每换一个θ都要重新算一遍膨胀。工程上最朴素的实现就是把机器人顶点先旋转θ角,再调用上面那个函数。旋转矩阵很简单:
rot = [cos(theta), -sin(theta); sin(theta), cos(theta)]; robotRot = robot * rot';当朝向数取比较少的时候,比如只有4个朝向,就会出现一个问题:相邻朝向之间存在“盲区”。机器人实际姿态介于两个离散朝向之间时,碰撞检测是缺失的。处理方式有两种:一是增加朝向数,二是对每个朝向的C-Space障碍区域再做一次半径等于机器人内切圆半径的膨胀,用保守覆盖弥补插值误差。我实测下来,取8个朝向加上少量膨胀,已经能覆盖绝大多数室内移动机器人场景。
2.4 把C-Space落成栅格图
得到连续多边形障碍区域后,需要用poly2mask或者inpolygon把它离散到栅格上。这里有一个容易踩的坑:poly2mask的行列坐标和图像坐标一致,但和地图坐标经常是反的,转换的时候容易绕晕。建议统一规定:地图坐标x向右,y向上,栅格矩阵的索引为(row, col)对应(y, x)。每次生成C-Space障碍矩阵时,先做坐标映射,再填充。
另外一个细节是机器人本身占用的区域也要标记成障碍,防止起点或终点落在机器人轮廓内。依次遍历所有障碍物多边形,对每个多边形做Minkowski和、旋转、栅格化,最后用逻辑或把所有障碍区域合并,就得到当前朝向下的完整C-Space栅格图。
3. A*算法在C-Space网格上的落地搜索
3.1 邻域、代价与启发函数
C-Space栅格图本质是一个带权图,每个栅格是节点,相邻可通行栅格之间有边。A*的实现门槛很低,核心维护两个集合:openSet(待扩展节点)和closedSet(已扩展节点),每条边从父节点到子节点的实际代价记为g,从子节点到终点的估计代价记为h,总评估值为f = g + h。说是经典,但细节里藏着不少性能差距。
邻域通常用8邻域,即上下左右加四对角。直线移动代价设为1,对角移动代价设为sqrt(2),这个必须区分,否则对角线会成为绝对优势方向,路径会走成锯齿。启发函数我建议直接用欧氏距离,不必非要曼哈顿,因为8邻域下欧氏距离既是可采纳的,又比切比雪夫距离更接近真实最短路径长度,搜出来的路径会更直:
h = norm(current - goal);如果地图上存在大量障碍物导致启发信息失真,可以考虑加一个很小的权重,比如f = g + 1.1 * h,让搜索稍微偏向目标方向。这个系数不能太大,不然A*会退化成贪心算法,容易搜出次优路径甚至死路。
3.2 数据结构和Matlab实现要点
Matlab写A*,性能瓶颈经常在openSet的维护上。最直接的写法是动态数组加find定位,但地图稍大就卡得无法直视。我建议至少用结构体数组加排序维护,或者引入heappush一类的手写二叉堆。如果不想手写堆,可以用Matlab的containers.Map做节点映射,配合sortrows定期整理,速度也能接受。
核心循环的结构大致如下:
nodes = struct('x', start(1), 'y', start(2), 'g', 0, 'h', 0, 'f', 0, 'parent', []); openList = nodes; closed = false(size(grid)); while ~isempty(openList) [~, idx] = min([openList.f]); current = openList(idx); openList(idx) = []; if current.x == goal(1) && current.y == goal(2) break; end closed(current.y, current.x) = true; for dx = -1:1 for dy = -1:1 if dx == 0 && dy == 0, continue; end nx = current.x + dx; ny = current.y + dy; if nx < 1 || nx > cols || ny < 1 || ny > rows, continue; end if grid(ny, nx) == 0 || closed(ny, nx), continue; end % 计算新g、h、f,更新或加入openList end end end这段代码只是骨架子,实际跑的时候要把父节点回溯、访问标记、跳过起点终点等逻辑都补全。还有一个小技巧:终点只要被扩展,就可以立即停止,而不用等openList清空,能省不少时间。
3.3 路径回溯与后处理
搜索完毕后从目标节点沿着parent指针回溯到起点,得到一串栅格路径。直接拿去用是能走,但很难看,因为栅格路径会有锯齿和斜穿角落的风险。后处理至少要做两件事。
第一是直线可见性简化:从起点开始,尝试连接后面的每一个节点,如果连线没有穿过任何C-Space障碍栅格,就把中间节点删掉。这个操作可以把路径压缩成一条由若干直线段组成的折线,长度更短,转弯更少。
第二是障碍栅格的“角落”检查。八邻域搜索默认允许机器人斜穿两个对角障碍之间的缝隙,但C-Space里这个缝隙很可能小于机器人尺寸。所以简化路径时,不光要看线段是否穿过障碍栅格,还要检查线段是否贴着障碍物边缘擦过去。最好对每个线段做稠密采样,逐个栅格检查碰撞,或者保留障碍物的多边形数据做精确的线段相交测试,这一步不能省。
3.4 多层朝向图的衔接
前面提到按朝向生成多张C-Space栅格图,搜索时如何处理多张图?最简单的方式是固定一个主朝向(比如起点朝向),在主朝向的C-Space上搜索一条路径,然后逐段检查机器人在该朝向时的实际碰撞情况,如果某一段不满足就切换到当地最优朝向重新搜索局部路径。
更系统的做法是扩展状态空间为(x, y, theta),A的节点变成三维,邻域不仅包括平面位置邻域,还包括朝向邻域。代价函数里给角度变化加一个权重,这样搜出来就是带朝向选择的完整路径。三维A的计算量比二维高一个数量级,但得到的路径可以直接喂给控制器,省去繁琐的局部规划器。我的建议是:先用二维搜索快速获得大概路线,再在路线周围的小窗口内做三维细化,兼顾速度和可靠性。
4. 仿真试验与参数对比实录
4.1 不同朝向数对C-Space形貌的影响
我拿一个1米乘1米的正方形机器人,在20米乘20米地图上放了几块墙壁状障碍物,分别测了4、8、16个朝向数。4个朝向时各层C-Space看起来“棱角分明”,因为旋转后的矩形扫出来的障碍边界夸张,窄通道被膨胀得几乎打不开。8个朝向时已经能基本还原通道形状,16个朝向时C-Space边界趋于平滑,但计算时间涨了近4倍。
如果机器人的形状接近圆形,比如六边形或八边形,朝向数的影响会小很多,4到8个够用。如果是长条形,比如AGV小车,朝向数少于16容易漏掉可行的侧向停车场入口。我的经验是先用8个朝向跑一遍,如果有路径但执行时频频碰撞,再加密到16或24,而不是一上来就全分辨率硬算。
4.2 网格分辨率对路径质量的直接影响
同样是20米乘20米地图,0.5米栅格时路径绕了一个很大的弧形,因为窄通道在低分辨率下已经被膨胀障碍物完全堵死。0.25米栅格时通道打开,A*找到了近路,路径长度缩短约18%。继续加密到0.1米,路径长度改善不到2%,但内存占用和搜索耗时翻了好几倍。
这说明在调参时先关注通道的“连通性”,路径长度是滞后指标。一个非常实用的经验是:每次改完分辨率,把C-Space栅格图用imagesc画出来人眼检查一遍,直接确认机器人参考点可以通行的区域是否和实际几何吻合,比看指标参数直观得多。
4.3 A*耗时与工程取舍
在我的测试中,200乘200的C-Space栅格图,A*用普通动态数组实现大概耗时1到2秒,把开列表换成二叉堆后降到0.3秒左右。地图到400乘400,差距进一步拉大,动态数组方案已经到十几秒,堆方案还在1秒内。对于需要实时规划的场合,可以考虑限定搜索区域,只在地图变化附近局部重规划,或者用JPS(Jump Point Search)剪枝,能在均匀障碍环境下提速数倍。
另外我测试了带成本不同地形的变体:把靠近障碍物的栅格设成高代价值,让路径自动与障碍保持安全距离。效果立竿见影,路径更“有安全感”,代价是路径会变长10%到20%,而且高代价区域设置太大会让A*找不到路径,需要反复调试权重。
5. 常见问题与排查技巧实录
5.1 C-Space边缘误判
最典型的问题是:C-Space生成的障碍区域在最外侧一行或一列没有正确标记,导致A沿着地图边界“贴墙走”。排查方法是在A跑完之后,检查路径节点序列和相邻栅格状态,如果路径点紧贴一个障碍区域,而该障碍区域又是地图边界引发的,多半是Minkowski和计算时没有把机器人参考点在地图外的位置排除掉。正确的做法是生成C-Space时把地图外区域也视为禁止区,或者在栅格化之后对边界做一次腐蚀。
5.2 A*搜索缓慢
如果确认栅格图不大但搜索依然很慢,先怀疑开列表操作。Matlab里最掩蔽的坑是动态扩数组,每次插入都触发整个数组拷贝。另一个坑是启发函数没起作用:h=0时A*退化成Dijkstra,在地图较大时扩展节点数量急剧上升。先打印每次迭代时的openList长度,如果稳定在一个高数量级,多半是启发函数失效或者权重方向反了。
5.3 路径反复横跳和贴障碍物
路径走锯齿通常是代价权重没调好,或者后处理里的直线简化没生效。我建议在代价函数里加入转向惩罚,比如路径方向变化一次加上0.2倍直线代价,路径会更平滑。如果路径贴着障碍物但没碰,主要是没有给安全余量,可以在C-Space障碍边界外再膨胀一圈,效果比事后平滑好得多。
5.4 速查表
| 症状 | 常见原因 | 修复建议 |
|---|---|---|
| 路径穿过障碍 | C-Space栅格化精度不足 | 提高分辨率或改用多边形精确碰撞检测 |
| 明明有通道却搜不出路径 | 分辨率太低或朝向数太少 | 加密栅格、增加朝向角度数 |
| 路径极度绕远 | 启发函数权重过大 | 权重降到1.0附近 |
| 搜索极慢 | openList数据结构不当 | 改用二叉堆或限制搜索区域 |
| 转角处刮碰 | 未做路径后处理 | 直线简化加密集碰撞采样 |
6. 最后一点实操体会
这整套流程我前前后后跑了不下十版,最大的感悟是:C-Space的建模精度决定路径规划的上限,A只是在这个上限内寻找最优解。很多人一开始把精力都放在优化搜索算法上,结果发现路根本是弯的,问题出在C-Space膨胀做得太粗糙。反过来,也有把C-Space算得无比精细、但A实现很粗糙的版本,光搜索就要卡好几秒,工程上完全无法接受。两者的平衡点,就是先按场景尺寸和机器人尺寸定好分辨率,再在保证通道连通的前提下尽可能降低栅格数量,最后才去优化搜索速度。
有条件的话,建议你把自己机器人的真实轮廓数据和几张实际环境地图导进去跑一遍。代码跑通之后,把C-Space栅格图、A搜索路径和真实地图叠加在一起可视化,一眼就能看出所有隐藏问题。我自己每次新拿到一个场景,都会先靠这层可视化把不合常理的设计错误揪出来,比调试一百行日志管用得多。如果后续想继续扩展,可以在这个基础上加入动态障碍物规避,或者把A换成D* Lite做局部重规划,底层这套C-Space建模思路是完全通用的。