☰
VINS-Mono IMU预积分代码详解:从processIMU到imu_factor
2026/10/5 1:22:14 网站建设 项目流程

1. 从一张框图说起:IMU预积分模块到底在解决什么问题

在VINS-Mono里待过一阵子的人应该都有这种感觉:整个系统跑通不难,但想读懂IMU预积分那部分代码,确实要费点劲。网上资料虽然多,但大多只讲数学公式,真正落到代码层面、能带着你把processIMU()、integrationBase类、imu_factor.h这三个东西串起来讲的,很少。

先聊点背景。视觉惯性里程计的核心问题之一,是IMU的频率通常有100Hz到200Hz,而图像帧只有10Hz到30Hz。如果按传统方式,每一帧图像到来时,都要把两帧图像之间所有IMU测量值当作一个整体去重新积分,那么每优化一次位姿,就要重新积一次分,计算的量非常大,而且高频IMU数据在非线性优化里会变成巨大的待优化变量,优化器根本扛不住。

预积分的核心动机就是把这个积分操作“挪到”优化之前完成。它把两帧之间的IMU相对运动(旋转增量、速度增量、位置增量)事先算好,作为优化中的“测量值”,而优化过程中只对帧的位姿、速度、零偏这些状态量做调整,不去动原始IMU数据。

理解了这点,再看代码就会清晰很多。整个模块其实就三个层次:

  • processIMU():负责接收IMU数据,判断当前数据属于哪个图像帧区间,并驱动预积分器做增量积分计算。
  • integrationBase类:预积分器的核心实现,里面存了增量旋转、增量速度、增量位置、协方差矩阵以及Jacobian矩阵,中值积分逻辑也都在这里。
  • imu_factor.h:把预积分结果封装成一个Ceres因子,供后端非线性优化调用。残差的计算、Jacobian矩阵的填充、信息矩阵的传递,都在这个文件里。

这套接口设计其实非常顺手。如果你只想跑通VINS-Mono,未必需要动这部分代码;但如果你想改传感器配置、换IMU型号、改预积分策略,或者干脆想移植到自己的系统里,那么这三个文件就是绕不开的坎。这篇文章我按数据流动的顺序,从processIMU()一路讲到imu_factor.h,把里面每一处关键代码的意图和隐含的数学原理都过一遍。

2. processIMU()深度拆解:预积分的输入入口

2.1 调用时机与前置规则

processIMU()在VINS-Mono里是VIO状态机的核心入口之一,它不属于integrationBase类,而是在estimator流程里以独立函数存在。每次拿到一帧IMU数据,不管是来自数据集还是RTK实采,系统都会调用这个函数。

调用时的核心任务可以归纳成一句话:判断当前IMU数据应该属于哪一组相邻关键帧,然后把该数据对应的时间间隔内的加速度和角速度塞进对应的预积分器。

这里有个重要的前置规则,就是第一帧图像之前的IMU数据怎么处理。VINS-Mono里会有一个first_imu标志位,用来处理IMU数据流还没有对应图像帧的情况——也就是系统刚开始跑、还没初始化完成的那几秒钟。这段路上的IMU数据只用来构建初始的旋转估计,不进入预积分过程。而一旦图像帧建立起来,后面的IMU数据就按照图像帧的时间戳,划分成一段一段的。

实现上并不复杂:

void Estimator::processIMU(double t, double gx, double gy, double gz, double ax, double ay, double az) { // 如果是第一次收到IMU数据,记录时间戳,不进入积分逻辑 if (!first_imu) { first_imu = true; acc_0 = Vector3d(ax, ay, az); gyr_0 = Vector3d(gx, gy, gz); } // 判断当前IMU数据是否落在当前帧与上一帧之间 if (!pre_integrations[frame_count]->push_back(dt, acc_1, gyr_1, acc_0, gyr_0)) { // 如果超出当前帧区间,说明需要建立新的预积分器 frame_count++; pre_integrations.push_back(new IntegrationBase(...)); } // 更新当前加速度和角速度 acc_0 = Vector3d(ax, ay, az); gyr_0 = Vector3d(gx, gy, gz); }

关键就在于push_back()返回值的判断逻辑。它的字面意思是“把这段IMU数据积累到当前预积分器里”,实际上内部会检查:如果当前IMU时间戳没有超过当前图像帧的时间戳,就正常积累;如果已经超了,说明这段IMU数据已经不属于当前帧区间,就需要新建一个预积分器给下一帧。这个设计保证了每个预积分器恰好覆盖两帧图像之间的所有IMU数据,不多不少。

2.2 时间对齐与帧间判断

时间对齐是IMU预积分最容易出错的地方,尤其是用数据集的时候。VINS-Mono默认的时间戳单位是秒,使用EuRoC数据集时是同步好的,但换到自己采集的数据或者TUM数据集时,时间戳乱一点,整个VIO就跑飞。processIMU里没有做复杂的时间插值,而是假设IMU数据和图像帧时间戳已经是对齐了的,只做区间归属判断。因此在实际使用中你必须保证喂进去的数据时间戳准确,否则预积分的结果就是错的,而且这种错非常隐蔽——不会崩,但精度会慢慢漂。

帧间判断的逻辑可以这样理解:系统里有一个frame_count表示当前正在处理的图像帧索引,每个图像帧都对应一个独立的pre_integrations[frame_count]。IMU数据来了以后,先看它的时间戳t是否小于当前帧时间戳frame_stamp[frame_count]。如果是,就正常把它放入当前预积分器;如果不是,就说明这一帧图像之间的IMU数据已经收齐,程序会把frame_count加一,然后为新的帧区间创建新的预积分器。

这里其实还有一个容易被忽略的细节,就是dt的计算。VINS-Mono在processIMU里取了当前IMU时间戳与上一帧IMU时间戳的差值。如果IMU数据频率稳定,这个dt值基本稳定在0.005秒或0.01秒左右,但IMU数据本身有抖动时,dt也会跟着抖动,这会对中值积分的精度有轻微影响。

2.3 中值积分的两次更新细节

进入pre_integrations[frame_count]->push_back()以后,真正的预积分计算才开始。VINS-Mono默认使用中值积分,也就是每个IMU采样间隔内,取当前时刻和下一时刻的加速度、角速度的平均值作为该间隔内的常量值。

这个过程通常涉及两次更新。第一次是先用当前时刻的测量值做一次积分(可以理解为“预测”),第二次是把下一时刻的测量值平均进去,再做一次校正。这样做的效果相当于在采样间隔内用一个更平滑的角速度和加速度来替代原来的分段常量假设,精度比欧拉积分高很多,而计算量只增加了一倍,性价比非常高。

中值积分的具体实现放在push_back()函数里,它会根据当前保存的加速度acc_1、角速度gyr_1以及新进来的acc_0、gyr_0,计算平均值,然后调用midPointIntegration()函数更新预积分量、协方差和Jacobian。这个函数是integrationBase类里最核心的函数,也是下面要重点拆解的对象。

3. integrationBase类:预积分量、协方差与Jacobian的载体

3.1 类内部的关键成员变量

integrationBase这个类是整个预积分模块的“数据仓库”。如果你打开vins_estimator的代码目录,找到这个类的定义,第一眼会被它的一堆成员变量吓到。但只要搞清楚哪些是预积分量、哪些是协方差、哪些是Jacobian、哪些是中间变量,整个类就清晰了。

核心预积分量有四个:

  • delta_q:当前帧相对于参考帧的旋转增量,用四元数表示,对应数学上的ΔR。
  • delta_v:速度增量,对应Δv。
  • delta_p:位置增量,对应Δp。
  • linearized_acc和linearized_gyr:最近一次的加速度和角速度零偏值。

协方差和Jacobian相关的成员包括jacobian和covariance,这两个矩阵在优化中非常重要。需要特别注意的是,代码里还有一个sum_dt,用于累计预积分间隔的总时间。这个变量看似不起眼,但实际上在残差计算中会用到,因为位置残差和速度残差都要乘以时间间隔。

另外,integrationBase里还保存了acc_0、gyr_0等用来做中值积分的上一时刻测量值。这些值不是IMU的原始数据,而是参与预积分计算时记录下来的前一帧IMU数据,目的就是为了下一帧数据到来时能求平均值。

3.2 push_back与中值积分实现

push_back()函数的结构很清晰:接收两帧IMU数据,判断时间间隔是否有效,然后更新预积分。它的本质工作是调用midPointIntegration(),但在这之前还需要维护一些状态量。

假设某次调用中,上一帧IMU加速度是acc_1,角速度是gyr_1,当前帧的IMU测量值是acc_0和gyr_0。那么中值积分会先计算:

acc_mid = (acc_1 + acc_0) / 2 gyr_mid = (gyr_1 + gyr_0) / 2

然后假设这个acc_mid和gyr_mid在dt时间内保持不变,进行一次积分。也就是说,预积分量的更新公式大致是:

delta_q = delta_q * quaternion_delta(gyr_mid * dt) delta_v = delta_v + delta_q * acc_mid * dt delta_p = delta_p + delta_v * dt + 0.5 * delta_q * acc_mid * dt^2

当然,这只是忽略零偏和重力项后的简化表达。真实的代码实现里需要把零偏的影响也考虑进去,如果是带零偏更新的版本,还要额外对Jacobian和协方差做递推。

这里有一个值得一提的细节:中值积分在旋转上的更新不是简单的加法。因为旋转本身是非线性的,我们需要把角速度积分得到的旋转增量转换成四元数,再用四元数乘法来更新delta_q。这正好解释了为什么delta_q的类型是Quaterniond而不是Vector3d。

3.3 协方差与Jacobian的递推方程

协方差和Jacobian的更新是midPointIntegration()的核心。初学者经常会问一个问题:为什么要维护这两个矩阵?这要从优化角度来理解。

协方差的物理意义是预积分量随时间推演的不确定度累积。IMU测量有噪声,每一次积分都会引入新的误差,误差会随着时间累积。如果不给优化器提供协方差信息,优化器就无法判断“这个预积分量到底可信多少”,进而影响信息矩阵的权重分配。VINS-Mono默认把协方差的初值设为零矩阵,然后递推更新。

Jacobian的意义则更直接。在优化过程中,我们要计算预积分残差对状态变量的导数。如果每次计算导数时都用数值微分去算,效率太低,而且数值不稳定。所以VINS-Mono采用了一种更聪明的办法:先在预积分阶段就把Jacobian的递推算好,优化时直接读取。

公式上,Jacobian的递推可以写成:

J_{t+dt} = F * J_t

其中F是状态转移矩阵,它描述了当前时刻的状态误差如何影响下一时刻的状态误差。协方差递推则是:

P_{t+dt} = F * P_t * F^T + G * Q * G^T

其中G是噪声驱动矩阵,Q是IMU噪声协方差矩阵。在代码里,这个F矩阵是一个15×15的大矩阵,这里有个值得注意的细节——这15个维度其实包含了位置、速度、旋转、加速度零偏、角速度零偏这五组状态量,每组3个。这个维度的选择并不是随意的,它对应了后端优化时IMU因子关联的状态变量集合。

3.4 midPointIntegration的两个版本对比

如果你仔细读过integrationBase的代码,会发现midPointIntegration在代码里有过不同的版本,早期版本和后期版本在“零偏更新”的处理方式上有差异。

老版本的做法是在预积分量更新时,把最近的零偏值带入积分公式,每次IMU数据进来都用最新的零偏值去重新计算。这样做的问题在于:零偏一旦变化,整个预积分量都需要重新计算,而且前期积分的Jacobian也会受影响,对优化收敛非常不友好。

VINS-Mono最终版本选择了“先按固定零偏积分,再通过Jacobian做修正”的策略。具体来说,midPointIntegration在预积分推进时使用固定的零偏值,同时把零偏误差的影响以Jacobian的形式记录下来。后端优化时,如果零偏估计值发生变化,预积分残差会通过Jacobian来近似修正,而不需要重新积分。

这个设计的巧妙之处在于:预积分量本身只需要随IMU数据积累一次,优化过程中的零偏修正完全靠线性化来近似,计算效率大幅提升。当然,这种近似在零偏变化很大时会失效,所以代码里还设置了一个阈值,比如零偏变化超过某个范围时强制重新积分。

4. 残差公式与Jacobian推导:imu_factor.h的数学内核

4.1 预积分残差的定义与形式

imu_factor.h是整个IMU预积分模块与Ceres优化器的衔接点。要理解这个文件,首先要理解它定义的残差是什么。

预积分残差的思路很直白:预积分量是“两帧之间的相对运动测量值”,而状态变量可以预测出一个“两帧之间的相对运动”。这两个值的差就是残差。

更具体一点,假设当前关键帧的状态为位置p_i、速度v_i、旋转q_i,下一关键帧的状态为p_j、v_j、q_j。预积分模块已经算出了两帧之间的旋转增量delta_q、速度增量delta_v、位置增量delta_p。那么残差可以写成:

r_p = q_i^inv * (p_j - p_i - v_i * dt - 0.5 * g * dt^2) - delta_p r_v = q_i^inv * (v_j - v_i - g * dt) - delta_v r_q = 2 * (delta_q^inv * (q_i^inv * q_j)).vec()

其中g是重力向量,.vec()表示取四元数的虚部。这三个残差分别对应位置、速度、旋转。

不过在imu_factor.h里,残差并不止这三项。因为优化还要估计加速度计零偏ba和陀螺仪零偏bg,所以残差里还隐含了对零偏的约束。VINS-Mono的处理方式是把零偏作为优化变量的一部分,在残差计算时,根据当前估计的零偏值与预积分时使用的零偏值的差异,通过Jacobian的预积分修正来调整delta_p、delta_v、delta_q。这也是为什么imu_factor.h里会看到类似eigen_velocity、eigen_q、eigen_vector这样的中间变量。

4.2 对优化变量的Jacobian推导

残差有了,接下来就是要对状态变量求导。VINS-Mono中IMU因子的优化变量一共有七个,按顺序是:

  • 位置p_i(3维)
  • 姿态q_i(4维,四元数)
  • 速度v_i(3维)
  • 加速度零偏ba_i(3维)
  • 角速度零偏bg_i(3维)
  • 位置p_j(3维)
  • 姿态q_j(4维,四元数)

其中速度和零偏只涉及本帧,不涉及另一帧,所以相关的Jacobian块会相对简单。在实际代码中,我们需要计算残差对每个优化变量的一阶偏导数,这些偏导数拼成一个大矩阵,就是imu_factor.h里最重要的jacobian矩阵。

以位置残差为例,对p_i和p_j的导数尤其直观。残差里包含q_i^inv * (p_j - p_i - v_i * dt - 0.5 * g * dt^2),对p_i求导得到-q_i^inv,对p_j求导得到q_i^inv。对v_i求导则得到-q_i^inv * dt。这些结果其实和普通惯性导航方程里的推导一致。

绕一点的是对姿态q_i的Jacobian,因为姿态以四元数形式出现在残差里,而且和预积分旋转增量耦合在一起。代码里一般通过扰动模型来推导:先给q_i一个微小的旋转扰动,然后看残差的变化量,提取出线性项作为Jacobian。

4.3 零偏更新的扰动模型

零偏的Jacobian是IMU因子里最复杂、也最容易写错的部分。它的困难在于,预积分量delta_p、delta_v、delta_q本来是在固定零偏假设下算出来的,但现在优化过程中零偏会变,所以必须通过Jacobian来补偿这个变化。

具体的做法可以这样理解。在预积分阶段,integrationBase已经算好了delta_q对bg的Jacobian(记为J_q_bg),以及delta_v和delta_p对ba、bg的Jacobian(记为J_v_ba、J_v_bg、J_p_ba、J_p_bg)。这些Jacobian在midPointIntegration里已经递推好了,直接存放在类的成员变量里。

在imu_factor.h的残差计算中,当后端优化把零偏从ba_i、bg_i更新成新的值以后,程序会用这些Jacobian对预积分量做一阶修正,比如:

delta_p_corrected = delta_p + J_p_ba * (ba_i - linearized_ba) + J_p_bg * (bg_i - linearized_bg)

然后残差的真正形式是:

r_p = q_i^inv * (p_j - p_i - v_i * dt - 0.5 * g * dt^2) - delta_p_corrected

这里用到的linearized_ba和linearized_bg,就是预积分时保存下来的零偏值。这样做的好处是:后端优化时不需要重新积分IMU数据,只需要用Jacobian做一个线性修正,就能近似得到新的预积分量。

需要注意的一点是,残差对零偏的Jacobian不只是上面那个预积分Jacobian,还要加上修正项本身对零偏的导数。完整的Jacobian是这两部分的组合,在代码中以sqrt_info乘上各分块矩阵的形式体现。

5. imu_factor.h接入ceres:从公式到代码的最后一公里

5.1 Factor类结构与Evaluate()函数

imu_factor.h里的IMU因子类继承自Ceres的SizedCostFunction,模板参数是残差维度(15维)和各优化变量的维度。Evaluate()是整个因子最核心的入口函数,Ceres每次迭代都会调用它。

Evaluate()函数的输入是优化变量的当前值数组parameters,输出是残差residuals和Jacobian矩阵jacobians。整体流程可以分为几个阶段:

第一步是解析参数。代码里会把parameters[0]到parameters[6]分别解析成位置、姿态、速度、零偏等Eigen类型的变量。这里要特别小心,Ceres里四元数的存储顺序是w, x, y, z,而VINS-Mono内部很多地方使用的是x, y, z, w的顺序,在传递参数时如果不做转化,姿态就会完全错乱。

第二步是计算修正后的预积分量。代码会根据当前零偏值和预积分时保存的零偏值,用Jacobian修正delta_p、delta_v、delta_q。相关代码在IntegrationBase::evaluate()函数中封装好了。

第三步是计算残差向量。按照上一节列出的公式,逐个填充这15维残差。顺序通常是位置残差3维、速度残差3维、旋转残差3维、零偏残差3+3维。VINS-Mono里还有一个值得注意的操作:在残差计算完成后,会乘以sqrt_info矩阵,也就是信息矩阵的平方根。这相当于对残差做了白化处理,让残差的协方差接近单位阵,有利于Ceres的数值稳定性。

5.2 信息矩阵的构造与鲁棒核

sqrt_info矩阵的计算是IMU因子中比较细节但也非常关键的一步。它的来源是预积分协方差矩阵covariance。在预积分完成之后,系统会根据协方差矩阵计算出信息矩阵:

sqrt_info = Eigen::LLT<Eigen::Matrix<double, 15, 15>>(covariance.inverse()).matrixL().transpose();

这里的逻辑是:协方差矩阵的逆是信息矩阵,信息矩阵做Cholesky分解,取上三角矩阵作为残差的加权矩阵。这样处理后的残差,其协方差在理想情况下接近单位矩阵,优化器对不同残差项的权重分配就自然合理了。

还有一个细节是鲁棒核函数的使用。VINS-Mono默认没有给IMU因子加鲁棒核,而是把鲁棒核加在了视觉因子上。这个设计符合实际:IMU预积分的残差一般比较平滑,异常值少,而视觉匹配则容易出现外点。如果你在实际应用中遇到IMU数据跳变,比如传感器受到剧烈冲击,可以考虑给IMU因子也加一个Huber核,但要小心不要过度压制正常的动态响应。

5.3 与视觉因子拼接时的注意事项

在VINS-Mono的滑动窗口优化中,IMU因子和视觉因子是同时在Ceres里求解的。IMU因子连接的是相邻两个关键帧的全部状态,而视觉因子连接的是多个关键帧的位姿和路标点。

一个常见的坑是:视觉因子的残差模块通常使用ceres::Problem::AddResidualBlock添加,每个视觉因子只关联一个位姿和一个路标点;而IMU因子则一步到位关联两个关键帧的7个状态块。如果状态块的编号或者维度不一致,Ceres会在求解时直接报错“parameter block size mismatch”。

另外,VINS-Mono在移除滑动窗口中的旧帧时,需要同步移除对应的IMU因子和视觉因子。这个移除操作依赖Ceres的Problem::RemoveResidualBlock接口,而调用这个接口时需要传入残差块的ID。因此,代码里通常会维护一个残差块ID的列表,在边缘化时逐个处理。如果你自己改动窗口大小或者边缘化策略,一定要检查这个列表是否同步更新,否则会出现Ceres内部状态不一致的诡异问题。

6. 我在调试IMU预积分时踩过的坑

6.1 零偏更新导致的不一致问题

我第一次给VINS-Mono换IMU模型时,遇到过一个特别诡异的现象:初始化阶段表现正常,但跑到几十秒后开始漂移,而且越是快速运动,漂移越严重。查了很久发现,问题出在零偏初值上。

integrationBase在构造时默认把零偏设为零,但在VINS-Mono的初始化流程里,processIMU()会在初始化完成前先估计一次陀螺仪零偏,然后用估计到的值去构造后续的预积分器。如果你在改代码时不小心让预积分器的零偏值和后端优化里的零偏初值不一致,就会导致残差计算时修正量出现常量偏差,而这个偏差会在积分中不断累积。

解决方式其实很简单:在初始化完成后,确保所有预积分器都使用相同的零偏初值,并且linearized_ba和linearized_bg与后端的先验一致。这类问题不会导致系统崩溃,但会表现为精度逐渐变差,排查起来很花时间。

6.2 协方差初值与尺度问题

协方差矩阵的初值设置对优化收敛有一定影响,但很多人容易忽略。VINS-Mono里预积分协方差初值是零矩阵,这个选择意味着初始时刻认为预积分量是完全准确的,然后随IMU噪声累积逐步增加不确定性。

如果你的IMU噪声参数设置得过大,协方差递推出来的数值会很大,导致sqrt_info矩阵的数值很小,最终IMU因子在优化中的权重被压低,系统就更依赖视觉。反过来,噪声参数设得太小,IMU的权重被抬高,一旦IMU数据质量不好,系统就会跟着遭殃。

一个比较实用的调参经验是:先用量测数据离线计算一下IMU的Allan方差,把噪声密度和随机游走参数标定出来,再填进配置。如果手头没有Allan方差工具,至少也要参考IMU芯片手册上的参数,不要随便填。

6.3 时间戳对齐的暗坑

时间戳问题我前面提过,这里再强调一次。VINS-Mono对IMU时间戳要求得很严格,它假设IMU数据按时间顺序到达,并且两帧IMU数据之间的时间间隔就是dt。如果数据里有重复时间戳或者乱序时间戳,dt可能为负,导致预积分出现NaN。

更隐蔽的问题是:IMU和相机的时间戳如果存在固定的时间偏移,预积分量和视觉观测之间的几何一致性会被破坏,但程序不会报错,只会表现为精度下降。解决这类问题的方法也比较成熟,就是用kalibr或者类似的工具做一次时间戳标定,获取相机和IMU之间的延时,然后在喂数据时统一补偿。

6.4 一些调参经验

最后说几条我在实践中总结出来的经验。

  • 如果室内的快速旋转场景下VINS-Mono容易飘,优先看陀螺仪噪声和零偏随机游走参数,多半是gyr_noise和gyr_bias设得太乐观。
  • 如果低纹理环境下经常初始化失败,可以适当降低IMU因子的协方差(也就是增大IMU权重),让系统在视觉信息不足时依然能约束住状态。
  • 如果跑自己采集的数据集,一定要注意IMU单位。有些IMU输出的加速度单位是g,而VINS-Mono默认使用m/s²,不换算的话预积分结果会偏差一个重力加速度量级,整个系统直接废掉。
  • 预积分里sum_dt不要自己去改,它表示该段预积分的总时间跨度,残差计算里依赖它。如果你用了不等间隔的IMU数据,务必确认这里的累加逻辑是准确的。

IMU预积分这部分代码,第一次读可能觉得矩阵又多又杂,但本质上就是一整套“用固定零偏积分、用Jacobian修正”的思路。把这个思路理清楚,再看processIMU()、integrationBase和imu_factor.h,就不会迷路了。

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

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

立即咨询