预计完成时间:12月10日
这里选择了https://github.com/shijiehua/Fast_Lio_Change 的 Fast-Lio-Change 框架作为我们的分析对象。Fast-Lio-Change 框架在原版的基础上重构了代码,并且添加了比较详尽的注释,比较适合我这样的初学者阅读。
参数读取模块
在我们的实例对象 FastLioProcess 开始构造时,我们需要先进行一遍基本参数的初始化设置。如果我们打开 FastLioProcess.h 文件可以看到很多参数类型已经提前声明在类里面了。参数读取模块顾名思义就是把我们写在 .yaml 文件里面的参数读取到程序中。SLAM 本身是一个比较工程化的项目,很多框架都依赖一些先验的参数(调参很重要),比如 Lidar 外参、IMU 外参这些东西。所以这里虽然比较臃肿,但还是有必要的。
一般来说这些 SLAM 框架的参数读取部分都大同小异,利用 ROS 的参数服务器进行合理的参数赋值。我在之后如果想要在此基础上进行修改,也不需要从头开始写,只需要添加我自己的参数即可。
代码实现
|
|
这里参数基本可以分为四大类:
-
硬件适配类(“不同的车,不同的雷达”)
比如
extrinT, extrinR这样的外参,除此之外还有激光雷达的线数、各种传感器的协方差等等。每次更换硬件进行 SLAM 处理都需要修改这些参数。 -
算力与性能平衡类
这些参数一般是我们算法实现的具体超参数。例如
filter_size_surf是我们的激光点云降采样体素大小,max_iterations是最大迭代次数。 -
环境适应类
例如
cube_side_length和blind,分别代表局部地图大小和盲区距离(其实后者更偏向于硬件适配)。我们需要根据具体的环境应用场景来做参数调试校准。 -
调试与可视化类
publish/scan_publish_en,pcd_save_en这些,一般是用来 debug 的。
我们重点看发布器和接受器的定义和初始化:
|
|
这里在接收到 Lidar 和 IMU 的 topic 之后,ROS 会自动调用下面的两个回调函数: livox_pcl_cbk 和 imu_cbk 。我们可以想像,之后应该是在这两个函数里面进行处理。首先可能要先解包,把 ROS 的数据格式转换为我们自定义的数据格式,然后把它放到我们的指定队列里面,供后续的去畸变模块来处理。
IMU 回调
一句话概括:复制 ROS 传递的 IMU 信息,放到一个 deque 里面。
这里很显然就是把 ROS 的 IMU message 放到我们的指定队列里面。仔细一看,确实,我们用到了一个叫做 imu_buffer 的妙妙工具,它在头文件里面的定义是: deque<sensor_msgs::Imu::ConstPtr> imu_buffer; 所以这个函数实际上放到队列里面的是 IMU 信息的指针。
sensor_msgs::Imu::Ptr msg(new sensor_msgs::Imu(*msg_in)); 这一句深拷贝了 ROS 回调出来的参数,这里需要调用 sensor_msgs::Imu 的拷贝构造函数。为什么不直接转移所有权呢?原因是通常在 ROS 的回调函数中,我们收到的消息是 const 指针(只读的),所以我们要深拷贝一份来作修改和下一步处理。
imu_temp 主要是用来去除异常数据的,可能会有 IMU 数据为 0 或者和上一帧重复之类的事情发生。这里还有一个特别神奇的操作:如果当前IMU的时间戳小于上一个时刻IMU的时间戳,则IMU数据有误,将IMU数据缓存队列清空,重新放数据。估计也是工程实践里面 debug 得到的宝贵教训吧。
既然涉及到多线程存取操作,那就离不开锁了。函数里很奇怪没有用 lock_guard 而是直接用了锁,那涉及到异常就可能会有死锁了,好孩子不要学。之后就是 sig_buffer.notify_all(); 告诉所有线程我执行完了,快来抢锁!
代码实现
|
|
Lidar 回调
一句话概括:同上,只不过中间可能要穿插一点点云的预处理。
其实 Lidar 回调还有 livox_pcl_cbk ,应该是专门针对的 livox 类型的激光雷达,可能它们的数据类型不太一样?不过这里我主要看常规的雷达回调部分,即 standard_pcl_cbk 。
这里的回调也和刚才的 imu 回调类似,也是要把一个激光雷达的信息塞到一个 deque 类型的容器里面。不过我感觉这里写得很奇怪。
首先又是典中典的加锁和时间戳顺序判断,这里因为如果时间戳错误会有 lidar_time_buffer.clear() 操作,所以加锁一定要放在最前面。之后看情况进行点云的预处理,把点云放到一个 PCL 的点云指针里面(甚至作者懒的写点云的具体处理流程)。最后分别把点云指针和时间放到两个 deque 里面。
代码实现
|
|
Lidar 点云预处理
一句话概括:按线数和回波次序筛选点之后下采样,把符合要求(点之间不能靠太近、点不要离我的机器人太近)的点放到一个 PCL 点云里面。
我们以 livox_avia_process 函数来分析点云的预处理方式。(题外话,看了 Livox 官网,avia 这款雷达要一万,还算便宜了,这些实验室真有钱啊)
函数的输入很简单,一个 ROS 的点云常量指针和一个用于输出的 PCL 类型的点云指针,输出为 void。 pl_surf pl_corn pl_full 是三个点云类型的变量,因为一直要用到所以就提前在类里面初始化了。具体的计算细节在 for 循环里面。
if((msg->points[i].line < N_SCANS) && ((msg->points[i].tag & 0x30) == 0x10 || (msg->points[i].tag & 0x30) == 0x00)) 这里首先约束了点所在的线数要小于N_SCANS,只处理第 0 条线到第 N_SCANS-1 条线的点;其次要求回波次序是 0 或者 1 (妈的,邪恶的位运算技术),也就是说只处理坚硬的障碍物(前两次反射),不关注多次反射之后的结果(例如茂密的植被后面的物体)。
点云的具体排列方式如下:

之后就是等间距的下采样: valid_num % point_filter_num == 0 ,我们把采样之后的点云放到 pl_full 里面。后面的 count_out 和 count_near 感觉是给调试使用的,我们暂时不关注。最后,只有和上一个点相距大于最小阈值,并且不在我们屏蔽范围内(也就是不是小车上面的点),我们才会把它放到 pl_surf ,即最终的输出点云里面(不过这里有一个深拷贝,说实话感觉用 std::move() 更好)
代码实现
|
|
点云-IMU的对齐封装
终于,到了激动人心的时间帧对齐环节!在刚才,我们的 IMU 和点云数据都被放到了队列里面,我们还有它们对应的时间帧。这个时候,按我的理解,需要采用合适的方法对齐两者的时间点,然后封装到一个帧里。当然,因为 ROS 的写入和我们的读取过程不在同一个线程内,我们在这里还需要考虑加锁。
💡 2025/12/10 补充
注意,这里的加锁是一个很大的问题,涉及到 FAST-LIO 系列的线程同步和竞态。首先,代码里面只有
sig_buffer.notify_all(),没有sig_buffer.wait(), 认为这里是多余的。其次,关于为什么插入线程要加锁,弹出线程不加锁。Gemini 和 DeepWiki 给出了不同的答案,但它们都认为代码作者写得有问题。Gemini 认为:代码中并没有调用
ros::AsyncSpinner或ros::MultiThreadedSpinner。这意味着整个节点只有一个主线程,多线程的加锁完全没有意义,它是靠轮询来模仿多线程的。DeepWiki 认为:主线程的sync_packages()函数没有加锁主要是因为性能考虑和执行时序的依赖,但这种设计确实存在理论上的竞态条件风险。我看了一下代码感觉 Gemini 说的更有道理一点,在
run()函数里面我们是先执行一次ros::spinOnce()来处理回调,之后再调用sync_packages()。确实从头到尾都在一个线程里面,没必要加锁。
看输入,是一个我们封装好的引用 meas ,具体含义可以去看 common_lib.h 头文件。这代表我们每个时间帧的所有测量结果,我们需要把 IMU 和 Lidar 的信息放进去。成功就 true,反之 return false。
在看下面的实现之前,我们可以先思考一个问题,IMU 的测量是一串离散的数值,那我们的一个数据包里面要包含从哪个时刻到哪个时刻的 IMU 测量才能保证对齐呢?除此之外,我们知道
接下来首先是边界条件,要确定 Lidar 和 IMU 队列里面不是空的。之后我们偷偷看一眼lidar_buffer里面的数据(之所以是偷看,是因为我们不在这里把它 pop_front())。作者在这里用了一个 flag lidar_pushed 来表示这一帧的雷达数据是否已经放到包里面。如果还没有放进去,那意味着两种情况:
- 这是第一次扫描。我们多做一个处理来应对边界条件:把
last_lio_update_time记作 buffer 里面待处理点云的时间,之后执行下面的步骤。 - 这不是第一次扫描。那我们需要从
lidar_buffer里面复制待处理点云以及它所在的时间帧。注意到激光雷达的扫描是一个连续的过程,所以我们还要计算点云扫描结束的段时间。
我们的预期实现是 IMU 数据正好覆盖整个 Lidar 扫描过程(这也是我们刚才要计算点云扫描结束时间的原因)。于是,接下来我们等待 imu_buffer 里面的 IMU 的最新时间超过 Lidar 扫描结束的时间,如果 IMU 扫描的数据量不够,那就 return false 等待下一次循环(这里就体现了我们刚才使用 lidatr_pushed 这个 flag 的好处了,之后就不用重复赋值)
接下来,一切准备就绪,我们要执行时间帧对齐(我猜这里应该用球面 slerp ?)。这里又给出了一个奇怪的边界条件检查 lidar_end_time < imu_time ,为了防止出现 Lidar 前面没有 imu 的情况,这种情况这一帧的 Lidar 就废掉了。接下来把 imu_buffer里面的数据一个个放到 meas.imu 里面即可,Lidar 数据也同理。
代码实现
|
|
线程唤醒
之前已经讨论过, FAST-LIO2 的多线程有点奇怪,实际上使用 ROS1 的 ros::spinOnce() 来轮询模拟多线程。感觉这段代码有点没必要。
代码实现
|
|
主函数
前面是很正常的 ros::Rate 和循环等参数设置,每次在回调函数执行之后收集每帧数据包,如果数据包收集失败(例如 imu_buffer 里面的数据量不够)就 sleep 一会。
flg_first_scan 是作者对于第一次测量的边界处理,但似乎因为在执行的代码里面已经做过了类似的处理,所以这里只是现实简单的调试信息。之后就是最核心的函数:
- 前向传播:
processImu()
代码实现
|
|