历史快照
本专题页写于包拆分与 FAST-LIVO2 / CustomMsg / 图像输入落地之前,反映当时的单包结构与
五前端状态。已知过时点:目录布局(apps/launch 现属 asuka_ros 包)、共享的
config_sensor.json(外参已改为各前端 odometry 配置的 sensor 块)、ROS 层 acc_scale
等描述、前端数量(现六种,新增 fastlivo)、CustomMsg 与图像输入链路。2026-09 下旬以来:lightning / superodom 前端与 optimization_miao 后端已移除
(现五前端:fastlio / batchlio / superlio / fastlivo / srlivo;smallpointlio 已于 2026-10-23 移除,odometry/10-11 为其历史快照);
freedom 扩展已整体迁出 asuka(移入 nagisa 包);KeyFrame::stamp 统一为扫描
起始时刻;执行器新增连续失败熔断与 wait_for() 排空检测、扩展与执行器有
ok() 健康查询。
现行事实以主文档 (index / architecture / parameters / odometry 等)与源码为准。
title: "FastLIO 里程计主流程" description: >- FastLIO 实现(16.8KB cpp),配合仓库内 thirdparty/fastlio 的 esekfom/ikfom 误差状态框架与 ikd_tree 地图结构完成状态估计与点面配准。
FastLIO 里程计主流程
模块定位与职责
asuka::fastlio::OdometryEstimation 是五种里程计插件之一,继承自 asuka::OdometryEstimation 抽象基类(头文件 L39),实现完整的 LIO 主循环:
- 数据接入:接收上游异步执行器转发的 IMU 与点云,维护有界环形缓冲;
- 时间同步:将一帧激光与覆盖其扫描时段的 IMU 测量打包(
sync_packages); - IMU 前向传播与去畸变:委托给 FastLIO 专属的
ImuPreprocess(另有专页详述); - 点面配准:基于
thirdparty/fastlio的 esekfom/ikfom 迭代误差状态卡尔曼滤波,观测模型由本类的h_share_model提供; - 地图维护:以 ikd_tree 增量 KD 树为局部地图,配合视域滑窗裁剪与增量加点;
- 结果输出:每帧构造
KeyFrame并通过全局回调Callbacks::on_new_frame发布;地图不常驻累积,save_map()被调用时从 ikd-tree 现场导出。
插件接入面极小:create.cpp 全文仅 6 行,导出 extern "C" create_odometry_estimation() 供 dlopen 工厂调用(create.cpp L3-L5)。
构造装配(odometry_estimation.cpp L10-L49)
构造函数完成四件事:
- 创建私有预处理:
ImuPreprocess与CloudPreprocess(后者通过cloud_preprocess()暴露给 ROS 层,供点云入口统一调用); - 读取配置:
config_odometry提供max_iteration(默认 4)、laser_point_cov(0.001)、fov_degree、filter_size_surf/map、cube_side_length、max_distance(作为det_range)、缓冲容量(lidar 50 / imu 1000)与mapping.enable_map_saving(门控save_map()的地图导出); - 初始化滤波器:
kf.init_dyn_share(ikfom::get_f, ikfom::df_dx, ikfom::df_dw, &h_share_model_trampoline, max_iterations, epsi)——过程模型与雅可比由use_ikfom.hpp提供的state_ikfom(含 rot/pos/vel/bg/ba 与雷达-IMU 外参offset_R_L_I/offset_T_L_I)定义,观测函数以函数指针形式注入;收敛阈值epsi[23]全部填 0.001; - 初始化地图结构:VoxelGrid 叶子大小设为
filter_size_surf,ikd_tree 降采样参数设为filter_size_map。
关键状态一览
| 状态 | 作用 |
|---|---|
lidar_buffer / time_buffer / imu_buffer |
有界环形缓冲,buffer_mutex 保护 |
lidar_pushed / pending_lidar* / lidar_end_time |
sync 阶段的"当前帧已取出但未凑齐 IMU"中间态 |
last_timestamp_imu |
insert_imu 更新,用于判断 IMU 是否覆盖到帧尾 |
lidar_mean_scantime / scan_num |
扫描时长滑动均值,短帧/坏帧的兜底估计 |
first_scan_flag |
首帧只做 imu_preprocess->initialize(first_lidar_time) 后直接返回 |
ekf_inited_flag |
首帧起 init_time(0.1s) 后为真,门控 map_incremental 的去重点判断 |
localmap_initialized / local_map_points |
局部地图包围盒状态,驱动滑窗裁剪 |
state_point / pos_lid / euler_cur |
最新后验状态缓存,供坐标变换与 FOV 段使用 |
active(static thread_local) |
trampoline 转发用的当前实例指针(见下文) |
process_once 主链路
flowchart TD
A["process_once(): active = this"] --> B{"sync_packages(measures)"}
B -- "缓冲为空 / IMU 时间不足" --> Z["返回 false"]
B -- "打包成功" --> C{"first_scan_flag?"}
C -- "是" --> C1["imu_preprocess->initialize(first_lidar_time)"] --> Z2["返回 true"]
C -- "否" --> D["imu_preprocess->process(measures, kf, feats_undistort)<br/>前向传播 + 点云去畸变"]
D --> E{"feats_undistort 为空?"}
E -- "是" --> Z3["warn 跳过"]
E -- "否" --> F["lasermap_fov_segment() 局部地图滑窗"]
F --> G["VoxelGrid 降采样 → feats_down_body"]
G --> H{"ikdtree.Root_Node 为空?"}
H -- "是(且点数>5)" --> H1["body→world 变换后 ikdtree.Build()"] --> Z4["返回 true"]
H -- "否" --> I{"feats_down_size < 5?"}
I -- "是" --> Z5["warn 跳过"]
I -- "否" --> J["kf.update_iterated_dyn_share_modified(laser_point_cov)"]
J --> K["迭代中反复回调 h_share_model:<br/>ikdtree 近邻 → 平面拟合 → 残差/雅可比"]
K --> L["map_incremental() 增量加点"]
L --> M["OMP 并行坐标变换<br/>cloud_world / cloud_body"]
M --> N["构造 KeyFrame → Callbacks::on_new_frame"]
关键节点说明:
process_once的返回语义(L99-L105):sync_packages失败返回 false(本轮无活可干);一旦打包成功即调用process_scan并返回 true——即使process_scan内部因点数过少而提前返回,该帧也已消费。异步执行器靠循环调用本函数排空计算。- 首帧特例(L156-L161):第一帧不参与估计,仅用其时间戳初始化 IMU 预处理。
- 初始建图分支(L183-L192):ikd树为空时,把降采样点经
point_body_to_world(用rot * (offset_R_L_I * p + offset_T_L_I) + pos全外参链)变换到世界系后ikdtree.Build,本帧不做配准。
sync_packages:帧-IMU 时间对齐(L107-L153)
- 任一缓冲为空即返回 false;
- 首次遇到一帧(
!lidar_pushed)时计算帧尾时间:末点offset/1000.0(毫秒转秒)为扫描时长;点数 ≤ 1 或时长小于0.5 × lidar_mean_scantime时退化为均值估计,否则按指数滑动更新均值(scan_num递增); - 若
last_timestamp_imu < lidar_end_time,保持lidar_pushed=true等待后续 IMU,返回 false; - 收集时间戳不晚于帧尾的 IMU 打入
measures,随后激光帧与时间戳出队、复位lidar_pushed。
这一段是 rosbag 回环/丢 IMU 等异常的第一道闸门:帧只有在 IMU 完整覆盖后才进入滤波。
观测模型与 trampoline 模式
esekfom 要求以裸函数指针注册观测函数,而 h_share_model 需要访问实例成员。实现采用 static thread_local OdometryEstimation* active(hpp L121)+ 静态 trampoline(L330-L332):process_once 入口处 active = this,滤波器每轮迭代回调 trampoline 时转发到当前线程的活跃实例。这样既满足 C 风格回调签名,又允许多线程各持一个实例。
sequenceDiagram
participant KF as esekfom::esekf (kf)
participant TR as h_share_model_trampoline
participant OE as active 实例
participant MAP as ikdtree
KF->>TR: 每轮迭代请求观测(h_x, h)
TR->>OE: 转发(state, dyn_share_data)
OE->>MAP: converge 时 Nearest_Search(5近邻)
MAP-->>OE: points_near / sq_dis
OE->>OE: estimate_plane(QR 拟合) + score>0.9 门控
OE-->>KF: h_x(N×12)、h(N)、valid/converge
h_share_model 内部(L334-L428):
- 每个降采样体点按候选状态投到世界系;仅当
ekfom_data.converge时才重新做近邻搜索(近邻数不足或最远距离平方 > 5 即剔除); estimate_plane(hpp L70-L94)用 5 近邻做最小二乘平面拟合(colPivHouseholderQr),任一点到平面偏差超 0.1 即拒绝;再以score = 1 - 0.9·|pd2|/√‖p_body‖ > 0.9二次门控;- 有效点压缩进
laser_cloud_ori/corr_normvect,pd2暂存在法向量点的intensity字段;有效点数为 0 时置valid=false并告警; - 雅可比每行 12 列:
[n, A, 0, 0],其中A = [p]× · R⁻¹n;外参列恒置零——L418-L425 被注释的extrinsic_est_en分支保留了在线估计外参的完整实现,是现成的扩展开关; h(i) = -norm_p.intensity,即负的点到面距离残差。
局部地图维护
lasermap_fov_segment(L256-L309):以雷达位置为中心维护边长 cube_len 的局部地图包围盒。当任一轴到盒边距离小于 mov_threshold(1.5) × det_range 时,整盒平移 mov_dist,把移出区域作为 BoxPointType 收进 cub_needrm,调用 ikdtree.Delete_Point_Boxes 裁剪,使地图始终跟随传感器。
map_incremental(L430-L474):滤波收敛后逐点决定加入策略——
- 无近邻或
ekf_inited_flag未置位:直接加入(要求降采样); - 计算该点所在体素中心
mid_point;若最近点在三个轴上都远离该体素(判定为互补观测),作为"无需再降采样"的点以downsample=false加入; - 否则与 5 近邻比较到体素中心的距离,仅当新点更近才加入(
downsample=true交给 ikd_tree 判重)。
这个"按体素中心择优"的策略是 FastLIO 控制地图密度、避免重复点的核心。
输出与地图保存
每帧结束(L214-L254):
- OMP 并行生成
cloud_world(世界系)与cloud_body(IMU 体坐标系,经外参变换); - 构造
KeyFrame:自增id、帧尾时间戳、FrameId::IMU、T_world_imu、v_world_imu = state_point.vel、imu_bias(bg/ba),并发布到Callbacks::on_new_frame供可视化/优化模块订阅; - 注意 L237-L239 注释:
cloud_body是复用的成员缓冲,因此给 KeyFrame 深拷贝一份cloud_imu,观察者可任意长期持有——这是成员缓冲复用与对外发布之间的关键隔离点。
地图不常驻累积:save_map() 在被调用时(此时 worker 已被上层 stop/join)在 buffer_mutex 下 flatten ikd-tree、导出全部有效点为 PointCloudT,受 mapping.enable_map_saving 门控(关闭时返回空)。由于 lasermap_fov_segment 会裁剪远离视场的方块,导出内容是滑动局部地图——当前默认 cube_side_length=1000 时覆盖整条轨迹,调小 cube 后仅含当前窗口。安全依赖上层 AsyncOdometryEstimation::save() 先 join 工作线程的契约(与"地图保存与退出时序"页所述风险点呼应)。
边界条件汇总
| 条件 | 处理 |
|---|---|
| 点数 ≤ 1 或扫描时长远小于均值 | 用 lidar_mean_scantime 兜底并告警 |
| IMU 未覆盖到帧尾 | sync 挂起该帧继续等待 |
| 去畸变后点云为空 | 跳过本帧(帧已消费) |
| ikd树为空且降采样点 > 5 | 仅建初始地图,不做配准 |
| 降采样点 < 5 | 跳过配准 |
| 有效面点为 0 | valid=false,滤波器本轮拒绝更新 |
| 缓冲容量上限 | 环形缓冲自动淘汰最旧数据(lidar 50 / imu 1000) |
扩展点
- 外参在线估计:取消 L418-L425 注释并接入
extrinsic_est_en配置即可启用(B 块雅可比已写好); - 并行度:
FASTLIO_ENABLE_OMP/FASTLIO_NUM_THREADS编译宏控制两处热点(坐标变换、近邻+拟合)的 OpenMP 并行;近邻距离缓冲走tls_search_sq_dis线程局部存储避免竞争; - 参数面:迭代次数、点面协方差、两级体素滤波、立方体边长、探测距离、缓冲容量均可经
config_odometry.json调整; - 换算法:
so_name切到其它实现即可,本类只依赖基类契约,与框架零耦合。
阅读路线建议
按 create.cpp(最小插件样例)→ 本页主流程 → "FastLIO IMU 预处理"(前向传播细节)→ "FastLIO 点云预处理与插件注册" → thirdparty 的 use_ikfom.hpp/esekfom.hpp 与 ikd_tree 的顺序阅读,可完整覆盖该算法的数据流与数学框架。
Sources:
- src/asuka/src/asuka/odometry/fastlio/odometry_estimation.cpp#L1-L240
- src/asuka/src/asuka/odometry/fastlio/odometry_estimation.cpp#L217-L487
- src/asuka/include/asuka/odometry/fastlio/odometry_estimation.hpp#L1-L120
- src/asuka/include/asuka/odometry/fastlio/odometry_estimation.hpp#L120-L185
- src/asuka/src/asuka/odometry/fastlio/create.cpp#L1-L6
关联源码
src/asuka/src/asuka/odometry/fastlio/odometry_estimation.cppsrc/asuka/include/asuka/odometry/fastlio/odometry_estimation.hpp