资讯动态

从零实现ESKF:IMU状态估计中的误差卡尔曼滤波实战

发布时间:2026/9/28 12:48:52 来源:尧图企业网站定制
1. 从零吃透ESKF为什么IMU状态估计需要误差卡尔曼滤波搞过IMU姿态解算的人多半都有过这样的经历拿MPU6050或者ICM42688这类六轴传感器先用互补滤波凑合跑一跑发现静态还行一动起来就飘得亲妈都不认识。换Mahony或者Madgwick好一点但遇到剧烈运动或者长时间运行航向角还是慢慢跑偏。这时候你自然会想到卡尔曼滤波——毕竟这是状态估计的“正规军”。但打开教材一看标准卡尔曼滤波KF要求系统是线性的而IMU的姿态运动学本质上是非线性的四元数微分方程里全是乘法耦合。扩展卡尔曼滤波EKF倒是能处理非线性可它需要把状态转移矩阵和观测矩阵都线性化推导雅可比矩阵的过程极其繁琐而且四元数的归一化约束在EKF里处理起来很别扭。误差卡尔曼滤波Error-State Kalman FilterESKF就是专门为解决这个问题而生的。它的核心思路非常巧妙不去直接估计状态本身比如四元数、速度、位置而是估计“真实状态”和“名义状态”之间的误差。名义状态用非线性方程递推误差状态用线性卡尔曼滤波来估计。这样做的好处是误差状态始终在小量附近线性化精度极高而且四元数的归一化约束可以单独处理不会污染协方差矩阵。我第一次接触ESKF是在做VIO视觉惯性里程计项目的时候当时用VINS-Mono跑D435i发现它的后端就是标准的ESKF结构。后来自己从零手写了一遍才真正理解为什么这个框架在IMU状态估计里这么受欢迎。这篇文章我会把整个实现过程拆开揉碎从状态定义、运动学递推、误差状态预测、观测更新到C代码落地全部讲清楚。适合已经了解卡尔曼滤波基本概念、想深入IMU融合定位的开发者也适合正在做机器人导航、自动驾驶定位相关项目的同学。2. ESKF整体设计思路与状态定义2.1 为什么选ESKF而不是EKF或UKF先说选型逻辑。EKF直接对非线性系统做一阶泰勒展开线性化点就是当前估计值。对于IMU这种高频运动系统姿态变化剧烈一阶近似的误差会很大。UKF无迹卡尔曼滤波通过sigma点传播来避免雅可比矩阵精度更高但计算量也更大而且对于四元数这种带约束的状态sigma点的生成和加权需要额外处理实现复杂度不低。ESKF的优势在于误差状态是小量线性化点始终在零附近一阶近似就足够精确。而且误差状态里的姿态误差可以用三维旋转向量表示不需要四元数的四维约束协方差矩阵永远是3×3的姿态块不会出现奇异。另外ESKF的名义状态递推和误差状态预测是解耦的代码结构清晰调试起来也方便。我实测下来在同样的IMU数据下ESKF的姿态估计精度比EKF高一个档次尤其是在快速旋转和长时间运行场景下漂移明显更小。计算量方面ESKF和EKF基本持平远低于UKF。2.2 状态向量定义与坐标系约定ESKF的状态分为名义状态和误差状态两部分。名义状态包括位置p3维速度v3维姿态四元数q4维加速度计零偏b_a3维陀螺仪零偏b_g3维总共16维。误差状态则是位置误差 δp3维速度误差 δv3维姿态误差 δθ3维旋转向量加速度计零偏误差 δb_a3维陀螺仪零偏误差 δb_g3维总共15维。注意姿态误差是3维而不是4维这是ESKF的关键设计——用旋转向量表示误差避免了四元数的过参数化问题。坐标系约定世界坐标系World Frame记为WIMU机体坐标系Body Frame记为B。重力向量在世界系下为g [0, 0, -9.81]^T假设Z轴向上。IMU测量的是机体坐标系下的角速度和加速度。注意坐标系约定一定要在代码开头就定死不然后面推导和调试会乱成一锅粥。我习惯用右手系Z轴向上重力为负。如果你用Z轴向下所有符号都要反过来。2.3 名义状态递推方程名义状态的递推完全基于IMU测量值不考虑噪声。设IMU的角速度测量为ω_m加速度测量为a_m则陀螺仪去偏ωω_m-b_g加速度计去偏aa_m-b_a姿态更新q←q⊗ Δq(ω·dt)其中Δq是由旋转向量ω·dt构造的增量四元数速度更新v←v (R·ag)·dt其中R是q对应的旋转矩阵位置更新p←pv·dt 0.5·(R·ag)·dt²零偏更新b_a←b_ab_g←b_g零偏建模为随机游走名义值不变这里有个细节速度更新用的是更新前的姿态还是更新后的姿态理论上应该用中点积分但为了简化通常用更新前的姿态。实测差异很小除非dt很大。2.4 误差状态预测方程误差状态的预测方程是ESKF的核心。对名义状态方程做一阶泰勒展开得到误差状态的线性递推δp← δp δv·dt δv← δv (-R·[a]×·δθ-R·δb_a)·dt δθ←R^T·δθ- δb_g·dt δb_a← δb_aδb_g← δb_g其中[a]×是加速度的反对称矩阵。这个递推关系可以写成矩阵形式δx←F·δx其中F是15×15的状态转移矩阵。F矩阵的结构如下按δp, δv, δθ, δb_a, δb_g分块块表达式F_ppIF_pvI·dtF_vvIF_vθ-R·[a]×·dtF_vba-R·dtF_θθR^TF_θbg-I·dtF_babaIF_bgbgI其余块为零。这个矩阵是稀疏的代码里可以直接按块填充不用构造完整的15×15矩阵再做乘法。实操心得F矩阵里的R^T和R一定要搞清楚是哪个方向。我当初在这里卡了两天就是因为把R^T写成了R结果姿态误差一直发散。记住姿态误差是在机体坐标系下定义的所以旋转矩阵要用R^T。3. C代码实现从状态定义到预测更新3.1 工程结构与依赖选择我用的工程结构很简单不依赖ROS或者Eigen之外的大型库。核心文件就三个eskf.h类定义和状态结构eskf.cpp预测和更新实现main.cpp数据读取和测试依赖方面Eigen是必须的用于矩阵运算和四元数操作。如果你不想用Eigen也可以手写矩阵运算但代码量会大很多而且容易出错。我建议直接用Eigen版本3.3以上就行。编译环境用CMakeC14标准。如果你在Windows上VS2019或者VS2022都可以注意安装“使用C的桌面开发”工作负载。Linux下直接g就行。cmake_minimum_required(VERSION 3.10) project(eskf_demo) set(CMAKE_CXX_STANDARD 14) find_package(Eigen3 REQUIRED) add_executable(eskf_demo main.cpp eskf.cpp) target_link_libraries(eskf_demo Eigen3::Eigen)3.2 状态结构体与初始化先定义状态结构体。名义状态和误差状态分开存协方差矩阵单独存。struct NominalState { Eigen::Vector3d p; // 位置 Eigen::Vector3d v; // 速度 Eigen::Quaterniond q; // 姿态 Eigen::Vector3d ba; // 加速度计零偏 Eigen::Vector3d bg; // 陀螺仪零偏 }; struct ErrorState { Eigen::Vector3d dp; Eigen::Vector3d dv; Eigen::Vector3d dtheta; Eigen::Vector3d dba; Eigen::Vector3d dbg; };协方差矩阵是15×15的初始化时姿态和零偏的不确定性给大一点位置和速度给小一点。我一般这样设P.setZero(); P.block3,3(0,0) Eigen::Matrix3d::Identity() * 1e-4; // 位置 P.block3,3(3,3) Eigen::Matrix3d::Identity() * 1e-4; // 速度 P.block3,3(6,6) Eigen::Matrix3d::Identity() * 1e-2; // 姿态 P.block3,3(9,9) Eigen::Matrix3d::Identity() * 1e-2; // 加速度计零偏 P.block3,3(12,12) Eigen::Matrix3d::Identity() * 1e-2; // 陀螺仪零偏噪声参数方面陀螺仪噪声密度我一般取0.005 rad/s/√Hz加速度计取0.05 m/s²/√Hz零偏随机游走取1e-4左右。这些值要根据你的IMU手册来调不同型号差异很大。注意初始化时姿态四元数一定要归一化否则后续旋转矩阵会出错。我习惯在构造函数里直接调用q.normalize()。3.3 预测步骤的完整实现预测步骤分两部分名义状态递推和误差状态协方差传播。名义状态递推代码void ESKF::predict(const Eigen::Vector3d omega_m, const Eigen::Vector3d acc_m, double dt) { // 去偏 Eigen::Vector3d omega omega_m - nom.bg; Eigen::Vector3d acc acc_m - nom.ba; // 姿态更新 Eigen::Vector3d angle_axis omega * dt; Eigen::Quaterniond dq; dq Eigen::AngleAxisd(angle_axis.norm(), angle_axis.normalized()); nom.q nom.q * dq; nom.q.normalize(); // 旋转矩阵 Eigen::Matrix3d R nom.q.toRotationMatrix(); // 速度更新 Eigen::Vector3d acc_world R * acc gravity; nom.v acc_world * dt; // 位置更新 nom.p nom.v * dt 0.5 * acc_world * dt * dt; // 零偏不变 }这里有个细节angle_axis.norm()可能为零直接归一化会出NaN。加个判断if (angle_axis.norm() 1e-8) { dq Eigen::AngleAxisd(angle_axis.norm(), angle_axis.normalized()); } else { dq Eigen::Quaterniond::Identity(); }协方差传播代码void ESKF::predictCovariance(const Eigen::Vector3d acc, double dt) { Eigen::Matrix3d R nom.q.toRotationMatrix(); Eigen::Matrix3d R_trans R.transpose(); // 构造F矩阵 Eigen::Matrixdouble, 15, 15 F Eigen::Matrixdouble, 15, 15::Identity(); F.block3,3(0,3) Eigen::Matrix3d::Identity() * dt; Eigen::Matrix3d acc_skew; acc_skew 0, -acc(2), acc(1), acc(2), 0, -acc(0), -acc(1), acc(0), 0; F.block3,3(3,6) -R * acc_skew * dt; F.block3,3(3,9) -R * dt; F.block3,3(6,6) R_trans; F.block3,3(6,12) -Eigen::Matrix3d::Identity() * dt; // 噪声矩阵Q Eigen::Matrixdouble, 15, 15 Q Eigen::Matrixdouble, 15, 15::Zero(); Q.block3,3(3,3) Eigen::Matrix3d::Identity() * acc_noise * acc_noise * dt * dt; Q.block3,3(6,6) Eigen::Matrix3d::Identity() * gyro_noise * gyro_noise * dt * dt; Q.block3,3(9,9) Eigen::Matrix3d::Identity() * acc_bias_noise * acc_bias_noise * dt; Q.block3,3(12,12) Eigen::Matrix3d::Identity() * gyro_bias_noise * gyro_bias_noise * dt; // 协方差传播 P F * P * F.transpose() Q; }实操心得Q矩阵的构造方式直接影响滤波器的收敛速度。我试过把Q设得太小结果滤波器对IMU噪声不敏感姿态跟踪滞后设得太大又会导致估计值抖动。建议先用IMU手册里的噪声密度值然后根据实际数据微调。一般来说陀螺仪噪声对姿态影响最大加速度计噪声对速度和位置影响最大。3.4 观测更新以GPS位置观测为例ESKF的观测更新和标准卡尔曼滤波类似但要注意观测矩阵H是针对误差状态的。假设我们有一个GPS位置观测z_p观测方程为z_p pn_p其中n_p是观测噪声。对误差状态求导得到观测矩阵H [I_3×3, 0_3×3, 0_3×3, 0_3×3, 0_3×3]更新步骤void ESKF::updatePosition(const Eigen::Vector3d z_p, const Eigen::Matrix3d R_noise) { Eigen::Matrixdouble, 3, 15 H Eigen::Matrixdouble, 3, 15::Zero(); H.block3,3(0,0) Eigen::Matrix3d::Identity(); Eigen::Vector3d residual z_p - nom.p; Eigen::Matrix3d S H * P * H.transpose() R_noise; Eigen::Matrixdouble, 15, 3 K P * H.transpose() * S.inverse(); Eigen::Matrixdouble, 15, 1 dx K * residual; // 注入误差状态 injectError(dx); // 协方差更新 Eigen::Matrixdouble, 15, 15 I Eigen::Matrixdouble, 15, 15::Identity(); P (I - K * H) * P; }误差注入是关键步骤把估计出来的误差状态加到名义状态上然后误差状态清零void ESKF::injectError(const Eigen::Matrixdouble, 15, 1 dx) { nom.p dx.segment3(0); nom.v dx.segment3(3); Eigen::Vector3d dtheta dx.segment3(6); Eigen::Quaterniond dq; if (dtheta.norm() 1e-8) { dq Eigen::AngleAxisd(dtheta.norm(), dtheta.normalized()); } else { dq Eigen::Quaterniond::Identity(); } nom.q nom.q * dq; nom.q.normalize(); nom.ba dx.segment3(9); nom.bg dx.segment3(12); }注意误差注入后协方差矩阵P不需要重置因为误差状态已经清零但P描述的是误差的不确定性注入后误差变为零P应该保持不变。有些实现会在注入后对P做修正但标准ESKF不需要。4. 调试与实战常见问题排查与参数调优4.1 姿态发散与零偏估计异常姿态发散是ESKF调试中最常见的问题。表现是静止时姿态缓慢旋转或者运动后航向角大幅偏移。原因通常有三个一是陀螺仪零偏没有正确估计二是姿态误差的协方差设置不合理三是旋转矩阵方向搞反了。排查方法先静止跑一段时间看陀螺仪零偏估计值是否收敛到测量值的均值附近。如果零偏估计一直在漂说明Q矩阵里陀螺仪零偏的噪声设得太大或者观测更新没有正确约束零偏。我一般会在静止时加一个零速度观测ZUPT强制速度为零这样能有效约束零偏。另一个常见问题是姿态误差的协方差矩阵P的姿态块出现负特征值导致滤波器崩溃。这是因为数值误差累积P不再正定。解决办法是每次更新后对P做对称化P 0.5 * (P P.transpose())并且定期做Cholesky分解检查正定性。4.2 观测更新频率与延迟处理IMU的预测频率通常很高100Hz到1kHz而观测GPS、视觉、磁力计频率低得多1Hz到30Hz。如果直接在观测到来时做更新会出现“观测滞后”问题——观测值对应的是过去某个时刻的状态而名义状态已经递推到当前时刻了。处理方法是维护一个状态缓冲区记录每个IMU时刻的名义状态和协方差。当观测到来时找到对应时刻的状态做更新然后重新递推后续状态。这个实现起来比较复杂简单一点的做法是接受一定的滞后误差或者用观测时间戳做插值。我实测下来对于低速运动场景比如室内机器人直接在当前时刻更新问题不大但对于高速运动比如无人机必须做时间对齐否则位置估计会有明显偏差。4.3 参数调优速查表下面这张表是我在实际项目中总结的参数调优经验供参考参数典型值调大效果调小效果陀螺仪噪声0.005 rad/s/√Hz姿态跟踪快但抖动大姿态平滑但跟踪滞后加速度计噪声0.05 m/s²/√Hz速度响应快但噪声大速度平滑但响应慢陀螺仪零偏噪声1e-4 rad/s²/√Hz零偏收敛快但可能震荡零偏稳定但收敛慢加速度计零偏噪声1e-3 m/s³/√Hz零偏适应快零偏稳定初始姿态协方差1e-2初始收敛快初始收敛慢观测噪声GPS1.0 m²更信任IMU更信任GPS实操心得调参时先固定观测噪声调IMU噪声让滤波器在静止时零偏收敛、运动时姿态跟踪不滞后。然后再调观测噪声平衡IMU和观测的权重。我一般会录一段数据反复回放调参这样效率最高。4.4 数值稳定性与四元数归一化四元数在长时间递推后会出现数值漂移模长不再为1。虽然每次更新后都调用了normalize()但协方差矩阵里的姿态块仍然可能因为四元数的微小误差而累积偏差。我的做法是每次预测后都归一化四元数并且在误差注入后也归一化。另外旋转矩阵用四元数直接转换不要手动构造避免符号错误。还有一个坑是Eigen的四元数乘法顺序。q1 * q2表示先应用q2再应用q1和旋转矩阵的乘法顺序一致。如果你搞反了姿态会朝反方向旋转。我当初在这里调了半天最后用单位旋转向量测试才确认。5. 进阶扩展从ESKF到多传感器融合5.1 加入磁力计观测磁力计可以提供航向角的绝对参考弥补陀螺仪航向漂移的问题。观测方程是磁力计测量的是机体坐标系下的磁场向量m_b世界坐标系下的磁场向量m_w已知可以通过查表或者初始化时估计。观测残差为rm_b -R^T ·m_w对误差状态求导观测矩阵H的姿态块为H_θ [R^T ·m_w]×其余块为零。加入磁力计后航向角不再漂移但要注意磁力计容易受干扰观测噪声要设大一点或者用鲁棒核函数抑制异常值。5.2 与视觉观测融合视觉观测通常提供的是特征点的重投影误差观测方程比较复杂但核心思想一样把重投影误差对误差状态求导得到观测矩阵。VINS-Mono就是典型的ESKF视觉观测的框架。如果你要做VIO建议先跑通纯IMU的ESKF再加入视觉观测逐步调试。5.3 代码优化与实时性ESKF的计算量主要在协方差传播和更新上15×15的矩阵运算在现代CPU上跑1kHz没问题。但如果你的IMU频率更高或者要跑在嵌入式平台上可以做以下优化利用F矩阵的稀疏性手动展开矩阵乘法用固定大小的Eigen矩阵Matrixdouble, 15, 15而不是动态矩阵把Q矩阵的构造提到循环外面只更新与dt相关的项。我实测在树莓派4B上单次预测更新耗时约50微秒跑1kHz绰绰有余。如果你用STM32之类的MCU建议降到100Hz并且用单精度浮点。最后分享一个小技巧调试ESKF时把名义状态、误差状态、协方差对角线都打印出来用Python画图分析。特别是协方差对角线如果某个状态的不确定性一直增大说明该状态没有被观测约束需要检查观测矩阵或者增加观测。这个ESKF框架后续还可以扩展加入轮速计观测做里程计融合加入气压计做高度估计或者用神经网络学习IMU噪声模型来替代高斯假设。我自己在实际项目中用这套代码跑了半年多稳定性很好姿态误差在1度以内位置误差在1%行程以内。如果你也在做IMU状态估计建议从这套代码开始先跑通再优化比直接上复杂的VIO框架要踏实得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑