ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于栅格地图的A星算法路径规划:原理、代码与工程避坑指南

基于栅格地图的A星算法路径规划:原理、代码与工程避坑指南

简介:基于栅格地图的A星算法路径规划是MATLAB环境下实现寻路算法的典型学习资料,面向机器人导航、游戏AI、自动驾驶等领域的初学者与研究者。资源以二维栅格地图为建模基础,围绕A启发式搜索的核心流程展开,包括开放集合与关闭集合维护、G-cost与H-cost计算、任意障碍物判定与动态地图更新等实现细节,适合用于理解路径规划原理并快速上手代码实践。压缩包共13个文件,1.28MB,以6个MATLAB脚本(如Axing.m、expand_array.m、distance.m等)为主,配合多篇路径规划相关学术文献(caj/caa格式),帮助读者结合理论论文理解算法改进与工程应用。代码层次清晰,包含起点目标初始化、节点扩展、目标检测与路径回溯等模块,并支持动态设置障碍物场景。已有2767人学习下载,对于想要在MATLAB中复现A算法、处理带障碍栅格地图的开发者是一份紧凑且实用的参考资料。

1. 基于栅格地图的A星算法路径规划:为什么工业机器人至今还在用它

在端到端深度学习规划被反复讨论的这几年,工厂AGV、商用扫地机和低速无人车里,跑得最稳的核心路径搜索仍然是“基于栅格地图的A星算法路径规划”这组组合。原因并不玄学:只要环境能被离散成一张二维栅格图,A星就能在毫秒到百毫秒级别给出一条无碰撞路径,严格程度和可解释性都优于纯学习方案。这套方法要解决的是经典问题:给定一张栅格地图、一个起点和一个目标点,求一条代价最小的无碰撞路径。适合正在做移动机器人导航、自动驾驶局部避障、仓储调度或游戏寻路的人。本文按“原理—代码—参数—避坑—进阶”的顺序展开,直接给出能跑的最小实现。

2. 栅格地图与A星原理:从代价表到启发式搜索的关键选型

2.1 栅格地图的数据结构:二维数组、分辨率与代价值的含义

真正落到代码里,栅格地图就是一张二维数组,行和列对应离散化后的环境栅格。每个格子存三个语义之一:0代表自由区域,1代表硬障碍,0到1之间是代价惩罚。比如靠近障碍物边缘的区域标成0.3,表示可通行但不希望走。这样设计的好处是A星在计算路径代价时能自然把“安全余量”叠加进目标函数,而不是只做二值碰撞检测。

分辨率是最关键的全局参数。室外低速车常用0.2米到0.5米,室内AGV常用0.02米到0.05米。分辨率越低搜索越快,但路径越粗糙,还可能丢失窄通道;分辨率越高越接近真实环境,但open表膨胀速度也越快。工程上必须结合机器人半径和最小通道宽度一起定,不能只按传感器精度来。我一般在项目里先按“机器人直径的1/4”估算栅格尺寸,再验证通道是否至少保留3个栅格。

import numpy as np grid = np.zeros((50, 50), dtype=np.float64) grid[20:25, 10:35] = 1.0 # 一堵横墙 grid[10:40, 25:28] = 1.0 # 一堵竖墙 grid[30, 5:20] = 0.6 # 软代价区,可通行但尽量绕开

这里索引顺序是(行, 列),画图时用origin='lower'可以让第0行显示在图像底部,不然会上下颠倒。0.6的格不堵死,但A星在计算移动代价时会叠加惩罚项,路径会倾向绕开它。真实世界坐标到栅格坐标的换算也要提前写清楚:

def world_to_grid(pos, origin, resolution): return (int((pos[0] - origin[0]) / resolution), int((pos[1] - origin[1]) / resolution))

这个函数有个容易踩的细节:当坐标正好落在分辨率边界附近时,int()向下取整可能把点划到障碍格上。稳妥做法是加半个栅格偏移:int((v - origin) / resolution + 0.5)。后面避坑章节会详细说这个边界问题。

2.2 A星核心:f=g+h 与 open/closed 两个集合为什么这样设计

A星维护两个集合:open表存放“已发现但尚未最终确定”的节点,closed表存放“已确定最优”的节点。每次从open表里取f值最小的节点扩展,f的计算公式是:

f(n) = g(n) + h(n)

g(n)是从起点到当前节点n的实际代价,h(n)是从n到目标点的估计代价。只要h始终不超过真实代价,A星能保证第一次从open表弹出某个节点时,它的g就是全局最优。这正是closed表敢“关死”节点不再回溯的数学基础。

open表通常用最小堆实现,在Python里就是heapq。堆中元素用(f, g, x, y)的元组存放,堆顶永远是f最小。这里有个工程上绕不开的问题:当某个节点因发现更短路径需要更新g时,堆里不能直接修改,常见做法是压入一个新的(f, g, x, y)记录,旧记录继续留在堆里。等旧记录被弹出时,用closed表判断它已经处理过,直接丢弃即可。这就是lazy deletion,代码里几乎都是这么写的。closed表用布尔二维数组而不是set,因为栅格坐标是连续整数,数组寻址是O(1)且内存开销远小于散列表。

为什么不用Dijkstra或纯贪心?Dijkstra相当于h=0,在大栅格地图上像水波一样向外扩散,搜索范围近似圆形,耗时明显更高。纯贪心只按h移动,可能一头扎进死胡同再回退,路径质量不稳定。A星把g和h同时放进f里,等效在“最优”和“收敛速度”之间加了一个调节旋钮,这个旋钮就是启发函数与权重w。

2.3 启发函数怎么选:曼哈顿、欧几里得与对角距离的取舍

启发函数决定了h的估计方式,也直接决定搜索范围和路径质量。4邻域移动时,曼哈顿距离dx+dy恰好等于真实最短代价,可采纳且精确,搜索范围最小。8邻域移动时情况就变了:一次斜向移动能同时减少dx和dy,真实代价是√2,但曼哈顿按走两步算成2,会高估真实代价。h一旦高估,A星就不再保证最优。

启发函数公式4邻域8邻域特性
曼哈顿dx+dy精确,最优高估,路径偏长搜索速度快但非最优
欧几里得√(dx²+dy²)低估,范围偏大可采纳,范围偏大通用但open表偏大
对角距离dx+dy+(√2−2)·min(dx,dy)不适用精确,最优8邻域下最佳选择

我一般在8邻域下默认用对角距离,只在需要兼容已有代码时才切欧几里得。另一个要点是:如果地图不是均匀代价,比如加入了软代价惩罚,那任何启发函数都只是近似,此时严格最优不再有定义,工程上就看路径总代价和规划耗时。为了进一步提速,可以在h前面乘一个权重系数w,w=1是严格最优,w在1.2到2之间能明显缩小搜索范围,代价是路径可能偏离最优解一点点。这个w是后面调参的主角。

3. 用Python实现基于栅格地图的A星路径规划:最小可运行代码与参数解读

3.1 环境准备与文件结构

我一般在本地用Python 3.8以上版本跑,第三方依赖只需要numpy做数组运算,画图验证时用matplotlib。文件就两个:astar_grid.py放核心算法,test_map.py构造地图和调参。没有复杂工程结构,也不需要ROS环境;ROS里的costmap_2d可以替换成矩阵,但核心搜索逻辑完全一致。

pip install numpy matplotlib

如果是在已有机器人项目里集成,你只需要把astar_grid函数的入参换成你的代价地图接口,返回的路径坐标再转回世界系即可。最小实现保持纯函数风格,方便单元测试。

3.2 A星核心代码与逐段说明

下面是一份完整可跑的A星实现,包含8邻域扩展、对角线穿墙检查、lazy deletion和parent路径回溯。它是后面所有调参实验的基础。

import heapq import math import numpy as np def heuristic(a, b, h_type='diagonal'): dx = abs(a[0] - b[0]) dy = abs(a[1] - b[1]) if h_type == 'manhattan': return dx + dy if h_type == 'euclidean': return math.hypot(dx, dy) # diagonal,8邻域下精确可采纳 return dx + dy + (math.sqrt(2) - 2.0) * min(dx, dy) def astar_grid(grid, start, goal, allow_diagonal=True, h_type='diagonal', w=1.0, penalty_weight=0.1): rows, cols = grid.shape open_heap = [] heapq.heappush(open_heap, (0.0, 0.0, start[0], start[1])) g_score = np.full((rows, cols), np.inf, dtype=np.float64) g_score[start[0], start[1]] = 0.0 parent_x = np.full((rows, cols), -1, dtype=np.int32) parent_y = np.full((rows, cols), -1, dtype=np.int32) closed = np.zeros((rows, cols), dtype=bool) if allow_diagonal: dirs = [(-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)] else: dirs = [(-1,0),(0,-1),(0,1),(1,0)] while open_heap: f, g, x, y = heapq.heappop(open_heap) if closed[x, y]: continue if (x, y) == goal: path = [] cur = (x, y) while cur != start: path.append(cur) cur = (parent_x[cur[0], cur[1]], parent_y[cur[0], cur[1]]) path.append(start) path.reverse() return path closed[x, y] = True for dx, dy in dirs: nx, ny = x + dx, y + dy if not (0 <= nx < rows and 0 <= ny < cols): continue if grid[nx, ny] >= 1.0: continue if dx != 0 and dy != 0: # 斜向移动必须检查两个相邻直向格,防止穿墙角 if grid[x + dx, y] >= 1.0 or grid[x, y + dy] >= 1.0: continue move_cost = math.hypot(dx, dy) move_cost += grid[nx, ny] * penalty_weight tg = g + move_cost if tg < g_score[nx, ny]: g_score[nx, ny] = tg parent_x[nx, ny] = x parent_y[nx, ny] = y h = heuristic((nx, ny), goal, h_type) heapq.heappush(open_heap, (tg + w * h, tg, nx, ny)) return None

这套代码的核心是三步:弹出最小f节点并判断终止条件;向邻域扩散并做碰撞过滤;满足条件时更新g和parent并重新压堆。有三处容易被新手忽略。

第一,对角线穿墙检查。dx != 0 and dy != 0时,必须额外确认(x+dx, y)和(x, y+dy)两个直向格子都自由,否则路径会斜着蹭过障碍物的角点。第二,parent记录用两个整型二维数组而不是字典。这是我在大地图上省内存最有效的一步,dict的键值对开销在节点数量超过十万时非常可观。第三,lazy deletion的实现方式是弹出后先查closed;同一个节点可能被多次压入堆,旧记录直接跳过。

参数方面,w是启发权重,w=1保证最优,w=1.5到2适合大图加速。penalty_weight是软代价惩罚系数,默认0.1意味着一个0.6代价的格子会额外增加0.06移动代价,连续经过多个软代价格后路径自然会绕开。注意这个系数不能调太大,否则为了躲一处0.1的软代价绕出几倍距离,得不偿失。

3.3 可视化验证与三个必调参数

写一个test_map.py来验证代码和观察路径质量:

import numpy as np import matplotlib.pyplot as plt from astar_grid import astar_grid np.random.seed(7) grid = np.zeros((50, 50)) grid[20:25, 10:40] = 1.0 grid[10:40, 25:28] = 1.0 grid[30, 5:20] = 0.6 start, goal = (5, 5), (45, 45) path = astar_grid(grid, start, goal, w=1.2) if path: xs = [p[0] for p in path] ys = [p[1] for p in path] plt.imshow(grid, origin='lower') plt.plot(ys, xs, 'b-', linewidth=2) plt.plot(start[1], start[0], 'go') plt.plot(goal[1], goal[0], 'ro') plt.savefig('astar_result.png', dpi=150)

这里有一个坐标习惯需要记牢:imshow的横轴是列索引,纵轴是行索引,所以画线时x传ys、y传xs。我早期在这里吃过亏,路径图上下翻转却以为是算法问题,排查了很久才发现是绘图坐标没对齐。

验证时看三件事:路径有没有碰撞、路径总长度、扩展节点数。扩展节点数直接打印closed.sum(),open堆峰值可以在每次push时记录len(open_heap)。调参优先级从高到低是:栅格分辨率、是否允许8邻域、启发权重w。分辨率决定规划质量和耗时上限,是最不该省的那一项。新手最常见的错误是一上来就把w调到3试图“加速”,这个翻车点下一章专门讲。

4. 基于栅格地图的A星路径规划避坑:5个搜索与建模问题修复

4.1 对角线路径穿墙而过:运动学上根本过不去

现象:规划结果看起来贴着障碍物的斜角,栅格碰撞检查通过,但把轨迹交给机器人执行时机身边缘会撞墙。原因:8邻域扩展时只检查了目标栅格是否被占用,没有检查组对角线方向移动必经的两个直向格。解决:在斜向移动前增加墙角检查,即目标格两侧的直向格都自由才允许斜移。前面代码里已经写了这行,如果你是从别的实现里移植的,先确认这段存在。

if grid[x + dx, y] >= 1.0 or grid[x, y + dy] >= 1.0: continue

除了墙角问题,还要结合机器人半径做障碍物膨胀。否则即使路径几何中心不撞墙,机器人轮廓也会剐蹭。膨胀操作放在A星之前,把障碍物往外扩半径对应的栅格数。

4.2 8邻域用曼哈顿启发式:路径看起来正常,实际比最优长不少

现象:同一张地图,用曼哈顿距离跑出来的路径比对角距离长,而且拐弯更多。原因:8邻域下曼哈顿高估了斜向移动的代价,破坏了A星的可采纳性,搜索过程把真正的最优解提前剪掉了。解决:8邻域统一用对角距离,代码里把h_type设为'diagonal'。我做过一个小对比:空旷50x50地图从(5,5)到(45,45),对角距离扩展节点数量大约是曼哈顿的一半,路径总g也更接近理论下界。

如果地图是带软代价的costmap,步长不再均匀,启发函数本来就是近似,这时“最优”没有严格定义。工程上只看路径总代价和规划耗时,不必纠结理论最优。但默认配8邻域加'diagonal'总是最稳的起点。

4.3 大栅格地图卡成黑匣子:open表膨胀到几百万

现象:地图尺寸到5000x5000,一跑就是几十秒,内存直线上升。原因:Python的dict和list对象开销大,尤其用字典存parent时,每个节点都要维护一个键值对;递归回溯路径在深路径上还可能碰到递归深度上限。解决:parent改成两个整型二维数组,closed改成布尔数组,open堆的元组不要塞多余字段。代码里的parent_x和parent_y就是按这个思路写的。

如果这样还慢,先降分辨率,从0.02改成0.05往往能少好几倍节点;再不行才考虑换C++或上JPS。不要一上来就重写语言,先检查数据结构和堆里的冗余字段。

4.4 路径贴边抖动:AGV一执行就疯狂修正

现象:路径贴着墙走,与障碍物只有一格距离,机器人实际半径导致它不断触发急停。原因:栅格地图里可通行和不可通行是二元值,A星只求几何最短,并不关心离障碍物多近;数学上最短路径就是会贴边,尤其在窄通道里。解决:对障碍物做膨胀,把机器人半径映射成栅格数并预留安全边距。

from scipy.ndimage import binary_dilation inflate_cells = int(round(robot_radius / resolution)) + safety inflated = binary_dilation(grid >= 1.0, iterations=inflate_cells) grid[inflated] = 1.0

提示:膨胀参数必须结合最小通道宽度验证,膨胀过猛会把可通过的窄通道封死。

更平滑的做法是在膨胀带外再扩展一圈软代价,例如0.3到0.5,让A星自己权衡绕行距离和贴边风险。软代价区域形状是衰减的,而不是一刀切不可通行。

4.5 加权A星权重设3以上:搜索没变快,路径反而绕远

现象:把w调到3或5想加速,结果扩展节点数没降,路径明显绕远,甚至在某些简单地图上要搜更久。原因:w让h被高估,搜索方向过早压向目标区域。如果目标方向被大片障碍挡住,搜索会先关死一批死路节点,再被迫回头重探,整体反而更慢。解决:经验上w取1.2到2.0,不要超过2。我习惯先跑一次w=1记录路径总代价,再跑w=1.5对比耗时和代价,若代价上涨超过5%就退回w=1。

另外要注意:w>1时启发不再可采纳,第一次弹出节点的g不一定全局最优,closed表关死节点会更危险。想要稳妥,可以在更新g时发现节点已在closed里就重新置为False再压堆,代价是多一些重复扩展,但能避免明显绕路。这个开关我建议只在w>1且路径质量变差时打开。

5. 让A星在栅格地图上真正可用:JPS加速、路径平滑与动态避障衔接

5.1 均匀栅格用JPS:比A星快几个数量级的兄弟算法

如果栅格代价只有0和1,且步长均匀,A星可以直接升级为JPS跳点搜索。JPS的核心思想是:在直线方向上只在拐点和阻挡点附近扩展,大量中间节点根本不会进open表,因此开阔区域几乎零扩展。适用边界很明确:栅格必须均匀,不能带0.6这种软代价,否则跳跃规则失效。带costmap的地图还得回到A星。我通常在室内简单仓储场景用JPS,室外复杂代价地图用A星加软代价。

5.2 路径平滑:贝塞尔与碰撞校验

A星输出的是栅格折线,直接给底盘执行会出现频繁转向。简单做法是做线性插值后,再用三次贝塞尔曲线平滑,保留首尾点不变。

def smooth_bezier(path, n_points=30): import numpy as np p0 = np.array(path[0], dtype=float) p1 = np.array(path[len(path) // 3], dtype=float) p2 = np.array(path[2 * len(path) // 3], dtype=float) p3 = np.array(path[-1], dtype=float) t = np.linspace(0, 1, n_points) result = [] for ti in t: point = ((1-ti)**3 * p0 + 3 * (1-ti)**2 * ti * p1 + 3 * (1-ti) * ti**2 * p2 + ti**3 * p3) result.append(tuple(point)) return result

平滑后必须做碰撞检测,因为曲线很可能被拉向障碍物边缘。逐段取点检查栅格占用值,一旦碰撞就放弃平滑,保留原折线。这个“先平滑再校验”的顺序不能反,否则会输出一条看着漂亮但执行不了的轨迹。

5.3 地图变了怎么办:从A星到D* Lite的迁移思路

如果地图是静态的,一次规划够用;但实际现场障碍物会移动。栅格地图上的D* Lite把A星反向搜索和增量重规划结合,能在局部变化后复用之前的大部分搜索结果。迁移不是重写,而是把目标当作搜索起点,把启发函数对称化,再加一个优先级队列保存上次的g值。我的建议是:静态地图先跑A星;地图更新频率不高就清空重新规划;更新频繁再考虑D* Lite。

我早期做室内导航时,也曾把8邻域加曼哈顿当默认配置,直到对比路径长度才发现非最优。后来每做一个新场景,我都先跑一次w=1的对角距离A星,再叠加膨胀和软代价,最后才谈提速。这套流程沿用到现在,谁改参数都得先过这三关。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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