自动驾驶路径规划算法C++源码深度解析:从A*到Hybrid A*
2026/9/13 6:56:05 网站建设 项目流程

简介:基于C++实现的自动驾驶常用路径规划算法源码包,来源于实际项目,内含A*、Dijkstra、RRT、RRT_Connect、RRT_Star、Bezier、B-Spline等经典与进阶算法,每个模块均配有可编译运行的demo及代码注释,适用于计算机、人工智能、数据科学等相关专业的课程设计、毕业设计、课程大作业或初期项目演示。资源包共36个文件,包含14个cpp源码、8个h头文件、12个gif演示动图,以及使用说明md和txt文档,整个压缩包仅18.67MB,目录按算法模块划分,便于检索和针对性学习。目前已有327人学习下载,代码均经过功能验证,稳定可靠,可直接运行体验。gif动图清晰展示了各算法在路径搜索中的实时效果,配合注释能直观理解从图搜索到随机采样等不同规划思路,适合作为入门进阶或二次开发的基础。

1. 基于 C++ 的自动驾驶路径规划算法源码包:拆包后的第一眼

拿到一份基于 C++ 的自动驾驶常用路径规划算法源码包,第一件事不是急着建工程、点编译,而是先打开使用说明,把算法模块和道路结构的对应关系梳理清楚。路径规划处在自动驾驶软件栈的中间层,上游输入栅格地图、障碍物列表和自车位姿,下游输出一条可被横纵向控制器跟踪的点序列。这个环节做得不扎实,感知再准、底盘执行再快,车照样会在原地打转。

常见实现选择 C++ 写核心规划器,原因很直接:规划循环频率通常要求 10Hz 到 50Hz,一次规划要在几十毫秒内完成碰撞查询和路径更新。Python 原型能验证思路,但实车条件下内存拷贝和解释器开销很快就会成为瓶颈。源码包里能看到的常用算法,一般绕不开 Dijkstra、A*、RRT*、DWA,以及带车辆运动学约束的 Hybrid A* 或 Lattice 规划器,各有各的适用场景。

这套代码对两类读者最有价值:一类是刚转自动驾驶规划的工程师,想脱离公式看真实工程怎么组织;另一类是已经跑过单个算法 Demo、但没见过项目级代码的同学。接下来按算法选型、源码实现、编译调优、场景验证的顺序,把这类包里的关键结构、参数和踩坑点逐一展开。

2. 路径规划算法选型:A*、RRT*、Hybrid A* 与 DWA 的分层取舍

在实车项目里,没有哪个路径规划算法能包打全场。源码包通常会按功能拆成全局路径规划和局部路径规划两层:全局层负责在高精地图或栅格地图上找到从起点到终点的粗略走廊,局部层再根据当前感知结果,在走廊内生成符合车辆运动学约束的平滑轨迹。选错算法的问题不会立刻暴露——低速园区里怎么跑都行,一进车流密集的十字路口,规划耗时、轨迹平滑度、避障反应速度的短板会被同时放大。

2.1 分层决策:全局规划与局部规划怎么配合

全局规划回答“走哪条路”,输入是高精地图 lanelet、栅格地图或从语义地图抽取的可行驶区域,输出是带方向属性的途经点序列。局部规划回答“当前这一秒怎么走”,输入是感知融合后的障碍物列表、预测轨迹和定位位姿,输出是每隔几十毫秒刷新一次的带时间戳轨迹点。源码里这两层一般不会揉进同一个类,常见做法是定义 Planner 抽象基类,把全局路径和局部轨迹作为两个独立数据类型,局部规划器每次开始前从全局路径中截取当前位置之后的一段作为参考线,参考线长度通常设定在 20 到 50 米。

看这类规划源码时,我习惯先找两层之间的数据接口,而不是先看某个算法的内部细节。如果接口上直接传vector<common::Pose>,说明团队对规划结果的表达还停留在直线段拼接阶段;如果传的是带横向偏差和曲率约束的 FrenetFrame 数据,工程化程度就明显更高,下游控制模块可以少做两次坐标变换,也更容易处理换道和弯道减速。

2.2 网格搜索:A* 与 Dijkstra 的代价函数设计

A* 在网格地图上做启发式搜索,代价函数 f = g + h 中,g 是起点到当前节点的实际代价,h 是对剩余距离的估计。Dijkstra 是它的特例,把启发式权重设为零时自然退化。源码编写上的差别只在于是否在节点扩展循环里计算 h,但工程上两者完全可以用同一套框架,运行前用一个开关切换。

启发式函数的选择要跟邻居扩展方式匹配:四方向扩展配曼哈顿距离,八方向扩展配切比雪夫距离;如果地图按真实尺度建立、对角线方向的移动代价等于 √2 倍直线代价,就要配欧氏距离。更常见的情况是代价地图带不同权重,比如车道区域 cost 低、路肩 cost 高,此时 h 可以适度放大,乘 1.05 到 1.2 左右,能明显减少扩展节点数,代价是路径不再保证全局最优。源码里如果看到启发式权重系数被往上调得很大,基本可以判断是性能调优时留下的临时改动,接手后要注意还原。

2.3 RRT* 与 Hybrid A*:采样搜索下的车辆运动学约束

RRT* 的核心差异在于重连步骤。基础 RRT 贪心地向随机采样点生长,找到第一条可行路径就返回;RRT* 在新节点加入后,会检查周围近邻半径内的已有节点,如果经由新节点到达这些节点的代价更小,就改写它们的父指针,让路径代价随迭代次数增加逐步逼近最优。工程实现中重点在近邻半径的计算,常用公式是 r = γ * sqrt(log(n) / n),其中 γ 与地图维度相关,n 是当前节点数。

但 RRT* 生成的是状态空间里的几何路径,不保证车辆能沿路径转向。Hybrid A* 把节点的后继扩展限定为一组离散的前轮转角组合,用圆弧积分推出车辆新位姿,因此每个新节点天然满足最小转弯半径约束。实际泊车场景里,我一般把 Reeds-Shepp 曲线作为搜索接近目标时的终止条件:当某个节点与目标位姿误差小于 0.3 米和 10 度时,直接用短曲线补足最后一段,避免采样在狭小空间里无限膨胀。泊车路径规划算法里 Hybrid A* 出现频率很高,就是因为低速场景对终点姿态要求严格,靠纯栅格搜索很难在合理时间内收敛。

2.4 DWA 与 Lattice:局部实时避障的两条技术路线

DWA(动态窗口法)在速度空间 (v, ω) 内做采样,先根据当前速度和加速度限制生成一个动态窗口,窗口内每个 (v, ω) 对应用一条预测圆弧,再按障碍物距离、目标朝向、速度大小加权评分,选最高分对应的速度去执行。评价权重一般写成 cost_params 结构体暴露在配置文件中,便于路试时改。这里给一组可用的初值:障碍物距离权重 0.25、朝向权重 0.2、速度权重 0.1,后续根据路径连续性和绕障半径去调。如果项目跑在 ROS 系框架里,局部规划器还可以换装 TEB 或 SMAC,但评估函数和碰撞检查的结构是一样的,换算法不换骨架。

Lattice 规划器把轨迹按纵向位移和横向偏移采样,为每个采样点求解五次多项式或一组满足终端约束的曲线族,再统一做碰撞检查和代价评估。它是 Frenet 坐标系的典型应用,横向和纵向维度可以独立设置速度、加速度约束,车道保持和换道可以共用一套逻辑,只是采样范围不同。缺点是实现量大,一次规划的候选轨迹经常在 500 到 2000 条之间,C++ 代码里计算曲线系数的部分要靠预计算表或查表法加速。

算法搜索空间车辆运动学约束实时性典型用途
A* / Dijkstra栅格不支持全局寻路
RRT*连续状态空间不支持越野与结构化程度低的场景
Hybrid A*连续状态加控制量支持较差泊车、园区低速
DWA速度空间隐含在窗口内局部实时避障
Lattice轨迹集合支持高速换道、弯道规划

读源码时发现同一个算法出现多份实现,不用觉得重复。很多项目是不同场景各自演进的结果,保留旧版本就是为了回归对比,删代码前先看看 config 里有没有对应的开关。

3. C++ 源码核心拆解:GridMap、碰撞检测与规划循环实现

路径规划源码包的目录结构有很强的一致性。压缩包里通常会先看到 include、src、config、data、tools 这几个目录,认清目录再动手,比逐行读代码快得多。这一章按实际项目最常见的组织方式拆开讲,方便你拿到新包后对照着看。

3.1 目录与类依赖:实际项目怎么组织源码

include 下放所有公共头文件,按模块拆成 grid_map.h、collision_checker.h、planner_base.h、astar_planner.h、rrt_star_planner.h、dwa_planner.h 等。src 下每个头文件对应一个 .cpp,config 放 yaml 或 json 参数文件,data 放测试地图和录制好的场景数据,tools 里一般是可视化脚本或日志转换程序。

编译依赖上,实际项目很少用纯标准库硬啃。常见依赖是 Eigen 做矩阵运算、yaml-cpp 读配置、OpenCV 做地图读取和可视化;如果目标平台没有图像库,会用自写的 PGM 解析器替代 OpenCV。这里有一个值得注意的工程细节:planner_base.h 里通常会定义两个纯虚接口,形如virtual bool plan(const PlanningRequest&, PlanningResponse&) = 0;virtual bool reset() = 0;。规划失败不会抛出异常,而是通过 response.status 传递错误码。看到这种设计,排查“路径规划失败”时就该先看错误码,而不是一头扎进算法内部打日志。

3.2 GridMap 与碰撞检测:两个最值得先读的类

网格地图实现上,惯用一维 vector 存储代价,而不是二维 vector。看下面这个头文件:

class GridMap { public: GridMap(int rows, int cols, float resolution) : rows_(rows), cols_(cols), resolution_(resolution), data_(rows * cols, 0.0f) {} bool inBounds(int x, int y) const { return x >= 0 && x < cols_ && y >= 0 && y < rows_; } float costAt(int x, int y) const { if (!inBounds(x, y)) { return kLethalCost; // 地图边界一律视为致命障碍 } return data_[y * cols_ + x]; // 行优先索引,一次乘加 } void setCost(int x, int y, float c) { if (inBounds(x, y)) { data_[y * cols_ + x] = c; } } bool isTraversable(int x, int y) const { return costAt(x, y) < lethal_threshold_; } void inflateObstacle(float radius_meter); int width() const { return cols_; } int height() const { return rows_; } private: int rows_, cols_; float resolution_; // 米/像素 float lethal_threshold_; std::vector<float> data_; };

data_ 用一维数组而不是 vector<vector >,原因是规划循环每秒要调用数万次 costAt,二维向量的每次访问都要两次指针跳转,而一维索引 y * cols_ + x 只做一次乘加;更重要的是整行拷贝、缓存遍历和后续做距离变换时,一维布局的 cache 命中率明显更好。

碰撞检测器围绕 GridMap 来做路径合法性判断。核心逻辑是沿线段步进采样,步长取栅格分辨率的一半:

class CollisionChecker { public: explicit CollisionChecker(const GridMap& map) : map_(map) {} bool isPathFree(const std::vector<Eigen::Vector2d>& path, double footprint_radius) const { for (size_t i = 1; i < path.size(); ++i) { if (!segmentFree(path[i - 1], path[i], footprint_radius)) { return false; } } return true; } private: bool segmentFree(const Eigen::Vector2d& a, const Eigen::Vector2d& b, double radius) const { double dist = (b - a).norm(); double step = map_.resolution() * 0.5; int n = static_cast<int>(std::ceil(dist / step)); for (int k = 0; k <= n; ++k) { double t = dist < 1e-6 ? 0.0 : double(k) / double(n); Eigen::Vector2d p = a + (b - a) * t; int px = static_cast<int>(p.x() / map_.resolution()); int py = static_cast<int>(p.y() / map_.resolution()); if (!map_.isTraversable(px, py)) { return false; } } return true; } };

footprint_radius 参数是车辆外接圆半径的简化表达。实际项目里会用多边形包围盒做更精确的检查,但核心仍然是“沿线段离散采样、逐点查询”。步长取分辨率的一半是个好习惯:太粗会漏检细杆和护栏间隙,太细则白白浪费 CPU。碰撞检查是规划器里的热点函数,后续想提升性能,优先优化这里而不是优化搜索循环。

3.3 核心规划循环:以 A* 的 Open 表实现为例

A* 的工程实现重点在 open 表的数据结构和过时节点处理。下面是一段常见的核心循环结构:

struct AStarNode { int x, y; float g; float f; int parent_idx; }; bool AStarPlanner::plan(const GridMap& map, const Eigen::Vector2d& start, const Eigen::Vector2d& goal, std::vector<Eigen::Vector2d>* path) { std::priority_queue<std::pair<float, int>, std::vector<std::pair<float, int>>, std::greater<>> open; std::vector<float> g_score(map.width() * map.height(), kInfty); std::vector<int> came_from(map.width() * map.height(), -1); int sid = toIndex(start); g_score[sid] = 0.0f; open.emplace(heuristic(start, goal), sid); int iter = 0; while (!open.empty() && iter++ < kMaxIterations) { auto [f, id] = open.top(); open.pop(); if (f > g_score[id] + 1e-6) continue; // 过时节点直接跳过 if (isGoal(id, goal)) { reconstructPath(id, came_from, path); return true; } for (const auto& nb : neighbors(id)) { float new_g = g_score[id] + moveCost(id, nb); if (new_g + 1e-6 < g_score[nb] && map.isTraversable(nb.x, nb.y)) { g_score[nb] = new_g; came_from[nb] = id; open.emplace(new_g + heuristic(nb, goal), nb); } } } return false; // 迭代上限耗尽,需要降级策略 }

priority_queue 配合过时节点判断,是实际项目中最常用的写法,复杂度对几百乘几百的栅格地图已经足够。如果地图扩大到 2000 x 2000 以上,会换用分桶队列进一步降低常数。kMaxIterations 是防止异常地图导致规划卡死的保险丝,这个上限必须在构造函数里和 yaml 配置对应起来。

值得多留意的不是 open 表本身,而是那些没写注释的分支:迭代上限耗尽后返回 false,任务调度层要做什么降级动作?目标点本身处于膨胀区内时,是报错还是退回最近可达点?这些隐藏状态往往决定了一个规划器在实车上的表现,也是源码包里“代码注释”最有价值的部分。读这类源码时,我会先把所有 return false 的路径标记出来,再对照使用说明看错误码定义,比逐行读懂整个算法更快。

3.4 使用说明与代码注释:工程交付的两个加分项

压缩包名里的“使用说明”和“代码注释”,在实际交付时是分开写的。使用说明至少要覆盖三件事:依赖环境怎么搭、哪些参数不能乱动、输出格式是什么。我见过不少源码包,README 写了详细的编译步骤,却漏了最关键的一句:地图坐标系原点是车体后轴中心还是图像左上角。坐标系不一致会让规划结果在仿真里看着正常,一上车就偏出车道。

代码注释方面,实用主义风格只注释“为什么”,不注释“是什么”。比如h *= 1.2; // 牺牲少量最优性换取更少的扩展节点是有价值的;int i = 0; // 计数器是纯噪音。接手团队代码时最怕看到一行“这里很关键”却不写关键在哪,这类注释不如删掉。理想情况是:算法核心处注释讲清楚约束条件,参数所在处注释讲清楚单位,降级分支处注释讲清楚触发前提。

4. 编译运行与参数调优:把规划器从 Demo 变成可交付

源码包里的代码注释做得再好,编译不过或者跑不出预期轨迹,价值都大打折扣。这一章讲依赖搭建、命令行启动和参数调优的顺序。重点不是背命令,而是理解每个参数在规划链路里的位置。

4.1 先装好 vscode 的 C++ 环境,再用 CMake 构建

在 vscode 里配置 C++/C++ 环境,实质是让编辑器能找到编译器和头文件路径。Ubuntu 下一般用 gcc-11 或 clang-14,Windows 下用 Visual Studio 2022 的 cl,macOS 下用 clang。配好后用 CMake 组织工程,一个最小可用的 CMakeLists.txt 大致如下:

cmake_minimum_required(VERSION 3.16) project(autopilot_planner LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Eigen3 REQUIRED) find_package(yaml-cpp REQUIRED) find_package(OpenCV QUIET COMPONENTS core imgcodecs highgui) add_executable(planner_main src/main.cpp src/grid_map.cpp src/collision_checker.cpp src/astar_planner.cpp src/rrt_star_planner.cpp src/dwa_planner.cpp ) target_include_directories(planner_main PRIVATE include) target_link_libraries(planner_main PRIVATE Eigen3::Eigen yaml-cpp OpenCV::core )

find_package 拆分写的好处是缺依赖时,CMake 的报错能直接指出缺哪一个,而不是等编译到一半才暴露。OpenCV 用 QUIET 关键字,没装也能编译,只影响可视化工具。Eigen 是 header-only 库,库里大量使用模板表达式,编译选项最好开 Release,否则 Eigen 的性能会比 Debug 慢一个数量级。

构建命令如下:

mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release cmake --build . -j$(nproc)

Windows 下把最后一行的-j$(nproc)去掉,用cmake --build . --config Release。构建出错时优先看两个位置:一是 find_package 报错,说明系统环境缺包;二是模板编译错误,说明 Eigen 版本和代码里某些 API 不匹配,常见于用了较新的 segment 相关接口但环境里是旧版 Eigen。

4.2 命令行启动与地图数据准备

编译通过后,先用自带测试数据跑冒烟测试。常见的数据准备流程是把网上下载的自动驾驶数据集转成灰度图,或者直接用 PGM 地图。灰度图映射规则一般是:0 为自由空间,255 为致命障碍,中间灰度作为软约束。转换时要注意像素分辨率和实际地图比例一致,否则 0.1 米分辨率的图在 0.05 米分辨率配置下,膨胀半径会差一倍。

运行示例:

./planner_main \ --map ../data/garage.png \ --resolution 0.1 \ --start "2.0,3.0,0.0" \ --goal "18.0,9.0,3.14" \ --planner hybrid_astar \ --max-iterations 60000 \ --config ../config/hybrid_astar.yaml

--start 和 --goal 的顺序是 x, y, theta,theta 单位是弧度。--planner 参数一般支持 astar、rrt_star、hybrid_astar、dwa 等值,内部用工厂方法根据字符串创建对应实例。--max-iterations 是搜索保险丝,出现“规划失败”时先把它调大再看日志。--config 指定的 yaml 文件存放权重与运动学参数,不要在 main 里硬编码数值。

首次运行如果地图没有加载出来,先检查图像路径是否相对于 build 目录定位正确;如果路径规划直接返回失败,优先打印起点和终点对应的栅格 cost,常见问题是起点落在膨胀后的障碍物区域内,规划器一开始就找不到可行邻居。

4.3 关键参数怎么调:膨胀半径、启发式权重与控制频率

参数之间不是独立的。调整时遵循一个固定顺序:先固定运动学参数,比如最小转弯半径、最大加速度;再调搜索参数,比如迭代次数、启发式权重;最后才调代价权重,比如障碍物距离、路径长度、横向偏移。下面是一组可以直接用于园区场景的初值:

参数推荐初值作用调大后的风险
inflation_radius0.4 米障碍物扩张范围路径过于保守,窄路无解
heuristic_weight1.05搜索效率与最优性平衡路径明显绕远
max_iterations50000搜索迭代上限内存占用上升,规划变慢
control_frequency20 Hz规划刷新频率计算超时,丢失控制周期
footprint_radius0.45 米碰撞检测边界实际车体蹭碰障碍物

宏观看流程,路径规划算法的迭代节奏和软件开发不太一样。每改一个参数,记录场景编号、参数值和规划结果,形成一张可回放的对照表。我遇到过一种典型问题是:膨胀半径调大后,A* 路径变长但耗时下降,因为窄通道被提前判死,搜索分支减少,表面看性能变好,实际上地图里所有 3 米以下的通道都被堵死。这就要回头对比栅格地图的可通行区域比例,而不只看单条路径。

5. 场景回放验证:把规划回归测试变成源码包自带的技能

路径规划调试和普通单元测试最大的差别在于:输入输出都是高维连续量,无法简单断言相等。最实用的做法是给每个场景建立一条可回放的数据记录,让每次代码改动都能和基准轨迹对比。这个习惯比任何参数表都值钱。

5.1 每跑一个场景,存一份输入输出快照

规划器每次跑完后,把规划请求(地图裁剪区域、起点终点、障碍物列表)和规划结果(路径点序列、耗时、返回码)按场景号存成一块快照。格式用 CSV 就够,结构类似下面这样:

scene_id,map,start,goal,planner,result,plan_time_ms,path scn_001,garage.png,"2.0,3.0,0.0","18.0,9.0,3.14",hybrid_astar,SUCCESS,85.2,"[[2.0,3.0],[2.1,3.0],...]"

地图本身不需要反复拷贝,存一个文件名加裁剪区域即可。这样本地积累几十个场景后,任何一次代码改动都可以做批量回放,而不是重新开仿真。

5.2 回归回放脚本:固定随机种子后逐场景对比

回放脚本做的事情很简单:读 CSV 里的场景,重新调用 planner_main,再用同一份地图计算新路径与基准路径的偏差。核心逻辑可以写成一个几十行的 Python 脚本:

import subprocess import math import json def replay_and_compare(scene, threshold_m=0.5): cmd = [ "./planner_main", "--map", scene["map"], "--start", scene["start"], "--goal", scene["goal"], "--planner", scene["planner"], "--config", scene.get("config", "config/planner.yaml"), ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=10) if r.returncode != 0: return False, f"scene {scene['id']} failed: {r.stderr[:200]}" result = json.loads(r.stdout) # 规划器以 json 形式输出路径 baseline = json.loads(scene["path"]) max_dev = max( math.dist(p, q) for p, q in zip(result["path"], baseline) ) return max_dev < threshold_m, f"max deviation: {max_dev:.3f} m"

注意一点:所有基于随机采样的算法,RRT* 和 Hybrid A* 都在内,回归对比前必须固定随机种子。否则两次规划天然不同,统计出来的偏差没有意义。固定方式很简单,在规划器构造函数里直接用场景 ID 派生种子,例如std::mt19937 rng(static_cast<unsigned>(std::hash<std::string>()(scene_id))),保证同一个场景每次运行得到同样的采样序列。

最后一件事:把规划耗时、返回码写进结构化日志行,和场景 CSV 放在同一层目录。下次遇到“偶尔规划失败”时,用grep -l FAIL scene_*.csv过滤出全部失败场景,再对失败场景单独回放。路径规划的问题几乎都是场景特异性的,可复现性藏在输入与参数组合里,不在海量日志里。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询