sync_packages()是 FAST-LIO 每帧处理真正开始前的“拼包函数”。它不做 IMU 积分、不做点云去畸变、不做 ikd-Tree 匹配,也不做 IESKF 优化;它只负责把:
一帧 LiDAR 点云 + 该帧扫描结束前已经到达的 IMU 数据 + 当前帧的 LiDAR 起止时间打包进MeasureGroup Measures,然后交给:
p_imu->Process(Measures, kf, feats_undistort);后面的ImuProcess才会利用这个包完成 IMU 前向传播和逐点去畸变。sync_packages()的本质是:保证当前 LiDAR 帧只有在“时间边界确定、IMU 已覆盖扫描结束时刻”后才允许进入后端。
1.它位于整条 FAST-LIO 链路的哪里
LiDAR 回调 ↓ p_pre->process() ↓ lidar_buffer + time_buffer IMU 回调 ↓ imu_buffer + last_timestamp_imu ↓ sync_packages(Measures) ↓ 得到: Measures.lidar Measures.lidar_beg_time Measures.lidar_end_time Measures.imu ↓ p_imu->Process() ↓ IMU 前向传播 + 点云去畸变 ↓ feats_undistort ↓ 体素降采样 ↓ ikd-Tree 点到平面约束 ↓ IESKF ↓ map_incremental()因此,sync_packages()的输出不是位姿,也不是去畸变点云,而是一个“当前时段传感器数据包”。后续模块默认认为这个包的时间关系可信。
2.先明确:它同步的到底是什么
很多人会把sync_packages()理解成“给 LiDAR 和 IMU 做精确时间同步”。这不够准确。
它实际完成的是:
【数据包同步 / 数据就绪判断】 1.选定当前最早未处理的一帧 LiDAR。 2.确定该帧的扫描起止时间。 3.等待 IMU 时间戳已经到达扫描结束时刻之后。 4.从 IMU 队列取出本帧需要的 IMU 数据。 5.返回 true,让后续模块处理这一帧。它不直接完成下面三件事:
1.不做 LiDAR-IMU 外参标定。 2.不逐点匹配 LiDAR 时间和 IMU 时间。 这件事在 UndistortPcl() 中完成。 3.不真正消除硬件时钟偏差。 它只能使用当前消息时间戳进行拼包。所以更准确地说:
sync_packages()是“帧级数据拼包器 + 时间覆盖门控器”,不是高精度时钟同步算法。
LiDAR 与 IMU 的硬件时间偏差、ROS 时间戳偏差、驱动时间戳语义错误,都会直接影响它后续拼出的包。
3.进入sync_packages()前,缓存里有什么
函数依赖四个最重要的全局缓存和状态变量。
【LiDAR 缓存】 lidar_buffer :保存已经经过 preprocess.cpp 处理的 LiDAR 点云。 time_buffer :保存每帧 LiDAR 消息 header 时间戳。 【IMU 缓存】 imu_buffer :按时间顺序保存 IMU 消息。 last_timestamp_imu :当前已接收到的最新 IMU 时间戳。 【拼包状态】 lidar_pushed :当前 LiDAR 帧是否已经锁定到 Measures 中。 lidar_end_time :当前锁定 LiDAR 帧的扫描结束时刻。 lidar_mean_scantime :历史有效扫描周期均值。 scan_num :已用于统计扫描周期的有效帧数。标准 LiDAR 回调中,点云会先经过:
p_pre->process(msg, ptr);然后进入:
lidar_buffer.push_back(ptr); time_buffer.push_back(msg->header.stamp.toSec());IMU 回调则把经过时间偏移修正后的消息压入imu_buffer,并更新last_timestamp_imu。
这里有一个非常重要的隐含假设:
time_buffer 中 LiDAR header.stamp 被当作“当前帧扫描开始时刻”。因为后面直接写:
meas.lidar_beg_time = time_buffer.front();如果某个驱动发布的 header 时间实际表示“扫描结束时刻”或“包中间时刻”,FAST-LIO 仍会把它误当扫描起点,导致整帧时间整体偏移。这个问题会让去畸变看起来像外参错误或 IMU 漂移,但根因其实是 LiDAR 时间戳语义不一致。该实现确实直接将消息时间戳写入time_buffer并作为lidar_beg_time使用。
4.函数入口:为什么 LiDAR 或 IMU 缓存为空就直接返回
函数开头逻辑是:
if (lidar_buffer.empty() || imu_buffer.empty()) { return false; }含义很直接:
没有 LiDAR: 无法形成一帧点云。 没有 IMU: 无法估计扫描期间运动。 因此: 当前帧不能进入 ImuProcess。这里返回false并不表示“定位失败”,只表示“数据还没到齐,继续等”。
主循环会反复调用sync_packages():
ros::spinOnce(); if (sync_packages(Measures)) { p_imu->Process(Measures, kf, feats_undistort); ... }只有返回true,后续的 IMU 去畸变、点云匹配和 IESKF 才会开始。
5.第一步:锁定当前最早的一帧 LiDAR
真正的 LiDAR 拼包逻辑由lidar_pushed控制。
if (!lidar_pushed) { meas.lidar = lidar_buffer.front(); meas.lidar_beg_time = time_buffer.front(); ... lidar_pushed = true; }这一段的关键不是“取第一帧点云”这么简单,而是:
当前 LiDAR 帧一旦被选中,就会被锁定,直到 IMU 覆盖其扫描结束时刻。
例如:
当前缓存: lidar_buffer: [L0, L1, L2] imu_buffer: [U0, U1, U2, U3] 当前要处理: L0第一次进入时:
Measures.lidar = L0 Measures.lidar_beg_time = time(L0) lidar_pushed = true但如果此刻 IMU 还没到 L0 的扫描结束时刻,函数会返回false。
下一次进入时,由于:
lidar_pushed = true它不会重新拿 L1,也不会重新计算 L0 的开始时间,更不会把 L0 从队列弹出。它会继续等 L0 对应的 IMU 到齐。
第一次调用: L0 已锁定 IMU 不足 ↓ return false 第二次调用: 仍然是 L0 继续等待 IMU ↓ return false 第三次调用: IMU 已覆盖 L0 结束时刻 ↓ 拼包成功这保证 LiDAR 和 IMU 不会错帧配对。
6.第二步:当前 LiDAR 帧的结束时间怎么计算
这部分是sync_packages()最关键的时间逻辑。
一帧 LiDAR 不是瞬间得到的。假设 LiDAR 为 10 Hz,一帧扫描大约持续 100 ms。系统必须知道扫描结束时刻,才能知道要等待 IMU 到什么时间。
代码通常使用当前点云最后一个点的:
PointType::curvature来估计当前帧扫描结束时间。
【正常扫描结束时间】 t_end = t_begin + curvature_last / 1000 变量: t_begin :当前 LiDAR 帧开始时间。 对应 meas.lidar_beg_time。 curvature_last :当前点云最后一个有效点的相对时间偏移。 /1000 :因为 curvature 在 FAST-LIO 中按毫秒 ms 保存, 除以 1000 后变成秒 s。 t_end :当前 LiDAR 扫描结束时刻。例如:
【示例:10 Hz LiDAR】 t_begin = 100.0000 s curvature_last = 98.7 ms t_end = 100.0000 + 98.7 / 1000 = 100.0987 s这说明当前帧从开始到结束持续约 98.7 ms。
这里的curvature不是普通意义的几何曲率。它是在preprocess.cpp中被复用成“逐点相对扫描时间”。Livox 通常来自offset_time,Ouster 通常来自t,Velodyne 没有逐点时间时可以通过 yaw 和扫描频率估计。
6.1 为什么不用下一帧 LiDAR 的时间戳当结束时间
理论上可以用:
下一帧起点 - 当前帧起点估计扫描周期。
但这样有两个问题:
问题 1: 必须等待下一帧 LiDAR 到来, 当前帧会额外增加一个扫描周期延迟。 问题 2: 下一帧消息时间可能抖动、丢帧、乱序, 不能保证准确代表当前帧扫描结束。FAST-LIO 直接利用当前点云内部最后一点的逐点时间,因此可以在下一帧 LiDAR 到来前确定当前帧的结束时刻。这个设计可以降低帧级等待延迟。
6.2 为什么有“扫描周期均值兜底”
代码有两类异常情况不会直接相信最后点的curvature。
情况 1: 当前点云点数 <= 1。 情况 2: 最后点相对时间明显太小: curvature_last / 1000 < 0.5 × lidar_mean_scantime此时使用:
【异常时的扫描结束时间】 t_end = t_begin + T_scan_mean 变量: T_scan_mean :历史有效扫描周期均值。 对应变量: lidar_mean_scantime。这属于经验保护。
比如系统历史上判断当前 LiDAR 一帧通常为 100 ms,但当前最后点时间却只有 2 ms。若直接相信:
t_end = t_begin + 0.002 s系统会错误认为点云是瞬时完成的,只取到极少 IMU 数据,后续去畸变会严重错误。
所以代码判断:
若最后点时间小于历史平均扫描周期的一半, 更可能是逐点时间缺失、点云截断、点顺序异常或时间字段错误, 于是改用历史平均周期。正常帧会更新历史均值:
【在线更新扫描周期均值】 T_mean(n) = T_mean(n-1) + [ T_scan(n) - T_mean(n-1) ] / n 变量: T_mean(n-1) :前 n-1 帧的平均扫描周期。 T_scan(n) :当前有效 LiDAR 帧扫描周期。 n :scan_num。这不是滑动窗口均值,而是从启动以来的累计均值。扫描频率稳定时它很平滑;若 LiDAR 频率中途动态改变,它会有明显滞后。
6.3points.back().curvature有一个隐含前提
源码直接使用:
meas.lidar->points.back().curvature因此它隐含要求:
当前 LiDAR 点云中最后一个点, 必须接近扫描时间上最晚的点。也就是说,点云顺序应基本按扫描时间递增。
如果驱动或预处理过程打乱点云顺序,例如:
真实时间顺序: 0 ms → 20 ms → 40 ms → 60 ms → 80 ms → 100 ms 实际容器顺序: 0 ms → 100 ms → 20 ms → 80 ms → 40 ms那么points.back()可能对应 40 ms,而不是 100 ms。sync_packages()会错误认为扫描已经在 40 ms 时结束,导致后半段真实运动没有被正确覆盖。
后续UndistortPcl()会对点云按curvature排序,但那已经发生在sync_packages()之后。因此,在当前实现里,扫描结束时间依赖 preprocess 输出点的原始排列顺序。这是源码行为直接推导出的重要前提。
7.MARSIM 为什么把扫描结束时间设成开始时间
代码对 MARSIM 有特殊分支:
if (lidar_type == MARSIM) { lidar_end_time = meas.lidar_beg_time; }也就是:
t_end = t_begin含义是:
当前 MARSIM 点云被假设为“瞬时快照”。 整帧点云不被当作 从 t_begin 扫到 t_end 的连续扫描。因此sync_packages()不等待一个完整扫描周期的 IMU 覆盖,而是按当前点云时间戳处理。
但这只是sync_packages()层面的定义。你前面看的IMU_Processing.cpp中,MARSIM 后续会使用:
上一帧 LiDAR 时间 ↓ 当前 LiDAR 时间作为 IMU 传播的时间区间,同时跳过普通逐点 LiDAR 补偿。也就是说,MARSIM 的整体设计假设是“每帧点云本来就是瞬时产生”。
8.第三步:为什么必须等 IMU 覆盖 LiDAR 扫描结束时刻
LiDAR 帧结束时间确定后,代码检查:
if (last_timestamp_imu < lidar_end_time) { return false; }对应逻辑是:
【当前帧允许处理的条件】 t_imu_latest >= t_lidar_end 变量: t_imu_latest :当前已接收到的最后一条 IMU 时间戳。 t_lidar_end :当前 LiDAR 帧扫描结束时刻。原因是 FAST-LIO 的去畸变目标是:
把所有点统一补偿到扫描结束时刻 LiDAR 坐标系。要做到这一点,至少要知道扫描结束附近 IMU 的姿态、速度和位置。
例如:
当前 LiDAR: 扫描开始:100.000 s 扫描结束:100.098 s 当前最新 IMU: 100.080 s此时 100.080 s 到 100.098 s 这段运动还未知。若系统立刻开始处理:
扫描后半段点 ↓ 无法获得可信的结束时刻姿态 ↓ 去畸变参考坐标错误 ↓ 点云后半段容易拉伸、扭曲、重影所以必须先返回false,保留当前 LiDAR 帧,等新的 IMU 到达。
注意,这里等待的是:
时间戳覆盖不是“IMU 数据数量足够多”。
即使当前已经有 100 条 IMU,但最后一条仍然停在t_end前面,也不能处理;反过来,即使 IMU 数量不多,只要结束时刻被覆盖,代码就允许继续。
9.第四步:从 IMU 缓存中取出当前帧需要的 IMU
当:
last_timestamp_imu >= lidar_end_time满足后,函数才真正开始填充:
meas.imu逻辑可以概括为:
从 imu_buffer 队首开始: 若 imu_time <= lidar_end_time: 加入 meas.imu 从 imu_buffer 删除 若 imu_time > lidar_end_time: 停止 保留在 imu_buffer 中形式化写成:
【当前帧取出的 IMU 集合】 Measures.imu = { IMU_j | t_j <= t_lidar_end } 同时: 第一个 t_j > t_lidar_end 的 IMU 继续留在 imu_buffer 中。这有两个关键作用。
第一,当前帧只消费自己时间边界之前的 IMU。
当前帧: [ t_begin ------------------ t_end ] 取出: 所有 timestamp <= t_end 的 IMU。第二,不消费下一帧可能需要的未来 IMU。
下一条 IMU: t_imu > t_end 不弹出 ↓ 保留在 imu_buffer ↓ 下一帧继续使用这避免一条 IMU 被“过早拿走”。
源码中虽然先通过last_timestamp_imu >= lidar_end_time确认缓存里已经有足够新的 IMU,但真正塞进Measures.imu的是时间不晚于扫描结束的 IMU;第一条晚于结束时刻的 IMU 会留在队列中。
9.1 为什么等到了结束后的 IMU,却不把它也塞进当前包
例如:
LiDAR: t_begin = 100.000 s t_end = 100.0987 s IMU 缓存: 100.000 100.005 100.010 ... 100.095 100.100系统先确认:
latest IMU = 100.100 >= 100.0987因此说明 IMU 已经覆盖当前 LiDAR 扫描结束时刻。
但真正装入Measures.imu的通常是:
100.000 100.005 ... 100.095而:
100.100会留给下一帧。
原因是当前ImuProcess::UndistortPcl()会先利用扫描内已有 IMU 轨迹推进到最后一个 IMU 时刻,再将状态短时间传播到精确的 LiDAR 扫描结束时刻。它不要求把第一条晚于t_end的 IMU 消费掉。你前面看的UndistortPcl()正是这样在最后补传播到pcl_end_time。
因此:
等待 IMU 覆盖结束时刻 ≠ 把结束后的未来 IMU 塞给当前帧前者保证当前帧时间边界完整;后者则会破坏下一帧的数据连续性。
10.第五步:拼包成功后,缓存如何出队
当 LiDAR、起止时间和 IMU 都填好后,函数执行:
lidar_buffer.pop_front() time_buffer.pop_front() lidar_pushed = false return true这表示:
当前 L0 帧已经完整交给 Measures。 L0 从 LiDAR 缓存移除。 L0 对应起始时间从 time_buffer 移除。 允许下一次调用锁定 L1。 当前拼包结束。虽然lidar_buffer.pop_front()把缓存中的指针删掉了,但:
Measures.lidar本身仍然持有该点云的智能指针,所以后续:
p_imu->Process(Measures, ...);仍然可以正常访问当前帧点云。
这一点是shared_ptr语义带来的:从缓存队列移除的是一份引用,不是立刻释放点云对象。
11.把整个函数理解成状态机
sync_packages()最好不要理解成一个普通if函数,而应理解成四状态状态机。
状态 S0:没有可处理 LiDAR 或 IMU 条件: lidar_buffer.empty() 或 imu_buffer.empty() 行为: return false状态 S1:锁定 LiDAR,计算扫描结束时间 行为: Measures.lidar = lidar_buffer.front() Measures.lidar_beg_time = time_buffer.front() 根据 curvature 计算 lidar_end_time lidar_pushed = true状态 S2:LiDAR 已锁定,但 IMU 尚未覆盖结束时间 条件: last_timestamp_imu < lidar_end_time 行为: 不弹出 LiDAR 不修改 lidar_pushed return false状态 S3:IMU 已覆盖扫描结束时间 行为: 取出所有 t_imu <= lidar_end_time 的 IMU 弹出当前 LiDAR 和时间戳 lidar_pushed = false return true这就是为什么:
lidar_pushed是整段代码的核心状态变量。它避免在等待 IMU 时重复换帧、重复估计结束时间,或者把下一帧 LiDAR 错配给当前 IMU 数据。
12.一个完整时间轴例子
假设 LiDAR 为 10 Hz,IMU 为 200 Hz。
LiDAR 当前帧: t_begin = 10.0000 s points.back().curvature = 97.5 ms t_end = 10.0975 sIMU 队列此时为:
9.995 10.000 10.005 10.010 ... 10.090 10.095 10.100第一次调用sync_packages():
last_timestamp_imu = 10.095 10.095 < 10.0975 结论: IMU 未覆盖扫描结束时刻。 行为: L0 被锁定。 lidar_pushed = true。 不弹出 L0。 return false。新的 IMU 到来:
10.100第二次调用:
last_timestamp_imu = 10.100 10.100 >= 10.0975 结论: IMU 已覆盖扫描结束时刻。函数取出:
9.995 10.000 10.005 ... 10.095但保留:
10.100然后:
Measures.lidar = L0 Measures.lidar_beg_time = 10.0000 Measures.lidar_end_time = 10.0975 Measures.imu = [ 9.995, 10.000, ... 10.095 ]接着返回:
true后续ImuProcess会利用这段 IMU 数据构建扫描期间轨迹,并从最后一个 IMU 时刻短时间预测到10.0975 s。
13.sync_packages()与真正的时间同步不是一回事
当前实现中,IMU 回调会先做:
timestamp_imu_corrected = timestamp_imu_raw - time_diff_lidar_to_imu若启用time_sync_en,Livox 分支还会在检测到 LiDAR 与 IMU 时间基差距较大时进行一次启发式偏移修正。
但这不代表它能解决所有同步问题。
【sync_packages 能解决】 LiDAR 比 IMU 先到: 等待。 IMU 比 LiDAR 先到: 缓存。 当前 LiDAR 的 IMU 尚未覆盖结束时刻: 不处理,继续等待。 【sync_packages 不能自动解决】 LiDAR 与 IMU 固定偏差 5 ms。 LiDAR header 实际是结束时间, 代码却将它当开始时间。 LiDAR 点时间单位错了 1000 倍。 IMU 消息存在系统性延迟但 header 未修正。 LiDAR 与 IMU 使用不同硬件时钟且持续漂移。所以:
sync_packages()只能保证“代码看来时间戳已经对齐”,不能保证“真实物理采样时刻已经对齐”。
如果硬件真实偏差为 10 ms,而 header 时间戳看似正确,函数照样能返回true,但去畸变仍可能产生墙面重影、转弯变形或地图抖动。
14.当前源码中需要重点注意的几个问题
14.1curvature单位必须是毫秒
代码使用:
curvature / 1000因此它假设:
curvature 单位 = ms若你误传:
curvature = 秒系统会多除一次 1000,认为扫描周期极短。
若误传:
curvature = 微秒系统会把扫描周期放大 1000 倍,可能一直等不到足够新的 IMU。
【正确】 curvature = 100 ms curvature / 1000 = 0.1 s 【错误:微秒被误当毫秒】 curvature = 100000 us curvature / 1000 = 100 s结果是系统可能等待 100 秒后的 IMU,表现为sync_packages()长时间一直返回false。
14.2 点云最后一个点必须对应接近扫描末尾
由于源码用:
points.back().curvature当前点云应满足:
points[0].curvature 接近 0 ms ... points.back().curvature 接近一帧扫描周期例如 10 Hz LiDAR:
预期: 最后点 curvature ≈ 100 ms若日志显示:
最后点 curvature = 2 ms但点云明明是完整 10 Hz 一帧,通常要检查:
逐点时间是否被正确写入。 点云是否被重排序。 最后点是否真的是时间最晚点。 LiDAR 驱动是否丢失逐点时间。 time_unit 是否配置正确。14.3 第一个异常帧的兜底时间可能不可靠
初始化时:
lidar_mean_scantime = 0 scan_num = 0如果第一帧就满足:
点数太少 或 最后点时间异常那么兜底逻辑得到的可能是:
t_end = t_begin + 0也就是把当前帧当作瞬时完成。
后续一旦出现正常帧,lidar_mean_scantime才会逐渐建立起来。因此启动阶段最好保证:
LiDAR 点云正常。 逐点时间正常。 IMU 已先稳定发布一小段时间。这是由当前初始化值和兜底逻辑可直接推导出的边界情况。
14.4 LiDAR 时间回退时,time_buffer也要注意一致性
LiDAR 回调检测到时间倒退后,会清空:
lidar_buffer但在上游这段回调代码中,清空逻辑并没有同时显示清空:
time_buffer而这两个队列必须始终保持一一对应:
lidar_buffer[i] ↔ time_buffer[i]如果真发生 LiDAR 时间回退、ROS 仿真时间重置或 bag 重放跳时,必须特别检查time_buffer是否也同步重置;否则新的 LiDAR 点云可能配上旧的时间戳,后续meas.lidar_beg_time会错位。源码中 LiDAR 回调确实在回退时清空lidar_buffer,而时间队列的同步清理需要结合你实际版本进一步核查。
15.排查sync_packages()时最值得打印什么
排查时不要只打印“同步成功/失败”,最有价值的是下面这组时间。
【LiDAR】 lidar_beg_time last_point_curvature_ms lidar_end_time lidar_end_time - lidar_beg_time 【IMU】 imu_buffer.front()->stamp imu_buffer.back()->stamp last_timestamp_imu Measures.imu.size() 【状态】 lidar_pushed lidar_buffer.size() time_buffer.size() imu_buffer.size()正常 10 Hz LiDAR 的典型预期:
lidar_end_time - lidar_beg_time ≈ 0.1 s last_point_curvature_ms ≈ 100 ms last_timestamp_imu >= lidar_end_time Measures.imu.size() ≈ IMU频率 / LiDAR频率 例如: 200 Hz IMU 10 Hz LiDAR 每帧大约: 20 条 IMU若出现:
lidar_end_time - lidar_beg_time ≈ 0 可能: curvature 全为 0、 首帧异常、 MARSIM、 点时间未写入。 lidar_end_time - lidar_beg_time ≈ 100 s 可能: 微秒 / 纳秒误当毫秒。 last_timestamp_imu 一直小于 lidar_end_time 可能: LiDAR 和 IMU 时间基不一致、 IMU 发布时间慢、 time_diff_lidar_to_imu 配置错误、 LiDAR header 时间不是开始时刻。 Measures.imu.size() = 0 可能: IMU 队列时间排序异常、 LiDAR 时间异常、 扫描结束时间计算异常。总结
sync_packages()是 FAST-LIO 的“帧级时间边界控制器”。它先从lidar_buffer锁定一帧尚未处理的 LiDAR 点云,将消息 header 时间当作扫描开始时间;然后从点云最后一个点的curvature推出扫描结束时间。curvature在 FAST-LIO 中不是几何曲率,而是逐点相对扫描时间,单位必须是毫秒。若最后点时间异常或点云过少,函数使用历史平均扫描周期兜底。
当前 LiDAR 帧被锁定后,sync_packages()不会立即处理,而是检查最新 IMU 时间戳是否已经到达扫描结束时刻。若未覆盖,函数返回false,但 LiDAR 帧保留在队列中,lidar_pushed维持为真;下一次调用继续等待同一帧,而不会错误跳到下一帧。只有当 IMU 已覆盖扫描结束时间,函数才将不晚于该结束时刻的 IMU 放入Measures.imu,保留第一条晚于结束时刻的 IMU 给下一帧,随后弹出当前 LiDAR 和对应时间戳,返回true。
这一步保证了ImuProcess接收到的是完整的“当前 LiDAR 扫描 + 足够 IMU 时间覆盖”组合,从而能够把 IMU 状态传播到扫描结束时刻,并将整帧点云统一去畸变到结束时刻 LiDAR 坐标系。它不负责真正的点级插值,也不负责解决真实硬件时间偏差;它只保证在当前时间戳体系下,数据包的开始、结束和缓存边界是完整的。真正决定其稳定性的,是 LiDAR header 时间语义、逐点curvature单位与排序、IMU 时间戳单调性,以及 LiDAR 与 IMU 是否处于同一时间基准。