Reeds-Shepp曲线这个系列写到第三部分了,前两部分把运动学模型、路径分类以及公式推导的思路基本过完了,这篇我集中说代码实现。公式推导是一回事,代码能跑是另一回事,中间隔着的坑比想象中多。这篇我不会再花大篇幅去重推公式,而是把重点放在“公式如何映射成函数”“枚举路径类型时如何保证不错不漏”“拼接出来的路径如何做误差评估”这几个工程关键点上,顺便把我在实际项目中踩过的坑都标出来。适合正在做自动驾驶路径规划、AGV调度、机器人导航的朋友参考,哪怕你之前只听过Reeds-Shepp这个名字,只要有一点点运动规划基础,也能跟着这篇文章把核心代码搭起来。
1. 系列第三部分的定位:从公式到可运行代码
1.1 前两部分的结论快速回顾
Reeds-Shepp曲线和前几篇讨论的Dubins曲线最大的区别,就是车辆允许倒车。所以最短路径不再局限于“弧线-直线-弧线”这种单调结构,而是会出现形如L+R+L、L+S-R、C|C|C这样带有转向反转和掉头操作的组合。前两部分推导出来的核心结论可以浓缩成三句话:
- 对满足最小转弯半径约束的车辆,任意位姿之间的最短路径一定可以表示成有限段“左转弧(L)、右转弧(R)、直线(S)”的拼接。
- 这些拼接模式按照圆弧方向组合,可以归纳成若干基础族,再根据符号展开成具体的48种路径类型。
- 每一种路径类型背后都对应一组基于几何关系的解析公式,给定起点、终点、车头朝向和转弯半径,可以直接算出每一段弧的圆心、切点、角度和长度,不需要数值优化求解。
第三部分要做的就是把这48种类型的求解过程写成代码,然后通过遍历所有路径类型、拼接路径、比较总长度,最终选出最短的那条。
1.2 为什么“暴力枚举所有路径类型”反而是最靠谱的做法
很多人在实现Reeds-Shepp曲线时,容易陷入一个误区:试图通过某种统一的数值优化方法,直接解出最短路径。从理论上讲这不是不行,但工程上非常不推荐。原因在于Reeds-Shepp问题的几何结构已经研究得非常透彻,最优解的路径类型只会落在有限集合里,直接用解析公式枚举不仅速度快,而且数值稳定性高,行为也可预测。
我见过一些代码尝试用样条插值加迭代优化来逼近最短路径,最终结果不仅慢,还经常收敛到局部最优,或者在某些极限位姿下出现振荡。相比之下,枚举48种路径类型的方式,每一次求解都是纯粹的几何计算,最坏情况下也就几十次函数调用,现代CPU上跑一次耗时在微秒级。这个效率对于实时规划器完全够用,而且每一个中间结果都可以可视化,调试时能看到到底是哪条候选路径被选中,为什么被选中,逻辑透明得多。
从这个角度说,标题里的“暴力枚举+推导公式+数学构造”这个组合其实很精准:枚举是框架,公式是核心,数学构造保证了每种路径类型都能被精确求解,而不是靠猜测。
2. 整体架构设计:从位姿输入到路径输出
2.1 核心数据结构:路径段、路点、候选结果
写代码之前,先把数据结构定清楚。Reeds-Shepp的整条路径本质上是由若干“圆弧段”和“直线段”拼接而成,所以我定义了一个Segment结构来描述单段路径:
class Segment: def __init__(self, seg_type, center, start_theta, end_theta, length): self.seg_type = seg_type # 'L', 'R', 'S' self.center = center # 圆弧圆心,直线段为 None self.start_theta = start_theta # 圆弧起始角度 self.end_theta = end_theta # 圆弧终止角度 self.length = length # 该段长度seg_type用来标记这一段是左转弧、右转弧还是直线;center存储圆心坐标,直线段不需要圆心;start_theta和end_theta是圆弧在圆上的起始极角与终止极角,用于后续插值;length记录该段的实际弧长或直线长度。
在Segment之上,我定义了一个RSPath结构,用来保存完整的候选路径:
class RSPath: def __init__(self, segments, total_length): self.segments = segments self.total_length = total_lengthsegments是一个Segment列表,顺序就是车辆实际行驶的顺序;total_length是所有段长度之和,最终选路径时主要比较这个值。
有了这两个结构,后续所有构造函数的输出都能统一,主流程只需要处理RSPath对象,不需要关心具体是CSC类型还是CCC类型。
2.2 坐标变换:把任意起终点统一到标准位形
这是整个代码实现里最值得先做的一步。Reeds-Shepp的公式推导,包括我前两篇写的那些计算过程,几乎都是建立在“起点在原点、车头朝向x轴正方向”这个标准位形下的。如果每次按任意起终点直接套公式,代码里会多出一堆正负号分支,非常容易出错。
我的做法是在主函数最前面做一次刚体变换:把起点平移到原点,再把整个坐标系旋转到起点朝向为x轴正方向。对应的代码很简单:
def normalize_pose(start, goal): x0, y0, yaw0 = start x1, y1, yaw1 = goal dx = x1 - x0 dy = y1 - y0 cos_yaw = math.cos(-yaw0) sin_yaw = math.sin(-yaw0) xf = dx * cos_yaw - dy * sin_yaw yf = dx * sin_yaw + dy * cos_yaw yawf = normalize_angle(yaw1 - yaw0) return (0.0, 0.0, 0.0), (xf, yf, yawf)这个变换本身是“保距”的,也就是平移旋转不会改变两点之间的实际距离,所以变换后再算出的路径长度和直接在原坐标系下算完全一致。求出路径后,如果需要把路点坐标映射回原来的世界坐标系,只需要做一次逆变换,也就是先反向旋转再平移。
需要注意normalize_angle这个函数。写角度相关的代码时,角度归一化是命门,稍后会专门说。这里先给出一个最简单可靠的版本:
def normalize_angle(angle): while angle > math.pi: angle -= 2.0 * math.pi while angle < -math.pi: angle += 2.0 * math.pi return angle2.3 主流程:枚举、构造、评估、筛选
整体主流程非常直白,写成伪代码就是:
def reeds_shepp_path(start, goal, rho): start_norm, goal_norm = normalize_pose(start, goal) xf, yf, yawf = goal_norm best_path = None best_err = float('inf') for path_type in enumerate_all_types(): candidate = construct_path(path_type, xf, yf, yawf, rho) if candidate is None: continue err = endpoint_error(candidate, xf, yf, yawf) if err < 1e-3 and candidate.total_length < best_path.total_length: best_path = candidate return transform_back(best_path)enumerate_all_types负责产生所有需要尝试的路径类型标识符,construct_path根据类型调用对应的构型构造器,endpoint_error检查路径终点是否和实际目标位姿一致。这个流程看起来简单,但真正常被忽略的细节是:不是所有路径类型在任意起终点下都有合法解。比如某些类型要求起点和终点的位置距离必须落在某个范围,否则切点不存在。所以每个构造函数的第一步都是做几何可行性检查,不合法就返回None,由主流程继续尝试下一种类型。
还有一个容易被忽略的点:枚举顺序。虽然最终我们会比较总长度取最小值,但如果把一些特定类型放在前面,可能在正反两种枚举顺序下得到同样的类型组合但比较方向相反。更安全的做法是同一类型下把所有“符号选择”都试一遍,这个后面讲CCC类型时具体展开。
3. 两大核心公式的代码化:CSC与CCC
3.1 圆弧段和直线段的公共基元
在写具体路径类型之前,先把两个基础函数准备好:一个是直线段,一个是圆弧段。
直线段最简单,输入两个点,直接返回距离:
def straight_length(p1, p2): return math.hypot(p2[0] - p1[0], p2[1] - p1[1])圆弧段稍复杂一点。给定圆心center、半径rho、起始极角theta_start、终止极角theta_end以及转向方向direction(1表示左转,-1表示右转),我需要计算这一段弧的实际弧长,并在插值时按方向生成路径点。弧长的计算不能直接用abs(theta_end - theta_start),因为角度可能有超过π的情况,而且一旦涉及多圈缠绕,必须做带上符号的归一化。
我在实践里用的方法是把“有符号角度增量”先算出来,再取绝对值作为弧长:
def arc_angle_and_length(center, radius, start_point, end_point, direction): start_theta = math.atan2(start_point[1] - center[1], start_point[0] - center[0]) end_theta = math.atan2(end_point[1] - center[1], end_point[0] - center[0]) if direction > 0: delta = normalize_angle(end_theta - start_theta) if delta <= 0: delta += 2.0 * math.pi else: delta = normalize_angle(start_theta - end_theta) if delta <= 0: delta += 2.0 * math.pi return delta * radius这个函数的核心思想是:左转时,车辆沿逆时针方向从起点绕到终点,所以角度差必须归一化到(0, 2π);右转时反过来,从终点绕到起点。这一步看似简单,但很多初版代码跑出来的路径会绕大圈,通常就是这里没有处理“角度负值加2π”的逻辑。
插值时也是基于这个逻辑,在起点和终点极角之间线性插值,再根据方向决定实际角度序列。
3.2 CSC类路径的切点公式与实现
CSC是“弧-直线-弧”结构的统称,比如L+S+R、L+S-L、R+S+L、R+S-R。这类路径的关键是求两段圆弧之间的公切线切点。
在标准位形下,起点的第一段圆弧圆心可以直接写出来。左转时圆心在起点左侧,右转时圆心在起点右侧:
o1x = 0.0 o1y = direction1 * rho终点的第二段圆弧圆心稍微复杂一点。假设终点位姿是(xf, yf, yawf),那么终点左转圆心的位置是:
o3x = xf - direction3 * rho * math.sin(yawf) o3y = yf + direction3 * rho * math.cos(yawf)direction3是终点处第二段弧的转向,1为左转,-1为右转。这个公式怎么理解呢?圆弧圆心始终位于车辆当前朝向的侧向偏移处。车辆朝向的单位方向是(cos(yawf), sin(yawf)),左转时圆心在左侧,左侧法向量是(-sin(yawf), cos(yawf));右转时圆心在右侧,右侧法向量是(sin(yawf), -cos(yawf))。乘上半径rho,就得到上面的表达式。
有了两个圆心,接下来就是求切点。同向转向(L+L或R+R)取外公切线,异向转向(L+R或R+L)取内公切线。等半径情况下,外公切线的两个切点可以这样求:
vx = o3x - o1x vy = o3y - o1y d = math.hypot(vx, vy) if d < 2.0 * rho: return None # 两圆相交甚至重叠,无公切线 nx = -vy / d # 垂直于连心线方向的单位向量 ny = vx / d t1x = o1x + rho * nx t1y = o1y + rho * ny t2x = o3x + rho * nx t2y = o3y + rho * ny这里的n = (-vy/d, vx/d)是连心线逆时针旋转90度得到的单位向量。用正负号分别对应两条外公切线,实际应该选哪条,取决于第一段圆弧起始角度到切点的旋转方向是否和车辆转向一致。我的经验是两条都试,构造出完整RSPath后再检查终点误差,把不合法的那条丢弃。
内公切线的公式稍麻烦一点。设连心线方向角为phi = atan2(vy, vx),等半径情况下可以推导出切点相对圆心的方向角是phi ± α,其中α = asin(2*rho/d)。两个切点分别位于两圆“对侧”,具体公式在代码里实现为:
alpha = math.asin(2.0 * rho / d) base = math.atan2(vy, vx) theta_t1 = base + alpha # 或者 base - alpha,取决于方向组合 theta_t2 = base - alpha t1x = o1x + rho * math.cos(theta_t1) t1y = o1y + rho * math.sin(theta_t1) t2x = o3x + rho * math.cos(theta_t2) t2y = o3y + rho * math.sin(theta_t2)内公切线符号选择是这类路径最容易写错的地方。我建议不要试图记住一个固定正负号,而是在代码里把base + alpha和base - alpha两种组合都生成,然后通过“第一段弧从起点到切点、第二段弧从切点到终点”的连续性验证来判断取舍。这样做虽然多了一点计算量,但代码逻辑清晰,不容易因为方向组合遗漏而漏掉合法路径。
3.3 CCC类路径的两圆交点公式与实现
CCC类路径是三段圆弧连续拼接,比如L+R+L、L+R-L等。和CSC不同,CCC里没有直线段,所以核心问题变成:给定第一段圆弧圆心、第三段圆弧圆心,如何确定中间那一段圆弧的圆心位置。
在标准位形下,第一段圆弧圆心O1和第三段圆弧圆心O3可以直接由起点和终点位姿算出来。中间圆弧的圆心O2必须满足两个条件:它到O1的距离为2*rho,到O3的距离也为2*rho。为什么是2*rho?因为两段反向圆弧在切点处内切,圆心距等于两段圆弧半径之和,即rho + rho。
于是问题就变成了求两个半径同为2*rho的圆的交点。代码如下:
D = math.hypot(o3x - o1x, o3y - o1y) if D > 4.0 * rho: return None # 两圆无交点,不存在标准CCC解 mid_x = (o1x + o3x) / 2.0 mid_y = (o1y + o3y) / 2.0 h = math.sqrt(max(0.0, (2.0 * rho) ** 2 - (D / 2.0) ** 2)) o2x1 = mid_x - h * (o3y - o1y) / D o2y1 = mid_y + h * (o3x - o1x) / D o2x2 = mid_x + h * (o3y - o1y) / D o2y2 = mid_y - h * (o3x - o1x) / D两个交点分别代表两种不同的绕行方式,代码里必须都尝试。得到O2后,三段圆弧的圆心、半径、起止极角都能确定。特别注意,第一段圆弧的起点是车辆起点,它在O1圆上的极角是固定的;第一段圆弧的终点是切点,它在O1圆上的方向角就是O1指向O2的方向角。因此第一段弧的转角可以算出来,第二段弧的转角则由O2指向O1和O2指向O3两个方向的夹角决定,第三段弧的转角则从O3指向O2的方向角转到终点在O3圆上的方向角。
这个过程中最容易出问题的,是三个角度之间的衔接不是简单的“取绝对差”,而必须考虑旋转方向。比如L+R+L的第一段是左转,那么从起点位置在圆上的极角转到O1->O2方向极角,必须逆时针绕行,所以角度增量要用“目标角减起始角,再取模”。
我在实际代码里会把每个弧段封装成一个函数,输入圆心、起点、终点、方向,输出弧长和关键角度。这样不管三段怎么组合,代码都不会出现混乱的正负号分支。
3.4 带拐点构型的处理思路
真实场景中,大概有90%以上的起终点位姿可以用CSC或标准的CCC类路径覆盖,剩下那些比较刁钻的组合,对应的是带拐点(也就是中间某处车速方向反转)的类型,比如C|C|C、CC|C等。
带拐点构型的公式推导在前两部分已经讲过,代码实现上不需要另起炉灶。我的做法是复用前面写的两圆交点求解函数,只是在构造某些弧段时,把“连续绕行”改成“先绕行一段,然后在Alphabetic类型里标记掉的掉头点位置改变曲率方向”。具体到代码,通常是给construct_path传入一个word字符串,比如"L+R-L",然后用一个循环解析这个字符串,遇到|就切换速度方向,遇到字母就调用对应的圆弧或直线基元。
这里想分享一个实用经验:与其手写48个构造函数,不如把8到9个基础构型风格写成一个通用模板,用参数控制每一段的转向、长度以及是否存在中点掉头。比如CCC类可以写成一个construct_three_arc(o1, o3, direction1, direction2, direction3, flip)函数,flip控制中间掉头变体。这样既保留了“每种路径都由公式精确构造”的优点,又避免了代码大量重复。
4. 路径插值、误差评估与最优路径筛选
4.1 从路径段到密集路点
公式构造出来的路径只是一堆符号描述,真正要交给控制器或可视化模块的,是离散化后的路点序列。插值逻辑也不复杂:对每个Segment,根据它的类型和长度,以固定步长生成中间点。
圆弧段插值时要特别注意,每一步的角度不能直接用线性插值start + t * (end - start),因为这样在跨过±π边界时会跳变。正确做法是先算好带方向的增量(左转正、右转负),然后:
theta = start_theta + direction * step_dist / rho x = center[0] + rho * math.cos(theta) y = center[1] + rho * math.sin(theta)这样算出来的点每间隔固定弧长一个,控制器使用起来非常方便。
直线段插值就简单了,直接按比例在首尾两个切点之间均匀取点即可。
4.2 终点误差检查与阈值设置
暴力枚举路径类型时,一定会出现一些几何上“看似合法”但实际拼接后整体误差很大的情况。例如CSC切点选错了方向,生成的第一段弧或第三段弧虽然角度计算正确,但拼接出来的路径终点位置和实际目标差了一截。所以主流程里必须有终点误差检查。
我的实现是,构造完候选路径后,顺着路径的每一段做一次正向递推,从起点开始累加位置和朝向,算到路径终点,再和目标(xf, yf, yawf)比较:
def endpoint_error(path, xf, yf, yawf): # 对path按段逐步积分,得到终点位姿 ex = end_x - xf ey = end_y - yf e_yaw = normalize_angle(end_yaw - yawf) return math.hypot(ex, ey) + abs(e_yaw) * 0.1位置误差和角度误差的加权系数我取0.1,主要是为了让角度误差和位置误差在同一个量级比较。实际使用中,只要位置误差小于1e-3米、角度误差小于1e-3弧度,就可以认为这条路径是合法的。如果候选路径连这个基本检查都过不了,直接丢弃,不需要参与后续长度比较。
4.3 为什么“最近”不一定等于“最短”
筛选最优路径时,最直接的想法是比较total_length,取最小。但在实际工程里,我建议把误差检查放在长度比较之前,否则可能出现某条路径理论长度很短,但终点误差为零点几米的情况,这种路径是不能用的。
另外还要注意,有些路径类型会在枚举结果里出现“重复等价”的情况。比如在某些特殊位姿下,L+S+R和R+S+L可能生成完全相同的路径,只是绕行方向不同,长度也相同。这种情况下保留哪一条都行,但如果你在代码里加了“只保留第一条满足误差的路径”的剪枝逻辑,就有可能丢掉真正的最短路径。我的习惯是:绝不提前剪枝,让所有类型都构造完、评估完,最后统一比较。
5. 实测排坑与工程细节
5.1 角度归一化的坑
这个坑几乎每个人都会踩。atan2返回的角度范围在-π到π之间,但你计算弧长时需要的角度范围可能是0到2π,也可能是任意带符号的角度。如果不做归一化,就会出现本该是“转5度”的地方算出来是“转355度”的情况,导致路径绕超大一圈。
我的统一约定是:所有涉及“两点之间的方位角”的地方,全部先经过normalize_angle处理;所有涉及“旋转增量”的地方,按照转向方向做0到2π的正向模运算。这个约定写进编码规范后,角度相关的bug至少减少八成。
5.2 切点方向选错的坑
CSC类型的两条公切线都合法,但只有一条能构成正确的行驶方向。我见过很多实现把公切线公式固定死,结果在某些顶点的yaw组合下,结果路径在切点附近出现车体朝向不连续。
排查这类问题最快的方法是可视化。把两段圆弧的圆心、起点、终点全部画出来,你一眼就能看出切点方向是不是反了。如果没有可视化工具,可以在代码里打印每个切点在圆上的极角以及第一段弧的起点极角,手动检查角度增量是否符合转向方向。
5.3 最小转弯半径与4rho边界检查
CCC类型求解时,O1和O3之间的距离如果大于4*rho,两圆就没有交点,标准CCC解不存在。但实际车辆位姿完全可能出现D略大于4*rho的情况,这时如果草率跳过CCC,只靠CSC可能找不到最优解,甚至找不到可行解。
我的建议是:在D > 4*rho时不要直接return None,可以尝试带拐点的CCC变体,或者把半径稍微放宽一点点重新解一次。当然放宽半径意味着车辆实际行驶轨迹可能超过最小转弯半径约束,所以这个放宽只能作为“候选路径”参与比较,最终输出前要额外校验每段弧的曲率不超过车辆极限。
5.4 可视化调试的小技巧
最后分享一个非常实用的调试技巧。Reeds-Shepp曲线这种几何规划算法,最适合用可视化来验证。我在开发阶段写了一个很轻量的matplotlib脚本,输入起点、终点、转弯半径,把所有候选路径全部画出来,包括每段弧的圆心、切点、圆弧轨迹,然后用不同颜色区分路径类型。调试效率提升非常大。
一旦发现某个起终点组合下所有候选路径都不合法,就把这个组合固定成单元测试用例,每次改完代码都跑一遍。这个做法帮我抓出了很多只在特殊位姿下才出现的边界bug,也是我推荐每个人都养成的习惯。
Reeds-Shepp曲线这块,公式推导是基础,但真正让算法稳定的,是那些藏在角度处理、切点方向、边界检查里的细节。这些坑我基本都在项目里踩过一遍,希望这篇第三部分能帮你少走些弯路。