ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于A*算法的网格环境往返式全覆盖路径规划与Matlab实现

基于A*算法的网格环境往返式全覆盖路径规划与Matlab实现 做路径规划的人一般最早接触的都是点对点导航给一个起点、一个终点算法帮你找一条能走过去的路。但实际工程里真正让人头疼的是另一类问题——全覆盖。扫地机器人要把整个客厅都扫一遍无人机要把一整块农田都拍一遍洗地车要把仓库地面全走一遍这些场景的诉求不是“从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(end1, :) [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{end1} [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(i1,2)], [finalPath(i,1), finalPath(i1,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形最后再加段间桥接和就近排序。每加一层功能都跑一次热力图看指标和视觉是否符合预期。按照这个顺序来最多两天就能把这套代码全部跑明白。如果调试中卡住了优先打印覆盖段的入口和出口坐标对照地图人工验证一下该段是否应该这样扫。这种方法比起盯着代码死看要高效得多。最后再分享一个小技巧随机生成几十张不同障碍密度的地图批量测试把覆盖率、重复率、规划时间按密度画成曲线论文汇报和答辩展示时这几张图的说服力比任何代码截图都强。
返回列表