简介:这是一套面向ROS机器人开发初学者与进阶者的自主移动机器人导航系统实践资源,围绕室内外场景的路径规划与避障需求,整合了Gazebo仿真与实体小车控制两条验证路径。包内共21个文件,以yaml参数配置、launch启动脚本、rviz可视化配置为主,辅以地图pgm、说明文档与md笔记,压缩包约66KB,结构紧凑便于快速部署。系统集成激光雷达SLAM建图、IMU姿态融合与摄像头视觉感知,并采用全局与局部代价结合的规划策略,兼顾路径最优与环境动态响应。读者可借助仿真环境在无硬件条件下完成导航算法调试,再迁移至实体小车验证速度与转向控制,同时参考地图与rviz配置理解建图定位流程。目前已有197人学习下载,适合希望系统掌握ROS导航栈、SLAM与多传感器融合的开发者对照实践。
1. 从一堆散装模块到一台会自己走路的机器人
室内走廊里放一台差速小车,让它自己从工位跑到茶水间再绕回来,这件事听起来简单,真正动手才会发现坑比想象中多。激光雷达给的点云要配准成地图,IMU 的角速度积分几分钟就漂到姥姥家,摄像头识别到的障碍物得换算到全局坐标系才有意义,而全局规划器算出来的路径如果没经过局部代价地图的二次修正,小车大概率会贴着桌角蹭过去。这套基于 ROS 的自主移动机器人导航系统,本质上就是把 SLAM 建图、IMU 姿态融合、视觉感知、全局与局部代价地图这几块拼成一个闭环,先在 Gazebo 仿真环境里跑通,再部署到实体小车上验证。它适合已经写过几个 ROS 节点、但还没把整条导航链路串起来的开发者,也适合想把仿真和实车对齐的团队。下面按我实际搭这套系统的顺序,把每一步的选择理由、参数含义和翻车点讲清楚。
2. 仿真环境搭建:Gazebo 里先把代价地图跑通
2.1 为什么先在 Gazebo 里做而不是直接上实车
实车调试最怕的是「不知道是算法错了还是硬件坏了」。Gazebo 的价值在于它把激光雷达、IMU、摄像头、轮式里程计全部虚拟化,每个传感器的噪声模型、更新频率、坐标系变换都可以精确控制。我一般会先在仿真里把move_base的全局和局部代价地图调到能稳定绕障,再把这套参数原样搬到实车上,只改传感器话题名和 TF 外参。这样实车出问题时,至少能确定算法链路本身是通的。
仿真环境的核心是 URDF/Xacro 描述文件,它定义了小车的连杆、关节、碰撞体,以及每个传感器相对于base_link的安装位置。激光雷达装在车顶中心,IMU 装在底盘几何中心,摄像头朝前略微下倾。这些外参在仿真里是精确值,到了实车就得靠卷尺和标定板重新量。
2.2 用 Xacro 描述小车并挂载三类传感器
下面是一个简化版的差速小车 Xacro 片段,包含底盘、两个驱动轮、一个万向轮,以及激光雷达和 IMU 的挂载。实际项目里摄像头会用gazebo_ros_camera插件单独配置。
<?xml version="1.0"?> <robot name="diff_bot" xmlns:xacro="http://www.ros.org/wiki/xacro"> <!-- 底盘主体:圆柱形,半径0.2m,高0.15m --> <link name="base_link"> <visual> <geometry><cylinder radius="0.2" length="0.15"/></geometry> <material name="gray"><color rgba="0.5 0.5 0.5 1"/></material> </visual> <collision> <geometry><cylinder radius="0.2" length="0.15"/></geometry> </collision> <inertial> <mass value="8.0"/> <inertia ixx="0.1" ixy="0" ixz="0" iyy="0.1" iyz="0" izz="0.15"/> </inertial> </link> <!-- 左驱动轮:连续关节,绕Y轴旋转 --> <joint name="left_wheel_joint" type="continuous"> <parent link="base_link"/> <child link="left_wheel"/> <origin xyz="0 0.22 -0.05" rpy="0 0 0"/> <axis xyz="0 1 0"/> </joint> <link name="left_wheel"> <visual> <geometry><cylinder radius="0.05" length="0.03"/></geometry> </visual> <collision> <geometry><cylinder radius="0.05" length="0.03"/></geometry> </collision> <inertial><mass value="0.5"/><inertia ixx="0.001" iyy="0.001" izz="0.001"/></inertial> </link> <!-- 激光雷达:固定在车顶中心,扫描平面略高于底盘 --> <joint name="lidar_joint" type="fixed"> <parent link="base_link"/> <child link="laser_frame"/> <origin xyz="0 0 0.12" rpy="0 0 0"/> </joint> <link name="laser_frame"> <visual><geometry><cylinder radius="0.03" length="0.04"/></geometry></visual> <inertial><mass value="0.1"/><inertia ixx="0.0001" iyy="0.0001" izz="0.0001"/></inertial> </link> <!-- IMU:装在底盘几何中心,方向与base_link一致 --> <joint name="imu_joint" type="fixed"> <parent link="base_link"/> <child link="imu_frame"/> <origin xyz="0 0 0.02" rpy="0 0 0"/> </joint> <link name="imu_frame"/> </robot>这段描述里最关键的是origin的xyz和rpy。激光雷达的z=0.12决定了它扫描平面离地高度,如果设得太低,地面反射会引入大量噪点;IMU 的z=0.02尽量贴近底盘中心,减少旋转时的离心加速度干扰。轮子的y=±0.22是轮距的一半,这个值直接参与差速运动学解算,量错了里程计会系统性偏航。
2.3 启动仿真并验证话题与 TF 树
写完 Xacro 后,用gazebo_ros的spawn_model把机器人加载进空世界,同时启动robot_state_publisher发布 TF。下面这条 launch 文件把仿真、状态发布、传感器插件一次性拉起来。
<launch> <!-- 加载Gazebo空世界,关闭默认地面反射噪声 --> <include file="$(find gazebo_ros)/launch/empty_world.launch"> <arg name="world_name" value="$(find diff_bot)/worlds/indoor.world"/> <arg name="paused" value="false"/> <arg name="use_sim_time" value="true"/> </include> <!-- 把Xacro解析成URDF并推送到参数服务器 --> <param name="robot_description" command="$(find xacro)/xacro $(find diff_bot)/urdf/diff_bot.xacro"/> <!-- 在Gazebo中生成机器人,初始位姿在原点 --> <node name="spawn_urdf" pkg="gazebo_ros" type="spawn_model" args="-param robot_description -urdf -model diff_bot -x 0 -y 0 -z 0.1"/> <!-- 发布关节状态和TF变换 --> <node name="robot_state_publisher" pkg="robot_state_publisher" type="robot_state_publisher"/> <node name="joint_state_publisher" pkg="joint_state_publisher" type="joint_state_publisher"/> </launch>启动后先别急着跑导航,用rostopic list确认/scan、/imu/data、/camera/image_raw三个话题都在,再用rosrun tf view_frames生成 TF 树 PDF,检查base_link到laser_frame、imu_frame的变换是否存在。我见过最常见的翻车是激光雷达的 TF 没发出来,move_base直接报「Timed out waiting for transform」,这时候回头查 Xacro 里laser_frame的 joint 是不是写成了floating而不是fixed。
3. 激光雷达 SLAM 建图:从点云到可导航栅格地图
3.1 选 gmapping 还是 cartographer
室内小场景用gmapping就够了,它基于粒子滤波,计算量小,在差速小车上跑起来很稳。cartographer更适合大场景和回环多的环境,但它对 IMU 和里程计的融合要求更高,调参也更玄学。我一般先在gmapping上把地图建出来,确认激光雷达安装角度和扫描范围没问题,再考虑要不要换cartographer做优化。
gmapping的核心参数是粒子数和地图分辨率。粒子数太少,机器人快速转向时地图会糊;粒子数太多,CPU 直接跑满。分辨率决定了栅格的精细程度,0.05m 是室内导航的常用值,再细对路径规划帮助不大,反而让代价地图膨胀变慢。
3.2 gmapping 关键参数配置与建图流程
下面是一份gmapping的 launch 配置,重点看delta、particles、linearUpdate和angularUpdate四个参数。
<launch> <node pkg="gmapping" type="slam_gmapping" name="slam_gmapping" output="screen"> <!-- 激光雷达话题名,与仿真/实车保持一致 --> <remap from="scan" to="/scan"/> <!-- 地图分辨率:0.05m/栅格,室内导航够用 --> <param name="delta" value="0.05"/> <!-- 粒子数:30个,小场景足够,大场景可加到50 --> <param name="particles" value="30"/> <!-- 里程计噪声模型:差速小车常用值 --> <param name="srr" value="0.1"/> <param name="srt" value="0.2"/> <param name="str" value="0.1"/> <param name="stt" value="0.2"/> <!-- 更新阈值:移动0.3m或转0.3rad才做一次地图更新 --> <param name="linearUpdate" value="0.3"/> <param name="angularUpdate" value="0.3"/> <!-- 激光雷达有效距离和角度范围 --> <param name="maxRange" value="8.0"/> <param name="minRange" value="0.12"/> <param name="maxUrange" value="7.5"/> </node> </launch>linearUpdate和angularUpdate设得太小,机器人原地抖动也会触发地图更新,地图边缘会出现重影;设得太大,快速转弯时地图来不及更新,墙角会缺一块。0.3 是我在轮距 0.44m 的小车上试出来的平衡点。maxUrange比maxRange略小,是为了避开激光雷达最远端的低置信度数据。
建图时用键盘遥控小车绕场景走一圈,速度控制在 0.2m/s 以内,转弯时慢一点。走完后用rosrun map_server map_saver -f my_map保存地图,会得到my_map.pgm和my_map.yaml两个文件。打开 pgm 检查一下,如果墙壁边缘呈锯齿状,说明里程计噪声参数偏大,把srr和str调小再重建。
3.3 地图保存后必须检查的三个地方
第一,看my_map.yaml里的resolution是否和delta一致,不一致会导致导航时坐标缩放错误。第二,看origin的yaw是否为 0,如果建图起点不是正方向,后续全局路径会整体旋转。第三,用图像工具打开 pgm,确认没有大面积灰色未知区域,灰色区域太多说明激光雷达扫描覆盖不够,需要补走几圈。
4. IMU 姿态融合与视觉感知:让小车知道自己歪没歪、前面有没有东西
4.1 为什么单靠轮式里程计不够
差速小车的轮式里程计在直行时还算准,一旦频繁转向,轮子打滑和地面摩擦差异会让航向角快速漂移。IMU 的陀螺仪能提供短时间高精度的角速度,但积分几分钟后零偏会累积成几十度的误差。常见做法是用robot_localization包里的扩展卡尔曼滤波,把轮式里程计和 IMU 融合成一条平滑的odom到base_link的变换。
融合的关键是配置ekf_localization_node的输入话题和协方差矩阵。轮式里程计提供x、y、yaw和对应速度,IMU 提供yaw角速度和linear_acceleration。如果 IMU 没有磁力计,就不要让它提供绝对yaw,只取角速度做积分修正。
4.2 robot_localization 融合配置与参数含义
下面是一份 EKF 配置文件,只融合轮式里程计和 IMU 的角速度,不启用 IMU 的绝对朝向。
# ekf.yaml ekf_filter_node: ros__parameters: frequency: 30.0 # 滤波输出频率,与传感器更新率匹配 sensor_timeout: 0.1 # 传感器超时,超过则不再等待 two_d_mode: true # 二维模式,忽略z轴和俯仰横滚 # 里程计输入:x, y, yaw 及对应速度 odom0: /wheel_odom odom0_config: [true, true, false, false, false, true, true, true, false, false, false, true, false, false, false] odom0_differential: false odom0_relative: false # IMU输入:只取yaw角速度,不取绝对朝向 imu0: /imu/data imu0_config: [false, false, false, false, false, false, false, false, false, false, false, true, false, false, false] imu0_differential: false imu0_relative: true # 输出到odom到base_link的变换 publish_tf: true map_frame: map odom_frame: odom base_link_frame: base_link world_frame: odomodom0_config里前三个true表示使用里程计的x、y、yaw,中间三个true表示使用对应的线速度和角速度。imu0_config里只有角速度的true,绝对朝向全部false,这是为了避免没有磁力计时 IMU 的yaw缓慢漂移污染融合结果。two_d_mode在室内平地场景必须开,否则 IMU 的俯仰和横滚噪声会耦合进平面运动。
4.3 摄像头视觉感知的最小可用方案
视觉感知不需要一上来就做深度学习,先用颜色阈值和轮廓检测把红色锥桶或绿色地标找出来,换算成base_link坐标系下的障碍物点,插入局部代价地图。下面这段 Python 代码用 OpenCV 做红色区域检测,并发布障碍物点。
#!/usr/bin/env python3 import rospy import cv2 import numpy as np from sensor_msgs.msg import Image from geometry_msgs.msg import PointStamped from cv_bridge import CvBridge class RedObstacleDetector: def __init__(self): self.bridge = CvBridge() # 订阅摄像头图像,队列长度1避免延迟累积 self.sub = rospy.Subscriber('/camera/image_raw', Image, self.callback) # 发布障碍物在base_link下的坐标 self.pub = rospy.Publisher('/visual_obstacle', PointStamped, queue_size=10) # 摄像头内参:焦距和光心,实车需标定后替换 self.fx, self.fy = 320.0, 320.0 self.cx, self.cy = 320.0, 240.0 def callback(self, msg): frame = self.bridge.imgmsg_to_cv2(msg, 'bgr8') hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) # 红色在HSV中跨越0和180,分两段提取 mask1 = cv2.inRange(hsv, (0, 100, 100), (10, 255, 255)) mask2 = cv2.inRange(hsv, (170, 100, 100), (180, 255, 255)) mask = mask1 | mask2 contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return # 取最大轮廓,过滤噪点 c = max(contours, key=cv2.contourArea) if cv2.contourArea(c) < 500: return M = cv2.moments(c) u = M['m10'] / M['m00'] v = M['m01'] / M['m00'] # 假设障碍物贴地,用相似三角形估算距离 # 实际项目中需要结合相机安装高度和俯仰角做完整投影 depth = 1.0 # 占位,实车用测距或已知尺寸反推 x = (u - self.cx) * depth / self.fx y = (v - self.cy) * depth / self.fy pt = PointStamped() pt.header.frame_id = 'base_link' pt.header.stamp = rospy.Time.now() pt.point.x = depth pt.point.y = -x pt.point.z = 0.0 self.pub.publish(pt) if __name__ == '__main__': rospy.init_node('red_obstacle_detector') RedObstacleDetector() rospy.spin()这段代码里depth是占位值,实际项目中要么用双目或深度相机直接拿深度,要么假设障碍物高度已知,用像素高度反推距离。pt.point.y = -x是因为图像坐标系u向右,而base_link的y向左,需要取反。发布出来的点会被局部代价地图的obstacle_layer订阅,作为动态障碍物插入。
5. 全局与局部代价地图:路径规划的参数怎么调才不撞墙
5.1 全局代价地图和局部代价地图的分工
全局代价地图基于map坐标系,用静态地图层和膨胀层生成,负责给全局规划器提供从起点到终点的粗略路径。局部代价地图基于odom坐标系,除了静态层和膨胀层,还叠加了激光雷达实时障碍物层和视觉障碍物层,负责给局部规划器提供避障所需的实时信息。两者共用一套膨胀参数,但局部地图的更新频率更高。
全局规划器常用global_planner或navfn,局部规划器常用teb_local_planner或dwa_local_planner。teb在窄通道里表现更好,但参数多、调参玄学;dwa参数少、稳定,适合先跑通。我一般先用dwa把整条链路跑顺,再换teb优化窄道通过性。
5.2 costmap_common 参数与膨胀半径的取舍
下面是一份costmap_common_params.yaml,定义了障碍物层、膨胀层和机器人 footprint。
# costmap_common_params.yaml obstacle_range: 3.0 # 激光雷达障碍物插入的最大距离 raytrace_range: 3.5 # 清除空闲空间的最大距离 footprint: [[-0.25, -0.22], [-0.25, 0.22], [0.25, 0.22], [0.25, -0.22]] # footprint是矩形,比圆形更贴合差速小车,减少不必要的膨胀 obstacle_layer: enabled: true combination_method: 1 # 取最大值,避免障碍物被覆盖 observation_sources: scan visual scan: topic: /scan data_type: LaserScan marking: true # 允许标记障碍物 clearing: true # 允许清除空闲区域 max_obstacle_height: 2.0 min_obstacle_height: 0.0 visual: topic: /visual_obstacle data_type: PointCloud2 marking: true clearing: false # 视觉障碍物只标记不清除,避免误删 inflation_layer: enabled: true cost_scaling_factor: 5.0 # 代价衰减系数,越大衰减越快 inflation_radius: 0.55 # 膨胀半径,约为机器人半宽+0.1m安全余量inflation_radius是最容易翻车的参数。设得太小,小车会贴着墙走,稍微有点定位误差就撞上;设得太大,窄通道直接变成不可通行。0.55m 是轮距 0.44m 小车的常用值,实际要按footprint的最大外接圆半径加 0.1m 安全余量来算。cost_scaling_factor控制代价从障碍物向外衰减的速度,5.0 表示衰减较快,路径倾向于远离障碍物但不会过度绕行。
5.3 局部规划器 dwa 的关键参数与速度限制
下面是一份dwa_local_planner_params.yaml,重点看max_vel_x、min_vel_x、acc_lim_x和xy_goal_tolerance。
# dwa_local_planner_params.yaml DWAPlannerROS: max_vel_x: 0.4 # 最大前进速度,室内建议不超过0.5 min_vel_x: -0.1 # 允许小幅后退,用于脱困 max_vel_y: 0.0 # 差速小车无横向速度 max_vel_theta: 1.0 # 最大角速度 min_vel_theta: 0.2 acc_lim_x: 0.5 # 线加速度限制,防止急冲 acc_lim_theta: 1.0 # 角加速度限制,防止原地猛转 xy_goal_tolerance: 0.15 # 到达目标点的位置容差 yaw_goal_tolerance: 0.1 # 到达目标点的朝向容差 latch_xy_goal_tolerance: true # 到达后锁定容差,避免反复调整 sim_time: 1.5 # 前向仿真时间,太短避障不及,太长反应迟钝 sim_granularity: 0.05 # 仿真步长 vx_samples: 10 # 线速度采样数 vtheta_samples: 20 # 角速度采样数 path_distance_bias: 32.0 # 路径跟随权重 goal_distance_bias: 24.0 # 目标接近权重 occdist_scale: 0.02 # 障碍物代价权重,太大会不敢走sim_time设 1.5 秒意味着规划器会预测未来 1.5 秒的运动轨迹,如果小车速度是 0.4m/s,相当于看前方 0.6m。在狭窄走廊里这个值够用,但在开阔区域可以加到 2.0 秒让路径更平滑。occdist_scale是避障激进程度的开关,0.02 表示障碍物代价权重较低,小车会贴着障碍物走但不会撞;如果发现小车在障碍物前犹豫不决,把这个值调到 0.05 以上。
5.4 全局规划器与局部规划器的衔接验证
启动move_base后,在 RViz 里先给一个 2D Nav Goal,观察全局路径是否从起点连到终点,再观察局部路径是否贴合全局路径且避开实时障碍物。如果全局路径正常但小车不动,检查局部规划器是否报「No valid trajectories」,这通常是inflation_radius太大导致所有采样轨迹都撞上膨胀层。如果小车走一半停下,检查xy_goal_tolerance是否设得太小,定位误差导致永远到不了目标点。
6. 避坑与排查:仿真到实车最容易翻车的五个地方
6.1 现象:仿真里跑得好好的,实车一启动就原地转圈
原因通常是 IMU 的安装方向与base_link不一致。仿真里 IMU 的rpy是精确的 0,实车装上去可能有几度偏差,导致融合后的航向角持续偏转。解决方法是先用rostopic echo /imu/data看静止时的角速度输出,如果z轴不为零,在 EKF 配置里加一个静态变换节点,或者直接在 IMU 驱动里做零偏校准。
6.2 现象:激光雷达建图时墙壁出现双层重影
原因是轮式里程计的srr和str噪声参数与实际打滑程度不匹配。仿真里轮子不打滑,这两个值可以设很小;实车在地毯上转向时打滑严重,需要把srr调到 0.2 以上。另一个可能是激光雷达的 TF 外参z值不准,扫描平面倾斜导致同一面墙在不同角度下测距不一致。用水平仪确认雷达安装面水平,再重新量z值。
6.3 现象:局部代价地图里视觉障碍物一闪一闪
原因是视觉检测的发布频率和局部代价地图的更新频率不匹配。摄像头 30fps 发布,代价地图 5Hz 更新,中间会丢帧。解决方法是加一个costmap_converter或者简单的缓存节点,把视觉障碍物保持 0.5 秒再发布,避免瞬时漏检导致障碍物消失。另外visual层的clearing要设为false,否则代价地图会在没有新视觉数据时把障碍物清掉。
6.4 现象:小车在窄通道口反复前进后退,进不去
这是dwa的经典问题,sim_time太短导致规划器看不到通道内部的可行路径,或者inflation_radius把通道两侧的膨胀层连成一片,通道直接变成高代价区域。先把inflation_radius降到刚好不撞墙的值,再把sim_time加到 2.0 秒,同时把path_distance_bias调高到 40 以上,让小车更倾向于跟随全局路径而不是自己找路。
6.5 现象:实车导航时突然报 TF 超时,小车急停
原因是某个传感器的 TF 发布频率掉下去了,常见于 USB 摄像头带宽不足或激光雷达数据量过大导致 CPU 卡顿。用rostopic hz /scan和rostopic hz /imu/data检查实际频率,如果低于配置值,先排查 USB 线和供电。另一个可能是robot_state_publisher的publish_frequency设得太低,默认 50Hz 一般够用,但如果 CPU 负载高,可以降到 30Hz 减少压力。
7. 从仿真到实车的参数迁移技巧
把仿真参数搬到实车,最忌讳的是整套复制。我一般会按「传感器层 → 融合层 → 规划层」的顺序逐层迁移,每层迁移后单独验证。传感器层先确认话题名、频率、量程一致;融合层先关掉 IMU,只用轮式里程计跑一圈,确认里程计方向正确,再打开 IMU 看航向角是否平滑;规划层先把速度限制降到仿真值的一半,跑通后再逐步提速。
下面这张表是我在迁移时必查的参数对照,左边是仿真值,右边是实车初始值,实车值通常更保守。
| 参数 | 仿真值 | 实车初始值 | 调整理由 |
|---|---|---|---|
| max_vel_x | 0.4 | 0.2 | 实车定位误差大,先慢跑 |
| inflation_radius | 0.55 | 0.65 | 实车 footprint 有装配误差 |
| sim_time | 1.5 | 2.0 | 实车传感器延迟更大 |
| xy_goal_tolerance | 0.15 | 0.25 | 实车定位精度不如仿真 |
| particles | 30 | 50 | 实车里程计噪声更大 |
迁移完成后,用rosbag record把/scan、/imu/data、/odom、/cmd_vel全部录下来,回放分析。我习惯在实车跑完后回放 bag,看cmd_vel的角速度指令和 IMU 的实际角速度是否跟得上,如果指令发了但实际转得慢,说明acc_lim_theta设小了或者电机扭矩不够。这套流程走下来,仿真到实车的差距基本能控制在可接受范围内。希望帮到你。
本文还有配套的精品资源,点击获取