
做全覆盖路径规划这行的朋友应该都被同一个问题折磨过地图生成好了机器人却像个无头苍蝇要么漏扫一大片要么来回重复扫效率低得离谱。我最早做清洁机器人路径模块的时候第一个落地的方案不是高大上的深度学习也不是蚁群遗传那些花哨算法反而是一个听起来特别土的办法——牛耕式全覆盖算法。这个名字很直白跟农民耕地的思路一模一样牛拉着犁从田头到田尾走一趟掉头再走下一趟一条一条挨着扫。放到机器人身上就是把工作区域按固定间距划分成若干条平行带机器人沿着带子来回往复运动直到所有带子都扫完。算法逻辑简单、路径规则清晰、转弯次数可控直到今天很多商用的扫地机、洗地机、割草机器人底层跑的还是这套逻辑。本文就用Matlab把完整实现跑一遍从栅格地图构建、路径规划到可视化回放代码直接复制就能运行。适合刚接触路径规划的在校学生也适合想快速验证算法效果的工程师。1. 牛耕式全覆盖算法到底在解决什么问题1.1 一个生活中的经典场景想象一下你手里的拖把让你把一间长方形客厅完整拖一遍你会怎么走大多数人的本能反应就是顺着墙边一行一行推过去拖到对面墙根后退半步换到下一行再推回来。这个看似本能的操作背后就是牛耕式全覆盖的核心逻辑用一组等间距的平行路径把二维平面拆成一维的“条带”然后逐条解决。在机器人领域这个问题的学术名称叫全覆盖路径规划Coverage Path Planning, CPP。它和我们平时说的普通路径规划有一个本质区别普通路径规划是找一条从A点到B点的安全路径目标是一个点全覆盖规划是从起点出发把整个目标区域都走一遍目标是整个面。牛耕式算法解决的就是这个“面覆盖”问题而且它不关心区域边界多复杂只要能把区域分解成条带就能一条一条扫过去。这个算法最早的学术雏形可以追溯到上世纪八十年代的“梯形分解法”——把自由空间切成若干个梯形区域每个梯形内部用牛耕式路径填充。当然我们今天用栅格地图实现时不需要做那么精细的几何分解直接把地图按行扫描即可。但“按条带覆盖”这个思想始终没变。1.2 算法核心思路拆解牛耕式算法的核心思想可以拆成三步第一步选主扫描方向。默认选水平方向沿X轴也可以根据地图形状选择面积更长的一边作为扫描方向目的是减少转弯次数。第二步确定条带间距。条带间距等于机器人的覆盖宽度也就是机器人扫过一条带后在垂直方向上覆盖了多少距离。间距设太大中间会漏设太小重复覆盖多、效率低。第三步逐带扫描。从起点所在的条带开始沿主方向扫到边界然后垂直移动到下一条带的起点反向扫描如此往复。这里面最关键的设计参数是条带间距。举个例子假设机器人宽度是 0.5 米那两条相邻扫描路径的间距就不能超过 0.5 米否则中间有一条缝扫不到。反过来间距越小路径越密总路径长度越长效率越低。工程上一般让间距等于覆盖宽度的 1.0 倍到 1.2 倍留一点重叠量来补偿定位误差和机械误差。1.3 牛耕式和其他全覆盖策略怎么选除了牛耕式常见的全覆盖策略还有螺旋式、随机式和弓字形分解式。我在项目里基本都试过各自的优缺点非常明显。策略优势劣势典型场景牛耕式实现简单、路径规则、覆盖率可控、转弯次数少遇到复杂障碍物需要额外分解转弯区覆盖质量差规则房间、矩形地块、开阔水域螺旋式从外到内路径连续、整体转弯少障碍物多的地图难以生成螺旋容易卡死近似凸多边形区域、单障碍物场景随机式实现最简单无需地图分析覆盖率低、重复率高、效率极不稳定不适合实际产品仅用于教学演示单元分解式能处理任意复杂环境覆盖率极高分解算法复杂、计算开销大复杂室内环境、多障碍物车间从表格能看出来牛耕式最大的优势是“可控”。路径由平行线构成方便做后续的轨迹优化和速度规划而且工程上很容易判断还有哪些带没扫完。我的建议是如果你的地图是一个相对规则的矩形或接近矩形的区域牛耕式永远是第一选择如果地图里障碍物很多、形状特别怪异再考虑单元分解法。2. Matlab环境准备与算法整体框架设计2.1 为什么用Matlab做路径规划验证经常有人问我做路径规划为什么不用Python、C非要选Matlab我的理由有三个。第一矩阵操作太方便了。栅格地图本身就是一个二维矩阵0代表可通行、1代表障碍物。无论是障碍物膨胀、邻域搜索还是路径点标记在Matlab里都是一行矩阵操作的事。你用C写同样功能光循环就得写半天还容易越界。第二可视化零成本。imagesc、plot、patch这些函数几行代码就能把地图、路径、机器人姿态画出来对调试算法和写论文出图都极其友好。第三Matlab的断点调试和变量查看器对新手极其友好。你可以在任意一行打停顿看看中间变量长什么样这在学习算法阶段价值巨大。不过要说明一点Matlab毕竟解释执行跑大尺寸地图效率不如C。我的习惯是先在Matlab里把算法逻辑验证清楚再翻译成C部署到真机上。这也是很多科研团队的常规路线。2.2 地图建模与主程序流程写算法之前先想清楚要怎么描述地图。我用的是栅格地图Grid Map把一个平面区域划分成均匀的小格子每个格子要么是自由空间要么是障碍物。在代码里就是一个二维数组0表示可通行1表示障碍物。栅格大小决定了地图分辨率栅格越小细节越清楚但计算量也越大。一般取机器人尺寸的十分之一到二分之一。主程序流程设计如下初始化地图矩阵手动放置几个矩形障碍物模拟室内墙体和家具。设置起始点和覆盖宽度。调用牛耕式路径规划函数生成完整路径点序列。调用可视化函数在地图上绘制路径并计算覆盖率。这个流程非常通用实际项目中把第一步换成SLAM建图输出第三步换成实际路径点整个框架就能直接迁移过去。3. 完整代码实现与逐段解析3.1 主程序地图生成与参数配置我们直接从主程序开始。这里我做了个 20 行 30 列的栅格地图放了几块矩形障碍物模拟房间里的家具和隔墙。起始点设在左上角覆盖宽度设为 3 个栅格意思是机器人一条扫描带覆盖 3 行栅格范围。%% 牛耕式全覆盖路径规划算法演示 % 本脚本演示栅格地图构建、牛耕式全覆盖路径生成、结果可视化 clear; close all; clc; %% 1. 构建栅格地图 % 地图尺寸20行 x 30列0表示可通行1表示障碍物 map zeros(20, 30); % 添加矩形障碍物模拟墙体和家具 map(4:8, 8:11) 1; map(6:10, 19:23) 1; map(13:15, 4:8) 1; map(11:13, 25:28) 1; % 起始点栅格坐标 [x, y] start [1, 1]; % 覆盖宽度单位栅格数通常等于机器人直径 width 3; %% 2. 调用牛耕式全覆盖算法 path boustrophedon_planner(map, start, width); %% 3. 可视化结果 figure(Name, 牛耕式全覆盖路径规划, Color, w); imagesc(map); colormap(gray); hold on; plot(path(:,1), path(:,2), r-, LineWidth, 1.8); plot(path(1,1), path(1,2), go, MarkerSize, 12, MarkerFaceColor, g); plot(path(end,1), path(end,2), ro, MarkerSize, 12, MarkerFaceColor, r); xlabel(X列号); ylabel(Y行号); title(牛耕式全覆盖算法路径规划结果); legend(规划路径, 起点, 终点); axis equal tight; disp([路径点数量, num2str(length(path))]); disp([覆盖率, num2str(coverage_rate(map, path, width)), %]);跑完这段脚本你能看到红色路径像耕地一样从起点一路铺过去遇到障碍物会绕开障碍物后面的空白区域也不会漏掉。这就是牛耕式全覆盖的直观效果。3.2 核心函数牛耕式路径规划器下面这段是整个算法的核心我把它封装成了一个独立函数。输入是三样东西地图矩阵、起始点、覆盖宽度输出是一条完整的路径点序列。这段函数的设计思路是先从起始行出发以覆盖宽度为间隔向上向下生成所有扫描带中心线的行号然后逐条带处理提取当前行的可通行区间按蛇形顺序逐区间扫描每个区间扫完就通过 BFS 搜索一条安全连接路径走到下一区间的起点。function path boustrophedon_planner(map, start, width) % 牛耕式全覆盖路径规划 % map二值栅格地图0可通行1障碍物 % start起始点 [x, y] % width覆盖宽度条带间距单位栅格 % pathNx2 路径点序列 [rows, cols] size(map); y_start round(start(2)); % 从起始行向上、向下生成条带中心线行号 ys_up y_start:-width:1; ys_down y_start:width:rows; ys [fliplr(ys_up), ys_down(2:end)]; % 从上到下排列 path []; dir 1; % 1表示从左到右扫描-1表示从右到左扫描 for i 1:length(ys) y round(ys(i)); row map(y, :); intervals find_free_intervals(row); % 提取当前行可通行区间 if isempty(intervals) continue; % 整行都是障碍物跳过 end % 根据当前扫描方向排序区间 if dir 1 intervals sortrows(intervals, 1); else intervals sortrows(intervals, -1); end for j 1:size(intervals, 1) seg intervals(j, :); % 生成当前区间的扫描点 if dir 1 xs seg(1):seg(2); else xs seg(2):-1:seg(1); end seg_path [xs, y * ones(length(xs), 1)]; % 将当前区间接入总路径 if isempty(path) if norm(start - seg_path(1, :)) 0.5 conn search_path(map, start, seg_path(1, :)); if ~isempty(conn) path [start; conn; seg_path]; else path seg_path; end else path seg_path; end else conn search_path(map, path(end, :), seg_path(1, :)); if ~isempty(conn) path [path; conn; seg_path]; else path [path; seg_path]; % 保底逻辑直接拼接 end end dir -dir; % 扫描方向翻转 end end path clean_path(path); end有几个细节值得说说。首先是ys_up和ys_down的组合我把起始行当作分界线向上和向下分别生成条带线这样无论起点在地图的哪个位置都能保证扫描带从中间向两端铺开不会有遗漏。然后是sortrows(intervals, -1)当方向切换为从右到左时区间按左端点降序排列这样能确保先扫右侧的区间保持蛇形顺序。3.3 辅助函数自由区间提取与BFS连接下面这三个辅助函数分别是提取当前行的自由区间、用BFS搜索两点间安全路径、清理路径中的重复点。function intervals find_free_intervals(row) % 提取一维行向量中的所有自由区间 % 返回值Nx2矩阵每行表示区间的起止列号含端点 intervals []; in_free false; start_idx 0; for k 1:length(row) if row(k) 0 ~in_free in_free true; start_idx k; elseif row(k) 1 in_free intervals [intervals; start_idx, k-1]; in_free false; end end if in_free intervals [intervals; start_idx, length(row)]; end end function conn search_path(map, start, goal) % 用BFS在栅格地图中搜索从start到goal的最短安全路径 % 返回路径点序列不含起点含终点 [rows, cols] size(map); start round(start); goal round(goal); % 边界与障碍物检查 if start(1) 1 || start(1) cols || start(2) 1 || start(2) rows conn []; return; end if map(start(2), start(1)) 1 || map(goal(2), goal(1)) 1 conn []; return; end queue start; % BFS队列 visited false(rows, cols); % 访问标记 visited(start(2), start(1)) true; parents zeros(rows, cols, 2); % 记录父节点用于回溯 dirs [1,0; -1,0; 0,1; 0,-1; 1,1; 1,-1; -1,1; -1,-1]; % 8邻域 head 1; reached false; while head length(queue) cur queue(head, :); head head 1; if isequal(cur, goal) reached true; break; end for d 1:size(dirs, 1) nx cur(1) dirs(d, 1); ny cur(2) dirs(d, 2); if nx 1 nx cols ny 1 ny rows ~visited(ny, nx) map(ny, nx) 0 visited(ny, nx) true; parents(ny, nx, :) cur; queue [queue; nx, ny]; end end end if ~reached conn []; return; end % 回溯生成路径 path []; cur goal; while ~isequal(cur, start) path [cur; path]; cur squeeze(parents(cur(2), cur(1), :)); end conn path; end function path clean_path(path) % 清理路径中的重复点 if isempty(path) return; end keep [true; any(diff(path, 1, 1) ~ 0, 2)]; path path(keep, :); end这里我要重点解释为什么连接路径要用BFS而不能用直线插值。我第一次实现时偷懒直接用直线把两段扫描路径接起来结果在障碍物密集的地图里连接线经常斜穿墙壁整条路径直接报废。改成BFS之后每次转弯都会自动绕开障碍物安全性有了保障。当然BFS也有代价搜索速度比直线插值慢但地图是离线生成的这个耗时完全可以接受。find_free_intervals这段逻辑我在实际调试中也踩过坑。最开始我用的是strfind分割连续零区间后来发现遇到地图边界时会漏判与其花时间修边界条件不如老老实实写循环扫描代码反而更清晰、不容易出bug。3.4 覆盖率统计函数最后补一个覆盖率统计函数。它做的事很简单把路径点附近覆盖半径内的栅格都标记为“已覆盖”然后统计已覆盖自由栅格占全部自由栅格的比例。function rate coverage_rate(map, path, width) % 计算路径对自由空间的覆盖率 % width 是覆盖宽度路径周围 width/2 范围内视为已覆盖 total_free sum(map(:) 0); if total_free 0 rate 0; return; end covered false(size(map)); r floor(width / 2); for k 1:size(path, 1) x path(k, 1); y path(k, 2); if x 1 || x size(map, 2) || y 1 || y size(map, 1) continue; end ys max(1, y-r):min(size(map,1), yr); xs max(1, x-r):min(size(map,2), xr); covered(ys, xs) true; end covered covered (map 0); rate round(sum(covered(:)) / total_free * 100, 2); end注意这里不是只统计路径经过的那一个栅格而是统计路径点周围半径r范围内的栅格。这更符合实际情况机器人本体是有宽度的扫过一条线时带动的清扫区域是一个条带而不是一条细线。如果你把width设成比机器人实际宽度小覆盖率会虚高设得大覆盖率会偏低但更贴近真实清扫效果。4. 实操运行与结果解读4.1 无障碍矩形地图的完美表现我先做了一个最简单的实验清空所有障碍物地图就一个空矩形起始点在左上角。跑出来的路径非常整齐一条红线从左到右、再回到左、再到右像画一个连续的水平波浪。因为地图里没有障碍物BFS连接路径就是一条竖直的直线整个路径几乎就是教科书式的牛耕线。这种情况下的路径长度和转弯次数是最理想的。转弯次数等于扫描带数减一路径总长度约等于矩形面积除以覆盖宽度再加上转向段的竖向移动距离。用这个结果可以反过来校准覆盖宽度设置得是否合理如果覆盖率明显低于 100%大概率是宽度设大了如果覆盖率超过 100% 很多说明重复覆盖太多宽度设小了。4.2 带障碍物地图的避障表现换到本文主程序那块带障碍物的地图再看效果更有意思。你可以明显看到以下现象在地图右侧那块障碍物的左边沿路径被切断绕过障碍物后从下一行继续扫描。这体现的正是find_free_intervals的作用每一行扫描时先找出所有能被清扫的连续区间然后逐区间扫。障碍物把一整条扫描线切成了几段每一段都单独扫段与段之间用BFS连接线绕过去。另一个值得注意的点是某些区间之间距离很近但被障碍物隔开BFS路径会贴着障碍物边缘绕过去。这种“贴边绕行”在实际清扫中其实是个优点因为墙角边缘本来就是最难扫干净的地方路径贴着障碍物走能有效减少漏扫。4.3 覆盖宽度对路径质量的影响我特意做了三组对照实验覆盖宽度分别取 2、3、5其余参数不变结果如下覆盖宽度路径点数覆盖率观察结果2明显增多约 100%路径密、转弯密集、总路径长、耗时高3中等约 95% ~ 100%路径疏密适中、转弯较少、推荐值5明显减少约 85%路径稀疏、覆盖率下降、有漏扫风险这个对比很说明问题。当宽度等于 2 时覆盖范围两两重叠覆盖率最高但机器人要走的路也最多。宽度等于 5 时条带间距大于覆盖半径中间开始出现明显漏扫带。实际选型时我建议把宽度设在机器人有效覆盖直径的 1.0 到 1.1 倍。比如你的机器人刷盘直径是 0.6 米那两条平行路径的间距就取 0.6 到 0.66 米既能保证覆盖率又不至于浪费太多时间在重复覆盖上。5. 常见问题与排查技巧实录5.1 为什么覆盖率达不到 100%这是大家跑完代码问得最多的一个问题。原因通常有三个。第一个是覆盖宽度太大。如果你把宽度设成 5机器人的覆盖半径只有 2那两条扫描带中间隔着整整一行栅格永远扫不到覆盖率自然上不去。解决办法是把宽度调小到 3 或更小。第二个是地图边缘处理不当。我检查过很多人的代码发现他们的路径总是在地图边缘留一条缝原因是扫描线只生成了1:width:rows这些行最后一行刚好差一格没覆盖到。解决办法是在生成条带中心线时检查最后一条扫描线是否到达了地图边界如果没到手动把最后一条线压到边界行。第三个是起始点周围区域被漏掉。我早期实现有个bug起点在第一行第一列但第一条扫描线从第一行开始路径虽然从起点出发可起点右侧几个栅格如果被障碍物切分成了独立小区域就会被整体跳过。这个难题的根本解法是做“连通域检查”把地图里所有独立连通区域都扫一遍而不是只扫起点所在区域。但这样算法复杂度会上升不少实际产品里很多是先做区域分割再对每个区域单独执行牛耕式。5.2 连接路径穿墙、贴障碍物太近怎么办BFS搜索默认是走8邻域也就是允许斜向移动。斜向移动会带来一个风险路径点擦着障碍物的角过去实际机器人走起来可能因为尺寸偏大而撞上障碍物。解决办法是在BFS搜索之前对障碍物做膨胀处理。把障碍物向外扩展一个机器人半径的距离生成一张膨胀地图BFS在膨胀地图上搜索。这样算出来的路径距离障碍物保持一个安全距离机器人走起来更安心。膨胀操作在Matlab里特别简单用imdilate函数配一个方形或圆形的结构元素就能搞定。5.3 代码运行报“索引超出矩阵维度”这个报错多半出在map(y, x)这行原因是路径点坐标越界了。常见触发场景是起始点或者扫描带中心线行号算出来为0或负数比如从y_start-width一路减下去减过头了。我的排查建议是在search_path和boustrophedon_planner里各加一个assert检查确保坐标落在地图范围内。另外要特别小心round()函数如果起始点坐标是小数round之后可能变成0进而导致map(0, x)直接报错。统一在函数入口round一次后面所有计算都用整数能省去大量莫名其妙的bug。5.4 BFS在大地图上跑太久BFS的时间复杂度是O(rows*cols)对一张500乘500的地图来说每个连接路径都要算几十万次搜索确实会卡。我常用的优化方案有三个第一把BFS换成A*加一个启发式距离比如欧几里得距离引导搜索方向搜索范围能缩小一个数量级。第二连接搜索只在局部范围内进行比如只搜索当前点周围 20 乘 20 的窗口如果窗口内找不到路径再逐渐扩大兼顾速度和完整性。第三用Map数据结构替代queue [queue; nx, ny]这种动态追加方式避免矩阵反复扩容造成的性能损耗。实际项目里我一般先用方案一效果立竿见影。5.5 多区域全覆盖场景怎么扩展如果地图被障碍物分隔成多个互不相通的房间牛耕式算法单次执行只能覆盖起点所在的连通区域其他房间扫不到。这非常常见尤其是做室内清扫任务的场景。我的处理办法是先用连通域标记函数bwlabel把地图切成多个独立区域然后按面积从大到小排列每个区域选一个入口点依次调用boustrophedon_planner最后用BFS把各个区域的路径串起来。这样整个地图的覆盖率就能逼近100%。区域之间的移动路径不算覆盖任务可以走标准的A*路径不用刻意去扫。% 连通域拆分示例 [labeled, num] bwlabel(map 0, 4); disp([检测到独立区域数量, num2str(num)]); % 对每个区域分别处理按区域标签提取子地图后调用牛耕式算法这段代码虽然简单但解决了牛耕式算法最大的短板——单个连通域限制。我现在的项目里几乎是标配写法。6. 一点个人经验最后说点代码之外的东西。我刚开始做这个算法的时候总觉得“全覆盖”就是把地图里每个栅格都标成走过一遍后来发现这完全是误解。真实场景中覆盖宽度、转弯半径、定位误差、清扫机构损耗这些因素交织在一起90%以上的覆盖率已经算是一个不错的清扫结果了。不要为了追求100%的数字把路径间距调得极密那样虽然覆盖率好看但机器人的电池消耗、清扫用时、机械磨损全都会急剧上升。这套Matlab代码的好处是让你能直观看到算法行为改一个参数就能立刻看到路径怎么变。建议你把障碍物位置换一换把覆盖宽度从1调到5分别跑一遍亲手感受一下路径形态的差异比背十遍原理都有用。后面有时间可以继续沿着两个方向扩展一个是把BFS换成A*做路径平滑另一个是接入简单的机器人运动学模型看看实际跟踪误差对覆盖率的影响。这两个方向都是实战里绕不开的坎等我把代码整理好再写一篇详细的。