1. FAST-LIO2工程化落地的两个关键拼图
搞过激光SLAM的朋友应该都有体会,FAST-LIO2的论文和开源代码本身已经足够优雅,iEKF框架把IMU和LiDAR的紧耦合做到了极致,跑公开数据集的效果也确实能打。但真正把它往实际项目里塞的时候,你会发现两个绕不过去的坎:一个是地图数据结构,一个是初始位姿从哪来。前者决定了系统能不能在长时间运行下保持实时性,后者决定了系统能不能在已知地图里找到自己。
这篇笔记就围绕这两个问题展开,重点聊清楚三件事:ikd-tree到底比传统kd-tree强在哪、为什么FAST-LIO2非用它不可;重定位这件事在FAST-LIO2的体系里应该怎么做,LI-Init和fast_lio_localization这两个方案各自的定位是什么;以及在实际调试中,rviz手动给初始位姿这个看起来"很土"的操作,为什么反而是最稳的兜底方案。
内容适合已经跑通过FAST-LIO2基础demo、准备往实际机器人或离线建图流程上迁移的读者。如果你还没跑通最基本的里程计,建议先把前面的笔记补完再来看这篇,不然很多细节会对不上。
2. ikd-tree:增量式kd-tree到底解决了什么问题
2.1 从kd-tree的痛点说起
先回忆一下kd-tree是什么。它是一棵对k维空间点进行划分的二叉树,每个节点用一个维度上的超平面把空间切成两半,查询最近邻的时候可以快速剪枝。PCL里那套pcl::KdTreeFLANN就是标准实现,建树一次,查询无数次,静态场景下非常好用。
但SLAM的地图是动态增长的。每来一帧新的LiDAR点云,就要往地图里加点;同时为了控制内存,还要把远处或者冗余的点删掉。如果用PCL的kd-tree,每加一批点就得重建整棵树。假设地图里有50万个点,重建一次的开销是O(n log n),在10Hz的LiDAR频率下,光建树就能把CPU吃满,实时性直接崩掉。
有人会说,那我攒一批点再重建行不行?可以,但代价是查询用的地图会滞后,而且重建瞬间的卡顿在机器人控制回路里是致命的。这就是ikd-tree要解决的核心矛盾:既要支持增量插入和删除,又要保证查询效率不退化。
2.2 ikd-tree的三个核心设计
ikd-tree出自港大MaRS实验室那篇《ikd-Tree: An Incremental K-D Tree for Robotic Applications》,它的设计思路可以拆成三块来理解。
第一块是增量插入。新点不是简单地挂到叶子节点上,而是沿着树往下走,找到合适的子树后做局部重建。关键在于它维护了一个"子树点数"的统计量,当某个子树的点数超过阈值(论文里叫balance criterion),就触发局部重构。这样避免了全局重建,单次插入的均摊复杂度接近O(log n)。
第二块是惰性删除。删除一个点的时候,ikd-tree不真的把它从树里摘掉,而是打一个deleted标记,同时把包含这个点的子树点数减一。真正物理删除发生在局部重构的时候,被标记的点会被顺手清理掉。这个设计非常聪明,因为SLAM里删除操作往往是大批量的(比如滑窗移出),惰性删除把O(n)的删除摊薄到了重构里。
第三块是降采样与重构的配合。ikd-tree内部集成了一个基于体素的降采样逻辑,当某个区域点数过密时,会按体素格保留一个代表点。这个和FAST-LIO2前端的特征提取是两回事,是地图维护层面的降采样,目的是控制地图规模。
2.3 为什么FAST-LIO2非它不可
FAST-LIO2的iEKF每次迭代都要在当前状态附近找最近邻点来构建残差。如果地图查询慢,整个滤波迭代就慢,IMU预积分的频率就跟不上,系统直接发散。ikd-tree把单次最近邻查询稳定在微秒级,而且插入新帧点云的时候不会阻塞查询,这是它能做到高频率输出的底层保障。
我实测过一个对比:同样50万点的地图,PCL kd-tree在每帧插入2000点的情况下,平均帧耗时在80ms以上,而ikd-tree能压到15ms以内。这个差距在嵌入式平台上更明显,ARM上PCL那套基本没法用。
注意:ikd-tree的源码在FAST-LIO2仓库里是单独一个
ikd-Tree文件夹,它不依赖PCL,可以单独抽出来用。如果你做的是其他需要动态地图的SLAM系统,完全可以把这块抠出来替换掉自己的地图模块。
2.4 实操中容易踩的坑
第一个坑是参数配置。ikd-tree有几个关键参数:balance_criterion(平衡因子,默认0.7)、delete_criterion(删除因子,默认0.5)、downsample_size(降采样体素大小)。平衡因子调小会让树更平衡但重构更频繁,调大则查询变慢。默认值在大多数场景下够用,但如果你的场景点云特别密集(比如室内近距离扫描),建议把downsample_size从默认的0.2调到0.3到0.5,否则地图点数会爆炸。
第二个坑是多线程安全。FAST-LIO2里ikd-tree的插入和查询是在不同线程里跑的,源码用了自旋锁保护。如果你自己改代码,千万不要在查询过程中去改树结构,否则会出现读到半更新状态的问题。我见过有人为了省事把锁去掉,结果跑几分钟就段错误。
第三个坑是地图保存与加载。ikd-tree本身不提供序列化接口,FAST-LIO2里保存的PCD是遍历树导出的。如果你要做重定位,需要的是PCD格式的全局地图,而不是ikd-tree的内存结构。这一点在下一节会详细说。
3. 重定位:从LI-Init到fast_lio_localization的路线选择
3.1 重定位要解决的本质问题
重定位说白了就一句话:机器人开机的时候不知道自己在哪,给它一张已知地图,让它自己找到位姿。听起来简单,但拆开看涉及好几个子问题:全局地图怎么存、当前帧特征怎么描述、候选位姿怎么搜、搜到之后怎么精配准、精配准之后怎么和FAST-LIO2的里程计对接。
FAST-LIO2本身是个里程计系统,它假设你从原点或者一个已知位姿开始,然后靠IMU和LiDAR连续推算。它没有全局定位能力,你直接拿它跑,它只会从(0,0,0)开始累积。所以重定位模块必须外挂,而且要和FAST-LIO2的坐标系对齐。
3.2 LI-Init:解决的是"初始化"而不是"重定位"
很多人把LI-Init当成重定位方案,其实它的定位更准确的说法是LiDAR-IMU初始化。它解决的是系统启动时IMU零偏、重力方向、初始速度这些状态量怎么估计的问题。LI-Init通过一段静止或者缓慢运动的数据,联合优化出这些初始参数,让FAST-LIO2的iEKF能快速收敛。
它和重定位的关系是:重定位给你一个粗略的全局位姿,LI-Init帮你把这个位姿下的IMU状态初始化好,两者配合才能让系统稳定接管。如果你只做重定位不做初始化,FAST-LIO2启动瞬间会因为IMU零偏没估准而漂移,重定位结果很快就被带偏了。
LI-Init的使用方式一般是录一段启动数据,离线跑一遍得到初始化参数,然后把这些参数写进FAST-LIO2的配置文件。在线版本也有,但对运动激励有要求,静止启动的场景下效果一般。
3.3 fast_lio_localization:工程上更实用的重定位方案
fast_lio_localization这个仓库在社区里讨论度很高,它的思路很直接:用FAST-LIO2建好的PCD地图作为先验,用当前LiDAR帧和地图做配准来求全局位姿。核心流程分两步,先粗后精。
粗定位用的是降采样后的点云做NDT或者ICP,在一个比较大的搜索范围内找初始匹配。这一步对初值不敏感,但精度有限,通常能到米级。精定位用原始点云做ICP,在粗定位结果附近做精细配准,能到厘米级。
这个方案最大的优点是不依赖视觉,纯LiDAR就能跑,在光照变化大或者无纹理的环境里比视觉重定位稳得多。缺点是如果初始位姿离真值太远(比如超过地图范围的一半),粗定位容易陷入局部最优。
3.4 两条路线的对比与选型建议
| 维度 | LI-Init | fast_lio_localization |
|---|---|---|
| 核心功能 | LiDAR-IMU初始化 | 全局重定位 |
| 输入 | 启动段IMU+LiDAR数据 | 当前LiDAR帧+先验PCD地图 |
| 输出 | IMU零偏、重力方向、初始状态 | 全局位姿(位置+姿态) |
| 实时性 | 离线为主,在线版有延迟 | 可在线,单次重定位秒级 |
| 依赖 | 纯LiDAR+IMU | 纯LiDAR |
| 适用场景 | 系统启动阶段 | 开机定位、丢失恢复 |
实际项目里这两个不是二选一,而是配合使用。典型流程是:开机先跑fast_lio_localization拿到全局位姿,然后用这个位姿作为先验跑LI-Init初始化IMU状态,最后把初始状态喂给FAST-LIO2接管里程计。这样整套系统就能在已知地图里无缝启动。
4. 保姆级实操:用fast_lio_localization搞定重定位
4.1 环境准备与依赖检查
假设你已经有一个跑通FAST-LIO2的ROS工作空间。fast_lio_localization需要额外装几个东西:ndt_omp(用OpenMP加速的NDT)、pcl_ros、tf2相关包。Ubuntu 20.04 + ROS Noetic的组合最省心,18.04 + Melodic也能跑但有些包版本要对齐。
先把仓库clone到你的src目录下,然后catkin_make。编译的时候注意,如果报ndt_omp找不到,去它的GitHub单独clone一份放进去。我遇到过好几次都是这个依赖没装全。
编译通过后,检查一下你的PCD地图。fast_lio_localization要求地图是世界坐标系下的全局点云,不是某一帧的局部点云。如果你之前用FAST-LIO2建图时保存的是每帧的scan,需要先用pcl_ros的pointcloud_to_pcd或者自己写个脚本把所有帧拼起来。地图的坐标系原点最好设在场景的一个明显角落,方便后面手动定位时对照。
4.2 配置文件的关键参数
fast_lio_localization的配置文件里几个参数必须改对,不然重定位根本出不来结果。
# 全局地图路径 map_path: "/home/user/maps/global_map.pcd" # 粗定位搜索范围(米) rough_search_radius: 30.0 # 粗定位降采样体素 rough_downsample_size: 0.5 # 精定位降采样体素 fine_downsample_size: 0.1 # ICP最大迭代次数 icp_max_iter: 50 # 收敛阈值 icp_fitness_threshold: 0.3rough_search_radius这个参数很关键。如果你的场景比较大,比如园区级地图,30米可能不够,要调到50甚至100。但调太大粗定位会变慢,而且容易匹配到错误的相似区域。我的经验是设成地图对角线长度的1/4左右比较合适。
icp_fitness_threshold是判断配准是否成功的阈值,值越小越严格。室内场景0.3够用,室外大场景可以放宽到0.5。如果重定位经常失败,先看这个值是不是设太严了。
4.3 完整重定位流程
启动顺序很重要,错了会各种报错。
第一步,启动roscore,然后rviz。rviz里要添加几个显示项:PointCloud2显示全局地图、PointCloud2显示当前帧、PoseStamped显示重定位结果、TF显示坐标变换。
第二步,加载全局地图。用pcl_ros的pcd_to_pointcloud节点把PCD发到/global_map话题上。这一步只是可视化用,实际配准是直接读文件的。
第三步,启动fast_lio_localization节点。它会订阅当前LiDAR帧,等收到第一帧后开始粗定位。这时候你在rviz里应该能看到当前帧和地图叠在一起,但位置是错的。
第四步,如果自动粗定位失败,就手动给一个初始位姿。在rviz里用2D Pose Estimate工具,在地图上点一下并拖出朝向。这个操作会发布一个/initialpose话题,fast_lio_localization收到后会以这个位姿为初值做精配准。
第五步,精配准成功后,节点会发布/localization_result话题,里面是全局位姿。同时会发布一个从map到camera_init的TF变换。FAST-LIO2订阅这个TF后,就会把自己的里程计输出变换到全局坐标系下。
4.4 rviz手动定位的实操技巧
手动给初始位姿这个操作看起来简单,但有几个细节决定了成败。
技巧一:先看地图再点。rviz里把全局地图的显示调成单色(比如白色),当前帧调成红色。这样你能清楚看到当前帧的几何特征和地图哪个区域匹配。找一个特征明显的角落,比如墙角、柱子、门口,在这些地方给初始位姿比在空旷区域准得多。
技巧二:朝向要大致对。2D Pose Estimate拖出来的箭头方向就是初始朝向。如果朝向差太多(超过90度),ICP很容易收敛到错误的方向。你可以先根据场景的朝向大致判断,比如走廊场景,箭头沿着走廊方向。
技巧三:多点几次。一次不成功就换个位置再点。fast_lio_localization每次收到/initialpose都会重新做精配准,不会累积错误。我一般会试三到四个不同的初始位置,取fitness最小的那个结果。
技巧四:配合键盘微调。rviz的2D Pose Estimate精度有限,如果精配准结果差一点点,可以用teleop_twist_keyboard发布小速度让机器人动一下,FAST-LIO2接管后会自动修正。但注意这个操作要在重定位成功之后做,不然会把初始位姿带偏。
提示:如果rviz里地图和当前帧怎么都对不上,先检查两者的坐标系是不是一致。FAST-LIO2默认输出的是
camera_init坐标系,而PCD地图可能是map或者world。用tf_echo看一下变换关系,不对的话在配置文件里改frame_id。
5. 常见问题与排查技巧实录
5.1 重定位失败问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 粗定位完全无输出 | 地图路径错误或PCD为空 | pcl_viewer打开PCD看有没有点 | 检查路径,重新导出地图 |
| 粗定位结果偏差大 | 搜索半径太小 | 看rviz里当前帧和地图距离 | 调大rough_search_radius |
| 精配准不收敛 | 初始位姿太远 | 看ICP迭代日志 | 手动给更近的初始位姿 |
| 配准成功但位姿跳变 | 场景有重复结构 | 对比多个候选位姿 | 换特征明显的区域重定位 |
| TF变换报错 | 坐标系不统一 | rosrun tf view_frames | 统一frame_id配置 |
| 重定位后里程计漂移 | IMU未初始化 | 看LI-Init是否跑过 | 补跑LI-Init初始化 |
5.2 地图质量决定重定位上限
我踩过最大的坑就是地图本身有问题。有一次建图的时候FAST-LIO2中途跟丢了,地图里有一段是重影的,结果重定位怎么都配不准。后来重新建了一遍图,一次就成功了。
建图的时候要注意几点:走慢一点,让LiDAR有足够的重叠;避免在长走廊或者空旷广场快速移动,这些地方几何约束弱,容易漂;建完图用pcl_viewer检查一下有没有明显的分层或者重影。地图质量不行,后面重定位算法再牛也救不回来。
5.3 多楼层场景的特殊处理
如果你的场景是多楼层,一张全局地图会包含不同楼层的点云,重定位的时候容易匹配到错误楼层。解决办法是按楼层分地图,每层存一个PCD,重定位的时候根据当前高度或者电梯状态切换地图。fast_lio_localization本身不支持多地图切换,需要自己写个调度节点,根据/initialpose的z值选择加载哪张地图。
5.4 实时性优化经验
fast_lio_localization默认的粗定位用的是全地图降采样,如果地图特别大(比如上百万点),单次粗定位可能要好几秒。优化思路有两个:一是把地图按空间网格切块,只加载当前位置附近的块;二是粗定位用更激进的降采样,比如体素设到1.0米,精定位再恢复精度。
我在一个园区级地图(约80万点)上实测,全图粗定位要3.2秒,切块后降到0.8秒。切块的实现不复杂,就是按XY坐标把地图分成10米见方的格子,每个格子存一个PCD,重定位时根据初始位姿加载周围9个格子。
5.5 和FAST-LIO2的对接细节
重定位成功后,fast_lio_localization会发布map到camera_init的TF。FAST-LIO2的laserMapping节点里有个参数publish_tf,默认是true,它会发布camera_init到body的TF。两个TF串起来,map到body的变换就完整了。
但这里有个时序问题:如果FAST-LIO2比重定位先启动,它会先发布camera_init到body,然后重定位再补上map到camera_init,rviz里会看到机器人先出现在原点然后跳到正确位置。解决办法是让重定位先跑,拿到结果后再启动FAST-LIO2,或者接受这个跳变,反正最终位姿是对的。
还有一个坑是时间戳同步。fast_lio_localization用的LiDAR帧时间戳必须和FAST-LIO2的一致,不然TF会报 extrapolation 错误。检查一下两个节点订阅的是不是同一个LiDAR话题,时间戳是不是都用了header.stamp。
6. 从工程视角看这套组合的边界
FAST-LIO2 + ikd-tree + fast_lio_localization这套组合,在结构化场景下的表现确实不错,但它不是万能的。我总结了几条边界条件,供你在选型时参考。
第一,场景必须有足够的几何特征。纯走廊、大广场、隧道这类退化场景,LiDAR重定位基本没戏,因为不同位置的扫描长得太像了。这种场景要么加视觉,要么加UWB之类的绝对定位手段。
第二,地图必须和当前环境一致。如果场景里家具挪了位置、墙拆了,重定位精度会下降。轻微变化ICP能容忍,大范围变化就得重新建图。
第三,动态物体影响很大。重定位的时候如果前面站了一堆人,当前帧里全是动态点,配准会偏。fast_lio_localization没有动态点剔除,需要自己在预处理里加。简单做法是用passthrough按高度滤掉地面附近的人腿,或者用statistical_outlier_removal去掉离群点。
第四,ikd-tree的内存占用要监控。长时间运行后地图点数会持续增长,虽然ikd-tree有降采样,但如果场景特别大,内存还是会吃紧。建议加一个定时保存和清理的逻辑,比如每跑一小时把地图存一次PCD然后重置。
这套东西我前后调了大概两个月,从最开始重定位十次失败八次,到后来基本一次成功,中间踩的坑基本都写在上面的排查表里了。核心体会就一句:重定位的精度上限由地图质量决定,算法只是逼近这个上限。所以与其在算法参数上反复调,不如先把建图这一步做扎实。