战斗包子
基于搜索的路径生成

基于搜索的路径生成

本文先从 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function BFS(start, goal):
# 1. 初始化三大法宝
queue = new Queue() // 放入起点
queue.add(start)

visited = new Set() // 标记起点
visited.add(start)

parent = new Map() // 记录起点
parent.set(start, null)

# 2. 循环,直到队列为空
while queue.is_not_empty():

current_node = queue.pop_front() # 从队列头部取出

# 3. 检查是否到达终点
if current_node == goal:
return reconstruct_path(parent, start, goal) # 成功!

# 4. 遍历邻居
for neighbor in get_neighbors(current_node):

# 5. 关键检查:是否是“新”节点
if neighbor not in visited:

# 6. 标记、入队、记录
visited.add(neighbor) # 标记为已访问
parent.set(neighbor, current_node) # 记录父节点
queue.add_back(neighbor) # 放入队尾,等待探索

# 7. 队列为空,仍未找到
return "No Path Found"

5. 算法特性总结

完备性 (Completeness): 是。只要路径存在,BFS 一定能找到。
最优性 (Optimality): 是 (仅限无权重图)。
时间复杂度 (Time): O(V+E)O(V + E)VV = 节点 (Vertex) 数量。EE = 边 (Edge) 数量。(每个节点和每条边最多被访问一次)。
空间复杂度 (Space): O(V)O(V)(在最坏情况下,Queue 和 visited 集合可能需要存储所有 VV 个节点)。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function Dijkstra(start, goal):
# 1. 初始化
pq = new PriorityQueue()
heapq.heappush(pq, (0, start)) # (cost, node)

cost_so_far = {start: 0}
parent = {start: None}

# 2. 循环,直到队列为空
while pq.is_not_empty():

current_cost, current_node = heapq.heappop(pq) # 弹出 *成本最低* 的

# 3. (关键优化) 处理“过时”节点
if current_cost > cost_so_far[current_node]:
continue # 这是一条旧的、更贵的路径,跳过

# 4. 检查终点
if current_node == goal:
return reconstruct_path(parent, start, goal) # 成功!

# 5. 遍历邻居
for neighbor in get_neighbors(current_node):

# 6. 计算新路径的 *累计成本*
new_cost = current_cost + get_cost(neighbor)

# 7. (Dijkstra 的灵魂) 检查是否是条 *更好的* 路径
if neighbor not in cost_so_far or new_cost < cost_so_far[neighbor]:

# 8. 是!更新所有记录
cost_so_far[neighbor] = new_cost
parent[neighbor] = current_node
heapq.heappush(pq, (new_cost, neighbor))

# 9. 队列为空,仍未找到
return "No Path Found"

5. 算法特性总结

完备性 (Completeness): 是。只要路径存在,一定能找到。
最优性 (Optimality): 是 (前提:图中没有负权重的边)。
时间复杂度 (Time): O((E+V)logV)O((E+V) \log V)O(ElogV)O(E \log V)VV = 节点数, EE = 边数。复杂度主要取决于优先队列 (heapq) 的性能。
空间复杂度 (Space): O(V)O(V)cost_so_far 和 parent 最多存储 VV 个节点。pq 在最坏情况下也可能存储 O(V)O(V) 个节点。

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 的执行过程:

  1. 初始化

    • pq = [ (0, S) ]
    • cost_so_far = { S: 0 }
  2. Step 1

    • 弹出 (0, S)
    • 探索 S 的邻居 AB
    • 找到 A:新成本是 0 + 2 = 2
    • 找到 B:新成本是 0 + 5 = 5
    • 更新法宝:
      • pq = [ (2, A), (5, B) ] (A 在前,成本更低)
      • cost_so_far = { S: 0, A: 2, B: 5 }
  3. Step 2

    • 弹出成本最低的 (2, A)
    • Dijkstra 在此刻““敲定” (finalizes):“A 的最低成本绝对2。”
    • (假设 A 没有邻居,探索结束)
  4. Step 3

    • 弹出 (5, B)
    • 探索 B 的邻居 A
    • 找到 A:新成本是 current_cost (5) + cost(B->A) (-4) = 1
    • 发现矛盾! 算法发现了一条到 A 成本为 1 的新路径。

问题所在:
Dijkstra 算法在 Step 2 时,已经错误地““承诺” (finalized) A 的成本是 2。它(在简单实现中)不会再回头去更新 AA 的所有邻居。它已经基于一个错误的前提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* 通过一个新公式 f(n)f(n) 来决定优先队列的顺序:

f(n)=g(n)+h(n)f(n) = g(n) + h(n)
  • g(n)g(n) (G-Cost):从起点 S 走到节点 n 的**“实际累计成本”**。

    • 作用:与 Dijkstra 一样,这是我们永久记录cost_so_far 字典中的“真实花费”。
    • 地位:算法的**“事实依据”**。
  • h(n)h(n) (H-Cost):从节点 n 走到终点 G 的**“估计成本”** (Heuristic)。

    • 作用:这就是“指南针”。它为算法提供了“方向感”。
    • 最常用的曼哈顿距离 ( abs(n.x - G.x) + abs(n.y - G.y) )。
    • 地位:算法的**“最佳猜测”**。
  • f(n)f(n) (F-Cost):从起点经过 n 到达终点的**“估计总成本”**。

    • 作用:它的唯一作用,就是作为 heapq 优先队列的**“排序标签”**。
    • 地位:算法的**“决策依据”**。

3. A* 的“法宝” (数据结构)

A* 的法宝与 Dijkstra 几乎一样,但用途被严格分开了:

目的 Dijkstra A* (A-Star)
1. 待办列表 PriorityQueue (heapq)
(存储 g(n)g(n) 用于排序)
PriorityQueue (heapq)
(存储 f(n)f(n) 用于排序)
2. 成本记录 cost_so_far (dict)
(存储 g(n)g(n))
g_cost_so_far (dict)
(仍然只存储 g(n)g(n))
3. 面包屑 parent (dict) parent (dict)
(完全不变)

A* 最关键的原则G-Cost (gg) 用于永久记录,F-Cost (ff) 用于队列排序。


4. 算法执行伪代码 (专业版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function A_Star(start, goal):
# 1. 初始化
# g_cost_so_far 只记录 G-Cost
g_cost_so_far = {start: 0}
parent = {start: None}

# pq (优先队列) 只记录 F-Cost
pq = new PriorityQueue()
h_start = heuristic(start, goal)
f_start = g_cost_so_far[start] + h_start
heapq.heappush(pq, (f_start, start)) # (F-Cost, node)

# 2. 循环
while pq.is_not_empty():

current_f_cost, current_node = heapq.heappop(pq)

# 3. 检查终点
if current_node == goal:
return reconstruct_path(parent, start, goal) # 成功!

# (专业优化:可以在这里检查弹出的节点是否“过时”)
# if current_f_cost > g_cost_so_far[current_node] + heuristic(current_node, goal):
# continue

# 4. 遍历邻居
for neighbor in get_neighbors(current_node):

# 5. 计算 *新* 的 G-Cost
# g(n) = g(current) + step_cost
step_cost = get_cost(neighbor) # (+ wall_penalty, etc.)
new_g_cost = g_cost_so_far[current_node] + step_cost

# 6. (A* 的灵魂) 检查 G-Cost 记录
if neighbor not in g_cost_so_far or new_g_cost < g_cost_so_far[neighbor]:

# 7. 更新 G-Cost 记录 (永久记录)
g_cost_so_far[neighbor] = new_g_cost

# 8. 计算 F-Cost (用于排序)
h_cost = heuristic(neighbor, goal)
f_cost = new_g_cost + h_cost

# 9. 推入队列
heapq.heappush(pq, (f_cost, neighbor))
parent[neighbor] = current_node

# 10. 队列为空
return "No Path Found"

5. 算法特性总结

  • 完备性 (Completeness): 。只要路径存在,一定能找到。
  • 最优性 (Optimality): 当且仅当启发函数 h(n)h(n) 是“可接受的” (Admissible),即它永远不会高估到终点的实际成本。
  • 时间复杂度 (Time): O((E+V)logV)O((E+V) \log V)。在实践中,由于 h(n)h(n) 的引导,它远远快于 Dijkstra。
  • 空间复杂度 (Space): O(V)O(V)

6. A* 的缺陷与局限

A* 已经非常接近“完美”的网格搜索算法,但它仍有局限:

  1. 静态算法:和 BFS/Dijkstra 一样,它假设地图是固定不变的。如果路径中途突然出现障碍物(如行人),它必须从头重新规划。 (注:D* Lite 等算法专门解决这个问题)。
  2. 路径“质量”问题
    • A* 找到的路径是“成本最低”的,但不一定是“最平滑”或“最安全”的。
    • 如您所见,它会紧贴墙壁拐弯。我们必须手动修改成本函数(如增加 WALL_PENALTY)来“诱导”它走得更平滑,这不是算法自带的功能。
  3. 网格的诅咒
    • 它找到的路径是由网格点组成的,充满了 90 度的“锯齿”转弯。
    • 在自动驾驶中,还需要一个“后处理”步骤(如路径平滑)才能将这些点变成车辆可以实际执行的平滑曲线。
  4. 启发函数的依赖
    • 如果 h(n)=0h(n) = 0,A* 退化为 Dijkstra(聪明但盲目)。
    • 如果 h(n)h(n) 高估了成本(“不可接受”),A* 会变得很快,但不再保证找到最优路径。

搜索算法对比:BFS vs Dijkstra vs A*

这三种算法是“基于搜索”的路径规划的基石,它们之间有着清晰的““升级””关系。

1. 算法的““升级””与““降级””关系

您可以将这三者视为一个不断进化的工具:

BFS (广度优先搜索) -> (升级) -> Dijkstra -> (升级) -> A*


1. BFS -> Dijkstra 的“升级”

  • BFS 的缺陷
    它只懂““步数””,不懂““成本””。在它看来,走 1 公里的高速公路(1步)和走 1 公里的沼泽(1步)代价相同。
  • Dijkstra 如何“升级”
    1. 引入 g(n)g(n) (G-Cost):Dijkstra 引入了**““累计实际成本””的概念。它不再关心走了几步,只关心““到这里一共花了多少钱””**。
    2. 升级数据结构:它将 BFS 的 Queue (先进先出队列) 升级为 PriorityQueue (最小堆),使其总是优先探索当前 g(n) 最低的节点。

结论:Dijkstra 就是““一个能处理带权重图(成本)的 BFS””。

““降级””:如果在一个 Dijkstra 算法中,你把所有的““成本”” (cost) 都设为 1,那么 Dijkstra 的行为将和 BFS 完全一致


2. Dijkstra -> A* 的“升级”

  • Dijkstra 的缺陷
    它虽然““聪明””(懂成本),但依旧““盲目””。它没有方向感。如果终点在东边,它会同时向西边探索,只要西边的路也足够便宜。
  • A* 如何“升级”
    1. 引入 h(n)h(n) (H-Cost):A* 引入了**““启发函数””(Heuristic),即一个指向终点的““指南针””。这是一个对““未来””成本的““最佳猜测””**。
    2. 升级决策公式:它不再像 Dijkstra 那样只根据 g(n)g(n) 来排优先级,而是根据一个新的公式 f(n)=g(n)+h(n)f(n) = g(n) + h(n) 来决定优先级。这使得它会有方向地优先探索那些““看起来离终点更近””的节点。

结论:A* 就是““一个带有‘方向感’(启发函数)的 Dijkstra 算法””。

““降级””:如果在一个 A* 算法中,你把““启发函数”” h(n)h(n) 恒定设为 0,那么 f(n)=g(n)+0f(n) = g(n) + 0,A* 算法就完全退化成了 Dijkstra 算法。


2. 三种算法优缺点对比

算法 核心思想 (优先级) 优点 (Pros) 缺点 (Cons)
BFS (广度优先) 先进先出 (FIFO) 1. 简单,易于实现。
2. 保证找到步数最少的路径。
1. 致命缺陷:无法处理““权重””或““成本””。
2. 盲目搜索,效率低下。
Dijkstra (迪杰斯特拉) g(n)g(n) (G-Cost)
(到起点的累计成本)
1. 保证找到成本最低的路径。
2. 适用于绝大多数““最优路径””问题(只要没有负权边)。
1. 盲目搜索:没有方向感,会探索大量无关区域。
2. 效率低于 A*。
A* (A-Star) f(n)=g(n)+h(n)f(n) = g(n) + h(n)
(G-Cost + H-Cost)
1. Dijkstra 的所有优点:保证找到成本最低的路径(需 h(n)h(n)““可接受””)。
2. 效率极高:受 h(n)h(n) 引导,只探索““有希望””的区域。
1. 算法性能依赖 h(n)h(n) 的好坏。
2. 与前两者一样,是静态算法,无法直接处理动态障碍物。

搜索代码分析

1.安全走廊构建

概述

在混合A* (Hybrid A*) 算法开始搜索之前,系统必须首先理解其运行的“世界模型”。这个世界模型就是“安全走廊”。

安全走廊的构建是 SafetyCorridorGenerator 类的核心职责。它的目标是利用来自地图和传感器的原始数据(如路沿、车道线),处理成一个清晰的、可供A*算法使用的“硬边界”(路沿)和“软边界”(车道线)。

此过程在 SafetyCorridorGenerator::process 函数中被调用。


步骤一:构建“龙骨” - 平滑的中心参考线

算法的第一步不是找边界,而是先确定一条平滑的中心“龙骨”,后续所有的边界都将相对这条“龙骨”来定义。

此步骤在 pre_process 函数中通过调用 setDiscreteReferencePoints 完成。

  1. 输入:函数接收一个包含多条原始参考折线的数据 ref_polylinesylines
  2. 定位车辆:计算自车 (ego_state_) 在这些原始折线上的投影位置 (ego_proj_len),以确定车辆的当前进度。
  3. 截取与采样:根据车辆的投影位置,在车辆前后截取一定范围(例如 corridor_re_Min_dist_corridor_re_Max_dist_)的参考线段,并以5米的间隔进行粗略采样 (samplePtsBetween)。
  4. B样条平滑
    • A*搜索不能使用粗糙的折线,因此系统必须对其进行平滑。
    • 它调用 shape_point_smoother_new_->Solve
    • 这个 ShapePointSmoother (来自 shape_point_smoother.cpp) 是一个基于 B样条的OSQP(二次规划)优化器
    • 优化器的目标是找到一条B样条曲线,该曲线在尽可能贴近粗采样点的同时,保持自身的高度平滑(最小化曲率、加加速度等)。
  5. 输出 (龙骨):平滑后的结果被存储在 disref_polyline_disref_polyline_pwg_ (PiecewiseGeo 格式) 中。这条线就是后续所有计算的基准。

步骤二:构建“墙壁” - 筛选可见的路沿

有了中心“龙骨”后,系统开始构建走廊的“硬墙壁”,即路沿 (Road Edges)。系统不会使用所有路沿,而是通过一个精妙的“可见性”算法来筛选出最内侧的边界。

此步骤在 computeVisiblePoints 函数中执行。

  1. 遍历所有路沿:函数遍历从上游获取的 roadedge_table_ 中的每一条路沿线。
  2. 判断左/右
    • 对于每条路沿线,LineDirectionjudge 函数被调用。
    • 此函数判断这条路沿线整体位于“龙骨” (disref_polyline_pwg_) 的左侧还是右侧。
  3. “视线”可见性检查
    • 这是最核心的逻辑。系统遍历路沿上的每一个点
    • 调用 checkpointIsVisible 函数。
    • 此函数会从当前路沿点 point 向它在“龙骨”上的投影点 disref_polyline_pwg_.project(point).proj 画一条虚拟“视线”
    • 它会检查这条“视线”是否与 polylines_table 中的任何其他路沿线段相交
  4. 筛选
    • 如果“视线”被遮挡(即相交),意味着这个点point不是最内侧的边界(例如,它是外侧车道或匝道口的边界),该点被丢弃。
    • 只有“可见”的点(即未被遮挡的点)才被认为是构成安全走廊的有效点。
  5. 存储:所有可见的左侧点被添加到 ref_to_vis_points_table_Left_,右侧点添加到 ref_to_vis_points_table_Right_

连接成廊 - 形成最终边界

最后一步是将筛选出的离散的“可见点”连接成连续的走廊边界。

此步骤在 connectPointsToPolygon 函数中执行。

  1. 排序
    • 系统获取所有可见的左侧点 (sortedVec_left) 和右侧点 (sortedVec_right)。
    • 它根据点在“龙骨”上的投影距离 (dis_s) 对这些点进行排序。
  2. 连接
    • 系统按排序顺序,将所有左侧可见点连接起来,形成连续的折线 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)。

  1. 起点 (Start Point)

    • 冷启动:如果这是第一次运行,或者上一帧的轨迹无效 (stitch_info_.last_refline_valid == false),起点被设置为车辆的当前局部坐标,即 (0, 0, 0)
    • 热启动 (轨迹缝合):在连续运行时,为了保证轨迹的平滑过渡,起点被设置为上一帧计算结果的最后一个点。这个点从 stitch_info_.points.back() 中获取。
  2. 终点 (End Point)

    • 终点是动态计算的,而不是一个固定目标。
    • 系统使用在 Part 1 中生成的平滑中心参考线 (disref_polyline_pwg_)。
    • 它沿着这条参考线,从自车当前投影点 (ego_rel_s_) 向前推进一个固定的目标距离 (search_point_end_dist_)。
    • 终点 ep 就是这个推进距离在参考线上的对应点,其朝向也由参考线的切线方向决定。

3. 步骤二:加载障碍物与边界

系统需要将 Part 1 构建的安全走廊和所有其他感知信息转换为A*算法可以理解的格式。

  1. 加载硬边界 (路沿)

    • Part 1 中生成的 corridor_polyline_left_corridor_polyline_right_ 被加载。
    • 它们被转换并存储在 search_data_current.obstacles 中。在A*搜索期间,这些是不可穿越的(在 ValidityCheck 中检查)。
  2. 加载软边界 (车道线)

    • 系统遍历所有的车道线 (laneline_table_)。
    • 车道线被加载到 search_data_current.soft_boundary 中。
    • 这些边界在A搜索中被视为“软”障碍物。A算法可以穿越它们,但在穿越时会受到巨大的代价惩罚 (soft_boundary_penalty_),使其倾向于不压线行驶。
  3. 加载粗参考线

    • Part 1 中生成的 disref_polyline_ 被加载到 search_data_current.coarse_refline
    • 这条线将用于在A搜索期间计算启发式函数 (Heuristic),引导A算法沿着“正确的”方向前进。

4. 步骤三:创建并行搜索任务 (多车道搜索)

这是本系统的一个高级特性。为了应对变道或大型障碍物,系统不会只在当前车道搜索一条路径,而是会同时创建并评估三条可能的路径

  1. 创建三个数据副本

    • search_data_current:用于搜索当前车道的轨迹。
    • search_data_left:用于搜索向左变道后的轨迹。
    • search_data_right:用于搜索向右变道后的轨迹。
      它们都继承了相同的终点和障碍物信息。
  2. 检查变道可行性

    • 对于左/右变道任务,系统需要检查“立即变道”是否可行(例如,是否会立即撞上隔离带或另一条车道线)。
    • startpointcheck_inglobal 函数被调用。
    • 此函数会从当前起点向左/右侧(偏移一个车道宽度 shift_lane_width)画一条虚拟的线,检查这条线是否会立即与路沿 (corridor_polyline_left_) 或车道线 (laneline_table_) 相交。
  3. 设置变道起点

    • 如果可行 (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*算法 f=g+hf = g + h 的框架,但对其所有关键组件进行了“混合”定制。


2. 状态、栅格化 (Gridding) 与搜索列表

混合A*的“混合”特性体现在它如何在连续空间中搜索,同时又用离散栅格来管理搜索进度。

  1. 连续状态

    • 搜索的真实状态是连续的 (x,y,θ)(x, y, \theta) 位姿。
    • 节点 (Node3d) 存储的是高精度的 doublex,y,ϕx, y, \phi 值。
  2. 离散栅格化 (Gridding)

    • 为了管理“已访问”列表(open_set_close_set_),A*算法必须知道两个节点是否“几乎在同一位置”。
    • 系统使用 xy_grid_resolution_(例如0.5米)和 phi_grid_resolution_(例如0.05弧度)作为分辨率。
    • 当一个 Node3d 被创建时,它的连续 (x,y,θ)(x, y, \theta) 状态会被栅格化(类似于“四舍五入”),以生成一个唯一的离散索引IDNode3d::GetIndex())。
    • 例如(1.2,0.6,0.1)(1.2, 0.6, 0.1)(1.3,0.7,0.12)(1.3, 0.7, 0.12) 这两个连续状态,在栅格化后可能共享同一个索引ID:“X_1_Y_0_Phi_2”。
  3. 搜索列表 (Open/Close Sets)

    • open_pq_:一个优先队列,存储连续的 Node3d 对象。它根据 ff 代价(f=g+hf=g+h)自动排序,保证A*总是先探索 ff 代价最低的节点。
    • open_set_close_set_:两个哈希表 (unordered_map),它们存储的是离散的索引ID。它们用于快速检查“这个栅格我们是否已经访问过?”。

3. A* 搜索循环

performAstarSearch 函数的主体是一个 while 循环。

  1. 初始化:将 start_node_ 放入 open_pq_open_set_
  2. 循环条件while (!open_pq_.empty())
    • 循环会一直运行,直到找到路径,或者 open_pq_ 变空(搜索失败),或者达到了最大探索节点数 (max_explored_num)。
  3. 弹出最优节点
    • current_node = open_pq_.top().first:从优先队列中取出 ff 代价最低的节点。
    • open_pq_.pop()
  4. 加入关闭列表:将 current_node索引ID加入 close_set_,表示“这个栅格我们已经处理过了”。
  5. 检查终点:调用 ChecknodeIsFinal。在您的代码中,这不仅是检查是否到达 (x,y)(x,y),而是检查车辆的纵向投影 (GetProjLength()) 是否已经超过了终点的投影 (endp_proj_len_) 且航向角接近。如果满足,则 final_node_ 被设置,搜索趋于结束。
  6. 扩展节点:对 current_node 进行节点扩展(见下文)。

4. 步骤一:节点扩展 (Node Expansion) - “Hybrid”的核心

这是混合A与传统A根本区别。它不探索 (x,y)(x, y) 栅格的8个邻居,而是模拟车辆的驾驶动作。

此步骤在 Next_node_generator 函数中实现。

  1. 生成离散动作
    • 循环 next_node_num_ 次(例如9次)。
    • 生成一组离散的转向角,例如 steering = {-20°, -10°, 0°, 10°, 20°...}
    • 同时生成前进(traveled_distance = step_size_)和后退(traveled_distance = -step_size_)两种动作。
  2. 运动学模拟
    • 对于每一个动作(如“前进,转向10度”),函数会根据自行车运动学模型计算出新的连续位姿 (x,y,θ)(x', y', \theta')
    • 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)
  3. 创建新节点:用 (x,y,θ)(x', y', \theta') 创建一个新的 Node3d 对象 (next_node)。

5. 步骤二:碰撞检测 (Collision Checking)

对于 Next_node_generator 生成的每一个 next_node,系统都会进行严格的碰撞检测。

此步骤在 ValidityCheck 函数中实现。

  1. 获取包围盒 (Bounding Box)Node3d::GetBoundingBox 函数根据车辆的 vehicle_param_(车长、车宽)和 next_node(x,y,θ)(x, y, \theta) 位姿,计算出车辆的2D矩形包围盒。
  2. 检查重叠
    • ValidityCheck 遍历 Part 1 中构建的所有硬边界线段 (obstacles_linesegments_vec_),这包括了 corridor_polyline_left_corridor_polyline_right_
    • 它检查车辆包围盒是否与任何一条硬边界线段发生重叠 (bounding_box.HasOverlap(linesegment))。
  3. 丢弃节点:如果发生重叠(碰撞),ValidityCheck 返回 false,A*搜索循环会立即丢弃这个 next_node,不再对其进行代价计算。

6. 步骤三:代价计算 (gghh)

如果 next_node 通过了碰撞检测,并且其栅格索引不在 close_set_ 中,系统就会调用 CalculateNodeCost 来计算其 ff 代价。

g(n)g(n) - 真实路径代价

g(n)g(n) 是从起点到当前节点**实际付出的代价**。它由两部分组成:
  1. 运动代价 (TrajCost)

    • 方向惩罚:后退的代价远高于前进(traj_back_penalty_ > traj_forward_penalty_)。
    • 换挡惩罚:如果动作从前进变为后退(或反之),施加 traj_gear_switch_penalty_
    • 转向惩罚:惩罚大幅度转向(traj_steer_penalty_ * steer^2)。
    • 转向变化惩罚:惩罚方向盘的快速变化(traj_steer_change_penalty_ * (steer - last_steer)^2)。
  2. 软边界代价 (LineCrossCost)

    • 此函数检查从 current_nodenext_node 的连线是否穿越了任何软边界(车道线 soft_laneline_linesegments_vec_)。
    • 如果穿越,施加一个巨大的代价 (soft_boundary_penalty_ * 100)。
    • 这使得A*算法会“极不情愿”地压线,但如果这是唯一的路径,它仍然会选择压线(与硬边界的“不可穿越”形成对比)。

h(n)h(n) - 启发式函数: HoloObstacleHeuristic

h(n)h(n) 是对未来代价的**估计**,用于“引导”A*算法朝终点前进。`Holo` 是 "Holonomic"(全向的)的缩写,意为在预估代价时,**暂时假装车辆是一个可以360°全向移动的“点”**,从而忽略非全向的运动约束(如转向、朝向)。这保证了 h(n)h(n) 永远不会高估实际代价。

您的代码中 包含了两种实现:

1. 实现方式一 (被注释掉): 2D栅格搜索 (真正的“全向-障碍物”启发)
这是最经典、最智能的启发函数,通过 grid_a_star_heuristic_generator_grid_search.cpp 实现。

  • 预计算: 在混合A开始前,先运行一个2D栅格A (GenerateDpMap)。
  • 反向搜索: 这个2D A*从终点开始,反向搜索并绕过所有硬障碍物(路沿)。
  • 生成DP Map: 它生成一个代价地图 (dp_map_),图中每个单元格的值是“从该点到终点的最短无障碍距离”。
  • 查询: 混合A*在运行时,通过 CheckDpMap 查询 dp_map_ 即可得到 hh 值。
  • 优点: 完美考虑了障碍物,引导性最强。

2. 实现方式二 (代码中实际使用): 基于粗轨迹 (“全向-无障碍物”启发)
您的代码实际运行的是一个更简单、针对车道环境高度优化的版本。它完全忽略障碍物,仅依赖 Part 1 中生成的平滑中心参考线 (rough_line_pwg_)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
double HybridAStar::HoloObstacleHeuristic(std::shared_ptr<Node3d> next_node) {
// ... (2D Grid Search被注释掉了)

// 1. 获取纵向距离
double dist_long = endp_proj_len_ - next_node->GetProjLength();

// 2. 获取横向距离
double dist_lat = next_node->GetProjDis();

// 3. 计算最终代价
res = heuristic_lat_w1_ * (cost_from_lat) + heuristic_long_w2_ * dist_long;

return res;
}
  • 工作原理:它将“全向”代价分解为:
    1. 横向代价:车辆“横移”到参考线上的代价,基于 dist_lat
    2. 纵向代价:车辆沿参考线行驶到终点的代价,即 dist_long
  • 最终 hh = w1×(横向代价)+w2×(纵向代价)w_1 \times (\text{横向代价}) + w_2 \times (\text{纵向代价})
  • 优点: 计算极快(一次投影)。在有清晰参考线的道路上,会强烈引导A*算法贴着参考线前进
  • 缺点: 完全无视障碍物,在复杂路口或障碍物遮挡参考线时,引导性会下降。

输出

while 循环结束(因为找到了 final_node_),GetNodeResult 函数会被调用。

  1. 路径回溯GetNodeResultfinal_node_ 开始,通过 GetPreNode() 指针链条反向回溯到 start_node_
  2. 输出:它将回溯路径上的所有 (x,y,ϕ)(x, y, \phi) 坐标点收集起来,存储在 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)

这是将“物理世界”转译为“数学问题”的关键一步。系统需要告诉优化器,这条轨迹必须在哪些边界内。

  1. 函数: HybridAStar::FillSearchResultToOptimizer
  2. 约束池: 此函数使用 constraint_pool_ (来自 constraint_pool.cpp)。
  3. 查询边界:
    • 它遍历加密后的A*路径上的每一个点
    • 对于每个点,它调用 constraint_pool_.queryConstraineLineSeg
    • constraint_pool_ 会在 Part 1 构建的安全走廊(corridor_polyline_left_corridor_polyline_right_)以及软边界(车道线)中,为这个点动态查询出离它最近的左、右边界线段。
  4. 输出: 生成一个完整的约束集,其中A*路径上的每个点都“绑定”了它必须遵守的局部边界。

4. 步骤三:B样条二次规划 (B-Spline QP) 平滑

这是轨迹生成的最后一步,也是计算量最大的一步。

  1. 调用求解器: 系统调用 search_result_smoother_->Solve()
  2. 求解器身份: search_result_smoother_ (来自 shape_point_smoother.cpp) 实际上是一个OSQP(二次规划)求解器的接口 (newOsqpInterface)。这与 Part 1 中用于平滑参考线的优化器是同一类。

OSQP求解器会解一个复杂的数学优化问题:

  • 目标函数 (Cost Function) - CalculateKernel

    • “找到一条B样条曲线,使其总代价最小”。
    • 总代价 =
      • w1×w_1 \times (平滑后轨迹**偏离A*参考路径**的距离) (`weight_p_`)
        • w2×w_2 \times (曲线的速度/一阶导数) (`weight_v_`)
        • w3×w_3 \times (曲线的加速度/二阶导数) (`weight_a_`)
        • w4×w_4 \times (曲线的加加速度Jerk/三阶导数) (`weight_j_`)
        • w5×w_5 \times (曲线的加加加速度Snap/四阶导数) (`weight_s_`)
    • 核心思想: 权重 w1w_1 (位置权重 weight_p_) 保证了轨迹不会偏离A*找到的安全路径。其他权重 w2w_2w5w_5 保证了轨迹本身是极其平滑、舒适、易于控制的。
  • 约束条件 (Constraints) - CalculateAffineConstraint

    • “在满足以下硬约束的前提下,最小化上述总代价”:
    • 边界约束: B样条曲线上的所有点,必须位于 FillSearchResultToOptimizer (步骤二) 提供的左、右硬边界(路沿)和软边界(车道线)之间。

5. 步骤四:轨迹选择与最终输出

至此,Part 2 中提交的三个并行搜索任务(左、中、右)均已完成(或因无效而被跳过)。

此步骤在 SafetyCorridorGenerator::performSearch 的末尾执行。

  1. 收集结果: 系统现在手握最多三个平滑且可行的轨迹方案(存储在 search_thread_map 中)。
  2. 选择最优轨迹:
    • 系统需要从这三个方案中选出一个“最佳”方案。
    • 它使用一个简单的“最近”逻辑:遍历所有有效轨迹上的所有点,找出离车辆当前位置 (car_current_pos) 最近的那个点。
    • 包含这个“最近点”的整条轨迹,被选为最终轨迹 (nearst_road_id)。
  3. 最终组装 (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,内侧前轮的转向角度(δinner\delta_{inner})必须大于外侧前轮的转向角度(δouter\delta_{outer}
  • 实现:在您的真实汽车上,这是通过一个精巧的机械结构(称为“转向梯形”)来实现的。

(这个我们玩乐高和besiege的时候都很熟悉了hhh)

阿克曼转向几何


2. 自行车模型 (Bicycle Model) - “如何模拟转向”

自行车模型是对阿克曼转向原理的数学简化。它是您代码中混合A*算法的核心运动学模型。

核心思想

我们不模拟四个轮子,太复杂了。我们把车简化成一辆自行车:

  1. 把两个后轮合并成一个“虚拟后轮”。
  2. 把两个前轮合并成一个“虚拟前轮”。

这辆“自行车”的状态(位姿)可以用三个变量来描述:

  • (x,y)(x, y):虚拟后轮中心的位置。
  • θ\theta (theta):自行车的朝向(航向角)。

如何在代码中使用 (Next_node_generator)

A*搜索不是基于时间 tt 和速度 vv 的,而是基于一个固定的步长 ssteps_{\text{step}}(即 traveled_distance)。

代码将描述自行车运动的微分公式改写为“离散”的差分公式,用于计算A*的下一个节点:

  1. 关键参数

    • LL:车辆轴距(前后轮之间的距离)。在您的代码中是 `vehicle_param_.wheel_base()`。
    • δ\delta (delta):虚拟前轮的转向角。在您的代码中是 `steering`。
    • (xk,yk,ϕk)(x_k, y_k, \phi_k):当前节点(`current_node`)的位姿。
  2. 计算新朝向 ϕk+1\phi_{k+1}

    • 新朝向角的变化 Δϕ(sstep/L)×tan(δ)\Delta\phi \approx (s_{\text{step}} / L) \times \tan(\delta)
    • 计算公式ϕk+1=ϕk+sstepLtan(δ)\phi_{k+1} = \phi_k + \frac{s_{\text{step}}}{L} \tan(\delta)
  3. 计算新位置 (xk+1,yk+1)(x_{k+1}, y_{k+1})

    • 新位置的变化 Δxsstep×cos(ϕ)\Delta x \approx s_{\text{step}} \times \cos(\phi)
    • 新位置的变化 Δysstep×sin(ϕ)\Delta y \approx s_{\text{step}} \times \sin(\phi)
    • 计算公式(您的代码中使用 ϕk+1\phi_{k+1} 进行积分,这是一种欧拉积分法): xk+1=xk+sstepcos(ϕk+1)x_{k+1} = x_k + s_{\text{step}} \cos(\phi_{k+1}) yk+1=yk+sstepsin(ϕk+1)y_{k+1} = y_k + s_{\text{step}} \sin(\phi_{k+1})

总结:自行车模型是一个数学公式,它能精确地告诉A*算法:“如果我在 (xk,yk,ϕk)(x_k, y_k, \phi_k),打 δ\delta 这么多方向,前进 ssteps_{\text{step}} 这么远,我会到达哪个新位姿 (xk+1,yk+1,ϕk+1)(x_{k+1}, y_{k+1}, \phi_{k+1})”。这就是混合A*实现“运动学可行”的根本。

自行车运动学模型


3. 纵向动力学 (Longitudinal Dynamics) - “如何规划速度”

这个概念与“转向”(横向)无关,它只关心沿路径前进和后退(纵向)。

在自动驾驶规划中,一个非常聪明且常见的技巧是**“解耦”**(Decoupled)规划:

  1. Step 1. 路径规划 (Path Planning)

    • 任务:先不管速度,只用自行车模型(运动学)找到一条几何上可行的 (x,y,θ)(x, y, \theta) 路径。
    • 执行者HybridAStar::performAstarSearch
  2. Step 2. 速度规划 (Speed Profile)

    • 任务:把Step 1找到的路径当成一条固定的“1D轨道”。现在,只在这条轨道上规划 (v,a,j)(v, a, j)(速度, 加速度, 加加速度)。
    • 执行者HybridAStar::GenerateSCurveSpeedAcceleration

纵向动力学模型如何工作

您的代码没有使用复杂的发动机、刹车或轮胎力模型。它使用了一个更简单、更强大的基于约束的优化模型

GenerateSCurveSpeedAcceleration 所做的事情是,调用 PiecewiseJerkSpeedProblem 求解器,并给它一组规则(动力学约束)

  • 规则1 (速度限制):你的速度 vv 永远不能超过 max_forward_v_max_reverse_v_
  • 规则2 (加速度限制):你的加速度 aa 永远不能超过 max_forward_acc_max_reverse_acc_。这代表了车辆“油门踩到底”或“刹车踩到底”的能力。
  • 规则3 (Jerk限制):你的加加速度 jj (Jerk) 永远不能超过 max_acc_jerk_。Jerk代表了你踩油门/刹车的“快慢”。限制Jerk是为了防止顿挫,保证乘客舒适性。

优化目标:求解器 PiecewiseJerkSpeedProblem 的目标是,在严格遵守上述所有动力学规则的前提下,找到一条从 v=0v=0v=0v=0 的速度曲线,使其总Jerk最小(即最平滑、最舒适)。


总结:三者关系

概念 类别 解决的问题 在代码中的体现
阿克曼转向 物理原理 “为什么”:为什么车轮需要差速转向? (无代码) 它是自行车模型的理论基础。
自行车模型 运动学 (Kinematics) “怎么转弯”:给定转向和距离,计算新的 (x,y,θ)(x, y, \theta) HybridAStar::Next_node_generator
纵向动力学 动力学 (Dynamics) “怎么加减速”:给定 (x,y)(x, y) 路径,计算可行的 (v,a,j)(v, a, j) HybridAStar::GenerateSCurveSpeedAcceleration
本文作者:战斗包子
本文链接:https://paipai121.github.io/2025/10/30/工作/基于搜索的路径生成/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可