做路径规划的人,一般最早接触的都是点对点导航:给一个起点、一个终点,算法帮你找一条能走过去的路。但实际工程里,真正让人头疼的是另一类问题——全覆盖。扫地机器人要把整个客厅都扫一遍,无人机要把一整块农田都拍一遍,洗地车要把仓库地面全走一遍,这些场景的诉求不是“从A到B”,而是“从A出发,把整个可行区域都走完”。这个项目标题“基于A算法的网格环境下的往返式全覆盖路径规划研究(Matlab代码实现)”,做的就是这件事:在栅格地图里,用A作为底层搜索工具,配合往返式的覆盖策略,生成一条能把所有可通行区域都走一遍的路径,并且在Matlab里把仿真和可视化都跑通。
这个选题非常适合两类人参考:一类是做移动机器人、AGV、扫地机器人课题的在校学生,毕设或课程项目里经常会遇到“全覆盖路径规划”这个关键词;另一类是想把A从“会背原理”升级到“能落地应用”的工程师。文章里我会把A的原理、往返式覆盖策略怎么设计、A*如何在覆盖过程中做断点桥接、Matlab代码怎么组织、以及我实际调试时踩过的坑全部拆开讲。读完你不仅能跑通这套代码,还能理解每一步为什么这么写,换个地图、换种算法也能自己改。
1. 别把全覆盖当成点对点:先把需求拆明白
1.1 从“找一条路”到“扫完一块地”
先想清楚一个问题:全覆盖路径规划和点对点路径规划,到底差在哪?
点对点规划的目标很单纯——找一条从起点到终点的最优(或近似最优)路径,代价函数通常是路径长度、时间或能耗。A正是这一类问题的经典解法。全覆盖规划的目标则是让机器人遍历环境中所有可到达的可行区域,并且尽可能少重复、少遗漏。这两个目标从数学上就不是一个量纲,所以不能直接拿A往全覆盖上套,需要先设计一套覆盖策略,再让搜索算法作为辅助工具参与进来。
举个例子你就明白了。手动扫地的时候,正常人不会拿着扫把从客厅一头走到另一头来回乱跑,而是会按某种“路径记忆”把地面分成几块,沿着一行一行的方向推着扫,扫完一行转到下一行,遇到沙发绕一下,继续扫。这个“一行一行推着扫”的模式,就是往返式覆盖;遇到障碍物绕开并接着扫的衔接动作,背后则需要一个搜索算法帮忙找路。A*在这个系统里的角色,更像是一个“救火队员”——主要负责处理覆盖主线中那些不能直线通过的位置,而不是从头到尾生成整条覆盖路线。
1.2 为什么选网格环境+往返式+A*的组合
三个关键词,每个都是一个选择,而且组合起来是有逻辑的。
网格环境是对地图最常见的离散化建模方式。把连续空间划分成大小相等的栅格,每个栅格要么可行、要么障碍,机器人的位姿就绑定到栅格中心点。这种模型简单、直观、通用性好,机器人领域大量的规划算法都是在栅格地图上验证的。虽然真实环境不一定完全对齐到网格上,但栅格粒度足够小的时候,误差就能控制到可接受范围。对于课程设计或者仿真验证,网格是最稳的选择。
往返式覆盖模式,也叫牛耕式、蛇形式,是所有覆盖策略里最基础也最可控的一种。机器人从地图一角进入,沿同一方向扫过一整行,行末掉头,再沿反方向扫下一行,整体路径呈“S”形。它的优点是路径模式简单、规划代价低、不容易把机器人绕晕,而且只要边界处理得当,覆盖率能做到非常高。相比之下,螺旋式(由外向内一圈一圈收)在地图边界不规则时容易出现漏扫区,随机遍历式则完全不可控,作为科研项目都不如往返式来得可靠。
A*在这个组合里的位置,前面已经说过了——它不负责主线覆盖,而是负责两部分工作:一是处理“行扫描中断”时的跨障碍转场,二是处理覆盖序列之间需要“短距离迁移”时的路径搜索。后面我会具体展示这两种情况在代码里是怎么实现的。
2. 算法核心:A*在这里扮演什么角色
2.1 快速回顾A*:代价函数与启发式选择
A*的核心是一个带启发信息的代价评估函数:
f(n) = g(n) + h(n)
其中g(n)表示从起点到当前节点n已经付出的实际代价,h(n)表示从当前节点n到目标点的估计代价(启发式函数)。算法每一轮都从开放列表中取出f值最小的节点进行扩展,直到找到目标点或者开放列表为空。
这里最关键的设计自由度在h(n)的选择上。不同网格邻域模型下,合适的启发函数并不相同。如果你允许机器人上下左右四个方向移动,曼哈顿距离是标准选择;如果允许八个方向移动,切比雪夫距离更贴合实际;如果要考虑任意角度运动,欧氏距离才合适。选错启发函数不会导致算法直接崩溃,但会让搜索效率明显下降,严重时甚至破坏A*的最优性保证。
我在这套覆盖系统里用的是四邻域模型,理由有两个:一是往返式覆盖的行列方向本身就对应上下左右移动,四邻域更贴近覆盖操作的实际语义;二是四邻域下曼哈顿距离是相容且可采纳的,A*一定能找到最短路径,这给桥接路径的长度优化提供了理论保障。八邻域在“找点对点路径”时确实更快,但在覆盖场景里,斜向移动往往会让机器人路径脱离行列扫描的规律,反而不太好处理。
2.2 往返式覆盖策略:主路径怎么生成
往返式覆盖的主路径生成逻辑,用大白话说就是一句话:按行扫描,奇偶数行方向交替。
从地图左上角开始,机器人在第1行从左往右遍历所有可行栅格,到达行尾后向下移动一格,在第2行从右往左遍历,依次类推。没有障碍物时,这条路径就是一个标准的“S”形。
但实际地图里必然有障碍物,问题马上就来了。假设地图第3行中间有一堵墙,把这一行分成了左段和右段,机器人从第2行下来,能先扫左段,扫到墙的位置就下不去了。这时候怎么办?两种思路:
第一种是最简单粗暴的“跳过处理”:当前行扫到头(无论是因为墙还是边界),直接转到下一行的对应位置继续按原方向扫描。缺点是可能留下大片未覆盖区域,覆盖率低。
第二种是“断点续扫”:把每一行按障碍物分割成若干可行段,段内顺序扫描,段与段之间用A搜索一条最短路径跳转过去继续扫。这种处理方式本质上是把“行扫描”降级为“段扫描”,虽然转场时会多走几步路,但能保证每一段可行区域都被覆盖到。这套代码里用的就是第二种思路,A在这里的价值体现得特别明显。
生成段序列之后,还需要对扫描顺序做一点优化。为了避免机器人反复横跳,我会按“就近原则”排序:当前段扫描完毕后,计算本段两端点到所有未扫描段端点的A*路径长度,选最近的下一个目标,而不是机械地按照行号顺序往下走。这个小改动看着不起眼,实测能把重复路径长度降低10%到20%,后面代码里我会展示具体怎么实现。
2.3 A*与覆盖路径怎么融合:桥接与断点跳转
有人可能会问:覆盖主线已经靠“按行走”生成了,A到底参与在哪几步?我在代码里画了一条很清楚的边界:生成覆盖序列用几何规则,序列内无法直线连接时启用A桥接。具体来说,A*出现在三个环节:
第一,段间桥接。行内遇到障碍物导致扫描段中断,机器人需要从当前断点移动到下一个段的起点。如果两个点之间隔了障碍物或已有覆盖区,不能直接连线,就用A*找一条可行路径。
第二,行末换行。从本行行末到下一行行首之间如果一路通行,直接向下走一步就行;如果下一行对应的列上正好是障碍物,不能垂直下去,就需要A*找一条绕行路线。
第三,起点到第一个扫描段、最后一个扫描段到终点(如果有终点要求)的连接。
这三个环节在代码里都调用同一个A函数,只是输入输出不同。这样做最大的好处是模块化:A实现一次,覆盖策略随便改,桥接逻辑不用动。如果你以后想换成D* Lite或者RRT*,也只需要替换那一个函数。
需要注意的是,A桥接路径不可避免会穿过某些已经覆盖过的区域,这会导致路径重复率上升。这是一个取舍问题:牺牲一点全局不重复性,换取断点处路径最短。工程上完全可接受,因为段与段之间的转场距离通常很短,重复率上升有限。想要进一步优化的话,可以在桥接时给已覆盖栅格设置一个惩罚代价值,A就会尽量避开已覆盖区域,代价是搜索时间变长。这套代码没有默认开启这个功能,我在扩展部分会讲怎么实现。
3. Matlab代码实现:从地图到平滑轨迹
3.1 栅格地图建模与坐标约定
Matlab里最自然的栅格地图表示就是二维矩阵:矩阵的每个元素对应一个栅格,0表示可通行,1表示障碍物。假设生成一个10x10的地图,其中几个位置设为障碍:
map = zeros(10, 10); map(3, 3:6) = 1; % 第3行第3~6列是墙 map(6:8, 7) = 1; % 第7列第6~8行是墙这里立刻会产生一个新手极容易踩的坑。Matlab矩阵索引是(row, col),也就是(行,列),其中row是第几行,col是第几列。如果你习惯x、y坐标,很容易把map(x, y)当成(x坐标, y坐标)来写,结果就是地图完全变形。实际图画出来后,障碍物的位置和你想象的不在一个地方。
我的习惯是做一个简单的坐标封装:定义一个坐标转换函数,内部统一用(行, 列)作为节点表示,所有涉及显示的坐标用[x, y] = meshgrid(1:cols, 1:rows)转换到绘图坐标,避免在算法代码里直接算x、y。这个封装看起来多写了几行代码,但能少掉很多调试时间。
另外,Matlab对中文字符支持有时候抽风,注释写中文有概率乱码。个人经验是注释直接用英文,或者确保脚本文件编码为UTF-8,不然换了电脑、换了Matlab版本,代码显示就乱七八糟,很影响心情。
3.2 核心A*函数怎么写
A*函数是整套代码的发动机,我给它的输入是地图、起点、终点,输出是一条从起点到终点的栅格序列。最简化但逻辑完整的实现如下:
function path = astar_path(map, start, goal) [rows, cols] = size(map); % 用于记录每个节点的父节点,便于回溯路径 parent = zeros(rows, cols, 2); gScore = inf(rows, cols); fScore = inf(rows, cols); gScore(start(1), start(2)) = 0; fScore(start(1), start(2)) = heuristic(start, goal); openList = start; closedMap = false(rows, cols); while ~isempty(openList) % 在开放列表中找f值最小的节点 [~, idx] = min(fScore(sub2ind([rows, cols], openList(:,1), openList(:,2)))); current = openList(idx, :); openList(idx, :) = []; % 到达目标点,回溯路径 if isequal(current, goal) path = reconstruct_path(parent, start, goal); return; end closedMap(current(1), current(2)) = true; % 四邻域扩展 for d = [1 0; -1 0; 0 1; 0 -1] neighbor = current + d; if neighbor(1) < 1 || neighbor(1) > rows || ... neighbor(2) < 1 || neighbor(2) > cols continue; end if map(neighbor(1), neighbor(2)) == 1 || closedMap(neighbor(1), neighbor(2)) continue; end tentative_g = gScore(current(1), current(2)) + 1; if tentative_g < gScore(neighbor(1), neighbor(2)) parent(neighbor(1), neighbor(2), :) = current; gScore(neighbor(1), neighbor(2)) = tentative_g; fScore(neighbor(1), neighbor(2)) = tentative_g + heuristic(neighbor, goal); if ~ismember(neighbor, openList, 'rows') openList = [openList; neighbor]; end end end end path = []; % 找不到路径就返回空 end function h = heuristic(node, goal) h = abs(node(1) - goal(1)) + abs(node(2) - goal(2)); % 曼哈顿距离 end这段代码有几个细节值得说。openList我用的是一个Nx2的矩阵,每次循环通过min函数取f值最小的节点。在小地图上这种朴素实现完全跑得动,10x10的地图搜索都在毫秒级;但如果地图放到200x200,这种线性扫描方式就比较慢了,届时再改成二叉堆或者用Matlab的优先级队列工具。我建议你先把逻辑跑通,再考虑性能优化。
启发函数用曼哈顿距离,配合四邻域模型是严格可采纳的,保证每次桥接找到的转场路径都是最短。这里不建议为了“好看”换成切比雪夫或欧氏,因为可采纳性一旦破坏,桥接路径变长不说,整个全覆盖的重复率指标也会被拖差。
3.3 往返覆盖序列生成
覆盖序列的生成分两步。第一步是提取所有可行栅格,并给每个栅格标记出它属于哪个“行段”。第二步是把行段组织成扫描顺序。
基础版的行扫描逻辑是先把每一行按左右方向交替生成点序列:
function coverSeq = generate_basic_cover_seq(map) [rows, cols] = size(map); coverSeq = []; for r = 1:rows if mod(r, 2) == 1 colOrder = 1:cols; else colOrder = cols:-1:1; end for c = colOrder if map(r, c) == 0 coverSeq(end+1, :) = [r, c]; end end end end这段代码生成的序列在无遮挡时就是连续的“S”形。但注意,它没有对障碍物造成的断行做处理。比如第3行的第5列是障碍,机器人在第3行扫到第4列后就找不到第6列怎么去了——序列直接跳过,但机器人实际路径中第4列到第6列之间隔着墙,不能直接走。这就会产生一条“看似覆盖了、实际到不了”的错误序列。
所以在正式代码里,我不会直接用这个基础版,而是先做行段分割,再生成可控的段间转场序列。行段分割逻辑如下:
function segments = extract_segments(map) [rows, cols] = size(map); segments = {}; for r = 1:rows c = 1; while c <= cols if map(r, c) == 0 segStart = c; while c <= cols && map(r, c) == 0 c = c + 1; end segEnd = c - 1; segments{end+1} = [r, segStart, segEnd]; else c = c + 1; end end end end每个段用一个三元组(row, colStart, colEnd)表示。扫描时,段的内部顺序按照当前行进方向生成;段与段之间,用A桥接。如果有N个行段,系统需要按“就近原则”给这N个段排一个扫描顺序,每个段扫描结束后,从当前段的出口点出发,A到下一个段入口点。
这段逻辑是全项目里最容易写乱的地方,因为“当前段出口点”和“下一个段入口点”的选择是有方向性的。比如在一个从左往右扫描的行段里,出口点是(row, colEnd);在一个从右往左扫描的行段里,出口点是(row, colStart)。我在代码里用一个startCell和endCell的字段来描述每个段的入口和出口,扫描完成后直接取endCell作为桥接起点,取下一个段的startCell作为桥接终点,逻辑就非常清晰。
3.4 路径合并、去重与可视化
通往全覆盖的最终路径是一串点序列,它由覆盖段内的逐点扫描序列和段间A*桥接路径拼接而成。拼接时的关键一步是去掉衔接处的重复点:覆盖段末尾点是桥接起点,桥接路径的第一个点也是它,拼接时保留一个即可,否则路径会原地回踩。
function finalPath = merge_paths(coveragePts, bridgePath) if isempty(bridgePath) finalPath = coveragePts; else finalPath = [coveragePts; bridgePath(2:end, :)]; end end这里还有一个已经被很多人踩过的隐藏问题:A*桥接路径的第二个点往往紧贴着覆盖段末尾点,如果不小心让路径原路返回再绕路,会导致点序列里出现“原地倒退”的片段。我排查过几次这种问题,根源都在于拼接时没有检查方向向量。拼接完整个路径后,我会跑一个全局的方向变化检测,凡是相邻三个点形成“来回折返”结构的,直接删掉中间点,路径看起来立即顺眼得多。
可视化的部分我推荐用两套图一起出。第一套是地图叠加覆盖轨迹,画出机器人的完整路径,用不同颜色区分覆盖段和桥接段;第二套是热力图,统计每个栅格被走过的次数,0次就是漏扫区域,1次是正常覆盖区域,2次及以上就是重复区域。热力图对评估覆盖效果极其直观,我调试时几乎每次都要看这两张图。
figure; imagesc(map); colormap(gray); hold on; for i = 1:size(finalPath, 1)-1 plot([finalPath(i,2), finalPath(i+1,2)], [finalPath(i,1), finalPath(i+1,1)], 'b-'); end plot(finalPath(1,2), finalPath(1,1), 'go', 'MarkerSize', 8); plot(finalPath(end,2), finalPath(end,1), 'ro', 'MarkerSize', 8);注意plot的坐标顺序,x对应列号,y对应行号,和矩阵索引是反的。写反的话图形会旋转90度,有些人不明白为什么自己的轨迹图总是竖着的,多半就是这里。
3.5 覆盖率与重复率怎么评估
光有路径图还不够,学术汇报和论文里需要量化指标。我常算三个指标:
覆盖率是核心指标,等于实际被覆盖过的可行栅格数除以全部可行栅格数。实现上用一个visited矩阵记录每个栅格被访问的次数,最后统计大于0的栅格比例。理想情况下覆盖率是100%,但因为边缘单格死角、桥接路径绕不过去等原因,实际项目中常有1%到3%的缺失。
重复率等于路径总步数减去被覆盖栅格数,再除以被覆盖栅格数。这个指标的意义是“为了覆盖全部区域,机器人额外多走了多少路”。纯往返式无遮挡时重复率几乎为0,障碍增多后A*桥接频繁,重复率会上升。我在随机地图上的测试结果是,障碍物密度在10%以下时重复率能控制在5%以内,密度到20%时重复率可能升到15%左右。
规划时间则统计覆盖序列生成、A桥接搜索、路径拼接三个环节的耗时。A桥接是最耗时的部分,在大地图上段数量多、每次搜索范围大,耗时增长非常明显。如果遇到性能问题,可以先检查是不是段间顺序排得太差,导致多次长距离桥接,然后再考虑优化A*的数据结构。
4. 调试过程与常见问题
4.1 启发函数不匹配的坑
我第一次跑这套代码的时候,图省事把启发函数写成了切比雪夫距离,但扩展方向仍然是四邻域。结果A*返回的“最短路径”经常不是真正的最短路径,覆盖路径总长度凭空多了10%。因为切比雪夫距离当前节点到目标的步数估计允许斜向移动,而四邻域扩展根本走不出斜向步,这个启发值比真实步数小很多(低估),搜索虽然还是能找到目标,但会扩展大量不必要的节点,路径质量也会下降。
排查方法很简单:把桥接起点到终点用A出的路径,和Dijkstra(即曼哈顿启发且扩展方式一致)出的路径做长度对比,如果A更长,就一定是启发函数不匹配。后来我把启发统一改成曼哈顿,所有桥接路径长度立刻和Dijkstra一致,搜索扩展节点数也明显下降。
4.2 矩阵行列与XY坐标混淆
这个问题我前面提过,但值得单独列出来,因为它真的是高频错误。Matlab的imagesc显示图像时,默认会把矩阵第1行显示在顶部,第1列显示在左侧。如果你用plot画路径时把坐标写成plot(x, y),而x对应列号、y对应行号,出来的轨迹就会上下颠倒。
解决办法有两种:一是在显示地图时用set(gca, 'YDir', 'reverse')让y轴方向和矩阵行方向一致;二是统一用(行, 列)作为内部节点表达,只在绘图时做换算。我推荐第二种,因为不依赖显示设置,改窗口也不出问题。
4.3 障碍物缝隙与小孔问题
网格地图里经常出现一种很恶心的地形:两个障碍物之间只隔一格宽的空隙。对点对点A*来说,这一格可以穿过,问题不大。但对全覆盖来说,这个单格通道往往意味着路径必须“挤”进去扫描一次再退出来,来回一定重复走,覆盖率看着是100%,重复率却高得离谱。
处理办法是引入机器人尺寸的栅格膨胀。如果机器人物理尺寸占2x2栅格,那么把所有距障碍物不足1格的可行栅格也标记为障碍,让路径搜索和覆盖序列都基于膨胀后的地图进行。膨胀后单格通道直接消失,重复率立即下来。这在扫地机器人里就是很常见的做法,因为机器人扫不进去的死角,你本来就不该规划进去,否则只是空转而已。
4.4 覆盖率90%卡住的边缘死角
还有一类问题是覆盖率卡在99%左右上不去,差的那几个栅格往往是边缘角落。比如地图左边界和障碍物之间形成一个宽度正好一个栅格的口袋区域,往返扫描的某一行从它旁边经过但没扫进去,桥接路径又因为附近障碍物密集而找不到合适的进入角度。
我处理这类死角的经验是:覆盖率分析结束后,先看热力图中0次访问的栅格都分布在哪。如果是个别孤立的单格,说明是死角,人工检查它是否真的可到达,如果可到达,手动在覆盖序列里补一个点;如果是一整片区域,说明是扫描序列里漏了整段,得回头检查段提取逻辑。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查手段 |
|---|---|---|
| A*桥接路径明显绕路 | 启发函数不匹配 | 换成与邻域模型匹配的启发函数 |
| 地图左右颠倒或上下翻转 | 行列坐标和绘图坐标混用 | 统一内部用(行,列),绘图再转换 |
| 覆盖率不达标却找不到漏扫点 | 障碍物膨胀范围过大 | 调整膨胀半径,结合热力图检查 |
| 路径中出现原地来回折返 | 路径拼接未去重 | 拼接时去掉重复点,检查方向向量 |
| 段间桥接耗时过长 | 扫描顺序不合理 | 实现就近排序,减少跨地图长距离桥接 |
| 某些可行区域A*找不到路 | 单格通道或膨胀后断连 | 检查连通分量,单独处理孤立区域 |
5. 还能往哪里扩展
5.1 转向代价与路径平滑
往返式覆盖的路径全是一格一格的折线,机器人如果真按这个路径走,每个转弯都要减速,能耗和耗时都不低。实际工程里可以给A*的启发函数加上方向权重,让搜索倾向于直行而非频繁转弯。更成熟的方案是走完全覆盖路径后,做一次路径平滑处理,用B样条或者最小转弯半径约束把折线变成连续曲线。但要小心,平滑后路径有可能会压到障碍物,所以平滑之后必须做碰撞检查,必要时局部重新规划。
5.2 未知环境的动态全覆盖
这个项目的前提是地图已知,但很多实际场景里地图是实时探测出来的。这时全覆盖可以改成“边探测边覆盖”:机器人在当前已知区域内按往返式覆盖,走到未知边界时,用传感器扫描扩展地图,更新段序列和覆盖计划。底层的A桥接逻辑基本不用改,只要把地图矩阵实时更新,A函数天然支持动态地图下的重规划。想做得再高级一点,可以用D* Lite替换A*,带来增量式重规划的效率提升。
5.3 算法组合方向
全覆盖路径规划还有一类主流思路是把全覆盖问题建模成旅行商问题(TSP)的变体——先计算所有“子区域”之间的转场代价,再用遗传算法、蚁群算法优化访问顺序。这种做法在子区域数量多、障碍分布复杂时,往往比“就近原则”贪心排序的路径更优。但又因为多次调用底层搜索算法来估计区域间代价,规划耗时明显更高。如果你的项目时间充裕,可以把这套代码里的“就近原则”排序模块替换成蚁群优化,A*函数完全复用,只看最终重复率和覆盖率的提升就能对比出算法差异。
6. 实操心得与建议
跑完这套流程,我最大的体会是全覆盖路径规划的成功率不取决于算法本身有多炫,而取决于细节处理有多严谨。段提取、坐标约定、路径拼接、去重、死角分析,每一步都有坑,任何一个地方出错,最后的路径图都是歪的,而且不仔细查根本看不出哪里错。
给新手的建议是:不要一上来就想把整个全覆盖系统写完,先把基础的A*点对点路径跑通,画出路径图,确认每一段桥接都合理;然后再加往返覆盖序列生成,先不要处理障碍,保证无遮挡时路径是一条漂亮的S形;最后再加段间桥接和就近排序。每加一层功能都跑一次热力图,看指标和视觉是否符合预期。按照这个顺序来,最多两天就能把这套代码全部跑明白。
如果调试中卡住了,优先打印覆盖段的入口和出口坐标,对照地图人工验证一下该段是否应该这样扫。这种方法比起盯着代码死看要高效得多。最后再分享一个小技巧:随机生成几十张不同障碍密度的地图批量测试,把覆盖率、重复率、规划时间按密度画成曲线,论文汇报和答辩展示时,这几张图的说服力比任何代码截图都强。