基于搜索的路径生成
- BFS (广度优先搜索)
- Dijkstra 算法 学习笔记
- A* (A-Star)
- 搜索算法对比:BFS vs Dijkstra vs A*
- 搜索代码分析
- 附录:关键车辆模型详解
本文先从 BFS 算法开始,介绍基于搜索的路径生成。然后再引导至当前实际工程中的搜索方法。
BFS (广度优先搜索)
1. 核心思想:逐层扩散
BFS 的核心思想可以比喻为“水波纹”:
- 它从一个
start(起点) 开始。 - 像波纹一样,一层一层地、均匀地向外探索。
- 它会先访问所有距离起点为1的节点(第1层),然后才访问所有距离为2的节点(第2层),以此类推。
2. 核心保证:最短路径
- 最优性:在无权重图(或所有边权重都为1)中,BFS 保证能找到步数最短的路径。
- 原因:由于它是逐层探索的,当它第一次到达
goal(终点) 时,所经过的“层数”必然是最少的。
3. BFS 的三大法宝(核心数据结构)
BFS 的实现依赖三个关键工具:
| 工具 | 数据结构 | 作用 |
|---|---|---|
| 1. 待办列表 | Queue (队列) |
(先进先出 - FIFO)。保证算法“逐层”按顺序探索。 |
| 2. 足迹记录 | Set (集合) |
( visited )。防止算法重复访问节点或陷入死循环。 |
| 3. 面包屑 | Dictionary (字典) |
( parent )。记录每个节点“从哪来”,用于在最后回溯路径。 |
4. 算法执行伪代码
1 | |
5. 算法特性总结
完备性 (Completeness): 是。只要路径存在,BFS 一定能找到。
最优性 (Optimality): 是 (仅限无权重图)。
时间复杂度 (Time): = 节点 (Vertex) 数量。 = 边 (Edge) 数量。(每个节点和每条边最多被访问一次)。
空间复杂度 (Space): (在最坏情况下,Queue 和 visited 集合可能需要存储所有 个节点)。
6. 算法缺陷
不懂成本: 找不到“最快”或“最短”的路,只能找到“转弯最少”的路。
搜索盲目: 像水波纹一样向所有方向搜索,在城市地图上会“撑爆”内存和算力。
反应僵硬: 无法应对实时路况(如行人、堵车),必须从头重算,太慢。
Dijkstra 算法 学习笔记
1. 核心思想:成本优先
Dijkstra (迪杰斯特拉) 算法是 BFS 的“升级版”。
- BFS 的目标:找到步数最少的路径。
- Dijkstra 的目标:在带权重的图中,找到累计成本最低的路径。
它的核心规则是:永远优先探索从起点出发,累计成本最低的那个““前沿” (frontier) 节点。
2. 核心保证:成本最优
- 最优性:是。只要图中没有负权重的边,Dijkstra 保证能找到从起点到所有其他节点的最低成本路径。
- 原因:由于它总是优先弹出累计成本最低的节点,所以当它第一次弹出
goal(终点) 时,它所记录的成本必然是全局最低的。(任何其他可能的路径,其成本在队列中时就已经高于这个值了)。
3. Dijkstra 的“升级工具” (Upgraded Tools)
Dijkstra 升级了 BFS 的数据结构,使其能够处理“成本” (cost)。
| 目的 | BFS (步数最少) | Dijkstra (成本最低) |
|---|---|---|
| 1. 待办列表 | Queue (先进先出) |
PriorityQueue (最小堆)(用 heapq 实现) 自动将成本最低的节点排到最前。 |
| 2. 足迹记录 | visited (Set) (只关心“去过没”) |
cost_so_far (Dictionary) (关心“花销多少”) |
| 3. 面包屑 | parent (Dictionary) |
parent (Dictionary) (工作方式相同,但只在找到更便宜路径时才更新) |
4. 算法执行伪代码 (专业版)
1 | |
5. 算法特性总结
完备性 (Completeness): 是。只要路径存在,一定能找到。
最优性 (Optimality): 是 (前提:图中没有负权重的边)。
时间复杂度 (Time): 或 = 节点数, = 边数。复杂度主要取决于优先队列 (heapq) 的性能。
空间复杂度 (Space): cost_so_far 和 parent 最多存储 个节点。pq 在最坏情况下也可能存储 个节点。
6. 算法缺陷
Dijkstra 最大的缺陷是效率,而不是正确性(在无负权边的情况下)。
它是一个“盲目搜索” (Blind Search) 算法。
- 缺陷描述:Dijkstra 算法虽然很“聪明”,会优先探索累计成本低的路径,但它没有**“方向感” (direction)**。
- 具体表现:如果您的终点
G在地图的东边,Dijkstra 算法在向东探索的同时,也会花费同样多的精力去探索西边、南边和北边的所有路径(只要它们的成本也很低)。 - 后果:它会探索大量“不相关” (irrelevant) 的区域,导致在大地图上效率低下。
一句话总结缺陷: Dijkstra 找到了最优的路径,但代价是盲目地探索了太多的区域,效率不高。
(这正是我们下一个要学的 A* 算法 所要解决的问题,A* 通过引入“启发函数” (heuristic) 解决了“盲目” (blindness) 问题。)
为什么负权重边会““破坏” (Break) Dijkstra?
因为负权重边打破了 Dijkstra 算法的根本前提。
-
Dijkstra 的根本前提(假设):
“一条路径的成本只会随着它的变长而增加(或保持不变)。它永远不会减少。
一旦我找到了一个到A节点成本为10的路径,我就最终确定了cost_so_far[A] = 10。我永远不需要再回头看A,因为任何未来能到达A的新路径,都必然是绕了更远的路,成本必定> 10。” -
负权重边如何打破这个前提:
负权重边意味着“捷径” (shortcuts) 或“回扣” (rebates),它允许你“时间倒流”,在未来找到一条更短的路径。
举例说明:
想象一个简单的地图,有三个节点:S (起点), A, B。
路径 1: S -> A
- 成本 = 2
路径 2: S -> B
- 成本 = 5
路径 3: B -> A
- 成本 = -4 (一个强大的““漩涡”)
Dijkstra 的执行过程:
-
初始化:
pq=[ (0, S) ]cost_so_far={ S: 0 }
-
Step 1:
- 弹出
(0, S)。 - 探索
S的邻居A和B。 - 找到
A:新成本是0 + 2 = 2。 - 找到
B:新成本是0 + 5 = 5。 - 更新法宝:
pq=[ (2, A), (5, B) ](A 在前,成本更低)cost_so_far={ S: 0, A: 2, B: 5 }
- 弹出
-
Step 2:
- 弹出成本最低的
(2, A)。 - Dijkstra 在此刻““敲定” (finalizes):“
A的最低成本绝对是2。” - (假设
A没有邻居,探索结束)
- 弹出成本最低的
-
Step 3:
- 弹出
(5, B)。 - 探索
B的邻居A。 - 找到
A:新成本是current_cost (5)+cost(B->A) (-4)= 1。 - 发现矛盾! 算法发现了一条到
A成本为1的新路径。
- 弹出
问题所在:
Dijkstra 算法在 Step 2 时,已经错误地““承诺” (finalized) A 的成本是 2。它(在简单实现中)不会再回头去更新 A 和 A 的所有邻居。它已经基于一个错误的前提(A=2)继续工作了。
一句话总结: 负权重边允许““未来” (later) 路径推翻““早期” (earlier) 的最优解,这违背了 Dijkstra 算法的贪心(Greedy)假设。
(注:专门用于处理负权重边的算法叫 Bellman-Ford,但它比 Dijkstra 慢得多。)
如果有负边会在那里来回走
A* (A-Star)
1. 核心思想:Dijkstra + “方向感”
A* 算法是 Dijkstra 算法的“智能”升级版。
- Dijkstra 的问题:它很“聪明”,总能找到成本最低的路径,但它很“盲目”。它会向所有方向探索“廉价”的路径,即使那个方向离终点十万八千里。
- A* 的解决方案:A* 在 Dijkstra 的“成本意识” (Cost) 基础上,增加了一个“指南针” (Compass),这个指南针永远指向终点。
这个“指南针”就是 启发函数 (Heuristic)。
2. A* 的核心公式
A* 通过一个新公式 来决定优先队列的顺序:
-
(G-Cost):从起点
S走到节点n的**“实际累计成本”**。- 作用:与 Dijkstra 一样,这是我们永久记录在
cost_so_far字典中的“真实花费”。 - 地位:算法的**“事实依据”**。
- 作用:与 Dijkstra 一样,这是我们永久记录在
-
(H-Cost):从节点
n走到终点G的**“估计成本”** (Heuristic)。- 作用:这就是“指南针”。它为算法提供了“方向感”。
- 最常用的:曼哈顿距离 (
abs(n.x - G.x) + abs(n.y - G.y))。 - 地位:算法的**“最佳猜测”**。
-
(F-Cost):从起点经过
n到达终点的**“估计总成本”**。- 作用:它的唯一作用,就是作为
heapq优先队列的**“排序标签”**。 - 地位:算法的**“决策依据”**。
- 作用:它的唯一作用,就是作为
3. A* 的“法宝” (数据结构)
A* 的法宝与 Dijkstra 几乎一样,但用途被严格分开了:
| 目的 | Dijkstra | A* (A-Star) |
|---|---|---|
| 1. 待办列表 | PriorityQueue (heapq)(存储 用于排序) |
PriorityQueue (heapq)(存储 用于排序) |
| 2. 成本记录 | cost_so_far (dict)(存储 ) |
g_cost_so_far (dict)(仍然只存储 ) |
| 3. 面包屑 | parent (dict) |
parent (dict) (完全不变) |
A* 最关键的原则:G-Cost () 用于永久记录,F-Cost () 用于队列排序。
4. 算法执行伪代码 (专业版)
1 | |
5. 算法特性总结
- 完备性 (Completeness): 是。只要路径存在,一定能找到。
- 最优性 (Optimality): 是,当且仅当启发函数 是“可接受的” (Admissible),即它永远不会高估到终点的实际成本。
- 时间复杂度 (Time): 。在实践中,由于 的引导,它远远快于 Dijkstra。
- 空间复杂度 (Space): 。
6. A* 的缺陷与局限
A* 已经非常接近“完美”的网格搜索算法,但它仍有局限:
- 静态算法:和 BFS/Dijkstra 一样,它假设地图是固定不变的。如果路径中途突然出现障碍物(如行人),它必须从头重新规划。 (注:D* Lite 等算法专门解决这个问题)。
- 路径“质量”问题:
- A* 找到的路径是“成本最低”的,但不一定是“最平滑”或“最安全”的。
- 如您所见,它会紧贴墙壁拐弯。我们必须手动修改成本函数(如增加
WALL_PENALTY)来“诱导”它走得更平滑,这不是算法自带的功能。
- 网格的诅咒:
- 它找到的路径是由网格点组成的,充满了 90 度的“锯齿”转弯。
- 在自动驾驶中,还需要一个“后处理”步骤(如路径平滑)才能将这些点变成车辆可以实际执行的平滑曲线。
- 启发函数的依赖:
- 如果 ,A* 退化为 Dijkstra(聪明但盲目)。
- 如果 高估了成本(“不可接受”),A* 会变得很快,但不再保证找到最优路径。
搜索算法对比:BFS vs Dijkstra vs A*
这三种算法是“基于搜索”的路径规划的基石,它们之间有着清晰的““升级””关系。
1. 算法的““升级””与““降级””关系
您可以将这三者视为一个不断进化的工具:
BFS (广度优先搜索) -> (升级) -> Dijkstra -> (升级) -> A*
1. BFS -> Dijkstra 的“升级”
- BFS 的缺陷:
它只懂““步数””,不懂““成本””。在它看来,走 1 公里的高速公路(1步)和走 1 公里的沼泽(1步)代价相同。 - Dijkstra 如何“升级”:
- 引入 (G-Cost):Dijkstra 引入了**““累计实际成本””的概念。它不再关心走了几步,只关心““到这里一共花了多少钱””**。
- 升级数据结构:它将 BFS 的
Queue(先进先出队列) 升级为PriorityQueue(最小堆),使其总是优先探索当前g(n)最低的节点。
结论:Dijkstra 就是““一个能处理带权重图(成本)的 BFS””。
““降级””:如果在一个 Dijkstra 算法中,你把所有的““成本”” (cost) 都设为 1,那么 Dijkstra 的行为将和 BFS 完全一致。
2. Dijkstra -> A* 的“升级”
- Dijkstra 的缺陷:
它虽然““聪明””(懂成本),但依旧““盲目””。它没有方向感。如果终点在东边,它会同时向西边探索,只要西边的路也足够便宜。 - A* 如何“升级”:
- 引入 (H-Cost):A* 引入了**““启发函数””(Heuristic),即一个指向终点的““指南针””。这是一个对““未来””成本的““最佳猜测””**。
- 升级决策公式:它不再像 Dijkstra 那样只根据 来排优先级,而是根据一个新的公式 来决定优先级。这使得它会有方向地、优先探索那些““看起来离终点更近””的节点。
结论:A* 就是““一个带有‘方向感’(启发函数)的 Dijkstra 算法””。
““降级””:如果在一个 A* 算法中,你把““启发函数”” 恒定设为 0,那么 ,A* 算法就完全退化成了 Dijkstra 算法。
2. 三种算法优缺点对比
| 算法 | 核心思想 (优先级) | 优点 (Pros) | 缺点 (Cons) |
|---|---|---|---|
| BFS (广度优先) | 先进先出 (FIFO) | 1. 简单,易于实现。 2. 保证找到步数最少的路径。 |
1. 致命缺陷:无法处理““权重””或““成本””。 2. 盲目搜索,效率低下。 |
| Dijkstra (迪杰斯特拉) | (G-Cost) (到起点的累计成本) |
1. 保证找到成本最低的路径。 2. 适用于绝大多数““最优路径””问题(只要没有负权边)。 |
1. 盲目搜索:没有方向感,会探索大量无关区域。 2. 效率低于 A*。 |
| A* (A-Star) | (G-Cost + H-Cost) |
1. Dijkstra 的所有优点:保证找到成本最低的路径(需 ““可接受””)。 2. 效率极高:受 引导,只探索““有希望””的区域。 |
1. 算法性能依赖 的好坏。 2. 与前两者一样,是静态算法,无法直接处理动态障碍物。 |
搜索代码分析
1.安全走廊构建
概述
在混合A* (Hybrid A*) 算法开始搜索之前,系统必须首先理解其运行的“世界模型”。这个世界模型就是“安全走廊”。
安全走廊的构建是 SafetyCorridorGenerator 类的核心职责。它的目标是利用来自地图和传感器的原始数据(如路沿、车道线),处理成一个清晰的、可供A*算法使用的“硬边界”(路沿)和“软边界”(车道线)。
此过程在 SafetyCorridorGenerator::process 函数中被调用。
步骤一:构建“龙骨” - 平滑的中心参考线
算法的第一步不是找边界,而是先确定一条平滑的中心“龙骨”,后续所有的边界都将相对这条“龙骨”来定义。
此步骤在 pre_process 函数中通过调用 setDiscreteReferencePoints 完成。
- 输入:函数接收一个包含多条原始参考折线的数据
ref_polylinesylines。 - 定位车辆:计算自车 (
ego_state_) 在这些原始折线上的投影位置 (ego_proj_len),以确定车辆的当前进度。 - 截取与采样:根据车辆的投影位置,在车辆前后截取一定范围(例如
corridor_re_Min_dist_到corridor_re_Max_dist_)的参考线段,并以5米的间隔进行粗略采样 (samplePtsBetween)。 - B样条平滑:
- A*搜索不能使用粗糙的折线,因此系统必须对其进行平滑。
- 它调用
shape_point_smoother_new_->Solve。 - 这个
ShapePointSmoother(来自shape_point_smoother.cpp) 是一个基于 B样条的OSQP(二次规划)优化器。 - 优化器的目标是找到一条B样条曲线,该曲线在尽可能贴近粗采样点的同时,保持自身的高度平滑(最小化曲率、加加速度等)。
- 输出 (龙骨):平滑后的结果被存储在
disref_polyline_和disref_polyline_pwg_(PiecewiseGeo 格式) 中。这条线就是后续所有计算的基准。
步骤二:构建“墙壁” - 筛选可见的路沿
有了中心“龙骨”后,系统开始构建走廊的“硬墙壁”,即路沿 (Road Edges)。系统不会使用所有路沿,而是通过一个精妙的“可见性”算法来筛选出最内侧的边界。
此步骤在 computeVisiblePoints 函数中执行。
- 遍历所有路沿:函数遍历从上游获取的
roadedge_table_中的每一条路沿线。 - 判断左/右:
- 对于每条路沿线,
LineDirectionjudge函数被调用。 - 此函数判断这条路沿线整体位于“龙骨” (
disref_polyline_pwg_) 的左侧还是右侧。
- 对于每条路沿线,
- “视线”可见性检查:
- 这是最核心的逻辑。系统遍历路沿上的每一个点。
- 调用
checkpointIsVisible函数。 - 此函数会从当前路沿点
point向它在“龙骨”上的投影点disref_polyline_pwg_.project(point).proj画一条虚拟“视线”。 - 它会检查这条“视线”是否与
polylines_table中的任何其他路沿线段相交。
- 筛选:
- 如果“视线”被遮挡(即相交),意味着这个点
point不是最内侧的边界(例如,它是外侧车道或匝道口的边界),该点被丢弃。 - 只有“可见”的点(即未被遮挡的点)才被认为是构成安全走廊的有效点。
- 如果“视线”被遮挡(即相交),意味着这个点
- 存储:所有可见的左侧点被添加到
ref_to_vis_points_table_Left_,右侧点添加到ref_to_vis_points_table_Right_。
连接成廊 - 形成最终边界
最后一步是将筛选出的离散的“可见点”连接成连续的走廊边界。
此步骤在 connectPointsToPolygon 函数中执行。
- 排序:
- 系统获取所有可见的左侧点 (
sortedVec_left) 和右侧点 (sortedVec_right)。 - 它根据点在“龙骨”上的投影距离 (
dis_s) 对这些点进行排序。
- 系统获取所有可见的左侧点 (
- 连接:
- 系统按排序顺序,将所有左侧可见点连接起来,形成连续的折线
corridor_polyline_left_。 - 系统按排序顺序(通常是逆序),将所有右侧可见点连接起来,形成
corridor_polyline_right_。 - 为了防止点过于稀疏,如果相邻点之间距离大于1.5米,还会进行插值 (
samplePtsBetween)。
- 系统按排序顺序,将所有左侧可见点连接起来,形成连续的折线
总结:输出
经过以上步骤,安全走廊的“硬边界”构建完成。系统生成了两个关键的成员变量:
corridor_polyline_left_:安全走廊的左侧“墙壁”(硬边界)。corridor_polyline_right_:安全走廊的右侧“墙壁”(硬边界)。
这两个变量将作为不可穿越的障碍物,在下一阶段被送入混合A*搜索器中。
搜索任务准备与并行化
1. 概述
在安全走廊(硬边界)构建完毕后,系统并不会立即开始搜索。它首先需要定义A*搜索的具体任务,包括:起点、终点、障碍物,以及最重要的——并行搜索的目标。
此过程主要在 SafetyCorridorGenerator::setSearchInputdata 函数中完成。
2. 步骤一:定义搜索的起点和终点
A*搜索需要一个明确的起点 (start_point) 和终点 (end_point)。
-
起点 (Start Point):
- 冷启动:如果这是第一次运行,或者上一帧的轨迹无效 (
stitch_info_.last_refline_valid == false),起点被设置为车辆的当前局部坐标,即(0, 0, 0)。 - 热启动 (轨迹缝合):在连续运行时,为了保证轨迹的平滑过渡,起点被设置为上一帧计算结果的最后一个点。这个点从
stitch_info_.points.back()中获取。
- 冷启动:如果这是第一次运行,或者上一帧的轨迹无效 (
-
终点 (End Point):
- 终点是动态计算的,而不是一个固定目标。
- 系统使用在 Part 1 中生成的平滑中心参考线 (
disref_polyline_pwg_)。 - 它沿着这条参考线,从自车当前投影点 (
ego_rel_s_) 向前推进一个固定的目标距离 (search_point_end_dist_)。 - 终点
ep就是这个推进距离在参考线上的对应点,其朝向也由参考线的切线方向决定。
3. 步骤二:加载障碍物与边界
系统需要将 Part 1 构建的安全走廊和所有其他感知信息转换为A*算法可以理解的格式。
-
加载硬边界 (路沿):
- Part 1 中生成的
corridor_polyline_left_和corridor_polyline_right_被加载。 - 它们被转换并存储在
search_data_current.obstacles中。在A*搜索期间,这些是不可穿越的(在ValidityCheck中检查)。
- Part 1 中生成的
-
加载软边界 (车道线):
- 系统遍历所有的车道线 (
laneline_table_)。 - 车道线被加载到
search_data_current.soft_boundary中。 - 这些边界在A搜索中被视为“软”障碍物。A算法可以穿越它们,但在穿越时会受到巨大的代价惩罚 (
soft_boundary_penalty_),使其倾向于不压线行驶。
- 系统遍历所有的车道线 (
-
加载粗参考线:
- Part 1 中生成的
disref_polyline_被加载到search_data_current.coarse_refline。 - 这条线将用于在A搜索期间计算启发式函数 (Heuristic),引导A算法沿着“正确的”方向前进。
- Part 1 中生成的
4. 步骤三:创建并行搜索任务 (多车道搜索)
这是本系统的一个高级特性。为了应对变道或大型障碍物,系统不会只在当前车道搜索一条路径,而是会同时创建并评估三条可能的路径。
-
创建三个数据副本:
search_data_current:用于搜索当前车道的轨迹。search_data_left:用于搜索向左变道后的轨迹。search_data_right:用于搜索向右变道后的轨迹。
它们都继承了相同的终点和障碍物信息。
-
检查变道可行性:
- 对于左/右变道任务,系统需要检查“立即变道”是否可行(例如,是否会立即撞上隔离带或另一条车道线)。
startpointcheck_inglobal函数被调用。- 此函数会从当前起点向左/右侧(偏移一个车道宽度
shift_lane_width)画一条虚拟的线,检查这条线是否会立即与路沿 (corridor_polyline_left_) 或车道线 (laneline_table_) 相交。
-
设置变道起点:
- 如果可行 (
startpointcheck_inglobal返回true):左/右搜索任务的起点会被立即设置为偏移一个车道宽度的位置 (search_data_left.start_point.y() = -shift_offset_left)。 - 如果不可行:
is_valid标志被设为false,该搜索任务后续将被跳过。
- 如果可行 (
5. Part 2 总结:输出
此过程完成后,系统生成了 search_data_vec_,这是一个包含三个搜索任务(Astar_Search_data)的向量。
- 任务1 (左):
is_valid = true/false,起点可能已偏移。 - 任务2 (中):
is_valid = true,起点为当前位置。 - 任务3 (右):
is_valid = true/false,起点可能已偏移。
这三个任务将被提交到线程池 (search_ThreadPool),在下一阶段被并行执行。
混合A*搜索算法
1. 概述
HybridAStar::performAstarSearch 函数 是混合A*算法的具体实现。它在 Part 1 构建的安全走廊内,根据 Part 2 定义的起止点,搜索一条运动学可行(Kinematically Feasible)的粗糙路径。
它严格遵循A*算法 的框架,但对其所有关键组件进行了“混合”定制。
2. 状态、栅格化 (Gridding) 与搜索列表
混合A*的“混合”特性体现在它如何在连续空间中搜索,同时又用离散栅格来管理搜索进度。
-
连续状态:
- 搜索的真实状态是连续的 位姿。
- 节点 (
Node3d) 存储的是高精度的double型 值。
-
离散栅格化 (Gridding):
- 为了管理“已访问”列表(
open_set_和close_set_),A*算法必须知道两个节点是否“几乎在同一位置”。 - 系统使用
xy_grid_resolution_(例如0.5米)和phi_grid_resolution_(例如0.05弧度)作为分辨率。 - 当一个
Node3d被创建时,它的连续 状态会被栅格化(类似于“四舍五入”),以生成一个唯一的离散索引ID(Node3d::GetIndex())。 - 例如: 和 这两个连续状态,在栅格化后可能共享同一个索引ID:“X_1_Y_0_Phi_2”。
- 为了管理“已访问”列表(
-
搜索列表 (Open/Close Sets):
open_pq_:一个优先队列,存储连续的Node3d对象。它根据 代价()自动排序,保证A*总是先探索 代价最低的节点。open_set_和close_set_:两个哈希表 (unordered_map),它们存储的是离散的索引ID。它们用于快速检查“这个栅格我们是否已经访问过?”。
3. A* 搜索循环
performAstarSearch 函数的主体是一个 while 循环。
- 初始化:将
start_node_放入open_pq_和open_set_。 - 循环条件:
while (!open_pq_.empty())。- 循环会一直运行,直到找到路径,或者
open_pq_变空(搜索失败),或者达到了最大探索节点数 (max_explored_num)。
- 循环会一直运行,直到找到路径,或者
- 弹出最优节点:
current_node = open_pq_.top().first:从优先队列中取出 代价最低的节点。open_pq_.pop()。
- 加入关闭列表:将
current_node的索引ID加入close_set_,表示“这个栅格我们已经处理过了”。 - 检查终点:调用
ChecknodeIsFinal。在您的代码中,这不仅是检查是否到达 ,而是检查车辆的纵向投影 (GetProjLength()) 是否已经超过了终点的投影 (endp_proj_len_) 且航向角接近。如果满足,则final_node_被设置,搜索趋于结束。 - 扩展节点:对
current_node进行节点扩展(见下文)。
4. 步骤一:节点扩展 (Node Expansion) - “Hybrid”的核心
这是混合A与传统A的根本区别。它不探索 栅格的8个邻居,而是模拟车辆的驾驶动作。
此步骤在 Next_node_generator 函数中实现。
- 生成离散动作:
- 循环
next_node_num_次(例如9次)。 - 生成一组离散的转向角,例如
steering = {-20°, -10°, 0°, 10°, 20°...}。 - 同时生成前进(
traveled_distance = step_size_)和后退(traveled_distance = -step_size_)两种动作。
- 循环
- 运动学模拟:
- 对于每一个动作(如“前进,转向10度”),函数会根据自行车运动学模型计算出新的连续位姿 。
next_phi = last_phi + traveled_distance / wheel_base * tan(steering)。next_x = last_x + traveled_distance * cos(next_phi)。next_y = last_y + traveled_distance * sin(next_phi)。
- 创建新节点:用 创建一个新的
Node3d对象 (next_node)。
5. 步骤二:碰撞检测 (Collision Checking)
对于 Next_node_generator 生成的每一个 next_node,系统都会进行严格的碰撞检测。
此步骤在 ValidityCheck 函数中实现。
- 获取包围盒 (Bounding Box):
Node3d::GetBoundingBox函数根据车辆的vehicle_param_(车长、车宽)和next_node的 位姿,计算出车辆的2D矩形包围盒。 - 检查重叠:
ValidityCheck遍历 Part 1 中构建的所有硬边界线段 (obstacles_linesegments_vec_),这包括了corridor_polyline_left_和corridor_polyline_right_。- 它检查车辆包围盒是否与任何一条硬边界线段发生重叠 (
bounding_box.HasOverlap(linesegment))。
- 丢弃节点:如果发生重叠(碰撞),
ValidityCheck返回false,A*搜索循环会立即丢弃这个next_node,不再对其进行代价计算。
6. 步骤三:代价计算 ( 和 )
如果 next_node 通过了碰撞检测,并且其栅格索引不在 close_set_ 中,系统就会调用 CalculateNodeCost 来计算其 代价。
- 真实路径代价
是从起点到当前节点**实际付出的代价**。它由两部分组成:-
运动代价 (
TrajCost):- 方向惩罚:后退的代价远高于前进(
traj_back_penalty_>traj_forward_penalty_)。 - 换挡惩罚:如果动作从前进变为后退(或反之),施加
traj_gear_switch_penalty_。 - 转向惩罚:惩罚大幅度转向(
traj_steer_penalty_ * steer^2)。 - 转向变化惩罚:惩罚方向盘的快速变化(
traj_steer_change_penalty_ * (steer - last_steer)^2)。
- 方向惩罚:后退的代价远高于前进(
-
软边界代价 (
LineCrossCost):- 此函数检查从
current_node到next_node的连线是否穿越了任何软边界(车道线soft_laneline_linesegments_vec_)。 - 如果穿越,施加一个巨大的代价 (
soft_boundary_penalty_ * 100)。 - 这使得A*算法会“极不情愿”地压线,但如果这是唯一的路径,它仍然会选择压线(与硬边界的“不可穿越”形成对比)。
- 此函数检查从
- 启发式函数: HoloObstacleHeuristic
是对未来代价的**估计**,用于“引导”A*算法朝终点前进。`Holo` 是 "Holonomic"(全向的)的缩写,意为在预估代价时,**暂时假装车辆是一个可以360°全向移动的“点”**,从而忽略非全向的运动约束(如转向、朝向)。这保证了 永远不会高估实际代价。
您的代码中 包含了两种实现:
1. 实现方式一 (被注释掉): 2D栅格搜索 (真正的“全向-障碍物”启发)
这是最经典、最智能的启发函数,通过 grid_a_star_heuristic_generator_ 和 grid_search.cpp 实现。
- 预计算: 在混合A开始前,先运行一个2D栅格A (
GenerateDpMap)。 - 反向搜索: 这个2D A*从终点开始,反向搜索并绕过所有硬障碍物(路沿)。
- 生成DP Map: 它生成一个代价地图 (
dp_map_),图中每个单元格的值是“从该点到终点的最短无障碍距离”。 - 查询: 混合A*在运行时,通过
CheckDpMap查询dp_map_即可得到 值。 - 优点: 完美考虑了障碍物,引导性最强。
2. 实现方式二 (代码中实际使用): 基于粗轨迹 (“全向-无障碍物”启发)
您的代码实际运行的是一个更简单、针对车道环境高度优化的版本。它完全忽略障碍物,仅依赖 Part 1 中生成的平滑中心参考线 (rough_line_pwg_)。
1 | |
- 工作原理:它将“全向”代价分解为:
- 横向代价:车辆“横移”到参考线上的代价,基于
dist_lat。 - 纵向代价:车辆沿参考线行驶到终点的代价,即
dist_long。
- 横向代价:车辆“横移”到参考线上的代价,基于
- 最终 = 。
- 优点: 计算极快(一次投影)。在有清晰参考线的道路上,会强烈引导A*算法贴着参考线前进。
- 缺点: 完全无视障碍物,在复杂路口或障碍物遮挡参考线时,引导性会下降。
输出
当 while 循环结束(因为找到了 final_node_),GetNodeResult 函数会被调用。
- 路径回溯:
GetNodeResult从final_node_开始,通过GetPreNode()指针链条反向回溯到start_node_。 - 输出:它将回溯路径上的所有 坐标点收集起来,存储在
result->x,result->y,result->phi中。
此阶段的输出是一条粗糙的、由短线段和圆弧组成的路径。
- 优点:它100%保证了运动学可行性(车能开),并且100%避开了硬边界(路沿)。
- 缺点:它在转向连接处是不平滑的(曲率不连续),不能直接用于车辆控制。
这条粗糙路径将作为“强参考”,在 Part 4 中被送入B样条优化器进行最终的平滑处理。
轨迹平滑与优化
1. 概述
混合A*搜索(Part 3)的输出 result->x, result->y 是一条由圆弧和直线段组成的粗糙路径。它虽然避开了所有硬障碍,但驾驶体验会非常糟糕。
本阶段的目标是利用这条A*路径作为“强参考”,通过B样条二次规划 (QP) 优化,生成一条最终的、平滑的、可执行的轨迹。
此过程在 HybridAStar::performAstarSearch 函数的后半部分 和 SafetyCorridorGenerator::performSearch 的末尾执行。
2. 步骤一:路径加密 (Densification)
A*算法输出的节点是稀疏的(例如每1米一个点)。为了给优化器提供更丰富的参考信息,系统首先对其进行加密。
- 函数:
HybridAStar::Densification_result。 - 操作: 它沿着A*的粗糙路径进行插值,生成一个点更密集的路径(例如,每0.5米一个点)。
3. 步骤二:填充优化约束 (Constraint Filling)
这是将“物理世界”转译为“数学问题”的关键一步。系统需要告诉优化器,这条轨迹必须在哪些边界内。
- 函数:
HybridAStar::FillSearchResultToOptimizer。 - 约束池: 此函数使用
constraint_pool_(来自constraint_pool.cpp)。 - 查询边界:
- 它遍历加密后的A*路径上的每一个点。
- 对于每个点,它调用
constraint_pool_.queryConstraineLineSeg。 constraint_pool_会在 Part 1 构建的安全走廊(corridor_polyline_left_和corridor_polyline_right_)以及软边界(车道线)中,为这个点动态查询出离它最近的左、右边界线段。
- 输出: 生成一个完整的约束集,其中A*路径上的每个点都“绑定”了它必须遵守的局部边界。
4. 步骤三:B样条二次规划 (B-Spline QP) 平滑
这是轨迹生成的最后一步,也是计算量最大的一步。
- 调用求解器: 系统调用
search_result_smoother_->Solve()。 - 求解器身份:
search_result_smoother_(来自shape_point_smoother.cpp) 实际上是一个OSQP(二次规划)求解器的接口 (newOsqpInterface)。这与 Part 1 中用于平滑参考线的优化器是同一类。
OSQP求解器会解一个复杂的数学优化问题:
-
目标函数 (Cost Function) -
CalculateKernel- “找到一条B样条曲线,使其总代价最小”。
- 总代价 =
- (平滑后轨迹**偏离A*参考路径**的距离) (`weight_p_`)
-
- (曲线的速度/一阶导数) (`weight_v_`)
-
- (曲线的加速度/二阶导数) (`weight_a_`)
-
- (曲线的加加速度Jerk/三阶导数) (`weight_j_`)
-
- (曲线的加加加速度Snap/四阶导数) (`weight_s_`)
- 核心思想: 权重 (位置权重
weight_p_) 保证了轨迹不会偏离A*找到的安全路径。其他权重 到 保证了轨迹本身是极其平滑、舒适、易于控制的。
-
约束条件 (Constraints) -
CalculateAffineConstraint- “在满足以下硬约束的前提下,最小化上述总代价”:
- 边界约束: B样条曲线上的所有点,必须位于
FillSearchResultToOptimizer(步骤二) 提供的左、右硬边界(路沿)和软边界(车道线)之间。
5. 步骤四:轨迹选择与最终输出
至此,Part 2 中提交的三个并行搜索任务(左、中、右)均已完成(或因无效而被跳过)。
此步骤在 SafetyCorridorGenerator::performSearch 的末尾执行。
- 收集结果: 系统现在手握最多三个平滑且可行的轨迹方案(存储在
search_thread_map中)。 - 选择最优轨迹:
- 系统需要从这三个方案中选出一个“最佳”方案。
- 它使用一个简单的“最近”逻辑:遍历所有有效轨迹上的所有点,找出离车辆当前位置 (
car_current_pos) 最近的那个点。 - 包含这个“最近点”的整条轨迹,被选为最终轨迹 (
nearst_road_id)。
- 最终组装 (Output):
- 系统将上一帧的缝合轨迹 (
stitch_info_) 与新选出的这条最优轨迹 (result.optimized_x,result.optimized_y等) 拼接在一起。 - 最终结果被存储在
disref_positon_,disref_heading_,disref_curvature_中,作为最终的执行轨迹被发送给控制模块。
- 系统将上一帧的缝合轨迹 (
6. Part 4 总结:最终轨迹
整个流程的最终输出是一条:
- 连续且平滑 (B样条优化)
- 严格遵守硬边界 (A*碰撞检测 + QP约束)
- 运动学可行 (A*运动学扩展)
- 高可用 (并行多车道搜索)
的轨迹,完美地结合了混合A*的路径查找能力和二次规划的平滑优化能力。
附录:关键车辆模型详解
在整个轨迹生成流程中,车辆的物理特性通过几个关键模型被抽象和应用。理解这些模型是理解混合A*和后续优化的基础。
1. 阿克曼转向 (Ackermann Steering) - “为什么需要模型”
这个概念是现实世界中的物理原理。它描述了汽车在转弯时,为了防止轮胎侧滑(想象一下车轮在地上横着“搓”),四个轮子必须满足的几何条件。
核心思想
当汽车转弯时,所有四个车轮都必须围绕一个共同的圆心旋转。这个点我们称之为瞬时旋转中心 (Instantaneous Center of Rotation, ICR)。
- 看内侧和外侧:想象一下,在转弯时,内侧前轮走的圆圈半径 R_inner 总是小于外侧前轮走的圆圈半径 R_outer。
- 关键结论:为了让它们都指向同一个ICR,内侧前轮的转向角度()必须大于外侧前轮的转向角度()。
- 实现:在您的真实汽车上,这是通过一个精巧的机械结构(称为“转向梯形”)来实现的。
(这个我们玩乐高和besiege的时候都很熟悉了hhh)
2. 自行车模型 (Bicycle Model) - “如何模拟转向”
自行车模型是对阿克曼转向原理的数学简化。它是您代码中混合A*算法的核心运动学模型。
核心思想
我们不模拟四个轮子,太复杂了。我们把车简化成一辆自行车:
- 把两个后轮合并成一个“虚拟后轮”。
- 把两个前轮合并成一个“虚拟前轮”。
这辆“自行车”的状态(位姿)可以用三个变量来描述:
- :虚拟后轮中心的位置。
- (theta):自行车的朝向(航向角)。
如何在代码中使用 (Next_node_generator)
A*搜索不是基于时间 和速度 的,而是基于一个固定的步长 (即 traveled_distance)。
代码将描述自行车运动的微分公式改写为“离散”的差分公式,用于计算A*的下一个节点:
-
关键参数:
- :车辆轴距(前后轮之间的距离)。在您的代码中是 `vehicle_param_.wheel_base()`。
- (delta):虚拟前轮的转向角。在您的代码中是 `steering`。
- :当前节点(`current_node`)的位姿。
-
计算新朝向 :
- 新朝向角的变化
- 计算公式:
-
计算新位置 :
- 新位置的变化
- 新位置的变化
- 计算公式(您的代码中使用 进行积分,这是一种欧拉积分法):
总结:自行车模型是一个数学公式,它能精确地告诉A*算法:“如果我在 ,打 这么多方向,前进 这么远,我会到达哪个新位姿 ”。这就是混合A*实现“运动学可行”的根本。
3. 纵向动力学 (Longitudinal Dynamics) - “如何规划速度”
这个概念与“转向”(横向)无关,它只关心沿路径前进和后退(纵向)。
在自动驾驶规划中,一个非常聪明且常见的技巧是**“解耦”**(Decoupled)规划:
-
Step 1. 路径规划 (Path Planning):
- 任务:先不管速度,只用自行车模型(运动学)找到一条几何上可行的 路径。
- 执行者:
HybridAStar::performAstarSearch。
-
Step 2. 速度规划 (Speed Profile):
- 任务:把Step 1找到的路径当成一条固定的“1D轨道”。现在,只在这条轨道上规划 (速度, 加速度, 加加速度)。
- 执行者:
HybridAStar::GenerateSCurveSpeedAcceleration。
纵向动力学模型如何工作
您的代码没有使用复杂的发动机、刹车或轮胎力模型。它使用了一个更简单、更强大的基于约束的优化模型。
GenerateSCurveSpeedAcceleration 所做的事情是,调用 PiecewiseJerkSpeedProblem 求解器,并给它一组规则(动力学约束):
- 规则1 (速度限制):你的速度 永远不能超过
max_forward_v_或max_reverse_v_。 - 规则2 (加速度限制):你的加速度 永远不能超过
max_forward_acc_或max_reverse_acc_。这代表了车辆“油门踩到底”或“刹车踩到底”的能力。 - 规则3 (Jerk限制):你的加加速度 (Jerk) 永远不能超过
max_acc_jerk_。Jerk代表了你踩油门/刹车的“快慢”。限制Jerk是为了防止顿挫,保证乘客舒适性。
优化目标:求解器 PiecewiseJerkSpeedProblem 的目标是,在严格遵守上述所有动力学规则的前提下,找到一条从 到 的速度曲线,使其总Jerk最小(即最平滑、最舒适)。
总结:三者关系
| 概念 | 类别 | 解决的问题 | 在代码中的体现 |
|---|---|---|---|
| 阿克曼转向 | 物理原理 | “为什么”:为什么车轮需要差速转向? | (无代码) 它是自行车模型的理论基础。 |
| 自行车模型 | 运动学 (Kinematics) | “怎么转弯”:给定转向和距离,计算新的 。 | HybridAStar::Next_node_generator |
| 纵向动力学 | 动力学 (Dynamics) | “怎么加减速”:给定 路径,计算可行的 。 | HybridAStar::GenerateSCurveSpeedAcceleration |