1. 全覆盖路径规划的本质:不是“找一条路”,而是“铺满一张图”
做移动机器人路径规划的人,最先接触到的算法大概率是A*:给定一个起点和一个终点,A*能在地图上找到一条避开障碍物的最短路径。但如果你做的是扫地机器人、割草机、洗地机、农业植保无人机这类设备,问题就完全变了一个样:它们不是从A点到B点,而是要把一整片工作区域完整地覆盖一遍,不能有大面积漏扫,也不能绕太多冤枉路。这就是全覆盖路径规划(Complete Coverage Path Planning,简称CCPP)。
我前阵子接了一个模拟割草机作业的小项目,场景是栅格化后的二维地块,草地图里散布着几块障碍物区域,比如树、花坛、石头。任务很简单:让机器人在不重复覆盖的前提下,把整个可行走区域“扫”一遍。拿到题目第一反应就是用A找路径,但真动手才发现,普通A是解决不了这个问题的——它本质上是个“点到点”的优化算法,而全覆盖是一个“面到面”的规划问题。
这篇文章把我当时的完整思路、Matlab代码框架,以及调试过程中踩过的坑都写出来了。核心方案是用“往返式扫描”作为主线覆盖策略,只在遇到障碍需要跨区域转移时调用A寻路。这样搭配的好处是:往返式保证覆盖的连续性和低重复率,A负责用最短路径把机器人从当前死胡同引导到最近尚未覆盖的区域,兼顾效率与完整度。无论你是课程作业、竞赛还是实际项目,这套思路都能直接迁移。
1.1 普通A*为什么解决不了全覆盖问题
先说一个很反直觉的结论:如果你直接套用A*让机器人“从起点遍历所有未覆盖点”,计算量会爆炸,而且路径质量很差。因为全覆盖路径规划本质上是一个类似“旅行商问题”的组合优化问题,地图上可能有成千上万个栅格点,直接在这些点之间做全局路径搜索,状态空间太大了。
更重要的是,A搜索的是“最短路径”而不是“覆盖轨迹”。机器人每次移动一步,它不仅是在走路径,同时也是在生产覆盖效果。如果只优化行走距离,很可能会出现反复横跳、局部密集扫描但其他区域漏掉的情况。实际做项目时,我们通常把全覆盖问题拆成两层:第一层决定覆盖的“宏观顺序”,比如先扫哪块、后扫哪块、遇到障碍后跳到哪里;第二层才是在具体转移点之间用A这类算法找到一像素级的最短可行路线。
所以这篇文章的做法是:用往返式算法解决宏观顺序问题,用A解决微观跳转问题。主循环负责“接下来往哪走”,A负责“如何安全地走过去”。这样既控制了复杂度,又保证了结果接近最优。
1.2 “往返式”策略为什么是工程上的常用解
“往返式”这个名词听着陌生,但你看一眼扫地机器人的工作方式就明白了:它沿着一个方向直线清扫,到边界后旋转180度,在相邻行反过来扫,一次次来回,就像老式打字机或者牛在耕地。这种方案最早就是模拟农业耕作,英文叫Boustrophedon,意思是“牛走的路径”。
为什么这个策略在工程上这么流行?因为它大幅降低了“决策维度”。如果机器人沿直线走,只需要决定什么时候换行、换到哪一行,而不用在每个栅格点都重新规划方向。对于大多数近似矩形的连续区域,先按行扫、到边界换行,天然就能覆盖完整,而且路径转向次数少,重复覆盖率也低。
但真实地图不会永远那么规整。当一行中间出现障碍物时,机器人沿着当前方向被挡住,它不能直接穿过去;更麻烦的是,有时候前方整段都已经是覆盖过的区域,机器人必须决定是原路返回、翻越到下一行,还是跳到地图上另一块孤立的未覆盖区域。这时候才轮到A出场。在往返式覆盖的框架里,A不是用来逐格寻路的,而是充当“区域转移导航模块”。这也是我最后选择这种混合方案的核心理由。
2. 网格地图建模与A*子导航模块:先把底层轮子搭好
在Matlab里写全覆盖路径规划,第一步不是写主循环,而是处理地图。因为后面所有的覆盖逻辑、A*搜索、覆盖率统计,都基于一个干净的栅格地图表示。
2.1 栅格地图与障碍物表达
我用的地图是一个二维矩阵,大小为Nrow x Ncol。矩阵元素取0表示自由栅格,取1表示障碍物栅格。比如下面这个代码块就定义了一个带障碍物的12x12地图:
% 栅格地图:0=自由,1=障碍 map = zeros(12,12); map(3,3:6) = 1; map(5,8:9) = 1; map(7:9,4:5) = 1; map(10,2:3) = 1; map(:,1) = 1; % 地图左侧外墙 map(:,12) = 1; % 地图右侧外墙 map(1,:) = 1; % 地图上侧外墙 map(12,:) = 1; % 地图下侧外墙建议把地图边界也设成障碍物,这样路径搜索和覆盖循环中不需要额外写“越界判断”,逻辑统一些。如果你用的是真实栅格地图数据,通常已经包含了边界,这步可以跳过。
还有一点需要注意:全覆盖任务里,机器人本身是有尺寸的。如果机器人半径是0.5米,栅格尺寸是0.3米,那贴着障碍物走的栅格实际上会碰撞。工程上常用的办法是把障碍物做“膨胀”,也就是把障碍物周围一定半径内的栅格也标记为1。Matlab里可以用bwdist算距离变换,然后用阈值转成膨胀后的障碍物区域。下面这行代码可以生成一个膨胀半径2栅格的地图:
obsDist = bwdist(map == 1); inflatedMap = obsDist <= 2; % 障碍物附近2格以内都视为不可通行 inflatedMap = double(inflatedMap);膨胀之后,机器人中心点只要落在自由栅格里,就不会和障碍物碰撞。这个细节很多人写A时可以跑通,但一放到全覆盖主循环就出事故,原因大多就是没做膨胀处理。
2.2 A*算法的核心逻辑与Matlab实现要点
虽然这篇文章的主角看起来是“往返式”,但A仍然是核心关键词,所以我先把A子函数写出来。这里我特意选了四邻域扩展,而不是八邻域。原因是全覆盖机器人通常在清扫时做直线运动,斜向移动容易造成覆盖重叠或漏扫,而且在“跨区转移”阶段斜着走也不是刚需。四邻域A*的代码如下:
function path = astar4(map, start, goal) % 输入:map 二值栅格地图(0自由,1障碍) % start, goal 均为[x, y]坐标 % 输出:path 从start到goal的栅格序列(不含起点,含终点) [Nrow, Ncol] = size(map); % gScore存储起点到当前点的实际代价 % parentMap用于回溯路径 gScore = inf(Nrow, Ncol); fScore = inf(Nrow, Ncol); parent = zeros(Nrow, Ncol, 2); gScore(start(2), start(1)) = 0; fScore(start(2), start(1)) = heuristic(start, goal); openList = start; closedMap = false(Nrow, Ncol); dirs = [1,0; -1,0; 0,1; 0,-1]; while ~isempty(openList) % 在openList中找到f值最小的节点 fVals = arrayfun(@(k) fScore(openList(k,2), openList(k,1)), 1:size(openList,1)); [~, idx] = min(fVals); cur = openList(idx, :); if isequal(cur, goal) path = []; while ~isequal(cur, start) path = [cur; path]; cur = [parent(cur(2), cur(1), 1), parent(cur(2), cur(1), 2)]; end return; end openList(idx, :) = []; closedMap(cur(2), cur(1)) = true; for i = 1:size(dirs, 1) nxt = cur + dirs(i, :); if nxt(1) < 1 || nxt(1) > Ncol || nxt(2) < 1 || nxt(2) > Nrow continue; end if map(nxt(2), nxt(1)) == 1 || closedMap(nxt(2), nxt(1)) continue; end tentativeG = gScore(cur(2), cur(1)) + 1; if tentativeG < gScore(nxt(2), nxt(1)) gScore(nxt(2), nxt(1)) = tentativeG; fScore(nxt(2), nxt(1)) = tentativeG + heuristic(nxt, goal); parent(nxt(2), nxt(1), :) = cur; if ~any(openList(:,1) == nxt(1) & openList(:,2) == nxt(2)) openList = [openList; nxt]; end end end end path = []; % 找不到路径 end function h = heuristic(pos, goal) % 四邻域用曼哈顿距离,代价比八邻域更直观 h = abs(pos(1) - goal(1)) + abs(pos(2) - goal(2)); end这个实现没有用containers.Map或结构体,而是直接用二维数组存 gScore、fScore和parent,效率完全够覆盖任务使用。要注意Matlab矩阵索引是(行,列),而栅格坐标习惯用(列,行),也就是(x,y),在调用时要统一转换我代码里的pos(1)是x(列),pos(2)是y(行)。
我特别说明一下为什么这里用曼哈顿距离作为启发函数。A要求启发函数不超过真实代价才能保证最优性,四邻域移动时相邻两格的真实代价就是1,曼哈顿距离是最自然且可行的选择。有人喜欢用欧式距离,但在四邻域网格中,欧式距离会低估真实代价,虽然仍然能找到路径,却容易让A扩展更多节点。如果你改成八邻域,那启发函数也应该换成切比雪夫距离,这是很多人优化时容易忽略的点。
2.3 为什么只在区域转移时调用A*?
有的读者可能会问:既然A这么好,为什么不在全覆盖主循环里让每一步都调用A找下一个目标?那样覆盖率肯定高,但计算开销受不了。
可以做个简单估算:一张100x100的栅格图有约一万个自由栅格,如果在每个未覆盖栅格处都做一次A搜索,相当于要做几千次点到点的路径规划。每次A在最坏情况下要扩展整个地图的节点,总计算量会到千万级别。即使Matlab能在几秒内跑完,对于需要实时决策的机器人也算不上优雅。
更关键的是,往返式扫描本身就有一个很强的先验信息:机器人应该尽量沿着当前行方向走,直到碰到障碍或已覆盖区域才转向。如果每一步都贪婪地找“全局最近未覆盖点”,反而会破坏这种条理性,导致路径频繁跨越、转向次数暴增。所以我把A限制在“当前扫描行走不下去”的情况。此时才需要跳出局部视野,寻找一个远处的未覆盖区域,并用A生成一条最短的可行转移路径。这样既不会频繁调用A*,又保证不会死循环。
3. 往返式全覆盖主体算法:从“牛耕”到“智能跳转”
全覆盖策略的难点不在“覆盖当前行”,而在“如何从一段扫完的区域平顺切到下一段区域”。这部分我把算法拆成三个层次:行内贪心、换行切换、跨区跳转。
3.1 行内扫描规则:先定方向,再尽量往前走
我的主循环一开始不是给机器人规划完整路径,而是让机器人按照当前方向一步步走。在栅格地图里,初始方向设为向右,也就是x递增方向。机器人每走一步就把当前栅格标记为已覆盖,然后尝试继续沿这个方向走。
如果右侧是自由且未覆盖,就直接走过去。如果右侧是障碍物或已经覆盖过,意味着“这一行到头了”,需要进入换行逻辑。此时不要立刻掉头,因为有可能左边还有一个狭长自由区域可以补扫。比如地图行中间有一块障碍物,机器人从右边扫过来,被障碍物挡住时,左边可能还有一个未被扫过的空隙。所以我的优先顺序是:先继续向前,无法向前则回头看一看有没有未覆盖点,最后才考虑换行。这个细节能明显降低漏扫率。
行内扫描的伪代码逻辑如下:
当前方向 dir,cur表示机器人位置 循环: 标记 cur 为已覆盖 如果 dir 方向下一格合法且未覆盖: 移动到该格 否则: 尝试反方向下一个格,如果合法且未覆盖: 翻转dir,移动到该格 否则: 进入换行逻辑这么设计的好处是,机器人不会在障碍物前盲目转向,而是先把当前能走通的“枝杈”扫干净,符合“能覆盖就覆盖”的优先原则。
3.2 换行切换:默认向上,偶尔向下
在真正的牛耕式路径里,横向扫描一般是固定方向换行,例如从下往上,逐行扫描。如果地图中没有障碍物,这个规则非常简单。但我的地图里障碍物分布复杂,有时候当前行的下一行整段都被障碍物挡住了,这时候继续“向上换行”是走不通的。
所以我把换行逻辑做成了“优先尝试向上,其次尝试向下”。对于当前列的竖直方向相邻栅格,如果它是自由且未覆盖,就移动到它并切换行进方向。这样实现起来很简单,但会带来一个问题:如果地图中恰好有一个类似“U型”的障碍结构,机器人可能向上走一下、向下走一下,发生所谓的“振荡”。
避免振荡的办法是在代码里加一个“最近转向记录”。我实现时用一个变量记录上一次换行是在哪个位置发生的,如果连续三次换行都发生在同一个或相邻的栅格里,就认为这里已经扫干净了,强制交给跨区跳转逻辑处理。这个细节后面我会在避坑章节展开。
3.3 跨区跳转:用A*寻找最近未覆盖点
当机器人连换行都走不动的时候,说明当前连通区域基本上扫完了。此时地图上可能还散落着其他未覆盖区域,它们被障碍物隔开。传统Boustrophedon会做区域分解,把地图按障碍物边界分成多个子区域,再规划子区域间的顺序。我这里为了代码实现简单,没有显式做区域分解,而是直接找“距离机器人当前位置最近的未覆盖栅格”,然后用A*把路径算出来。
最近未覆盖点的筛选有一个小技巧:如果直接遍历所有未覆盖栅格,然后逐一算欧氏距离,也够快,但要过滤掉那些虽然距离近但需要绕很远路的点。更稳妥的办法是维护一个“未覆盖栅格列表”,每覆盖一个栅格就把它从列表里移除,然后在线性时间内找到最小曼哈顿距离的候选点。在覆盖任务刚开始、地图中未覆盖区域特别多的时候,这个简单策略也能快速收敛。
找到目标点后,调用A导航。需要注意的是,在跨区转移过程中,A走的路径本身也会覆盖经过的自由栅格。所以主循环里,应该让A*返回的路径上的每一个栅格都被标记为已覆盖。这样避免了重复扫描转移路径,也不会在后续扫描时再对这些区域做无意义的工作。
4. 完整Matlab代码实现与逐段解析
原理说再多,不如直接放一套能跑的代码。下面的实现是我简化后的版本,保留核心逻辑,去掉了可视化装饰,适合你自己改着玩。
4.1 构建地图并初始化覆盖表
% MainCoverage.m clc; clear; close all; % 1. 定义地图 map = zeros(20, 20); map(:,1) = 1; map(:,20) = 1; map(1,:) = 1; map(20,:) = 1; map(5,6:10) = 1; map(8:12,5) = 1; map(14:16, 14:16) = 1; map(3, 13:15) = 1; % 2. 初始化覆盖标记 [Nrow, Ncol] = size(map); covered = false(Nrow, Ncol); % 3. 机器人起点设置为地图左下角自由栅格附近 cur = [2, 19]; % [x, y] if map(cur(2), cur(1)) == 1 error('起点位于障碍物上,请重新设置'); end % 4. 初始扫描方向:向右 dir = 1;这里有一个坐标转换注意点:Matlab矩阵的行号y向下递增,但人类习惯的栅格直角坐标是x向右、y向上。我代码里cur = [2, 19]表示x=2,y=19,在矩阵中就是map(19,2)。你的地图如果按不同方式读入,要统一检查。我后面的所有代码都按这个约定写。
4.2 主循环:往返式扫描 + A*跳转
% 记录完整路径,方便可视化 path = cur; maxIter = 50000; iter = 0; while iter < maxIter iter = iter + 1; % 标记当前位置为已覆盖 covered(cur(2), cur(1)) = true; % 1. 尝试沿当前方向前进 next = cur + [dir, 0]; if insideMap(next, Nrow, Ncol) && map(next(2), next(1)) == 0 && ~covered(next(2), next(1)) cur = next; path = [path; cur]; continue; end % 2. 尝试回头扫描反方向 back = cur + [-dir, 0]; if insideMap(back, Nrow, Ncol) && map(back(2), back(1)) == 0 && ~covered(back(2), back(1)) dir = -dir; cur = back; path = [path; cur]; continue; end % 3. 尝试向上换行(列不变,行减1,因为显示时y向上) up = cur + [0, -1]; if insideMap(up, Nrow, Ncol) && map(up(2), up(1)) == 0 && ~covered(up(2), up(1)) cur = up; dir = -dir; path = [path; cur]; continue; end % 4. 尝试向下换行 down = cur + [0, 1]; if insideMap(down, Nrow, Ncol) && map(down(2), down(1)) == 0 && ~covered(down(2), down(1)) cur = down; dir = -dir; path = [path; cur]; continue; end % 5. 走到死胡同,搜索最近的未覆盖点,并调用A* uncoveredList = findUncovered(map, covered); if isempty(uncoveredList) break; end target = nearestPoint(cur, uncoveredList); subPath = astar4(map, cur, target); if isempty(subPath) % A*找不到路径,说明目标点实际上不在同一个连通域,换个候选点试试 target2 = secondNearestPoint(cur, uncoveredList); if isempty(target2) break; end subPath = astar4(map, cur, target2); if isempty(subPath) break; end end % 沿着A*路径移动,并标记经过的栅格 for i = 1:size(subPath, 1) stepPos = subPath(i, :); if map(stepPos(2), stepPos(1)) == 0 covered(stepPos(2), stepPos(1)) = true; cur = stepPos; path = [path; cur]; end end dir = 1; % 重置扫描方向 end % 输出结果 coverageRate = sum(covered(:)) / sum(map(:) == 0) * 100; fprintf('覆盖率:%.2f%%\n', coverageRate);4.3 需要用到的子函数
function flag = insideMap(pos, Nrow, Ncol) flag = pos(1) >= 1 && pos(1) <= Ncol && pos(2) >= 1 && pos(2) <= Nrow; end function list = findUncovered(map, covered) [rows, cols] = find(map == 0 & ~covered); list = [cols, rows]; % 存成[x, y] end function target = nearestPoint(cur, pointList) dist = abs(pointList(:,1) - cur(1)) + abs(pointList(:,2) - cur(2)); [~, idx] = min(dist); target = pointList(idx, :); end function target = secondNearestPoint(cur, pointList) dist = abs(pointList(:,1) - cur(1)) + abs(pointList(:,2) - cur(2)); [~, idx] = sort(dist); % 跳过第一个不可达点,寻找第二个 for i = 2:length(idx) target = pointList(idx(i), :); return; end target = []; end我把findUncovered实现成直接返回所有未覆盖点,没有做列表维护,因为示例地图很小,性能可以接受。如果你要跑1000x1000的大地图,建议用队列或者增量式维护未覆盖点集,否则每次主循环调用findUncovered都会扫描整个地图,效率会直线下降。
4.4 可视化与覆盖率统计
覆盖率是一刀切的评价指标。我算的是“已覆盖自由栅格 / 总自由栅格”。在往返式框架下,如果主循环正常结束,覆盖率应该是100%。但实际中会因为地图过于破碎、A*找不到可达点而提前退出,所以必须打印覆盖率来监控。
可视化部分比较简单,你可以用plot画出path的行进轨迹,也可以用imagesc动态显示覆盖状态。我常用的是imagesc(map + covered),然后把障碍物画成黑色,已覆盖区域画成白色,未覆盖区域画成灰色,这样一眼能看出漏扫在哪。下面是基础可视化代码:
figure; hold on; % 绘制障碍物 [rowObs, colObs] = find(map == 1); plot(colObs, -rowObs, 'ks', 'MarkerFaceColor', 'k', 'MarkerSize', 8); % 绘制覆盖轨迹 if size(path,1) > 1 plot(path(:,1), -path(:,2), 'b-', 'LineWidth', 1.5); plot(path(1,1), -path(1,2), 'go', 'MarkerFaceColor', 'g', 'MarkerSize', 10); plot(path(end,1), -path(end,2), 'ro', 'MarkerFaceColor', 'r', 'MarkerSize', 10); end axis equal; grid on; box on; title('往返式全覆盖路径规划结果'); xlabel('X(列)'); ylabel('Y(行,向下)');注意这里我把y坐标取负,是因为Matlab的imagesc默认显示矩阵时行号向下递增,我想让地图看起来更像真实直角坐标系。如果你不习惯,直接在画图时乘负号就行。
5. 我在调试这版代码时踩过的坑,以及几个有用的扩展思路
看完代码再劝一句:全覆盖路径规划里,最容易出问题的从来不是算法复杂度,而是状态机转换的条件写得不完整。我调试这版代码时,至少遇到三次死循环和两次覆盖率卡在99%的情况,下面把教训写清楚。
5.1 振荡和死循环的根源:优先级设计不合理
最典型的现象是,机器人在一个两行区域之间来回换行,反复横跳但就是覆盖不到新区域。原因出在换行逻辑上。我一开始写的顺序是:向上换行优先,向下换行其次,然后才考虑回头。结果地图里有个“V型”障碍物,机器人走到V型底部,先向上走一格,然后又因为下一格是障碍物回到向下,循环往复。
解决的办法有三个层次:第一,在尝试换行前加“最少前进步数”限制,比如必须连续向前走了至少2格才能换行,否则说明这个区域很窄,不值得在上下行之间折腾;第二,记录最近换行位置,如果连续3次换行距离小于3个栅格,就强制切换到A*跳转;第三,也是最彻底的,显式维护一个“已覆盖区域连通域”的概念,只有当当前连通域扫完才允许跨区跳转。
我的示例代码里没有加步数限制,因为地图比较整,但你在自己地图上跑的时候,强烈建议加一个简单计数器,代码大约三行,收益非常大。
5.2 覆盖率卡在99%:A*返回的路径可能穿过未覆盖区但不“完全覆盖”
有一次程序跑完显示覆盖率99.2%,我看生成路径,原来是一个窄长条区域没有被覆盖。原因是跨区跳转时,A*计算的路径只经过了一些关键节点,但机器人沿着路径走的时候,路径两侧各有一行栅格没有被覆盖。全覆盖任务里,机器人本身是有宽度的,一次移动可能覆盖不只一个栅格。
所以正确做法不是简单标记A路径经过的栅格,而是要在主循环的“沿A路径移动”阶段,对路径经过的每个起点终点之间的中间栅格做左邻右舍扩展覆盖。具体说,就是判断机器人前方两侧相邻的可通行栅格,只要没被覆盖,就一并标记为已覆盖。但这样就与机器人的真实物理清扫宽度有关。如果你做的是模拟研究,可以简单认为一次覆盖一个栅格;如果做实际机器人,建议在仿真里就把清扫宽度建模为3x3或5x5的区域。
5.3 地图分辨率与膨胀半径:不是越细越好
网格分辨率直接决定计算量和覆盖率效果。有些人喜欢把地图离散得很细,觉得更精确。但我在20x20的地图上跑得很欢,换到200x200的地图后,A*的寻路速度会明显下降,而且往返式扫描的换行策略容易出现“有些行漏扫,有些行反复扫”的问题。
我的经验是:栅格尺寸应该和机器人清扫宽度在一个量级,最好一个栅格等于机器人一次清扫宽度的1/3到1/2。这样既能平滑转行,也不会产生太多冗余路径。另外,膨胀参数也不要设太大,尤其不要在窄通道场景里盲目膨胀,否则有些刚好能过的通道会被堵死,A*就只能宣告无解,覆盖率被迫止步于一个很低的数值。
5.4 扩展思路:显式区域分解与分层规划
我目前这套方法是“扫描为主,A*为辅”的最简版本,适合快速实现、快速看效果。如果你想让路径更接近最优,建议用改进型Boustrophedon:先用bwlabel对自由区域做连通域标记,把每个连通域当成一个子区域,然后在每个子区域内用往返式扫描,最后把子区域之间的转移顺序规划问题转化成一个“最小移动代价”的排序问题。
这里的转移顺序依然可以用A来评估,具体做法是:对每两个子区域的边界点做A搜索,得到代价矩阵,然后用贪心算法或TSP求解器决定访问顺序。这一套下来,代码量虽然有提升,但覆盖率和重复率指标都会有明显改善,而且能处理更复杂的地形。
我个人在实际项目中的体会是,全覆盖路径规划的重点不是“用更聪明的算法替代往返式”,而是把问题分解成覆盖、转移、调度三个子问题,并对每个子问题选择合适的工具。A*在最底层解决转移问题,已经在绝大多数场景里足够好用。如果你正在做类似的方向,建议先把我这套代码跑起来,用它当作基线版本,然后逐步加入区域分解、换行优化、动态障碍物避开等改进,这样每一步都能看到明确的效果差异。