参考:ChatGPT、autoware

对应源码文件: autoware_devel/src/universe/autoware_universe/planning/behavior_path_planner/autoware_behavior_path_start_planner_module/src/freespace_pull_out.cpp

从自由空间脱困到回归车道:Hybrid A* Freespace Pull Out 算法教程

Freespace Pull Out 解决的是一个很具体、也很棘手的起步问题:车辆停在路肩、停车位边缘、障碍物旁边或车道外侧,普通的“沿车道中心线向前走”已经不可行;基于车道的平滑横移、圆弧驶出等方法也找不到安全路径。这时车辆需要临时把自己看作一个在二维占据栅格里运动的非完整约束车辆,先在自由空间中绕开障碍物,再回到道路中心线。

这篇教程专门讲 Freespace Pull Out 中的 Hybrid A* 思想。它不按源码顺序解释,而是从零建立算法:什么是自由空间驶出、为什么普通 A* 不够、怎样把车辆位姿离散成可搜索状态、怎样用自行车模型生成运动原语、怎样做矩形车身碰撞检测、怎样设计启发函数和代价函数、怎样判定到达目标、怎样把自由空间轨迹重新接回车道。读完后,即使不了解 Autoware,也应该能独立理解这类算法如何工作。

1. 先理解 Freespace Pull Out 的任务

车辆从路边起步时,理想情况是沿着目标车道方向平滑合入车道中心线。但现实里可能出现下面的情况:

  1. 车辆前方中心线方向被障碍物挡住。
  2. 车辆与道路方向夹角较大,简单横移不满足转向半径。
  3. 路肩、停车区或障碍物形成狭窄空间,需要先倒车再前进。
  4. 车道几何本身不足以描述车辆当前可行驶区域。

因此,自由空间驶出不再把路径限制在车道中心线附近,而是解决下面的问题:

给定起点  q s = ( x s , y s , θ s )  和道路上的回归目标  q g = ( x g , y g , θ g ) ,  在障碍物栅格中寻找一条可行车辆轨迹 \text{给定起点 } q_s=(x_s,y_s,\theta_s) \text{ 和道路上的回归目标 } q_g=(x_g,y_g,\theta_g), \text{ 在障碍物栅格中寻找一条可行车辆轨迹} 给定起点 qs=(xs,ys,θs) 和道路上的回归目标 qg=(xg,yg,θg), 在障碍物栅格中寻找一条可行车辆轨迹

这里的 q = ( x , y , θ ) q=(x,y,\theta) q=(x,y,θ) 是车辆的二维位姿, x , y x,y x,y 表示平面位置, θ \theta θ 表示车头朝向。所谓“可行车辆轨迹”,必须同时满足四类约束:

不撞障碍物 \text{不撞障碍物} 不撞障碍物

不驶出规划栅格边界 \text{不驶出规划栅格边界} 不驶出规划栅格边界

满足车辆最小转弯半径 \text{满足车辆最小转弯半径} 满足车辆最小转弯半径

尽量少倒车、少换挡、少贴近障碍物,并最终回到道路方向 \text{尽量少倒车、少换挡、少贴近障碍物,并最终回到道路方向} 尽量少倒车、少换挡、少贴近障碍物,并最终回到道路方向

从行为规划视角看,Freespace Pull Out 不是最终一路开到终点的全局路径规划。它更像一个“脱困段”:

当前位置 → 自由空间搜索 → 道路中心线上的回归点 → 继续沿道路中心线行驶 \text{当前位置} \rightarrow \text{自由空间搜索} \rightarrow \text{道路中心线上的回归点} \rightarrow \text{继续沿道路中心线行驶} 当前位置自由空间搜索道路中心线上的回归点继续沿道路中心线行驶

这就是本算法的主线。

2. 为什么需要 Hybrid A*

在讲 Hybrid A* 之前,先把普通 A* 讲清楚。A* 本质上是一种“带方向感的最短路搜索”。它面对的问题是:

给定起点  s  和终点  g ,  在一张图中找到总代价尽可能小的路径 \text{给定起点 }s \text{ 和终点 }g, \text{ 在一张图中找到总代价尽可能小的路径} 给定起点 s 和终点 g, 在一张图中找到总代价尽可能小的路径

这里的“图”不是图片,而是由节点和边组成的数学对象:

G = ( V , E ) \mathcal{G}=(\mathcal{V},\mathcal{E}) G=(V,E)

其中 V \mathcal{V} V 是节点集合, E \mathcal{E} E 是边集合。若从节点 a a a 可以走到节点 b b b,就存在一条边:

( a , b ) ∈ E (a,b)\in\mathcal{E} (a,b)E

每条边都有一个非负代价:

c ( a , b ) ≥ 0 c(a,b)\ge0 c(a,b)0

在二维栅格地图里,可以把每个空闲格子看成一个节点,把相邻格子之间的移动看成边。比如从一个格子向右走一格,代价可以是 1 1 1;斜向走一格,代价可以是 2 \sqrt{2} 2 。这样,找路径就变成了在图中找一串节点:

s = n 0 → n 1 → ⋯ → n K = g s=n_0\rightarrow n_1\rightarrow\cdots\rightarrow n_K=g s=n0n1nK=g

路径总代价为:

J = ∑ i = 0 K − 1 c ( n i , n i + 1 ) J= \sum_{i=0}^{K-1}c(n_i,n_{i+1}) J=i=0K1c(ni,ni+1)

A* 的目标是找到 J J J 尽可能小的路径。

如果完全不知道终点在哪里,可以用 Dijkstra 算法。Dijkstra 每次选择“从起点走到这里已经花费最小”的节点继续扩展。它的评价函数只有:

f ( n ) = g ( n ) f(n)=g(n) f(n)=g(n)

其中:

g ( n ) = 从起点到节点  n  已经花掉的真实代价 g(n)=\text{从起点到节点 }n\text{ 已经花掉的真实代价} g(n)=从起点到节点 n 已经花掉的真实代价

Dijkstra 很稳,但它没有目标方向感。它像水波一样从起点向四周扩散,直到碰到终点。

另一种极端是贪心最佳优先搜索。它只看节点离终点还剩多远:

f ( n ) = h ( n ) f(n)=h(n) f(n)=h(n)

其中:

h ( n ) = 从节点  n  到终点的估计代价 h(n)=\text{从节点 }n\text{ 到终点的估计代价} h(n)=从节点 n 到终点的估计代价

贪心搜索方向感很强,但它可能为了看起来更接近终点而绕进死胡同。

A* 正好把这两种思想合在一起:

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

这个公式可以用一句话理解:

评价一个节点,不仅看已经走了多贵,也看预计还要走多贵 \text{评价一个节点,不仅看已经走了多贵,也看预计还要走多贵} 评价一个节点,不仅看已经走了多贵,也看预计还要走多贵

其中 g ( n ) g(n) g(n) 负责“脚踏实地”,因为它是真实已经付出的代价; h ( n ) h(n) h(n) 负责“抬头看路”,因为它给搜索一个朝向目标的趋势。

对新手来说,可以把 A* 想象成每次都问:

如果我从起点走到这里,再从这里走到终点,总共大概要花多少? \text{如果我从起点走到这里,再从这里走到终点,总共大概要花多少?} 如果我从起点走到这里,再从这里走到终点,总共大概要花多少?

这个“大概要花多少”就是 f ( n ) f(n) f(n)

普通栅格 A* 通常把 h ( n ) h(n) h(n) 设成当前格子到目标格子的几何距离。若节点 n n n 的坐标为 ( i , j ) (i,j) (i,j),目标为 ( i g , j g ) (i_g,j_g) (ig,jg),四邻域移动时常用曼哈顿距离:

h manhattan ( n ) = ∣ i − i g ∣ + ∣ j − j g ∣ h_{\text{manhattan}}(n)= |i-i_g|+|j-j_g| hmanhattan(n)=iig+jjg

八邻域或连续平面里更常用欧氏距离:

h euclidean ( n ) = ( i − i g ) 2 + ( j − j g ) 2 h_{\text{euclidean}}(n)= \sqrt{(i-i_g)^2+(j-j_g)^2} heuclidean(n)=(iig)2+(jjg)2

启发函数有一个重要概念:如果 h ( n ) h(n) h(n) 从不高估真实剩余代价,就称为可采纳启发:

0 ≤ h ( n ) ≤ h ∗ ( n ) 0\le h(n)\le h^*(n) 0h(n)h(n)

其中 h ∗ ( n ) h^*(n) h(n) 是从 n n n 到目标的真实最优代价。可采纳启发的直觉是:可以乐观,但不能吹牛。如果 h h h 可采纳,并且边代价非负,经典 A* 能保证找到最优路径。

但自动驾驶里的实时规划常常会使用加权启发:

f ( n ) = g ( n ) + w h h ( n ) f(n)=g(n)+w_h h(n) f(n)=g(n)+whh(n)

w h > 1 w_h>1 wh>1 时,搜索会更“急着朝目标走”,速度通常更快,但严格最优性会变弱。自由空间起步规划更重视有限时间内找到可行、可执行、足够好的路径,所以会接受这种折中。

普通栅格 A* 通常搜索二维点:

s = ( i , j ) s=(i,j) s=(i,j)

每次从当前格子扩展到上下左右或八邻域格子。这种方法适合点机器人,却不适合汽车。汽车至少有三个关键限制:

首先,汽车有朝向。两个车辆虽然中心点相同,但车头方向不同,下一步能去的地方完全不同:

( x , y , θ 1 ) ≠ ( x , y , θ 2 ) (x,y,\theta_1)\neq(x,y,\theta_2) (x,y,θ1)=(x,y,θ2)

其次,汽车不能横向平移。车辆的速度方向大致沿车身纵向,无法从一个格子直接侧向移动到另一个格子。

第三,汽车有最小转弯半径。若轴距为 L L L,前轮转角为 δ \delta δ,曲率为:

κ = tan ⁡ δ L \kappa=\frac{\tan\delta}{L} κ=Ltanδ

转弯半径为:

R = 1 ∣ κ ∣ = L ∣ tan ⁡ δ ∣ R=\frac{1}{|\kappa|}=\frac{L}{|\tan\delta|} R=κ1=tanδL

∣ δ ∣ ≤ δ max ⁡ |\delta|\le \delta_{\max} δδmax 时,最小转弯半径为:

R min ⁡ = L tan ⁡ δ max ⁡ R_{\min}=\frac{L}{\tan\delta_{\max}} Rmin=tanδmaxL

普通 A* 的“斜着走一格”并不保证满足这个半径约束。

Hybrid A* 的核心思想是:搜索索引是离散的,但节点之间的运动是连续车辆运动学积分出来的。也就是说,它把状态空间写成:

q = ( x , y , θ ) q=(x,y,\theta) q=(x,y,θ)

再把它映射到离散索引:

index ⁡ ( q ) = ( i , j , k ) \operatorname{index}(q)=(i,j,k) index(q)=(i,j,k)

其中 i , j i,j i,j 是栅格位置索引, k k k 是航向角索引。搜索时,优先队列、闭集、重复访问判断都基于 ( i , j , k ) (i,j,k) (i,j,k);但每一条边不是简单的邻接格,而是一小段车辆可执行的前进或倒车圆弧。

这就是 “Hybrid” 的含义:

连续车辆运动学 + 离散图搜索 = Hybrid A* \text{连续车辆运动学}+ \text{离散图搜索}= \text{Hybrid A*} 连续车辆运动学+离散图搜索=Hybrid A*

它比普通 A* 更懂车辆,比纯连续优化更稳定,也更适合在占据栅格上做脱困搜索。

把普通 A* 和 Hybrid A* 对照起来看,会更容易理解:

普通 A* 的节点是一个二维格子:

n = ( i , j ) n=(i,j) n=(i,j)

Hybrid A* 的节点是一个带朝向的车辆位姿:

n = ( x , y , θ ) n=(x,y,\theta) n=(x,y,θ)

普通 A* 的节点身份由二维格子决定:

id ⁡ ( n ) = ( i , j ) \operatorname{id}(n)=(i,j) id(n)=(i,j)

Hybrid A* 的节点身份由位置格子和角度桶决定:

id ⁡ ( n ) = ( i , j , k ) \operatorname{id}(n)=(i,j,k) id(n)=(i,j,k)

普通 A* 的边是“从当前格子走到相邻格子”:

( i , j ) → ( i + Δ i , j + Δ j ) (i,j)\rightarrow(i+\Delta i,j+\Delta j) (i,j)(i+Δi,j+Δj)

Hybrid A* 的边是“车辆用某个转角行驶一小段距离”:

q ′   = F ( q , δ , d ) q'\ =F(q,\delta,d) q =F(q,δ,d)

普通 A* 的碰撞检查通常只看目标格子是否被占据:

O ( i , j ) = 0 O(i,j)=0 O(i,j)=0

Hybrid A* 的碰撞检查要看完整车身矩形是否与障碍物重叠:

B ( q ) ∩ O = ∅ \mathcal{B}(q)\cap\mathcal{O}=\varnothing B(q)O=

因此,Hybrid A* 并不是抛弃 A*,而是把 A* 的节点、边、代价和碰撞检查全部替换成适合汽车的版本。

3. 自由空间驶出的整体流程

完整流程可以理解为一条递进链路:

判断是否需要自由空间脱困 → 在道路中心线上采样回归目标 → 把代价地图变成占据栅格 → 用 Hybrid A* 搜索起点到目标 → 按前进/倒车切分轨迹 → 接回道路中心线 \text{判断是否需要自由空间脱困} \rightarrow \text{在道路中心线上采样回归目标} \rightarrow \text{把代价地图变成占据栅格} \rightarrow \text{用 Hybrid A* 搜索起点到目标} \rightarrow \text{按前进/倒车切分轨迹} \rightarrow \text{接回道路中心线} 判断是否需要自由空间脱困在道路中心线上采样回归目标把代价地图变成占据栅格 Hybrid A* 搜索起点到目标按前进/倒车切分轨迹接回道路中心线

自由空间规划并不是一直运行。它通常作为兜底方案出现:车辆已经停止,普通起步路径没有找到,且有新鲜的代价地图可用时,才尝试自由空间搜索。

更具体地说,它适合在下面的状态下启动:

车辆处于起步规划场景 \text{车辆处于起步规划场景} 车辆处于起步规划场景

车辆已经停止 \text{车辆已经停止} 车辆已经停止

常规停车等待或车道语义驶出没有找到可行路径 \text{常规停车等待或车道语义驶出没有找到可行路径} 常规停车等待或车道语义驶出没有找到可行路径

代价地图存在且时间足够新 \text{代价地图存在且时间足够新} 代价地图存在且时间足够新

如果代价地图过旧,搜索即使找到路径也可能对应已经失效的障碍物环境;如果车辆还没有停止,自由空间脱困段的低速几何搜索也不一定适合作为当前运动的直接接管路径。

设车辆当前位置为 q s q_s qs,它在当前道路中心线上的弧长坐标为 s ego s_{\text{ego}} sego。算法不会随便选一个远处目标,而是在车辆前方道路中心线上生成候选回归点:

s start = max ⁡ ( 0 ,   s ego + d start ) s_{\text{start}}= \max(0,\ s_{\text{ego}}+d_{\text{start}}) sstart=max(0, sego+dstart)

s end = s ego + d end s_{\text{end}}= s_{\text{ego}}+d_{\text{end}} send=sego+dend

然后按间隔 Δ s \Delta s Δs 采样:

s m = s start + m Δ s , s m ≤ s end s_m=s_{\text{start}}+m\Delta s, \qquad s_m\le s_{\text{end}} sm=sstart+mΔs,smsend

每个 s m s_m sm 对应道路中心线上的一个目标位姿:

q g ( m ) = ( x c ( s m ) ,   y c ( s m ) ,   θ c ( s m ) ) q_g^{(m)}= \left( x_c(s_m),\ y_c(s_m),\ \theta_c(s_m) \right) qg(m)=(xc(sm), yc(sm), θc(sm))

其中 C ( s ) = ( x c ( s ) , y c ( s ) ) C(s)=(x_c(s),y_c(s)) C(s)=(xc(s),yc(s)) 是道路中心线, θ c ( s ) \theta_c(s) θc(s) 是中心线切向角。

这一步非常关键。自由空间搜索不是漫无目的地“找一条路”,而是问:

我能否从当前位置安全地开到前方某个道路中心线点? \text{我能否从当前位置安全地开到前方某个道路中心线点?} 我能否从当前位置安全地开到前方某个道路中心线点?

通常会从较近的候选回归点开始尝试,找到第一条可行路径就停止。这样得到的路径不会过度绕行,也更像一个起步脱困动作。

在正式搜索前,还必须检查起点和候选目标本身是否有效:

collision ⁡ ( q s ) = 0 \operatorname{collision}(q_s)=0 collision(qs)=0

collision ⁡ ( q g ( m ) ) = 0 \operatorname{collision}(q_g^{(m)})=0 collision(qg(m))=0

只要起点或目标已经与障碍物、未知区域或地图边界冲突,该候选问题就没有继续搜索的意义。

4. 代价地图、占据栅格与坐标变换

Hybrid A* 在代价地图上搜索。代价地图可以看成一个二维栅格:

M ∈ Z H × W M\in\mathbb{Z}^{H\times W} MZH×W

每个格子存一个代价值 M i j M_{ij} Mij。值越大,越可能是障碍物;未知区域通常也会被当作不可通行区域。

给定障碍物阈值 τ obs \tau_{\text{obs}} τobs,可以定义占据函数:

O ( i , j ) = { 1 , M i j < 0  或  M i j ≥ τ obs 0 , otherwise O(i,j)= \begin{cases} 1, & M_{ij}<0 \text{ 或 } M_{ij}\ge \tau_{\text{obs}} \\ 0, & \text{otherwise} \end{cases} O(i,j)={1,0,Mij<0  Mijτobsotherwise
M i j < 0 M_{ij}<0 Mij<0:未知区域,当成障碍物;
M i j ≥ τ obs M_{ij}\ge \tau_{\text{obs}} Mijτobs:代价值超过障碍物阈值,也当成障碍物;
其他情况:认为可以通行。
O ( i , j ) = 1 O(i,j)=1 O(i,j)=1 表示该栅格不可通行。

代价地图有自己的局部坐标系。若地图原点在世界坐标中的位姿为 T M W T^W_M TMW,车辆世界位姿为 T B W T^W_B TBW,先把车辆变换到地图坐标:

T B M = ( T M W ) − 1 T B W T^M_B=(T^W_M)^{-1}T^W_B TBM=(TMW)1TBW

得到局部位姿:

q M = ( x M , y M , θ M ) q^M=(x^M,y^M,\theta^M) qM=(xM,yM,θM)

若栅格分辨率为 r r r,则位置索引采用四舍五入:

i = round ⁡ ( x M r ) i=\operatorname{round}\left(\frac{x^M}{r}\right) i=round(rxM)

j = round ⁡ ( y M r ) j=\operatorname{round}\left(\frac{y^M}{r}\right) j=round(ryM)

航向角被离散成 N θ N_\theta Nθ 个桶。角分辨率为:

Δ θ = 2 π N θ \Delta\theta=\frac{2\pi}{N_\theta} Δθ=Nθ2π

将角度归一化到 [ 0 , 2 π ) [0,2\pi) [0,2π) 后:

k = round ⁡ ( normalize ⁡ [ 0 , 2 π ) ( θ ) Δ θ )   m o d   N θ k= \operatorname{round} \left( \frac{\operatorname{normalize}_{[0,2\pi)}(\theta)}{\Delta\theta} \right) \bmod N_\theta k=round(Δθnormalize[0,2π)(θ))modNθ

于是连续位姿 q q q 对应离散索引:

index ⁡ ( q ) = ( i , j , k ) \operatorname{index}(q)=(i,j,k) index(q)=(i,j,k)

搜索图中的唯一节点编号可以写成:

id ⁡ ( i , j , k ) = ( j W + i ) N θ + k \operatorname{id}(i,j,k)= (jW+i)N_\theta+k id(i,j,k)=(jW+i)Nθ+k

这说明 Hybrid A* 并不只是在二维地图上找路。它实际上在三维离散空间中搜索:

S = { 0 , … , W − 1 } × { 0 , … , H − 1 } × { 0 , … , N θ − 1 } \mathcal{S}= \{0,\dots,W-1\} \times \{0,\dots,H-1\} \times \{0,\dots,N_\theta-1\} S={0,,W1}×{0,,H1}×{0,,Nθ1}

当默认 N θ = 120 N_\theta=120 Nθ=120 时,角分辨率为:

Δ θ = 360 ∘ 120 = 3 ∘ \Delta\theta=\frac{360^\circ}{120}=3^\circ Δθ=120360=3

所以同一个位置上,车头朝向相差约 3 ∘ 3^\circ 3 就可能进入不同的搜索状态。

5. 车辆不是一个点:矩形车身模型

自由空间规划必须考虑车身尺寸。设车辆矩形以后轴中心或基准点为参考,车长为 l l l,车宽为 w w w,基准点到车尾距离为 l b l_b lb,到车头距离为:

l f = l − l b l_f=l-l_b lf=llb

车辆在自身坐标系中的矩形区域为:

B = { ( u , v ) ∣ − l b ≤ u ≤ l f ,   − w 2 ≤ v ≤ w 2 } \mathcal{B}= \left\{ (u,v) \mid -l_b\le u\le l_f,\ -\frac{w}{2}\le v\le\frac{w}{2} \right\} B={(u,v)lbulf, 2wv2w}

当车辆位姿为 q = ( x , y , θ ) q=(x,y,\theta) q=(x,y,θ) 时,车身占据区域为:

B ( q ) = { [ x y ] + R ( θ ) [ u v ] ∣ ( u , v ) ∈ B } \mathcal{B}(q)= \left\{ \begin{bmatrix}x\\y\end{bmatrix}+ R(\theta) \begin{bmatrix}u\\v\end{bmatrix} \mid (u,v)\in\mathcal{B} \right\} B(q)={[xy]+R(θ)[uv](u,v)B}

其中旋转矩阵为:

R ( θ ) = [ cos ⁡ θ − sin ⁡ θ sin ⁡ θ cos ⁡ θ ] R(\theta)= \begin{bmatrix} \cos\theta & -\sin\theta\\ \sin\theta & \cos\theta \end{bmatrix} R(θ)=[cosθsinθsinθcosθ]

如果 B ( q ) \mathcal{B}(q) B(q) 中任意一点落入障碍物格子,则该位姿不可行:

∃ p ∈ B ( q ) , O ( grid ⁡ ( p ) ) = 1 ⇒ collision ⁡ ( q ) = 1 \exists p\in\mathcal{B}(q),\quad O(\operatorname{grid}(p))=1 \Rightarrow \operatorname{collision}(q)=1 pB(q),O(grid(p))=1collision(q)=1

实际搜索不可能对连续矩形里每个点都检测,因为矩形内部有无穷多个点。如果每扩展一个 Hybrid A* 节点,都重新对整车矩形做连续几何求交,计算会非常重。更实用的做法是:把“车辆矩形覆盖哪些栅格”提前离散化。

先固定一个航向桶 k k k(“第几个朝向区间”)。此时车辆相对栅格坐标系的朝向已经确定,车身矩形相对车辆基准点的形状也就确定。然后在车身矩形内部按小步长采样,例如使用 r 2 \frac{r}{2} 2r,其中 r r r 是 costmap 分辨率。对每个采样点 ( u , v ) (u,v) (u,v),先旋转到当前航向,再映射成相对车辆基准点的栅格偏移,(u,v)是相对于车辆坐标系下的车辆基准点偏移,( Δ x , Δ y \Delta x,\Delta y ΔxΔy)是相对于地图坐标系的基准点偏移):

$$
\begin{bmatrix}
\Delta x\
\Delta y
\end{bmatrix}

R(\theta_k)
\begin{bmatrix}
u\
v
\end{bmatrix}
$$

Δ i = round ⁡ ( Δ x r ) , Δ j = round ⁡ ( Δ y r ) \Delta i=\operatorname{round}\left(\frac{\Delta x}{r}\right), \qquad \Delta j=\operatorname{round}\left(\frac{\Delta y}{r}\right) Δi=round(rΔx),Δj=round(rΔy)

不同采样点可能落到同一个栅格,所以还要去重。最终,每个航向桶 k k k 都可以预先生成一组车身覆盖格子:

I body ( k ) = { ( Δ i , Δ j ) } \mathcal{I}_{\text{body}}(k)= \left\{ (\Delta i,\Delta j) \right\} Ibody(k)={(Δi,Δj)}

这组 I body ( k ) \mathcal{I}_{\text{body}}(k) Ibody(k) 的含义是:

如果车辆基准点位于某个栅格中心,那么车身矩形会覆盖这些相对偏移格子 \text{如果车辆基准点位于某个栅格中心,那么车身矩形会覆盖这些相对偏移格子} 如果车辆基准点位于某个栅格中心,那么车身矩形会覆盖这些相对偏移格子

举个小例子会更直观。假设车辆基准点在车身中心附近,车长 4   m 4\,\text{m} 4m,车宽 2   m 2\,\text{m} 2m,costmap 分辨率是 1   m 1\,\text{m} 1m。如果我们在车身矩形内部按 0.5   m 0.5\,\text{m} 0.5m 的步长取点,那么车身坐标系里会有很多采样点,比如:

( u , v ) ∈ { − 1.5 , − 1.0 , − 0.5 , 0 , 0.5 , 1.0 , 1.5 } × { − 1.0 , − 0.5 , 0 , 0.5 , 1.0 } (u,v)\in \{ -1.5,-1.0,-0.5,0,0.5,1.0,1.5 \} \times \{ -1.0,-0.5,0,0.5,1.0 \} (u,v){1.5,1.0,0.5,0,0.5,1.0,1.5}×{1.0,0.5,0,0.5,1.0}

其中, u u u 表示车身前后方向, v v v 表示车身左右方向。像 ( u , v ) = ( 1.5 , 0.5 ) (u,v)=(1.5,0.5) (u,v)=(1.5,0.5) 这样的点,代表“车身前部偏右的一块区域”; ( u , v ) = ( − 1.0 , − 0.5 ) (u,v)=(-1.0,-0.5) (u,v)=(1.0,0.5) 代表“车身后部偏左的一块区域”。

如果当前航向桶对应的角度是 90 ∘ 90^\circ 90,那么这个采样点会先旋转到地图坐标系,再变成某个栅格偏移。也就是说,原来在车身右上角的点,转到地图里后,可能落到车辆基准点的上方或右方,具体取决于当前朝向。

所以这些采样点并不是“随便挑的一些点”,而是在做这件事:

用一批离散代表点,近似整辆车的连续矩形占据区域 \text{用一批离散代表点,近似整辆车的连续矩形占据区域} 用一批离散代表点,近似整辆车的连续矩形占据区域

因此,真正检测某个位姿时,不需要重新采样矩形。只需把这些偏移索引平移到车辆基准格子 ( i , j ) (i,j) (i,j)

( i ′ , j ′ ) = ( i + Δ i ,   j + Δ j ) (i',j')=(i+\Delta i,\ j+\Delta j) (i,j)=(i+Δi, j+Δj)

然后检查是否越界或占据:

collision ⁡ ( i , j , k ) = ∃ ( Δ i , Δ j ) ∈ I body ( k ) , O ( i + Δ i , j + Δ j ) = 1 \operatorname{collision}(i,j,k)= \exists(\Delta i,\Delta j)\in\mathcal{I}_{\text{body}}(k), \quad O(i+\Delta i,j+\Delta j)=1 collision(i,j,k)=(Δi,Δj)Ibody(k),O(i+Δi,j+Δj)=1

直观地说,完整检查就是问:

车辆矩形覆盖的所有格子里,有没有任何一个是障碍物? \text{车辆矩形覆盖的所有格子里,有没有任何一个是障碍物?} 车辆矩形覆盖的所有格子里,有没有任何一个是障碍物?

如果有,碰撞;如果没有,不碰撞。

但是,Hybrid A* 搜索会产生大量候选节点。如果每个候选节点都遍历完整的 I body ( k ) \mathcal{I}_{\text{body}}(k) Ibody(k),仍然会花很多时间。为了进一步加速,可以借助欧氏距离变换,也就是 EDT。

D obs ( i , j ) D_{\text{obs}}(i,j) Dobs(i,j) 表示车辆基准格子 ( i , j ) (i,j) (i,j) 到最近障碍物格子的欧氏距离。也就是说:

$$
D_{\text{obs}}(i,j)

\text{车辆基准点附近最近障碍物离它有多远}
$$

注意这里的距离是从“车辆基准点”量到“最近障碍物”,还不是从“车身外轮廓”量到障碍物。

对车辆矩形而言,基准点到车身边界的最小距离近似为:

d min ⁡ = min ⁡ ( w 2 ,   l b ,   l f ) d_{\min}= \min \left( \frac{w}{2},\ l_b,\ l_f \right) dmin=min(2w, lb, lf)

这个 d min ⁡ d_{\min} dmin 可以理解成:从车辆基准点向任何方向出发,最快可能碰到车身边界的距离。因为车辆矩形围绕基准点展开,离基准点最近的边界可能是左侧边、右侧边、后边或前边,所以取:

d min ⁡ = min ⁡ ( w 2 , l b , l f ) d_{\min}=\min\left(\frac{w}{2},l_b,l_f\right) dmin=min(2w,lb,lf)

基准点到最远车身角点的距离为:

d max ⁡ = max ⁡ ( l f 2 + ( w 2 ) 2 , l b 2 + ( w 2 ) 2 ) d_{\max}= \max \left( \sqrt{l_f^2+\left(\frac{w}{2}\right)^2}, \sqrt{l_b^2+\left(\frac{w}{2}\right)^2} \right) dmax=max(lf2+(2w)2 ,lb2+(2w)2 )

这个 d max ⁡ d_{\max} dmax 可以理解成:从车辆基准点到整车矩形最远角点的距离。也就是说,整个车身一定被包含在以车辆基准点为圆心、半径为 d max ⁡ d_{\max} dmax 的圆里:

B ( q ) ⊆ { p ∣ ∥ p − p base ∥ ≤ d max ⁡ } \mathcal{B}(q) \subseteq \left\{ p\mid \|p-p_{\text{base}}\|\le d_{\max} \right\} B(q){pppbasedmax}

同时,以车辆基准点为圆心、半径为 d min ⁡ d_{\min} dmin 的圆一定被包含在车身矩形内部:

{ p ∣ ∥ p − p base ∥ < d min ⁡ } ⊆ B ( q ) \left\{ p\mid \|p-p_{\text{base}}\|<d_{\min} \right\} \subseteq \mathcal{B}(q) {pppbase<dmin}B(q)

于是有两个非常有用的快速结论。

第一种情况:障碍物足够远。

若:

D obs ( i , j ) > d max ⁡ D_{\text{obs}}(i,j)>d_{\max} Dobs(i,j)>dmax

最近障碍物比车身任何部分都远,可以直接判定无碰撞。

原因是:整个车辆都被包在半径 d max ⁡ d_{\max} dmax 的圆里。如果最近障碍物比这个圆还远,那么障碍物不可能碰到车身。

第二种情况:障碍物足够近。

若:

D obs ( i , j ) < d min ⁡ D_{\text{obs}}(i,j)<d_{\min} Dobs(i,j)<dmin

最近障碍物已经进入基准点附近的必撞区域,可以直接判定碰撞。

原因是:半径 d min ⁡ d_{\min} dmin 的圆一定在车身矩形内部。如果最近障碍物已经进入这个圆,那么它必然也进入了车身矩形。

只有中间情况才需要检查矩形采样格子:

d min ⁡ ≤ D obs ( i , j ) ≤ d max ⁡ d_{\min}\le D_{\text{obs}}(i,j)\le d_{\max} dminDobs(i,j)dmax

这个中间区域的含义是:仅凭最近障碍物距离还判断不出来。障碍物可能在车身矩形内,也可能在矩形外但仍落在外接圆内。所以必须回到精确一点的做法,遍历 I body ( k ) \mathcal{I}_{\text{body}}(k) Ibody(k)

来看一个具体例子。假设车辆参数为:

w = 2.0   m , l b = 1.0   m , l f = 3.0   m w=2.0\,\text{m},\qquad l_b=1.0\,\text{m},\qquad l_f=3.0\,\text{m} w=2.0m,lb=1.0m,lf=3.0m

则:

$$
d_{\min}

\min(1.0,1.0,3.0)

1.0,\text{m}
$$

$$
d_{\max}

\max
\left(
\sqrt{3.02+1.02},
\sqrt{1.02+1.02}
\right)

\sqrt{10}
\approx
3.16,\text{m}
$$

现在看三种情况。

若最近障碍物距离为:

D obs = 4.0   m D_{\text{obs}}=4.0\,\text{m} Dobs=4.0m

因为:

4.0 > d max ⁡ = 3.16 4.0>d_{\max}=3.16 4.0>dmax=3.16

所以障碍物比车辆最远角点还远,可以直接判定无碰撞,不需要遍历车身覆盖格子。

若最近障碍物距离为:

D obs = 0.5   m D_{\text{obs}}=0.5\,\text{m} Dobs=0.5m

因为:

0.5 < d min ⁡ = 1.0 0.5<d_{\min}=1.0 0.5<dmin=1.0

所以障碍物已经进入车身内部必然覆盖的区域,可以直接判定碰撞。

若最近障碍物距离为:

D obs = 2.0   m D_{\text{obs}}=2.0\,\text{m} Dobs=2.0m

因为:

1.0 ≤ 2.0 ≤ 3.16 1.0\le2.0\le3.16 1.02.03.16

这时不能只靠距离判断。比如障碍物可能在车辆前方矩形内部,也可能在车辆斜侧方、落在外接圆内但不在矩形内。此时才需要遍历:

( Δ i , Δ j ) ∈ I body ( k ) (\Delta i,\Delta j)\in\mathcal{I}_{\text{body}}(k) (Δi,Δj)Ibody(k)

逐个检查车身覆盖格子是否占据。

所以这套加速逻辑可以总结成:

太远:直接安全 太近:直接碰撞 中间:再做矩形格子精查 \text{太远:直接安全} \qquad \text{太近:直接碰撞} \qquad \text{中间:再做矩形格子精查} 太远:直接安全太近:直接碰撞中间:再做矩形格子精查

这让 Hybrid A* 能在大量节点扩展时保持较高效率。

6. 欧氏距离变换:既服务碰撞,也服务代价

欧氏距离变换,也就是 EDT,给每个非障碍格子计算到最近障碍物的距离:

D obs ( i , j ) = min ⁡ ( a , b ) : O ( a , b ) = 1 r ( i − a ) 2 + ( j − b ) 2 D_{\text{obs}}(i,j)= \min_{(a,b):O(a,b)=1} r\sqrt{(i-a)^2+(j-b)^2} Dobs(i,j)=(a,b):O(a,b)=1minr(ia)2+(jb)2

同时还可以记录最近障碍物相对当前格子的方向角:

ϕ obs ( i , j ) = atan2 ⁡ ( y b − y j ,   x b − x i ) \phi_{\text{obs}}(i,j)= \operatorname{atan2}(y_b-y_j,\ x_b-x_i) ϕobs(i,j)=atan2(ybyj, xbxi)

这个距离图有三种用途。

第一,用于碰撞检测加速。上一节已经说明,距离太远一定无碰撞,距离太近一定碰撞。

第二,用于自适应扩展步长。离障碍物近时,车辆应当用更短的运动原语细致搜索;空旷区域可以用更长步长快速推进。

第三,用于障碍物距离代价。即使没有碰撞,贴着障碍物走也不舒服、不稳健,因此路径代价应鼓励车辆留出余量。

注意,EDT 不是简单的“障碍物膨胀”。障碍物膨胀通常只给出可行与不可行;EDT 给出连续距离,因此可以构造更细腻的软代价。

7. 运动原语:用自行车模型生成下一批节点

Hybrid A* 的每一次扩展不是走到八邻域,而是尝试若干车辆运动原语。车辆运动学采用常见的运动学自行车模型:

x ˙ = v cos ⁡ θ \dot{x}=v\cos\theta x˙=vcosθ

y ˙ = v sin ⁡ θ \dot{y}=v\sin\theta y˙=vsinθ

θ ˙ = v L tan ⁡ δ \dot{\theta}=\frac{v}{L}\tan\delta θ˙=Lvtanδ

其中 L L L 是轴距, δ \delta δ 是前轮转角。

用路径弧长 s s s 替代时间,满足:

d θ d s = tan ⁡ δ L = 1 R \frac{d\theta}{ds}= \frac{\tan\delta}{L}= \frac{1}{R} dsdθ=Ltanδ=R1

其中:

R = L tan ⁡ δ R=\frac{L}{\tan\delta} R=tanδL

如果一段运动原语中转角 δ \delta δ 保持不变,行驶距离为 d d d,则角度变化为:

β = d R \beta=\frac{d}{R} β=Rd

∣ δ ∣ |\delta| δ 很小时,可以认为车辆直行:

x ′ = x + d cos ⁡ θ x'=x+d\cos\theta x=x+dcosθ

y ′ = y + d sin ⁡ θ y'=y+d\sin\theta y=y+dsinθ

θ ′ = θ \theta'=\theta θ=θ

∣ δ ∣ |\delta| δ 不为零时,车辆沿圆弧运动,积分得到:

先把这个过程想成“沿着固定半径的圆走了一小段”。因为转角 δ \delta δ 保持不变,所以转弯半径 R R R 是常数,角速度也就恒定。此时车辆朝向满足:

θ ( s ) = θ 0 + s R \theta(s)=\theta_0+\frac{s}{R} θ(s)=θ0+Rs

这里 s s s 是沿轨迹前进的弧长。若从初始位置开始积分,设初始位姿为 ( x 0 , y 0 , θ 0 ) (x_0,y_0,\theta_0) (x0,y0,θ0),那么由几何关系可知:

d x d s = cos ⁡ θ ( s ) \frac{dx}{ds}=\cos\theta(s) dsdx=cosθ(s)

d y d s = sin ⁡ θ ( s ) \frac{dy}{ds}=\sin\theta(s) dsdy=sinθ(s)

θ ( s ) = θ 0 + s R \theta(s)=\theta_0+\frac{s}{R} θ(s)=θ0+Rs 代进去,就得到:

x ( s ) = x 0 + ∫ 0 d cos ⁡ ( θ 0 + s R )   d s x(s)=x_0+\int_0^d \cos\left(\theta_0+\frac{s}{R}\right)\,ds x(s)=x0+0dcos(θ0+Rs)ds

y ( s ) = y 0 + ∫ 0 d sin ⁡ ( θ 0 + s R )   d s y(s)=y_0+\int_0^d \sin\left(\theta_0+\frac{s}{R}\right)\,ds y(s)=y0+0dsin(θ0+Rs)ds

现在对第一个积分做变量替换。令:

u = θ 0 + s R u=\theta_0+\frac{s}{R} u=θ0+Rs

则:

d s = R   d u ds=R\,du ds=Rdu

s = 0 s=0 s=0 时, u = θ 0 u=\theta_0 u=θ0;当 s = d s=d s=d 时, u = θ 0 + d R = θ 0 + β u=\theta_0+\frac{d}{R}=\theta_0+\beta u=θ0+Rd=θ0+β。于是:

x ′ = x 0 + R ∫ θ 0 θ 0 + β cos ⁡ u   d u x'=x_0+R\int_{\theta_0}^{\theta_0+\beta}\cos u\,du x=x0+Rθ0θ0+βcosudu

而:

∫ cos ⁡ u   d u = sin ⁡ u \int \cos u\,du=\sin u cosudu=sinu

所以:

x ′ = x 0 + R ( sin ⁡ ( θ 0 + β ) − sin ⁡ θ 0 ) x'=x_0+R\left(\sin(\theta_0+\beta)-\sin\theta_0\right) x=x0+R(sin(θ0+β)sinθ0)

同理,

y ′ = y 0 + R ∫ θ 0 θ 0 + β sin ⁡ u   d u y'=y_0+R\int_{\theta_0}^{\theta_0+\beta}\sin u\,du y=y0+Rθ0θ0+βsinudu

而:

∫ sin ⁡ u   d u = − cos ⁡ u \int \sin u\,du=-\cos u sinudu=cosu

所以:

y ′ = y 0 + R ( cos ⁡ θ 0 − cos ⁡ ( θ 0 + β ) ) y'=y_0+R\left(\cos\theta_0-\cos(\theta_0+\beta)\right) y=y0+R(cosθ0cos(θ0+β))

把初始角度写回 θ \theta θ,把初始位置写回 x , y x,y x,y,就得到源码里那组圆弧积分公式:

x ′ = x + R ( sin ⁡ ( θ + β ) − sin ⁡ θ ) x'=x+ R\left(\sin(\theta+\beta)-\sin\theta\right) x=x+R(sin(θ+β)sinθ)

y ′ = y + R ( cos ⁡ θ − cos ⁡ ( θ + β ) ) y'=y+ R\left(\cos\theta-\cos(\theta+\beta)\right) y=y+R(cosθcos(θ+β))

θ ′ = θ + β \theta'=\theta+\beta θ=θ+β

如果你想检查这个结果是否合理,可以看两个极端:

d = 0 d=0 d=0 时, β = 0 \beta=0 β=0,于是

x ′ = x , y ′ = y x'=x,\qquad y'=y x=x,y=y

这表示车没有移动,符合直觉。

β \beta β 很小的时候,用一阶近似:

sin ⁡ ( θ + β ) ≈ sin ⁡ θ + β cos ⁡ θ \sin(\theta+\beta)\approx \sin\theta+\beta\cos\theta sin(θ+β)sinθ+βcosθ

cos ⁡ ( θ + β ) ≈ cos ⁡ θ − β sin ⁡ θ \cos(\theta+\beta)\approx \cos\theta-\beta\sin\theta cos(θ+β)cosθβsinθ

代回去可得:

x ′ ≈ x + R β cos ⁡ θ = x + d cos ⁡ θ x'\approx x+R\beta\cos\theta = x+d\cos\theta xx+Rβcosθ=x+dcosθ

y ′ ≈ y + R β sin ⁡ θ = y + d sin ⁡ θ y'\approx y+R\beta\sin\theta = y+d\sin\theta yy+Rβsinθ=y+dsinθ

这就退化成了直行公式。也就是说,直行并不是另一套完全不同的模型,而是圆弧模型在“转角趋近于 0”时的极限情况。

这组公式是 Hybrid A* 的运动学核心。它保证每条边都是车辆可以实际跟踪的局部轨迹。

8. 转角离散:从连续方向盘到有限动作集

这一节要解决的问题很直接:

当前节点到底允许尝试哪些转向动作? \text{当前节点到底允许尝试哪些转向动作?} 当前节点到底允许尝试哪些转向动作?

如果不做离散化,方向盘角度是连续的,理论上有无穷多个值,A* 根本没法枚举。于是 Hybrid A* 把“连续转角”压缩成少量代表动作:

  • 左转
  • 直行
  • 右转

更一般地说,车辆最大前轮转角为 δ vehicle \delta_{\text{vehicle}} δvehicle。为了留出控制余量,自由空间搜索通常只使用其中一部分:

δ max ⁡ = ρ δ vehicle \delta_{\max}= \rho\delta_{\text{vehicle}} δmax=ρδvehicle

其中 ρ ∈ ( 0 , 1 ] \rho\in(0,1] ρ(0,1] 是最大转角使用比例。

这一步的目的不是“把车转得更猛”,而是反过来:

只用车辆物理上更稳、更容易跟踪的那一部分转角 \text{只用车辆物理上更稳、更容易跟踪的那一部分转角} 只用车辆物理上更稳、更容易跟踪的那一部分转角

若转角步数为 N s N_s Ns,则转角分辨率为:

Δ δ = δ max ⁡ N s \Delta\delta= \frac{\delta_{\max}}{N_s} Δδ=Nsδmax

运动原语使用整数转角索引:

u ∈ { − N s , − N s + 1 , … , 0 , … , N s − 1 , N s } u\in\{-N_s,-N_s+1,\dots,0,\dots,N_s-1,N_s\} u{Ns,Ns+1,,0,,Ns1,Ns}

对应真实转角:

δ u = u Δ δ \delta_u=u\Delta\delta δu=uΔδ

每个节点最多会尝试:

2 N s + 1 2N_s+1 2Ns+1

个前进原语。如果允许倒车,则再尝试同样数量的倒车原语。因此每个节点的最大分支数为:

B = { 2 N s + 1 , 仅前进 2 ( 2 N s + 1 ) , 允许倒车 B= \begin{cases} 2N_s+1, & \text{仅前进}\\ 2(2N_s+1), & \text{允许倒车} \end{cases} B={2Ns+1,2(2Ns+1),仅前进允许倒车

默认 N s = 1 N_s=1 Ns=1 时,动作集非常简洁:

δ ∈ { − δ max ⁡ , 0 , + δ max ⁡ } \delta\in\{-\delta_{\max},0,+\delta_{\max}\} δ{δmax,0,+δmax}

如果允许倒车,一个节点最多扩展 6 6 6 个动作:前进左转、前进直行、前进右转、倒车左转、倒车直行、倒车右转。

所以这一节的本质不是“讲一个参数”,而是在定义 Hybrid A* 的第一层动作空间:

每个节点有哪些可尝试的方向盘动作 \text{每个节点有哪些可尝试的方向盘动作} 每个节点有哪些可尝试的方向盘动作

这个动作空间必须足够小,A* 才能算得动;又必须足够像车,路径才有意义。

一种很有用的理解方式是:

转角离散 = 把连续方向盘变成有限几个代表姿态 \text{转角离散} = \text{把连续方向盘变成有限几个代表姿态} 转角离散=把连续方向盘变成有限几个代表姿态

这样,搜索树每次扩展时就不是在“随机试方向”,而是在从一个有限集合里选下一个动作。

9. 扩展距离:为什么步长不能固定得太死

这一节要解决的问题是:

选定了转角以后,这一步到底走多远? \text{选定了转角以后,这一步到底走多远?} 选定了转角以后,这一步到底走多远?

如果步长固定得太死,Hybrid A* 会有两个极端问题:

  • 步长太长:狭窄通道里容易一脚跨过可行解,或者直接撞障碍物
  • 步长太短:节点数量暴涨,搜索变慢

所以这里不是为了追求某个“神奇精确值”,而是在做一件更实际的事:

让步长随场景变化,在效率和精度之间折中 \text{让步长随场景变化,在效率和精度之间折中} 让步长随场景变化,在效率和精度之间折中

每个运动原语需要一个行驶距离 d exp d_{\text{exp}} dexp

因此可以定义最小步长:

d min ⁡ = max ⁡ ( d param ,   1.5 r ) d_{\min}= \max \left( d_{\text{param}},\ 1.5r \right) dmin=max(dparam, 1.5r)

其中 d param d_{\text{param}} dparam 是配置的基础扩展距离, r r r 是栅格分辨率。乘以 1.5 1.5 1.5 是为了让每次扩展至少跨过足够的栅格距离,避免原地反复落入同一索引。

这句话的意思可以再翻译一次:

不能只走到“还在同一个格子附近”的程度,否则搜索会空转 \text{不能只走到“还在同一个格子附近”的程度,否则搜索会空转} 不能只走到还在同一个格子附近的程度,否则搜索会空转

最大步长与轴距相关:

d max ⁡ = max ⁡ ( 0.5 L ,   d min ⁡ ) d_{\max}= \max \left( 0.5L,\ d_{\min} \right) dmax=max(0.5L, dmin)

若启用自适应扩展,当前节点到目标的欧氏距离为 d g d_g dg,到最近障碍物的距离为 d o d_o do,则:

d raw = min ⁡ ( 0.15 d g ,   0.3 d o ) d_{\text{raw}}= \min \left( 0.15d_g,\ 0.3d_o \right) draw=min(0.15dg, 0.3do)

最终扩展距离为:

d exp = clip ⁡ ( d raw , d min ⁡ , d max ⁡ ) d_{\text{exp}}= \operatorname{clip} \left( d_{\text{raw}}, d_{\min}, d_{\max} \right) dexp=clip(draw,dmin,dmax)

这个公式体现了两个直觉:

离目标越近,步长越短,方便精确进入目标容差。

离障碍物越近,步长越短,方便在狭窄空间里细致调整。

在开阔且离目标较远的区域,步长可以增大,提高搜索效率。

所以这一节的目的不是“算一个固定长度”,而是给每一次动作配一个合适的前进距离,让搜索既不会太笨,也不会太慢。

10. 前进、倒车与搜索方向

这一节要解决的是最现实的驾驶问题:

这一步是前进,还是倒车? \text{这一步是前进,还是倒车?} 这一步是前进,还是倒车?

车辆起步脱困经常需要倒车,所以状态里不仅记录位姿,还记录当前运动方向:

m ∈ { forward , backward } m\in\{\text{forward},\text{backward}\} m{forward,backward}

运动原语距离带符号:

d = { + d exp , 前进 − d exp , 倒车 d= \begin{cases} +d_{\text{exp}}, & \text{前进}\\ -d_{\text{exp}}, & \text{倒车} \end{cases} d={+dexp,dexp,前进倒车

把这个 d d d 代入自行车模型,就能同时得到前进和倒车的下一个位姿。

也就是说,方向不是一个附加装饰,而是运动学的一部分:

同样的转角,同样的距离,前进和倒车会走出不同的轨迹 \text{同样的转角,同样的距离,前进和倒车会走出不同的轨迹} 同样的转角,同样的距离,前进和倒车会走出不同的轨迹

还存在“正向搜索”和“反向搜索”的概念。正向搜索从车辆当前位置扩展到目标;反向搜索从目标扩展到车辆当前位置。两者得到的几何路径可以相同,但节点展开方向不同。反向搜索有时能更容易从道路目标处长出一条回到起点的轨迹,尤其当目标姿态约束很强时。

这一层的目的可以概括成两句话:

  1. 让 Hybrid A* 能搜索包含倒车的脱困路径。
  2. 让后续控制器知道每一段路是前进还是倒车。

无论搜索方向如何,最终输出都要转换成从车辆当前位置到道路回归点的顺序,并且每个路点要带上它属于前进段还是倒车段。这个方向标记很重要,因为后续会在换挡处把路径切分成多个子路径。

11. A* 的基本框架

这一节先不急着讲 Hybrid A*。先用一个普通二维栅格案例把 A* 讲清楚,再把每个概念映射回车辆规划。

设有一张小地图。S 是起点,G 是目标,X 是障碍物,. 是可通行格子:

y=4   .  .  .  .  .
y=3   .  .  .  .  .
y=2   .  .  X  .  G
y=1   .  .  X  .  .
y=0   S  .  X  .  .
      x=0 1  2  3  4

起点和目标是:

S = ( 0 , 0 ) , G = ( 4 , 2 ) S=(0,0),\qquad G=(4,2) S=(0,0),G=(4,2)

障碍物是:

X = { ( 2 , 0 ) , ( 2 , 1 ) , ( 2 , 2 ) } X=\{(2,0),(2,1),(2,2)\} X={(2,0),(2,1),(2,2)}

为了让例子简单,先假设普通 A* 只能上下左右移动,每走一格代价为 1 1 1。也就是:

c ( n , n ′ ) = 1 c(n,n')=1 c(n,n)=1

这里的“节点”就是一个格子坐标,例如:

n = ( x , y ) n=(x,y) n=(x,y)

A* 给每个节点记三类代价:

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

其中:

g ( n ) = 从起点到  n  的累计实际代价 g(n)=\text{从起点到 }n\text{ 的累计实际代价} g(n)=从起点到 n 的累计实际代价

h ( n ) = 从  n  到目标的估计代价 h(n)=\text{从 }n\text{ 到目标的估计代价} h(n)= n 到目标的估计代价

在这个案例里, g ( n ) g(n) g(n) 很具体:从 S S S 走到 n n n 已经走了几格。

例如,从 S = ( 0 , 0 ) S=(0,0) S=(0,0) 走到 ( 1 , 0 ) (1,0) (1,0),只走了一步,所以:

g ( 1 , 0 ) = 1 g(1,0)=1 g(1,0)=1

S S S 走到 ( 1 , 1 ) (1,1) (1,1),可能走:

( 0 , 0 ) → ( 1 , 0 ) → ( 1 , 1 ) (0,0)\rightarrow(1,0)\rightarrow(1,1) (0,0)(1,0)(1,1)

所以:

g ( 1 , 1 ) = 2 g(1,1)=2 g(1,1)=2

h ( n ) h(n) h(n) 是“估计还要走多远”。在只能上下左右走的栅格里,常用曼哈顿距离:

h ( x , y ) = ∣ x − x G ∣ + ∣ y − y G ∣ h(x,y)=|x-x_G|+|y-y_G| h(x,y)=xxG+yyG

比如目标是 G = ( 4 , 2 ) G=(4,2) G=(4,2),则:

h ( 1 , 0 ) = ∣ 1 − 4 ∣ + ∣ 0 − 2 ∣ = 5 h(1,0)=|1-4|+|0-2|=5 h(1,0)=∣14∣+∣02∣=5

h ( 1 , 1 ) = ∣ 1 − 4 ∣ + ∣ 1 − 2 ∣ = 4 h(1,1)=|1-4|+|1-2|=4 h(1,1)=∣14∣+∣12∣=4

于是:

f ( 1 , 0 ) = g ( 1 , 0 ) + h ( 1 , 0 ) = 1 + 5 = 6 f(1,0)=g(1,0)+h(1,0)=1+5=6 f(1,0)=g(1,0)+h(1,0)=1+5=6

f ( 1 , 1 ) = g ( 1 , 1 ) + h ( 1 , 1 ) = 2 + 4 = 6 f(1,1)=g(1,1)+h(1,1)=2+4=6 f(1,1)=g(1,1)+h(1,1)=2+4=6

f ( n ) f(n) f(n) 的意思就是:

如果路径经过  n ,  从起点到目标大约总共要花多少代价 \text{如果路径经过 }n,\text{ 从起点到目标大约总共要花多少代价} 如果路径经过 n, 从起点到目标大约总共要花多少代价

所以 A* 每次优先扩展 f f f 最小的节点。它不是只看“已经走了多远”,也不是只看“离目标多近”,而是两者相加。

节点状态分三类:

None → Open → Closed \text{None} \rightarrow \text{Open} \rightarrow \text{Closed} NoneOpenClosed

含义分别是:

None : 尚未发现 \text{None}: \text{尚未发现} None:尚未发现

Open : 已发现,等待扩展 \text{Open}: \text{已发现,等待扩展} Open:已发现,等待扩展

Closed : 已扩展,不再重复处理 \text{Closed}: \text{已扩展,不再重复处理} Closed:已扩展,不再重复处理

在这个案例里,可以这样理解:

None : 这个格子还没有被搜索看见 \text{None}: \text{这个格子还没有被搜索看见} None:这个格子还没有被搜索看见

Open : 这个格子已经被发现,但还没轮到它向外扩展 \text{Open}: \text{这个格子已经被发现,但还没轮到它向外扩展} Open:这个格子已经被发现,但还没轮到它向外扩展

Closed : 这个格子已经向外扩展过,它的邻居已经被检查 \text{Closed}: \text{这个格子已经向外扩展过,它的邻居已经被检查} Closed:这个格子已经向外扩展过,它的邻居已经被检查

下面用这个小地图走几轮 A*。

初始时,只有起点在 Open 集合中:

g ( S ) = 0 g(S)=0 g(S)=0

h ( S ) = ∣ 0 − 4 ∣ + ∣ 0 − 2 ∣ = 6 h(S)=|0-4|+|0-2|=6 h(S)=∣04∣+∣02∣=6

f ( S ) = 6 f(S)=6 f(S)=6

此时:

Open = { S } , Closed = ∅ \text{Open}=\{S\},\qquad \text{Closed}=\varnothing Open={S},Closed=

第一轮,A* 从 Open 中取出 f f f 最小的 S S S,把它放入 Closed,然后扩展它的邻居。 S = ( 0 , 0 ) S=(0,0) S=(0,0) 的可行邻居是:

( 1 , 0 ) , ( 0 , 1 ) (1,0),\quad (0,1) (1,0),(0,1)

于是:

g ( 1 , 0 ) = 1 , h ( 1 , 0 ) = 5 , f ( 1 , 0 ) = 6 g(1,0)=1,\quad h(1,0)=5,\quad f(1,0)=6 g(1,0)=1,h(1,0)=5,f(1,0)=6

g ( 0 , 1 ) = 1 , h ( 0 , 1 ) = 5 , f ( 0 , 1 ) = 6 g(0,1)=1,\quad h(0,1)=5,\quad f(0,1)=6 g(0,1)=1,h(0,1)=5,f(0,1)=6

并记录父节点:

parent ⁡ ( 1 , 0 ) = S \operatorname{parent}(1,0)=S parent(1,0)=S

parent ⁡ ( 0 , 1 ) = S \operatorname{parent}(0,1)=S parent(0,1)=S

此时:

Open = { ( 1 , 0 ) , ( 0 , 1 ) } \text{Open}=\{(1,0),(0,1)\} Open={(1,0),(0,1)}

Closed = { S } \text{Closed}=\{S\} Closed={S}

第二轮,两个节点的 f f f 都是 6 6 6,谁先扩展取决于优先队列的同价处理。假设先扩展 ( 1 , 0 ) (1,0) (1,0)。它的邻居有:

( 0 , 0 ) , ( 2 , 0 ) , ( 1 , 1 ) (0,0),\quad (2,0),\quad (1,1) (0,0),(2,0),(1,1)

其中 ( 0 , 0 ) (0,0) (0,0) 已经 Closed,跳过; ( 2 , 0 ) (2,0) (2,0) 是障碍物,跳过;只剩:

( 1 , 1 ) (1,1) (1,1)

于是:

g ( 1 , 1 ) = g ( 1 , 0 ) + 1 = 2 g(1,1)=g(1,0)+1=2 g(1,1)=g(1,0)+1=2

h ( 1 , 1 ) = ∣ 1 − 4 ∣ + ∣ 1 − 2 ∣ = 4 h(1,1)=|1-4|+|1-2|=4 h(1,1)=∣14∣+∣12∣=4

f ( 1 , 1 ) = 6 f(1,1)=6 f(1,1)=6

并记录:

parent ⁡ ( 1 , 1 ) = ( 1 , 0 ) \operatorname{parent}(1,1)=(1,0) parent(1,1)=(1,0)

这一步展示了 A* 的第一个关键动作:过滤不可行邻居

对应到 Hybrid A*,过滤的内容会更复杂:

  1. 检查 n ′ n' n 是否越界。
  2. 检查 n ′ n' n 的基准格子是否为障碍物。
  3. 检查完整矩形车身是否碰撞。

普通 A* 只看一个格子有没有障碍;Hybrid A* 要检查整辆车的矩形 footprint。

第三轮,再扩展 ( 0 , 1 ) (0,1) (0,1)。这里需要特别说明:第二轮结束时,Open 集合里至少有两个同样优先级的节点:

( 0 , 1 ) : g = 1 , h = 5 , f = 6 (0,1):\quad g=1,\quad h=5,\quad f=6 (0,1):g=1,h=5,f=6

( 1 , 1 ) : g = 2 , h = 4 , f = 6 (1,1):\quad g=2,\quad h=4,\quad f=6 (1,1):g=2,h=4,f=6

因为二者的 f f f 值相同,A* 的主规则无法区分谁更应该先扩展。本教程这个例子采用一种常见的平局处理方式:如果 f f f 相同,就先扩展更早进入 Open 集合的节点 ( 0 , 1 ) (0,1) (0,1) 是第一轮从起点 S S S 直接加入 Open 的,而 ( 1 , 1 ) (1,1) (1,1) 是第二轮才从 ( 1 , 0 ) (1,0) (1,0) 加入 Open 的,所以第三轮先研究 ( 0 , 1 ) (0,1) (0,1)

如果某个实现采用另一种平局规则,例如 f f f 相同时优先选择 h h h 更小、看起来离目标更近的节点,那么第三轮也可能先扩展 ( 1 , 1 ) (1,1) (1,1)。这不改变 A* 的核心思想,只会影响同分节点的展开顺序。

现在继续看 ( 0 , 1 ) (0,1) (0,1) 的扩展。它的可行邻居里可能也能到达 ( 1 , 1 ) (1,1) (1,1)

( 0 , 1 ) → ( 1 , 1 ) (0,1)\rightarrow(1,1) (0,1)(1,1)

候选代价为:

g new ( 1 , 1 ) = g ( 0 , 1 ) + 1 = 2 g_{\text{new}}(1,1)=g(0,1)+1=2 gnew(1,1)=g(0,1)+1=2

但是 ( 1 , 1 ) (1,1) (1,1) 之前已经有旧代价:

g old ( 1 , 1 ) = 2 g_{\text{old}}(1,1)=2 gold(1,1)=2

新旧相同,不更好,所以不需要更新父节点。

如果某次出现:

g new ( n ′ ) < g old ( n ′ ) g_{\text{new}}(n')<g_{\text{old}}(n') gnew(n)<gold(n)

才会更新:

g ( n ′ ) ← g new ( n ′ ) g(n')\leftarrow g_{\text{new}}(n') g(n)gnew(n)

f ( n ′ ) ← g ( n ′ ) + h ( n ′ ) f(n')\leftarrow g(n')+h(n') f(n)g(n)+h(n)

parent ⁡ ( n ′ ) ← n \operatorname{parent}(n')\leftarrow n parent(n)n

这个动作叫“松弛”。它的意思很朴素:

如果我发现了一条到达某节点更便宜的路,就改用这条路 \text{如果我发现了一条到达某节点更便宜的路,就改用这条路} 如果我发现了一条到达某节点更便宜的路,就改用这条路

为了把这个例子完整走完,我们继续沿着同一张小地图展开。下面给出一种可能的 A* 展开顺序。注意:当多个节点 f f f 相同时,不同实现的优先队列可能选择不同节点;这里选择其中一种顺序来讲清楚过程,不影响 A* 的核心逻辑。

为了让表格更紧凑,记号写成:

( x , y ) : g , h , f (x,y):g,h,f (x,y):g,h,f

例如:

( 1 , 0 ) : 1 , 5 , 6 (1,0):1,5,6 (1,0):1,5,6

表示:

g ( 1 , 0 ) = 1 , h ( 1 , 0 ) = 5 , f ( 1 , 0 ) = 6 g(1,0)=1,\quad h(1,0)=5,\quad f(1,0)=6 g(1,0)=1,h(1,0)=5,f(1,0)=6

完整过程如下。

轮次 从 Open 取出 主要检查与更新
初始化 Open 只有 S : 0 , 6 , 6 S:0,6,6 S:0,6,6
1 S = ( 0 , 0 ) S=(0,0) S=(0,0) 加入 ( 1 , 0 ) : 1 , 5 , 6 (1,0):1,5,6 (1,0):1,5,6,加入 ( 0 , 1 ) : 1 , 5 , 6 (0,1):1,5,6 (0,1):1,5,6;它们的 parent 都是 S S S
2 ( 1 , 0 ) (1,0) (1,0) ( 0 , 0 ) (0,0) (0,0) 已 Closed,跳过; ( 2 , 0 ) (2,0) (2,0) 是障碍物,跳过;加入 ( 1 , 1 ) : 2 , 4 , 6 (1,1):2,4,6 (1,1):2,4,6,parent 为 ( 1 , 0 ) (1,0) (1,0)
3 ( 0 , 1 ) (0,1) (0,1) ( 1 , 1 ) (1,1) (1,1) 同为 f = 6 f=6 f=6,但 ( 0 , 1 ) (0,1) (0,1) 更早进入 Open,所以本例先取它; ( 0 , 0 ) (0,0) (0,0) 已 Closed,跳过;到 ( 1 , 1 ) (1,1) (1,1) 的新代价仍是 2 2 2,不更优;加入 ( 0 , 2 ) : 2 , 4 , 6 (0,2):2,4,6 (0,2):2,4,6,parent 为 ( 0 , 1 ) (0,1) (0,1)
4 ( 1 , 1 ) (1,1) (1,1) ( 2 , 1 ) (2,1) (2,1) 是障碍物,跳过;加入 ( 1 , 2 ) : 3 , 3 , 6 (1,2):3,3,6 (1,2):3,3,6,parent 为 ( 1 , 1 ) (1,1) (1,1)
5 ( 0 , 2 ) (0,2) (0,2) ( 1 , 2 ) (1,2) (1,2) 的新代价仍是 3 3 3,不更优;加入 ( 0 , 3 ) : 3 , 5 , 8 (0,3):3,5,8 (0,3):3,5,8,parent 为 ( 0 , 2 ) (0,2) (0,2)
6 ( 1 , 2 ) (1,2) (1,2) ( 2 , 2 ) (2,2) (2,2) 是障碍物,跳过;加入 ( 1 , 3 ) : 4 , 4 , 8 (1,3):4,4,8 (1,3):4,4,8,parent 为 ( 1 , 2 ) (1,2) (1,2)
7 ( 0 , 3 ) (0,3) (0,3) ( 1 , 3 ) (1,3) (1,3) 的新代价仍是 4 4 4,不更优;加入 ( 0 , 4 ) : 4 , 6 , 10 (0,4):4,6,10 (0,4):4,6,10,parent 为 ( 0 , 3 ) (0,3) (0,3)
8 ( 1 , 3 ) (1,3) (1,3) 加入 ( 2 , 3 ) : 5 , 3 , 8 (2,3):5,3,8 (2,3):5,3,8,parent 为 ( 1 , 3 ) (1,3) (1,3);加入 ( 1 , 4 ) : 5 , 5 , 10 (1,4):5,5,10 (1,4):5,5,10
9 ( 2 , 3 ) (2,3) (2,3) ( 2 , 2 ) (2,2) (2,2) 是障碍物,跳过;加入 ( 3 , 3 ) : 6 , 2 , 8 (3,3):6,2,8 (3,3):6,2,8,parent 为 ( 2 , 3 ) (2,3) (2,3);加入 ( 2 , 4 ) : 6 , 4 , 10 (2,4):6,4,10 (2,4):6,4,10
10 ( 3 , 3 ) (3,3) (3,3) 加入 ( 4 , 3 ) : 7 , 1 , 8 (4,3):7,1,8 (4,3):7,1,8,parent 为 ( 3 , 3 ) (3,3) (3,3);加入 ( 3 , 2 ) : 7 , 1 , 8 (3,2):7,1,8 (3,2):7,1,8;加入 ( 3 , 4 ) : 7 , 3 , 10 (3,4):7,3,10 (3,4):7,3,10
11 ( 4 , 3 ) (4,3) (4,3) 发现目标 G = ( 4 , 2 ) : 8 , 0 , 8 G=(4,2):8,0,8 G=(4,2):8,0,8,parent 为 ( 4 , 3 ) (4,3) (4,3);但目标此时只是进入 Open
12 G = ( 4 , 2 ) G=(4,2) G=(4,2) 目标从 Open 中被取出,判定到达,搜索结束

这张表里有几个关键细节。

第一,A* 不是看到障碍物就停,而是绕过障碍物。比如 ( 2 , 0 ) (2,0) (2,0) ( 2 , 1 ) (2,1) (2,1) ( 2 , 2 ) (2,2) (2,2) 都是障碍物,所以搜索自然被迫走到上方通道:

( 1 , 3 ) → ( 2 , 3 ) → ( 3 , 3 ) (1,3)\rightarrow(2,3)\rightarrow(3,3) (1,3)(2,3)(3,3)

第二,一个节点可能被不同路径再次发现。例如第 3 轮, ( 0 , 1 ) (0,1) (0,1) 也可以走到 ( 1 , 1 ) (1,1) (1,1)

( 0 , 1 ) → ( 1 , 1 ) (0,1)\rightarrow(1,1) (0,1)(1,1)

但候选代价是:

g new ( 1 , 1 ) = g ( 0 , 1 ) + 1 = 2 g_{\text{new}}(1,1)=g(0,1)+1=2 gnew(1,1)=g(0,1)+1=2

而旧代价已经是:

g old ( 1 , 1 ) = 2 g_{\text{old}}(1,1)=2 gold(1,1)=2

所以不更新 parent。若某次真的发现更便宜路线,比如:

g old ( C ) = 7 g_{\text{old}}(C)=7 gold(C)=7

但新路线为:

g new ( C ) = g ( n ) + c ( n , C ) = 4 + 1 = 5 g_{\text{new}}(C)=g(n)+c(n,C)=4+1=5 gnew(C)=g(n)+c(n,C)=4+1=5

因为:

5 < 7 5<7 5<7

A* 才会把 C C C 的父节点改成 n n n,并重新计算 f ( C ) f(C) f(C)。这就是“松弛”的具体含义。

第三,目标不是一被发现就立刻结束。第 11 轮只是把 G G G 放进 Open:

G : 8 , 0 , 8 G:8,0,8 G:8,0,8

第 12 轮,当 G G G 成为当前 f f f 最小节点并被取出时,才判定搜索完成。这与源码中的逻辑一致:

pop from Open → isGoal → setPath \text{pop from Open} \rightarrow \text{isGoal} \rightarrow \text{setPath} pop from OpenisGoalsetPath

搜索结束后,靠 parent 回溯路径。根据上表,目标的父链是:

G ← ( 4 , 3 ) ← ( 3 , 3 ) ← ( 2 , 3 ) ← ( 1 , 3 ) ← ( 1 , 2 ) ← ( 1 , 1 ) ← ( 1 , 0 ) ← S G \leftarrow (4,3) \leftarrow (3,3) \leftarrow (2,3) \leftarrow (1,3) \leftarrow (1,2) \leftarrow (1,1) \leftarrow (1,0) \leftarrow S G(4,3)(3,3)(2,3)(1,3)(1,2)(1,1)(1,0)S

把它反过来,就得到完整路径:

S → ( 1 , 0 ) → ( 1 , 1 ) → ( 1 , 2 ) → ( 1 , 3 ) → ( 2 , 3 ) → ( 3 , 3 ) → ( 4 , 3 ) → G S \rightarrow (1,0) \rightarrow (1,1) \rightarrow (1,2) \rightarrow (1,3) \rightarrow (2,3) \rightarrow (3,3) \rightarrow (4,3) \rightarrow G S(1,0)(1,1)(1,2)(1,3)(2,3)(3,3)(4,3)G

这就是从起点到终点的完整 A* 案例。你可以看到,A* 的每一步都只有三件事:

  1. 从 Open 里取出当前最值得扩展的节点。
  2. 检查它的邻居,过滤障碍物和无效节点。
  3. 对有效邻居更新 g g g f f f、parent,再放回 Open。

这就是 A* 的核心循环:

  1. 把起点放入 Open 集合,令 g ( s ) = 0 g(s)=0 g(s)=0
  2. 从 Open 集合里取出 f f f 最小的节点 n n n
  3. 如果 n n n 是目标,结束搜索。
  4. n n n 标记为 Closed,表示它已经扩展过。
  5. 枚举 n n n 的所有后继 n ′ n' n
  6. 对每个 n ′ n' n 做越界、障碍物、碰撞等检查。
  7. g new ( n ′ ) = g ( n ) + c ( n , n ′ ) g_{\text{new}}(n')=g(n)+c(n,n') gnew(n)=g(n)+c(n,n) 判断是否找到更好的到达方式。
  8. 如果更好,就更新 g g g f f f 和父节点,并把 n ′ n' n 放回 Open 集合。
  9. 如果 Open 集合为空还没找到目标,则搜索失败。

再用一个更小的选择例子,看 g g g h h h f f f 如何影响扩展顺序。假设 Open 里有两个候选节点 A A A B B B

g ( A ) = 2 , h ( A ) = 8 g(A)=2,\quad h(A)=8 g(A)=2,h(A)=8

g ( B ) = 5 , h ( B ) = 2 g(B)=5,\quad h(B)=2 g(B)=5,h(B)=2

则:

f ( A ) = g ( A ) + h ( A ) = 10 f(A)=g(A)+h(A)=10 f(A)=g(A)+h(A)=10

f ( B ) = g ( B ) + h ( B ) = 7 f(B)=g(B)+h(B)=7 f(B)=g(B)+h(B)=7

虽然 B B B 已经走过的路更贵,因为 g ( B ) > g ( A ) g(B)>g(A) g(B)>g(A),但它离目标估计更近,所以总估计代价更小:

f ( B ) < f ( A ) f(B)<f(A) f(B)<f(A)

A* 会优先扩展 B B B。这说明 A* 不是简单贪图“眼前便宜”,也不是只看“离目标近”,而是在两者之间做平衡。

如果令:

h ( n ) = 0 h(n)=0 h(n)=0

A* 就退化成 Dijkstra:

f ( n ) = g ( n ) f(n)=g(n) f(n)=g(n)

如果让 h ( n ) h(n) h(n) 占主导,搜索会更接近贪心。Hybrid A* 中的启发权重 w h w_h wh 就是在调节这种平衡:

f ( n ) = g ( n ) + w h h ( n ) f(n)=g(n)+w_h h(n) f(n)=g(n)+whh(n)

w h w_h wh 越大,搜索越愿意朝目标方向冲; w h w_h wh 越小,搜索越像均匀探索,通常更稳但更慢。

现在把这个二维小案例映射回 Hybrid A*。

普通 A* 里,后继节点来自相邻格子:

( x , y ) → ( x + 1 , y ) (x,y)\rightarrow(x+1,y) (x,y)(x+1,y)

( x , y ) → ( x , y + 1 ) (x,y)\rightarrow(x,y+1) (x,y)(x,y+1)

Hybrid A* 里,后继节点来自车辆动作。当前车辆位姿为:

q = ( x , y , θ ) q=(x,y,\theta) q=(x,y,θ)

动作由两个量组成:

a = ( δ u , d ) a=(\delta_u,d) a=(δu,d)

其中 δ u \delta_u δu 是离散转角, d d d 是带符号行驶距离。运动模型给出:

q ′ = F ( q , δ u , d ) q'=F(q,\delta_u,d) q=F(q,δu,d)

所以 Hybrid A* 的“邻居”不是旁边那个格子,而是:

车辆按某个转角前进或倒车一小段后到达的新位姿 \text{车辆按某个转角前进或倒车一小段后到达的新位姿} 车辆按某个转角前进或倒车一小段后到达的新位姿

一次扩展可以理解为下面这条链:

取出当前节点 → 选择前进或倒车 → 选择一个转角 → 用自行车模型积分出新位姿 → 把新位姿离散成  ( i , j , k ) → 检查碰撞 → 计算  g , h , f → 更新 Open 集合 \text{取出当前节点} \rightarrow \text{选择前进或倒车} \rightarrow \text{选择一个转角} \rightarrow \text{用自行车模型积分出新位姿} \rightarrow \text{把新位姿离散成 }(i,j,k) \rightarrow \text{检查碰撞} \rightarrow \text{计算 }g,h,f \rightarrow \text{更新 Open 集合} 取出当前节点选择前进或倒车选择一个转角用自行车模型积分出新位姿把新位姿离散成 (i,j,k)检查碰撞计算 g,h,f更新 Open 集合

然后将 q ′ q' q 离散化:

index ⁡ ( q ′ ) = ( i ′ , j ′ , k ′ ) \operatorname{index}(q')=(i',j',k') index(q)=(i,j,k)

如果这个索引越界、落在障碍物上,或者完整车身矩形发生碰撞,就丢弃该候选:

invalid ⁡ ( q ′ ) = 1 ⇒ discard \operatorname{invalid}(q')=1 \Rightarrow \text{discard} invalid(q)=1discard

否则继续计算:

g ( q ′ ) = g ( q ) + C ( q , a , q ′ ) g(q')=g(q)+C(q,a,q') g(q)=g(q)+C(q,a,q)

f ( q ′ ) = g ( q ′ ) + h ( q ′ ) f(q')=g(q')+h(q') f(q)=g(q)+h(q)

这里的 C ( q , a , q ′ ) C(q,a,q') C(q,a,q) 不只是距离,还包含转弯、倒车、换挡、障碍物距离和目标对齐等代价。于是 Hybrid A* 的 A* 框架没变,变化的是节点生成方式和边代价定义。

最后再看 Open、Closed、parent 在 Hybrid A* 里的具体含义:

Open : 已经生成,但还没展开车辆动作的位姿节点 \text{Open}: \text{已经生成,但还没展开车辆动作的位姿节点} Open:已经生成,但还没展开车辆动作的位姿节点

Closed : 已经展开过车辆动作的位姿节点 \text{Closed}: \text{已经展开过车辆动作的位姿节点} Closed:已经展开过车辆动作的位姿节点

parent ⁡ ( q ′ ) : 哪一个旧位姿通过哪次车辆动作到达了  q ′ \operatorname{parent}(q'): \text{哪一个旧位姿通过哪次车辆动作到达了 }q' parent(q):哪一个旧位姿通过哪次车辆动作到达了 q

还有一个容易忽略的小细节:当车辆刚从前进切到倒车,或刚从倒车切到前进时,搜索会避免立刻沿着同一个转角原路退回父节点。否则搜索会浪费大量节点在“刚走出去又走回来”的局部循环上。用直觉说,就是不要把第一步扩展用来撤销上一步动作。

后续内容:从自由空间脱困到回归车道:Hybrid A* Freespace Pull Out 算法教程(2)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐