VIO(视觉惯性里程计)这几年在机器人、AR、自动驾驶里用得越来越广。简单来说,就是把相机和IMU的数据融合,让运动估计既平滑又不漂移。写VIO的时候,IMU数据怎么处理是绕不开的一环,尤其是imu.cpp这个文件,它几乎是整个系统的地基。这一节我们就把这个文件从头到尾拆一遍,看看每一块代码在干什么、为什么这么写。如果你已经在跑视觉SLAM,或者正准备从零搭一个VIO,那这篇内容基本是绕不开的,我会直接把平时调试时踩过的坑和验证技巧一并放进来。
1. 内容整体设计与思路拆解
1.1 imu.cpp在VIO中的角色
VIO的核心就是把视觉和惯性信息放到一个状态估计框架里,视觉擅长提供长期一致的几何约束,但相机帧率低、运动快时容易糊;IMU虽然噪声大、有偏置,但帧率高,能在短时间内提供非常平滑的相对运动估计。imu.cpp就是负责把原始IMU测量变成后端优化能用的那一刻的关键模块。
在一个典型的VIO系统里,imu.cpp通常被拆成几个职责清晰的函数:数据加载、测量预处理、预积分计算、协方差传播。这些功能单独放在一个文件里,不是为了代码好看,而是为了调试和替换。比如你要换一个IMU设备,只需要改传感器参数,不需要动优化器;你要加一个静态初始化,也只需要在预处理模块里加一段逻辑。整个VIO的状态变量是位姿、速度、偏置、重力方向等,而imu.cpp输出的预积分增量,直接决定了这些状态之间如何“约束”在一起。
所以imu.cpp不是简单的“传感驱动”,它承担了三个任务:第一,把原始测量变成干净的角速度和加速度;第二,把相邻两帧之间的IMU测量压缩成相对位姿变化(预积分);第三,给优化器提供这个相对变化的权重——协方差。理解了这一点,后面的代码解析就顺了。
1.2 代码模块划分与设计意图
我见过很多初学VIO的人,一上来就把IMU处理写成一个大函数,几百行下来,后面根本没法维护。我自己的习惯是模仿经典视觉SLAM框架的写法,把imu.cpp拆成几个小节块:
- 数据接口模块:读取原始IMU消息(时间戳、角速度、线加速度),通常以结构体
IMUMeasure或类对象来存储。 - 预积分模块:一个
Preintegration类,维护累加的相对旋转delta_R、相对速度delta_v、相对平移delta_p,还有对应的协方差矩阵covariance_。 - 偏置处理模块:因为IMU的偏置会随时间漂移,预积分结果要能对偏置做一阶修正,也就是说要提供对偏置的雅可比矩阵。
- 重力初始化模块:在VIO启动时需要估计初始重力方向,这在imu.cpp里常常以单独函数形式存在,比如
initGravity()。
这种设计背后有一个很实际的考虑:预积分需要在优化算法里被反复调用,如果每次优化迭代都重新积分一遍所有IMU数据,计算量会爆炸。预积分的好处是把帧间IMU测量独立处理,只优化偏置项对积分结果的“修正”,imu.cpp把它做成了“预积分结果+偏置扰动”的组合体,就能复用大量已算好的中间量。
此外,协方差传播也被设计成随预积分一起更新,而不是事后再算。这样在后端构建Hessian矩阵时,可以直接用当前的协方差作为信息矩阵,非常自然。
2. 核心细节解析与实操要点
2.1 IMU测量模型与噪声处理
代码里拿到一个IMU测量,第一件事是把它映射到理想的物理模型。常见的模型是这样写的:
// 陀螺仪测量模型 w_meas = w_true + b_g + n_g // 加速度计测量模型 a_meas = a_true + b_a + n_a这里的b_g是陀螺仪偏置,b_a是加速度计偏置,n_g和n_a是白噪声。在实际代码中,我们要做两件事:一是初始化偏置,比如静止时加速度计读数的均值就近似对应b_a和重力;二是在优化过程中持续估计偏置,所以imu.cpp里需要维护当前最新的偏置状态。
很多刚接触VIO的人会忽略一个细节:加速度计测的是“比力”,不是真正的线性加速度。当IMU静止时,加速度计的读数接近(-g),因为要抵消重力,所以它的方向往往是向上的。如果你直接用读数去和位移的二阶导做差,方向就会反。在imu.cpp里,常见做法是先定义一个重力向量gravity_,比如在初始世界坐标系下是(0,0,9.81)或(0,0,-9.81),具体方向取决于坐标系定义,然后在加速度积分中把重力项减去。
关于噪声参数怎么设,最稳妥的来源是厂商手册或标定程序。如果实在没有数据,可以用经验值:陀螺仪白噪声密度通常在1e-4~1e-3 rad/s/sqrt(Hz),加速度计在1e-3~1e-2 m/s^2/sqrt(Hz)。imu.cpp里一般把这些参数做成可配置变量,而不是写死在代码里,方便在数据集上反复调。
2.2 预积分原理及其代码实现
预积分这个概念的来源,是后端优化中经常要在两个关键帧(比如相机帧)之间插入IMU约束。如果每一轮优化都把这段IMU测量重新积分一遍,代价太大。于是把从第i帧到第j帧之间的所有IMU测量一次性积出来,形成三个增量量:
delta_R:相对旋转增量。delta_v:相对速度增量(考虑了重力但不含偏置修正)。delta_p:相对平移增量。
这三个量在后端构建残差时,用来计算预测值和测量值之间的差。在imu.cpp里,实现思路是遍历时间戳在t_i和t_j之间的所有IMU测量,然后用离散积分更新这三个量。具体更新公式可以简化为:
// 假设已经估计了陀螺仪和加速度计偏置 Eigen::Matrix3d R_i = delta_R; Eigen::Vector3d acc_meas = (a_meas - b_a); Eigen::Vector3d ang_vel = (w_meas - b_g); // 中值积分方法:取前后两个时间点的平均角速度 Eigen::Vector3d ang_vel_avg = 0.5 * (ang_vel + ang_vel_prev); double dt = t_cur - t_prev; // 更新相对旋转 delta_R = delta_R * Exp(ang_vel_avg * dt); // 更新相对速度 delta_v += delta_R * acc_meas * dt; // 注意加速度需转换到世界系 // 更新相对平移 delta_p += delta_v * dt + 0.5 * delta_R * acc_meas * dt * dt;Exp是李代数指数映射,把角速度向量转换为旋转矩阵增量。在代码里常用AngleAxis或Sophus库实现。中值积分比欧拉积分更精确,很多VIO框架都用它。这里有一个关键点:delta_R是相对于i时刻的坐标系,所以要记得每一步更新后都保持在同一个参考系下。
2.3 坐标系与参数初始化
imu.cpp里最常见的问题是坐标系混乱。IMU有自己的机体坐标系(通常x向右、y向前、z向上),相机也有自己的坐标系,世界坐标系往往和初始相机坐标系对齐。在VIO解算中,通常需要知道IMU到相机的变换T_bc,这个外参可以通过标定得到。在代码中,这个变换要么作为一个成员变量传入,要么在初始化时读取配置文件。
重力初始化也很重要。启动时IMU可能处于静止或缓慢运动状态,我们可以用前几秒钟的加速度计读数平均值来估计重力方向。更常见的做法是把重力作为一个待优化的方向向量,放在初始化模块里。但不论哪种方式,imu.cpp都需要提供一个“重力参数”给后续积分用,否则加速度积分会飘得离谱。
另外,时间对齐是一个大坑。相机帧和IMU测量通常来自不同设备,时间戳可能相差几十毫秒。如果不对齐,预积分结果会有系统偏差。我一般会在数据读取模块里做一个线性插值或最近邻匹配,先把IMU测量对齐到相机时间戳,然后再喂给预积分模块。
3. 实操过程与核心环节实现
3.1 数据读取与预处理
先看一个典型的IMU数据结构定义:
struct IMUData { double timestamp; // 秒 Eigen::Vector3d gyro; // 角速度,单位 rad/s Eigen::Vector3d acc; // 加速度(比力),单位 m/s^2 };读取数据时的第一件事是检查时间戳是否单调递增。有些低端IMU会在传输时丢包或者乱序,如果直接拿来做积分,整个预积分结果都会崩。我通常会在读取后做一个排序和去重操作,再把时间戳转成相对起始时刻的偏移值。
预处理还包括去除初始偏置。假设我们在静止状态下采集了N个测量,那么陀螺仪的初始偏置可以取这N个读数的均值,加速度计的初始偏置则需要减去重力方向分量。在imu.cpp里,我一般会实现一个calibrate()函数,输入前几秒的数据,输出初始偏置。
void calibrate(const std::vector<IMUData>& init_data, Eigen::Vector3d& bg, Eigen::Vector3d& ba) { Eigen::Vector3d gyro_sum = Eigen::Vector3d::Zero(); Eigen::Vector3d acc_sum = Eigen::Vector3d::Zero(); for (int i = 0; i < init_data.size(); ++i) { gyro_sum += init_data[i].gyro; acc_sum += init_data[i].acc; } bg = gyro_sum / init_data.size(); // 加速度计偏置初始值:假设已知重力方向,通常先置零,后续优化中估计 // 这里可以用静止时的加速度模长近似 g 的值 ba = Eigen::Vector3d::Zero(); // 初始不设偏置 }当然真实的VIO里,加速度计偏置往往会和重力一起初始化,因为重力方向未知,直接取均值会吃掉一部分重力分量。这里只是为了展示流程,实际实现会复杂一些。
3.2 偏置估计与校正
在滑动窗口VIO中,IMU偏置被放入状态向量,和位姿一起被优化。但预积分本身是在假设偏置固定的情况下计算的,所以当优化更新偏置后,预积分结果需要做一阶修正。
在imu.cpp里,通常的做法是保存预积分对偏置的雅可比矩阵。当优化器给出新的偏置b_g_new和b_a_new时,利用雅可比对原来的delta_R、delta_v、delta_p进行线性修正。这样就不需要重新积分了。
// 假设已经有 preintegration 对象,包含 delta_R, delta_v, delta_p 和偏置雅可比 void updateByBias(const Eigen::Vector3d& bg_new, const Eigen::Vector3d& ba_new) { Eigen::Vector3d dbg = bg_new - bg_; Eigen::Vector3d dba = ba_new - ba_; // 一阶修正 delta_R = delta_R * Exp(jacobian_R_bg * dbg); delta_v += jacobian_v_bg * dbg + jacobian_v_ba * dba; delta_p += jacobian_p_bg * dbg + jacobian_p_ba * dba; // 注意更新后的偏置 bg_ = bg_new; ba_ = ba_new; }这一步是整个imu.cpp的精髓,能不能高质量地集成到非线性优化器里,就看这个修正模块写不写得准。我在调试时经常故意给一个偏置扰动,再验算修正后的预积分和直接从原始测量重新积分的差值,确保在误差范围内。
3.3 预积分增量计算与更新
下面是一个简单的预积分类主体实现,我把核心流程放在integrate()方法里:
class Preintegration { public: Preintegration(const Eigen::Vector3d& gyro_bias_, const Eigen::Vector3d& acc_bias_, double dt_interval, const Eigen::Matrix<double, 6, 6>& cov_imu) : bg_(gyro_bias_), ba_(acc_bias_), dt_interval_(dt_interval), cov_imu_(cov_imu) {} void integrate(const IMUData& imu_pre, const IMUData& imu_cur) { double dt = imu_cur.timestamp - imu_pre.timestamp; if (dt <= 0.0) return; Eigen::Vector3d gyro_avg = 0.5 * (imu_pre.gyro + imu_cur.gyro) - bg_; Eigen::Vector3d acc_avg = 0.5 * (imu_pre.acc + imu_cur.acc) - ba_; // 更新旋转增量 Eigen::Matrix3d dR = Exp(gyro_avg * dt); delta_R = delta_R * dR; // 更新速度增量 (加速度需变换到世界系 i 时刻) Eigen::Vector3d acc_world = delta_R * acc_avg - gravity_; delta_v += acc_world * dt; // 更新平移增量 delta_p += delta_v * dt + 0.5 * acc_world * dt * dt; // 更新协方差 (省略,实际需要根据噪声模型离散化) // updateCovariance(dt, dR, acc_avg); } PreintegratedResult getResult() const { return {delta_R, delta_v, delta_p}; } private: Eigen::Vector3d bg_, ba_; Eigen::Vector3d gravity_; double dt_interval_; Eigen::Matrix<double, 6, 6> cov_imu_; Eigen::Matrix3d delta_R = Eigen::Matrix3d::Identity(); Eigen::Vector3d delta_v = Eigen::Vector3d::Zero(); Eigen::Vector3d delta_p = Eigen::Vector3d::Zero(); };注意这里更新delta_p用的是delta_v,这个delta_v是速度增量,而不是当前时刻的速度。因为delta_v始终是相对于第i时刻的参考系,所以加速度的积分方式必须保持一致。好多人在这个地方容易写错,写成delta_p += delta_v * dt就跳过了重力补偿,结果轨迹会往下掉。
每次integrate调用前,必须先手动设置重力向量,比如在初始化时调用setGravity(g)。否则预积分出来的是比力积分,没有扣除重力,最终位置会随时间的平方指数增长,一眼就能看出野值。
3.4 协方差传播
预积分结果的可靠性如何,最后都要落到协方差矩阵上。在优化器里,残差的信息矩阵通常取协方差矩阵的逆。如果协方差估计过小,优化会过度信任IMU,导致轨迹僵硬;如果过大,IMU约束就被忽略了,VIO退化成视觉SLAM。
协方差传播采用误差状态运动模型,把连续时间IMU噪声离散化成线性更新。一个简化的过程是这样:
void updateCovariance(double dt, const Eigen::Matrix3d& dR, const Eigen::Vector3d& acc_avg) { // 状态误差维度:旋转(3) + 速度(3) + 平移(3) Eigen::Matrix<double, 9, 9> F = Eigen::Matrix<double, 9, 9>::Identity(); Eigen::Matrix<double, 9, 12> G = Eigen::Matrix<double, 9, 12>::Zero(); // 理论公式中,F 是对状态误差的雅可比,G 是噪声输入矩阵 // 实际数值需按公式推导,这里只展示框架 // F.block<3,3>(0,3) = -delta_R * skew(acc_avg) * dt; // F.block<3,3>(3,6) = I_3x3 * dt; // G.block<3,3>(0,0) = delta_R * dt; // G.block<3,3>(3,3) = delta_R * dt; // ... Eigen::Matrix<double, 9, 9> Q = Eigen::Matrix<double, 9, 9>::Zero(); // 噪声协方差 covariance_ = F * covariance_ * F.transpose() + G * Q * G.transpose(); }实际代码中要按官方推导的公式逐块赋值,不能拍脑袋。我的建议是,如果你不想手推这些矩阵,先把IMU噪声模型的标准离散化公式抄一遍,再按尺寸填充矩阵,然后验证它的一个最基本性质:随着时间增长,协方差要单调增大,而且增量要正定。我遇到过有人在代码里把协方差更新写成了减法,结果优化器直接报inf,查了半天才发现是符号写反了。
4. 常见问题与排查技巧实录
4.1 坐标系不一致导致的错误
VIO调试中最常见的现象是轨迹绕着一个轴陀螺,或者旋转方向反了。这类问题90%出在坐标系定义不一致上。比如相机坐标系通常是z向前、x向右、y向下(OpenCV风格),而IMU坐标系可能是x向前、y向左、z向上。如果你直接用相机外参去校正IMU测量,预积分出来的旋转就完全对不上。
我会在imu.cpp里专门加一个打印函数,输出第一帧预积分后的旋转矩阵,和视觉估计的短暂位移做对比。如果旋转方向差了一个轴,一眼就能看出来。还有一种隐蔽的情况:四元数的虚部顺序不同,Eigen默认是(x, y, z, w),但有些代码库用的是(w, x, y, z),跨库使用时必须转换。我曾经因为没有转换四元数顺序,导致整个优化结果发散。
4.2 数值不稳定与单位问题
IMU的原始数据单位五花八门。有的设备给的是角速度(rad/s),有的给的是度/秒;有的加速度计读数已经包含了重力,有的还需要你自己减去9.81。我建议在一开始就统一单位,并且在代码注释里写清楚。否则后期调参时很难定位问题是出在数学公式上还是单位转换上。
另一个数值问题是双精度和单精度。预算分矩阵很小,旋转矩阵每个元素都接近1或0,如果只用float,误差在几千帧之后会被放大,协方差也会失去正定性。我习惯在预积分内部全部用double,只有在输出给优化器计算残差时才根据需要转成单精度,这样能省不少麻烦。
4.3 调试技巧
我在手写VIO初期走的最有效的路径,是先构造一个模拟数据集。假设IMU理想、外参已知、重力准确,那么预积分增量应该和真实运动的位移、旋转完全一致。在这个条件下,如果delta_p算出来偏了,就一定是代码里的公式错了。具体做法很简单:把delta_R乘上一个已知旋转,构造一段角速度,然后旋转加速度计测量,再手动积分一遍,和imu.cpp的输出对比。
另一个实用技巧是打印每一帧的delta_R和相邻视觉位姿的旋转差。正常情况下两者应该接近,如果偏差很大,说明IMU和相机的时间戳没有对齐,或者外参有误差。这也是我为什么建议在imu.cpp里保留一个“调试模式”开关,可以把中间量都输出到日志里,而不是只给后端一个最终结果,排查问题会快很多。
我在实盘调试时,最先做的不是跑完整数据集,而是构造一段已知运动的模拟数据。把IMU参数设成理想值,看预积分结果是否和真值吻合。这个习惯帮我避开了很多低级错误。如果你也在手写VIO,不妨先从imu.cpp下手,把这块做扎实,后面的优化和前端才有稳固的支撑。