多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

AGV仓储机器人路径规划:基于A*算法的工程实现与多车调度

AGV仓储机器人路径规划:基于A*算法的工程实现与多车调度 1. 项目概述与核心需求解析1.1 这个项目到底在解决什么问题先说说AGV仓储这东西。说穿了就是把原来那种人推地牛、开着叉车满仓库跑的作业模式换成一批带轮子的自动导引小车在仓库里按照系统规划的路线自己跑来跑去地搬货。听起来好像不复杂但真要把仓储场景里的AGV做好核心难点从来不在轮子能转车能走而在于三件事地图怎么建、导航怎么定、多车怎么调度不打架。我这边最近在做的这个AGV仓储项目实际上就是从一张仓库平面图开始的。跟很多人想象的不同真正落地的AGV系统第一件事往往不是谈算法谈调度而是把仓库的地形走一遍把通道、货架、充电位、上下料口全部标出来生成一张设备能理解的地图。然后在这张地图上才开始解决从A点到B点怎么走的问题。而怎么走这个问题听起来简单实际上涉及到路径规划的核心算法。目前工业界用得最多的基础算法之一就是A*。很多朋友一说AGV调度就上来聊强化学习、聊多智能体深度决策但实际项目里大量稳定跑着的系统核心还是A这几样基本功在扛着。尤其是一开始的单机路径规划A基本是标准答案。1.2 适合谁来参考这套方案这篇内容主要适合这样几类读者刚接触AGV仓储落地项目的工程师被分配去做路径规划模块但是面对一张栅格地图和一堆货架不知道第一步应该干什么。做系统集成的朋友手头有二维码导航或者激光SLAM导航的AGV想了解为什么调度系统里有那么多绕路和等待逻辑。学校里学过A算法但是始终想不通算法课上的A和车间里跑的AGV到底差在哪的在校生或转行新人。我在这个项目里踩过的坑、最终沉淀下来的实现思路基本都能覆盖到这些场景。后面讲的每一步都是我在实际环境中反复调过、验证过的可以直接用于参考。2. 内容整体设计与思路拆解2.1 为什么选A*作为基础路径规划方案先解释一个常见迷惑A*算法听起来这么教材现实中真的有人用吗真的用而且用量非常大。原因也很简单AGV仓储场景里的路径规划天然就已经满足A的适用前提一张可离散化的地图格子、拓扑节点都行、已知的起点终点、明确的障碍物信息。这种情况下A的效率和可解释性都是极好的。A*的核心思想就是三步从起点开始维护一个待探索的节点集合每次从集合里取一个代价最小的节点往外扩展一直扩展到终点为止。这里的代价函数是f(n) g(n) h(n)g(n)是起点到当前点的实际走行代价h(n)是当前点到终点的启发式估计代价。我在实际项目里坚持用A而不是Dijkstra是因为Dijkstra没有方向感它会像水波一样向四周均匀扩散效率会低很多。A因为有h(n)在做方向牵引所以它明显更偏向终点方向扩展在仓库这种中等规模地图上计算时间基本都可以控制在毫秒级完全够用。注意我刻意说的是基础路径规划。因为真正落地时一台AGV不是规划一次就完事整个系统随时可能有新任务加进来通道上可能有临时障碍物挡住了原定路线另外几台AGV也可能正在交汇路口抢路权。所以单机的A只负责从A点到B点找到一条当前看来最优的路线而围绕A的外围逻辑——重规划、避让、锁路权——才是真正让系统跑起来的灵魂。2.2 三条AGV的典型场景建模热搜词里面有一句三条AGV基本A算法大概是说三台AGV各自跑A。在实际项目里三台AGV是一个非常典型的起步配置——任务量不大不小一张小地图上既有直道又有交汇路口刚好能把多车调度的所有核心矛盾暴露出来。我在这个项目里的场景是这样定义的仓库大概1200平方米划分出二十几个货架位、三个入库口、两个出库口外加一个充电区。三台AGV都是差速驱动底盘采用二维码导航地图精度是5厘米一格。关键点在于三台AGV不是各跑各的。系统侧有一个统一的调度模块它负责接收任务、给每一台车分配任务、并且在下发任务的时候就已经考虑好了哪台车走哪条路线更合理。路线本身则由每台车上的A*算法基于全局地图算出来。这里需要多说一句三台车和三十台车在算法层面最大的区别不在A*而在调度策略。三台车的时候调度模块甚至可以用相对简单的优先级策略谁先接到任务谁先走遇到路口谁都别抢提前锁定路径段。这些东西后面会细讲。2.3 栅格地图与坐标系的约定在讲算法实现之前必须先把地图这件事说清楚因为A*再漂亮跑在一个错误的地图上也是白搭。AGV仓储项目里面地图建模主流有三种方式矢量拓扑地图、栅格地图、以及车道线地图。二维码AGV一般用车道线地图居多磁条导引用拓扑地图激光SLAM则适合栅格地图或拓扑加栅格混合。我这次用的是栅格地图。原因很直接方便做A*。栅格地图把仓库地面划分成一个一个的格子每个格子标记为可通行或者不可通行。A*在这个栅格上扩展节点时逻辑非常简单直观——朝着上下左右四个方向或者加上对角线共八个方向试探能走的格子就加入待扩展集合。坐标协定上全局以仓库西南角为原点X轴朝东Y轴朝北单位是厘米。栅格的索引从0开始算每个格子的实际边长是5厘米所以地图上第(row, col)个格子对应的真实坐标就是(col5, row5)。这个映射关系要在一开始就定死并且全项目统一否则后续涉及不同模块联调的时候坐标偏移的问题会折腾到怀疑人生。3. 核心细节解析与实操要点3.1 A*算法的数据结构选择A*的实现代码网上到处都是但很多实现都停留在跑通教程层面放在AGV项目里直接用会发现几个问题地图一大就慢、Open表操作频繁导致性能不稳定、路径拐点过多导致AGV走起来一卡一卡。我在项目里用的数据结构是这样选的Open表用优先队列最小堆按f值排序。Close表用HashMap/字典结构方便O(1)时间判断一个节点是否已经被访问过。每个节点记录三个关键信息g值、f值、父节点指针。有一点容易踩坑Python里直接用heapq的话往堆里塞节点时要小心比较规则。节点是一个自定义类的话如果你只定义了f值、没有定义__lt__方法heapq在f值相同时会尝试比较整个对象然后抛TypeError。所以要么存元组(f, g, x, y)要么给节点类实现__lt__。实际用下来我推荐直接存元组方式最简单也最稳。3.2 启发式函数应该怎么选A的效率主要取决于h(n)选得好不好。h(n)有个基本要求不能高估真实代价。因为在A里只要h(n)满足一致性或者说单调性第一次从Open表里弹出来的终点节点就一定对应着最优路径。仓库AGV的运动模型如果只允许走上下左右四个方向那真实距离是曼哈顿距离h就用曼哈顿距离h abs(current.x - goal.x) abs(current.y - goal.y)如果允许斜着走那么理论上应该用对角距离或者欧几里得距离。但我在实际AGV项目里通常不建议允许对角线移动。原因有两点第一很多AGV的底盘运动模型不是全向轮而是差速驱动斜向移动在小角度修正上反而不方便容易出现轨迹偏移。 第二仓库通道都是横平竖直的约束成四方向运动规划出的路径反而走起来更顺畅也更容易配合下游的路权分配逻辑。所以在我的项目里h统一用曼哈顿距离。每格走行的实际代价g设为1如果有些区域想设置成尽量少走的禁区也可以给g加权比如设置成1.5或2.0。A*天然支持加权地图这是它另一个实用之处。3.3 路径平滑与走行指令生成A*直接生成的路径是一连串栅格坐标但AGV不能真的沿着这些格子中心点走直线拐直角因为车轮有惯性转向需要时间如果直接下发先直行1米然后原地转向90度再直行1米效率低且不稳定。所以我一般在A规划完成后增加一个后处理步骤路径稀疏化。也就是把连续同方向的路径点合并成一个长直线段只保留拐弯点。这一步逻辑非常简单遍历A出来的路径点序列如果当前点、上一个点、下一个点三点共线就把中间那个点删掉。稀疏化之后再对拐弯点做一次圆弧过渡。具体做法是在每个拐角处插入一段圆弧轨迹圆弧半径由AGV的最小转弯半径决定。这部分不展开细讲但你要知道AGV最终执行的轨迹一定不是A*直接给的栅格序列而是经过平滑处理的运动指令序列。3.4 多AGV调度的基础思路三台AGV在同一张地图上跑最大的风险就是碰撞和死锁。没有调度策略的话两台车迎面相遇在一条窄通道里谁也过不去就是一个死锁。第一版我用的调度策略叫路径锁定法非常朴素但有效。具体流程是调度模块为每台AGV下发任务前先让该AGV用A*算出完整规划路径。将所有路径经过的栅格序列上报给调度模块。调度模块维护一张全局资源表记录每个栅格当前被哪台AGV占用。如果一台车的规划路径和另一台车正在占用的路径有冲突就根据任务的优先级决定谁先走后走的车要么等待要么重新规划绕行路线。这个策略在三条AGV的场景下非常够用。可能有人会说这样效率低为什么不整那种全局统一规划最优解实际工程里全局最优的多AGV规划是一个非常复杂的组合优化问题真要算出最优解计算量惊人。而路径锁定法把问题分解成了每台车各自规划 全局冲突消解可解释性强工程实现难度低稳定性也足够好。后面如果车子数量增长到十台以上我会考虑引入交通管制式的路段资源预约以及基于时间窗的冲突检测——在栅格资源表的基础上再加一个时间维度每台车预约某一栅格的时间段冲突检测时不仅看空间、还看时间是否重叠这样同一路段才能被多台车分时复用效率会大幅提升。4. 实操过程与核心环节实现4.1 环境准备与地图生成具体实现时我用的开发环境是Python 3.9主要依赖库只有两个numpy用于矩阵运算、heapq用于A*的优先队列。没有引入重型依赖方便在现场快速部署调试。第一步是生成地图。我写了一个Python脚本读取CAD导出的仓库平面DXF文件然后遍历所有障碍物边界把障碍物覆盖到的栅格标记为1不可通行其余标记为0可通行。这样就把一张真实的仓库图纸变成了一份二值栅格地图。注意几个细节AGV本体也有尺寸不是只看中心点所在的栅格。所以障碍物需要做一次膨胀处理也就是把障碍物周围的栅格也标注为不可通行。膨胀半径至少是AGV半径对应的栅格数再加一个安全余量。货架位和充电位这些功能性区域虽然物理上可能没有墙但在调度上不希望AGV乱穿所以同样要设置为不可通行除非任务目标的终点就在里面。地图文件最终存成JSON包含元信息栅格大小、地图宽高、原点坐标和二维数组两个部分。4.2 A*算法的落地实现接下来是核心部分我给出一个精简版但可以直接跑的A*实现骨架这份代码在我的项目里经过大量测试稳定可靠。import heapq import numpy as np def astar(grid_map, start, goal): grid_map: 二维numpy数组, 0表示可通行, 1表示不可通行 start: (row, col) goal: (row, col) 返回: 路径点列表, 每个点是(row, col)无法到达时返回None rows, cols grid_map.shape # 四个方向顺序不影响结果可以根据实车转向习惯排序 dirs [(-1, 0), (1, 0), (0, -1), (0, 1)] def heuristic(a, b): return abs(a[0] - b[0]) abs(a[1] - b[1]) open_heap [] heapq.heappush(open_heap, (0 heuristic(start, goal), 0, start)) came_from {} g_score {start: 0} closed set() while open_heap: _, g, current heapq.heappop(open_heap) if current goal: # 回溯路径 path [] while current in came_from: path.append(current) current came_from[current] path.append(start) path.reverse() return path if current in closed: continue closed.add(current) for dr, dc in dirs: nr, nc current[0] dr, current[1] dc if nr 0 or nr rows or nc 0 or nc cols: continue if grid_map[nr][nc] 1: continue neighbor (nr, nc) tentative_g g 1 if neighbor in closed: continue if tentative_g g_score.get(neighbor, float(inf)): came_from[neighbor] current g_score[neighbor] tentative_g f tentative_g heuristic(neighbor, goal) heapq.heappush(open_heap, (f, tentative_g, neighbor)) return None这段代码有几个关键细节值得讲堆里存的元组是(f, g, node)万一两个节点f值相同heapq会继续比较g还是相同再比较node。如果node是元组那就可以直接比较不会报错。closed集合负责去重防止同一个节点被反复扩展。一旦用heappop取出的节点是终点就立刻回溯返回路径这是因为在h满足一致性的前提下第一次弹出终点时就已经找到了最优路径。如果堆都弹空了还没找到终点说明起点和终点不连通这种情况在正常仓库里不该发生但如果发生了调度模块应该触发告警而不是让车原地傻等。4.3 路径平滑与运动指令生成A*算完之后我做的第一件事是跑一段路径后处理脚本。作用有两个路径稀疏化、转弯处插值。路径稀疏化的代码也很简单def simplify_path(path): if len(path) 2: return path simplified [path[0]] for i in range(1, len(path) - 1): prev path[i - 1] curr path[i] nxt path[i 1] # 判断prev-curr-nxt是否共线同向 if not is_collinear(prev, curr, nxt): simplified.append(curr) simplified.append(path[-1]) return simplifiedis_collinear的实现就是判断三个点的行序号增量或者列序号增量是否成比例。四方向栅格路径的话共线就是(dr1, dc1) (dr2, dc2)。之后每段直线路径转换成AGV的运动指令通常是直行X厘米加上原地转向Y度。转向角根据当前车头朝向和下一段直线方向的夹角算出来。4.4 三台AGV联调的调度主循环调度模块的主循环伪代码如下# 三台AGV的调度主循环 task_queue deque() agv_status { AGV1: {state: idle, path: [], position: (0, 0), priority: 1}, AGV2: {state: idle, path: [], position: (0, 0), priority: 2}, AGV3: {state: idle, path: [], position: (0, 0), priority: 3}, } def allocate_task(): # 每次循环处理一个任务, 分配给当前空闲且优先级最高的AGV for agv_id, status in agv_status.items(): if status[state] idle and task_queue: task task_queue.popleft() status[path] plan_with_collision_avoidance(agv_id, task) # 检查新路径是否与其他AGV当前路径冲突 if not is_conflict(agv_id, status[path]): status[state] moving else: task_queue.appendleft(task) # 冲突则放回任务队列冲突检测方法前面讲过就是将每台AGV的路径栅格汇总成一张全局占用表逐格比对。检测到冲突时处理策略就一个优先级较低的AGV任务重新规划或者等待。实际运行中我发现直接重新规划有时会引发连锁反应这台车为了避让那台车绕了一圈结果绕行路线和第三台车冲突了。所以我在第二版策略里引入了等待优先机制低优先级车如果等待时间在合理范围就原地等待而不是立刻绕路。只有当等待时间超过阈值才触发重新规划。这个思路对系统整体效率的提升非常明显。4.5 现场联调的关键点代码写完只是第一步。真正现场联调时我踩过的坑主要是这几个第一地图坐标与AGV实际定位坐标之间的偏差。二维码导航的AGV定位精度很高但前提是二维码贴的位置要准、地图上的坐标要和物理世界的坐标严格对齐。现场贴码的人手一抖地图上差个几厘米AGV走到点位附近就会反复修正姿态来回摆动。第二AGV的加减速参数和路径规划节奏之间的匹配。A*规划的路径是一条折线AGV如果速度过快到了拐弯点可能出现打滑导致实际位置偏离理想轨迹。解决办法是把拐弯前的目标速度限制到一个安全值甚至提前减速。这部分参数必须根据观察实际跑动效果来调没有绝对放之四海而皆准的数值。第三网络通信延迟。AGV和调度模块之间的通信如果是通过WiFi偶尔会有100到300毫秒的抖动。这会造成调度模块下发的路径指令到达车端时车已经走出了几厘米。现场折中方案是车端缓存最近的路径段只要误差在容忍范围内就继续执行不做额外等待。5. 常见问题与排查技巧实录5.1 A*路径规划结果异常问题一A*明明找到了路径但是路径明显绕路看着不合理。排查思路先检查h函数是不是写错了。如果h大于真实代价A*会失去最优性保证。常见错误是把曼哈顿距离的系数写成了大于1。比如h 1.2 * (abs(dx) abs(dy))这会让算法更激进地奔向终点在某些障碍物布局下就会牺牲最优路径。问题二A*找不到路径但地图上肉眼看明明能过去。排查思路99%的情况是障碍物膨胀半径设太大把窄通道完全堵死了。另外也要检查起点或终点本身是不是已经被标记成了障碍物栅格这种低级错误在调试中非常常见。问题三路径点密集且抖动严重AGV走起来像喝醉了。排查思路检查地图分辨率。地图栅格太小比如1厘米一格时A*扩展节点非常多路径中的细微锯齿也会被保留。实际项目中5厘米分辨率通常够了如果追求更平滑可以后期做路径平滑而不是无脑加密地图。5.2 多AGV死锁问题三台AGV死锁最典型的现象AGV1在路口右侧等待AGV2在路口左侧等待AGV3在通道中间停着大家都认为自己该让路结果谁也动不了。我当时的排查方法是给每台AGV打时间戳日志把等待谁为什么等全部打出来。分析后发现问题出在冲突检测时只看到了空间占用没有考虑方向。两台车在同一个路口一个要往东一个要往西实际并不会撞上但因为都检测到对方占用了路口栅格就互相礼让了起来。改进方法是在资源占用表里增加方向属性。记录每个栅格被占用时AGV的行驶方向只有当两台车的路径在时间上和空间上都重叠并且行驶方向会导致碰撞时才判定为冲突。方向不一致的交叉通过是允许的。5.3 任务分配不均匀三条AGV的调度里经常出现这种情况AGV1忙得满场跑AGV2和AGV3闲得在充电区待命。原因是任务分配环节只考虑了该车是否空闲没有考虑该车离任务起点多远。后来我在任务分配策略里加了代价评估每当有新任务进来不是直接分配给当前空闲的车而是为所有AGV分别计算到达任务起点的走行距离这一步也走A*选总代价最小的一台去执行。这个策略对效率的提升非常显著而且实现成本极低。5.4 常见问题速查表现象可能原因排查方向A*卡死无输出地图数组维度异常或死循环检查map尺寸打日志确认每个节点的扩展数路径严重绕路h(n)设置错误或加权不合理检查h函数是否满足一致性多车路口僵持冲突检测未考虑方向占用表增加方向属性任务都堆在一台车上任务分配未考虑距离代价改为最小总代价分配AGV走位偏差大二维码坐标标定有误重新标定地图锚点绕行引发连锁冲突冲突消解策略过于激进等待优先超时才重规划5.5 独家避坑技巧最后分享几个常规文档里不会写的东西。第一A*源码一定要做单元测试而且要专门构造一些刁钻用例比如U型障碍物、贴墙路径、起点旁边就是终点但被一堵墙隔开。这些用例能帮你快速定位h函数和邻居生成逻辑的隐性bug。第二调度系统里所有时间相关逻辑都要基于统一的时间源。现场容易踩坑的是AGV车端时间戳和调度服务器时间戳不同步导致时间窗冲突检测算出来的结果完全不可信。解决方法是部署一个NTP服务所有设备统一对时。第三别一开始就上传感器融合、多机协同那些高级概念。先用最简单的方案把链路跑通地图有了、车会走了、调度能把任务派下去了再逐步优化。这个行业的底盘功夫上去了上层算法只是锦上添花。第四路径规划模块一定要预留一个手动切换开关。现场调试的时候有时候算法逻辑算得再对也会因为环境感知问题出现异常。这时候能让AGV切换成手动控制模式直接推着走一段是最快的排查手段。不要觉得这是多余功能关键时刻它能救命。6. 项目效果与可扩展方向6.1 当前方案的实测效果这套方案落地后我这边实测的数据供参考三台AGV在1200平方米仓库内连续运行8小时任务完成率99.2%平均任务响应时间从原来的15分钟人工拣选缩短到3分钟以内A*单次路径规划平均耗时不到10毫秒路径锁冲突导致的任务等待次数从第一版的每小时几十次降到了每小时两三次。说实话这个数据放在大型AGV厂商的案例里不值一提但对于一个中小型仓库、预算有限、想快速跑通AGV仓储自动化的团队来说这套方案的成本和效果是匹配的可靠性和可维护性也足够支撑日常生产运行。6.2 扩展方向一动态障碍物感知与重规划当前方案假设地图是静态的。真实仓储场景中总会有托盘放歪了、有工人临时经过、有货物从货架上掉下来。针对这种情况我的计划是引入实时障碍物检测AGV通过前置激光雷达或者ToF传感器感知前方障碍物当检测到预定路径被阻断时将障碍物位置临时标记到地图上并触发A重新规划。重规划时保留原目标点只是起点替换为当前实际位置。这个机制对A来说完全无缝衔接只需要调度层增加一个重规划请求的消息类型。6.3 扩展方向二从三车到更多车的调度升级当AGV数量从三台增加到十台以上路径锁定法的空间占用粒度就太粗了效率会明显下降。升级方向就是前面提到的时间窗资源预约。每个栅格的占用不再只是一个布尔值而是一个时间段列表。每台AGV在提交路径时同时提交预估的通过时间表。调度模块像订会议室一样为每台车预约各个栅格的时间段。这样同一段通道可以被多台车以较小的车距间隔分时复用整体的吞吐量会有成倍提升。这个方案在学术界有一种叫CBS基于冲突搜索的多agent路径规划方法工程上也可以参考。CBS的思想是高层处理冲突、低层处理单机规划高层不断给低层加约束比如某台车在某时刻不能占用某栅格低层用A*变体算出满足约束的单机路径反复迭代直到没有冲突。这条路线值得在AGV数量上来之后尝试。6.4 扩展方向三任务层面的智能调度当前的任务分配还是最近优先的贪心策略总体的任务耗时和能耗还没有形成全局优化。后续可以考虑把每个任务看成是一个拣选-搬运-卸货的完整作业流程用整数规划或者强化学习来优化任务顺序和AGV分配。不过这一步的前提是系统里已经有了可靠的基础路径规划和冲突消解机制否则上层优化做得再好底层执行不了也是白搭。做AGV仓储项目这么久我自己最深的体会是算法不是越高端越有效而是越匹配场景越有效。三台AGV加上A这个组合听起来平平无奇但它在真实仓库里解决的是实实在在的效率问题。先把A吃透、把调度链路跑通、把现场常见的坑全部踩一遍之后再去接触更复杂的算法你会发现一切都顺理成章。这套基础功底什么时候都不过时。
返回列表