如果你在露天园区用 2D 激光雷达建一张地图,一般十几分钟就能出一张能用的栅格图;可一旦把同样一套 ROS 建图流程搬到洞穴、地下矿道或隧道里,情况会很快失控——地图漂移、回环闭合失败、点云撕裂,甚至跑着跑着系统直接报“找不到 TF”。洞穴建图的难点,从来不在 SLAM 算法本身,而在传感器数据是否可信、时间戳是否对齐、坐标系是否完整。
我一直在整理“洞穴建图”这个课题,也反复对比过 ROS1 和 ROS2 两套环境。我的判断很明确:在洞穴这种 GPS 失效、纹理重复、粉尘多、光线差的环境中,传感器工程比代码调试更关键。算法能解决 90% 的问题,剩下 10% 是让传感器在任何环境下都不“说谎”。这也决定了 ROS1 和 ROS2 在洞穴建图项目中真正的取舍点:不是“哪套更新”,而是“哪套能更快把传感器数据变成可靠的地图约束”。
这篇文章以“洞穴建图 | ROS1 | ROS2”为主题,会从环境准备讲起,覆盖传感器配置、TF 坐标系、时间同步、Cartographer 建图流程、3D 激光惯性方案,以及洞穴场景下最常见的漂移和排查思路。读完之后,你可以直接在 ROS1 或 ROS2 环境中跑通一套最小洞穴建图流程,并知道下一步该怎么扩展。
1. 洞穴建图为什么容易失败:问题本质不是算法
1.1 洞穴环境对建图系统提出了哪些苛刻要求
先看一张典型的洞穴建图数据流:激光雷达或深度相机采集帧,IMU 提供角速度和加速度,轮式或履带里程计给出局部位移,SLAM 节点把这些数据统一优化成一张地图。听起来和室内建图没有区别,但洞穴环境把每个环节都推到了极限。
第一个问题是 GPS 不可用。Robot Operating System 里很多里程计和定位框架都假设存在一个“绝对参考”,洞穴里没有。没有 GPS,系统只能依赖局部坐标系,也就是odom和map之间的位姿由 SLAM 自己维护。一旦漂移积累,odom到map之间就会出现明显偏差。
第二个问题是隧道走廊特征稀疏。洞穴通常是一段段狭长通道,激光扫过去,大量点云集中在前进方向,横向信息很少。这样的环境里,回环检测非常容易失效,因为不同位置的扫描看起来几乎一样。没有回环约束,前方的位姿就会沿着隧道方向慢慢漂移,等走出洞口再回头,地图已经歪了。
第三个问题是传感器物理条件恶劣。洞穴里光照不足,视觉相机很容易拍出过曝或全黑图像;粉尘和潮湿会让激光雷达点云产生噪点;地面不平会导致履带或轮式里程计打滑。这些因素叠加起来,建图系统会频繁出现“数据不可用”的瞬间。
1.2 什么样的开发者适合读这篇文章
如果你正在做以下事情,这篇文章会很有参考价值:
- 课程项目或毕业设计选择了“洞穴建图”“地下空间建图”“矿道仿真”等方向;
- 想了解 ROS1 和 ROS2 在真实 SLAM 任务中的差异,尤其是 Cartographer 的配置和运行方式;
- 需要在一个没有 GPS 的环境中做移动机器人自主导航,先要把环境地图建出来;
- 已经会用 ROS 建室内图,但换到地下或隧道场景后地图一直漂移,想找到排查方法。
这篇文章不会停留在“启动一个 SLAM 节点”的层面,而是会重点讲清楚:在洞穴这种退化环境下,怎样的传感器组合和参数调整才能让地图保持稳定。
2. 洞穴建图的基础概念与框架选型
2.1 SLAM、里程计、建图三个概念要拆开
很多初学者把 SLAM 当成一个“黑盒”模块,但洞穴建图项目里必须把它拆开理解。
- 里程计:只关心短时间内的相对位移。车轮、IMU、激光扫描匹配都能提供里程计信息,但它只解决“我相对上一时刻移动了多少”,不解决“我在哪里”。
- 建图:把每一帧传感器数据放到同一个全局坐标系下,拼成一张可用的地图。
- SLAM:在不确定的位姿估计下同时完成建图和定位,核心是维护历史位姿之间的约束,尤其是回环约束。
洞穴建图对三个环节的要求分别是:里程计要稳定,建图要能处理稀疏点云,SLAM 要能在缺少回环时尽可能抑制漂移。
2.2 为什么激光雷达 + IMU 是洞穴建图的主流组合
视觉方案在洞穴里非常吃亏。低光照、粉尘、纹理重复都会让视觉特征匹配失效。相比之下,激光雷达不依赖光照,能直接提供几何信息;IMU 不受打滑和粉尘影响,可以提供高频的姿态和加速度。两者组合能形成很好的互补:激光负责绝对几何约束,IMU 负责短时间剧烈运动补偿。
在 ROS1 / ROS2 中,最常见的洞穴建图框架包括:
| 框架 | ROS1 支持 | ROS2 支持 | 适用场景 |
|---|---|---|---|
| Cartographer | 官方支持 | 官方支持 | 2D 隧道、小规模洞穴、走廊环境 |
| LIO-SAM | 官方仓库主要面向 ROS1 | 以社区移植为主 | 3D 大场景、需要紧耦合激光惯性 |
| FAST-LIO | ROS1 生态更完整 | 有社区分支 | 3D 大场景、强调计算实时性 |
| BLAM | ROS1 | 较少 | 轻量级 3D 激光建图实验 |
这里需要说明:以上框架的版本和 ROS 分支会随社区更新变化,安装前一定要查看对应仓库的 README 和分支说明。从整体维护情况看,ROS1 生态对这些开源 SLAM 的支持更完整;ROS2 主要依赖 Cartographer 和部分社区移植版本。这也是“洞穴建图”这类偏研究型项目仍然大量使用 ROS1 的原因之一。
2.3 ROS1 与 ROS2 在洞穴建图中的取舍
ROS1 的优势是资料多、老驱动多、第三方 SLAM 包多。很多工业激光雷达、旧的底盘驱动、科研用的建图算法都只提供了 ROS1 接口。ROS2 的优势是安全性、实时性、多机通信和生命周期管理更好,适合产品化。
洞穴建图项目如果没有历史代码包袱,推荐从 ROS2 Humble 开始;如果一定要用 LIO-SAM 这类激光惯性方案,可以先用 ROS1 Noetic 跑通数据流,再考虑移植到 ROS2。不要一上来就双线并行,否则会在环境问题上消耗大量时间。
3. 环境准备:ROS1 与 ROS2 的安装与配置
3.1 版本建议与操作系统匹配
洞穴建图项目和普通 ROS 项目一样,首先要确定操作系统和 ROS 发行版。最常用的组合是:
| 操作系统 | ROS 发行版 | 说明 |
|---|---|---|
| Ubuntu 20.04 | ROS Noetic(ROS1) | 老项目多、LIO-SAM 等算法更好跑 |
| Ubuntu 22.04 | ROS2 Humble(ROS2) | 新项目推荐、支持 ARM 设备 |
| Ubuntu 24.04 | ROS2 Jazzy | 新版本迭代中,需确认第三方包兼容性 |
| 树莓派等 ARM | Ubuntu Server + ROS2 Humble | 适合小型洞穴建图平台 |
版本细节请以实际项目为准,本文重点演示通用思路。开发环境推荐 Ubuntu 22.04 + ROS2 Humble,同时用 Docker 或双系统保留一个 Ubuntu 20.04 + ROS1 Noetic 环境做兼容测试。
3.2 配置国内 ROS 镜像源
国内用户安装 ROS 时,最常遇到的问题是从官方源下载包特别慢,甚至中途超时。解决方式是先把 ROS 源换成国内镜像。
这里以清华镜像为例:
# ROS1 Noetic 源(Ubuntu 20.04 / focal) sudo sh -c 'echo "deb http://mirrors.tuna.tsinghua.edu.cn/ros/ubuntu focal main" > /etc/apt/sources.list.d/ros1-mirror.list' # ROS2 Humble 源(Ubuntu 22.04 / jammy) echo "deb [arch=$(dpkg --print-architecture)] http://mirrors.tuna.tsinghua.edu.cn/ros2/ubuntu jammy main" | sudo tee /etc/apt/sources.list.d/ros2-mirror.list换源后需要重新导入 ROS 的 GPG key,不同发行版导入方式有差异。还有一种更省事的方式,是用社区维护的一键安装脚本。这类脚本本质上就是在替你执行“换源、安装、初始化 rosdep”这几步,但在安装前至少要把脚本里涉及的源地址和分支读一遍,避免装到不合适的版本。
3.3 安装 ROS1 Noetic
在 Ubuntu 20.04 上安装 ROS1 Noetic,核心命令如下:
# 添加 ROS1 源 sudo sh -c 'echo "deb http://packages.ros.org/ros/ubuntu $(lsb_release -sc) main" > /etc/apt/sources.list.d/ros1-latest.list' # 导入 key sudo apt install curl curl -s https://raw.githubusercontent.com/ros/rosdistro/master/ros.asc | sudo apt-key add - # 更新并安装 sudo apt update sudo apt install ros-noetic-ros-base ros-noetic-desktop-full # 初始化 rosdep sudo rosdep init rosdep update # 配置环境变量 echo "source /opt/ros/noetic/setup.bash" >> ~/.bashrc source ~/.bashrcros-noetic-desktop-full包含 rviz、tf、nav 等常用工具,安装体积比较大,建议保持网络稳定。
3.4 安装 ROS2 Humble
在 Ubuntu 22.04 上安装 ROS2 Humble,官方推荐命令如下:
sudo apt update sudo apt install software-properties-common sudo add-apt-repository universe sudo apt update && sudo apt install curl -y sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null sudo apt update sudo apt install ros-humble-ros-base ros-humble-desktop echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc source ~/.bashrcROS2 不依赖apt-key这种全局 key 管理方式,而是使用 keyring 文件,所以安装命令会略有不同。如果你已经配置了国内镜像源,这里的packages.ros.org可以替换成对应的镜像地址。
3.5 创建工作空间与编译工具
ROS1 通常用catkin,ROS2 通常用colcon。这里建议统一创建cave_ws工作空间:
# ROS1 mkdir -p ~/cave_ws/src cd ~/cave_ws catkin_make source devel/setup.bash # ROS2 mkdir -p ~/cave_ws/src cd ~/cave_ws colcon build source install/setup.bash无论使用哪个版本,后续自定义节点、启动文件、配置文件都要放到src目录下对应功能包中。
4. 传感器配置与坐标系:洞穴建图的前提
4.1 洞穴建图中的数据流
一套典型的洞穴建图系统,数据流大致如下:
激光雷达 -> /scan 或 /velodyne_points -> SLAM IMU -> /imu/data -> SLAM 底盘编码器 -> /odom -> SLAM(可选) SLAM -> /map、/tf -> rviz 可视化在 ROS 中,tf坐标树的完整程度直接决定 SLAM 能否启动。如果缺少base_link到激光雷达的静态变换,Cartographer 会直接报 “Could not find transform”。因此,在运行建图之前,必须先检查 tf 树。
4.2 发布传感器静态变换
假设机器人底盘坐标系是base_link,激光雷达在底盘正上方 0.2 米处,IMU 在底盘后方 0.05 米处,可以这样发布静态变换:
# ROS1 rosrun tf2_ros static_transform_publisher 0 0 0.2 0 0 0 base_link laser_frame # ROS2(注意参数顺序,部分版本支持四元数形式) ros2 run tf2_ros static_transform_publisher 0 0 0.2 0 0 0 base_link laser_frame ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 0 1 base_link imu_link注意:ROS2 的static_transform_publisher在不同版本中的参数格式有差异,有些版本使用x y z yaw pitch roll,有些使用x y z qx qy qz qw。运行前可以用ros2 run tf2_ros static_transform_publisher --help查看。
4.3 检查话题、TF 和发布频率
启动传感器驱动后,建议按以下顺序检查:
# ROS1 rostopic list rostopic hz /scan rostopic echo /scan -n 1 rosrun tf2_tools view_frames # ROS2 ros2 topic list ros2 topic hz /scan ros2 topic echo /scan --once ros2 run tf2_tools tf2_monitor如果激光雷达话题频率低于预期,或者 TF 树里缺少关键坐标变换,建图质量会大打折扣。洞穴环境里点云质量本来就不高,必须在数据来源处确认发布频率和范围。
4.4 时间同步:为什么不能直接拼接不同时间戳的消息
在洞穴环境中,机器人经常处于颠簸状态,激光雷达每一帧和 IMU 每一帧的真实时刻可能相差几十毫秒。如果直接用不同时间戳的数据做融合,点云会出现“重影”,地图也会出现重影。
ROS1 中建议使用message_filters做近似时间同步:
#!/usr/bin/env python3 # 文件路径:cave_ws/src/cave_sync/scripts/sync_node.py(ROS1) import rospy import message_filters from sensor_msgs.msg import PointCloud2, Imu def callback(pointcloud, imu): rospy.loginfo("同步收到点云和IMU消息") def main(): rospy.init_node("cave_sync") sub_pc = message_filters.Subscriber("/velodyne_points", PointCloud2) sub_imu = message_filters.Subscriber("/imu/data", Imu) ts = message_filters.ApproximateTimeSynchronizer([sub_pc, sub_imu], 10, 0.05) ts.registerCallback(callback) rospy.spin() if __name__ == "__main__": main()这里的ApproximateTimeSynchronizer接受slop参数,也就是 0.05 秒。洞穴环境中如果机器人颠簸大,建议适当放宽到 0.1 秒,但不要太大,否则同步后的数据已经失真。
ROS2 中的写法类似:
# 文件路径:cave_ws/src/cave_sync/cave_sync/sync_node.py(ROS2) import rclpy from rclpy.node import Node from sensor_msgs.msg import PointCloud2, Imu from message_filters import ApproximateTimeSynchronizer, Subscriber class CaveSync(Node): def __init__(self): super().__init__("cave_sync") self.sub_pc = Subscriber(self, PointCloud2, "/velodyne_points") self.sub_imu = Subscriber(self, Imu, "/imu/data") self.sync = ApproximateTimeSynchronizer([self.sub_pc, self.sub_imu], 10, 0.05) self.sync.registerCallback(self.callback) def callback(self, pointcloud, imu): self.get_logger().info("同步收到点云和IMU消息") def main(): rclpy.init() node = CaveSync() rclpy.spin(node) if __name__ == "__main__": main()在 CMakeLists.txt 或 setup.py 中正确配置入口后,就能在对应环境中启动该节点。
5. 完整示例:使用 Cartographer 做 2D 洞穴建图
5.1 为什么先选 2D Cartographer
洞穴建图项目如果还处于验证阶段,建议先用 2D 激光雷达跑通 Cartographer。原因是:2D 雷达便宜、驱动成熟、算法对点云依赖更容易调参;通过 2D 流程可以把“TF、时间同步、地图保存”这些关键环节全部理顺,再升级到 3D 方案会轻松很多。
5.2 Cartographer 配置文件
Cartographer 的 2D 配置以 Lua 文件形式存在。这里给出一份适合洞穴走廊环境的配置示例:
-- 文件路径:cave_ws/src/cartographer_ros/cartographer_ros/configuration_files/my_cave_2d.lua include "map_builder.lua" include "trajectory_builder.lua" options = { map_builder = MAP_BUILDER, trajectory_builder = TRAJECTORY_BUILDER, map_frame = "map", tracking_frame = "base_link", published_frame = "odom", odom_frame = "odom", provide_odom_frame = true, publish_frame_projected_to_2d = false, use_odometry = false, use_nav_sat = false, use_landmarks = false, num_laser_scans = 1, num_multi_echo_laser_scans = 0, num_subdivisions_per_laser_scan = 1, num_point_clouds = 0, lookup_transform_timeout_sec = 0.2, submap_publish_period_sec = 0.3, pose_publish_period_sec = 5e-3, trajectory_publish_period_sec = 30e-3, } MAP_BUILDER.use_trajectory_builder_2d = true TRAJECTORY_BUILDER_2D.submaps.num_range_data = 35 TRAJECTORY_BUILDER_2D.min_range = 0.3 TRAJECTORY_BUILDER_2D.max_range = 30.0 TRAJECTORY_BUILDER_2D.missing_data_ray_length = 5.0 TRAJECTORY_BUILDER_2D.use_imu_data = false TRAJECTORY_BUILDER_2D.use_online_correlative_scan_matching = true TRAJECTORY_BUILDER_2D.motion_filter.max_angle_rad = 0.002 TRAJECTORY_BUILDER_2D.motion_filter.max_distance_m = 0.1 return options洞穴环境中容易遇到“扫描重叠少、回环少”的情况,所以把num_range_data调大一些,让子图积累更多扫描帧;use_online_correlative_scan_matching打开后可以增强扫描匹配的鲁棒性。
5.3 ROS1 启动文件
<launch> <!-- 文件路径:cave_ws/src/cave_mapping/launch/cartographer_2d_cave.launch --> <node name="cartographer_node" pkg="cartographer_ros" type="cartographer_node" args="-configuration_directory $(find cartographer_ros)/configuration_files -configuration_basename my_cave_2d.lua" output="screen" /> <node name="cartographer_occupancy_grid_node" pkg="cartographer_ros" type="cartographer_occupancy_grid_node" args="-resolution 0.05" output="screen" /> </launch>5.4 ROS2 启动文件
# 文件路径:cave_ws/src/cave_mapping/launch/cartographer_2d_cave.py(ROS2) import os from launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): config_dir = os.path.join( os.getenv('AMENT_PREFIX_PATH', ''), 'cartographer_ros', 'share', 'cartographer_ros', 'configuration_files' ) return LaunchDescription([ Node( package='cartographer_ros', executable='cartographer_node', name='cartographer_node', output='screen', parameters=[{ 'configuration_directory': config_dir, 'configuration_basename': 'my_cave_2d.lua', }], ), Node( package='cartographer_ros', executable='cartographer_occupancy_grid_node', name='cartographer_occupancy_grid_node', output='screen', parameters=[{ 'resolution': 0.05, 'publish_period_sec': 1.0, }], ), ])脚本中config_dir的写法在实际工程里可能因为 ROS2 包安装位置不同而需要调整,更稳妥的做法是使用ament_index_python查找包路径,或者直接写绝对路径。
5.5 运行建图
确保激光雷达话题是/scan,坐标变换已发布,然后分别执行:
# ROS1 roslaunch cave_mapping cartographer_2d_cave.launch # ROS2 ros2 launch cave_mapping cartographer_2d_cave.py打开 rviz(ROS1 用rviz,ROS2 用rviz2),添加Map显示,话题选择/map;再添加TF显示,就能看到机器人移动时地图逐渐生成。
5.6 保存地图
Cartographer 保存地图和普通 SLAM 不太一样,它先保存.pbstream,再转换成 ROS 常用的.pgm和.yaml:
# ROS1 rosservice call /finish_trajectory 0 rosservice call /write_state "{filename: '/home/user/cave_ws/cave.pbstream'}" rosrun cartographer_ros cartographer_pbstream_to_ros_map \ -pbstream_filename /home/user/cave_ws/cave.pbstream \ -resolution 0.05 # ROS2 ros2 service call /finish_trajectory cartographer_ros/srv/FinishTrajectory "{trajectory_id: 0}"ROS2 下的/write_state服务名称可能因版本不同,建议先执行ros2 service list和ros2 service find cartographer_ros/srv/WriteState确认。
5.7 保存原始数据
建图数据采集过程中,最好同时录制 rosbag,方便后续回放调参:
# ROS1 rosbag record -O cave_mapping.bag /scan /imu/data /tf /tf_static # ROS2 ros2 bag record /scan /imu/data /tf /tf_static洞穴环境复杂,现场不一定有机会反复跑,rosbag 是唯一可靠的“数据备份”。
6. 扩展到 3D 洞穴建图:点云、IMU 与 LIO 方案
6.1 为什么洞穴大场景需要 3D 激光雷达
2D 激光雷达只能扫描一个平面,洞穴里地面和顶部往往不在同一水平面,2D 建图会丢失很多几何约束。3D 激光雷达能同时感知地面、墙壁、顶部,对洞穴这种三维结构来说更可靠。
3D 建图的问题也很明显:点云量巨大,普通 ROS 节点处理不过来;运动畸变更严重;点云中的噪声更难滤除。因此,3D 方案通常采用“激光雷达 + IMU”紧耦合的方式,由 IMU 负责高频运动预测,激光雷达负责低频位姿修正。
6.2 点云预处理:先做范围过滤
在把点云送给 SLAM 前,先做一个 pass-through 滤波,把过远的点、地面噪声点、头顶上的杂散点都过滤掉。这里以pcl_ros为例:
<launch> <!-- 文件路径:cave_ws/src/cave_mapping/launch/pass_through_cave.launch --> <node pkg="pcl_ros" type="passthrough" name="pass_through_z" output="screen"> <remap from="~input" to="/velodyne_points"/> <remap from="~output" to="/points_filtered"/> <param name="filter_field_name" value="z"/> <param name="filter_limit_min" value="-1.0"/> <param name="filter_limit_max" value="5.0"/> <param name="filter_limit_negative" value="false"/> </node> </launch>过滤参数并不是越小越好。洞穴地面起伏较大,如果z的下限设置过高,会丢失坑洼区域的点云,导致地面不平整;上限过低又会丢掉顶部约束。建议先录制一段 rosbag,反复调整过滤参数,再决定最终阈值。
6.3 LIO-SAM 与 FAST-LIO 的思路
LIO-SAM 和 FAST-LIO 共同点是“紧耦合激光惯性里程计”:把 IMU 预积分结果和激光点云配准结果放到同一个因子图或卡尔曼滤波框架中优化。相对于 Cartographer,它们在大范围 3D 场景中更能抑制漂移,但对传感器标定、IMU 噪声模型、点云去畸变要求很高。
如果你在 ROS1 环境中使用 LIO-SAM,需要注意几个前置条件:IMU 必须完成标定,激光雷达和 IMU 的外参要写入配置;imu_link和base_link之间的静态变换必须准确;启动时通常需要保持机器人静止几秒,让