简介:面向ROS机器人开发者,聚焦Ubuntu18.04 + ROS Melodic环境下基于C++实现RRT路径规划算法,并部署到Turtlebot3小车的完整应用,适用于复杂环境下的路径搜索与运动规划学习。内容涵盖状态表示、随机采样、近邻搜索、树扩展、目标检测等核心步骤,配合Turtlebot3的激光雷达、TF变换与地图构建模块,同时提供Gazebo模型与rviz可视化配置,可帮助读者快速掌握从算法原理到实物仿真的关键流程。压缩包共204个文件,以sdf模型、yaml配置、launch启动、rviz可视化、cpp源文件及world仿真环境等类型为主,整体仅2.6MB,结构清晰,便于直接对照学习。已有3407人学习,适合需要在ROS中实现运动规划、并希望借助Turtlebot3平台进行验证的开发者。资源提供可直接编译运行的工程结构与仿真场景,配合rviz即可直观观察RRT树的扩展过程和最终路径效果,有效降低入门门槛;给出的MD说明文档可帮助快速了解工程组织与运行方式。
1. RRT算法在ROS中的实现:一份能让turtlebot3自己找路的源码包
如果你正在学机器人路径规划,又恰好用turtlebot3做仿真验证,那这份源码包就是一次把RRT算法落地的完整演练。它基于C++、ROS Melodic和Ubuntu18.04,实现RRT(快速扩展随机树)路径规划的完整流程——随机采样、最近邻搜索、碰撞检测、树扩展,到路径回溯输出,每一步都有源码可读、有可视化可看。你可以直接在Gazebo仿真环境里跑起来,也可以在rviz中观察树是怎么一步步长到目标点的。无论是刚接触路径规划的初学者,还是想在move_base里替换默认规划器的二次开发者,这份代码都能作为一个不错的起点。
2. 为什么在ROS里写一个RRT规划器:从算法原理到工程结构
2.1 RRT算法打底:随机树怎么在状态空间里长出一条路径
RRT的核心逻辑不需要太多前置知识:从起点节点开始,在状态空间里反复随机采样,每次采样得到一个目标点位,再从现有树中找到离它最近的节点,以固定步长向采样点方向生长一个新节点。如果新节点与最近节点之间的线段没有碰到障碍物,就把它挂到树上。循环往复,当树上的某个节点离目标点足够近,就沿着父子指针回溯出一条从起点到目标的路径。
相比A*需要把地图栅格化成图再做启发式搜索,RRT不需要对空间做任何离散化预处理,只要你告诉我“这条线段上有没有障碍”,它就能继续迭代。代价是它不保证找到最优路径,只保证在概率意义上逐步收敛到可行路径。所以在实际机器人系统里,RRT常被用作全局规划的第一步,找到一条可行路径之后再交给后端做平滑或轨迹优化。这也是这个项目为什么把可视化做得那么细的原因——你得先看懂随机树在怎么探索,才能理解后面每一步优化在干什么。
2.2 工程目录拆解:一个标准ROS功能包的骨架
解压后你会得到一个完整的ROS功能包,目录结构大致如下:
rrt_path_planner/ ├── CMakeLists.txt ├── package.xml ├── include/rrt_planner/ │ ├── rrt_node.hpp │ └── rrt_tree.hpp ├── src/ │ ├── rrt_node.cpp │ └── rrt_tree.cpp ├── launch/ │ └── rrt_plan.launch ├── config/ │ └── rrt_params.yaml └── rviz/ └── rrt_display.rviz这是ROS C++功能包的典型布局:include放类声明,src放实现,launch和config把启动逻辑与参数分离,rviz目录保存一套现成的可视化配置。从这个结构可以看出,作者希望开发者把注意力放在算法本身,不要为环境搭建分心。
package.xml里声明的依赖主要包括roscpp、nav_msgs、visualization_msgs、tf2这些,编译时需要依赖导航相关的消息包。如果你在catkin_make阶段遇到找不到头文件的报错,优先检查这几个包是否已安装。CMakeLists.txt里需要把rrt_planner_node声明为可执行目标,并链接好catkin库。
2.3 数据链路与坐标变换:规划器在ROS里扮演什么角色
RRT规划器在ROS里的定位是全局路径规划节点,它与仿真环境的数据交换由5个话题构成:
| 方向 | 话题名 | 消息类型 | 说明 |
|---|---|---|---|
| 订阅 | /map | nav_msgs/OccupancyGrid | 代价地图,碰撞检测的数据来源 |
| 订阅 | /initialpose | geometry_msgs/PoseWithCovarianceStamped | 起始位姿,可手动设置 |
| 订阅 | /goal | geometry_msgs/PoseStamped | 目标点,rviz中点击产生 |
| 发布 | /plan | nav_msgs/Path | 最终规划出来的全局路径 |
| 发布 | /rrt_tree | visualization_msgs/MarkerArray | 树搜索过程的可视化 |
坐标变换是刚上手最容易踩坑的地方:/map是地图坐标系,/odom是机器人里程计坐标系,rviz里点击2D Nav Goal产生的是map系下的目标点,而机器人当前位置要从odom系的位姿推算。项目在launch文件里用static_transform_publisher发布了这两个坐标系之间的固定变换,才让规划器和可视化在默认状态下就能正常工作。如果你在自己电脑上运行后发现树长到了奇怪的位置,十有八九是这组静态变换没有加载上。
3. 核心代码拆解:采样、碰撞检测与树扩展的完整链路
3.1 节点初始化与参数系统
从rrt_node.cpp开始看,节点初始化时从参数服务器读取所有可调参数,不允许缺省时会给出一套默认值:
#include "rrt_planner/rrt_node.hpp" RRTNode::RRTNode(ros::NodeHandle& nh, ros::NodeHandle& nh_private) : nh_(nh), nh_private_(nh_private) { nh_private_.param<double>("step_size", step_size_, 0.1); nh_private_.param<double>("goal_tolerance", goal_tolerance_, 0.2); nh_private_.param<int>("max_iterations", max_iterations_, 5000); nh_private_.param<double>("goal_bias", goal_bias_, 0.1); map_sub_ = nh_.subscribe("/map", 1, &RRTNode::mapCallback, this); goal_sub_ = nh_.subscribe("/goal", 1, &RRTNode::goalCallback, this); path_pub_ = nh_.advertise<nav_msgs::Path>("/plan", 1); tree_pub_ = nh_.advertise<visualization_msgs::MarkerArray>("/rrt_tree", 1); }这里几个参数直接决定算法行为。step_size是每一步生长的长度,默认0.1米;goal_tolerance是判定到达目标的距离阈值,默认0.2米;max_iterations限制最大迭代次数,防止在不可达目标上死循环;goal_bias表示每次采样时有10%的概率直接把目标点当作采样点,用来加速收敛。所有参数都能在config/rrt_params.yaml里改,不用重新编译,这是我在调参时最省事的地方。
3.2 树节点与最近邻搜索
RRT树的每个节点存储坐标、父节点索引和从根到当前节点的累积路径长度。在树里插入新节点前,要先找到离采样点最近的现有节点。项目在这里直接用了线性遍历,因为5000次迭代内的节点规模不超过几千个,没必要上KD-Tree:
int RRTree::findNearestNode(const std::vector<Node>& tree, double x, double y) { int nearest_idx = 0; double min_dist = std::numeric_limits<double>::max(); for (size_t i = 0; i < tree.size(); ++i) { double dist = (tree[i].x - x) * (tree[i].x - x) + (tree[i].y - y) * (tree[i].y - y); if (dist < min_dist) { min_dist = dist; nearest_idx = static_cast<int>(i); } } return nearest_idx; }这里用平方距离代替欧几里得距离,省去开根号的开销。在高频迭代场景下,这种微小优化累积起来很可观。findNearestNode是整棵树对外暴露的核心接口之一,后续想替换成KD-Tree或八叉树,只需要改这一个函数,调用方完全不受影响。初次实现时容易在距离比较中忘记统一用平方距离,代码正确性没问题,但每轮迭代多做一次sqrt,性能差异肉眼可见。
3.3 生长函数与栅格碰撞检测
新节点要加入树里,需要满足生长方向合法且与障碍物无碰撞。下面是生长函数的关键逻辑:
bool RRTree::extendTree(Node& nearest_node, double angle, double step_size, Node& new_node) { new_node.x = nearest_node.x + step_size * cos(angle); new_node.y = nearest_node.y + step_size * sin(angle); if (isLineCollisionFree(nearest_node, new_node)) { new_node.parent_index = getNodeIndex(nearest_node); return true; } return false; }angle由采样点与最近邻节点的坐标差值通过atan2计算得到。isLineCollisionFree的实现常见有两种:一种是把线段离散成点序列,逐点查栅格值;另一种是用Bresenham算法沿栅格轨迹走一遍。这个项目用的是前一种——把线段等分成若干小段,每段末端查一次地图栅格值。检测步长小于栅格分辨率的一半时,精度基本够用。
我第一次用它跑仿真时发现栅格地图分辨率是0.05米,而step_size默认是0.1米,相当于新边只被离散成两个检测点,遇到细窄障碍物容易漏检。后来把碰撞检测步长改成0.02米,漏检问题基本消失。这是这份代码里值得注意的第一个精度细节。
3.4 主循环:目标偏置、迭代收敛与路径回溯
整个搜索流程写在run函数里,主干逻辑如下:
void RRTPlanner::run() { tree_.clear(); tree_.push_back(start_node_); for (int iter = 0; iter < max_iterations_; ++iter) { double sampled_x, sampled_y; if (uniform01() < goal_bias_) { sampled_x = goal_.x; sampled_y = goal_.y; } else { sampled_x = randomInMapRange(); sampled_y = randomInMapRange(); } int nearest_idx = findNearestNode(tree_, sampled_x, sampled_y); double angle = atan2(sampled_y - tree_[nearest_idx].y, sampled_x - tree_[nearest_idx].x); Node new_node; if (extendTree(tree_[nearest_idx], angle, step_size_, new_node)) { tree_.push_back(new_node); publishTree(); double dist_to_goal = distance(new_node, goal_); if (dist_to_goal < goal_tolerance_) { path_ = tracePath(new_node); publishPath(); ROS_INFO("RRT find path, iter = %d", iter); return; } } } ROS_WARN("RRT failed to find a path within max_iterations."); }主循环的流程很清楚:采样、找最近邻、计算方向、生长、碰撞检测、加入树、检查目标、回溯。goal_bias是我最推荐调整的参数,它本质上是探索与利用的平衡旋钮:偏置越大,树越倾向于直接冲向目标点,但复杂环境下容易陷入局部死胡同;偏置减小,随机扩展增多,覆盖率提高但收敛变慢。在比较空旷的仿真仓库里,goal_bias取0.15左右表现最好。
路径回溯的代码单独看更直观:
std::vector<geometry_msgs::PoseStamped> RRTree::tracePath(const Node& goal_node) { std::vector<Node> rev_path; int idx = goal_node.index; while (idx != -1) { rev_path.push_back(tree_[idx]); idx = tree_[idx].parent_index; } std::reverse(rev_path.begin(), rev_path.end()); std::vector<geometry_msgs::PoseStamped> path; for (const auto& n : rev_path) { geometry_msgs::PoseStamped pose; pose.pose.position.x = n.x; pose.pose.position.y = n.y; path.push_back(pose); } return path; }回溯逻辑是从目标节点沿parent_index一路回退,直到根节点(根节点的父索引是-1),再把节点列表反转成从起点到目标的顺序。注意这里生成的path只包含位置信息,没有给每个点赋姿态朝向。实际给turtlebot3跟踪时,朝向需要根据相邻两个点的连线方向计算,否则小车到达转折点时会原地打转找角度,这是后续二次开发大概率要补的一块。
4. 在Ubuntu18.04上跑起来:编译、启动与可视化验证
4.1 环境准备:ROS Melodic与turtlebot3仿真依赖
这个项目面向Ubuntu18.04和ROS Melodic。开始之前,先把基础环境装好:
sudo apt install ros-melodic-desktop-full sudo apt install ros-melodic-navigation sudo apt install ros-melodic-turtlebot3 ros-melodic-turtlebot3-simulationsdesktop-full自带rviz和gazebo,navigation包提供导航栈通用的消息与代价地图类型,turtlebot3相关包用于启动仿真小车。把功能包放进工作区后编译:
cd ~/catkin_ws/src # 将 rrt_path_planner 功能包目录放到这里 cd ~/catkin_ws && catkin_make source ~/catkin_ws/devel/setup.bash编译完成后确认devel/lib/目录下生成了rrt_planner_node可执行文件。如果编译报错,优先检查package.xml声明的依赖是否装全,常见缺失是nav_msgs和visualization_msgs,用apt安装ros-melodic-navigation即可解决。
4.2 launch文件做了什么
项目自带launch文件,功能是启动规划器节点、加载参数文件并拉起rviz:
<launch> <arg name="map_file" default="$(find rrt_planner)/maps/simple_map.yaml"/> <node name="rrt_planner" pkg="rrt_planner" type="rrt_planner_node" output="screen"/> <node name="rviz" pkg="rviz" type="rviz" args="-d $(find rrt_planner)/rviz/rrt_display.rviz"/> </launch>map_file参数指定仿真地图,规划器节点启动后会把订阅到的OccupancyGrid传给碰撞检测模块。如果你用turtlebot3的Gazebo仿真,需要先启动仿真世界,再启动这个launch。我的习惯是分两个终端跑:一个终端启动turtlebot3仿真环境,一个终端启动规划器。这样规划器崩溃时能看到完整日志,不会和仿真输出混在一起。
4.3 在rviz中设置目标点并观察搜索轨迹
启动完成后,rviz里会加载预制显示配置。因为此时还没有地图和目标点,RRT树的可视化是空的。点击顶部2D Nav Goal按钮,在白色可通行区域点一下并拖出方向箭头,规划器就会以当前机器人位姿为起点开始迭代搜索。
这时在rviz左侧Displays面板勾选/rrt_tree对应的MarkerArray,可以看到树在实时向外扩展。每次迭代产生的新节点用线段连到父节点,颜色从起点向目标渐变。当一条高亮路径出现时,说明搜索成功,/plan话题同步发布了nav_msgs/Path消息。如果想验证路径是否真的无碰撞,用RQT工具查看path消息,或者直接把turtlebot3的仿真底盘切到跟随path模式,让小车沿路径走一遍。
4.4 参数热修改的验证方法
跑通一次之后,可以打开config/rrt_params.yaml,把goal_bias改成0.05再重新启动launch,对比树的扩展形态差异。偏置降低后,树会更均匀地向四周探索,路径会更迂回但成功率通常更高。这种对比实验能帮你直观理解每个参数在算法里的实际作用,比纯看公式有效得多。
5. 排查避坑手记:RRT在仿真中容易翻车的5个细节
5.1 树长的位置和地图对不上号
现象:rviz中RRT树在乱长,明明地图左上角有障碍,树却从那里穿过去,看起来像是树和地图不在同一个坐标系里。
原因:/map和/odom两个坐标系之间缺少变换。规划器一端从map取地图数据,一端从odom取机器人位姿,两个坐标系没有关联时,树的根节点位置和地图栅格对不上,碰撞检测自然全部失效。
解决:确认launch里是否加载了static_transform_publisher。常见做法是在launch中加一个节点,发布map到odom的固定变换。如果地图和odom有漂移,还需要额外做定位校准,不过仿真环境里固定变换足够。
5.2 明明有通路却一直提示找不到路径
现象:地图很空旷,目标点明确可达,但max_iterations耗尽后ROS_WARN打印失败信息。
原因:step_size设置过大,单次生长长度跨过了目标点附近的容差区域,而goal_bias太小,随机采样很难落到目标点周边,树始终在远离目标的方向上扩展。
解决:把step_size调到0.05米,goal_tolerance放宽到0.3米,同时把goal_bias提升到0.2。这三个参数需要一起调,单独改任何一个都容易引入新问题。这个组合在turtlebot3常见仿真地图里表现稳定。
5.3 RViz卡顿、树渲染延迟明显
现象:树每扩展一次就发布一次MarkerArray,迭代几百次后rviz越来越卡,旋转视角时明显掉帧。
原因:可视化消息发布频率太高,rviz对大量Marker逐帧渲染代价很大。
解决:把publishTree改为每10次迭代调用一次,或者只在搜索成功后一次性发布完整树结构。想看搜索过程时保留高频发布,只是调参验证路径时完全可以把可视化输出关掉。
5.4 生成的路径不平滑,小车走起来一顿一顿
现象:路径虽然绕开了障碍,但折线很多,turtlebot3仿真里跟踪时频繁左右转向。
原因:RRT原始输出不做任何平滑处理,随机树的非平滑性质直接体现在结果路径上。这是算法特性,不是代码实现的问题。
解决:在回溯得到path之后加后处理步骤。我常用的做法是对路径做Ramer-Douglas-Peucker简化,去掉近似共线的中间点,再用B样条或三次样条插值平滑。这个后处理逻辑可以放在tracePath之后,不影响RRT核心流程。
5.5 同样的参数,换个地图就翻车
现象:A地图上能顺利规划,B地图上频繁失败,或路径质量骤降,同一张地图重复运行结果也不完全一样。
原因:RRT本质是随机算法,每次运行扩展的树形态都不同。部分成功的运行可能只是恰好采样点分布得巧,没有固定随机种子的情况下,结果天然不可复现。
解决:在参数文件里固定随机种子。这是RRT工程化时最容易忽略的稳定性问题,固定种子之后,对比实验才有意义。我在调参时把随机种子当作和step_size同级别的参数来管理,每次实验记录种子值,后续复现问题就能精确定位。
6. 二次开发:让RRT规划器接入现有导航栈
6.1 插件化改造:对接move_base全局规划器
如果你想把这个RRT规划器接入move_base的全局规划框架,需要对标nav_core的BaseGlobalPlanner接口,重写initialize和makePlan两个函数。得益于项目里RRTree类和碰撞检测逻辑相对独立,移植时只需要把RRTNode的外壳去掉,将核心算法搬进插件类,话题的订阅发布替换成参数传入的costmap引用。
6.2 多机场景下的命名空间隔离
如果后续要做多机器人协同,每个机器人实例化一个RRTPlanner时,要注意话题命名空间隔离。默认的/map和/plan会被多个机器人竞争订阅,常见做法是用机器人命名空间robo1/rrt_planner、robo2/rrt_planner这种方式隔离话题和参数。这段逻辑在单机demo中看不到,但做多机仿真时几乎是必踩的坑。
6.3 把调参变成一套可复现的流程
从那以后我每次跑RRT调参都会强制走一遍固定流程:先固定随机种子,再改step_size和goal_bias,最后看碰撞检测步长。每轮实验记录参数组合和结果指标,而不是凭感觉东调一下西调一下。这样每一个改动都有参照,不至于把随机性当成参数影响。希望帮到你。
本文还有配套的精品资源,点击获取