简介:这是一套Hybrid A算法的Python源码实现,面向移动机器人、自动驾驶领域的路径规划学习者和研究者,解决车辆在非完整动力学约束下难以用传统A直接生成平滑路径的问题,适合已具备基础搜索算法知识的读者进一步深入。压缩包共17个文件,包含4个Python模块(核心算法、A*基础、Reeds-Shepp曲线、可视化绘图)、7个pyc编译缓存文件以及6个GIF运行演示动画,整体仅3.56MB,代码结构清晰、可直接运行,便于本地调试与二次扩展。源码配套详细博客教程,从算法理论推导、源码逐行注释到整体流程总结逐步深入,并结合GIF动图直观展示网格搜索、节点扩展、启发式代价计算和RS曲线生成等关键步骤,帮助读者理解状态栅格建立、节点离散化以及车辆最小转弯半径、前进后退切换等运动学约束的处理方式。目前已有864人学习下载,是一份兼顾原理与实现的优质学习资料,适合作为课程设计、毕业设计或科研入门的参考。
1. Hybrid A* 的 Python 实现:它解决的不只是“带方向的 A*”
很多人第一次听到 Hybrid A*,第一反应是“这不就是加了方向维度的 A* 吗”。这个印象只对了一半,另一半恰恰是坑的起点。经典 A* 在离散栅格上找路径,走出来的是一条折线,车辆不能原地转向、不能横移,这条折线根本执行不了;Hybrid A* 把“车辆能不能走”这个约束直接塞进搜索过程里,每个节点不仅带位置,还带航向和动作来源。它解决的不是“更精细的路径规划”,而是“在连续状态空间里搜出车辆真的能开出来的轨迹”。做无人车、仓储 AGV、园区配送车的规划模块,绕不开这个算法。
这篇文章从节点定义、搜索循环、代价函数到参数调优和避坑,给出一套能在本地跑起来的最小 Python 实现。新手能跟着代码一步步把轨迹画出来,熟手可以重点看第 4 章的启发式权重、碰撞检测和内存控制这几个最容易翻车的地方。
2. 先搞清楚 Hybrid A* 在搜什么:节点定义与搜索空间
2.1 为什么经典 A* 在车辆规划里会翻车
经典 A* 的搜索空间是二维格点,每个格子有“通”或“不通”两种状态。它规划出来的路径由一系列相邻格子的中心点连成,拐弯处是直角或者 45 度折线。对于四轮车辆来说,问题在于:车辆的最小转弯半径可能是 3 米,而栅格里一个直角弯的曲率半径是 0。车辆在到达拐点之前就必须开始打方向盘,否则根本转不过去。
我在一个模拟项目 X 里第一次用 A* 给仓储 AGV 导航,仿真里看起来路径很干净,放到带运动学约束的仿真器里一跑,车辆在第三个弯道就冲出路径了。后来加了轨迹跟踪控制器,也只能让车辆沿着路径“蠕动”过去,转弯处严重减速。根源不在控制器,而在规划层给了控制器一条车辆物理上无法跟踪的参考线。Hybrid A* 的思路就是让搜索过程自己遵守运动学约束,每一步扩展出来的节点都是车辆模型前向模拟得到的可达状态,而不是几何上相邻的格子。
2.2 单个节点的数据结构和车辆运动学约束
Hybrid A* 的节点比 A* 多一个维度:方向。典型节点表示为 (x, y, theta),其中 theta 是航向角。在实际代码里,我习惯把节点定义成一个类,包含坐标、航向、代价、父节点指针,以及一个可选的“动作”字段,用来记录这个节点是从哪个转向角扩展来的。动作字段在最终轨迹平滑时很有用。
车辆运动学模型最常用的是自行车模型。状态更新公式为:
# 自行车模型离散更新 def motion_step(x, y, yaw, v, steer, wheelbase, dt): """ 前向模拟一步:给定当前状态和转向角,返回下一时刻状态。 v 为纵向速度,steer 为前轮转角,wheelbase 为轴距。 """ new_x = x + v * dt * math.cos(yaw) new_y = y + v * dt * math.sin(yaw) new_yaw = yaw + v / wheelbase * math.tan(steer) * dt return new_x, new_y, new_yaw其中v / wheelbase * tan(steer)是横摆角速度。注意dt不是仿真步长,而是“这一步走了多远”的时间间隔,实际使用时我会用arc_length / v换算。wheelbase越小,同样的转向角产生的角速度越大,车辆越灵活。这个参数直接决定了路径的最小转弯半径,匹配到真实车辆时不能随意设置。
2.3 3D 栅格与障碍检测的映射方式
搜索空间是 (x, y, theta) 的三维空间。x 和 y 来自地图栅格,theta 需要离散化。常见做法是把 360 度分成 8、16 或 24 个桶,每个桶覆盖一个角度区间。theta 分桶数直接影响内存占用和搜索效率:分桶越多,closed set 里去重判定越精细,但状态空间膨胀得越快。
障碍检测要单独处理。不能只用车辆中心点去查栅格,否则车身的四个角可能插进障碍物里。我一般会把车辆 footprint 简化为一个矩形或几个圆的组合,每次扩展完一个节点,把矩形轮廓离散成若干采样点,逐个点查地图栅格是否为障碍。伪代码逻辑如下:
def is_collision(x, y, yaw, footprint_points, grid_map): """ 把 footprint 旋转到当前航向下,逐点检测栅格占用。 footprint_points 是车辆轮廓在局部坐标系下的点集(例如前后保险杠和两侧)。 """ cos_t, sin_t = math.cos(yaw), math.sin(yaw) for lx, ly in footprint_points: # 旋转到全局坐标系 gx = x + lx * cos_t - ly * sin_t gy = y + lx * sin_t + ly * cos_t # 查栅格,越界视为碰撞 if grid_map.is_occupied(gx, gy): return True return False这里有个性能细节:footprint 采样点越多,碰撞检测越准确,但每个节点都要做一次旋转矩阵运算,搜索总节点数上万时,这个开销会被放大。我通常在固定地图上预生成几组“按 theta 桶旋转后的 footprint 点集”,用索引直接查,省掉重复的三角函数计算。
3. 在本地跑通最小可运行的 Hybrid A*:核心 Python 循环
3.1 最小地图与车辆参数:先把东西跑起来
我在本地搭最小实现时,会先造一张 50x50 的栅格地图,画上几个障碍物,然后定义一辆参数极简的车。地图用二维数组表示,0 是自由空间,1 是障碍物。车辆参数只保留四个:轴距、速度、最大转向角、车身尺寸。这样做的目的是先用最小配置跑通主循环,再去调复杂参数。
import math import heapq from dataclasses import dataclass, field # 车辆参数:轴距 2.5m,速度恒定 1.0m/s,最大转向角 35 度 WHEELBASE = 2.5 V = 1.0 MAX_STEER = math.radians(35) # 栅格地图:0 自由,1 障碍。这里用 50x50 全空地图便于先跑通 GRID = [[0 for _ in range(50)] for _ in range(50)] @dataclass class Node: x: float y: float yaw: float g: float h: float parent: "Node" = None action: float = 0.0 @property def key(self): # 栅格坐标 + theta 桶索引,作为 closed set 的查重键 xi = int(self.x / GRID_RES) yi = int(self.y / GRID_RES) ti = int(normalize_angle(self.yaw) / THETA_BIN) return (xi, yi, ti)key这个属性是 Hybrid A* 和 A* 在实现上最核心的区别。A* 的 closed set 直接用二维格子坐标,这里必须把 yaw 离散化后一起作为键。normalize_angle把航向角归一到 0~2π 范围。GRID_RES和THETA_BIN是两个全局参数,后面第 4 章会重点讲它们怎么配合。
3.2 节点扩展:前向模拟不是直线,是曲线
A* 扩展邻居是查四个或八个方向的格子,Hybrid A* 的扩展则是对着一组转向角做前向模拟。我常用的动作集是 [-MAX_STEER, -MAX_STEER/2, 0, MAX_STEER/2, MAX_STEER],每个动作模拟一段固定弧长。这样做出来的子节点天然满足转弯半径约束。
def expand(node, steer_set): """对当前节点做一组前向模拟,产生子节点列表""" children = [] for steer in steer_set: x, y, yaw = node.x, node.y, node.yaw # 模拟一整段弧长,内部用更小步长做运动学积分 arc_steps = 5 for _ in range(arc_steps): x, y, yaw = motion_step( x, y, yaw, V, steer, WHEELBASE, dt=ARC_LENGTH / (arc_steps * V) ) child = Node( x=x, y=y, yaw=yaw, g=node.g + ARC_LENGTH, h=0.0, # 启发式在计算代价时再填 parent=node, action=steer ) children.append(child) return childrenARC_LENGTH决定每次扩展的“步长”,我一般设置为车辆速度乘以一个固定模拟时间(比如 1 秒)。步长太大,轨迹可能穿过狭窄通道的缝隙;步长太小,搜索层数变多、效率下降。这里的action字段记录了子节点所用的转向角,最终回溯轨迹时能看出车辆在每个路段是什么转向状态。
3.3 代价函数 g 与启发式 h:两个必须分开算的分数
g 值不能只算路径长度。我用三部分叠加:行驶距离、转向变化惩罚、倒车惩罚(如果动作集里包含倒车)。转向惩罚让搜索优先选择直线和缓弯,避免频繁摆动方向盘;倒车惩罚让算法优先正向行驶,只在必要时候倒车。这个设计直接影响轨迹的舒适度,对仓储 AGV 这类追求效率的场景尤其明显。
启发式 h 要满足可采纳性(admissible),否则搜索结果不再是最优路径。最省事的做法是直接用二维欧氏距离,因为它一定不大于车辆实际能走的最短路径长度。更高精度的做法是预计算 Reeds-Shepp 距离作为 h 的输入,但每次扩展都算一次 RS 曲线代价较高,我一般只在主循环里用欧氏距离,第 5 章会讲 RS 曲线用在哪个环节更划算。
def compute_h(x, y, goal): """不带方向的欧氏距离启发式,保证可采纳""" return math.hypot(goal.x - x, goal.y - y) def compute_g(node, child, steer): """g = 行驶距离 + 转向切换惩罚 + 倒车惩罚""" dist_cost = ARC_LENGTH steer_change_cost = abs(child.action - node.action) * 1.5 reverse_cost = 0.0 # 如果动作集里包含负速度,这里额外加惩罚 return node.g + dist_cost + steer_change_cost + reverse_cost3.4 A* 主循环 + Reeds-Shepp 最短路径收尾
主循环和 A* 几乎一样,区别在终止条件。当当前节点进入了“终点区域”时,由于栅格和 theta 的离散化,很难保证正好停在终点状态上,我会尝试用 Reeds-Shepp 曲线从当前状态精确连接到终点状态。如果这条曲线无碰撞,就直接拼接输出,否则继续搜索。
def hybrid_astar(start, goal): """ 最小可运行的 Hybrid A* 主循环。 start、goal 都是包含 x, y, yaw 的对象。 """ open_list = [] closed_set = {} start_node = Node(start.x, start.y, start.yaw, 0.0, 0.0) heapq.heappush(open_list, (start_node.g + compute_h(start.x, start.y, goal), id(start_node), start_node)) while open_list: _, _, current = heapq.heappop(open_list) # 已经在 closed set 里的节点直接跳过 if current.key in closed_set: continue closed_set[current.key] = current # 到达终点邻域,尝试用 RS 曲线精确连接 if dist(current.x, current.y, goal.x, goal.y) < GOAL_TOL: rs_path = reeds_shepp(current, goal) if rs_path is not None and not collision_any(rs_path): return extract_final_path(current, rs_path) # 扩展子节点 for child in expand(current, STEER_SET): if collision_check(child): continue child.g = compute_g(current, child, child.action) child.h = compute_h(child.x, child.y, goal) heapq.heappush(open_list, (child.g + child.h, id(child), child)) return None # 没找到路径collision_any是第 2 章碰撞检测的批量版本,GOAL_TOL我一般取 0.5 米。reeds_shepp函数在最小实现里可以先返回“直线段插值”的路径点,但真实使用时应该换成完整的 RS 曲线实现,那里面的弧段和直行段连接逻辑比较长,等第 5 章讲提前终止时再展开。
4. 参数调优与避坑:Trajectory 为什么有人跑通有人跑飞
4.1 栅格分辨率和 theta 分桶数:第一个要调的参数对
先给一组我常用的起步参数,后面按场景改:
| 参数 | 起步值 | 说明 |
|---|---|---|
| GRID_RES | 0.2 m | 栅格分辨率,决定位置离散粒度 |
| THETA_BIN | 16 | 方向分桶数,360 度 / 16 = 22.5 度一桶 |
| ARC_LENGTH | 1.0 m | 每次扩展的弧长,车辆速度 × 模拟时间 |
| STEER_SET | [-35°, -17.5°, 0°, 17.5°, 35°] | 转向角动作集 |
| GOAL_TOL | 0.5 m | 终点邻域判定半径 |
分辨率 0.2 和方向分桶 16 的组合,在 50x50 地图上是 250x250x16 的闭集键空间,内存可控。如果你把 THETA_BIN 调到 72,对应状态空间直接翻 4.5 倍,搜索时间和内存一起涨。分辨率调小到 0.05,位置分裂成 1000x1000,闭集查重的哈希开销会变得非常明显。我的习惯是:室外大场景用 0.5 米分辨率配 16 分桶,室内 AGV 用 0.1 米配 24 分桶。
4.2 避坑一:把启发式设成“带方向的欧氏距离”后搜索效率反而下降
现象:我把 h 值改成“目标点相对当前航向的旋转误差 + 欧氏距离”,本意是让搜索更有方向感。结果搜索节点数暴增,有时候几千次扩展都找不到终点。
原因:这个自定义启发式不满足可采纳性。当车辆朝向和目标方向不一致时,旋转误差分会高估真实代价,导致 h 大于实际剩余代价,A* 退化成贪心搜索,把搜索引向看似方向正确但实际绕远的区域。
解决:h 要么用欧氏距离,要么用真正可采纳的 RS 距离。带朝向的惩罚放到 g 里,通过转向切换惩罚来约束,不要让启发式承担它不该承担的角色。
4.3 避坑二:碰撞检测用中心点代替 footprint,窄道规划直接穿墙
现象:地图里有一条比车身略宽的通道,规划结果直接穿过通道边缘,看起来像“蛇皮走位”,实车仿真发现车身刮墙。
原因:碰撞检测只查了车辆重心所在的栅格,车辆四个角点已经侵入障碍物栅格但没有被识别。
解决:footprint 点集一定要包含车辆四角和前后轴中点,至少 6 个点。检测逻辑见第 2 章的is_collision。血泪经验是:点集里的点不是越多越好,而是要把“最凸出的轮廓”包住。前后保险杠的角点漏掉任何一个,都可能规划出贴墙轨迹。
4.4 避坑三:open list 里的重复 push 导致内存不断上涨
现象:程序跑到一半,内存占用从几百 MB 一路涨到几个 GB,然后卡死。
原因:同一个节点可以被不同父节点扩展到多次,每次都用heapq.heappush压入 open list。如果不检查 closed set,这些重复节点就一直堆在队列里。另一个隐蔽原因是current.key里的 theta 索引计算用了int(),如果归一化角度出现边界抖动,同一个物理角度可能落到两个不同的桶里,closed set 查重失效。
解决:pop 出来时判断current.key in closed_set,命中就跳过。theta 边界问题,我在normalize_angle里对 2π 附近的值做一次归并,确保映射稳定。这个坑排查起来最耗时,因为表面症状是内存涨,实际是哈希键不稳定。
4.5 避坑四:启发式权重调太高,路径“贴墙”甚至低速蠕动
现象:把 h 的权重从 1.0 调到 2.5,搜索明显变快,但路径紧贴障碍物轮廓,甚至出现车辆贴着墙边“蹭过去”的轨迹。
原因:h 权重越高,搜索越倾向选择离目标“看起来更近”的方向,g 中路径质量和安全距离的代价被相对削弱。权重超过一定阈值,A* 的优先级排序从“最小总代价”退化成“最小剩余距离”,等价于贪心算法。
解决:权重控制在 1.0 到 1.5 之间。如果你需要更快的搜索速度,优先减小 ARC_LENGTH 或者减少 steering 动作集数量,而不是调高启发式权重。把“安全距离”作为一项硬约束写进碰撞检测里,让车辆轮廓外扩一个安全裕量,比用权重软约束可靠得多。
5. 从“能跑”到“能用”:三个值得做的进阶动作
5.1 用 RS 曲线做终点验证与优先级修正
前面主循环里,RS 曲线只在接近终点时才调用。我后来改成在每次从 open list 弹出节点时都先尝试一次 RS 连接,代价低但收益很大。Reeds-Shepp 曲线是“从任意位姿到任意位姿”的最短路径公式,计算一次大约几十微秒,比继续扩展几层子节点便宜得多。做法如下:
rs = reeds_shepp(current, goal) if rs is not None and not collision_any(rs.points): return rs这样做的选择是:搜索常常在几百个节点内就被 RS 曲线“截停”,总规划时间降一半以上。RS 曲线在这里的意义不只是收尾,它相当于一个“终点可达性探测器”,比任何启发式都精确。
5.2 用轨迹平滑消除转向角突变
搜索出来的路径由固定转向角动作拼接而成,直接给跟踪控制器会有转向角方波跳变。常见做法是跑一轮梯度平滑,目标函数三部分:平滑项、碰撞项、曲率项。
def smooth_path(points, obstacles, alpha=0.5, beta=0.3): """ alpha 控制平滑强度,beta 控制贴近原路径程度。 每次迭代把每个路径点往相邻点均值方向拉,同时用投影惩罚拉回非碰撞区域。 """ for _ in range(50): for i in range(1, len(points) - 1): nxt = (points[i-1] + points[i+1]) / 2 # 碰撞项:如果插入点进入障碍,往回拉 if in_collision(nxt): nxt = points[i] + (nxt - points[i]) * 0.3 points[i] = points[i] + alpha * (nxt - points[i])注意平滑后要重新检查曲率约束,防止平滑过程把半径压到车辆极限以下。
5.3 从仿真到真车:阿克曼与差分驱动底盘的适配清单
阿克曼底盘的关键参数是最大转向角和轴距,直接对应搜索里的MAX_STEER和WHEELBASE。差分驱动底盘没有转向角概念,RS 曲线要换成 Dubins 曲线,动作集里也不需要转向角,改成“左轮差速 / 右轮差速 / 直线”三档。这组适配最容易忽略的是控制频率:规划层输出路径点,跟踪层需要按控制周期插值,如果插值逻辑没写好,实车上会出现轨迹抖动。我的建议是先做轨迹可视化验证,再做仿真器闭环,最后上实车,每一步都对照参数表确认运动学参数一致。
这个方向做下来,最深的体会是:Hybrid A* 的性能瓶颈很少在算法本身,更多在离散分辨率、启发式可采纳性和足迹检测这三个基础问题上。先把这三件事做扎实,再把 RS 曲线提前终止加进去,整个规划模块的响应速度和轨迹质量都会上一个大台阶。希望帮到你。
本文还有配套的精品资源,点击获取