跳转至

第 8 章 扩展卡尔曼滤波与目标跟踪

本章目标:从零理解"为什么需要跟踪"和"卡尔曼滤波怎么工作",然后以 RM_Vision_2027 仓库中的真实代码为线索,逐函数走读 11 维 EKF 的数学公式与 C++ 实现。每一个函数都给出数学公式、实际代码、以及"数学与代码对照"注释。

前置知识

本章涉及大量数学推导。遇到不懂的概念参考: - 📖 ch04 TF2 坐标变换 - 📖 ch02 C++ 指针与引用(Eigen 矩阵操作)


8.1 为什么需要跟踪

检测器的局限

在第 7 章中,我们的神经网络检测器逐帧独立工作:

帧 t-1:  检测器 → (x₁, y₁, z₁, yaw₁)
帧 t:    检测器 → (x₂, y₂, z₂, yaw₂)
帧 t+1:  检测器 → (x₃, y₃, z₃, yaw₃)

每帧之间没有任何信息传递。这意味着:

  • 噪声直接暴露:单帧检测结果直接用于瞄准,任何跳变都会导致云台抖动。
  • 丢帧即失:某帧目标被遮挡或检测失败,系统立刻丢失目标。
  • 延迟无法补偿:从图像采集到电机响应存在约 100–200 ms 延迟,如果直接用当前帧检测结果射击,目标早已不在原位。

跟踪器的作用

跟踪器引入时间维度,融合多帧检测结果来估计目标的运动状态:

特性 检测器 跟踪器
输入 单帧图像 检测序列
输出 当前帧位置 位置 + 速度(完整运动状态)
鲁棒性 丢帧即丢失 可短暂预测填补丢帧
延迟补偿 可根据速度预测未来位置
噪声抑制 多帧融合平滑噪声

跟踪的核心价值:延迟补偿

射击决策需要的是子弹到达时刻目标的位置,而不是当前时刻的位置。如果子弹飞行时间 \(\Delta t\),我们需要:

\[ \hat{x}_{\text{shoot}} = \hat{x} + \hat{v}_x \cdot \Delta t \]

这就是跟踪器存在的最大意义——估计速度,预测未来


8.2 卡尔曼滤波基础

直觉:预测与更新

卡尔曼滤波的核心思想可以用一句话概括:

先猜,再看,然后把猜的和看的折中一下。

具体来说,每帧分两步:

  1. 预测(Predict):根据运动规律,猜目标下一帧在哪。
  2. 更新(Update):看到新的检测结果后,修正预测。

"折中"的比例取决于我们对预测有多自信、对检测有多自信——这就是卡尔曼增益。

预测-更新循环

graph LR A["上一帧状态
x̂, P"] -->|预测| B["预测状态
x̄⁻, P⁻"] B -->|新检测| C["更新
x̂⁺, P⁺"] C -->|下一帧| A

更完整的流程:

graph TD A["起始: x̂, P"] --> B["预测: 运动模型"] B --> C{"本帧有检测?"} C -->|有| D["更新: 融合检测"] C -->|无| E["只保留预测结果"] D --> F["输出: 最终状态估计"] E --> F F --> G["等待下一帧"] G --> A

数学形式(线性卡尔曼滤波)

预测步骤(线性运动模型 \(F\),过程噪声 \(Q\)):

\[ \bar{x}^- = F \hat{x}^+ \]
\[ P^- = F P^+ F^\top + Q \]

更新步骤(线性观测 \(H\),测量噪声 \(R\),观测 \(z\)):

\[ K = P^- H^\top (H P^- H^\top + R)^{-1} \]
\[ \hat{x}^+ = \bar{x}^- + K(z - H\bar{x}^-) \]
\[ P^+ = (I - K H) P^- \]

其中:

  • \(\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 维状态向量:

\[ \boldsymbol{x} = \begin{bmatrix} x_c \\ v_{x_c} \\ y_c \\ v_{y_c} \\ z_c \\ v_{z_c} \\ \text{yaw} \\ v_{\text{yaw}} \\ r \\ r_{\text{diff}} \\ z_{\text{diff}} \end{bmatrix} \in \mathbb{R}^{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\)),假设各状态量的导数恒定。

\[ \boldsymbol{f}(\boldsymbol{x}, \Delta t) = \begin{bmatrix} x_c + v_{x_c} \Delta t \\ v_{x_c} \\ y_c + v_{y_c} \Delta t \\ v_{y_c} \\ z_c + v_{z_c} \Delta t \\ v_{z_c} \\ \text{yaw} + v_{\text{yaw}} \Delta t \\ v_{\text{yaw}} \\ r_1 \\ r_{\text{diff}} \\ z_{\text{diff}} \end{bmatrix} \]

状态转移矩阵 \(F \in \mathbb{R}^{11 \times 11}\)

\[ F = \begin{bmatrix} 1 & \Delta t & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 1 & \Delta t & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 1 & \Delta t & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 1 & \Delta t & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 1 \end{bmatrix} \]

因为运动模型本身就是线性的,雅可比矩阵 \(\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 维,但不是直接的装甲板笛卡尔坐标,而是球坐标 + 朝向角

\[ \boldsymbol{z} = \begin{bmatrix} \text{yaw}_{\text{ypd}} \\ \text{pitch}_{\text{ypd}} \\ \text{distance} \\ \text{angle} \end{bmatrix} \in \mathbb{R}^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)\)

\[ \text{yaw}_{\text{ypd}} = \text{atan2}(y_a,\; x_a) \]
\[ \text{pitch}_{\text{ypd}} = \text{atan2}\!\left(z_a,\; \sqrt{x_a^2 + y_a^2}\right) \]
\[ d = \sqrt{x_a^2 + y_a^2 + z_a^2} \]

实际代码(匿名命名空间中的辅助函数):

// 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) 而非 asinacos,这样可以避免数值不稳定。\(d\) 对应 std::sqrt(x*x + y*y + z*z)

8.4.3 装甲板位置的非线性映射 \(\boldsymbol{h}\)

完整观测函数 \(\boldsymbol{h}(\boldsymbol{x})\) 分为两步:

第一步:从状态向量计算装甲板在笛卡尔坐标系中的位置 \((x_a, y_a, z_a)\)

\[ \theta_i = \text{yaw} + i \cdot \frac{2\pi}{N} \]
\[ x_a = x_c - r_i \cos(\theta_i), \quad y_a = y_c - r_i \sin(\theta_i), \quad z_a = z_c + h_i \]

其中 \(N\) 是装甲板数量(4/2/3),\(i\) 是当前装甲板编号,\(r_i\)\(h_i\) 取决于装甲板类型:

\[ r_i = \begin{cases} r + r_{\text{diff}} & \text{if } N=4 \text{ and } (i=1 \text{ or } i=3) \\ r & \text{otherwise} \end{cases} \]
\[ h_i = \begin{cases} z_{\text{diff}} & \text{if } N=4 \text{ and } (i=1 \text{ or } i=3) \\ 0 & \text{otherwise} \end{cases} \]

实际代码:

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 维观测:

\[ \boldsymbol{h}(\boldsymbol{x}) = \begin{bmatrix} \text{yaw}_{\text{ypd}}(x_a, y_a, z_a) \\ \text{pitch}_{\text{ypd}}(x_a, y_a, z_a) \\ d(x_a, y_a, z_a) \\ \text{angle} \end{bmatrix} \]

其中 \(\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}\)

\[ J_{xyza} = \begin{bmatrix} 1 & 0 & 0 & 0 & 0 & 0 & r_i \sin\theta_i & 0 & \frac{\partial x_a}{\partial r} & \frac{\partial x_a}{\partial r_{\text{diff}}} & 0 \\ 0 & 0 & 1 & 0 & 0 & 0 & -r_i \cos\theta_i & 0 & \frac{\partial y_a}{\partial r} & \frac{\partial y_a}{\partial r_{\text{diff}}} & 0 \\ 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 & 0 & \frac{\partial z_a}{\partial z_{\text{diff}}} \\ 0 & 0 & 0 & 0 & 0 & 0 & 1 & 0 & 0 & 0 & 0 \end{bmatrix} \]

其中:

\[ \frac{\partial x_a}{\partial r} = -\cos\theta_i, \quad \frac{\partial x_a}{\partial r_{\text{diff}}} = \begin{cases} -\cos\theta_i & \text{side armor} \\ 0 & \text{otherwise} \end{cases} \]
\[ \frac{\partial y_a}{\partial r} = -\sin\theta_i, \quad \frac{\partial y_a}{\partial r_{\text{diff}}} = \begin{cases} -\sin\theta_i & \text{side armor} \\ 0 & \text{otherwise} \end{cases} \]
\[ \frac{\partial z_a}{\partial z_{\text{diff}}} = \begin{cases} 1 & \text{side armor} \\ 0 & \text{otherwise} \end{cases} \]

实际代码:

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_dldy_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}\)

\[ J_{\text{ypd}} = \begin{bmatrix} \frac{\partial \text{yaw}_{\text{ypd}}}{\partial x_a} & \frac{\partial \text{yaw}_{\text{ypd}}}{\partial y_a} & 0 \\ \frac{\partial \text{pitch}_{\text{ypd}}}{\partial x_a} & \frac{\partial \text{pitch}_{\text{ypd}}}{\partial y_a} & \frac{\partial \text{pitch}_{\text{ypd}}}{\partial z_a} \\ \frac{\partial d}{\partial x_a} & \frac{\partial d}{\partial y_a} & \frac{\partial d}{\partial z_a} \end{bmatrix} \]

实际代码:

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}}\)

链式法则组合

\[ \mathcal{H} = J_{\text{ypd,extended}} \cdot J_{xyza} \]

其中 \(J_{\text{ypd,extended}} \in \mathbb{R}^{4 \times 3}\) 是将 \(J_{\text{ypd}}\)\(3 \times 3\))嵌入到 \(4 \times 3\) 矩阵中的形式:

\[ J_{\text{ypd,extended}} = \begin{bmatrix} J_{\text{ypd}} \\ \mathbf{0}_{1 \times 3} \end{bmatrix} \]

实际代码(接续 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\) 子块为:

\[ Q_{2\times2} = \sigma_a^2 \begin{bmatrix} \frac{\Delta t^4}{4} & \frac{\Delta t^3}{2} \\ \frac{\Delta t^3}{2} & \Delta t^2 \end{bmatrix} \]

完整 \(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_ 在头文件中初始化为 falsebool is_converged_ = false;),在 init() 中被重置为 false,但整个代码库中没有任何地方将其设置为 true。这意味着 is_converged() 始终返回 false,上方的角速度钳位条件永远无法触发。这是一段死代码——钳位逻辑虽然存在,但在当前版本中不会执行。如果需要启用钳位,需要补充收敛判断逻辑(例如:当 EKF 的协方差对角元素低于某个阈值时设置 is_converged_ = true)。


8.6 自适应测量噪声协方差 \(R\)

RM_Vision_2027 的测量噪声 \(R\) 不是固定常数,而是根据当前检测与预测的角度差目标距离动态调整:

\[ R = \text{diag}\!\left(\sigma_{\text{yaw}}^2,\; \sigma_{\text{pitch}}^2,\; \sigma_{\text{dist}}^2,\; \sigma_{\text{angle}}^2\right) \]

其中:

\[ \sigma_{\text{yaw}}^2 = 4 \times 10^{-3}, \quad \sigma_{\text{pitch}}^2 = 4 \times 10^{-3} \]
\[ \sigma_{\text{dist}}^2 = \ln\!\left(|\Delta\theta| + 1\right) + 1 \]
\[ \sigma_{\text{angle}}^2 = \frac{\ln\!\left(d + 1\right)}{200} + 9 \times 10^{-2} \]

其中 \(\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) —— 预测步骤

\[ P^- = F P^+ F^\top + Q \]
\[ \bar{\boldsymbol{x}}^- = \boldsymbol{f}(\hat{\boldsymbol{x}}^+) \]

实际代码:

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 形式保证数值稳定性:

卡尔曼增益

\[ K = P^- \mathcal{H}^\top (\mathcal{H} P^- \mathcal{H}^\top + R)^{-1} \]

协方差更新(Joseph 形式)

\[ P^+ = (I - K \mathcal{H}) P^- (I - K \mathcal{H})^\top + K R K^\top \]

状态更新

\[ \hat{\boldsymbol{x}}^+ = \bar{\boldsymbol{x}}^- + K \cdot \text{z\_subtract}(\boldsymbol{z},\; \boldsymbol{h}(\bar{\boldsymbol{x}}^-)) \]

新息协方差(用于 NIS 检测)

\[ S = \mathcal{H} P^- \mathcal{H}^\top + R \]

NIS(归一化新息平方)

\[ \text{NIS} = \boldsymbol{r}^\top S^{-1} \boldsymbol{r} \]

实际代码(完整函数):

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^+ = (I - KH)P^-(I - KH)^\top + KRK^\top \]

可以验证它等价于标准形式——它将 \(P^+\) 写成两个半正定矩阵之和,保证 \(P^+\) 始终至少半正定,数值更稳定。


8.8 Tracker::initEKF() —— EKF 初始化

收到第一帧检测结果时,需要将装甲板检测位置反推为机器人中心位置,构建 11 维初始状态。

数学

给定装甲板检测位置 \((x_a, y_a, z_a)\) 和朝向角 yaw,已知初始半径 \(r\),机器人中心位置为:

\[ x_c = x_a + r \cos(\text{yaw}), \quad y_c = y_a + r \sin(\text{yaw}) \]

初始状态向量:

\[ \boldsymbol{x}_0 = [x_c,\; 0,\; y_c,\; 0,\; z_a,\; 0,\; \text{yaw},\; 0,\; r,\; 0,\; 0]^\top \]

初始协方差对角线:

\[ P_0 = \text{diag}(1,\; 64,\; 1,\; 64,\; 1,\; 64,\; 0.4,\; 100,\; 1,\; 1,\; 1) \]

实际代码:

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.2765xc = 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 维状态,将跟踪状态机设为 DETECTINGupdateArmorsNum 根据装甲板类型确定 \(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 数据关联策略

数据关联分两步:

  1. ID 匹配:在所有检测到的装甲板中,筛选与当前跟踪目标 tracked_id 编号相同的装甲板。
  2. 空间匹配:在同 ID 装甲板中,选择与 EKF 预测位置欧氏距离最小的那一个。

匹配成功的条件:

\[ \min \| \boldsymbol{p}_{\text{pred}} - \boldsymbol{p}_{\text{det}} \| < d_{\max} \quad \text{且} \quad |\text{yaw}_{\text{det}} - \text{yaw}_{\text{pred}}| < \theta_{\max} \]

当匹配失败但只有一个同 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 帧则进入 TRACKINGTRACKING 下首次不匹配进入 TEMP_LOSTTEMP_LOST 下连续不匹配超过 lost_thres 帧则回到 LOST


8.11 跟踪状态机

状态定义

RM_Vision_2027 的跟踪器维护四种状态:

状态 含义 描述
LOST 丢失 没有目标,等待初始化
DETECTING 检测中 收到初步检测,尚未确认
TRACKING 跟踪中 稳定跟踪目标
TEMP_LOST 临时丢失 短暂丢帧,仍用预测结果

状态转移图

stateDiagram-v2 [*] --> LOST LOST --> DETECTING : init() 检测到装甲板 DETECTING --> TRACKING : 连续 N₁ 帧匹配成功 DETECTING --> LOST : 匹配中断 TRACKING --> TEMP_LOST : 匹配失败 TEMP_LOST --> TRACKING : 重新匹配 TEMP_LOST --> LOST : 连续 N₃ 帧无匹配

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() —— 装甲板跳变处理

什么是装甲板跳变

标准步兵机器人有多块装甲板分布在不同侧面。当机器人旋转时,面向相机的装甲板会发生切换——例如从"前装甲板"切换到"侧装甲板"。此时:

  1. yaw 角发生突变(可能跳变 \(\pm \frac{\pi}{2}\)\(\pm \pi\))。
  2. 装甲板到中心的距离可能不同(\(r\)\(r + r_{\text{diff}}\))。
  3. 装甲板高度可能不同(\(z_c\)\(z_c + z_{\text{diff}}\))。

跳变处理的数学

当检测到跳变时:

\[ \text{yaw} \leftarrow \text{yaw}_{\text{obs}} \]
\[ r_1 \leftarrow \text{another\_r} \quad (\text{交换半径}) \]
\[ z_c \leftarrow z_{\text{obs}} \quad (\text{更新高度}) \]

如果位置偏差过大,还需要重置位置和速度:

\[ x_c \leftarrow x_{\text{obs}} + r \cos(\text{yaw}), \quad v_{x_c} \leftarrow 0 \]
\[ y_c \leftarrow y_{\text{obs}} + r \sin(\text{yaw}), \quad v_{y_c} \leftarrow 0 \]

实际代码:

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 机制

📖 ch04 TF2 坐标变换

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) 返回从 fromto 的最短弧(范围 \((-\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,第一次调用 orientationToYawshortest_angular_distance(0, raw_yaw) 直接返回 raw_yaw(因为 raw_yaw 本身就在 \([-\pi, \pi]\) 内)。此后每帧累加最短角度差,yaw 值可以超出 \([-\pi, \pi]\),形成连续的时间序列。


8.13 发散检测与防护

半径边界约束

半径 \(r\) 是 EKF 中最容易发散的状态量,需要设置硬边界:

\[ r \in [r_{\min}, r_{\max}] = [0.12,\; 0.4] \text{ m} \]
  // 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,归一化新息平方)

\[ \text{NIS} = \boldsymbol{r}^\top S^{-1} \boldsymbol{r} \]

其中 \(\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,归一化估计误差平方)

\[ \text{NEES} = (\hat{\boldsymbol{x}}^+ - \bar{\boldsymbol{x}}^-)^\top (P^+)^{-1} (\hat{\boldsymbol{x}}^+ - \bar{\boldsymbol{x}}^-) \]

如果滤波器调校正确,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\) 持续增长或减小,位置估计飞出合理范围。

排查步骤

  1. 检查 is_diverged() 返回值——如果持续为 true,说明滤波器参数需要调整。
  2. 检查自适应 \(R\) 是否太小(滤波器过于信任检测)。
  3. 检查 \(Q\) 是否太大(滤波器过于信任运动模型,忽略检测)。
  4. 检查 PnP 解算结果是否正确(输入数据有误)。
  5. 检查 NIS recent_nis_failures 持续超标率——如果超过 50%,说明模型与观测严重不匹配。

Q: 跟踪延迟太大怎么办?

症状:跟踪结果总是比实际位置"慢半拍"。

解决方案

  1. 增大 \(Q\) 中的加速度方差 \(v_1\)——让滤波器更信任检测,响应更快。
  2. 减小 \(R\) 中的基准值——同理。
  3. 在射击预测中使用速度外推:\(\hat{x}_{\text{shoot}} = \hat{x} + \hat{v} \cdot \Delta t_{\text{bullet}}\)

Q: 装甲板跳变后跟踪丢失?

排查步骤

  1. 检查 max_match_yaw_diff_ 阈值——太大则触发不到跳变处理,太小则误触发。
  2. 检查跳变后位置重置是否正确(getArmorPositionFromState 的计算)。
  3. 检查 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}\) 的直接来源。