深入解析EKF传感器融合:手写滤波器替换robot_pose_ekf的实战指南 写到一个“替换robot_pose_ekf”的系列说实话这个主题我酝酿了很久。前几篇已经把EKF的数学基础、雅可比推导、噪声模型讲了一遍这一篇我想直接落到工程上。为什么非要自己写一个EKF去替换现成的robot_pose_ekf没有别的原因就是被逼的。当时项目里用robot_pose_ekf做轮式里程计加IMU的融合室内平整地面上表现还行一旦上到室外草地、石子路输出就开始“发飘”yaw角偶尔还会跳变几十度底盘明明在直行rviz里的轨迹却在原地打转。打开robot_pose_ekf源码一看滤波器的状态模型全写死了想加一个IMU零偏估计都没地方下手调参只能调那几个对角矩阵改完有没有效果全凭感觉。这种情况下自己写一个可控的EKF替换进去几乎是唯一靠谱的出路。这篇文章面向的是已经在用ROS做机器人定位、又不想被轮子绑死的开发者。内容不是把EKF公式再抄一遍而是完整记录从状态建模、代码实现到ROS接口替换、调参踩坑的全过程最后让你拿到一个可以直接编译、可以直接替换robot_pose_ekf的最小可用实现。1. 为什么放着现成的robot_pose_ekf不用偏要自己写先说清楚robot_pose_ekf到底做了什么以及我在实际项目中踩到了哪些具体问题这样你才能判断自己是不是也有同样的替换需求。1.1 robot_pose_ekf做了什么robot_pose_ekf是ROS里一个老牌传感器融合功能包它的输入是多个传感器消息最常见的是轮式里程计nav_msgs/Odometry和IMUsensor_msgs/Imu也支持视觉里程计。它内部的滤波器把各个传感器的数据按时间顺序喂进一个扩展卡尔曼滤波器输出一个融合后的nav_msgs/Odometry发布在/robot_pose_ekf/odom_combined话题上同时广播odom - base_footprint的坐标变换。它的状态模型是7维的包含三维位置和代表姿态的roll、pitch、yaw三个欧拉角。每个传感器进来的时候滤波器会先根据运动模型预测到当前时刻再用这个传感器的观测做校正。这个思路本身很经典问题在于具体实现。1.2 真实使用中遇到的三个痛点第一个痛点是状态模型不可扩展。项目里轮式里程计存在轮距不准导致的yaw漂移IMU存在零偏这些都需要放在滤波器状态里在线估计。robot_pose_ekf的状态量是固定的7维想加一个0.01量级的偏置参数都做不到只能眼睁睁看着轨迹慢慢画弧。第二个痛点是噪声参数和滤波器行为之间隔着太多抽象。它每个传感器对应一组固定的测量噪声矩阵没有把协方差矩阵的物理含义暴露出来。调参的时候我只能盲调R_odom、R_imu这些参数至于滤波器的预测噪声Q矩阵更是写死在代码逻辑里想让它模拟轮子打滑、IMU温漂这些真实场景根本无从谈起。第三个痛点是调试困难。robot_pose_ekf内部不打印任何状态协方差矩阵滤波发散了你根本无从判断是哪个传感器的观测出了问题还是预测模型不对。有一次调试室外长距离行走数据融合后的位置和真实轨迹差了快两米我在终端里什么都看不到只能靠录包离线分析。那种对着黑盒子的无力感经历过一次就再也不想经历了。1.3 替换的意义接口不变内核可控我当时的替换思路是这样的输入输出接口完全不变继续订阅/odom和/imu/data继续发布/odom_combined和TF但把内部滤波器换成自己写的EKF类。这样一来导航栈、AMCL、move_base这些下游模块完全无感知不需要改任何配置而滤波器的行为完全掌握在自己手里。对比维度robot_pose_ekf自研EKF状态模型固定7维不可改可自由定义随时扩展Q矩阵隐藏实现不易调显式定义物理含义清楚调试能力无协方差输出可打印任何中间量传感器模型内置三种难新增可随意叠加观测模型代码可控性黑盒完全透明这张表是我替换完之后总结的核心优势其实就是一句话出了问题我能立刻知道是哪一环出了问题而不是对着一个黑盒子猜。2. 动手前把EKF原理吃透状态量、运动模型与观测模型自己写EKF的第一步不是写代码而是把状态量、运动模型、观测模型这三件事想清楚。这三件事定下来代码只是把它们翻译成矩阵运算而已。2.1 状态量为什么还是7维位置加欧拉角我一开始也纠结过要不要用四元数表示姿态因为四元数没有万向锁问题。但考虑到两个原因最终选择了和robot_pose_ekf一致的7维状态x [x, y, z, roll, pitch, yaw]^T第一个原因是兼容性。既然目标是无缝替换输出格式和状态含义最好和原实现保持一致这样下游模块在处理位姿时没有任何迁移成本。第二个原因是直观。欧拉角的物理含义清楚调试时打印出来一眼就能看出yaw在怎么变而四元数的四个分量对大多数人来说不够直观。当然如果项目需要做全姿态机动比如无人机、机械臂那就必须用四元数或者旋转矩阵表示姿态。但对于地面轮式机器人底盘本身不会出现roll和pitch接近90度的情况欧拉角完全够用。2.2 运动模型的推导与雅可比地面轮式机器人的运动模型我采用一个非常实用的方案使用里程计提供的速度作为控制输入使用IMU提供的偏航角速度来决定机器人朝向的变化。当然如果只用里程计那么偏航角速度也可以从里程计获得但我在实际项目中把yaw的更新主要交给IMU因为IMU的陀螺仪比轮式里程计的差速推算更稳定。假设控制输入为u [v, omega]^T其中v是线速度omega是偏航角速度。离散时间下的运动方程为x_{k1} x_k v * dt * cos(yaw_k) y_{k1} y_k v * dt * sin(yaw_k) z_{k1} z_k roll_{k1} roll_k pitch_{k1} pitch_k yaw_{k1} yaw_k omega * dt这个模型属于“恒速恒转向”模型前提是dt足够小通常传感器频率在20Hz以上时这个假设误差很小。如果底盘经常急加速急刹车可以在控制输入里加入线加速度项但那样状态量要扩展复杂度会成倍上升。状态转移的雅可比矩阵F就是对上述方程关于状态向量求偏导。这个必须推准确因为EKF的协方差传播全靠它。F矩阵如下F [ 1, 0, 0, 0, 0, -v*dt*sin(yaw) ] [ 0, 1, 0, 0, 0, v*dt*cos(yaw) ] [ 0, 0, 1, 0, 0, 0 ] [ 0, 0, 0, 1, 0, 0 ] [ 0, 0, 0, 0, 1, 0 ] [ 0, 0, 0, 0, 0, 1 ]注意第1行第6列和第2行第6列这两个元素它们表示yaw的变化如何通过坐标旋转影响x和y的位置这也是平面运动模型里位置和姿态耦合的关键。如果你漏了这两项滤波器会认为位置和yaw互不影响协方差传播会失真融合效果会大打折扣。2.3 观测模型里程计和IMU分别观测什么在EKF里观测模型描述的是“传感器的读数对应状态的哪几个分量”。对于轮式里程计它给出的位姿估计通常包含x、y、z、roll、pitch、yaw也就是对全状态都提供了观测观测矩阵H是6x7的单位阵。但在实际使用中里程计在z方向误差很大很多机器人平台甚至不提供可信的z观测。我在实现里做了一个可配置项选择是否使用里程计的z分量。对于地面机器人我通常只使用里程计的x、y和yaw三个分量z交给IMU或激光雷达去约束。对于IMU经过内置姿态解算后它通常直接发布四元数形式的姿态即/imu/data消息里的orientation字段。这种情况下我把四元数转成欧拉角作为roll、pitch、yaw三个分量的观测观测矩阵H是3x7H_imu [ 0, 0, 0, 1, 0, 0 ] [ 0, 0, 0, 0, 1, 0 ] [ 0, 0, 0, 0, 0, 1 ]如果IMU是裸传感器没有内部姿态解算那就要用加速度计和陀螺仪自己解算roll和pitch再用磁力计或航向角积分获取yaw实现会复杂不少。我建议在做替换时先保证IMU驱动已经输出四元数姿态把问题隔离出来。2.4 Q矩阵和R矩阵的初值物理意义比数值重要很多人在EKF里最头疼的就是Q和R怎么设。我用的方法很朴素先理解它们的物理意义再按量级去试。Q矩阵描述预测模型的噪声。它反映的是“运动模型在dt时间内积累的误差”。qd表示线速度噪声qyaw表示偏航角速度噪声。我用的初始Q矩阵是对角阵Q diag(qd * cos(yaw)^2 * dt^2, qd * sin(yaw)^2 * dt^2, 0.001, 0.001, 0.001, qyaw * dt^2)其中qd的初值大约0.1到0.5单位m²/s²qyaw大约0.01到0.05单位rad²/s²。这个量级的意思是在室内光滑地面每秒钟由模型误差产生的位置不确定性在0.1到0.5平方米的方差量级转弯时yaw的不确定性每秒钟在0.01到0.05平方弧度的方差量级。偏大偏小都可以后面根据实际调试再调。R矩阵描述传感器测量噪声。轮式里程计的位置观测噪声我习惯设成R_odom diag(0.01, 0.01, 0.001, 0.001, 0.001, 0.01) // 单位 m² 或 rad²IMU的姿态观测噪声R_imu diag(0.001, 0.001, 0.001) // 单位 rad²这些初值的单位很重要。方差的单位是物理量的平方如果设置数量级差太多滤波效果会很明显地异常——R太小会让观测被过度信任轨迹会跟着测量跳R太大会让滤波器反应迟钝漂移完全得不到修正。3. EKF核心代码实现Predict与Correct逐行落地原理清楚了代码就好写了。我用的语言是C矩阵运算用Eigen库因为ROS本身就依赖Eigen不需要额外引入。整个EKF类不依赖ROS数据结构是一个纯粹的数学类这样便于单元测试和后续移植。3.1 工程结构我建立了三个文件ekf.hpp // EKF类的声明 ekf.cpp // EKF类的实现 ekf_ros_node.cpp // ROS节点负责消息订阅和发布ekf.hpp里最重要的成员如下#include Eigen/Dense class Ekf { public: typedef Eigen::Matrixdouble, 7, 1 Vector7d; typedef Eigen::Matrixdouble, 7, 7 Matrix7d; Ekf(); void setInitialState(const Vector7d x0, const Matrix7d P0); void predict(double dt, double v, double omega); void correct(const Eigen::MatrixXd H, const Eigen::VectorXd z, const Eigen::MatrixXd R); Vector7d getState() const; Matrix7d getCovariance() const; private: Vector7d x_; Matrix7d P_; bool initialized_; };这里我使用Eigen::MatrixXd作为观测矩阵的类型因为不同的传感器观测维度不同里程计可能是3维或6维IMU是3维写一个通用的correct函数可以复用。3.2 Predict函数的实现predict函数接收三个参数时间间隔dt、线速度v、偏航角速度omega。先根据运动方程更新状态再计算雅可比F最后做协方差传播。void Ekf::predict(double dt, double v, double omega) { if (!initialized_) return; double yaw x_(5); // 先按运动方程更新状态 x_(0) v * dt * std::cos(yaw); x_(1) v * dt * std::sin(yaw); // z、roll、pitch保持不变 x_(5) omega * dt; // 归一化yaw x_(5) normalizeAngle(x_(5)); // 计算状态转移雅可比F Matrix7d F Matrix7d::Identity(); F(0, 5) -v * dt * std::sin(yaw); F(1, 5) v * dt * std::cos(yaw); // 预测噪声矩阵Q Matrix7d Q Matrix7d::Zero(); double qd 0.2; double qyaw 0.03; Q(0, 0) qd * std::cos(yaw) * std::cos(yaw) * dt * dt; Q(1, 1) qd * std::sin(yaw) * std::sin(yaw) * dt * dt; Q(5, 5) qyaw * dt * dt; // 协方差传播 P_ F * P_ * F.transpose() Q; }这里最容易出错的一个细节是计算F矩阵时yaw必须使用预测之前的角度值。如果先用x_(5) omega * dt更新了yaw再拿更新后的yaw去算cos和sin雅可比就和运动方程不一致了协方差传播会出问题。我一开始就在这里栽过跟头代码里特意在更新状态前把yaw保存下来。另一个要注意的细节是normalizeAngle函数把角度归一化到[-π, π]区间。这个函数看似不起眼实际上极其关键后面校正函数里也会用到。3.3 Correct函数的通用实现correct函数是EKF更新的通用实现输入是观测矩阵H、观测值z、测量噪声矩阵R。它的维度完全由调用者指定所以里程计和IMU可以复用同一个函数。void Ekf::correct(const Eigen::MatrixXd H, const Eigen::VectorXd z, const Eigen::MatrixXd R) { if (!initialized_) return; int n H.rows(); int m H.cols(); assert(m 7); Eigen::VectorXd y z - H * x_; // 残差 // 对残差中的角度分量做归一化 for (int i 0; i n; i) { // 判断残差的第i个分量是否为角度 // 这里约定观测矩阵的最后3行对应roll、pitch、yaw if (i n - 3) { y(i) normalizeAngle(y(i)); } } Eigen::MatrixXd S H * P_ * H.transpose() R; Eigen::MatrixXd K P_ * H.transpose() * S.inverse(); x_ x_ K * y; x_(5) normalizeAngle(x_(5)); // Joseph form数值稳定性更好 Eigen::MatrixXd I Eigen::MatrixXd::Identity(m, m); P_ (I - K * H) * P_ * (I - K * H).transpose() K * R * K.transpose(); }角度残差归一化是EKF实现里绕不过去的一个坑。举一个具体例子如果真值是179度估计值是-179度二者差距其实是2度但直接相减会得到-358度这个巨大的“残差”会让滤波器瞬间错乱协方差更新完全失真。处理办法就是在计算出残差后对角度相关分量做normalizeAngle把它们归一化到[-π, π]区间。协方差更新我用的是Joseph form也就是(I - K H) P (I - K H)^T K R K^T。教科书上一般写(I - K H) P但那是数学上等价、数值上并不等价的简化写法。实际工程中由于舍入误差矩阵可能失去对称性和半正定性Joseph form能更好地维持协方差矩阵的性质。这个细节在滤波长期运行后意义非常明显能有效避免协方差矩阵变得不合法导致的发散。3.4 更新调度逻辑传感器来了怎么办EKF本身只是数学工具真正的融合逻辑在于“什么时候predict什么时候correct”。常见做法是传感器事件驱动每收到一个传感器的消息就先predict到当前时刻再用这个消息去correct。void handleOdom(const nav_msgs::Odometry::ConstPtr odom) { double dt (odom-header.stamp - last_time_).toSec(); if (dt 0) return; double v odom-twist.twist.linear.x; double omega odom-twist.twist.angular.z; ekf_.predict(dt, v, omega); Eigen::VectorXd z(3); z odom-pose.pose.position.x, odom-pose.pose.position.y, yawFromQuaternion(odom-pose.pose.orientation); Eigen::MatrixXd H(3, 7); H.setZero(); H(0, 0) 1; H(1, 1) 1; H(2, 5) 1; Eigen::Matrix3d R R_odom_.block3, 3(0, 0); ekf_.correct(H, z, R); last_time_ odom-header.stamp; }这里有几个细节。一是predict必须使用消息自带的时间戳而不是ros::Time::now()。因为多个传感器到达节点的时间有先后但滤波器的预测推进应该按照传感器采集时刻进行否则时间基准会乱。二是dt 0时候要直接返回防止时间戳乱序导致滤波器倒退。IMU的回调逻辑类似区别在于观测值来自imu-orientation四元数转换成的欧拉角观测矩阵H是3x7。另外IMU的预测输入里偏航角速度也可以直接用IMU的角速度这样一个传感器既参与预测又参与校正在实际场景中融合效果会更好。4. 和ROS接口对接让新节点无缝替换robot_pose_ekf有了核心的EKF类接下来要解决的是“怎么把它变成能替换robot_pose_ekf的ROS节点”的问题。4.1 话题、坐标系与消息类型首先要明确输入输出接口。我这边定义如下方向话题名消息类型说明输入/odomnav_msgs/Odometry轮式里程计输入/imu/datasensor_msgs/ImuIMU姿态数据输出/odom_combinednav_msgs/Odometry融合后的里程计输出TFodom - base_footprinttf2_msgs/TFMessage坐标变换如果项目里原本就在用robot_pose_ekf这些话题名大概率是一致的不需要改任何配置。如果用的不是这些名字可以通过ROS参数在launch文件里指定。4.2 节点实现订阅与发布的装配ekf_ros_node.cpp的核心结构如下class EkfRosNode { public: EkfRosNode() : nh_(~) { // 读取参数 nh_.paramstd::string(odom_topic, odom_topic_, /odom); nh_.paramstd::string(imu_topic, imu_topic_, /imu/data); nh_.paramstd::string(output_frame, output_frame_, odom); nh_.paramstd::string(base_frame, base_frame_, base_footprint); nh_.paramdouble(freq, freq_, 50.0); // 初始化EKF Ekf::Vector7d x0; x0.setZero(); Ekf::Matrix7d P0 Ekf::Matrix7d::Identity() * 1e-5; ekf_.setInitialState(x0, P0); // 订阅和发布 odom_sub_ nh_.subscribe(odom_topic_, 10, EkfRosNode::odomCallback, this); imu_sub_ nh_.subscribe(imu_topic_, 10, EkfRosNode::imuCallback, this); odom_pub_ nh_.advertisenav_msgs::Odometry(/odom_combined, 10); timer_ nh_.createTimer(ros::Duration(1.0 / freq_), EkfRosNode::publishTimer, this); } void odomCallback(const nav_msgs::Odometry::ConstPtr odom) { // 事件驱动predict correct odom current_odom_ odom; if (has_imu_) { // 按时间戳先后处理 } } void imuCallback(const sensor_msgs::Imu::ConstPtr imu) { // 事件驱动predict correct imu } void publishTimer(const ros::TimerEvent) { // 发布Odometry消息和TF } private: ros::NodeHandle nh_; ros::Subscriber odom_sub_; ros::Subscriber imu_sub_; ros::Publisher odom_pub_; ros::Timer timer_; Ekf ekf_; ros::Time last_time_; bool has_imu_; };这里我用了定时器来以固定频率发布融合结果而不是在每个传感器回调里都发布。这样做的好处是下游模块收到的消息频率稳定不会因为传感器频率波动而忽快忽慢。发布频率一般和原robot_pose_ekf保持一致50Hz足够。4.3 消息发布和TF广播发布融合结果时要把EKF状态填到nav_msgs::Odometry消息中void publishResult() { nav_msgs::Odometry odom_msg; odom_msg.header.stamp ros::Time::now(); odom_msg.header.frame_id output_frame_; odom_msg.child_frame_id base_frame_; auto state ekf_.getState(); odom_msg.pose.pose.position.x state(0); odom_msg.pose.pose.position.y state(1); odom_msg.pose.pose.position.z state(2); tf2::Quaternion q; q.setRPY(state(3), state(4), state(5)); odom_msg.pose.pose.orientation.x q.x(); odom_msg.pose.pose.orientation.y q.y(); odom_msg.pose.pose.orientation.z q.z(); odom_msg.pose.pose.orientation.w q.w(); // 带上协方差这是很多下游模块做代价地图评估时需要用到的 auto cov ekf_.getCovariance(); for (int i 0; i 6; i) { for (int j 0; j 6; j) { odom_msg.pose.covariance[i * 6 j] cov(i, j); } } odom_pub_.publish(odom_msg); // 发布TF geometry_msgs::TransformStamped transform; transform.header.stamp ross::Time::now(); transform.header.frame_id output_frame_; transform.child_frame_id base_frame_; transform.transform.translation.x state(0); transform.transform.translation.y state(1); transform.transform.translation.z state(2); transform.transform.rotation odom_msg.pose.pose.orientation; tf_broadcaster_.sendTransform(transform); }协方差的填充容易被忽略但很多下游模块会读这个字段。比如move_base的costmap在评估机器人定位方差时用的是odometry消息里的协方差信息。把它填上替换后的行为才能和robot_pose_ekf完全对齐。4.4 替换时需要特别注意的两件事第一件事旧的robot_pose_ekf节点必须从启动文件里移除或者显式把它屏蔽掉。如果新旧两个节点同时运行两者会同时广播odom - base_footprint的TFTF树会因为存在两个不同的源而互相覆盖定位数据会变得完全不可用。第二件事启动文件里的参数对应关系。robot_pose_ekf的参数名和我的参数名不一样需要逐项映射。建议在launch文件里写一次完整的对照后续就不需要再动launch node namemy_ekf pkgmy_ekf typeekf_ros_node outputscreen param nameodom_topic value/odom/ param nameimu_topic value/imu/data/ param nameoutput_frame valueodom/ param namebase_frame valuebase_footprint/ param namefreq value50/ /node /launch5. 调试阶段的高频坑时间戳、协方差与噪声参数代码写完之后才是真正考验的开始。我把调试过程中踩到的几个高频坑按排查顺序整理出来这些内容在官方文档里基本找不到但一旦遇到能省你大半天时间。5.1 时间戳用错导致的“假发散”第一个高频坑是用ros::Time::now()而不是传感器消息里的时间戳。我写过一版在里程计回调里用now()去算dt结果发现融合出来的轨迹总是滞后而且每隔一段时间就会突然跳一段。原因是这样的里程计消息从驱动发出到被我的节点接收到中间隔着网络传输和消息队列now()减去header.stamp的差值不是恒定的导致每次predict推进的dt忽大忽小。有时候两个消息的dt明明是20ms但now()算出来是18ms长时间误差累积下来滤波器的协方差传播就失真了。正确做法是使用odom-header.stamp和imu-header.stamp作为时间基准。这样dt反映的是传感器数据本身的间隔不受节点调度影响。如果发现dt偶尔出现负值说明传感器时间戳发生了回拨这时候直接跳过这次更新不要用负的dt去做预测。5.2 协方差矩阵发散怎么定位是哪一步炸的协方差发散是最让人头疼的问题。我的排查方法是把P矩阵的对角线元素打印到日志里观察到底是哪个分量的方差在异常增长。发散时通常分三类情况。一是所有方差都快速增大这通常是F矩阵或者Q矩阵设置有问题比如雅可比算错导致协方差传播时P矩阵被放大。二是只有某个传感器对应的观测分量方差增大基本可以断定是这个传感器的观测和预测经常打架残差始终很大。三是方差震荡比如一会儿涨一会儿跌这多半是时间戳抖动导致的dt不稳定。我写了一个调试用的开关通过参数debug控制是否打印P矩阵if (debug_) { auto P ekf_.getCovariance(); ROS_INFO(P: %f %f %f %f %f %f %f, P(0, 0), P(1, 1), P(2, 2), P(3, 3), P(4, 4), P(5, 5)); }输出到终端可以直接看到每个维度的协方差趋势。还有一个排查技巧只订阅里程计不订阅IMU看滤波器单独融合里程计的表现再反过来只订阅IMU。这样可以快速定位是哪个传感器源引入的问题避免两个传感器相互掩盖。5.3 调参的顺序先R后Q从小到大调EKF参数有一个我总结出来的顺序顺着这个顺序调比瞎改要高效得多。第一步先调R矩阵。把机器人静置在桌面上只发IMU数据调小R_imu直到roll和pitch的输出方差小到基本不动yaw角也不乱飘。这一步的目标是让滤波器在静止时有一个稳定的姿态基准。第二步调R_odom。让机器人走直线调R_odom的x、y分量观察轨迹是否平滑。R_odom太小时轨迹会跟着里程计的噪声抖太大会让位置长时间停留在预测值上弯道时轨迹变“直”。第三步调Q矩阵。这个最靠后因为它影响的是滤波器对模型误差的包容程度。Q太大滤波器会过于信任传感器观测位置漂移大但响应快Q太小滤波器会过于信赖预测模型传感器修正能力变弱。一个可行的判断标准是在平整地面上直线走10米融合出来的末端误差在0.1米以内Q的量级基本就合适了。无论调哪个参数一次只改一个改完跑一遍数据对比这是EKF调参的铁律。同时调两个参数即使结果变好了你也说不清是哪个参数起的作用之后出问题就更难回溯了。5.4 两个传感器频率差异大怎么办里程计和IMU的频率经常差别很大我的项目里IMU是100Hz里程计只有20Hz。这种情况下EKF的predict会被IMU回调触发得非常频繁。有人可能会担心predict太频繁会不会导致状态在一段时间内被过度推进实际上不会。EKF的predict本质上是把协方差和状态按照时间间隔做一次传播预测次数多了但每次间隔小总的效果是等价的。但如果IMU和里程计的时间戳不同步比较明显比如相差超过100ms就需要在节点里做一次近似时间同步丢到足够接近的传感器数据再喂给滤波器。我常用message_filters::approximateTime来实现这个同步但也需要注意过于激进的时间同步策略会把数据都丢掉。后来我选了一个更简单的策略两个传感器各自独立进滤波器但先到的一个先predict到当前时刻来晚的那个在它到达时再先predict再correct这样时间错位的影响基本可以忽略。6. 测试与扩展从替换成功到玩出自己的花样整个节点跑通之后我做了三组测试。第一组是静态测试机器人静置1小时观察融合输出是否稳定位置漂移小于5厘米姿态角保持平稳。第二组是直线测试在地面画一条20米直线沿直线走融合出的轨迹最大横向误差小于0.15米。第三组是圆形轨迹测试原地转圈和行走进圆观察yaw角能否平滑累计不能出现跳变。这三组测试都过了之后我才敢把新节点正式替换进系统。替换之后能做的事就多了。我用两行代码扩展了状态向量把轮式里程计的轮距误差作为一个待估计的量加入状态在线估计它在不同地面下的变化。这个功能在robot_pose_ekf里完全做不到但在我自己的EKF里只是一个维度扩展和H矩阵加一列的工作。如果再往前一步可以把IMU的陀螺仪零偏也放进状态量这样长时间运行时偏航角不会再因为IMU零偏而缓慢漂移。状态向量从7维变到9维代价是计算量略微增加但整个系统的鲁棒性提升非常明显。最后分享一个我个人实测的小技巧。如果真的把EKF调到了怀疑人生的状态不要盯着rviz里的轨迹猛看而是把P矩阵、每个传感器的残差向量、每个观测矩阵的维度全部打印到日志文件里。用rosbag play --pause逐帧对比传感器输入和滤波器内部状态绝大部分问题都能在半小时内暴露出来。自己写EKF这件事最大的价值不是省掉一个功能包而是它逼你把“数据从哪里来、到哪里去、每一步在算什么”彻底搞清楚。搞清楚之后下次遇到什么诡异的定位问题你都会本能地往状态模型和噪声模型上想而不是对着黑盒子里面的代码发呆。