☰
VINS-MONO IMU预积分源码解析:从processIMU到imu_factor
2026/10/21 8:54:59 网站建设 项目流程

提到VINS-MONO,很多人第一反应是“那个视觉惯性导航系统”,代码确实写得工整,但读起来也真的劝退。尤其是初次打开vins_estimator.cpp,看到processIMU()里一长串缓冲操作,紧接着是integrationBase.h里满屏的矩阵递推,最后还要绕到imu_factor.h里跟 C++ 模板和流形雅可比搏斗,一般的热情基本撑不过一个晚上。

我自己当时啃这段代码也绕了不少弯路,后来才发现,这三块内容实际上是一条线:processIMU()负责把 IMU 数据喂进去,integrationBase类负责把两帧图像之间的 IMU 测量“打包”成预积分量以及对应的协方差和雅可比,imu_factor.h负责在优化时把这个预积分量变成可供 Ceres 使用的残差和梯度。这三块串起来,就是整个 VINS-MONO 惯性约束的完整链路。

这篇解读就是为了把我的阅读路径和踩过的坑记录下来,适合正在读 VINS-MONO 源码、或者想把论文里的预积分公式落到代码上的读者。我会尽量把公式、变量和代码逐一对上,也会说明哪些地方容易看晕、哪些地方是工程上的关键决策。

1. 先搞清IMU预积分在VINS-MONO里到底解决了什么问题

读代码之前如果不懂预积分在优化里扮演的角色,后面的矩阵递推基本都是白看。所以先把背景讲清楚:VINS-MONO 的状态量是相机位姿(位置、旋转)加上速度、陀螺仪零偏、加速度计零偏等,图像帧之间的所有关联,一边靠视觉特征的重投影,另一边靠 IMU 的惯性约束。后者的约束来源,就是integrationBase累积出来的相对运动量。

1.1 为什么不能直接对 IMU 原始数据做积分

如果在一个滑动窗口里,两个关键帧之间有几百个 IMU 采样,最直觉的做法是从第 i 帧的位姿出发,用角速度积分旋转、用加速度积分速度和位置,然后把预测值和第 j 帧的估计值做差。这个方法在滤波里很常见,但在图优化里有一个致命问题:优化过程中第 i 帧的位姿会反复变化,一旦位姿变了,基于它的积分结果就必须全部重新算一遍。窗口里有 10 个关键帧,每轮迭代都重新积分几百次,计算量立刻爆炸。

预积分的聪明之处在于,它把两帧之间的相对运动积成一个与绝对位姿无关的量。也就是说,我可以先假设第 i 帧的零偏不变,把第 i 帧到第 j 帧之间的角速度增量、速度增量、位置增量全部预计算好,优化时不管第 i 帧的世界坐标怎么改变,这些“帧间相对量”都能直接复用剩余观测量。

1.2 图优化需要的三个输出:预积分量、协方差、雅可比

光有相对位移还不够,优化器还得知道三件事:这个量是多少、这个量的可信度有多大、随状态变化怎么变。对应到代码就是integrationBase的三个核心成员:

  • delta_q、delta_v、delta_p:预积分出来的相对旋转、速度和位置变化;
  • covariance:预积分量的不确定性,会在因子求残差时转成信息矩阵;
  • jacobian:预积分量对零偏和初值状态的一阶偏导,用于零偏变化时快速修正,也用于残差的实际求导。

后面拆integrationBase的时候,你会发现这三个输出全都在midPointIntegration()里同步更新,这跟论文里预积分的离散递推是一一对应的。

1.3 代码里这是三条主线,不是三个孤立文件

我最初读代码时犯过一个错误:把这几个文件当成独立的数学练习去看,结果每个公式都认识,但不知道它们在整体流程里怎么被调用。实际调用链是这样的:

  • 传感器数据回调(imu_callback)触发Estimator::processIMU(),后者每收到一个 IMU 消息就更新预积分对象;
  • 图像数据到达后,processImage()启动一次优化前的准备,其中会对每个关键帧检查零偏是否已经更新,若更新则调用预积分类的repropagate()从头重算;
  • 优化求解时,每个 IMU 残差块由IMUFactor创建,IMUFactor::Evaluate()内部会调用integrationBase的evaluate()方法计算残差值以及雅可比。

所以,理解三块代码的正确顺序应该是:先看processIMU()的调度,再看integrationBase的数学实现,最后看imu_factor.h怎么把这些量接到 Ceres 上。这也是下面几个章节的展开顺序。

2. processIMU()逐段拆解:数据入口、缓冲匹配与零偏重积分

processIMU()定义在vins_estimator.cpp里,是Estimator类的成员。它的职责很纯粹:把不断到达的 IMU 测量点按时间顺序放入缓冲,并在每一帧图像到来之前,把这段时间内的所有 IMU 测量累积到对应的integrationBase对象中。

2.1 时间对齐:图像帧索引与IMU缓冲区的配合

VINS-MONO 使用feature_id的视觉特征帧和图像帧同步,而 IMU 数据则通过时间戳对齐。processIMU()的大致逻辑如下:

void Estimator::processIMU(double dt, Vector3d linear_acceleration, Vector3d angular_velocity) { // dt:当前IMU采样与上一采样之间的时间差 // 更新当前IMU状态 acc_0 = linear_acceleration; gyr_0 = angular_velocity; if (frame_count == 0 || pre_integrations[frame_count] == nullptr) { // 初始化预积分器 } else { // 将新的IMU测量添加到当前帧对应的预积分器中 pre_integrations[frame_count]->push_back(dt, linear_acceleration, angular_velocity); } }

这里有一个容易混淆的点:frame_count不是图像算法里的帧计数,而是当前正在处理的“图像帧序号”。每处理完一帧图像,frame_count会加 1,同时新建一个integrationBase实例。这样做是为了让每一段图像间隔内的 IMU 累积互不干扰,优化时能直接拿出第 i 帧与第 j 帧之间的预积分量。

时间对齐方面,dt从 IMU 消息的时间戳计算得出,VINS-MONO 在imu_callback里调用了processIMU(),传入的是当前 IMU 数据与上一 IMU 数据之间经过插值计算的时间差。如果你要移植代码,尤其要注意这里dt必须精确到秒,不能是整数毫秒,否则预积分的位置项误差会被放大得很快。

2.2 零偏变化时的repropagate机制

processIMU()不只是把 IMU 数据推入缓冲。它还承担了一个更微妙的职责:维护“零偏是否变化”这一事件。在优化器跑完一轮求解后,滑动窗口里的陀螺仪和加速度计零偏估计值会更新。如果直接用新零偏去强制更新已经积分到一半的预积分量,数学上是不合理的,因为预积分内部的递推全部建立在零偏不变的假设上。

所以 VINS-MONO 的做法是:在每次优化结束后,如果零偏确实发生了变化,就对从该帧之后的所有预积分器调用repropagate()。这个函数会保留已经缓存好的原始 IMU 测量序列(acc_0、gyr_0以及linear_acceleration、angular_velocity的这些历史值),但将delta_p、delta_v、delta_q重置为初始值,然后用新的零偏重新积分一遍。

这个过程我在早期读代码时一直当成一个“内部清理工作”略过了,直到调试轨迹发散才发现它的重要性。如果你看到的processIMU()代码里频繁出现if (pre_integrations[i]->updated == true)这类判断,那多半就是这个机制的一部分。需要注意,预积分器指针在frame_count移动时会做整体平移,因此pre_integrations是一个长数组,下标与当前的窗口帧一一对应。

2.3 为什么processIMU里还有"初始第一位姿"的分支

在代码开头,VINS-MONO 会有一次initFirstIMUPose()调用。它的作用不是把预积分清零,而是估算初始的重力方向与当前 IMU 的姿态。因为在没有视觉信息的最开始几百毫秒内,系统只能依赖 IMU 来感知姿态变化。

这里acc_0和gyr_0的作用不仅仅是本次采样的数据,它们还被缓存为线性化点的初始状态。也就是说,预积分并不是凭空开始的,它需要一个“起点”,而这个起点就是processIMU()里被不断更新的acc_0和gyr_0。我在移植到自定义传感器平台时,把这个初始缓存漏了,结果预积分一开始就有一小段偏差,导致仿真里第一秒的 VIO 轨迹就出现几厘米的漂移。

2.4 缓冲与内存管理里不容易发现的坑

processIMU()的另一个隐藏功能是把 IMU 数据存一份到acc_msg、gyr_msg等历史缓冲中。这些缓冲不是无限增长的,在关键帧滑动窗口边缘化时会被清掉。如果你自己改过窗口大小,要特别注意pre_integrations数组的重分配和指针移动逻辑。

常见错误是:图像帧没有及时到来,导致processIMU()被连续调用了几百次,而frame_count一直停在 0,这时预积分器会一直累积同一段 IMU 数据,最终产出的delta_p会是一个巨大的位置增量。VINS-MONO 的调度方式保证了图像频率通常高于 IMU 频率的十分之一,问题不明显;但在低帧率相机上,这个坑会非常致命。我的经验是,调试这类问题最简单的方法是在processIMU()入口打印frame_count和pre_integrations下标,观察它们是否按预期切换。

3. integrationBase类的工程内核:中值积分、协方差与雅可比递推

如果说processIMU()是调度入口,那么integrationBase就是整个预积分核心的“发动机”。这个类里几乎所有函数都在做一类事:在 IMU 测量序列上做离散积分,同时把积分结果对零偏和初值状态的一阶敏感性也算出来。

3.1 成员变量:从论文公式到代码字段的映射

先看类里面常见的成员变量,我把它们和论文里的符号做了一个对应:

class IntegrationBase { public: // 零偏 Vector3d linearized_acc; // 线性化点处的加速度计零偏 Vector3d linearized_gyr; // 线性化点处的陀螺仪零偏 Vector3d acc_0, gyr_0; // 最近一次IMU观测 // 预积分量 Quaterniond delta_q; // 帧间相对旋转增量 Vector3d delta_v; // 帧间相对速度增量 Vector3d delta_p; // 帧间相对位置增量 MatrixXd jacobian; // 预积分量对各状态量的一阶雅可比 MatrixXd covariance; // 预积分量的协方差矩阵 double sum_dt; // 累计积分时长 // 噪声参数 double acc_n, acc_w; // 加速度计噪声和随机游走 double gyr_n, gyr_w; // 陀螺仪噪声和随机游走 };

delta_p、delta_v、delta_q的设计意图是:它们是“从第 i 帧 IMU 坐标系出发,经过一系列 IMU 测量后,在第 j 帧 IMU 坐标系下观测到的相对运动”。也就是说,这些量不依赖世界坐标,只依赖第 i 帧和第 j 帧之间的 IMU 测量序列。这跟论文里“body坐标系下的预积分”概念一致。jacobian矩阵用来表达预积分量对两个关键帧状态的偏导,而covariance则是随机噪声经过离散积分后的累积协方差,后者会被因子用来设置信息矩阵的权重。

3.2 propagate与midPointIntegration:中值积分到底怎么处理加速度和角速度

propagate()函数是预积分器的对外接口,它会保存新旧两次观测的中间值,计算时间间隔,然后调用midPointIntegration()。中值积分的思想很朴素:一次采样内的角速度和加速度取前后两个采样的平均值,用平均值来做一步积分。

关键代码逻辑可以概括为:

void IntegrationBase::propagate(double _dt, const Vector3d &_acc_1, const Vector3d &_gyr_1) { dt = _dt; acc_1 = _acc_1; // 本次观测值 gyr_1 = _gyr_1; // 本次观测值 Vector3d result_delta_p; Quaterniond result_delta_q; Vector3d result_delta_v; Vector3d result_linearized_ba; Vector3d result_linearized_bg; midPointIntegration(_dt, acc_0, gyr_0, _acc_1, _gyr_1, delta_q, delta_v, delta_p, linearized_ba, linearized_bg, &result_delta_p, &result_delta_q, &result_delta_v, &result_linearized_ba, &result_linearized_bg); delta_q = result_delta_q; delta_v = result_delta_v; delta_p = result_delta_p; acc_0 = _acc_1; gyr_0 = _gyr_1; }

可以看到,acc_0和gyr_0扮演了一个“上一拍”的角色。我最初看这个函数时一直疑惑为什么要维护两套加速度计数据,后来意识到这就是中值积分的本质:要想取当前帧和下一帧的平均值,必须把上一帧的数据保存下来,等下一帧到了才能算出一个均值。

midPointIntegration()本身则是纯粹的计算:先构造中间时刻的四元数,再更新位置、速度、协方差和雅可比。这里的dt精度又一次体现出来——如果传入的dt是毫秒级的整数,加速度积分会有系统性的偏差,长期运行后位置发散得非常快。

3.3 协方差与雅可比的递推更新:为什么一定要在积分过程中同步算

很多读者看到midPointIntegration()里的协方差和雅可比更新会把它们当成“附带的数学操作”,觉得可以最后再算。但代码写得非常明确:协方差矩阵的递推必须依赖当前时刻的状态转移矩阵,而该矩阵又跟每一步的采样值有关。

离散化的协方差递推大致可以抽象为:

covariance = F * covariance * F.transpose() + V * Q * V.transpose()

其中F是误差状态的转移矩阵,V是噪声到状态扰动的映射矩阵。它们都依赖当前时刻的旋转量delta_q和当前帧的角速度/加速度。如果在所有积分结束后再计算协方差,误差状态转移过程就需要重新走一遍,等于是重复算两遍,而且容易在零偏变化时发生不一致。

VINS-MONO 在propagate()里先调用midPointIntegration()更新delta_q、delta_v、delta_p,再更新用于零偏一阶修正的jacobian。这个顺序也很讲究:如果先更新delta_q再更新协方差,那么协方差递推里用到的旋转应该是“更新后的旋转”,符合实际误差传播路径。动手改过代码的读者如果发现结果对dt特别敏感,可以检查是不是把协方差更新放到了预积分量更新之前。

3.4 零偏修正分支:evaluate与repropagate的区别

IntegrationBase对外提供两个用于零偏的函数,初学者特别容易混淆:

  • evaluate():利用已经积出来的jacobian,在线性化点附近对预积分量做一阶泰勒修正。这个过程不会重新走一遍 IMU 测量,速度极快,适合在图优化迭代中反复调用。
  • repropagate():将预积分量全部重置为初值,清空历史中间量,然后用缓存的原始 IMU 测量序列从头重新积分。这个过程精度完整,但计算量大,适合在零偏估计发生较大变化时调用。

VINS-MONO 的策略是:优化器内部的每次迭代都使用evaluate()来快速修正零偏;而一轮优化完成后,如果零偏收敛值变化超过阈值,就触发一次repropagate(),保证下一次优化使用的主预积分量是真正基于最新零偏的积分结果。

这里有一个我踩过的坑:如果完全把repropagate()去掉,只依赖evaluate()的一阶修正,短时间看起来没问题,但零偏漂移较大或者两帧之间时间较长时,残差会离线性化点越来越远,一阶泰勒展开的误差会被图优化放大,导致整个滑窗内的 IMU 约束质量骤降。所以我建议在移植代码时,evaluate()和repropagate()都要保留,不要为了省计算量删掉后者。

3.5 一个容易看晕的细节:delta_q的初值到底是多少

在IntegrationBase的构造函数里,delta_q被初始化为单位四元数,delta_p和delta_v被初始化为零向量。这看起来很简单,但有读者会疑惑:如果系统起始时 IMU 坐标系相对世界坐标有一个偏转,预积分量不应该是单位四元数吧?

答案是:预积分量本身是相对量,它只描述“帧间”变化,不感知绝对姿态。如果你在第一个 IMU 采样时刻就把 IMU 坐标系相对于某个世界坐标系的姿态信息加进去,那反而破坏预积分量的帧间相对性。真正把绝对姿态放进约束的,是imu_factor.h里的残差构造——它把当前帧与下一帧的 absolute pose 变换到 IMU 坐标系后,再与delta_q做比较。所以delta_q保持单位四元数初值是合理的,体现了预积分“只做差异累积,不做绝对姿态估计”的定位。

4. imu_factor.h如何把预积分变成图优化里的15维残差

imu_factor.h是实现 IMU 因子约束的 Ceres cost function 文件。如果前两章讲的是“如何获得预积分量”,那么这一章讲的是“如何把这个预积分量变成优化器能用的误差”。

4.1 因子类的顶点结构与残差构成

在 Ceres 的接口里,一个 residual block 由若干参数块和一个残差向量组成。VINS-MONO 的 IMU 因子继承自ceres::SizedCostFunction<15, 7, 9, 7, 9, 7>,这表示它输出 15 维残差,同时接收 5 个参数块:

  • 参数块 0:第 i 帧的位姿(7 维,四元数 + 平移);
  • 参数块 1:第 i 帧的速度与零偏(9 维,速度 3 + 加速度计零偏 3 + 陀螺仪零偏 3);
  • 参数块 2:第 j 帧的位姿(7 维);
  • 参数块 3:第 j 帧的速度与零偏(9 维);
  • 参数块 4:IMU 与相机之间的外参(7 维)。

15 维残差的分布通常是:位置残差 3 维、速度残差 3 维、旋转残差 3 维、加速度计零偏残差 3 维、陀螺仪零偏残差 3 维。零偏残差可以直接视为两个帧之间的零偏随机游走约束,来源于 IMU 零偏缓慢变化的先验假设。

注意,这里外参作为参数块传入,意味着 VINS-MONO 优化过程中外参可以被进一步优化,而不单纯是一个固定值。如果你只想固定外参,可以直接把setConstant()应用到对应的参数块上,不必修改因子本身。

4.2 残差计算中的坐标系转换细节

预积分量是在 IMU 坐标系下定义的,但优化状态量是相机位姿。因此IMUFactor::Evaluate()的第一步必然是把状态量从相机系转换到 IMU 系。代码中常见写法是取出外参旋转q_ext和外参平移p_ext,然后对每个帧的位姿做如下操作:

// 将世界系下的相机位置转换到IMU坐标系 Eigen::Vector3d Pi = pose_i.segment<3>(0); // 相机坐标系下的位置 Eigen::Quaterniond Qi(pose_i[6], pose_i[3], pose_i[4], pose_i[5]); // 转换到IMU坐标系 Eigen::Vector3d Pwi = Pi; // 仍以相机位姿表达,仅做体感理解 Eigen::Quaterniond Qwi = Qi; // 对应相机姿态

实际上,VINS-MONO 的状态量虽然叫pose_i,但在滑动窗口优化器里它代表的是“第 i 帧的相机位姿”。而在 IMU 因子内部,它会用外参把相机位姿变换成 IMU 位姿,再与世界坐标系下的重力对齐。这一步如果外参方向搞反,整个因子会给出完全错误的残差。判断外参方向的一个重要技巧是去看初始化外参的代码写的是R_ic(IMU 系到相机系)还是R_ci(相机系到 IMU 系)。我曾因为混淆这两个方向,导致 IMU 约束与视觉约束互相矛盾,轨迹分成两半。

4.3 残差公式与雅可比计算

IMUFactor::Evaluate()里有个EvaluationInfo结构体,它保存了各个残差分量对位姿、速度、零偏的雅可比块。由于 Ceres 需要同时提供残差和雅可比,而 IMU 因子涉及旋转矩阵、四元数、右扰动,直接写解析雅可比很繁琐,所以 VINS-MONO 用了一些辅助函数,比如:

  • RotationMatrix:从四元数构建旋转矩阵;
  • JacoRight:SO(3) 右雅可比,用于处理角速度扰动对旋转增量的影响;
  • quaternionToRotation等工具函数。

计算逻辑的核心思想是:先根据当前帧与下一帧的 IMU 位姿,算出预测的相对运动;再用预积分量作为“测量”,两者做差得到残差。而残差对状态量的偏导,等于残差对预积分量的偏导乘以预积分量对状态量的偏导,这里会用到IntegrationBase::evaluate()返回的雅可比矩阵jacobian。

这个过程在纸上推导确实不难,但看代码时会遇到很多jacobian的块索引,比如E.jacobian_pose_i、E.jacobian_vel_i、E.jacobian_pose_j等。这些块实际来自预积分雅可比矩阵的对应分块。读懂IntegrationBase里的jacobian分块顺序,再回到imu_factor.h里按图索骥,比直接硬啃矩阵索引高效得多。

4.4 信息矩阵与零偏先验的处理

预积分协方差矩阵提供了残差的不确定性,但 Ceres 的残差块本身只接受残差向量,不直接接受完整的协方差矩阵。VINS-MONO 的处理方式是在Evaluate()里用一个固定的权重矩阵对残差做加权,这个权重矩阵通常取协方差矩阵的 Cholesky 分解的逆。

具体到代码,就是先根据预积分的协方差算出sqrt_info,然后让残差变成sqrt_info * residual。这样做的效果等价于在最大似然估计里对每个量按信息矩阵加权。需要注意的是,sqrt_info应当在预积分器完成积分后、进入优化前计算一次,不要在每次Evaluate()里重复计算。VINS-MONO 里通常会把这个矩阵预存到类的成员里,只在repropagate()后更新。

零偏残差部分则没有那么复杂的权重,它通常是让优化器在零偏随机游走模型下保持平滑。如果你发现优化后的零偏跳动非常剧烈,可以尝试检查零偏残差的权重是否过小,也就是随机游走噪声参数acc_w、gyr_w是否设得太大。

5. 读完这三块代码,我建议你记住的几个工程习惯

代码全部梳理完之后,有些东西并不是纯数学推导能告诉你的,而是实打实的工程经验。这里分享几条我自己的体会。

5.1 调试时先看repropagate有没有被正确触发

很多 IMU 约束相关的问题,表象都是“VIO 轨迹发散”或者“零偏估计异常”,但根因并不是优化器的锅,而是预积分器的repropagate没有被及时触发。调试时可以在IntegrationBase::repropagate()入口打印linearized_acc和linearized_gyr的变化量,如果发现零偏已经变了但repropagate没有被调用,那多半是updated标志位在边缘化时被错误清除了。

5.2 零偏初值对IMU因子的影响比想象中大

VINS-MONO 论文里建议在初始化阶段对零偏做一个初步估计。如果你跳过初始化,直接把零偏设成零,IMUFactor里预积分量的一阶修正范围会很快超出泰勒展开的有效区间。尤其陀螺仪零偏如果存在 0.01 rad/s 级别的误差,旋转残差就很容易出现异常。我在自己的数据集上测试过,把陀螺仪零偏初值从 0 改成 0.005 后,初始化阶段的收敛速度快了将近一倍。

5.3 阅读这套代码的推荐顺序

如果你现在正准备从零开始啃这段代码,我建议的顺序不是按文件先后,而是按数据流:

  1. 先跑通 VINS-MONO 官方数据集,在运行时把pre_integrations[frame_count]里的sum_dt打印出来,感知一下两帧之间累积的 IMU 数据量级;
  2. 读processIMU(),梳理缓冲数组和frame_count的关系;
  3. 读IntegrationBase::midPointIntegration(),配合论文的预积分离散公式逐行对应;
  4. 最后读imu_factor.h,理解残差和雅可比的组装方式。

按这个路径走,你会发现真正难的不是数学本身,而是把数学符号和代码变量一一对应起来的这一步。对应好之后,VINS-MONO 的 IMU 约束部分就基本通透了一半。

5.4 最后的调试技巧:打印每个IMU残差分量的数量级

我在调完这一整套代码后,留下一个习惯:每轮优化后打印各个IMUFactor残差分量的范数。如果位置残差普遍在 10 的负二次方、旋转残差在 10 的负三次方级别,说明预积分和状态估计的尺度是正常的;如果位置残差突然跳到上百,那一定是从帧边缘化、时间戳同步或者外参方向三处中的某一处出了问题。这个排查思路比直接盯着总误差看效率高得多,至少能省下不少对着日志发呆的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询