第 8 章 扩展卡尔曼滤波与目标跟踪¶
本章目标:从零理解"为什么需要跟踪"和"卡尔曼滤波怎么工作",然后以 RM_Vision_2027 仓库中的真实代码为线索,逐函数走读 11 维 EKF 的数学公式与 C++ 实现。每一个函数都给出数学公式、实际代码、以及"数学与代码对照"注释。
前置知识
本章涉及大量数学推导。遇到不懂的概念参考: - 📖 ch04 TF2 坐标变换 - 📖 ch02 C++ 指针与引用(Eigen 矩阵操作)
8.1 为什么需要跟踪¶
检测器的局限¶
在第 7 章中,我们的神经网络检测器逐帧独立工作:
每帧之间没有任何信息传递。这意味着:
- 噪声直接暴露:单帧检测结果直接用于瞄准,任何跳变都会导致云台抖动。
- 丢帧即失:某帧目标被遮挡或检测失败,系统立刻丢失目标。
- 延迟无法补偿:从图像采集到电机响应存在约 100–200 ms 延迟,如果直接用当前帧检测结果射击,目标早已不在原位。
跟踪器的作用¶
跟踪器引入时间维度,融合多帧检测结果来估计目标的运动状态:
| 特性 | 检测器 | 跟踪器 |
|---|---|---|
| 输入 | 单帧图像 | 检测序列 |
| 输出 | 当前帧位置 | 位置 + 速度(完整运动状态) |
| 鲁棒性 | 丢帧即丢失 | 可短暂预测填补丢帧 |
| 延迟补偿 | 无 | 可根据速度预测未来位置 |
| 噪声抑制 | 无 | 多帧融合平滑噪声 |
跟踪的核心价值:延迟补偿¶
射击决策需要的是子弹到达时刻目标的位置,而不是当前时刻的位置。如果子弹飞行时间 \(\Delta t\),我们需要:
这就是跟踪器存在的最大意义——估计速度,预测未来。
8.2 卡尔曼滤波基础¶
直觉:预测与更新¶
卡尔曼滤波的核心思想可以用一句话概括:
先猜,再看,然后把猜的和看的折中一下。
具体来说,每帧分两步:
- 预测(Predict):根据运动规律,猜目标下一帧在哪。
- 更新(Update):看到新的检测结果后,修正预测。
"折中"的比例取决于我们对预测有多自信、对检测有多自信——这就是卡尔曼增益。
预测-更新循环¶
x̂, P"] -->|预测| B["预测状态
x̄⁻, P⁻"] B -->|新检测| C["更新
x̂⁺, P⁺"] C -->|下一帧| A
更完整的流程:
数学形式(线性卡尔曼滤波)¶
预测步骤(线性运动模型 \(F\),过程噪声 \(Q\)):
更新步骤(线性观测 \(H\),测量噪声 \(R\),观测 \(z\)):
其中:
- \(\bar{x}^-\) :预测状态(先验)
- \(\hat{x}^+\) :更新后的状态(后验)
- \(P\) :状态协方差矩阵,描述估计的不确定性
- \(K\) :卡尔曼增益,决定"信预测还是信检测"
- \(z - H\bar{x}^-\) :新息(innovation),检测与预测的差异
关键理解:当 \(P^-\) 很大(预测不准)时,\(K\) 变大,更信检测;当 \(R\) 很大(检测噪声大)时,\(K\) 变小,更信预测。
8.3 扩展卡尔曼滤波 (EKF) 数学推导¶
当运动模型或观测模型是非线性函数时,标准卡尔曼滤波不再适用。扩展卡尔曼滤波(EKF)的做法是对非线性函数在当前估计点做一阶泰勒展开(即线性化),然后套用卡尔曼滤波框架。
RM_Vision_2027 的跟踪系统中,运动模型是线性的(匀速模型),但观测模型是非线性的——它先从机器人中心推算装甲板的笛卡尔位置(涉及三角函数),再转换为球坐标(涉及 atan2、sqrt 等),因此需要使用 EKF。
8.3.1 状态向量定义¶
RM_Vision_2027 使用 11 维状态向量:
| 索引 | 符号 | 含义 | 单位 |
|---|---|---|---|
| 0 | \(x_c\) | 机器人中心 \(x\) 坐标 | m |
| 1 | \(v_{x_c}\) | 机器人中心 \(x\) 方向速度 | m/s |
| 2 | \(y_c\) | 机器人中心 \(y\) 坐标 | m |
| 3 | \(v_{y_c}\) | 机器人中心 \(y\) 方向速度 | m/s |
| 4 | \(z_c\) | 装甲板 \(z\) 坐标(高度) | m |
| 5 | \(v_{z_c}\) | 装甲板 \(z\) 方向速度 | m/s |
| 6 | \(\text{yaw}\) | 机器人航向角 | rad |
| 7 | \(v_{\text{yaw}}\) | 航向角变化率 | rad/s |
| 8 | \(r_1\) | 主装甲板到机器人中心的半径 | m |
| 9 | \(r_{\text{diff}} = r_2 - r_1\) | 前后装甲板半径差 | m |
| 10 | \(z_{\text{diff}}\) | 前后装甲板高度差 | m |
为什么使用 11 维而非 9 维? 标准步兵有两块半径不同的装甲板(前 \(r_1\),后 \(r_2 = r_1 + r_{\text{diff}}\))。将 \(r_{\text{diff}}\) 和 \(z_{\text{diff}}\) 纳入状态向量后,装甲板跳变时无需依赖外部标定值,滤波器自身就能自适应估计这些参数。
8.3.2 状态转移函数(运动模型)¶
采用匀速模型(Constant Velocity, CV):在相邻帧之间(时间间隔 \(\Delta t\)),假设各状态量的导数恒定。
状态转移矩阵 \(F \in \mathbb{R}^{11 \times 11}\):
因为运动模型本身就是线性的,雅可比矩阵 \(\mathcal{F} = \frac{\partial \boldsymbol{f}}{\partial \boldsymbol{x}} = F\)。
Eigen 库
Eigen 是 C++ 的线性代数库,提供矩阵/向量运算。Eigen::Matrix<double,11,11> 表示 11x11 双精度矩阵。
详见 Eigen 官方文档。
代码中 \(F\) 的构建如下——以单位矩阵为基础,仅修改 4 个非对角元素:
// Tracker::predict() 中构建 F
Eigen::MatrixXd F = Eigen::MatrixXd::Identity(11, 11);
F(0, 1) = dt; // xc += vxc * dt
F(2, 3) = dt; // yc += vyc * dt
F(4, 5) = dt; // zc += vzc * dt
F(6, 7) = dt; // yaw += vyaw * dt
数学与代码对照
数学公式中的 \(F\) 是一个 \(11 \times 11\) 矩阵,在对角线上为 1,在 \((0,1)\)、\((2,3)\)、\((4,5)\)、\((6,7)\) 四个位置为 \(\Delta t\)。代码先创建一个 \(11 \times 11\) 单位矩阵,再逐个设置这四个元素。最后三行 \((r, r_{\text{diff}}, z_{\text{diff}})\) 没有非对角元素,意味着这些参数在预测中保持不变——它们是"准静态"的物理参数。
8.4 观测模型与雅可比矩阵¶
8.4.1 观测向量¶
RM_Vision_2027 的观测向量是 4 维,但不是直接的装甲板笛卡尔坐标,而是球坐标 + 朝向角:
其中前三项 \((\text{yaw}_{\text{ypd}}, \text{pitch}_{\text{ypd}}, \text{distance})\) 是装甲板位置的球坐标表示,第四项 \(\text{angle}\) 是装甲板朝向角。
8.4.2 笛卡尔到球坐标的转换¶
从装甲板的笛卡尔坐标 \((x_a, y_a, z_a)\) 转换为球坐标 \((\text{yaw}, \text{pitch}, d)\):
实际代码(匿名命名空间中的辅助函数):
// cartesian → spherical (yaw, pitch, distance)
Eigen::Vector3d xyz2ypd(const Eigen::Vector3d & xyz)
{
double x = xyz[0], y = xyz[1], z = xyz[2];
double yaw = std::atan2(y, x);
double pitch = std::atan2(z, std::sqrt(x * x + y * y));
double distance = std::sqrt(x * x + y * y + z * z);
return {yaw, pitch, distance};
}
数学与代码对照
\(\text{yaw}_{\text{ypd}}\) 对应代码中的 std::atan2(y, x)。\(\text{pitch}_{\text{ypd}}\) 对应 std::atan2(z, std::sqrt(x*x + y*y))——注意这里用的是 atan2(z, r_xy) 而非 asin 或 acos,这样可以避免数值不稳定。\(d\) 对应 std::sqrt(x*x + y*y + z*z)。
8.4.3 装甲板位置的非线性映射 \(\boldsymbol{h}\)¶
完整观测函数 \(\boldsymbol{h}(\boldsymbol{x})\) 分为两步:
第一步:从状态向量计算装甲板在笛卡尔坐标系中的位置 \((x_a, y_a, z_a)\):
其中 \(N\) 是装甲板数量(4/2/3),\(i\) 是当前装甲板编号,\(r_i\) 和 \(h_i\) 取决于装甲板类型:
实际代码:
Eigen::Vector3d Tracker::h_armor_xyz(const Eigen::VectorXd & x, int id) const
{
int armor_num = static_cast<int>(tracked_armors_num);
double angle = limit_rad(x[6] + id * 2 * M_PI / armor_num);
!!! info "`limit_rad()` 函数定义"
这个函数将角度归一化到 `[-π, π]` 范围:
```cpp
inline double limit_rad(double angle) {
angle = fmod(angle + M_PI, 2 * M_PI);
if (angle < 0) angle += 2 * M_PI;
return angle - M_PI;
}
```
在 EKF 中,yaw 角会不断增长(可能超过 2π),但比较和显示时需要归一化到 [-π, π]。
bool use_l_h = (armor_num == 4) && (id == 1 || id == 3);
double r = use_l_h ? x[8] + x[9] : x[8];
double armor_x = x[0] - r * std::cos(angle);
double armor_y = x[2] - r * std::sin(angle);
double armor_z = use_l_h ? x[4] + x[10] : x[4];
return {armor_x, armor_y, armor_z};
}
数学与代码对照
x[6] 是状态中的 yaw,id * 2 * M_PI / armor_num 是第 id 块装甲板相对机器人前方的角度偏移。use_l_h 标志判断是否为四装甲板机器人的"侧面"装甲板(编号 1 和 3),此时使用 \(r + r_{\text{diff}}\) 和 \(z_c + z_{\text{diff}}\)。注意装甲板位置是机器人中心减去 \(r\cos\theta\)(代码中用 -r*cos),因为装甲板在机器人中心"前方"的几何关系由 yaw 的定义决定。
第二步:将装甲板笛卡尔位置转换为球坐标,加上朝向角,得到 4 维观测:
其中 \(\text{angle} = \text{yaw} + i \cdot \frac{2\pi}{N}\)(装甲板朝向角,与 yaw 直接相关)。
8.4.4 观测雅可比矩阵 \(\mathcal{H}\) 的推导¶
\(\mathcal{H} = \frac{\partial \boldsymbol{h}}{\partial \boldsymbol{x}}\) 是一个 \(4 \times 11\) 矩阵。由于 \(\boldsymbol{h}\) 是一个复合函数,需要用链式法则分步计算。
计算分为两层:
内层:装甲板笛卡尔位置对状态的雅可比 \(J_{xyza} = \frac{\partial (x_a, y_a, z_a, \text{angle})}{\partial \boldsymbol{x}} \in \mathbb{R}^{4 \times 11}\)
其中:
实际代码:
Eigen::MatrixXd Tracker::h_jacobian(const Eigen::VectorXd & x, int id) const
{
int armor_num = static_cast<int>(tracked_armors_num);
double angle = limit_rad(x[6] + id * 2 * M_PI / armor_num);
bool use_l_h = (armor_num == 4) && (id == 1 || id == 3);
double r = use_l_h ? x[8] + x[9] : x[8];
double dx_da = r * std::sin(angle);
double dy_da = -r * std::cos(angle);
double dx_dr = -std::cos(angle);
double dy_dr = -std::sin(angle);
double dx_dl = use_l_h ? -std::cos(angle) : 0.0;
double dy_dl = use_l_h ? -std::sin(angle) : 0.0;
double dz_dh = use_l_h ? 1.0 : 0.0;
// Jacobian of armor_xyz w.r.t state -> (4x11)
Eigen::MatrixXd H_xyza(4, 11);
H_xyza << 1, 0, 0, 0, 0, 0, dx_da, 0, dx_dr, dx_dl, 0,
0, 0, 1, 0, 0, 0, dy_da, 0, dy_dr, dy_dl, 0,
0, 0, 0, 0, 1, 0, 0, 0, 0, 0, dz_dh,
0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0;
// ...
数学与代码对照
dx_da = r * std::sin(angle) 对应 \(\frac{\partial x_a}{\partial \text{yaw}} = r_i \sin\theta_i\)(注意代码中 \(x_a = x_c - r\cos\theta\),对 \(\theta\) 求导得 \(r\sin\theta\))。dx_dr = -std::cos(angle) 对应 \(\frac{\partial x_a}{\partial r} = -\cos\theta_i\)。dx_dl 和 dy_dl 是对 \(r_{\text{diff}}\)(状态索引 9)的偏导,仅在侧面装甲板时非零。dz_dh 是对 \(z_{\text{diff}}\)(状态索引 10)的偏导。
外层:球坐标对装甲板笛卡尔位置的雅可比 \(J_{\text{ypd}} = \frac{\partial (\text{yaw}_{\text{ypd}}, \text{pitch}_{\text{ypd}}, d)}{\partial (x_a, y_a, z_a)} \in \mathbb{R}^{3 \times 3}\)
实际代码:
Eigen::MatrixXd xyz2ypd_jacobian(const Eigen::Vector3d & xyz)
{
double x = xyz[0], y = xyz[1], z = xyz[2];
double x2y2 = x * x + y * y;
double sqrt_x2y2 = std::sqrt(x2y2);
double x2y2z2 = x2y2 + z * z;
double sqrt_all = std::sqrt(x2y2z2);
double dyaw_dx = -y / x2y2;
double dyaw_dy = x / x2y2;
double dpitch_dx = -(x * z) / ((z * z / x2y2 + 1) * std::pow(x2y2, 1.5));
double dpitch_dy = -(y * z) / ((z * z / x2y2 + 1) * std::pow(x2y2, 1.5));
double dpitch_dz = 1.0 / ((z * z / x2y2 + 1) * sqrt_x2y2);
double dd_dx = x / sqrt_all;
double dd_dy = y / sqrt_all;
double dd_dz = z / sqrt_all;
Eigen::MatrixXd J(3, 3);
J << dyaw_dx, dyaw_dy, 0,
dpitch_dx, dpitch_dy, dpitch_dz,
dd_dx, dd_dy, dd_dz;
return J;
}
数学与代码对照
dyaw_dx = -y / x2y2 是 \(\frac{\partial \text{atan2}(y,x)}{\partial x} = \frac{-y}{x^2+y^2}\)。dpitch_dz 的分母中 z*z/x2y2 + 1 来自 \(\frac{\partial \text{atan2}(z, r_{xy})}{\partial z}\) 的链式法则。dd_dx = x / sqrt_all 是 \(\frac{\partial \sqrt{x^2+y^2+z^2}}{\partial x} = \frac{x}{\sqrt{x^2+y^2+z^2}}\)。
链式法则组合:
其中 \(J_{\text{ypd,extended}} \in \mathbb{R}^{4 \times 3}\) 是将 \(J_{\text{ypd}}\)(\(3 \times 3\))嵌入到 \(4 \times 3\) 矩阵中的形式:
实际代码(接续 h_jacobian 函数):
Eigen::Vector3d armor_xyz = h_armor_xyz(x, id);
Eigen::MatrixXd H_ypd = xyz2ypd_jacobian(armor_xyz);
// Chain rule: d(ypd) / d(state) = d(ypd)/d(xyz) * d(xyz)/d(state)
Eigen::MatrixXd H_full(4, 11);
H_full << H_ypd(0, 0), H_ypd(0, 1), H_ypd(0, 2), 0,
H_ypd(1, 0), H_ypd(1, 1), H_ypd(1, 2), 0,
H_ypd(2, 0), H_ypd(2, 1), H_ypd(2, 2), 0,
0, 0, 0, 1;
return H_full * H_xyza;
}
数学与代码对照
H_full(4, 11) 是将 \(J_{\text{ypd}}\) 嵌入到 4 行矩阵中,第 4 行全零。最终 H_full * H_xyza 是两个 4x11 矩阵的逐元素乘法。第 4 行全零乘以 H_xyza 的前三行后,再加上 \(0 \times H_{xyza,4}\) 行,但 H_xyza 的第 4 行 [0,0,0,0,0,0,1,0,0,0,0] 直接携带 angle 对 yaw 的偏导。这就是为什么 H_xyza 需要是 \(4 \times 11\) 而非 \(3 \times 11\)——它的第 4 行负责传递 angle 的梯度信息。最终 \(\mathcal{H}\) 的第 4 行(angle 行)只在第 7 列(yaw)为 1,其余全 0。
8.5 过程噪声协方差矩阵 \(Q\)¶
过程噪声使用分段白噪声模型(Piecewise White Noise Model),每个位置-速度子系统共享一个加速度方差。
对于加速度方差 \(\sigma_a^2\),其 \(2 \times 2\) 子块为:
完整 \(11 \times 11\) 矩阵由多个这样的 \(2 \times 2\) 子块沿对角排列。RM_Vision_2027 使用两种方差参数:
| 参数 | 含义 | 普通机器人 | 前哨站 |
|---|---|---|---|
| \(v_1\) | 位置/速度加速度方差 | 100 | 10 |
| \(v_2\) | 角加速度方差 | 400 | 0.1 |
\(r\)、\(r_{\text{diff}}\)、\(z_{\text{diff}}\) 的过程噪声为零(准静态参数)。
实际代码:
void Tracker::predict(double dt)
{
// State transition matrix F (11x11 constant velocity model)
Eigen::MatrixXd F = Eigen::MatrixXd::Identity(11, 11);
F(0, 1) = dt; // xc += vxc * dt
F(2, 3) = dt; // yc += vyc * dt
F(4, 5) = dt; // zc += vzc * dt
F(6, 7) = dt; // yaw += vyaw * dt
// Process noise Q (Piecewise White Noise Model)
double v1, v2;
if (tracked_id == "outpost") {
v1 = 10; // outpost accel variance
v2 = 0.1; // outpost angular accel variance
} else {
v1 = 100; // accel variance
v2 = 400; // angular accel variance
}
double a = dt * dt * dt * dt / 4;
double b = dt * dt * dt / 2;
double c = dt * dt;
Eigen::MatrixXd Q = Eigen::MatrixXd::Zero(11, 11);
Q(0, 0) = a * v1; Q(0, 1) = b * v1;
Q(1, 0) = b * v1; Q(1, 1) = c * v1;
Q(2, 2) = a * v1; Q(2, 3) = b * v1;
Q(3, 2) = b * v1; Q(3, 3) = c * v1;
Q(4, 4) = a * v1; Q(4, 5) = b * v1;
Q(5, 4) = b * v1; Q(5, 5) = c * v1;
Q(6, 6) = a * v2; Q(6, 7) = b * v2;
Q(7, 6) = b * v2; Q(7, 7) = c * v2;
// r1, r2_diff, z_diff have zero process noise (static parameters)
// ...
数学与代码对照
a = dt*dt*dt*dt/4 对应 \(\frac{\Delta t^4}{4}\),b = dt*dt*dt/2 对应 \(\frac{\Delta t^3}{2}\),c = dt*dt 对应 \(\Delta t^2\)。v1 用于 \((x_c, v_{x_c})\)、\((y_c, v_{y_c})\)、\((z_c, v_{z_c})\) 三个子系统,v2 用于 \((\text{yaw}, v_{\text{yaw}})\) 子系统。最后三个状态 \((r, r_{\text{diff}}, z_{\text{diff}})\) 的 \(Q\) 对角元素为零——这些参数变化极慢,不注入过程噪声。
调参经验:前哨站的方差远小于普通机器人(\(v_1=10\) vs \(100\)),因为前哨站运动模式更规律(匀速旋转),加减速较少。\(v_2=400\) 对于普通机器人意味着 yaw 方向允许较大角加速度,适合灵活转向的步兵。
8.5.1 前哨站角速度钳位¶
前哨站以近乎恒定的角速度旋转,但如果 EKF 估计出的角速度 \(v_{\text{yaw}}\) 偏离过大,会导致预测发散。代码在 predict() 中对前哨站的 yaw 角速度做了硬钳位——当 EKF 收敛且角速度超过 2 rad/s 时,将其限制为 \(\pm 2.51\) rad/s:
// Outpost speed clamping
static constexpr double outpost_max_v_yaw = 2.51; // ~144 deg/s
if (is_converged() && tracked_id == "outpost" && std::abs(ekf.x[7]) > 2)
ekf.x[7] = ekf.x[7] > 0 ? outpost_max_v_yaw : -outpost_max_v_yaw;
is_converged_ 是死代码
is_converged_ 在头文件中初始化为 false(bool is_converged_ = false;),在 init() 中被重置为 false,但整个代码库中没有任何地方将其设置为 true。这意味着 is_converged() 始终返回 false,上方的角速度钳位条件永远无法触发。这是一段死代码——钳位逻辑虽然存在,但在当前版本中不会执行。如果需要启用钳位,需要补充收敛判断逻辑(例如:当 EKF 的协方差对角元素低于某个阈值时设置 is_converged_ = true)。
8.6 自适应测量噪声协方差 \(R\)¶
RM_Vision_2027 的测量噪声 \(R\) 不是固定常数,而是根据当前检测与预测的角度差和目标距离动态调整:
其中:
其中 \(\Delta\theta = \text{yaw}_{\text{obs}} - \text{atan2}(y_c, x_c)\) 是观测角度与预测方向的差异,\(d\) 是目标距离。
公式与代码的对应关系
观测向量的顺序是 \((\text{yaw}_{\text{ypd}},\; \text{pitch}_{\text{ypd}},\; d,\; \text{angle})\),因此 \(R(2,2)\) 对应距离维度,\(R(3,3)\) 对应朝向角维度。代码中 R_dig << 4e-3, 4e-3, log(|delta_angle|+1)+1, log(d+1)/200+9e-2 意味着:距离噪声由角度差 \(\Delta\theta\) 驱动(角度差大时距离估计不可靠),朝向角噪声由距离 \(d\) 驱动(距离远时 PnP 解算的角度误差更大)。这是"交叉耦合"的自适应策略——一个维度的不确定性取决于另一个维度的物理量。
实际代码:
void Tracker::update_ypda(const Armor & armor, int id)
{
// Measurement Jacobian: d(ypd, angle) / d(state)
Eigen::MatrixXd H = h_jacobian(ekf.x, id);
// Adaptive measurement noise R
double delta_angle = limit_rad(orientationToYaw(armor.pose.orientation) -
std::atan2(ekf.x[2], ekf.x[0]));
Eigen::Vector3d armor_ypd = xyz2ypd(Eigen::Vector3d(
armor.pose.position.x, armor.pose.position.y, armor.pose.position.z));
Eigen::Vector4d R_dig;
R_dig << 4e-3, 4e-3,
std::log(std::abs(delta_angle) + 1) + 1,
std::log(std::abs(armor_ypd[2]) + 1) / 200 + 9e-2;
Eigen::Matrix4d R = R_dig.asDiagonal();
// ...
数学与代码对照
观测向量 \(\boldsymbol{z} = (\text{yaw}_{\text{ypd}},\; \text{pitch}_{\text{ypd}},\; d,\; \text{angle})^\top\),因此 R_dig 的第三个元素 \(R(2,2)\) 对应距离维度,第四个元素 \(R(3,3)\) 对应朝向角维度。log(|delta_angle| + 1) + 1 赋给距离噪声:当观测角度与预测方向偏差大时,说明检测可能不可靠,距离估计的不确定性增大。log(|armor_ypd[2]| + 1) / 200 + 0.09 赋给朝向角噪声:armor_ypd[2] 是目标距离,远处目标的 PnP 解算误差更大,朝向角估计的不确定性随之增大。这是一种"交叉耦合"的自适应策略——距离噪声由角度差驱动,角度噪声由距离驱动。
非线性观测函数 \(\boldsymbol{h}\) 和角度感知减法¶
由于观测涉及球坐标,更新步骤需要传递非线性观测函数 \(\boldsymbol{h}\) 和角度感知减法函数给 EKF:
// Nonlinear observation function: state -> [ypd_yaw, ypd_pitch, ypd_dist, angle]
auto h = [&](const Eigen::VectorXd & x) -> Eigen::Vector4d {
Eigen::Vector3d xyz = h_armor_xyz(x, id);
Eigen::Vector3d ypd = xyz2ypd(xyz);
double angle = limit_rad(x[6] + id * 2 * M_PI / static_cast<int>(tracked_armors_num));
return {ypd[0], ypd[1], ypd[2], angle};
};
// Angular-aware subtraction
auto z_subtract = [](const Eigen::VectorXd & a, const Eigen::VectorXd & b) -> Eigen::VectorXd {
Eigen::VectorXd c = a - b;
c[0] = limit_rad(c[0]); // yaw
c[1] = limit_rad(c[1]); // pitch
c[3] = limit_rad(c[3]); // angle
return c;
};
数学与代码对照
h 是完整的观测函数 \(\boldsymbol{h}(\boldsymbol{x})\):先调用 h_armor_xyz 从状态计算装甲板笛卡尔坐标,再通过 xyz2ypd 转为球坐标,最后加上朝向角。z_subtract 实现 \(\boldsymbol{z} - \boldsymbol{h}(\boldsymbol{x})\),但对 yaw、pitch、angle 三个角度维度做了 limit_rad 归一化(限制到 \([-\pi, \pi]\)),避免 \(2\pi\) 跳变。
8.7 EKF 核心:predict 与 update 的实际实现¶
RM_Vision_2027 的 ExtendedKalmanFilter 类是一个通用框架——它不包含特定的运动模型或观测模型,而是通过函数参数接收 \(F\)、\(Q\)、\(\boldsymbol{f}\)、\(\boldsymbol{h}\) 等。这让同一个 EKF 类可以用于不同维度和不同模型。
8.7.1 EKF 类定义¶
class ExtendedKalmanFilter
{
public:
Eigen::VectorXd x;
Eigen::MatrixXd P;
ExtendedKalmanFilter() = default;
ExtendedKalmanFilter(
const Eigen::VectorXd & x0, const Eigen::MatrixXd & P0,
std::function<Eigen::VectorXd(const Eigen::VectorXd &, const Eigen::VectorXd &)> x_add =
[](const Eigen::VectorXd & a, const Eigen::VectorXd & b) { return a + b; });
Eigen::VectorXd predict(const Eigen::MatrixXd & F, const Eigen::MatrixXd & Q);
Eigen::VectorXd predict(
const Eigen::MatrixXd & F, const Eigen::MatrixXd & Q,
std::function<Eigen::VectorXd(const Eigen::VectorXd &)> f);
Eigen::VectorXd update(
const Eigen::VectorXd & z, const Eigen::MatrixXd & H, const Eigen::MatrixXd & R,
std::function<Eigen::VectorXd(const Eigen::VectorXd &)> h,
std::function<Eigen::VectorXd(const Eigen::VectorXd &, const Eigen::VectorXd &)> z_subtract);
private:
Eigen::MatrixXd I;
std::function<Eigen::VectorXd(const Eigen::VectorXd &, const Eigen::VectorXd &)> x_add;
};
数学与代码对照
x 是状态向量 \(\boldsymbol{x}\),P 是协方差矩阵 \(P\)。x_add 是状态加法函数——默认是普通的向量加法,但在跟踪器中被替换为"角度感知加法"(加完后对 yaw 做归一化)。predict 有两个重载:线性版本(用 \(F\boldsymbol{x}\))和非线性版本(传入自定义 \(\boldsymbol{f}\))。
8.7.2 ExtendedKalmanFilter::predict(F, Q, f) —— 预测步骤¶
实际代码:
Eigen::VectorXd ExtendedKalmanFilter::predict(
const Eigen::MatrixXd & F, const Eigen::MatrixXd & Q,
std::function<Eigen::VectorXd(const Eigen::VectorXd &)> f)
{
P = F * P * F.transpose() + Q;
x = f(x);
return x;
}
数学与代码对照
P = F * P * F.transpose() + Q 直接对应式 \(P^- = F P^+ F^\top + Q\)。x = f(x) 对应 \(\bar{\boldsymbol{x}}^- = \boldsymbol{f}(\hat{\boldsymbol{x}}^+)\)。注意协方差传播先于状态更新执行——这个顺序在数学上等价,但代码选择了先算 \(P\) 再算 \(x\)。
调用时传入的 \(\boldsymbol{f}\) 带有 yaw 归一化:
ekf.predict(F, Q, [&](const Eigen::VectorXd & x) -> Eigen::VectorXd {
Eigen::VectorXd x_prior = F * x;
x_prior[6] = limit_rad(x_prior[6]);
return x_prior;
});
8.7.3 ExtendedKalmanFilter::update(z, H, R, h, z_subtract) —— 更新步骤¶
这是 EKF 的核心更新公式,使用 Joseph 形式保证数值稳定性:
卡尔曼增益:
协方差更新(Joseph 形式):
状态更新:
新息协方差(用于 NIS 检测):
NIS(归一化新息平方):
实际代码(完整函数):
Eigen::VectorXd ExtendedKalmanFilter::update(
const Eigen::VectorXd & z, const Eigen::MatrixXd & H, const Eigen::MatrixXd & R,
std::function<Eigen::VectorXd(const Eigen::VectorXd &)> h,
std::function<Eigen::VectorXd(const Eigen::VectorXd &, const Eigen::VectorXd &)> z_subtract)
{
Eigen::VectorXd x_prior = x;
Eigen::MatrixXd K = P * H.transpose() * (H * P * H.transpose() + R).inverse();
// Joseph form for numerical stability
P = (I - K * H) * P * (I - K * H).transpose() + K * R * K.transpose();
x = x_add(x, K * z_subtract(z, h(x)));
// Chi-squared NIS/NEES tests
Eigen::VectorXd residual = z_subtract(z, h(x));
Eigen::MatrixXd S = H * P * H.transpose() + R;
double nis = residual.transpose() * S.inverse() * residual;
double nees = (x - x_prior).transpose() * P.inverse() * (x - x_prior);
// Chi-squared threshold (df=4, 95% confidence)
constexpr double nis_threshold = 0.711;
constexpr double nees_threshold = 0.711;
// ...
数学与代码对照
K = P * H.transpose() * (H * P * H.transpose() + R).inverse() 对应 \(K = P^- \mathcal{H}^\top (\mathcal{H} P^- \mathcal{H}^\top + R)^{-1}\)。协方差更新用 (I - K*H) * P * (I - K*H).transpose() + K * R * K.transpose() 实现 Joseph 形式。状态更新 x = x_add(x, K * z_subtract(z, h(x))) 中,z_subtract(z, h(x))$ 是新息 $\boldsymbol{z} - \boldsymbol{h}(\bar{\boldsymbol{x}}^-)$,x_add做角度感知加法。注意:代码中 NIS/NEES 阈值0.711` 是经验调参值,并非标准卡方检验的统计临界值(df=4 的 95% 临界值是 9.488,df=11 是 19.675)。对 NIS 和 NEES 使用同一阈值在统计上也不严格正确(两者自由度不同)。实际工程中,这个阈值更多是用于触发日志告警的启发式判断,而非严格的统计检验。
8.7.4 Joseph 形式的优势¶
标准协方差更新公式 \(P^+ = (I - KH)P^-\) 在数值计算中容易出现协方差矩阵不对称甚至不正定的问题。Joseph 形式将更新改写为:
可以验证它等价于标准形式——它将 \(P^+\) 写成两个半正定矩阵之和,保证 \(P^+\) 始终至少半正定,数值更稳定。
8.8 Tracker::initEKF() —— EKF 初始化¶
收到第一帧检测结果时,需要将装甲板检测位置反推为机器人中心位置,构建 11 维初始状态。
数学¶
给定装甲板检测位置 \((x_a, y_a, z_a)\) 和朝向角 yaw,已知初始半径 \(r\),机器人中心位置为:
初始状态向量:
初始协方差对角线:
实际代码:
void Tracker::initEKF(const Armor & a)
{
double xa = a.pose.position.x;
double ya = a.pose.position.y;
double za = a.pose.position.z;
last_yaw_ = 0;
double yaw = orientationToYaw(a.pose.orientation);
double r = 0.26;
dz = 0;
another_r = r;
if (a.type == "large" && (tracked_id == "3" || tracked_id == "4" || tracked_id == "5")) {
r = 0.2;
} else if (tracked_id == "outpost") {
r = 0.2765;
}
// 11D state: [xc, vxc, yc, vyc, zc, vzc, yaw, vyaw, r1, r2_diff, z_diff]
double xc = xa + r * cos(yaw);
double yc = ya + r * sin(yaw);
Eigen::VectorXd x0(11);
x0 << xc, 0, yc, 0, za, 0, yaw, 0, r, 0, 0;
// Initial covariance diagonal
Eigen::VectorXd P0_dig(11);
P0_dig << 1, 64, 1, 64, 1, 64, 0.4, 100, 1, 1, 1;
if (tracked_id == "outpost") {
P0_dig << 1, 64, 1, 64, 1, 81, 0.4, 100, 1e-4, 0, 0;
}
Eigen::MatrixXd P0 = P0_dig.asDiagonal();
// Angle-aware state addition
auto x_add = [](const Eigen::VectorXd & a_vec, const Eigen::VectorXd & b) -> Eigen::VectorXd {
Eigen::VectorXd c = a_vec + b;
c[6] = limit_rad(c[6]);
return c;
};
ekf = ExtendedKalmanFilter(x0, P0, x_add);
target_state = ekf.x;
}
数学与代码对照
默认半径 r = 0.26 m(标准步兵),大装甲板的 3/4/5 号机器人用 r = 0.2,前哨站用 r = 0.2765。xc = xa + r * cos(yaw) 对应 \(x_c = x_a + r\cos(\text{yaw})\)——注意这里是加而不是减,因为 h_armor_xyz 中用的是减号(\(x_a = x_c - r\cos\theta\)),初始化是其逆运算。初始协方差中速度分量为 64(不确定性大,因为初始速度未知为 0),yaw 的协方差为 0.4(相对确定,因为直接从检测获得),\(r_{\text{diff}}\) 和 \(z_{\text{diff}}\) 为 1 和 1。前哨站的 \(P_0\) 不同:高度速度为 81,\(r_{\text{diff}}\) 为 \(10^{-4}\)(因为前哨站三块板等半径),\(z_{\text{diff}}\) 为 0。x_add 是角度感知加法,对 yaw 分量做 limit_rad 归一化。
8.9 Tracker::init() —— 从第一帧检测初始化跟踪器¶
init 负责选择要跟踪的装甲板并启动 EKF:
void Tracker::init(const Armors::SharedPtr & armors_msg)
{
if (armors_msg->armors.empty()) {
RCLCPP_WARN(rclcpp::get_logger("armor_tracker"), "No armor detected!");
return;
}
// Choose armor closest to image center
double min_distance = DBL_MAX;
tracked_armor = armors_msg->armors[0];
for (const auto & armor : armors_msg->armors) {
if (armor.distance_to_image_center < min_distance) {
min_distance = armor.distance_to_image_center;
tracked_armor = armor;
}
}
initEKF(tracked_armor);
tracked_id = tracked_armor.number;
tracker_state = DETECTING;
detect_count_ = 1;
update_count = 0;
jumped = false;
last_id_ = 0;
is_converged_ = false;
switch_count_ = 0;
updateArmorsNum(tracked_armor);
}
数学与代码对照
初始化时选择离图像中心最近的装甲板作为跟踪目标(distance_to_image_center 最小)。调用 initEKF 构建 11 维状态,将跟踪状态机设为 DETECTING。updateArmorsNum 根据装甲板类型确定 \(N\):普通步兵 \(N=4\),平衡步兵 3/4/5 号 \(N=2\),前哨站 \(N=3\)。
8.10 Tracker::update() —— 主跟踪循环¶
这是每帧调用的核心函数,完整流程为:预测 → 数据关联 → 更新 → 半径约束 → 状态机转移。
void Tracker::update(const Armors::SharedPtr & armors_msg, double dt)
{
// ---- EKF predict ----
predict(dt);
bool matched = false;
target_state = ekf.x;
if (!armors_msg->armors.empty()) {
// Find the closest armor with matching number
Armor same_id_armor;
int same_id_armors_count = 0;
auto predicted_position = getArmorPositionFromState(ekf.x);
double min_position_diff = DBL_MAX;
double yaw_diff = DBL_MAX;
for (const auto & armor : armors_msg->armors) {
if (armor.number == tracked_id) {
same_id_armor = armor;
same_id_armors_count++;
auto p = armor.pose.position;
Eigen::Vector3d position_vec(p.x, p.y, p.z);
double position_diff = (predicted_position - position_vec).norm();
if (position_diff < min_position_diff) {
min_position_diff = position_diff;
yaw_diff = std::abs(orientationToYaw(armor.pose.orientation) - ekf.x(6));
tracked_armor = armor;
}
}
}
info_position_diff = min_position_diff;
info_yaw_diff = yaw_diff;
if (min_position_diff < max_match_distance_ && yaw_diff < max_match_yaw_diff_) {
matched = true;
update_count++;
// ... armor plate ID matching and EKF update (see section 8.10.1)
} else if (same_id_armors_count == 1 && yaw_diff > max_match_yaw_diff_) {
handleArmorJump(same_id_armor);
} else {
RCLCPP_WARN(rclcpp::get_logger("armor_tracker"), "No matched armor found!");
}
}
// ...
8.10.1 数据关联策略¶
数据关联分两步:
- ID 匹配:在所有检测到的装甲板中,筛选与当前跟踪目标
tracked_id编号相同的装甲板。 - 空间匹配:在同 ID 装甲板中,选择与 EKF 预测位置欧氏距离最小的那一个。
匹配成功的条件:
当匹配失败但只有一个同 ID 装甲板且 yaw 差异过大时,触发装甲板跳变处理(见 8.11 节)。
8.10.2 装甲板 ID 匹配(选择正确的板)¶
匹配成功后,需要确定当前看到的是哪一块装甲板(编号 0 到 \(N-1\))。方法是:从 EKF 状态预测出所有 \(N\) 块装甲板的 \((x,y,z,\text{angle})\),然后选择与检测最接近的那块。
// Match armor to expected plate id
int id = 0;
double min_angle_error = 1e10;
const auto xyza_list = armor_xyza_list();
int armor_num = static_cast<int>(tracked_armors_num);
std::vector<std::pair<Eigen::Vector4d, int>> xyza_i_list;
for (int i = 0; i < armor_num; i++) {
xyza_i_list.push_back({xyza_list[i], i});
}
std::sort(xyza_i_list.begin(), xyza_i_list.end(),
[](const auto & a, const auto & b) {
Eigen::Vector3d ypd1 = xyz2ypd(a.first.head(3));
Eigen::Vector3d ypd2 = xyz2ypd(b.first.head(3));
return ypd1[2] < ypd2[2];
});
double measured_yaw = orientationToYaw(tracked_armor.pose.orientation);
for (int i = 0; i < std::min(3, armor_num); i++) {
const auto & xyza = xyza_i_list[i].first;
Eigen::Vector3d ypd = xyz2ypd(xyza.head(3));
double angle_error = std::abs(limit_rad(measured_yaw - xyza[3])) +
std::abs(limit_rad(
std::atan2(tracked_armor.pose.position.y,
tracked_armor.pose.position.x) -
ypd[0]));
if (std::abs(angle_error) < std::abs(min_angle_error)) {
id = xyza_i_list[i].second;
min_angle_error = angle_error;
}
}
if (id != 0) jumped = true;
数学与代码对照
先按距离排序所有装甲板,只在最近的 3 块中选择——这避免了远处装甲板的误匹配。角度误差衡量两个维度:装甲板朝向角差异 limit_rad(measured_yaw - xyza[3]) 和方位角差异 limit_rad(atan2(py,px) - ypd[0])。armor_xyza_list() 调用 h_armor_xyz 计算每块板的预测位置。
8.10.3 半径约束与状态机转移¶
// Prevent radius from spreading (physical constraints)
if (ekf.x(8) < 0.12) {
ekf.x(8) = 0.12;
} else if (ekf.x(8) > 0.4) {
ekf.x(8) = 0.4;
}
target_state = ekf.x;
// Tracking state machine
if (tracker_state == DETECTING) {
if (matched) {
detect_count_++;
if (detect_count_ > tracking_thres) {
detect_count_ = 0;
tracker_state = TRACKING;
RCLCPP_INFO(rclcpp::get_logger("armor_tracker"), "Target tracking confirmed!");
}
} else {
detect_count_ = 0;
tracker_state = LOST;
}
} else if (tracker_state == TRACKING) {
if (!matched) {
tracker_state = TEMP_LOST;
lost_count_ = 1;
}
} else if (tracker_state == TEMP_LOST) {
if (!matched) {
lost_count_++;
if (lost_count_ > lost_thres) {
lost_count_ = 0;
tracker_state = LOST;
RCLCPP_WARN(rclcpp::get_logger("armor_tracker"), "Target lost!");
}
} else {
tracker_state = TRACKING;
lost_count_ = 0;
}
}
}
数学与代码对照
半径硬约束 \(r \in [0.12, 0.4]\) m——比 8.7 节讨论的理论范围 \([0.05, 0.5]\) 更严格,这是经验调参的结果。状态机逻辑:DETECTING 下连续匹配超过 tracking_thres 帧则进入 TRACKING;TRACKING 下首次不匹配进入 TEMP_LOST;TEMP_LOST 下连续不匹配超过 lost_thres 帧则回到 LOST。
8.11 跟踪状态机¶
状态定义¶
RM_Vision_2027 的跟踪器维护四种状态:
| 状态 | 含义 | 描述 |
|---|---|---|
LOST |
丢失 | 没有目标,等待初始化 |
DETECTING |
检测中 | 收到初步检测,尚未确认 |
TRACKING |
跟踪中 | 稳定跟踪目标 |
TEMP_LOST |
临时丢失 | 短暂丢帧,仍用预测结果 |
状态转移图¶
LOST → DETECTING:由 Tracker::init() 触发,选择离图像中心最近的装甲板,初始化 EKF。
DETECTING → TRACKING:连续匹配成功帧数超过 tracking_thres,确认跟踪。
DETECTING → LOST:中间出现任何匹配失败,重置计数器回到 LOST(防误检)。
TRACKING → TEMP_LOST:当帧匹配失败,进入临时丢失。
TEMP_LOST → TRACKING:重新匹配成功,恢复跟踪。
TEMP_LOST → LOST:连续丢帧超过 lost_thres,认为目标已真正丢失。
动态 lost_thres:根据帧率自适应¶
lost_thres 不是固定常数,而是由 tracker_node.cpp 每帧动态计算:
dt_ = (time - last_time_).seconds();
tracker_->lost_thres = static_cast<int>(lost_time_thres_ / dt_);
其中 lost_time_thres_ 是配置参数(单位:秒,表示允许丢失的最长时间),dt_ 是本帧的实际时间间隔。这样做的目的是将丢帧容忍度从"帧数"转换为"时间":无论相机帧率是 30 fps 还是 120 fps,系统允许丢失的目标时间窗口是固定的。例如,lost_time_thres_ = 0.3 秒时,在 60 fps 下 lost_thres = 18 帧,在 120 fps 下 lost_thres = 36 帧。
8.12 Tracker::handleArmorJump() —— 装甲板跳变处理¶
什么是装甲板跳变¶
标准步兵机器人有多块装甲板分布在不同侧面。当机器人旋转时,面向相机的装甲板会发生切换——例如从"前装甲板"切换到"侧装甲板"。此时:
- yaw 角发生突变(可能跳变 \(\pm \frac{\pi}{2}\) 或 \(\pm \pi\))。
- 装甲板到中心的距离可能不同(\(r\) 和 \(r + r_{\text{diff}}\))。
- 装甲板高度可能不同(\(z_c\) 和 \(z_c + z_{\text{diff}}\))。
跳变处理的数学¶
当检测到跳变时:
如果位置偏差过大,还需要重置位置和速度:
实际代码:
void Tracker::handleArmorJump(const Armor & current_armor)
{
double yaw = orientationToYaw(current_armor.pose.orientation);
ekf.x(6) = yaw;
updateArmorsNum(current_armor);
if (tracked_armors_num == ArmorsNum::NORMAL_4) {
dz = ekf.x(4) - current_armor.pose.position.z;
ekf.x(4) = current_armor.pose.position.z;
std::swap(ekf.x(8), another_r);
}
RCLCPP_WARN(rclcpp::get_logger("armor_tracker"), "Armor jump!");
// If position diff is large, reset diverged state
auto p = current_armor.pose.position;
Eigen::Vector3d current_p(p.x, p.y, p.z);
Eigen::Vector3d infer_p = getArmorPositionFromState(ekf.x);
if ((current_p - infer_p).norm() > max_match_distance_) {
double r_val = ekf.x(8);
ekf.x(0) = p.x + r_val * cos(yaw);
ekf.x(1) = 0;
ekf.x(2) = p.y + r_val * sin(yaw);
ekf.x(3) = 0;
ekf.x(4) = p.z;
ekf.x(5) = 0;
ekf.x(9) = 0;
ekf.x(10) = 0;
RCLCPP_WARN(rclcpp::get_logger("armor_tracker"), "Reset diverged state!");
}
target_state = ekf.x;
}
数学与代码对照
ekf.x(6) = yaw 直接用新检测的 yaw 覆盖预测值。std::swap(ekf.x(8), another_r) 交换当前半径 \(r_1\) 与保存的 another_r——这是跳变处理的核心:当从装甲板 A 切换到装甲板 B 时,\(r\) 从 \(r_1\) 变为 \(r_2\),而 another_r 保存的就是上一次的 \(r\)。dz = ekf.x(4) - current_armor.pose.position.z 记录高度差。位置重置逻辑:当推算位置与检测位置偏差超过 max_match_distance_ 时,清除速度并将位置重置为检测值 + 半径偏移。
8.12.1 yaw 解缠绕:orientationToYaw 的 unwrap 机制¶
PnP 解算返回的四元数经 tf2::Matrix3x3::getRPY() 转换为欧拉角时,yaw 的范围是 \([-\pi, \pi]\)。当机器人连续旋转跨越 \(\pm \pi\) 边界时,yaw 会出现 \(2\pi\) 跳变——例如从 \(3.1\) 突变到 \(-3.1\)(实际只旋转了 \(0.08\) rad)。这种跳变会导致 EKF 的新息(innovation)计算出错,滤波器发散。
orientationToYaw 通过记录上一帧的 yaw 值 last_yaw_,利用 angles::shortest_angular_distance 计算两帧之间的最短角度差,实现解缠绕:
double Tracker::orientationToYaw(const geometry_msgs::msg::Quaternion & q)
{
tf2::Quaternion tf_q;
tf2::fromMsg(q, tf_q);
double roll, pitch, yaw;
tf2::Matrix3x3(tf_q).getRPY(roll, pitch, yaw);
yaw = last_yaw_ + angles::shortest_angular_distance(last_yaw_, yaw);
last_yaw_ = yaw;
return yaw;
}
shortest_angular_distance(from, to) 返回从 from 到 to 的最短弧(范围 \((-\pi, \pi]\)),因此即使原始 yaw 跳变 \(\pm 2\pi\),输出的 yaw 也只变化 \(\pm 0.08\)。效果是 yaw 可以连续增长或减小,不会被限制在 \([-\pi, \pi]\) 内——这对 EKF 的匀速运动模型(yaw += v_yaw * dt)至关重要。
初始化时 last_yaw_ = 0
initEKF 中将 last_yaw_ 设为 0,第一次调用 orientationToYaw 时 shortest_angular_distance(0, raw_yaw) 直接返回 raw_yaw(因为 raw_yaw 本身就在 \([-\pi, \pi]\) 内)。此后每帧累加最短角度差,yaw 值可以超出 \([-\pi, \pi]\),形成连续的时间序列。
8.13 发散检测与防护¶
半径边界约束¶
半径 \(r\) 是 EKF 中最容易发散的状态量,需要设置硬边界:
// Prevent radius from spreading (physical constraints)
if (ekf.x(8) < 0.12) {
ekf.x(8) = 0.12;
} else if (ekf.x(8) > 0.4) {
ekf.x(8) = 0.4;
}
发散检测¶
除了半径边界,还有更全面的发散检测(检查 \(r\) 和 \(r + r_{\text{diff}}\) 是否都在合理范围内):
bool Tracker::is_diverged() const
{
double r = ekf.x[8];
bool r_ok = r > 0.05 && r < 0.5;
if (static_cast<int>(tracked_armors_num) == 4) {
double l = ekf.x[8] + ekf.x[9];
bool l_ok = l > 0.05 && l < 0.5;
return !(r_ok && l_ok);
}
return !r_ok;
}
数学与代码对照
is_diverged() 检查两个半径:\(r_1 = x[8]\) 和 \(r_2 = x[8] + x[9]\),两者都必须在 \([0.05, 0.5]\) 内才不算发散。注意这里的边界比 update() 中的硬约束 \([0.12, 0.4]\) 更宽——is_diverged() 是检测(输出警告),而硬约束是防护(强制修改)。
8.14 NIS/NEES 一致性检验¶
EKF 的 update 函数在更新后自动计算两个统计量来评估滤波器一致性:
NIS(Normalized Innovation Squared,归一化新息平方):
其中 \(\boldsymbol{r} = \boldsymbol{z} - \boldsymbol{h}(\hat{\boldsymbol{x}}^+)\) 是更新后的新息,\(S = \mathcal{H} P \mathcal{H}^\top + R\)。
NIS 使用后验状态计算,偏离标准定义
标准 NIS 的定义要求使用先验(更新前)状态:\(\boldsymbol{r}_{\text{prior}} = \boldsymbol{z} - \boldsymbol{h}(\bar{\boldsymbol{x}}^-)\),\(S = \mathcal{H} P^- \mathcal{H}^\top + R\)。但在 ExtendedKalmanFilter::update 中,NIS 的计算发生在状态更新和协方差更新之后:
// 状态已更新: x = x_add(x, K * z_subtract(z, h(x)));
// 协方差已更新: P = (I - K*H) * P * (I - K*H)^T + K*R*K^T
// 此处的 h(x) 使用的是后验状态 x⁺,而非先验状态 x⁻
Eigen::VectorXd residual = z_subtract(z, h(x));
Eigen::MatrixXd S = H * P * H.transpose() + R; // P 已是 P⁺
因此实际计算的是 \(\boldsymbol{r} = \boldsymbol{z} - \boldsymbol{h}(\hat{\boldsymbol{x}}^+)\)(后验残差),\(S = \mathcal{H} P^+ \mathcal{H}^\top + R\)(后验协方差)。后验残差通常比先验新息小(因为更新已经修正了状态),所以这个 NIS 值会偏小,卡方检验的统计性质不严格成立。在工程实践中,代码使用了经验值 0.711 作为阈值(远小于标准 df=4 的 95% 临界值 9.488),部分补偿了这一偏差。
NEES(Normalized Estimation Error Squared,归一化估计误差平方):
如果滤波器调校正确,NIS 和 NEES 应服从自由度为 4 的卡方分布。RM_Vision_2027 使用 95% 置信度的阈值 0.711,并维护一个滑动窗口统计近期 NIS 超标率:
constexpr double nis_threshold = 0.711;
constexpr double nees_threshold = 0.711;
if (nis > nis_threshold) nis_count_++, data["nis_fail"] = 1;
if (nees > nees_threshold) nees_count_++, data["nees_fail"] = 1;
total_count_++;
last_nis = nis;
recent_nis_failures.push_back(nis > nis_threshold ? 1 : 0);
if (recent_nis_failures.size() > window_size) {
recent_nis_failures.pop_front();
}
int recent_failures = std::accumulate(
recent_nis_failures.begin(), recent_nis_failures.end(), 0);
double recent_rate = static_cast<double>(recent_failures) / recent_nis_failures.size();
这些诊断数据存储在 ekf.data 字典中,可用于实时监控滤波器健康状态。
8.15 球坐标 vs 笛卡尔坐标观测对比¶
rm_vision(笛卡尔坐标)vs RM_Vision_2027(球坐标)¶
| 特性 | rm_vision (9维) | RM_Vision_2027 (11维) |
|---|---|---|
| 状态维度 | 9 | 11 |
| 观测坐标系 | 笛卡尔 \((x_a, y_a, z_a, \text{yaw})\) | 球坐标 \((\text{yaw}_{\text{ypd}}, \text{pitch}_{\text{ypd}}, d, \text{angle})\) |
| 半径参数 | 单一 \(r\) | \(r_1\) 和 \(r_{\text{diff}} = r_2 - r_1\) |
| 高度差 | 无 | 状态中包含 \(z_{\text{diff}}\) |
| 测量噪声 \(R\) | 固定对角矩阵 | 自适应:基于角度差和距离动态调整 |
| NIS/NEES | 无 | 实时一致性检验 |
为什么选择球坐标?¶
在球坐标系中,角度误差分布更符合实际情况——方位角误差大致恒定,距离误差随距离增大。而笛卡尔坐标系中,\((x, y)\) 误差在远距离时会被"拉伸",导致 \(R\) 矩阵难以建模。
具体来说,如果相机的角分辨率是固定的(比如 0.1 度),那么:
- 在球坐标中:\(\sigma_{\text{yaw}}\) 和 \(\sigma_{\text{pitch}}\) 是常数,\(\sigma_d\) 随 \(d\) 线性增长。
- 在笛卡尔中:\(\sigma_x\) 和 \(\sigma_y\) 都随 \(d\) 线性增长,且与目标方位角耦合。
球坐标的 \(R\) 矩阵更接近对角形式,更容易调参。
8.16 常见问题与调试建议¶
Q: 跟踪发散怎么办?¶
症状:半径 \(r\) 持续增长或减小,位置估计飞出合理范围。
排查步骤:
- 检查
is_diverged()返回值——如果持续为 true,说明滤波器参数需要调整。 - 检查自适应 \(R\) 是否太小(滤波器过于信任检测)。
- 检查 \(Q\) 是否太大(滤波器过于信任运动模型,忽略检测)。
- 检查 PnP 解算结果是否正确(输入数据有误)。
- 检查 NIS
recent_nis_failures持续超标率——如果超过 50%,说明模型与观测严重不匹配。
Q: 跟踪延迟太大怎么办?¶
症状:跟踪结果总是比实际位置"慢半拍"。
解决方案:
- 增大 \(Q\) 中的加速度方差 \(v_1\)——让滤波器更信任检测,响应更快。
- 减小 \(R\) 中的基准值——同理。
- 在射击预测中使用速度外推:\(\hat{x}_{\text{shoot}} = \hat{x} + \hat{v} \cdot \Delta t_{\text{bullet}}\)。
Q: 装甲板跳变后跟踪丢失?¶
排查步骤:
- 检查
max_match_yaw_diff_阈值——太大则触发不到跳变处理,太小则误触发。 - 检查跳变后位置重置是否正确(
getArmorPositionFromState的计算)。 - 检查
updateArmorsNum是否正确识别了装甲板数量。
8.17 本章小结¶
| 章节 | 核心知识点 |
|---|---|
| 8.1 | 检测器没有时间连续性,跟踪器融合多帧估计运动状态,核心价值是延迟补偿 |
| 8.2 | 卡尔曼滤波 = 预测 + 更新的循环,通过卡尔曼增益平衡"信模型"和"信检测" |
| 8.3 | 11 维状态向量,包含 \(r_{\text{diff}}\) 和 \(z_{\text{diff}}\) 用于装甲板跳变自适应 |
| 8.4 | 观测模型使用球坐标,雅可比通过链式法则分两层计算 |
| 8.5–8.6 | 过程噪声用分段白噪声模型,前哨站角速度钳位(\(\pm 2.51\) rad/s,但 is_converged_ 是死代码),测量噪声自适应(距离噪声由角度差驱动,角度噪声由距离驱动,交叉耦合) |
| 8.7 | EKF 核心实现:通用框架,支持非线性 \(\boldsymbol{f}\)/\(\boldsymbol{h}\)、角度感知加减法、Joseph 形式 |
| 8.8–8.9 | 初始化从装甲板位置反推机器人中心,初始协方差速度分量为 64 |
| 8.10 | 主循环:预测 → 数据关联(ID + 空间匹配)→ 装甲板 ID 匹配 → 更新 → 半径约束 → 状态机 |
| 8.11 | 四状态跟踪状态机,lost_thres 根据帧率动态计算(\(\lfloor lost\_time\_thres / dt \rfloor\)) |
| 8.12 | 装甲板跳变处理:yaw 覆盖 + 半径交换 + 高度补偿 + 发散时位置重置;orientationToYaw 通过 shortest_angular_distance 实现 yaw 解缠绕 |
| 8.13 | 半径硬约束 \(r \in [0.12, 0.4]\) 和发散检测 is_diverged() |
| 8.14 | NIS/NEES 一致性检验(注意 NIS 使用后验残差而非标准先验新息),滑动窗口监控滤波器健康状态 |
| 8.15 | 球坐标观测的优势:误差分布更自然,\(R\) 更接近对角 |
下一章预告:第 9 章将介绍 PnP 姿态估计——如何从 2D 装甲板角点反推目标的 3D 位置和朝向,这是跟踪器观测向量 \(\boldsymbol{z}\) 的直接来源。