简介:基于ROS_2与Navigation_2框架开发的智能自动巡检机器人仿真系统,面向机器人、自动化方向学习者与ROS开发者,用于在虚拟环境中验证运动控制、环境感知、路径规划及自主避障等关键算法。系统集成语音播报、图像采集、目标点循环导航和多传感器融合,覆盖从建图定位到任务调度的完整闭环。资源共419个文件,包含53个CMake构建脚本、42个Python控制与算法文件、24个URDF/Xacro机器人模型、YAML导航配置及rviz可视化配置等,压缩包仅448KB,目录结构清晰,便于按功能模块查阅,可快速定位到小车模型、导航节点、应用层脚本与日志输出。已有101人学习下载。借助该仿真包,可搭建巡检机器人仿真场景,学习Navigation_2导航栈配置与调参思路,理解多传感器数据融合和自主避障的实现流程,同时参考语音播报与图像采集的集成方法,适合课程设计、毕业设计或ROS2项目入门实践。
1. 巡检仿真不是玩具:ROS2 与 Navigation2 这套系统解决的是任务闭环
我拆过不少 ROS2 仿真项目,最容易踩的坑是:机器人在 Gazebo 里能走、能在 Rviz 里看到雷达点云,但一接 Navigation2 做巡检就露馅——要么定位飘、要么避障撞墙、要么巡航到第三个点就假死。这份基于 ROS2 与 Navigation2 的智能自动巡检机器人仿真系统,不是单点演示,而是把机器人运动控制、环境感知、路径规划、多传感器融合、自主避障、图像采集、语音播报、目标点循环导航串成一个可跑的任务闭环。你拿到手能照着它的 launch 文件和参数复现:机器人沿目标点循环巡航,中途遇障碍绕开,到点后触发图像采集与语音播报。它适合刚装完 ROS2 想往巡检方向落地的开发者,也适合被 AMCL、代价地图、BT Navigator 折腾过、想找一份完整参考的从业者。
2. 仿真环境与机器人模型:先让底盘、雷达、相机在 Gazebo 里各就各位
不管导航算法写得再好,仿真里第一步永远是环境与模型。这套资源的默认落点是 Gazebo Classic + ROS2,选它而不是新版的 Gazebo Ignition,理由很实际:Navigation2 和 robot_localization 的插件对 Classic 的接口兼容最全,教程、launch 模板、报错信息都搜得到;Ignition 的桥接要多配一层 ros_gz_bridge,对巡检这种传感器多的场景反而增加排查成本。机器人本体通常用 URDF/Xacro 描述,Gazebo 里每个传感器对应一个<sensor>插件,话题名、帧名都从这里定死。后面代价地图、EKF、AMCL 找的是同一批话题,这一步错一个名字,后面全是红报错。
2.1 URDF/Xacro 模型:传感器的挂载关系决定后面所有坐标系
我先说一个仿真里最容易被忽略的点:传感器在 URDF 里的挂载位置,直接决定 tf 树长什么样,而 Navigation2 的所有代价地图和定位模块都依赖 tf 树。常见做法是把激光雷达挂在 base_link 正上方,相机挂在前面,IMU 放在底盘中心。下面是一段典型 xacro 片段:
<link name="base_link"> <visual> <geometry><box size="0.45 0.35 0.12"/></geometry> </visual> <collision> <geometry><box size="0.45 0.35 0.12"/></geometry> </collision> <inertial> <mass value="4.2"/> <inertia ixx="0.05" ixy="0.0" ixz="0.0" iyy="0.06" iyz="0.0" izz="0.05"/> </inertial> </link> <joint name="laser_joint" type="fixed"> <parent link="base_link"/> <child link="laser_frame"/> <origin xyz="0 0 0.25" rpy="0 0 0"/> </joint> <link name="laser_frame"> <inertial> <mass value="0.05"/> <inertia ixx="0.0001" ixy="0.0" ixz="0.0" iyy="0.0001" iyz="0.0" izz="0.0001"/> </inertial> </link>joint 为什么用 fixed,因为激光雷达和底盘是刚性连接,别用 revolute 或 continuous,否则导航时 tf 树里多一个会转的关节,代价地图会把雷达扫到的环境当成相对机器人旋转的障碍,那基本就没法规划了。inertial 必须写,Gazebo 里没有质量的 link 在物理引擎里会报错或产生飘移。laser_frame 的 z 取 0.25 米,是室内巡检机器人的典型雷达高度,低于这个值容易扫到椅子腿,高了又扫不到地面台阶;相机的挂载方式同理,只是多一个 camera_link,如果要用深度相机做点云避障,还要挂一个深度 camera 插件。
2.2 传感器仿真参数:频率、视野、噪声怎么设才不坑导航
模型挂好之后,传感器插件的参数才是真正影响导航质量的地方。很多新手在这里抄一个模板就不管了,结果导航时雷达数据稀疏、IMU 噪声过大,EKF 输出乱跳。以下是我在这套系统里常用的参数表和理由:
| 传感器 | 插件类型 | 关键参数 | 建议值 | 影响点 |
|---|---|---|---|---|
| 激光雷达 | gazebo_ros_ray / gpu_ray | samples、range、update_rate | samples=360、range=8m、update_rate=10Hz | 点数决定代价地图分辨率,range 决定探测距离 |
| 相机 | gazebo_ros_camera | image_width/height、update_rate | 640x480、15Hz | 分辨率太高仿真容易卡顿 |
| IMU | gazebo_ros_imu | update_rate、noise | 50Hz、陀螺仪噪声按实际 IMU 量级 | 频率过低会让 EKF 姿态更新滞后 |
gpu_ray 是激光雷达的 GPU 实现,比普通 ray 插件省 CPU,代价是 GPU 占用高一点。对巡检场景我一般选 gpu_ray,因为后半段还要跑 Navigation2 的 planner 和 costmap,CPU 要给导航栈留余量。range 设 8 米对室内巡检够用了,再大意义不大,代价地图的 inflation 半径一般不超过 1 米,远处障碍主要由全局代价地图用膨胀方式处理。相机分辨率我压到 640x480,能看清目标点画面即可,这直接影响后面仿真长时间运行的帧率。
2.3 启动世界的 launch:把机器人拖进仿真
有了 URDF 和插件参数,下一步是把机器人放进 Gazebo 世界。这一步的 launch 文件决定了你每次调试的起点。下面是一个常见做法,用 python 写 launch 文件:
import launch from launch_ros.actions import Node from launch.actions import ExecuteProcess def generate_launch_description(): return launch.LaunchDescription([ ExecuteProcess( cmd=['gazebo', '--verbose', '-s', 'libgazebo_ros_factory.so'], output='screen' ), Node( package='gazebo_ros', executable='spawn_entity.py', arguments=['-topic', 'robot_description', '-entity', 'patrol_robot', '-x', '0.0', '-y', '0.0', '-z', '0.1'], output='screen' ), Node( package='robot_state_publisher', executable='robot_state_publisher', parameters=[{'robot_description': robot_description}] ), ])这里 robot_description 变量由 xacro 解析得到,标准写法是xacro model.xacro的输出存成字符串再传进参数。关键点是-topic robot_description而不是直接传 urdf 文件路径,这样机器人模型只维护一份,后续改 xacro 重新生成即可。z 取 0.1 是因为 Gazebo 的碰撞模型和地面之间有间隙,完全贴地会抖。启动后应该看到三个话题开始发布:/scan、/camera/image_raw、/imu/data,说明传感器链路已经通了,没通的话先回去查 2.1 里的 joint 命名。这一章跑通,后面的运动控制和导航才有数据可用。
3. 运动控制与多传感器融合:从 cmd_vel 到稳定 odom 的实线路径
导航栈最终是通过发布cmd_vel让机器人动的,所以运动控制这一层是 Navigation2 和仿真世界之间的最后一公里。在这个仿真系统里,底盘一般是差速驱动(两轮加万向轮),控制方式有两种:直接在 URDF 里挂 gazebo_ros_diff_drive 插件,或者走 ros2_control 的 diff_drive_controller。我偏向前者,因为巡检仿真不需要复杂的硬件抽象层,插件方式启动快、话题直出,少一套 controller_manager 的配置文件,出问题好查。
3.1 差速底盘插件:cmd_vel 怎么转成轮速,odom 和 tf 谁负责发
gazebo_ros_diff_drive 插件直接订阅/cmd_vel,根据差速模型算出左右轮转速,然后发布里程计话题和 odom 到 base_footprint 的 tf。URDF 里挂载在 base_link 上的插件配置大致如下:
<gazebo> <plugin name="diff_drive" filename="libgazebo_ros_diff_drive.so"> <ros> <namespace>/</namespace> <remapping>cmd_vel:=cmd_vel</remapping> <remapping>odom:=odom</remapping> </ros> <left_joint>left_wheel_joint</left_joint> <right_joint>right_wheel_joint</right_joint> <wheel_separation>0.28</wheel_separation> <wheel_diameter>0.08</wheel_diameter> <max_wheel_torque>10.0</max_wheel_torque> <max_wheel_acceleration>1.0</max_wheel_acceleration> <publish_odom>true</publish_odom> <publish_odom_tf>true</publish_odom_tf> </plugin> </gazebo>wheel_separation和wheel_diameter必须和 URDF 里的轮距、轮径一致,否则里程计会按错误的比例积分,机器人明明直走,odom 里却显示在转圈。publish_odom_tf设为 true 时,插件会发布 odom 到 base_footprint 的 tf,如果后面发现 rviz 里机器人模型飞了,先看这一项是不是 false 或者被谁抢发了同一段 tf。这里还要注意 remapping 的命名要和 Navigation2 期望的话题一致,nav2 默认发的就是cmd_vel,不需要额外改。
3.2 多传感器融合:robot_localization 的 EKF 参数怎么填
仿真里轮式里程计在打滑、急转时会有明显漂移,单靠 odom 做位姿估计,Navigation2 的 AMCL 会经常找不到北。这个资源里的多传感器融合走的是 robot_localization 的 EKF 节点,把 odom 和 IMU 揉成一个/odometry/filtered。配置文件的几个关键项:
ekf_filter_node: ros__parameters: frequency: 30.0 two_d_mode: true odom0: /odom odom0_config: [true, true, false, false, false, false, false, false, false, false, false, true, false, false, false] imu0: /imu/data imu0_config: [false, false, false, true, true, true, false, false, false, false, false, false, false, false, false] process_noise_covariance: [0.05, 0.0, 0.0, 0.0, 0.05, 0.0, 0.0, 0.0, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]长数组是 robot_localization 的 15 维配置,每个 true/false 表示该传感器是否提供对应状态的观测。odom0_config 里 x、y、yaw 开了,z 轴和 roll/pitch 全关,这是 two_d_mode 下防止雷达和地面不平整把高度带偏的标准做法。IMU 只给 roll、pitch、yaw 三个姿态角,不做位置观测,避免 IMU 的零偏直接污染平面位置。process_noise_covariance前三个值(x、y、theta 的过程噪声)我习惯取 0.05,如果机器人起步刹车急,这个值要调大到 0.1,否则滤波结果会过度相信模型、跟不上实际运动。
3.3 融合验收:别等导航翻车才检查 tf 树
EKF 配好之后,先跑一组数据看结果再进导航。两个命令是必做的:ros2 topic echo /odometry/filtered --once看输出的 x、y、yaw 是不是在合理范围,ros2 run tf2_tools view_frames生成 tf 树 PDF,确认 map、odom、base_footprint、laser_frame、camera_link 都在一条链路里。最常见的翻车是 IMU 和 odom 的 frame_id 写错——一个叫 base_link 一个叫 base_footprint,EKF 直接拒绝融合并刷 Transform error。这一章的判断标准是:手动发几个 cmd_vel 速度,rviz 里机器人前进后退转弯,odom 走向和实际一致,IMU 融合后姿态不跳变。到这一步,导航栈才有资格启动。
4. 路径规划与自主避障:Navigation2 配置和循环巡航脚本
前面把模型、传感器、运动控制都备齐了,这一章进入标题里的核心:路径规划和自主避障。Navigation2 不是单个节点,它是一组服务的组合:map_server 提供地图,AMCL 在地图上定位,planner_server 算全局路径,controller_server 做局部避障,BT Navigator 调度行为树。调试时不要只盯某一个节点,要先建立链路的观念——前面 tf、代价地图、定位任何一个环节断了,表现都是规划不出来或者机器人不动。
4.1 导航栈启动链路:map_server、AMCL、planner、controller 各管一段
以一张建好的静态地图作为输入(slam_toolbox 或 gmapping 生成),nav2 的启动顺序很有讲究:先起 map_server 和 AMCL,等map话题有数据后再起 planner 和 controller,否则代价地图在空地图上初始化,后面加载地图也不会自动刷新。这里给一个简化版 launch 核心段:
map_server = Node( package='nav2_map_server', executable='map_server', parameters=[{'yaml_filename': 'map.yaml'}], output='screen' ) amcl = Node( package='nav2_amcl', executable='amcl', parameters=['amcl_params.yaml'], output='screen' ) planner = Node( package='nav2_planner', executable='planner_server', parameters=['nav2_params.yaml'], output='screen' ) controller = Node( package='nav2_controller', executable='controller_server', parameters=['nav2_params.yaml'], output='screen' ) bt_navigator = Node( package='nav2_bt_navigator', executable='bt_navigator', parameters=['nav2_params.yaml'], output='screen' )四个节点共用一份 nav2_params.yaml 是导航里的常见做法,每个节点只取自己需要的参数段。AMCL 的 yaml 里要显式给出base_frame_id: base_footprint和odom_frame_id: odom,这两个值如果和前面机器人模型的 tf 不一致,AMCL 会直接罢工。planner 和 controller 的区别在于:planner 算的是全局静态路径,controller 才是真正避障的执行者,两个角色各配一份代价地图参数,不能互相混用。
4.2 代价地图参数:robot_radius、inflation、传感器源怎么配
代价地图是导航栈对环境的几何理解,它把传感器数据(激光、点云)投影到地图上并做膨胀。nav2_params.yaml 里的核心片段:
local_costmap: local_costmap: ros__parameters: robot_radius: 0.18 inflation_radius: 0.35 cost_scaling_factor: 3.0 observation_sources: scan scan: topic: /scan sensor_frame: laser_frame data_type: LaserScan marking: true clearing: true global_costmap: global_costmap: ros__parameters: robot_radius: 0.18 inflation_radius: 0.5 track_unknown_space: truerobot_radius要和 URDF 里底盘实际包络尺寸匹配,0.18 对应差速底盘半个车宽加一点余量。inflation_radius是膨胀半径,全局代价地图设 0.5、局部设 0.35 是巡检场景的常用搭配:全局路径离障碍远一点留余量;局部因为要过窄道,膨胀太大反而绕路。cost_scaling_factor决定代价随距离衰减的曲线,3.0 是默认值,越小衰减越慢,机器人会离障碍越远。如果模型里还挂了深度相机想参与避障,就需要再加一个 rgbd 源,topic 指向点云话题,data_type 设 PointCloud2,并把 sensor_frame 设为相机帧。多传感器融合在这一层的含义不是 EKF 那种数学融合,而是让代价地图同时消费多个传感器源,取并集后再膨胀。
4.3 目标点循环导航:用 action client 写巡航任务队列
这是巡检机器人和普通点到点导航最不一样的地方:要的是按固定顺序循环访问多个目标点,而不是只去一个点。Navigation2 的 NavigateToPose 是 action 接口,巡检代码用一个 Python 节点维护目标点队列,逐个发送、等待结果、出错重试。核心逻辑如下:
import rclpy from rclpy.node import Node from nav2_msgs.action import NavigateToPose from rclpy.action import ActionClient from geometry_msgs.msg import PoseStamped class PatrolNode(Node): def __init__(self): super().__init__('patrol_node') self.client = ActionClient(self, NavigateToPose, 'navigate_to_pose') self.waypoints = [ (1.5, 0.8, 0.0), (2.5, 2.5, 1.57), (0.5, 2.8, 3.14), ] self.current_idx = 0 def send_goal(self, x, y, yaw): goal = NavigateToPose.Goal() goal.pose.header.frame_id = 'map' goal.pose.pose.position.x = float(x) goal.pose.pose.position.y = float(y) goal.pose.pose.orientation.z = float(yaw) self.client.send_goal_async(goal) def patrol(self): self.send_goal(*self.waypoints[self.current_idx]) self.current_idx = (self.current_idx + 1) % len(self.waypoints)注意 frame_id 必须是map,不是odom,因为目标点是地图坐标系下的绝对坐标。yaw 我这里用四元数 z 分量近似,纯二维巡航场景够用,要严格姿态就把它转成完整四元数。send_goal_async返回后立刻发下一个目标会覆盖上一个,正确做法是等 action 的 done 回调里再发下一个;循环索引用取模实现循环,这就是标题里目标点循环导航的最小实现。实际工程里还要加一个等待服务器可用的逻辑,否则机器人刚启动导航栈还没就绪就发 goal 会直接失败。
4.4 避障不是玄学:DWB 控制器的几个关键参数
自主避障在 Navigation2 里由 controller_server 执行,DWB 是默认局部规划器之一。仿真里避障失败通常不是算法问题,而是参数给得太激进。我在这个系统里常用的几个值:
| 参数 | 作用 | 踩过坑的值 | 建议值 |
|---|---|---|---|
| max_vel_x | 最大前进速度 | 0.5 容易刹不住 | 0.22 m/s |
| min_speed_xy | 最低线速度 | 0.01 导致刹车抖动 | 0.05 m/s |
| path_distance_bias | 沿全局路径权重 | 默认值在窄道会贴墙 | 4.0 |
| goal_distance_bias | 朝目标点权重 | 5.0 会切近路撞障碍 | 3.0 |
DWB 的权重值配出来之后,先在 rviz 里手动给一个故意绕障碍的目标点试,观察局部路径是否稳定收敛。如果机器人前后抖动,先降 max_vel_x,再调大 min_speed_xy,不要一上来就重写 DWB 的轨迹打分函数,仿真里八成是速度约束和权重失衡。避障参数调完再看全局路径是否穿过障碍区,如果全局路径穿墙,问题出在 4.2 的全局代价地图,而不是避障本身。
5. 避坑排查:Navigation2 仿真最容易翻车的五个现场
这一章不写抽象理论,只写我复现这套仿真系统时真实踩过的五个坑。所有问题都遵循同一个排查思路:先看现象,再定位原因,最后给解决。强烈建议你把这章的五个问题按顺序过一遍,能避开我在仿真里踩掉的八成雷。
5.1 启动阶段的硬伤:tf 树断裂与地图全灰
现象 1:launch 起来后 Rviz 里找不到机器人模型,或者模型悬在半空乱抖。原因:多数情况是 tf 树缺了 odom 到 base_footprint 这一段。模型显示依赖 robot_state_publisher 发布的关节状态,而模型要在导航坐标系下站稳,必须有一条完整 tf 链路从 map 或 odom 连到 base_footprint。diff_drive 插件如果没启动,或者 publish_odom_tf 设成了 false,这一段就直接断掉。解决:先ros2 run tf2_ros tf2_echo odom base_footprint,如果报 No transform,去检查 URDF 里 diff_drive 插件是否加载成功、话题名有没有重映射。配置正确的话这条命令会持续输出平移和旋转量。从那以后我每次启动环境第一件事就是 tf2_echo,而不是急着开导航。
现象 2:map_server 启动正常,但 map 话题发布出来的地图全灰、没有障碍轮廓。原因:nav2_map_server 加载 yaml 时需要图片路径相对 yaml 文件路径正确,这是一个经典相对路径坑。yaml_filename 里给的相对路径是相对 launch 文件的工作目录,不是 map.yaml 所在目录,所以 launch 换个目录启动就找不到图片。我见过有人 map.yaml 里这样写:
image: map.png resolution: 0.05 origin: [-10.0, -10.0, 0.0] negate: 0 occupied_thresh: 0.65 free_thresh: 0.196image 这一行用的是相对路径,工作目录一变就挂。解决:所有地图文件用绝对路径,或确保 launch 的工作目录固定。判断标准是ros2 topic echo /map --once能看到 data 数组非空、有黑色像素点,说明地图真正加载了;如果 data 全是 0 或者全灰,就是图片没读进来。
5.2 定位与代价地图:初始位姿与传感器订阅
现象 3:AMCL 能跑,但导航时机器人频繁重新定位、路径忽左忽右,甚至在原地转圈。原因:AMCL 需要初始位姿。静态地图导航和 SLAM 不一样,没有激光匹配的历史地图,如果不给 initial pose,粒子池初始分布是全图均匀的,定位收敛要花很长时间,表现就是走走停停、位姿跳变。解决:在 launch 里给 AMCL 参数直接设初始位姿,或者在 rviz 里手动用 2D Pose Estimate 指定一次。注意初始位姿要尽量准,给反了比不给还糟,粒子会先聚到一个错误位置,后期很难收敛回来。
现象 4:代价地图上能看到障碍轮廓,但 planner 算出来的路径照样穿墙,或者局部避障一点反应都没有。原因:代价地图的 obstacle_layer 没有收到传感器数据。检查两个点:一是 observation_sources 的 topic 名是否和实际发布的激光话题一致,二是 sensor_frame 是否填对。常见错误是话题名写错但 rviz 的 LaserScan 显示用的是另一个订阅,看起来有数据,实际上代价地图层的订阅是空的。解决:用ros2 topic info /scan看发布者和订阅者数量,如果订阅者是 0,说明代价地图根本没配对这个话题;再ros2 param get /local_costmap/obstacle_layer enabled确认 layer 开关是 true,两个都正常了,穿墙问题基本消失。
5.3 巡航调度:目标点到达了却不结束
现象 5:机器人已经走到目标点,但 navigate_to_pose 的 action 一直不返回,循环导航卡在第一个点。原因:Navigation2 判断目标点完成用的不是简单到达半径,而是路径完成加姿态容差。如果 xy_goal_tolerance 设得太小,比如 0.05 米,仿真里位置控制有误差,机器人会一直在目标点附近微调,永远达不到容差。另外 action 的 done 回调里如果没有正确消费 result,下一个 goal 会排队但发不出去,巡航就死在第一个点。解决:把 xy_goal_tolerance 调到 0.1 到 0.15 米,yaw_goal_tolerance 调到 0.1 弧度左右,巡检这种多目标巡航不需要精确到厘米级;同时在 done 回调里处理 result 状态,确认 status 为成功或失败后再发下一个目标,失败则重试当前点或跳到下一个点。这样循环队列才转得起来。
这五个坑从启动、定位、代价地图到巡航调度覆盖了完整链路,按顺序排一遍,能避免我在仿真里反复翻车的绝大多数场景。
6. 进阶技巧:把语音播报与图像采集接进巡航状态机
循环导航只是解决能走,巡检要解决的是到点做事。这套系统里语音播报和图像采集,本质上是导航任务的两个动作回调。我一般会在巡航节点上再加一段:到达目标点后,先触发图像采集,保存当前画面;再触发语音播报,提示到达哪个巡检点。下面是一个状态回调的骨架:
def goal_done(self, future): result = future.result() if result.status == 4: self.capture_image() self.speak('到达巡检点 %d' % self.current_idx) self.patrol()capture_image 的做法是订阅/image_raw,用 OpenCV 把当前帧写到带时间戳的文件名;speak 在仿真里没有真实喇叭,常见做法是接一个 TTS 节点把文本发到 /speech 话题,同时打印日志,后续换真机时只替换这个节点的实现,状态机代码不用动。关于验证,别在仿真里等语音真响,看 /speech 话题有没有文本输出,再看保存的图片是不是目标点视角,两个信号都对了,巡航闭环才算完整。
这套仿真包我拆完后,把 launch、nav2 参数和巡航脚本都按注释版整理好放在里面,想省掉自己搭 Gazebo 世界和导航栈配置这堆体力活的话,可以直接拿去改。从那以后我每次做巡检项目,都会先按模型挂载、传感器参数、EKF、代价地图、巡航状态机这个顺序强制走一遍,跑通了再谈算法优化,希望帮到你。
本文还有配套的精品资源,点击获取