简介:本资源是一个面向高校机器人方向本科生与研究生的ROS2实践项目,聚焦迷宫环境下的自主导航与路径规划算法实现,适用于毕业设计、课程设计及AI+机器人交叉学习场景。压缩包共30个文件(180KB),含13个SDF世界模型文件(定义不同尺寸迷宫结构)、6个Python核心脚本(涵盖A*路径搜索、路径跟踪、障碍物生成与TSP优化等关键逻辑)、3组配置文件(用于参数调优)及配套README说明与.gitignore规范。已有73人学习下载,项目采用ROS2 Jazzy与Gazebo Harmonic官方仿真环境深度集成,提供从世界建模、机器人模型搭建、传感器仿真到算法闭环验证的完整流程,所有代码模块化清晰、注释完备,可直接运行并支持快速替换自定义算法模块,是理解ROS2节点通信、TF坐标变换与实时路径规划落地的理想教学级工程范例。 拿到这个项目标题的时候,我第一反应是:终于有人把ROS 2的版本迭代和Gazebo的仿真体系捋到一块儿了。Jazzy是ROS 2的长期支持版本,Harmonic是Gazebo的同步长期支持版本,这两个名字绑在一起,本身就意味着开发者在选型上踩过坑、查过兼容表,最后才敲定的组合。如果你还在Humble和Foxy之间犹豫,或者被Gazebo Classic和Gazebo的差异搞到头大,这篇东西应该能帮你省下好几个周末的折腾时间。
整个项目的核心目标是做一个能在仿真环境里自主走迷宫的差速机器人。听起来不算复杂,但真做起来会发现,它把ROS 2的通信机制、Gazebo的物理引擎、SLAM建图、路径规划、甚至传统迷宫求解算法全部串在了一起。我从环境搭建开始,一路做到机器人成功走出迷宫,中间踩了无数坑,这篇文章会把整个过程拆开揉碎,包括我为什么选这套技术栈、每个环节的核心原理、以及你照着做的时候最容易翻车的地方。
1. 从版本选择说起:为什么是Jazzy搭配Harmonic
很多初学者容易忽略一个问题:ROS 2和Gazebo不是两个独立的软件,它们之间有严格的版本对应关系。Jazzy Jalisco是2024年发布的ROS 2长期支持版本,官方推荐搭配的仿真器就是Gazebo Harmonic。这个组合有一个天然优势——两者的维护周期几乎对齐,不会出现一个还在更新、另一个已经停止维护的尴尬情况。
我见过太多人在Humble上装Gazebo 11,然后在启动仿真时遇到各种奇怪的段错误。根源在于Gazebo Classic和ROS 2的接口是通过gazebo_ros_pkgs桥接的,而新版Gazebo(Harmonic及以后)采用了完全不同的插件机制和传输层。Jazzy + Harmonic的组合,ros_gz功能包已经非常成熟,机器人和传感器的仿真插件直接调用Harmonic的原生API,通信延迟和稳定性都明显优于老方案。
具体到系统环境,我推荐Ubuntu 24.04。Jazzy对24.04的支持是最好的,Harmonic在24.04上安装也只需要几行命令。如果你还在用22.04,也不是不行,但需要自己处理一些依赖冲突,尤其是sdformat和gz-transport的版本问题,纯属给自己找麻烦。
还有一个容易忽略的细节:ROS 2的rmw实现会影响仿真数据传输的实时性。默认的rmw_fastrtps_cpp在重负载下偶尔会出现数据丢失,我后来换成了rmw_cyclonedds_cpp,发布里程计和激光数据时的表现稳定了不少。这一行配置差一点,整个迷宫求解过程的稳定性就完全不同。
2. 搭建一个能跑迷宫仿真的最小环境
环境搭建这一步,说简单也简单,说坑也坑。我强烈建议你直接从二进制包安装,不要从源码编译。源码编译Jazzy + Harmonic的组合在部分机器上会触发编译器版本问题,纯属浪费时间。
2.1 先装ROS 2 Jazzy
如果你用的是Ubuntu 24.04,安装流程比老版本要简洁很多。核心步骤是设置软件源、添加ROS 2 GPG密钥、然后apt install ros-jazzy-desktop。ros-jazzy-desktop这个包包含了rviz2、demo_nodes_cpp、tf2等常用工具,做迷宫仿真完全够用。
装完之后别忘了做一件事:把source /opt/ros/jazzy/setup.bash写进~/.bashrc。如果你用zsh,就写setup.zsh。这个步骤漏掉的话,每次新开终端都要手动source一遍,极其影响心情。
2.2 再装Gazebo Harmonic
Harmonic的安装分为gz-harmonic主包和ros_gz桥接包两部分。前者主要是仿真器本体,后者是ROS 2和Gazebo通信的桥梁。装完之后用gz sim -v 4启动一个空白世界,能正常打开就说明基本环境没问题。
注意,这里的桥接包必须和你的ROS 2版本匹配。Jazzy对应的是ros-jazzy-ros-gz,这个包名在Ubuntu的apt源里可以直接搜到,别下错了版本。如果下成了Humble的ros_gz,你会发现节点虽然能启动,但话题永远收不到数据。
2.3 环境变量配置
Harmonic装好之后,还要在.bashrc里加一行export GZ_SIM_RESOURCE_PATH=$GZ_SIM_RESOURCE_PATH:~/your_workspace/src。这个变量告诉Gazebo去哪里找机器人模型和世界文件。我当时忘了配这一步,结果机器人模型怎么都加载不出来,报错还特别隐蔽,只说找不到model://路径,排查了好久。
另外再装一个gz-tools2,它提供了一些命令行工具,比如gz model、gz world,调试模型坐标和关节状态的时候能救命。
3. 迷宫环境建模:我不建议手画每一面墙
迷宫场景看起来只是几面墙拼在一起,但如果你真的用Gazebo的图形界面一块块拖墙体模型,后续调整迷宫布局会非常痛苦。正确做法是直接用文本格式定义世界文件,把墙体坐标写成一个数组,然后用脚本批量生成。这样改迷宫结构只需要改一组坐标,重启仿真就能生效。
3.1 用SDF格式定义迷宫墙体
Harmonic沿用SDFormat作为世界描述格式。每一面墙本质是一个<model>节点,里面包含一段<box>几何体和对应的<collision>属性。麻烦的地方在于,每个模型都要写重复的mass、inertia、surface friction信息,手写十几面墙能写到怀疑人生。
我当时写了一个Python脚本,把墙体的坐标和尺寸存成CSV文件,脚本自动生成对应的SDF片段,拼接到世界文件里。这样不仅省事,还避免了手写时容易漏掉<collision>导致的物理穿透问题。
3.2 设置合适的物理参数
迷宫求解对里程计精度要求不高,但对碰撞检测的稳定性要求很高。建议把default_gravity保持默认,但把墙体的<surface>摩擦系数改到0.8左右,不然机器人轮子打滑会导致后续定位漂移。
还有一个参数值得注意:max_step_size。Harmonic默认是0.001秒,如果电脑性能一般,仿真速度会拖得很慢。我调到了0.002,物理精度少量下降,但仿真速度明显提升,对迷宫这种低速场景完全够用。
3.3 生成一个25格乘25格的测试迷宫
我的实现里,迷宫设计成正方形网格布局,每个格子边长1米,墙壁高0.2米。这个尺寸既能容纳机器人顺畅转向,又不会让雷达扫描范围超出墙体。为了避免机器人一开始就被困在角落,入口放在迷宫外沿,机器人从入口出发后,通过右手法则决定下一步走向。这样的布局对后续算法验证比较友好。
4. 机器人本体:差速底盘和传感器配置
迷宫求解的机器人本体不需要机械臂,不需要复杂关节,一个差速底盘加一个激光雷达就足够了。我在项目里用的是一台两轮差速小车,前侧还有一个万向轮支撑。
4.1 URDF模型的关键连接
URDF里最核心的就是<joint>的类型和<limit>设置。差速轮要设成continuous类型,不需要角度限制,万向轮设成fixed,不然仿真里它会随意摆动导致抖动。两个驱动轮还要通过<transmission>和<gazebo>插件绑定到差速控制器上,这一步漏了的话,机器人就变成了一台雕塑。
4.2 激光雷达选型
迷宫求解最适合的传感器是2D激光雷达。Harmonic自带的gpu_ray传感器插件效率很高,能直接输出模拟的LaserScan消息。扫描范围设成360度,解析度1度,最大测距10米。如果你用的是ray(非GPU)版本,CPU占用会居高不下,建图实时性反而变差。
4.3 里程计和IMU的配合
差速底盘的里程计通过轮式编码器积分获得。这里有个经典坑:纯里程计在仿真中也会有累计误差,所以最好加一个IMU作为辅助。Harmonic的IMU插件输出的是Imu消息,把它和轮式里程计做EKF融合,定位精度会好很多。安装robot_localization包后,配置ekf_node的odom0和imu0输入即可。
robot_localization的配置是一个低频但关键的工作。频率设置注意一点:IMU的频率最好高于100Hz,里程计50Hz即可,太低的话融合出来的姿态跳变明显。我一开始IMU设成30Hz,融合结果惨不忍睹,后来改成200Hz后效果才稳定下来。
5. 让机器人知道自己在哪里:SLAM建图和定位
迷宫求解的前提是机器人必须拿到准确的自身位姿和地图信息。我选择的方式是用rtabmap从单线激光雷达数据实时构建二维占据栅格地图,然后基于这个地图做路径规划。这套方案的好处是建图过程和迷宫求解可以解耦——先跑一遍建图,拿到完整迷宫地图,再单独执行迷宫搜索逻辑。
5.1 slam_toolbox还是rtabmap
这里有个重要的取舍:slam_toolbox计算量小,续航性好,但它推出的地图通常以激光扫描匹配为主,在长走廊场景容易飘。rtabmap则维护了完整的位姿图,闭环检测做得好,迷宫这种大量重复纹理的环境,恰好是它的强项。我的项目里用的是rtabmap,因为它能直接输出里程计话题(/rtabmap/odom),省去自己维护TF树的部分麻烦。
如果你非要坚持用slam_toolbox,至少把minimum_travel_heading设为0.2弧度,minimum_travel_distance设为0.1米,不然建图时机器人在原地打转也会频繁插入帧,拖慢建图速度。
5.2 让你的导航栈信任你的地图
在建图过程中,地图的分辨率和尺寸直接影响后续Nav2路径规划的可行性。分辨率设成0.02米/像素,既能捕捉迷宫墙体的细节,又不会让地图体积过大。最终生成的地图通过map_server发布,同时在Nav2的配置里把map_topic指向对应的地图话题,避免默认值找不到地图。
一个容易被忽略的细节:地图的TF关系必须严格满足map -> odom -> base_footprint的层级结构。rtabmap默认会输出整个TF树,但如果你自己写节点广播位姿,很容易把map和odom搞反,导致机器人在地图上的位置一直跳变。
6. 迷宫求解算法:从传统规则到图搜索
拿到地图之后,就进入重头戏了。迷宫求解有两种主流思路:一种是传统的沿墙走算法,不需要全局地图;另一种是基于已知地图的图搜索算法,机器人先把迷宫完整地探索一遍,然后规划一条从入口到出口的最优路径。我的项目最终采用的是第二种思路,更贴近实际导航场景,也有更强的泛用性。
6.1 为什么最终摒弃了纯右手法则
右手法则(或左手法则)在真实的物理迷宫里是可行的,因为机器人可以靠触碰墙壁来决定转向。但在仿真环境中,激光雷达扫描到的墙是离散点,它代表的“墙”其实是一条具有一定宽度和噪声的点云边界。如果你仅靠规则去碰壁,很容易出现所谓的“贴边抖动”——机器人不断修正自己的朝向,导致里程计误差迅速累积。
所以我的方案是先做全图探索:让机器人沿着迷宫网格逐格扫描,利用Nav2的navigate_to_pose动作接口驱动机器人逐个遍历可达格点,同时把未知区域逐步填充进地图。等整张图被探索完毕之后,再利用A*或Dijkstra在主地图上规划一条最优路径。
6.2 栅格地图与A*搜索
因为迷宫是基于网格生成的世界,所以地图天然可以用二维数组表示。A*算法之所以比BFS更适合这个场景,在于它融合了到起点的实际代价和到终点的启发式估计。在迷宫这类格子成本几乎相等的地图里,用曼哈顿距离作为启发函数已经足够了,不需要引入更复杂的欧氏距离。
在写搜索逻辑时,要注意相邻节点不仅包括上下左右四个方向,还包括斜角方向。但斜角穿墙是很多初学者会犯的错误——如果右上角有墙,那么从当前格子移动到右上格子实际上会穿过墙体交接点。一个安全的做法是:只有两个相邻的正方向都通行时,才允许走斜角。
6.3 实现A*的代码骨架
下面是我在项目里用的A*核心逻辑,包含了完整的路径输出。为了简洁,我用的是Python伪代码风格,实际项目中你可能需要转成C++插件:
import heapq def heuristic(a, b): # 曼哈顿距离 return abs(a[0] - b[0]) + abs(a[1] - b[1]) def astar(grid, start, goal): open_list = [] heapq.heappush(open_list, (0, start)) came_from = {} g_score = {start: 0} f_score = {start: heuristic(start, goal)} while open_list: _, current = heapq.heappop(open_list) if current == goal: path = [] while current in came_from: path.append(current) current = came_from[current] path.append(start) return path[::-1] for dx, dy in [(1,0),(-1,0),(0,1),(0,-1),(1,1),(1,-1),(-1,1),(-1,-1)]: neighbor = (current[0]+dx, current[1]+dy) if not (0 <= neighbor[0] < len(grid) and 0 <= neighbor[1] < len(grid[0])): continue if grid[neighbor[0]][neighbor[1]] == 1: continue # 斜角安全校验 if dx != 0 and dy != 0: if grid[current[0]+dx][current[1]] == 1 or grid[current[0]][current[1]+dy] == 1: continue tentative_g = g_score[current] + (1.414 if dx!=0 and dy!=0 else 1.0) if tentative_g < g_score.get(neighbor, float('inf')): came_from[neighbor] = current g_score[neighbor] = tentative_g f_score[neighbor] = tentative_g + heuristic(neighbor, goal) heapq.heappush(open_list, (f_score[neighbor], neighbor)) return None这个函数输入是二维占据栅格(0表示可通行,1表示墙体),输出是路径点列表。实际部署时,我会把Ros消息里的OccupancyGrid转换成这个二维数组,跑完A*之后再把路径点转化成坐标,调用nav2_msgs/action/NavigateToPose跟随路径。
6.4 探索策略:用覆盖路径完成全图扫描
覆盖路径规划(Coverage Path Planning)在自动扫地机器人里很常用,思路是让机器人逐行扫描,不走重复路线。但在迷宫里,格子之间的连通性不是矩形,所以不能直接用网格全覆盖。我采用的是当前节点左转优先+BFS回溯的策略来逐格遍历。
具体操作是:机器人当前站在某个格子,先检查左边的格子是否未探索且可通行,如果可行就走左边;如果左边不行,则按前方、右边的顺序试探;如果周围都已走过,就利用已建好的全局路径回到最近一个未探索分支点。这个策略配合A*回溯,能保证机器人最终走过所有可达格子。
6.5 路径追踪与避障
拿到A算出的全局路径后,让机器人沿着路径走,本质上是把路径点依次发送为Nav2的NavigateToPose目标。这里有一个关键点:Navigation2不会沿着你给的路径点一个一个执行,它只接收目标点,然后自行规划局部路径。因此你需要把A路径拆分成多段,每次发给Nav2一个子目标,等机器人到达后再发下一个。如果你一次性把每个路径点全发出去,Nav2只会保留最后一个目标点,中间路径直接被丢弃。
所以主控节点通常会维护一个路径点队列,在navigate_to_pose的done回调里弹出下一个点。如果机器人中途被未知障碍挡停,需要重新调用A*计算当前点到目标点的路径。我在主控里加了一个定时器,每200ms检查机器人当前位姿和目标点的距离误差,如果误差超过0.2米且持续多秒,就触发重规划。
7. 导航集成:在迷宫里跑通Nav2的关键细节
Nav2是ROS 2里最重量级的导航栈,但在迷宫这种窄通道场景里,直接套默认参数几乎必失败。下面几个是我在测试中总结出最值得调的地方。
7.1 Costmap的膨胀层
默认的膨胀半径是0.55米,这对60cm宽的车身可能都有点保守。但如果你把膨胀半径设得太大,机器人会被迷宫墙体堵死——因为所有通道都变成了“不可通行”区域。我的经验是:把inflation_radius设为0.08米左右,cost_scaling_factor设为5.0,让代价地图只在墙体边缘产生高代价,而通道中央保持低代价。这样才能保证机器人在1米宽的走廊里走“S型”而不撞墙。
7.2 局部规划器:DWB还是TEB
Nav2默认的局部规划器是DWB,它的优势是计算量小,但在狭窄通道里容易来回甩尾。TEB(Timed Elastic Band)则在优化轨迹时会考虑时间最优和避障约束,在迷宫里表现好了很多。
TEB的参数有几个要动:dt_ref(时间步长)从默认的0.3改成0.2,max_vel_x设成0.5m/s,max_vel_theta设成1.0rad/s,min_obstacle_dist设成0.05m。如果你的车轮轴距较小,还需要适当降低acc_lim_x,否则急转时容易原地打滑。
另外TEB依赖代价地图的代价函数进行优化,它对costmap的实时更新要求较高。如果你发现机器人总是偏离路径,可以打开teb.verbose日志,它会打印每个优化轨迹的评分细节,帮助你判断是避障权重过高还是路径跟踪得太死板。
7.3 机器人半径和Footprint配置
URDF模型里的机器人半径不一定等于footprint,因为footprint是导航栈认为的“安全轮廓”。我建议把footprint设成比实际车身小一圈,比如实际长度为0.45m,就设成一个长为0.4m、宽为0.3m的矩形。这样在迷宫窄通道里会让规划器更有余量。
7.4 生命周期节点和自动启动
Nav2启动时会启动一堆生命周期节点,调试时经常会漏掉“激活”这一步,导致导航没反应。我建议直接用现成的nav2_bringup里的launch文件,或者写一个只包含自己参数文件的launch,把autostart设为True,避免每天手动点击激活。
8. 避坑记录:我在这个项目里踩过的六个深坑
这部分是我最想写的内容。这些坑每一个都曾经让我卡了好几个小时,上网搜也搜不到完整答案。
8.1 坑一:Harmonic的仿真时间戳和ROS 2的时钟不同步
Gazebo Harmonic发布的话题默认带的是仿真时间戳,而ROS 2节点默认用系统时钟。如果/clock话题没有正确发布,rtabmap和Nav2的TF时间戳会直接对不上,表现为地图疯狂抖动。
解决办法是启动Gazebo时加上-r参数(运行实时仿真),同时在ros_gz_bridge的配置里确保把/clock话题桥接到ROS 2。如果还是不生效,检查仿真器是否在暂停状态——gz sim默认是暂停的,必须按播放键才开始走时间。
8.2 坑二:URDF里漏配<gazebo>插件导致车轮不转
很多人写完URDF,启动仿真后发现机器人一动不动,/odom话题也没有数据。这通常是因为差速驱动插件没有正确加载。Harmonic里对应的插件是gz_ros2_control里的GazeboSimSystem,你需要把<plugin>节点放到URDF的<gazebo>标签里,并指定<parameters>指向你的ros2_controllers.yaml配置文件。
一个更隐蔽的问题是,插件加载成功后,控制器管理器不会自动激活。你还需要通过ros2 control load_controller手动加载diff_drive_controller,或者写个launch让它自动加载。直接用ros2 run跑的话,很容易漏这一步。
8.3 坑三:地图大小和实际世界坐标不一致
用rtabmap建图时,如果地图的origin设成非零值,Nav2的代价地图会以map的坐标系原点去解释。这会导致A*规划的路径在Rviz里看起来正确,但机器人实际运动轨迹偏移。
解决办法是启动rtabmap节点时用frame_id:=map选项,并且确认没有额外的静态坐标变换把地图原点搬到别的地方。我建议在启动文件里加一个static_transform_publisher,把map到odom之间的变换始终发布成零值,防止TF树出现毛刺。
8.4 坑四:TEB在窄通道里的震荡
TEB的参数其实非常依赖机器人运动学。如果你发现机器人在墙边来回摇摆,通常有两个原因:一是max_vel_x太大,导致优化器无法在有限的距离内把速度降下来;二是weight_obstacle设得太高,机器人为了避免碰撞而大幅绕路。
我的调试方法是把max_vel_x先降到0.3,然后逐步往上加,同时观察/cmd_vel话题的速度曲线。如果速度曲线出现剧烈的正负交替,就说明TEB在目标函数里没有找到平滑解,此时需要加大dt_ref或者增加weight_kinematics_forward_drive。
8.5 坑五:雷达点云里缺少墙体信息
有时候机器人一进迷宫,雷达数据看起来很正常,但建图时墙体的轮廓特别模糊。原因多半是gpu_ray插件的光线数量太少,或者range设得不够远。gpu_ray的<samples>参数决定了每帧的激光线条数,我设成了360,对应每度一条,效果就非常清晰。如果你发现雷达的视角有盲区,检查一下<min_angle>和<max_angle>是否设成了90度或270度而不是360度。
8.6 坑六:自主导航时机器人总往未知区域跑
Nav2的代价地图对未知区域默认是“可通行”的。如果你的迷宫在探索阶段还没完全建图,机器人很可能会把未知区域当成潜在通路,然后一头撞上去。
解决办法是把代价地图的track_unknown_space设为true,同时把unknown_cost_value设成一个较大的值,让规划器把未知区域视为禁行区。探索阶段结束后再切换成完整的迷宫地图,这样行进路径就很安全。
9. 从仿真到实车的移植思路
仿真里有几点可以预见地在实体机器人上会出问题,建议趁早考虑。第一,仿真的轮地摩擦、电机响应都偏理想化,实车差速控制器的PID参数必须重调;第二,激光雷达的扫描范围、角度分辨率在不同硬件上差异巨大,建图参数要重新标定;第三,robot_localization的EKF协方差矩阵在仿真里会趋于零,实车上则需要根据传感器噪声手动调整。
在架构层面,只要URDF、控制器、SLAM和Nav2的接口全部按ROS 2标准写,仿真到实车的迁移基本只需要更换机器人驱动插件和传感器驱动节点,导航和规划部分可以直接复用。
根据我自己的测试,从零搭建这套环境到机器人成功走出第一个迷宫,大概需要一周左右的业余时间。如果你是第一次接触ROS 2,建议先把教程里的turtlesim和demo_nodes跑一遍,再回来啃这个项目,会顺很多。
本文还有配套的精品资源,点击获取