1. 为什么你的SLAM系统需要一个“记忆大师”?

如果你玩过机器人或者无人机,肯定遇到过这样的烦心事:机器人在一个大仓库里转悠,一开始定位还挺准,走着走着,位置就慢慢飘了,最后地图都歪了。或者,它绕了一大圈回到起点,却死活认不出来这是来过的地方,硬生生把地图画成了“双胞胎”。这个问题,在SLAM(同步定位与建图)里,就叫累积误差

想象一下,你蒙着眼睛在一个陌生房间里走路,全靠数自己的步数和感觉转弯角度来画地图。一开始几步可能很准,但走了几十步、几百步后,一个小角度的误差积累起来,你可能觉得自己在客厅,实际已经走到阳台了。SLAM系统用激光雷达(LiDAR)或摄像头“看”世界,和蒙眼走路有点像,每一帧的位姿估计都有微小误差,时间一长,地图就“散架”了。

回环检测(Loop Closure Detection),就是解决这个问题的“记忆大师”。它的核心任务很简单:认出“故地重游”。当系统检测到当前场景与很久以前访问过的某个场景高度相似时,它就形成一个“回环约束”。这个约束像一根强有力的橡皮筋,会把已经漂移的轨迹和地图猛地拉回正确的位置,大幅修正累积误差,得到一个全局一致的地图。

那么,自己从头写一个稳定、高效的回环检测模块难吗?实话实说,挺难的。涉及到特征提取、场景描述子构建、高效检索、几何验证等一系列复杂步骤。好在,社区里已经有非常优秀的开源方案,比如 SC-A-LOAMFAST_LIO_SLAM 中集成的回环检测模块(常基于 Scan ContextSC-PGO)。它们就像经过实战检验的“记忆大师”插件。

这篇文章,我就来手把手教你,如何把这个成熟的“记忆大师”模块,从 SC-A-LOAMFAST_LIO_SLAM 项目中“拆”出来,集成到你自己的SLAM系统里。我踩过不少坑,也总结了一套相对通用的移植方法,目标是让你用最小的改动,获得最稳定的回环检测能力。无论你的前端是激光里程计还是视觉里程计,只要它能提供必要的数据,这套方法都有很高的参考价值。

2. 移植前必读:理解“记忆大师”的工作模式

在动手改代码之前,我们必须搞清楚这个回环检测模块(后文我们常称之为 SC-PGO模块,因为它常包含Scan Context用于检测和Pose Graph Optimization用于优化)到底需要什么、产出什么。理解清楚这个接口,移植就成功了一半。

我以 FAST_LIO_SLAM 项目中集成的模块为例来分析,它的结构非常清晰。这个回环模块本质上是一个独立的ROS节点(或者节点组),它不关心你前端具体是FAST-LIO、A-LOAM还是其他什么算法。它只订阅两类数据:

  1. 高频里程计位姿(Odometry):通常是以 nav_msgs/Odometry 消息类型发布的、经过前端实时估计的机器人位姿(位置和姿态)。这个话题频率很高(比如10Hz),构成了机器人运动轨迹的“骨架”。模块需要用它来知道机器人“大概在哪”。
  2. 关键帧点云(Keyframe PointCloud):不是每一帧激光数据都用来做回环检测,那样计算量太大且冗余。前端SLAM系统会选取一些有代表性的帧作为“关键帧”,通常是以 sensor_msgs/PointCloud2 类型发布。这个点云是原始点云经过运动畸变校正后的数据,是回环检测模块进行场景识别的“原材料”。

有了这两样输入,回环检测模块内部会干这几件事:

  • 构建场景描述子:对每个关键帧点云,提取一种紧凑的、对视角和轻微变化鲁棒的特征描述子(如Scan Context)。
  • 数据库检索:将当前帧的描述子与历史所有关键帧的描述子进行比对,寻找最相似的候选帧。
  • 几何验证:如果找到候选,会尝试用ICP等算法进行精细配准,计算一个相对位姿变换,确保不是误匹配。
  • 发布优化结果:一旦确认一个有效的回环,它会输出优化后的位姿(同样是 nav_msgs/Odometry)。这个优化后的位姿,融合了回环约束,比单纯的前端里程计更准确、全局一致性更好。

所以,对于你的SLAM系统,你需要确保它能稳定地发布这两个话题。很多基础的前端SLAM代码(比如一些裸的激光里程计)可能只发布里程计,不发布关键帧点云。这时你就需要修改前端代码,在适当的时候(比如机器人移动超过一定距离或旋转超过一定角度)将当前帧点云发布出来。

另一个重要的点是坐标系。你必须确保里程计和点云都在同一个坐标系下(通常是 odommap),并且点云的位姿与里程计位姿是对应的。如果坐标系乱了套,回环检测肯定无法工作。

3. 实战第一步:解剖FAST_LIO_SLAM的集成方式

理论说再多,不如直接看代码。我们打开 FAST_LIO_SLAM 的仓库(这里假设你已克隆到本地)。它的目录结构通常包含两个核心部分:FAST_LIO 目录(前端里程计)和 SC-PGO 目录(回环检测与优化)。我们的关注点在 SC-PGO

首先看它的启动文件(.launch 文件),这是配置的入口。你会看到类似下面的关键内容:

<node pkg="sc_pgo" type="sc_pgo_node" name="sc_pgo" output="screen">
    <param name="lidar_type" value="VLP16" />
    <remap from="/Odometry_after_opt" to="/aft_mapped_to_init" />
    <remap from="/loop_map" to="/key_pose_origin" />
    <!-- 可能还有一个话题,但如原始文章所说,有时是冗余的 -->
    <!-- <remap from="/cloud_for_scancontext" to="/laser_cloud_corner_last" /> -->
</node>

这段XML代码告诉了我们几乎所有需要修改的信息:

  • lidar_type:指定雷达类型。这是因为Scan Context等描述子的构建可能与雷达的线束、垂直视场角有关。你必须根据自己使用的雷达(如Velodyne VLP-16, Ouster OS1-64, Livox Avia等)修改这个参数。你需要去 sc_pgo 包的代码里查看它支持哪些雷达类型,如果不支持你的雷达,可能需要自己添加对应的配置,或者使用一个配置相近的型号。
  • /Odometry_after_opt:这是SC-PGO模块输出的、经过回环优化后的位姿话题。它被重映射到了 aft_mapped_to_init。这意味着,在这个项目中,优化后的位姿会覆盖到前端FAST-LIO发布的 aft_mapped_to_init 这个话题上(或者另一个节点订阅它来更新地图)。在你自己的系统中,你需要决定这个优化后的位姿给谁用。比如,你可以让它发布到 /optimized_odom,然后让你的地图构建节点订阅这个话题。
  • /loop_map:这是SC-PGO模块输入的关键帧点云话题。它被重映射到了 key_pose_origin。所以,在这个项目里,前端FAST-LIO需要发布名为 key_pose_originPointCloud2 消息。这是你需要在自己前端代码中实现或修改话题名的关键点
  • 关于 /cloud_for_scancontext:正如原始文章作者发现的,在一些版本中这个话题的重映射可能是冗余的,在代码全局搜索后发现并无实际订阅。这是一个很好的提醒:在移植时,对于不确定的话题,可以尝试注释掉,然后运行系统,通过 rostopic listrostopic info 来检查节点实际的订阅关系,避免被无效配置干扰。

所以,集成回环模块,对你来说就变成了两个明确的任务:1. 让你的前端SLAM发布它需要的两个话题;2. 修改SC-PGO的启动配置,告诉它去哪里订阅和发布。

4. 改造你的SLAM前端:发布关键数据

现在,我们聚焦于如何修改你自己的SLAM前端代码。假设你有一个基于激光的里程计模块,它已经能实时计算并发布里程计话题,比如叫做 /laser_odom

第一步:确保里程计话题可用 检查你的代码,找到发布 nav_msgs/Odometry 消息的地方。确认这个消息包含完整的位姿(pose.pose)和协方差(pose.covariance),并且时间戳是正确的。这个话题将作为SC-PGO模块对机器人运动轨迹的先验估计。

第二步:创建并发布关键帧点云 这是核心改动。关键帧的选择策略可以简单也可以复杂。一个最常用且有效的策略是“距离+旋转阈值”:

  • 记录上一个关键帧的位姿。
  • 每当新的激光帧到来并完成里程计估计后,计算当前位姿与上一个关键帧位姿之间的平移距离和旋转角度(欧拉角或四元数夹角)。
  • 如果平移距离超过一定阈值(例如0.5米)旋转角度超过一定阈值(例如15度),则将当前帧判定为关键帧。

在你的代码中,找到处理点云和位姿的地方,添加类似下面的逻辑(以C++和ROS为例):

// 声明发布器(在类初始化中)
ros::Publisher keyframe_pub_;
keyframe_pub_ = nh_.advertise<sensor_msgs::PointCloud2>("/keyframe_cloud", 10);

// 在每帧处理函数中
Eigen::Isometry3d current_pose = ... // 获取当前帧的位姿(Eigen矩阵形式)
static Eigen::Isometry3d last_keyframe_pose = Eigen::Isometry3d::Identity();
static int keyframe_id = 0;

double trans_distance = (current_pose.translation() - last_keyframe_pose.translation()).norm();
Eigen::AngleAxisd rot_diff(current_pose.rotation().transpose() * last_keyframe_pose.rotation());
double angular_distance = rot_diff.angle() * 180.0 / M_PI; // 转换为度

if (trans_distance > 0.5 || angular_distance > 15.0) {
    // 保存为关键帧
    last_keyframe_pose = current_pose;
    keyframe_id++;

    // 发布关键帧点云
    // 注意:点云需要转换到里程计坐标系(通常是odom或map)下
    pcl::PointCloud<pcl::PointXYZI>::Ptr cloud_corrected(new pcl::PointCloud<pcl::PointXYZI>);
    // ... 这里对你的原始点云应用current_pose变换,消除运动畸变,得到在odom坐标系下的点云 ...
    
    sensor_msgs::PointCloud2 keyframe_msg;
    pcl::toROSMsg(*cloud_corrected, keyframe_msg);
    keyframe_msg.header.stamp = current_scan_time;
    keyframe_msg.header.frame_id = "odom"; // 确保与里程计坐标系一致!
    keyframe_pub_.publish(keyframe_msg);
}

重要提示:发布的关键帧点云必须是在里程计坐标系下的、经过运动畸变校正的点云。如果你的前端里程计已经输出了校正后的点云(比如叫 cloud_registered),并且它已经在 odom 坐标系下,那么你可以直接发布它,而无需再次变换。关键是保证点云的 frame_id 与里程计消息的 header.frame_id 一致。

5. 配置与启动:让模块为你工作

当前端改造完成,可以稳定发布 /laser_odom/keyframe_cloud 后,我们就可以来配置SC-PGO模块了。

第一步:获取SC-PGO模块代码 你可以从 SC-A-LOAMFAST_LIO_SLAM 的GitHub仓库中,将 SC-PGOsc_pgo 相关的目录复制到你的工作空间。通常它包含 include, src, launch, config 等目录。确保你复制了完整的包,并且其CMakeLists.txt和package.xml是完整的。

第二步:修改启动文件 在你的SC-PGO包的launch文件夹中,找到主要的 .launch 文件(例如 sc_pgo.launch)。按照我们第三章的分析进行修改:

<launch>
    <node pkg="sc_pgo" type="sc_pgo_node" name="sc_pgo" output="screen">
        <!-- 修改1:雷达类型 -->
        <param name="lidar_type" value="VLP16" /> <!-- 改为你的雷达,如“OS1-64” -->
        
        <!-- 修改2:输入话题重映射 -->
        <!-- 假设你的里程计话题叫 /laser_odom -->
        <remap from="/Odometry" to="/laser_odom" />
        <!-- 假设你的关键帧点云话题叫 /keyframe_cloud -->
        <remap from="/loop_map" to="/keyframe_cloud" />
        
        <!-- 修改3:输出话题重映射 -->
        <!-- 将优化后的位姿发布到一个新话题,避免干扰前端 -->
        <remap from="/Odometry_after_opt" to="/optimized_odom" />
        
        <!-- 其他参数保持默认或根据需要调整 -->
        <param name="sequence" value="true" /> <!-- 是否处理序列数据 -->
        <param name="distance_threshold" value="10.0" /> <!-- 回环搜索距离阈值 -->
        <param name="time_threshold" value="30.0" /> <!-- 回环搜索时间阈值 -->
    </node>
</launch>

注意:不同版本的SC-PGO代码,其节点内部订阅的话题名可能略有不同。最常见的输入话题名是 /Odometry/loop_map,但务必用 rosnode info /sc_pgo 命令(运行节点后)或查看节点的源代码(找 ros::Subscriber)来确认准确的话题名。

第三步:调整关键参数 除了雷达类型,还有几个影响回环检测性能和行为的参数需要关注:

  • distance_threshold:只有与当前帧距离超过此阈值的历史关键帧才会被考虑作为回环候选。防止与很近的帧误匹配。根据环境大小设置,室内可以设小点(5-10米),室外设大点(20-30米)。
  • time_threshold:只有与当前帧时间差超过此阈值的历史关键帧才会被考虑。防止与连续帧误匹配。通常设置为至少能覆盖机器人走一圈的时间。
  • sequence:如果处理的是有顺序的数据集(如ROS bag),设为 true;如果是离线处理无序点云,设为 false

这些参数没有绝对标准,需要在你的实际场景中测试调整。一个建议是,一开始把 distance_thresholdtime_threshold 设得宽松一些,确保能检测到回环,然后再逐步收紧,减少误检。

6. 调试与验证:眼见为实

所有代码修改和配置完成后,激动人心的测试环节来了。我建议按以下步骤进行,可以帮你高效定位问题。

1. 单独测试前端 首先,在不启动SC-PGO模块的情况下,运行你的SLAM前端。打开RViz,添加 PointCloud2 显示,订阅你的 /keyframe_cloud 话题。然后控制机器人移动或播放数据集。你应该能看到点云随着机器人运动而不断扩展,并且关键帧是间隔性地出现,而不是每一帧都出现。同时,用 rostopic echo /laser_odom 查看里程计消息是否正常输出。

2. 运行完整的系统 确保前端运行正常后,在新的终端启动SC-PGO模块:roslaunch your_sc_pgo_pkg sc_pgo.launch。在RViz中,额外添加一个 Path 显示,订阅 /optimized_odom 话题(或者你设置的优化位姿话题)。同时,也可以继续显示原始里程计路径作为对比。

3. 观察回环触发 控制机器人行走,特别是让它走一个闭环(比如绕一个房间或走廊一圈回到起点)。观察RViz中的路径:

  • 理想情况:当机器人回到起点附近时,你会看到优化后的路径(/optimized_odom)发生一次明显的“跳跃”或“矫正”,与原始里程计路径(/laser_odom)分开,并且整个闭环的轨迹变得非常吻合。在终端里,SC-PGO节点通常会打印检测到回环的日志,如 [INFO] Loop found between frame 150 and frame 20
  • 无回环:如果机器人走完一圈,两条路径始终重合,且没有明显的矫正,说明回环未被检测到。问题可能出在:关键帧点云质量太差、雷达类型参数不对、distance_threshold 设得太大、或者Scan Context描述子无法有效匹配你的场景(比如长廊这种高度相似的环境)。
  • 误回环:如果机器人在不应该的地方(比如两个不同的、但看起来相似的走廊)路径突然被错误地“拉”到一起,那就是误检测。需要提高 distance_thresholdtime_threshold,或者检查前端点云是否存在严重噪点。

4. 实用调试命令

  • rostopic list:检查所有话题是否存在,特别是输入输出话题。
  • rosnode info /sc_pgo:查看SC-PGO节点实际订阅和发布了哪些话题,确认重映射是否生效。
  • rviz 中的 TF 显示:确保坐标系树正确,没有多余的或断裂的坐标系变换。SC-PGO模块可能会发布一个从 mapodom 的静态TF,用于校正漂移,这是正常的。

调试过程可能会反复几次,尤其是关键帧提取和参数调整。我的经验是,先在一个小范围的、结构特征明显的环境中(比如有桌椅的房间)测试,成功后再扩展到更复杂的环境。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐