hybrid A* 这个东西,我在不同项目里前前后后写过好几版,从最早照着论文抄、跑出来完全不能用,到后来能在模拟环境里稳稳泊进一个比车长只多几十厘米的车位,中间踩过的坑足够写满一个笔记本。这个标题说“逐行讲解”“带你从头开始写”,我特别有共鸣,因为当年我最缺的就是一篇能让我照着敲、敲完还能讲清楚每一行为什么要这么写的文章。
这篇内容适合正在做自动泊车、低速园区配送机器人导航、或者只是想搞懂运动规划里“怎么把离散搜索和连续空间捏在一起”的读者。无论你是学生、刚转行的工程师,还是在项目里被规划模块折磨过一阵子的老手,读完应该都能明白:hybrid A* 不是玄学,它就是一个很聪明的“离散网格搜图 + 运动学模型积分 + 终点曲线闭合”的组合拳。下面我按我自己从零实现时的思路和踩坑经验,把整条主线捋一遍。
1. 先想清楚:泊车场景为什么非用混合A*不可
1.1 普通A*在泊车场景里的三个硬伤
很多人刚开始接触自动泊车规划时,第一反应是“这不就是路径搜索吗,A* 就够了”。我在模拟项目里调过普通A*,也见过项目里有人拿它试泊车,最后都绕不开三个致命问题。
第一个问题是状态维度。普通A* 的状态是这个格子坐标 (x, y),但车是一个不能原地转弯、不能横着走的刚体,它还有一个航向角 yaw。停车位里最后一小段往往需要精确调整车身姿态,你光知道车在哪个格子没用,还得知道车头冲哪个方向、方向盘的转角是多少、下一段是往前走还是倒着走。这直接导致普通A* 搜出来的路径根本没法直接控制。
第二个问题是运动学约束。普通A* 认为可以从一个格子走到任意相邻格子,连线是自由的,不管车辆最小转弯半径。泊车场景里,车必须在最小转弯半径的限制下移动,否则路径描出来“好看”,车辆到不了。就算你搜完再做平滑、再插值,姿态上的硬约束也很难修复。
第三个问题是最终精度。普通A* 的解是一串离散网格点,即便你做插值,也很难保证终点姿态和航向角精确对准,而泊车恰恰对终点位置和角度要求极高——停歪了、停斜了都算失败,控制模块很难收拾这个烂摊子。
1.2 hybrid A* 的核心思路:网格上搜索,连续空间生长
hybrid A* 的聪明之处在于把 A* 的框架保留了下来,但把“节点”的定义和“扩展节点”的过程给改了。
它在离散网格上做搜索、用网格管理节点去重,这个和 A* 一样。但区别在于:每次从堆顶弹出的节点,不再向四方或八方的邻居格子连线,而是按照车辆运动学模型,在连续空间里模拟一小段真实的行驶轨迹。比如规定方向盘打多少度、走多长时间,通过自行车模型积分出下一时刻车辆的位置和航向角,生成一个“带姿态”的新节点。
这个思路特别像“在豆腐上雕花”:网格是那把用来管理搜索规模的手术刀,但实际雕刻是在连续空间里进行的。用俗话讲,它是“在离散地图上撒网,在运动学空间里收线”。
1.3 为什么不是RRT*、多项式曲线或者纯几何法
也有不少人问:不用搜索方法,直接用Reeds-Shepp曲线连接起点终点行不行?或者用RRT*、用多项式规划行不行?我个人的体会是,它们各有各的适用场景,但在“结构化环境中的泊车”这个场景下,hybrid A* 的“搜索框架”是有不可替代的好处的。
RS曲线这些几何方法很快,但它在复杂障碍物环境下几乎必然穿过障碍物,因为它是纯粹的几何最短路径,完全不感知环境。你还需要额外的碰撞检查和绕障机制,稍有不慎就会陷入局部死胡同。RRT* 随机采样能绕障,但结果是“可行解”而未必是“像人开出来的解”,而且低速泊车场景对稳定性和可复现性要求高,随机采样在这种窄小车位里有概率性失败,排查起来也费劲。
多项式曲线和优化方法(比如数值最优控制)能生成非常优美的轨迹,但它们的计算成本高、初值敏感,在算力有限的嵌入式平台上做全局搜索很容易超时。hybrid A* 走的是工程折中:先用网格搜索保证“有解大概率能找到”,再用RS曲线保证“终点精确闭合”,最后做平滑得到一条能车跟踪的轨迹。这个分层思路非常可靠。
2. 从零开始写:核心数据结构与搜索主循环
2.1 状态定义与节点类怎么写
我写hybrid A* 时,第一个落笔的地方是节点类的定义,这一段直接影响后面所有代码的可读性。状态我用三元组 (x, y, yaw) 表示,因为这是平面泊车,不需要z方向和翻滚俯仰。
import math from dataclasses import dataclass @dataclass class HybridNode: x: float # 世界坐标 x,单位米 y: float # 世界坐标 y yaw: float # 航向角,单位弧度,范围[-pi, pi] steer: float = 0.0 # 前轮转角,单位弧度,用于记录控制量 parent: "HybridNode" = None # 父节点指针,回溯用 g: float = float("inf") # 从起点到当前点的累计代价 f: float = float("inf") # 估计总代价 = g + h is_reverse: bool = False # 当前节点相对于父节点是否为倒车 def __lt__(self, other): return self.f < other.f这里有几个细节值得展开讲一讲。
首先,为什么用 dataclass 而不是普通的 class?因为 Python 的 dataclass 会自动生成init、repr,调试时打印节点信息非常方便,写起来也干净。
其次,为什么加一个 is_reverse 标志位?因为高速扩张过程中,同一位置同一个航向角可能由前进和倒车两种不同方式到达,而这个标志位直接参与后面的代价惩罚计算。你如果在代价函数里不区分前进和倒车,最终搜出来的路径可能会频繁“搓方向盘换挡”,老司机绝对开不出来。
第三,为什么用 h 而不是等搜索结束再算?因为 A* 的堆排序需要 f 作为优先级,每次弹堆顶之前你就要把父节点的 g 和启发函数 h 加起来。
2.2 网格哈希与“一格一节点”策略
hybrid A* 最早出自知名论文,工程实现里最关键的一个技巧是“每个网格只保留一个代表性节点”,这也是它和普通 A* 在存储上的本质区别。
普通 A* 里每个格子就是一个节点,一个格子只会被展开一次。但 hybrid A* 中,同一个格子可能被很多不同航向角的节点访问到。如果全部保留,状态空间就是网格数乘以航向角离散数,搜索规模爆炸到根本吃不动。
所以标准做法是:把连续坐标映射到分辨率有限的网格,每个网格只保留“进入代价最小的那个节点”。如果有一个新节点经过计算发现它的 g 值比当前网格里存的旧节点还要小,就替换掉旧节点,并把它重新塞进堆里;否则这个新节点直接丢弃。
# 网格参数:位置分辨率 0.5m,航向分辨率 15度 GRID_RES = 0.5 YAW_RES = math.radians(15) def grid_hash(x, y, yaw): gx = int(round((x - X_MIN) / GRID_RES)) gy = int(round((y - Y_MIN) / GRID_RES)) gyaw = int(round(wrap_angle(yaw) / YAW_RES)) % int(round(2 * math.pi / YAW_RES)) return (gx, gy, gyaw) def is_better(state_key, new_g, g_table): # 如果该网格没有记录过代价,或者新的g值更小,则接受新节点 if state_key not in g_table: return True return new_g < g_table[state_key] - 1e-6这个“一格子一代表”策略牺牲了严格最优性,但换来了计算可行性。实际调试下来几乎不影响路径质量,因为在网格分辨率足够细的情况下,不同航向的节点差异不会大到影响宏观路径。
2.3 主循环:堆、闭合表、搜索截止条件
主体搜索循环我一开始写得很简陋,后来慢慢长成了下面这样,每一步都有它存在的理由。
import heapq # open_list: 优先队列,closed_set: 已扩展网格 key 集合 open_list = [] heapq.heappush(open_list, start_node) closed_set = set() while open_list: current = heapq.heappop(open_list) cur_key = grid_hash(current.x, current.y, current.yaw) # closed_set 解决重复扩展问题:一个网格key只正式扩展一次 if cur_key in closed_set: continue closed_set.add(cur_key) # 检查是否进入终点闭合范围 if distance(current, goal) < CLOSE_RADIUS: # 尝试用Reeds-Shepp曲线直接连到终点,成功则回溯返回 rs_path = try_rs_path(current, goal) if rs_path is not None: return build_final_path(current, rs_path) # 按预定义的离散控制输入扩展下一步运动 for next_node in expand(current): # 碰撞检测 + 边界检测 if is_collision(next_node) or not in_parking_area(next_node): continue next_key = grid_hash(next_node.x, next_node.y, next_node.yaw) if next_key in closed_set: continue if is_better(next_key, next_node.g, g_table): heapq.heappush(open_list, next_node)一个值得注意的细节是“先尝试 RS 闭合再扩展”。很多人把这个顺序写反了,先把当前节点的邻居全扩展完再去碰运气连RS曲线,结果是在已经在终点附近时还白白扩展很多无效节点,白白浪费时间。正确的直觉是:只要当前节点离终点足够近,就先尝试直接连终点,连上了就收工,因为 RS 曲线已经能处理连续姿态的精确对接。
另一个细节是 g_table 的更新。当你发现一个新的、g值更小的节点到达了同一个网格时,你不能只把它塞进 open_list,还必须同步更新 g_table 里的记录值,否则后面弹出的旧数据可能会错误触发 is_better 的条件,导致重复扩展或者错过更优路径。
3. 运动学模型、离散控制输入与碰撞检测
3.1 自行车模型是泊车规划的默认选择
在低速泊车场景下,车辆的动力学几乎可以忽略,轮胎侧偏、加速度限制都不用重点考虑,用最简单的自行车运动学模型就够了。
自行车模型的离散形式长这样:
DISCRETE_STEER = [-math.radians(30), -math.radians(15), 0, math.radians(15), math.radians(30)] STEP_TIME = 0.5 # 每个节点的模拟时长,单位秒 WHEELBASE = 2.8 # 轴距,单位米 VEL_FORWARD = 1.0 # 前进速度,单位m/s VEL_REVERSE = -0.8 # 倒车速度,单位m/s def move(x, y, yaw, steer, velocity, dt=STEP_TIME): # 自行车模型积分 x_new = x + velocity * math.cos(yaw) * dt y_new = y + velocity * math.sin(yaw) * dt yaw_new = yaw + velocity / WHEELBASE * math.tan(steer) * dt # 若倒车,航向角变化方向天然由速度符号决定 return (x_new, y_new, normalize_angle(yaw_new))这里最容易被忽略的是 yaw 的归一化。C++ 实现里如果不做角度规约到 [-pi, pi],角度会越积越大,最后在画路径和做哈希的时候经常出现莫名奇妙的定位错误。Python 里直接用自带的 math.atan2(sin(yaw), cos(yaw)) 归一即可。
我实测下来,转向角离散成 5 档、每档之间间隔 15 度,在普通停车场环境中能得到质量不错的路径。转角档位太少(比如只有 0、最大转角三档),路径会比较“愣”,拐弯处明显有段落感;档位太多(比如 9 档以上),搜索时间大幅增加,但路径质量提升有限。泊车场景推荐从 5 档起步。
3.2 碰撞检测不是点查询,而是“扫过车身包络”
碰撞检测是搜索效率的隐形杀手,很多初版实现都在这块写崩了。
你不能只检查当前节点对应的中心点是否在地图障碍物格子里,因为车有长宽,车头车尾在转弯时会扫过更大的包络。特别是倒车入库这种场景,车尾外摆扫过的区域经常比车身投影大出一截。
我在实现里用的是“沿轨迹采样 + 车身矩形覆盖 + 栅格检测”三步法。每一步的意图都值得说清楚。
def is_collision_state(x, y, yaw): # 1) 计算车辆四个角点(或简化为前后轴两个矩形) corners = vehicle_corners(x, y, yaw, LENGTH=4.5, WIDTH=1.9) # 2) 将矩形覆盖到的栅格坐标取交集 for cx, cy in corners: gx = grid_x(cx) gy = grid_y(cy) if obstacle_map[gx][gy]: return True return False def is_collision_trajectory(node_from, node_to): # 关键是沿轨迹插值采样,而不是只检查终点 samples = 10 # 采样数 for i in range(samples + 1): t = i / samples x = lerp(node_from.x, node_to.x, t) y = lerp(node_from.y, node_to.y, t) yaw = lerp_angle(node_from.yaw, node_to.yaw, t) if is_collision_state(x, y, yaw): return True return False这里有个细节:如果两个节点之间的运动不是直线,而是圆弧(实际上每次扩展都是按自行车模型积分出来的圆弧),那么采样时要在圆弧上取点,才能准确覆盖车身扫过的轨迹。我初版图省事直接在两节点坐标之间线性插值,结果某些大转角转向动作会漏检,轨迹贴着障碍物边缘擦过去,非常危险。
3.3 碰撞检测的“预计算”与“数据布局”
碰撞检测还有一个工程上的诀窍:把代价地图预先载入内存、用 2D bool 数组或者 numpy 数组,而不是每次查询时再去访问一张 PNG 或 YAML 地图文件。这点在 C++ 里特别重要,因为图片解码是硬盘 IO,每扩张一个节点可能解几十次,直接卡死。
Python 里如果用的是 numpy 数组,查询是 O(1) 的。如果你用 2D 列表,在 Python 里其实也能接受,但一定要避免在循环里调用一层map[y][x]再套一层...这种嵌套很深的查表写法,能压成map_flat[y * cols + x]就压平,实测能快不少。
碰撞检测的“下采样”也很关键。我在采样数上走过极端:一开始为了绝对安全用了 30 个采样点,结果每个节点的碰撞检测耗时是搜索循环里最大的开销,一个停车场环境要跑好几秒。后来发现 10 个采样点配合车身轮廓顶点检测已经完全够用,搜索时间立刻降了一半以上。采样点数太多会拖慢搜索,太少则容易漏检,10 到 15 是一个平衡点。
4. 代价函数与启发函数:影响路径风格的两个旋钮
4.1 代价函数:定义“什么是好路径”
hybrid A* 的搜索结果是代价函数定义下的最优路径,所以代价函数本质上就是“老司机风格开关”。同一个场景,你把倒车惩罚调大,它就会尽量避免倒车,可能绕一个大圈;你把倒车惩罚调小,它就频繁切挡,原地左右扭着出来,像新手在驾校练科目二。
我的 g 值代价设计成了这样几项加起来:
def edge_cost(from_node, to_node, map_info): cost = 0.0 # 1) 距离代价:移动距离越长,代价越高 dist = distance(from_node, to_node) cost += dist # 2) 倒车惩罚:倒车时给一个比较大的倍数 if to_node.is_reverse: cost += REVERSE_PENALTY * dist # 比如 1.5 ~ 2.0 # 3) 转向惩罚:方向盘转角越大,代价越高 # 目的是让轨迹尽量缓和,避免猛打方向 cost += STEER_PENALTY * abs(to_node.steer) # 4) 换挡惩罚:如果和父节点的前进/倒车状态不同,额外罚一次 if from_node and from_node.is_reverse != to_node.is_reverse: cost += GEAR_SHIFT_PENALTY # 比如 1.0 ~ 2.0 # 5) 离障碍物距离惩罚(可选):离障碍物太近给一点压力 d_obs = obstacle_distance(to_node.x, to_node.y) if d_obs < SAFE_DIST: cost += OBS_NEAR_PENALTY * (SAFE_DIST - d_obs) return cost各项惩罚的作用完全不同。距离代价保证路径最短;倒车惩罚让车辆尽量用前进挡走大弯;转向惩罚防止出现太陡的方向盘动作;换挡惩罚抑制频繁切换前进/倒车;离障碍物距离惩罚让轨迹不贴墙太近。某同事在做类似项目时,把倒车惩罚从 1.0 一次调到 3.5,结果原本能搜出来的路径直接搜不出来了——因为倒车代价太高,搜索宁愿绕极大一圈也拒绝倒车。惩罚项必须从小到大慢慢调,不能贪。
4.2 启发函数:用max还是用混合?
hybrid A* 的启发函数和普通 A* 不一样,它至少要用两个启发式,然后取最大值。
第一个是“无障碍启发”,忽略障碍物、只考虑当前位置到终点的运动学可达性。在无障碍的平面里,从状态A到状态B的最短路径可以用Reeds-Shepp曲线算出来,这个距离是运动学下界,已经非常紧凑。第二个是“迪杰斯特拉无障碍启发”,把地图里所有障碍物膨胀到车辆半径之后,在二维栅格上跑一遍简单的 Dijkstra,得到每个格子到终点的最短距离。这个值忽略了车辆的航向角约束,但考虑了障碍物。
最终用 max(rs_heuristic, dijkstra_heuristic) 作为启发值。
def heuristic(node, goal, dijkstra_table): h_rs = reed_shepp_distance((node.x, node.y, node.yaw), goal) h_dij = dijkstra_table[grid_x(node.x)][grid_y(node.y)] return max(h_rs, h_dij)为什么取 max 而不是加和?因为 A* 的启发函数必须“不大于实际代价”才能保证最优性,取 max 不会比实际代价更大,而且还能把两个不同维度的下界同时用起来。单纯加和可能导致启发值高估实际代价,从而丢掉理论上的最优解。
这里提一个容易踩的坑:如果你用的 RS 启发实现里调用了车辆的转弯半径参数,而这个参数和搜索时用的运动学模型不一致,启发值就可能与实际运动学可达性相差甚远,路径会变得诡异。必须保证启发函数的转弯半径和扩张模型的转弯半径一致,哪怕只差 10%,路径质量都会明显下降。
4.3 一个常被忽略的问题:固定转向代价的启发式可能不可采纳
上面讲的混合启发式,在严格意义下并不总是 admissible(可采纳)。原因是 RS 启发算出的是“无碰撞、角度精确、转向代价为零”的最短路,但你的搜索里带了转向惩罚、倒车惩罚、离障碍物惩罚。这些惩罚项会让实际代价大于 RS 启发给出的距离,启发值反而低于实际代价,理论最优性依然成立。
但如果你在启发函数里也加了转向代价(有些实现为了“更准”会这么做),得出启发值可能超过从当前节点到终点的实际代价,破坏可采纳性。我自己在项目里试过加转向代价的 RS 启发,结果搜索时间暴涨,搜出来的路径没比不加好多少。所以我的经验是:启发函数保持“纯运动学距离”、不加任何工程惩罚项,简单且稳健。
5. 终点闭合与路径平滑:让轨迹真正可跟踪
5.1 终点闭合:Reeds-Shepp曲线是怎么介入的
Reeds-Shepp(本文简称RS)曲线,简单说就是:在“车辆只能前进或倒车、受最小转弯半径约束”的条件下,从给定起点状态 (x, y, yaw) 到终点状态的最短路径,是由若干段圆弧和直线段拼接而成。它输出的是一串连续的弧线段,每一段都对应一个方向盘动作。
在搜索主循环里,我从开放列表中弹出节点后,会先判断它到终点的直线距离是否小于某个阈值(比如 20 米或者 8 米,按照车位场景来)。一旦小于阈值,马上调 RS 生成连续曲线,再做碰撞检测。
CLOSE_RADIUS = 8.0 # 根据场景调整 def try_rs_path(current, goal): rs = rs_gen(current.x, current.y, current.yaw, goal.x, goal.y, goal.yaw, min_turning_radius) if rs is None: return None if not is_collision_rs(rs): return rs return None这里“阈值”的选取很有讲究。阈值太大,还没怎么搜索就直接调 RS,很多情况下 RS 路径会穿越障碍物,反复尝试也浪费算力;阈值太小,搜索到终点附近才调 RS,搜索范围扩大、时间变长。我常用的做法是:先按“车辆长度 + 2 米”设一个初值,然后在真实环境里跑几次看轨迹质量。如果发现搜出来的路径经常在最后一段“绕远”,说明阈值偏小;如果发现 RS 连接经常失败,说明阈值偏大。
5.2 RS闭合不是终点,还要做平滑
RS 曲线本身带折线式转向,尤其是圆弧和直线段交界处,曲率不连续,车辆无法直接跟踪。所以工程实现里一般还会加一个平滑环节。
平滑的思路是对路径点做一个小型优化:设路径点为 P_i,目标函数包含“平滑性”、“与原始路径的贴近程度”、“避撞约束”三项。用梯度下降或者更简单的方法迭代更新 P_i。
我实现过最简单的一版平滑,参考了常见的轨迹平滑思路,每一步做:
for i in range(1, len(path) - 1): path[i] = path[i] + alpha * ( (path[i - 1] + path[i + 1] - 2 * path[i]) # 平滑项 - beta * (path[i] - original_path[i]) # 贴近原始路径 )alpha 是平滑强度,beta 是原始路径保留强度。平滑后的路径要重新做一次碰撞检测,因为平滑过程可能把路径拉进障碍物。这个环节在 C++ 工程里甚至会做两轮,先平滑再用碰撞地图检查,如果碰撞就局部重规划或者微调。
平滑不能乱调参数。alpha 太大、beta 太小,路径会“飘”出可行区域,轨迹倒是很顺滑,但撞墙了;alpha 太小,平滑效果不明显,锐角转弯依然在。我给项目的建议是 alpha 取 0.4 左右,beta 取 0.2 左右,迭代 20 到 30 次,每个场景实际调一次。平滑后的轨迹一定要用车辆模型重新算一遍转向角序列,如果算出来转向角超过车辆物理极限,说明平滑强度过大。
5.3 重建轨迹与转向角序列:给控制层的“成品”
搜索+RS闭合+平滑后,得到的是一条密集的连续路径点序列。真正交付给控制层,还需要做最后一步:把路径转化为带时间戳或长度的轨迹,计算每个路径点对应的转向角。
转向角计算直接反向用自行车模型:
def compute_steer(p0, p1): dx = p1.x - p0.x dy = p1.y - p0.y yaw_diff = wrap_angle(p1.yaw - p0.yaw) # 由 yaw_diff ≈ (v / L) * tan(steer) * dt 反解 if abs(dx) < 1e-6 and abs(dy) < 1e-6: return 0.0 dist = math.hypot(dx, dy) if dist < 1e-6: return 0.0 return math.atan2(yaw_diff * WHEELBASE, dist)这里有个细节:如果你直接对平滑后的路径点用曲率公式算转向角,得到的转向角序列在换挡点处会跳变,控制器跟踪时容易抖动。工程上通常对转向角序列再做一次低通滤波,或者限制转向角变化率。否则就会出现“规划轨迹是对的,但控制层就是跟不住”的窘境。这一点我在模拟环境里反复体会过,转交处直接给转向角,车辆就会有轻微横向摆动,需要在控制模块里加限幅才能舒服。
6. 泊车场景常见问题与调参经验实录
6.1 不同类型车位的参数差异
泊车场景不是一个参数打天下的。垂直车位、平行车位、斜列车位,它们的难点完全不同。
垂直车位(倒库)是 hybrid A* 最拿手的场景,因为空间相对规整,难点在于最后一段要对准库位中心线,RS闭合的精度要求高。平行车位(侧方停车)最麻烦,因为车位长度往往只比车长多几十厘米,搜索空间窄,路径必须走“先进、回调、再进”的多段倒车动作,倒车惩罚和换挡惩罚的平衡特别关键。斜列车位相对简单,因为车可以斜着进入,转弯半径压力小。
我给项目做参数配置时,通常以“垂直车位”为基准调通整套算法,再单独为平行车位调整倒车惩罚和挡位惩罚。平行车位场景建议把倒车惩罚调低到基准场景的 0.8 倍左右,让搜索敢于多倒几次车;而狭窄车位则要把离障碍物距离惩罚调高,防止路径贴着库边线走。
6.2 我踩过的四个坑
第一个坑是最经典的:网格分辨率太小,搜索慢到怀疑人生。我一开始把位置网格分辨率设到 0.1m,航向分辨率设到 5 度,结果 90% 的时间都花在碰撞检测和节点扩展上,一个普通停车场要跑几十秒才出路径。后来把位置分辨率调到 0.5m、航向调到 15 度,路径依然可用,搜索时间下降到原来的十分之一以下。实际项目里我要做任何参数实验,先确认分辨率没有设的过细。
第二个坑是倒车惩罚调太大,导致搜不出路径。在某公司车库里测试时,我把倒车惩罚设成前进代价的 3 倍以上,结果在狭窄空间里,因为不得不倒车,搜索直接在死胡同里打转。调参时注意:如果搜索发现不了路径,第一件事不是去改模糊的启发函数,而是先把倒车惩罚降下来看能不能搜出来。倒车惩罚安全区间通常在 0.5 到 2.5 之间,超过 3 我就基本不会再试了。
第三个坑是“一格一节点”策略导致路径不连贯。这个问题的根本原因是我在替换节点时,只比较了 g 值,没有考虑当前节点是从哪个方向进的,导致替换后父链断裂。后面我规定:如果新节点的 g 值低于旧节点,且两者的航向角差超过一个阈值(比如 30 度),则强制保留旧节点,或者给新节点一个额外的航向惩罚。这样避免了很多诡异的“跳变路径”。
第四个坑和碰撞检测的采样有关。我前期把采样数拉满,结果每次扩展都很慢,尤其是停车场边缘、车辆贴墙时,碰撞检测会成为搜索热路径上的最大瓶颈。实践下来采样 10 个点加车身矩形点的方案,已经能在成功率上达到 99% 以上,没必要追求 100% 的“绝对无碰撞”,因为后续控制层还有安全机制兜底。
6.3 快速验证与可视化技巧
如果你也在写类似的算法,我强烈建议做两件事。
第一件是轨迹可视化。把搜索扩展的所有节点画成浅色点、最终路径画成深色线,叠加在障碍物地图上。这样一来,你一眼就能看出搜索是否在无谓地绕远、是否频繁换挡、路径是否贴障碍物太近。我调参数时几乎每改一个参数就跑一次可视化,效率极高。
第二件是“日志分阶段打印”。在搜索循环里加上类似if current_dist < 5: print("节点数量:", len(closed_set), "当前f值:", current.f)这种调试代码,方便观察搜索是否收敛、收敛速度如何。这比每次都调到出路径再看结果高效得多。
还遇到过一种情况:搜索明明已经到达终点附近,RS闭合却反复失败,日志里也看不出所以然。后来发现不是碰撞也不是几何问题,而是启发函数里的迪杰斯特拉表没有更新:地图改动了,旧的栅格代价还留着,导致启发值严重高估,优先队列总在错误的方向上扩展。这类“地图更新但表格没刷新”的问题,在工程迭代里非常容易出现,值得在代码注释里重点标注。
写在最后
我个人在实际操作中的体会是:写 hybrid A* 最难的地方不是看懂公式,而是把“离散搜索”和“连续运动学”这两套思维方式在代码里真正拧到一起。你先跑通一个能出路径的版本,哪怕很慢、哪怕路径丑,后面的调优都不可怕。怕的是刚开始就追求完美,把启发函数、代价函数、RS闭合一次性堆上去,出了 bug 根本不知道问题在哪。
最后再分享一个小技巧:如果你在调倒车惩罚、转向惩罚这些权重时觉得参数太多、无从下手,可以固定其他参数,只留一个最重要的(比如倒车惩罚),用一维搜索的方式从 0.5 到 2.5 逐步试探,每试一次就把轨迹可视化保存下来。用不了十次,你就能找到这个场景下最像“老司机”的那组参数。