ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

扫地机器人路径规划仿真:从随机碰撞到全覆盖算法解析

扫地机器人路径规划仿真:从随机碰撞到全覆盖算法解析 “这个清扫机教程太详细了吧”这个标题放在 CSDN 上大多数人第一反应是扫地机器人又有什么新鲜玩法了买扫地机器人的时候大家习惯性比吸力、比尘盒、比噪音却很少有人把“覆盖率”当第一指标。等用上几个月才发现决定一台清扫机好不好用的根本不是吸力而是它“怎么走”。路径规划不行机器就会在茶几周围反复转圈角落一次都不进去路径规划做好了扫一遍就能覆盖全屋省时省电。这篇文章不准备讲硬件组装而是从软件算法角度手把手带大家写一个可运行的“清扫机路径规划仿真”。我会用 Python 从零实现一张栅格房间地图模拟三种清扫策略随机碰撞、弓字形清扫、贪心全覆盖清扫并用覆盖率来评价它们的好坏。读完这篇文章你能掌握清扫路径规划的核心思路得到一个可以直接运行的仿真脚本也能真正看懂厂商宣传里“弓字形”“划区清扫”“全覆盖规划”这些词背后的原理。1. 清扫机的本质问题不是吸力是覆盖率先下一个结论扫地机器人的核心技术难点几乎都集中在“决策层”也就是路径规划。吸力大小、尘盒容量、边刷长短这些是执行层的细节真正决定清扫质量的是机器人在房间里的移动策略。这个问题在学术上的名字是 Coverage Path Planning通常缩写为 CCP即覆盖路径规划问题。它的目标是在给定环境中规划一条路径让机器人经过所有需要清扫的区域同时尽量减少重复路径并避开障碍物。用快递小哥来类比会更直观快递员要派送一个小区里所有楼栋的包裹路线规划得好一条路走完覆盖率 100%步数最少路线规划得乱同一个单元反复上楼另一些单元没送到。扫地机器人和这个问题的区别只在于快递员有地图和小区的门牌号而扫地机在工作前通常没有完整地图需要在清扫过程中自己感知、累积并规划。因此评价一台清扫机通常看三个指标指标含义为什么重要覆盖率实际清扫面积 / 需要清扫总面积覆盖率低意味着漏扫重复率重复清扫面积 / 总清扫面积重复率高意味着效率低、耗电清扫时长完成覆盖所需时间直接影响用户体验后面我会围绕覆盖率来对比三种策略这是最容易量化、也最能说明问题的指标。2. 清扫机仿真建模把房间变成网格在写路径规划算法之前先把真实世界抽象成计算机能处理的数据结构。最常用的做法是“栅格地图”。我们把房间切成很多小方格每一格用一个数字表示状态。在本文的仿真中我使用三种状态0空地表示可以清扫但还没有清扫。1障碍物表示墙壁、家具等不可通过的位置。2已清扫表示清扫机已经经过并完成了清扫。用一个二维数组grid[y][x]来保存这些状态x 是列y 是行。清扫机每移动到一个格子就把这个格子标记为 2。这样做的好处是覆盖率可以直接通过统计 2 的格子数量和空地总数来计算非常直观。为了让场景更有代表性我构造一个 10×10 的房间中间放一堵竖直墙右下角再放一个 2×2 的障碍块。这两个障碍物会让简单策略暴露问题也方便后续验证复杂策略。下面是 Room 类的基本实现# 文件路径sweeper_sim.py import random from collections import deque import numpy as np import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap class Room: 栅格房间地图。 0: 空地 1: 障碍物 2: 已清扫区域 def __init__(self, width10, height10, obstaclesNone): self.width width self.height height self.grid np.zeros((height, width), dtypeint) if obstacles: for x, y in obstacles: if 0 x width and 0 y height: self.grid[y][x] 1 def total_cleanable(self): return int(np.count_nonzero(self.grid ! 1)) def cleaned_count(self): return int(np.count_nonzero(self.grid 2)) def coverage(self): total self.total_cleanable() if total 0: return 0.0 return self.cleaned_count() / totaltotal_cleanable统计除障碍物之外的总格子数cleaned_count统计已经被清扫的格子数两者相除就是覆盖率。接下来定义清扫机本体。清扫机只需要维护当前位置和行走步数每次移动时尝试从(x, y)走到相邻格子。如果目标位置越界或者是障碍物就返回 False 表示移动失败。class Sweeper: def __init__(self, room, x0, y0): self.room room self.x x self.y y self.steps 0 self.room.grid[y][x] 2 def move(self, dx, dy): nx, ny self.x dx, self.y dy if not (0 nx self.room.width and 0 ny self.room.height): return False if self.room.grid[ny][nx] 1: return False self.x, self.y nx, ny self.room.grid[ny][nx] 2 self.steps 1 return True def move_to(self, x, y): self.x, self.y x, y self.room.grid[y][x] 2 self.steps 1这里的move_to是给后面的规划算法用的。规划算法已经通过 BFS 验证过路径合法所以move_to不再做障碍物校验直接放置清扫标记。这个类设计得足够简单方便在后面接入不同的清扫策略。到这里仿真基础就完成了一个是地图一个是移动能力。接下来开始实现不同的清扫策略。3. 策略一随机碰撞清扫先写最基础的随机碰撞策略。它的工作流程非常接近早期入门级扫地机器人清扫机向当前方向直行。遇到边界或障碍物时随机换一个方向继续走。重复这个过程直到达到最大步数。在我的仿真里简化了“当前方向”的概念每次移动都从四个方向中随机选择。这种随机探测更像是一种无人引导的布朗运动适合演示“没有路径规划”的时候覆盖率是什么水平。def random_strategy(sweeper, max_steps300): directions [(1, 0), (-1, 0), (0, 1), (0, -1)] for _ in range(max_steps): random.shuffle(directions) moved False for dx, dy in directions: if sweeper.move(dx, dy): moved True break if not moved: break这里为什么每次要random.shuffle(directions)因为如果固定方向顺序清扫机会表现出“优先向右、然后左、再下、上”的固定倾向可能导致系统性偏置。随机打乱方向顺序能更公平地模拟“撞到就随机转向”的行为。从仿真结果来看随机策略有两个明显问题第一覆盖率增长曲线是“先快后慢”的。因为一开始空地多随便走都能踩到新格子但越到后面未清扫区域越碎清扫机很容易在已清扫区域里反复游荡。第二覆盖率上限不稳定。房间越复杂、障碍物越多随机策略越容易困在某个连通区域里无法进入另一个区域。比如在本文构造的 10×10 地图里中间的障碍墙把房间分成了左右两部分随机策略如果一直停留在左侧右侧的覆盖率就会一直为 0。所以我的判断是随机碰撞只能作为最廉价的兜底方案它无法满足“靠谱清扫”的基本要求。4. 策略二弓字形清扫弓字形清扫也叫牛耕式清扫英文是 Boustrophedon。这个名字来自古希腊语的“牛转”形容牛在田地里来回耕地的路线第一行从左耕到右掉头换一行再从右耕到左这样的 S 形轨迹就是弓字形。弓字形为什么效果好因为它用最少的转弯覆盖整块区域。每一条路径都与上一条相邻所以只要区域没有障碍物打断理论上可以做到既不漏扫也不大量重复。先看在无障碍矩形房间里的实现def boustrophedon_strategy(sweeper, max_steps500): 简单版弓字形清扫适合没有内部障碍物的矩形房间。 direction 1 # 1: 向右 -1: 向左 while sweeper.steps max_steps: if sweeper.move(direction, 0): continue if sweeper.move(0, 1): direction -direction else: break这个实现的逻辑很直白先向右走能走就一直走。走不动撞到右侧边界后尝试向下移动一格。向下移动成功后方向改为向左。向左走到头后再向下移动一格方向改回向右。如此反复直到向下移动失败即已经扫完最后一行。在 10×10 没有障碍物的房间里这个策略的路径非常规律最终覆盖率接近 100%且重复率很低。这也是为什么几乎所有中高端扫地机器人都把弓字形作为基础清扫模式。但弓字形有一个致命弱点遇到内部障碍物时行会被截断。比如本文地图里的那面竖直墙它挡住了右边的区域。清扫机在第二行扫到墙根就走不动了它必须决定怎么绕过去。真实的弓字形方案不会这么简单它会引入“区域分解”概念也就是把房间按障碍物轮廓切分成多个子区域在每个子区域里分别执行弓字形清扫。从工程角度看“简单弓字形 回补”是很多扫地机实际采用的办法。主循环用弓字形覆盖连续大区域等到主要路径都扫完后再规划一条路径去补扫那些被障碍物打断后遗漏的零散区域。5. 策略三贪心全覆盖清扫为了在一个带障碍物的地图里稳定达到高覆盖率我引入第三种策略贪心全覆盖清扫。这个策略的思想是每隔一段时间找出地图上所有未清扫的格子。从中选择一个离当前位置最近的未清扫格子。用 BFS 广度优先搜索计算一条最短可行路径。沿着路径走过去沿途把经过的格子都标记为已清扫。重复以上过程直到所有可达空地都被清扫。这相当于先“看到”全局地图再找路去覆盖。它比随机策略聪明也比单纯弓字形更健壮因为它天然具备绕开障碍物的能力。BFS 最短路径部分实现如下def bfs_shortest_path(grid, start, target): h, w grid.shape q deque([start]) visited {start} parent {start: None} dirs [(1, 0), (-1, 0), (0, 1), (0, -1)] while q: x, y q.popleft() if (x, y) target: path [] cur (x, y) while cur is not None: path.append(cur) cur parent[cur] path.reverse() return path[1:] # 去掉起点 for dx, dy in dirs: nx, ny x dx, y dy if 0 nx w and 0 ny h and (nx, ny) not in visited and grid[ny][nx] ! 1: visited.add((nx, ny)) parent[(nx, ny)] (x, y) q.append((nx, ny)) return Noneparent字典记录了每个节点的前驱节点最后从目标节点反向回溯到起点就能还原完整路径。这里路径上的格子已经确认可以通行因为 BFS 遍历时避开了障碍物。有了 BFS贪心全覆盖就好写了def greedy_cover_strategy(sweeper, max_steps1000): 贪心全覆盖每次找最近的未清扫点用 BFS 规划路径走过去。 room sweeper.room grid room.grid while sweeper.steps max_steps: candidates [ (x, y) for y in range(room.height) for x in range(room.width) if grid[y][x] 0 ] if not candidates: break target min( candidates, keylambda p: abs(p[0] - sweeper.x) abs(p[1] - sweeper.y), ) path bfs_shortest_path(grid, (sweeper.x, sweeper.y), target) if path is None: reachable False for cand in sorted( candidates, keylambda p: abs(p[0] - sweeper.x) abs(p[1] - sweeper.y), ): path bfs_shortest_path(grid, (sweeper.x, sweeper.y), cand) if path: target cand reachable True break if not reachable: break for px, py in path: sweeper.move_to(px, py) if sweeper.steps max_steps: return这段代码有两个细节值得说明。第一个细节是选择未清扫点时用的是曼哈顿距离abs(p[0] - x) abs(p[1] - y)而不是欧氏距离。因为在网格地图里机器人只能上下左右移动曼哈顿距离更接近真实的路径长度。第二个细节是如果最近的目标点不可达程序会尝试按距离排序寻找其他可达的未清扫点。这保证了当地图里有多个连通区域时清扫机不会因为某一个点过不去就提前结束。如果把贪心策略用真正的扫地机器人来做对照它对应的就是厂商宣传里的“划区清扫”逻辑。只不过真实产品里的地图不是预先给定的而是通过传感器实时构建的也就是 SLAM 技术。6. 完整示例代码与运行方式把前面的代码整合到一个文件sweeper_sim.py里。这里我补全visualize函数和主程序方便直接运行。def visualize(room, title): cmap ListedColormap([white, black, green]) plt.imshow(room.grid, cmapcmap, interpolationnearest) plt.title(title) plt.colorbar(ticks[0, 1, 2], label0空地 1障碍 2已清扫) plt.show() if __name__ __main__: # 房间中部放一堵竖直障碍墙 右下角一个 2x2 障碍块 obstacles [(4, y) for y in range(2, 6)] [(7, 7), (8, 7), (7, 8), (8, 8)] print( 随机碰撞策略运行 3 次) for i in range(3): room Room(10, 10, obstacles) sweeper Sweeper(room, 0, 0) random_strategy(sweeper, max_steps300) print(f第 {i1} 次覆盖率 {room.coverage():.1%}步数 {sweeper.steps}) if i 0: visualize(room, 随机碰撞策略) print(\n 贪心全覆盖策略 ) room Room(10, 10, obstacles) sweeper Sweeper(room, 0, 0) greedy_cover_strategy(sweeper, max_steps1000) print(f覆盖率 {room.coverage():.1%}步数 {sweeper.steps}) visualize(room, 贪心全覆盖策略)环境要求很简单Python 3.8 及以上。numpy。matplotlib可视化时用到如果暂时不装可以把visualize调用去掉。安装依赖pip install numpy matplotlib运行仿真python sweeper_sim.py运行之后控制台会分两次输出实验结果。第一次是随机策略跑 3 次的结果每次覆盖率会有波动第二次是贪心全覆盖策略的结果覆盖率会稳定在较高水平。重点看可视化效果白色表示空地黑色表示障碍物绿色表示已清扫区域。随机策略的绿色区域通常非常分散有些绿色甚至集中在某些角落反复叠加而贪心全覆盖策略的绿色区域最终基本覆盖所有白色空地只有障碍物保持黑色。需要注意的是随机策略的结果不要只看一次因为它依赖随机行为。想判断一个随机策略的真实水平必须多次运行取平均值这也是实验设计的通识。7. 覆盖率对比与结果分析从算法逻辑上可以直接推演出三种策略的覆盖率趋势不需要太复杂的实验设计。随机策略的覆盖率变化特征是“前期迅速上升后期逐渐停滞”。前 50 步时房间到处是待清扫空地每一步踩中新格子的概率很大跑到 200 步以后剩下未清扫的格子大多是碎片区域清扫机很可能在这几个碎片之间不断横跳。这种策略的覆盖率方差很大地图障碍越复杂方差越大。简单弓字形策略在无障碍矩形房间里的覆盖率接近 100%。因为它只要按行推进就不会漏掉连续区域。但遇到内部障碍物时标准弓字会被打断覆盖率会明显下降除非设计师加入区域分解和回补。贪心全覆盖策略在步数充足时可以达到 100% 覆盖率。因为它的核心逻辑就是“只要还存在未清扫且可达的格子就一定规划路径走过去”。从步数角度看贪心策略的路径长度也不是最优的因为每次只选择当前最近的未清扫点属于局部最优不是全局最优。但考虑到清扫机场景对实时性要求高这种贪心策略的实现简单、稳定非常适合作为教学和工程原型。三种策略的横向对比如下策略是否依赖地图覆盖率上限实现复杂度漏扫风险随机碰撞否低不稳定低高简单弓字形否无障碍房间高有障碍降低低中贪心全覆盖是高可达区域可接近 100%中低如果把“覆盖率”“重复率”“清扫时长”三个指标都考虑进来结论会更完整。随机策略覆盖率低但重复率反而很高因为它在已清扫区域上花费了大量步数弓字形策略重复率最低路线效率最高贪心策略覆盖率最高但路径整体偏长因为每次都要从当前位置绕到最近的未清扫点有时候路线显得不够优雅。这也是真实扫地机器人不会只用单一策略的原因。主流方案是“弓字形 回补 随机微调”的组合先在大块连续区域执行弓字形再用回补策略处理遗漏区域最后在传感器无法感知的盲区做短时随机试探。8. 常见问题与排查思路写仿真代码的过程中有几个问题很容易遇到。我整理成表格方便大家对照排查。问题现象可能原因排查方式解决方案随机策略跑了几次覆盖率都不到 60%地图障碍物太多区域被分割成多个连通块打印Room.grid查看障碍分布增加步数上限或改用其他策略贪心策略说有未清扫点但 BFS 找不到路径未清扫点位于另一个不连通的区域检查目标点周围是否有障碍物包围使用reachable逻辑跳过不可达点或先做连通域分解覆盖率已经很高但清扫路径很长贪心策略每次选最近点不是全局最优打印路径长度并观察轨迹改用区域分解 区域内弓字形减少跨区域移动matplotlib 窗口显示中文乱码系统缺少中文字体查看控制台日志绘图中使用英文标题或配置中文字体程序运行很慢尤其 BFS 部分地图尺寸过大且每次都在全图范围搜索统计bfs_shortest_path调用次数加入简单的搜索剪枝或使用 A* 替代 BFS其中最常见的一个问题是随机策略覆盖率和图美观度“看起来都很差”。这时候不要怀疑代码写错了随机策略本身就是这个水平。如果你把地图换成完全没有任何障碍物的 10×10 房间随机策略的覆盖率也远不如弓字形因为它在边界区域会浪费太多步数。还有一个容易踩的坑是重新运行同一段代码时随机策略每次结果都不一样。这不是 bug而是缺少随机种子。想要结果可复现可以在主程序开头加上random.seed(42)或者在实例化随机策略前固定种子。这样虽然减少了随机性但便于调试和效果对比。9. 从仿真到真实产品工程落地建议仿真是验证算法思路最便宜的方式。但真实扫地机器人跟仿真环境的差异很大如果只停留在网格仿真里很难理解开发一台真正可用的清扫机有多复杂。在真实产品里前面这个“已知地图”不成立。扫地机器人启动时不知道家里长什么样它必须边移动边建图。这个过程就是 SLAM 技术解决的问题。常见的 SLAM 方案有两类激光 SLAM使用激光雷达测距精度高建图准确但硬件成本高。视觉 SLAM使用摄像头图像依靠特征点估计位姿成本低但对光线敏感。建图之后才轮到路径规划层。真实产品不可能直接在完整栅格地图上跑一个贪心覆盖就完成任务它要处理更多工程问题第一里程计误差。轮子转多少圈并不能精确等于机器人实际移动了多少距离轮胎打滑、地毯高度变化都会造成定位漂移。需要配合 IMU 惯性测量单元来修正。第二在线规划。真实产品通常采用“先沿墙巡边建立边界地图再在内部执行弓字形清扫”的流程。弓字形执行过程中如果第一次遇到障碍物机器人会沿障碍物边缘绕行一段然后继续尝试回到原来的弓字形轨迹上。第三补扫机制。清扫过程中可能会遇到门被关上、宠物突然挡路等情况导致某些区域没有扫完。很多产品会在主清扫结束后重新检查地图中未覆盖的区域规划一条路径回去补扫。第四安全机制。除了避障还有防跌落。扫地机器人需要底部悬崖传感器在楼梯边缘及时刹车否则就会摔坏。从开发的优先级来看我建议先关注这三个指标覆盖率、重复率、清扫时长。先把这三个指标做出来再谈智能程度。如果覆盖率上不去说明路径规划有问题如果覆盖率上去了但重复率很高说明路径不够高效如果两个指标都好但清扫时间太长说明机器人的速度曲线和转弯策略需要优化。仿真环境里最容易验证的就是这第一层而真实产品的难点集中在第二层和第三层。10. 最后回顾整篇文章我最想传递的判断是清扫机的核心竞争力是“知道该往哪走”而不是“吸力有多大”。随机碰撞策略代码最短但覆盖率最差弓字形策略高效但怕障碍物打断贪心全覆盖策略稳定但路径偏长、依赖更完整的地图。三种策略各有适用场景真实产品往往把它们组合起来用。如果这篇文章对你有帮助建议收藏备用。下一步你可以做一件很简单的事把这套仿真代码复制下来改一改障碍物布局看看不同清扫策略在你自定义房间里的表现。把覆盖率曲线画出来之后你对“扫地机器人为什么需要建模和规划”的理解一定会比只看参数表的人深得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进