万字长文,让你一文轻松掌握卡尔曼滤波!

万字长文,让你一文轻松掌握卡尔曼滤波!!!

最近由于有个项目需要使用到目标追踪,于是便想着来复习一下卡尔曼滤波器,但是我发现目前网路上对卡尔曼滤波的教学大致呈现出了两家分化的趋势,即要么通篇都是理论,要么就是直接给一长串代码让你看,因此我打算参考这篇博客深入浅出理解卡尔曼滤波【实例、公式、代码和图】 - 知乎的理论推导部分再加上自己对卡尔曼滤波器的实际应用写一篇更加详细的教程,从理论出发,理论结合实际来手把手带着大家掌握卡尔曼滤波器的使用

一、卡尔曼滤波器的数学原理

1.1 引入

卡尔曼滤波器是个什么东西呢?抽象一点来讲卡尔曼滤波器是一个能将多个传感器的不确定的数据结合在一起经过分析得到一个相对准确的数据的算法。由于我们数据的来源可以有很多同时由于我们每个来源的测量模型本身的近似或者是测量精度的影响亦或者是测量过程中不可避免要加入噪声,为了的到一个相对准确的结果我们既可以用到卡尔曼滤波器,那这一个过程我们参考深入浅出理解卡尔曼滤波【实例、公式、代码和图】 - 知乎中提到的先简化为一个最基础的小车匀速运动模型来辅助理解:

现在有一个简单的数学问题,现在有一辆小车,从坐标原点出发,以$ v_0 = 2 , \text{m/s} \(的速度自西向东做直线运动,\)t_1=4s$时刻在距离原点的东\(8m\)处,\(t_1\)时刻雷达测得小车距离原点的东\(9m\)处。已知在假定小车做匀速直线运动的前提下,该运动模型的方差为\(\emptyset_q^2=4(m^2)\),雷达测试仪对距离测试的方差为 \(\emptyset_r^2=1(m^2)\) ,那么问题来了,这辆小车到底在距离原点的何处?更准确来说,这辆小车在距离原点何处的可能性最大?

我们很简单就能得到由经典牛顿运动公式推导出来的小车理论位置\(x_q=v*t=8m\),这个值我们暂且称之为预测值,但是很明显,这个预测值并不满足实际情况,因为我们的运动方程过于的简单和理想也并没有将各种阻力亦或是各种误差考虑进其中,而对于雷达给出的测量路程\(x_r=9m\),我们暂且称之为观测值,这个观测值由于任何仪器本身都有误差,因此也不是很可信;那么问题来了,我们该如何利用这两个数据得到一个比较贴近真实值的计算值呢?最简单的方法就是二者权重各占\(50\%\)然后相加取平均\(\overline{x_1}=\frac{x_q+x_r}{2}=8.5m\),但是由于我们知道方差可以代表准确度而对于一个数值,其方差越大越不可信,因此从数学理论上来说方差越大的权重占比应该越小且两者权重相加应该为\(1\),因此我们得到了第二个计算公式:\(\overline{x_2}=\frac{\emptyset_q^2}{\emptyset_q^2+\emptyset_r^2}*x_r+\frac{\emptyset_r^2}{\emptyset_q^2+\emptyset_r^2}*x_q=8.8m\),单单从数据上来看\(\overline{x_2}=8.8m\)也比\(\overline{x_1}=8.5m\)更加的可信也更加的准确,这是因为我们利用了题目所给出的所有信息,而我们计算\(\overline{x}=\frac{\emptyset_q^2}{\emptyset_q^2+\emptyset_r^2}*x_r+\frac{\emptyset_r^2}{\emptyset_q^2+\emptyset_r^2}*x_q\)的这个式子也称之为卡尔曼滤波器的更新公式。

1.2 卡尔曼滤波器的原理推导

由上面的那个例子我们可以引申出卡尔曼滤波器的两个重要的步骤,分别是得到预测值的"预测(Predict) "以及由预测值加权修正得到相较正确值的"更新(Update) ",同时我们也可以得到两个重要的公式,分别是由物体运动状态得到用于描述系统状态随时间的变化的状态方程 和由传感器或者是测量值得到的用于描述测量值与系统状态之间的关系的观测方程

  • 状态方程: \(x_k=Fx_{k-1}+Bu_{k-1}+w_k\)

其中\(x_k\)为\(k\)时刻系统的真实值(例如速度、位置的等量),\(x_{k-1}\)为\(k-1\)时刻系统的真实值,\(u_{k-1}\)表示\(k-1\)时刻系统的控制输入量(如加速度),\(w_k\)表示系统\(k\)时刻的系统噪声,\(F\)表示卡尔曼滤波的状态转移矩阵,\(B\)表示卡尔曼滤波的控制矩阵

  • 观测方程 :\(z_k=Hx_k+v_K\)

其中\(z_k\)表示系统在\(k\)时刻的观测值(例如雷达或者里程计等的测量结果),\(v_k\)表示系统噪声及上文提到的测量精度等因素,\(H\)表示为卡尔曼滤波的观测转移矩阵

在正式开始推到前我们再来了解一下卡尔曼滤波器应用的两个基本假设前提:

  1. 当前时刻的系统状态仅与上一时刻有关
  2. 模型和系统均满足线性关系
  3. 引入的噪声符合高斯分布

知道了上述两个方程和假设前提之后,现在我们开始进行详细的推导,由状态方程和观测方程我们可以发现我们难以定义系统在\(k\)时刻的噪声,不管是过程噪声还是观测噪声我们都没有一个比较好的方法来获取,于是我们可以定义一个变量\(\tilde{x}k\)表示系统的最优估计值用以逼近我们需要得到的\(x_k\),使误差尽可能的小,对于状态方程来说由于\(w_k\)和\(x{k-1}\)未知,因此我们可以先忽略误差\(w_k\)并且假设如下:

\[x_k^-=F\tilde{x}{k-1}+Bu{k-1} \]

在假设的方程中我们使用上一时刻的最有估计值\(\tilde{x}_{k-1}\)来替代上一时刻的真实值,并且这个时候的\(x_k^-\)我们称之为当前时刻通过状态方程 得到的预测值(先验估计值) ,很明显这个预测值相比于真实值误差还是很大,于是我们便可以使用我们前面提到的第二个基本方程即观测方程 来对我们的先验估计值进行修正,这里有两种处理的方法,为了便于理解我们只从预估值的角度出发,我们现在对观测方程进行处理,同样的我们忽略掉测量噪声可以得到:

\[ {\usepackage{amsmath} z_k=Hx_k+v_K\Longrightarrow z_k= Hx_k^{\text{measure}} \Longrightarrow x_k^{\text{measure}}=H^{-1}z_k \]

通过观测值和观测矩阵变换了可以得到和预估值维度相同的\(x_k^{\text{measure}}\),因此我们将其与状态方程得到的\(x_k^-\)做差可以用来修正预估值:

\[\tilde{x}_k=x_k^-+G\cdot(x_k^{\text{measure}}-x_k^-) \]

其中\(G\)为系数矩阵,上述式子本质上与下述式子是同一种表示方式:

\[\tilde{x}_k=Ax_k^-+Bx_k^{\text{measure}},A+B=I \]

此时我们可以知道\(A=I-G,B=G\),我们可以对其进一步进行简化,我们设\(G=K\cdot H\),因此有:

\[\begin{align} \tilde{x}_k &= x_k^- + G \cdot (x_k^{\text{measure}} - x_k^-) \\ &= x_k^- + KH(H^{-1}z_k - x_k^-) \\ &= x_k^- + K(z_k - Hx_k^-)...\enclose{circle}{1} \end{align} \]

其中\(\tilde{x}_k\)为最优值,由于在这个方程里我们加入观测值,因此\(\tilde{x}_k\)和当前时刻的观测量有关系我们也将其称之为后验估计值,\(K\)为卡尔曼增益,我们一会将对卡尔曼增益进行求解,在求解之前我们先将上述的思路转化为数学语言,我们设\(e_k=x_k-\tilde{x}_k\),即我们现在要做的就是求解最优目标函数\(min_K|e_k|\),其中\(e_k\)表示最优值和真实值之间的误差,我们将观测方程\(z_k=Hx_k+v_K\)带入公式\(\enclose{circle}{1}\)可以得到:

\[\tilde{x}_k=x_k^- + K(Hx_k+v_K - Hx_k^-) \]

由于我们需要构建\(e_k=x_k-\tilde{x}_k\),因此我们使用\(x_k\)来分别减去左右两式子,可以得到:

\[\begin{align} x_k-\tilde{x}_k &= x_k-x_k^- - K(Hx_k+v_K - Hx_k^-) \\ &= (I-KH)(x_k-x_k^-)-Kv_k \\ \end{align} \]

为了简化表达,我们再次引入一个新的变量\(e_k^-=x_k-x_k^-\)用来表示预测值和真实值之间的误差,因此又有:

\[e_k=(I-KH)e_k^--Kv_k...\enclose{circle}{2} \]

直接通过随机变量的关系式来求解最优目标函数显示不可行,但是我们可以通过表征随机变量的特征值来进行求解,而在概率论里最简单的特征值就是数学期望,因此我们再次假设:

\[\begin{cases} P_K = E[e_ke_k^t] ,用来表示真实值和最优值的后验误差协方差矩阵\\ P_k^-= E[e_k^-e_k^{-t}] ,用来表示真实值和预测值的先验误差协方差矩阵 \end{cases} \]

协方差的概念具体可以参考这篇文章如何直观地理解「协方差矩阵」? - 知乎,简单来说协方差矩阵是一个用于描述多维随机变量之间协方差关系的矩阵,对于一个包含多个随机变量的向量,协方差矩阵的每个元素表示两个随机变量之间的协方差,其中对角线元素表示每个随机变量的方差,而非对角线元素表示不同随机变量之间的协方差。接着我们继续进行推导,根据\(P_K\)的定义,我们不难得到

\[P_k^t=E[(e_ke_k^t)^t]=E[(e_k^t)^te_k^t]=E[e_ke_k^t]=P_k,P_k^-=P_k^{-t}同理 \]

接着我们将公式\(\enclose{circle}{2}\)的两边乘以其自己的转置矩阵,并取其期望来构造我们需要的协方差矩阵:

\[\begin{align} E[e_ke_k^t] &= E[((I-KH)e_k^t-Kv_k)((I-KH)e_k^-Kv_k)^t] \\ &=E[(I-KH)e_k^-e_K^{-t}(I-KH)^t-(I-KH)e_k^-v_k^tK^t-Kv_ke_k^{-t}(I-KH)^t+Kv_kv_k^tK^t] \end{align} \]

由于测量噪声\(v_k\)和\(\tilde{x}_k、x_k^-、x_k\)均无关,因此\(v_k\)和\(e_k^-、e_k\)相互独立,考虑到\(p(v_k)\sim N(0,R)\),因此

\[\begin{cases} E[(I-KH)e_k^-v_k^tK^t]=(I-KH)E[e_k^-]E[v_k^t]K^t=0\\ E[Kv_ke_l^{-t}(I-KH)^t]=KE[v_k]E[e_k^{-t}](I-KH)^t=0 \end{cases} \]

\[\Downarrow \]

\[\begin{align} P_k &= (I-KH)P_k^-(I-KH)^t+KE[v_kv_k^t]K^t\\ &= (I-KH)P_k^-(I-H^tK^t)+KRK^t\\ &= P_k^--KHP_k^--P_k^-H^tK^t+K(HP_k^-H^t+R)K^t...\enclose{circle}{3}\\ \end{align} \]

至此,我们便将随机变量最优值的问题转为为了纯数学问题,接着我们开始求解最优卡尔曼增益\(K\),我们可以列出如下等式:

\[\begin{align} \frac{\partial \text{tr}(P_k)}{\partial K} &= \frac{\partial \text{tr}(P_k^-)}{\partial K} - \frac{\partial \text{tr}(KHP_k^-)}{\partial K} - \frac{\partial \text{tr}(P_k^- H^t K^t)}{\partial K} + \frac{\partial \text{tr}(K(HP_k^- H^t + R)K^t)}{\partial K} \\ \end{align} \]

接着我们有以下结论(具体推导可以看原博客)

\[\begin{align} \text{结论1:} \quad & \frac{\partial \text{tr}(AX)}{\partial X} = A^T \\ \text{结论2:} \quad & \frac{\partial \text{tr}(XA)}{\partial X} = A^T \\ \text{结论3:} \quad & \frac{\partial \text{tr}(XAX^T)}{\partial X} = (AX^T + A^T X^T)^T = X(A + A^T) \\ \text{结论4:} \quad & \frac{\partial \text{tr}(X^T AX)}{\partial X} = (X^T A + X^T A^T)^T = (A + A^T)X \\ \text{结论5:} \quad & \frac{\partial \text{tr}(P)}{\partial X} = \frac{\partial \text{tr}(P^T)}{\partial X} \end{align} \]

运用这些结论我们可以将原式子化简为:

\[\begin{align} \frac{\partial \text{tr}(P_k)}{\partial K} &= \frac{\partial \text{tr}(P_k^-)}{\partial K} - \frac{\partial \text{tr}(KHP_k^-)}{\partial K} - \frac{\partial \text{tr}(P_k^- H^t K^t)}{\partial K} + \frac{\partial \text{tr}(K(HP_k^- H^t + R)K^t)}{\partial K} \\ &= 0 - (HP_k^-)^t - (HP_k^{-t})^t + K[(HP_k^- H^t + R) + (HP_k^- H^t + R)^t] \\ &= -2P_k^- H^t + 2K(HP_k^- H^t + R) \\ &= 0 \end{align} \]

所以我们可以得到:\(K=P_k^-H^t(HP_k^-H^t+R)^{-1}...\enclose{circle}{4}\),将其带入公式\(\enclose{circle}{3}\)中,并对\(P_k\)进行化简可以得到:

\[P_k=P_k^--KHP_k^--P_k^-H^tK^t+P_k^-H^tK^t=(I-KH)P_k^-...\enclose{circle}{5} \]

之后借鉴上述推导的思路,我们利用\(x_k^-=F\tilde{x}{k-1}+Bu{k-1}\)来构建\(e_k^-\),并在等式两边同时减去\(x_k\)后取负:

\[\begin{align} x_k-x_k^- &= x_k-F\tilde{x}{k-1}-Bu{k-1}\\ &=Fx_{k-1}+Bu_{k-1}+w_k-F\tilde{x}{k-1}-Bu{k-1}\\ &=F(x_{k-1}-\tilde{x}_{k-1})+w_k \end{align} \]

\[\Downarrow \]

\[e_k^-=Fe_{k-1}+w_k \]

最后我们用类似于构建\(P_k^-\)的方法来构建\(P_k^-\),将其两边乘以自己的转置矩阵,又\(w_k\)和\(e_{k-1}\)相互独立,我们可以得到:

\[E[e_k e_k^t] = E[(Fe_{k-1} + w_k)(Fe_{k-1} + w_k)^t] = E[Fe_{k-1} e_{k-1}^t F^t + w_k w_k^t] \]

因此\(P_k^-=FP_{k-1}F^t+Q\)...\(\enclose{circle}{6}\)

至此我们完成了卡尔曼滤波器的预测及更新公式的推导

1.3 卡尔曼滤波器的搭建步骤

我们在上一节对卡尔曼滤波器的数学公式进行了推导并得到了以下九个公式:

\[\begin{align} \text{状态方程:} \quad & x_k=Fx_{k-1}+Bu_{k-1}+w_k\\ \text{观测方程:} \quad & z_k=Hx_k+v_K\\ \text{最优估计值:} \quad & \tilde{x}k=x_k^- + K(z_k - Hx_k^-)\\ \text{后验误差值:} \quad & e_k=(I-KH)e_k^--Kv_k\\ \text{后验误差协方差矩阵:} \quad & P_k=P_k^--KHP_k^--P_k^-H^tK^t+P_k^-H^tK^t=(I-KH)P_k^-\\ \text{卡尔曼增益:} \quad & K=P_k^-H^t(HP_k^-H^t+R)^{-1}\\ \text{先验误差:} \quad & e_k^-=Fe{k-1}+w_k\\ \text{先验误差协方差矩阵:} \quad & P_k^-=FP_{k-1}F^t+Q\\ \text{先验预测值:} \quad & x_k^-=F\tilde{x}{k-1}+Bu{k-1}\\ \end{align} \]

那么在实际过程中我们应该怎么使用这九个公式呢?我们通过构建一个匀加速运动的小车模型这一例子来讲解我们的使用步骤,卡尔曼滤波器的核心就两步,一步是预测一步是更新,我们假设小车在直线上运动,状态向量\(x_0\)包含位置和速度,我们通过某一传感器来测量小车的位置,也仅测量位置。

1.3.1 定义系统模型

根据题意,小车在直线上的状态可以用位置$ x\(和速度\)v$来表示,因此状态向量和系统状态转移方程定义为:

\[x_k = \begin{bmatrix} x_k \\ v_k \end{bmatrix},x_k=Fx_{k-1}+Bu_{k-1}+w_k,其中 F = \begin{bmatrix} 1 & \Delta t \\ 0 & 1 \end{bmatrix}, \quad B = \begin{bmatrix} \frac{1}{2}\Delta t^2 \\ \Delta t \end{bmatrix}, \quad w_k \sim N(0, Q), \]

\(u_{k-1}\)是加速度(控制量),\(\Delta t\) 是时间间隔,接着观测方程便可以定义为:

\[z_k=Hx_k+v_K, H = \begin{bmatrix} 1 & 0 \end{bmatrix}, \quad v_k \sim N(0, R) \]

1.3.2 卡尔曼滤波的预测及更新

卡尔曼滤波的两步核心为预测和更新,我们按照以下顺序依次计算:

(1)预测步骤:

  • 先验预测状态:\(x_k^-=F\tilde{x}{k-1}+Bu{k-1}\)
  • 先验误差协方差矩阵:\(P_k^-=FP_{k-1}F^t+Q\)

(2)更新步骤:

  • 计算卡尔曼增益:\(K=P_k^-H^t(HP_k^-H^t+R)^{-1}\)
  • 更新状态估计:\(\tilde{x}_k=x_k^- + K(z_k - Hx_k^-)\)
  • 更新后验误差协方差矩阵:\(P_k=(I-KH)P_k^-\)

我们利用我们已知的几个公式搭建起了上述初始化、预测及更新的系统模型,接着我们将卡尔曼滤波器实际应用到我们的小车位置的估计上,首先假设我们小车的初始状态以及初始状态协方差矩阵为:

\[\tilde{x}_0 = \begin{bmatrix} x_0 \\ v_0 \end{bmatrix},P_0 = \begin{bmatrix} \sigma_x^2 & 0 \\ 0 & \sigma_v^2 \end{bmatrix}. \]

接着我们启用卡尔曼滤波器进行迭代计算,每次迭代先进行预测再结合传感器的观测值进行更新即可不断的到我们想要的预测值,具体在代码的实现可以看下面实际部署的教程

二、卡尔曼滤波的实际使用部署

单纯了解卡尔曼滤波器的理论并不足以解决实际问题。我们的目标是将这一强大的数学工具应用于真实场景,比如目标追踪、导航、信号处理等。因此,在本节中,我将带领大家使用 PythonC++ 两种编程语言,从零开始实现一个简单的卡尔曼滤波目标追踪器。通过实践,我们不仅能够更深刻地理解卡尔曼滤波的工作原理,还能掌握如何根据实际需求调整其参数以获得最优效果。

卡尔曼滤波的应用场景非常广泛,尤其是在动态系统的状态估计中。例如,追踪一个移动目标的位置,预测下一时刻的位置和速度,在面对噪声干扰时提供更可靠的结果。在实现过程中,我们会关注以下几个关键步骤:

  1. 定义系统模型:构建状态方程和观测方程,定义状态转移矩阵 FFF、观测矩阵 HHH、过程噪声协方差 QQQ、观测噪声协方差 RRR 等参数。
  2. 初始化卡尔曼滤波器:设置初始状态向量和协方差矩阵。
  3. 实现滤波过程:通过预测和更新两个核心步骤,实现动态系统状态的连续估计。
  4. 优化与调试:根据实际测量数据调整参数,验证模型效果。

在接下来的两节中,我们将分别使用 Python 和 C++ 实现卡尔曼滤波器,从基础理论到代码实现,完成一个基于卡尔曼滤波的目标追踪器

2.1 基于卡尔曼滤波的Python目标追踪器

接下来我将带着大家手把手的用Python实现对某个物体在二维图像上的预测追踪,首先我们先导入我们所需一些软件包:

python 复制代码
import cv2   #opencv图像处理库
import numpy as np  #数组矩阵计算库
import time  #时间模块

很多人只知道OpenCV是图像处理的库,但是其实OpenCV中有KalmanFilter卡尔曼滤波模块,我相信现在在学习卡尔曼滤波的大家应该已经对Python的基本语法非常熟悉了,因此我们不再对Python中的类等基本概念作阐述。我们首先创建一个卡尔曼滤波器的类方便我们进行使用,这个类中应该包括对卡尔曼滤波器的初始化以及我们二点预测函数

python 复制代码
class KalmanFilter:
    def __init__(self):
    def predict(self, coordX, coordY):

接着我们将为卡尔曼滤波器类的初始化函数完善其实现,我们首先调用OpenCV的卡尔曼滤波器同时定义其状态方程维度和观测方程维度,由于我们要追踪预测的物体在二维图像上具有4个维度,分别是位置\((x,y)\)以及对应方向上的速度\((v_x,v_y)\),而测量维度由于我们仅知道他在图像上的位置所以观测维度为2\((x,y)\):

python 复制代码
#cv2.KalmanFilter(dynamParams, measureParams[, controlParams])
self.kf = cv2.KalmanFilter(4, 2)  #dynamParams状态变量维度,measureParams观测变量维度,controlParams(可选)控制输入的维度

接着我们设置状态转移矩阵以及观测矩阵,两个矩阵的设置依赖于我们的系统模型即如状态变量如何变化以及我们如何从状态中获取观测量,首先我们来说观测矩阵,观测矩阵\(H\)的作用是描述我们如何从系统的状态向量\(F\)中获取我们的观测值\(Z=H \cdot F\),由于我们要提取的观测值\((x,y)\)在\(F\)状态矩阵中的前两维,因此我们的观测矩阵设置为一个二维矩阵\([[1,0,0,0],[0,1,0,0]]\)其中矩阵中的1表示直接测量位置\(x,y\),接着我们来讲我们该如何设置我们的状态转移矩阵,因为我们的状态矩阵是一个四维的矩阵其状态向量为\(F=[x,y,v_x,v_y]^T\),同时我们通过高中物理分析可以知道我们要追踪的物体在二维图像上具有以下物理方程:

\[\begin{cases} x_k=x_{k-1}+vx_{k-1}+\Delta t,即当前的x方向的位置由上一时刻的位置x和速度vx决定 \\ y_k=y_{k-1}+vy_{k-1}+\Delta t,即当前的y方向的位置由上一时刻的位置y和速度vy决定 \\ vx_k=vx_{k-1},即假设x方向上的速度保持不变 \\ vy_k=vy_{k-1},即假设y方向上的速度保持不变 \\ \end{cases} \]

因此我们可以列出如下状态方程\([x,y,v_x,v_y]:[[1,0,1,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]]\)每一维上的1便代表当前量与上一时刻二点哪一个量有关系,于是我们的代码如下:

python 复制代码
 # 设置测量矩阵
 self.kf.measurementMatrix = np.array([[1, 0, 0, 0], 
                                      [0, 1, 0, 0]], np.float32)
 # 设置状态转移矩阵
 self.kf.transitionMatrix = np.array([[1, 0, 1, 0], 
                                     [0, 1, 0, 1], 
                                     [0, 0, 1, 0], 
                                     [0, 0, 0, 1]], np.float32)

接下来我们开始设置系统的过程噪声\(w_k\),由于我们的状态向量中的\([x,y,v_x,v_y]\)中的四个状态量之间没有相关性,因此我们过程噪声协方差矩阵\(Q=E[w\cdot w^T]\)用一个四维的单位矩阵来表示,同时我们设置一个权重用于表示系统状态中的每个量在每次更新中可能偏离模型预测的程度,其中较小的值表示对模型预测的信任较高,较大的值表示对噪声影响的容忍度更高(适合更复杂的环境),因此我们的过程噪声协方差矩阵及代码实现如下:

\[\begin{bmatrix} 1.0 & 0.0 & 0.0 & 0.0 \\ 0.0 & 1.0 & 0.0 & 0.0 \\ 0.0 & 0.0 & 1.0 & 0.0 \\ 0.0 & 0.0 & 0.0 & 1.0 \\ \end{bmatrix}*0.03 \]

python 复制代码
self.kf.processNoiseCov = np.eye(4, dtype=np.float32) * 0.03  #np.eye用于创建单位矩阵,0.03为权重

接下来设置我们的测量噪声协方差矩阵\(R=[v\cdot v^T]\),由于我们的观测量是物体在两个方向上的位置,因此两个状态也无相关性,所以我们类比过程噪声协方差矩阵的设置即可

\[\begin{bmatrix} 1.0 & 0.0 \\ 0.0 & 1.0 \\ \end{bmatrix}*0.1 \]

python 复制代码
self.kf.measurementNoiseCov = np.eye(2, dtype=np.float32) * 0.1

最后我们来设置我们的初始误差协方差矩阵\(P_0 = E\left[(X_0 - \hat{X}_0) \cdot (X_0 - \hat{X}_0)^T\right] ,X_0表示实际初始状态,\hat{X}_0表示估计初始状态\),初始误差协方差矩阵用于描述初始状态的不确定性,因为我们的初始状态可能是通过外部方法或估计得来的,可能带有一定误差,而误差协方差矩阵可以用来定义卡尔曼滤波器对初始状态的信任程度,由于我们四个初始状态之间没有相关性因此我们依旧使用一个四维的单位矩阵然后乘上权重,其中权重如果为较小的值(如 0.1)表示初始状态非常准确,如果为较大的值(如 10.0)表示初始状态存在较大不确定性,我们默认设置为0.1,

python 复制代码
self.kf.errorCovPost = np.eye(4, dtype=np.float32) * 0.1

但是有很多同学看到这里会一脸迷惑,我们这里只讲了状态量没有相关性时使用单位矩阵,那那那如果遇到每个状态量之间存在相关性的情况我们该如何设置呢?别急,我们接着往下讲

我们以过程噪声协方差矩阵(processNoiseCov)为例子,如果我们的状态变量\(F=[x,y,v_x,v_y]^T\)之间存在关系(相关性),那么矩阵就将不再是对角阵,而是一个对称的实数矩阵,此时,矩阵的非对角线元素描述了状态变量之间的协方差,体现它们的相关性,有些懵是不是?让我们举个例子,我们现在四个状态量之间存在相关性,那么我们的过程噪声协方差矩阵将会是这样的形式:

\[Q = \begin{bmatrix} q_{xx} & q_{xy} & q_{xvx} & q_{xvy} \\ q_{xy} & q_{yy} & q_{yvx} & q_{yvy} \\ q_{xvx} & q_{yvx} & q_{vxvx} & q_{vxvy} \\ q_{xvy} & q_{yvy} & q_{vxvy} & q_{vyvy} \end{bmatrix} \]

其中对角线元素\([q_{xx},q_{yy},q_{v_x v_x},q_{v_y v_t}]\)用来表示每个状态变量的过程噪声强度,非对角线元素\([q_{xy},q_{x v_x},q_{x v_y},...]\)用来表示每个状态变量之间的相关性强度(协方差)例如:如果\(x\)和\(y\) 的噪声之间有相关性,那么\(q_{xy}\neq0\);如果位置和速度的噪声相关性较强,那么\(q_{xv_x}, q_{yv_y} \neq 0\),因此在我们的设计中,相关性通常反映在实际的物理模型或经验数据中,比如:

  • 同一方向的变量相关性:如位置和速度在同一方向上可能受到相同外力(如风、摩擦)影响

  • 不同方向的变量相关性 :如\(x\)和\(y\)方向的噪声可能因为受相同扰动(如振动或传感器偏移)而相关

  • 历史数据驱动或经验:通过分析历史数据,估计状态变量之间的协方差

所以,我们的教学也可以使用如下当作我们的过程噪声协方差矩阵,但是为了大家学起来简单我们便不考虑变量之间的相关性了:

\[\text{kf.processNoiseCov} = \begin{bmatrix} 0.03 & 0.01 & 0.02 & 0.01 \\ 0.01 & 0.03 & 0.01 & 0.02 \\ 0.02 & 0.01 & 0.03 & 0.01 \\ 0.01 & 0.02 & 0.01 & 0.03 \end{bmatrix} \]

python 复制代码
self.kf.processNoiseCov = np.array([
    								[0.03, 0.01, 0.02, 0.01],
                                    [0.01, 0.03, 0.01, 0.02],
                                    [0.02, 0.01, 0.03, 0.01],
                                    [0.01, 0.02, 0.01, 0.03]
									], dtype=np.float32)

所以到这一步我们卡尔曼滤波器的初始化便完成啦,完成代码如下:

python 复制代码
def __init__(self):
    self.kf = cv2.KalmanFilter(4, 2)
    self.kf.measurementMatrix = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], np.float32)
    self.kf.transitionMatrix = np.array([[1, 0, 1, 0], [0, 1, 0, 1], [0, 0, 1, 0], [0, 0, 0, 1]], np.float32)
    self.kf.processNoiseCov = np.eye(4, dtype=np.float32) * 0.03
    self.kf.measurementNoiseCov = np.eye(2, dtype=np.float32) * 0.5
    self.kf.errorCovPost = np.eye(4, dtype=np.float32) * 1.0

接着我们继续完成卡尔曼滤波器的预测及更新部分predict,由于我们是使用观测值来修正我们的理论计算值,因此我们预测函数的输入应该是观测物体的\(x,y\),但

python 复制代码
def predict(self, coordX, coordY):

进入预测函数之后,我们首先需要将输入进来的\((x,y)\)转化为观测向量

python 复制代码
 measured = np.array([[np.float32(coordX)], [np.float32(coordY)]])

然后我们先用观测值来更新卡尔曼滤波器的内部状态,接着根据修正后的状态进行下一步的预测

python 复制代码
self.kf.correct(measured)
predicted = self.kf.predict()

最后我们将预测出来的\((x,y)\)位置提取出来并转换为整数

python 复制代码
x, y = int(predicted[0]), int(predicted[1])
return x, y

至此,我们的追踪预测器已经设置完毕啦!,那我们该怎么使用卡尔曼滤波器呢?接着我们先初始化卡尔曼滤波器,然后创建图像背景以及手动输入我们要追踪预测的物体的运动坐标,这些点代表物体的真实运动路径。我们以二维平面上的点\((x,y)\)形式表示。然后,我们使用卡尔曼滤波器对这些真实轨迹点进行预测,并将预测点与真实轨迹点一起绘制在图像上

python 复制代码
# 初始化 Kalman 滤波器
kf = KalmanFilter()
# 创建白色背景图像
img = np.ones((720, 1280, 3), dtype=np.uint8) * 255
ball_positions = [(4, 300), (61, 256), (116, 214), (170, 180), (225, 148), (279, 120), (332, 97),
         (383, 80), (434, 66), (484, 55), (535, 49), (586, 49), (634, 50),
         (683, 58), (731, 69), (778, 82), (824, 101), (870, 124), (917, 148),
         (962, 169), (1006, 212), (1051, 249), (1093, 290)] #创建待追踪物体点集
cv2.namedWindow("Kalman Filter Tracking", cv2.WINDOW_NORMAL)# 创建窗口
predicted_points = []# 用于存储所有预测点

接着我们在ball_positions中创建一个循环用于模拟每次更新一个坐标点,由于我们点的数量太小为了有更好的显示效果我们没更新一次点绘制坐标点的时候我们手动延迟了0.5秒。

python 复制代码
# 显示实际轨迹点和预测点
for pt in ball_positions:
    # 创建新的白色背景
    display_img = img.copy()
    # 绘制之前的所有点
    for prev_pt in ball_positions[:ball_positions.index(pt)+1]:
        cv2.circle(display_img, prev_pt, 5, (0, 0, 255), -1)  # 红色:实际轨迹点
    # 绘制之前的所有预测点
    for prev_pred in predicted_points:
        cv2.circle(display_img, prev_pred, 5, (255, 0, 0), 2)  # 蓝色:预测点
    # 预测新的点
    predicted = kf.predict(pt[0], pt[1])
    predicted_points.append(predicted)
    # 绘制当前预测点
    cv2.circle(display_img, predicted, 5, (255, 0, 0), 2)
    # 添加图例
    cv2.putText(display_img, "Red: Actual Path", (30, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2)
    cv2.putText(display_img, "Blue: Predicted Path", (30, 60), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2)
    # 显示图像
    cv2.imshow("Kalman Filter Tracking", display_img)
    cv2.waitKey(1)
    time.sleep(0.5)  # 暂停0.5秒
cv2.waitKey(0)
cv2.destroyAllWindows()

我们运行代码之后效果如下:

那这时候有些小伙伴又要问了,我不想用OpenCV自带的卡尔曼滤波器,我想自己创建一个该怎么办???别急,我们继续往下看,如果要自己搭建一个卡尔曼滤波器的话,无非就是按照我们上文的搭建步骤手动定义和管理所有矩阵及其运算代码即可,我们依旧创建一个卡尔曼滤波器类

python 复制代码
class KalmanFilter:
    def __init__(self):
    def predict(self):
    def update(self,z):

我们首先在初始化函数部分定义我们所需要的状态向量、状态协方差矩阵、状态转移矩阵、测量矩阵、过程噪声协方差矩阵、测量噪声协方差矩阵、单位矩阵即可,和上述唯一的差别便是我们需要自行定义状态向量以及观测向量的矩阵而不能直接使用self.kf = cv2.KalmanFilter(4, 2)函数来创建

python 复制代码
def __init__(self):
    # 状态向量 [x, y, vx, vy]
    self.x = np.zeros((4, 1), dtype=np.float32)
    # 测量矩阵
    self.H = np.array([[1, 0, 0, 0],
                       [0, 1, 0, 0]], dtype=np.float32)
    # 状态协方差矩阵
    self.P = np.eye(4, dtype=np.float32) * 1.0
    # 状态转移矩阵
    self.F = np.array([[1, 0, 1, 0],
                       [0, 1, 0, 1],
                       [0, 0, 1, 0],
                       [0, 0, 0, 1]], dtype=np.float32)

    # 过程噪声协方差矩阵
    self.Q = np.eye(4, dtype=np.float32) * 0.03
    # 测量噪声协方差矩阵
    self.R = np.eye(2, dtype=np.float32) * 0.01
    # 单位矩阵
    self.I = np.eye(4, dtype=np.float32)

接着便是我们的预测部分

python 复制代码
def predict(self):
    """预测步骤"""
    # 状态预测
    self.x = np.dot(self.F, self.x)
    # 协方差预测
    self.P = np.dot(np.dot(self.F, self.P), self.F.T) + self.Q
    return self.x[:2].flatten()  # 返回预测位置 [x, y]

以及我们的更新部分:

python 复制代码
def update(self, z):
    """更新步骤"""
    # 计算卡尔曼增益
    S = np.dot(np.dot(self.H, self.P), self.H.T) + self.R
    K = np.dot(np.dot(self.P, self.H.T), np.linalg.inv(S))
    # 状态更新
    y = z.reshape(2, 1) - np.dot(self.H, self.x)  # 计算创新
    self.x += np.dot(K, y)
    # 协方差更新
    self.P = np.dot(self.I - np.dot(K, self.H), self.P)

可以看到自己定义卡尔曼滤波其实和OpenCV实现的卡尔曼滤波没太大差别,只不过是把我们上述的1.3的内容用代码实现了一遍而已,在此便不再赘述啦,同学们自行比对理解

2.1 基于卡尔曼滤波的C++目标追踪器

讲完了Python的代码,接着我们来用C++进行实现,C++中的OpenCV和Python的一样也有卡尔曼滤波器功能,但是由于我们一般很少在C++中使用OpenCV的卡尔曼滤波器反而更多是使用Eigen库来实现,因此我将C++中使用OpenCV实现的追踪预测代码直接附在这里,使用方法和Python无异,我们着重来讲如何用Eigen库实现卡尔曼滤波

c++ 复制代码
#include <opencv2/opencv.hpp>
#include <vector>
#include <thread>
#include <chrono>
class KalmanFilter {
public:
    KalmanFilter() {
        // 初始化Kalman滤波器
        kf.init(4, 2, 0); // 状态维度4,测量维度2,无控制输入
        // 设置测量矩阵
        cv::setIdentity(kf.measurementMatrix, cv::Scalar(1));
        // 设置状态转移矩阵
        kf.transitionMatrix = (cv::Mat_<float>(4, 4) << 
            1, 0, 1, 0,
            0, 1, 0, 1,
            0, 0, 1, 0,
            0, 0, 0, 1);
        // 设置过程噪声协方差矩阵
        cv::setIdentity(kf.processNoiseCov, cv::Scalar(0.03));
        // 设置测量噪声协方差矩阵
        cv::setIdentity(kf.measurementNoiseCov, cv::Scalar(0.01));
        // 设置初始误差协方差矩阵
        cv::setIdentity(kf.errorCovPost, cv::Scalar(0.1));
    }
    std::pair<int, int> predict(int coordX, int coordY) {
        // 更新测量值
        cv::Mat measurement = (cv::Mat_<float>(2, 1) << coordX, coordY);
        kf.correct(measurement); // 更新滤波器状态
        // 预测下一状态
        cv::Mat prediction = kf.predict();
        int x = static_cast<int>(prediction.at<float>(0));
        int y = static_cast<int>(prediction.at<float>(1));
        return {x, y};
    }
private:
    cv::KalmanFilter kf;
};
int main() {
    // 初始化Kalman滤波器
    KalmanFilter kf;
    // 创建白色背景图像
    cv::Mat img(720, 1280, CV_8UC3, cv::Scalar(255, 255, 255));
    // 轨迹点
    std::vector<std::pair<int, int>> ball_positions = {
        {4, 300}, {61, 256}, {116, 214}, {170, 180}, {225, 148}, {279, 120},
        {332, 97}, {383, 80}, {434, 66}, {484, 55}, {535, 49}, {586, 49},
        {634, 50}, {683, 58}, {731, 69}, {778, 82}, {824, 101}, {870, 124},
        {917, 148}, {962, 169}, {1006, 212}, {1051, 249}, {1093, 290}
    };
    std::vector<std::pair<int, int>> predicted_points;
    // 创建窗口
    cv::namedWindow("Kalman Filter Tracking", cv::WINDOW_NORMAL);
    for (size_t i = 0; i < ball_positions.size(); ++i) {
        // 创建新的白色背景
        cv::Mat display_img = img.clone();
        // 绘制之前的实际轨迹点
        for (size_t j = 0; j <= i; ++j) {
            cv::circle(display_img, cv::Point(ball_positions[j].first, ball_positions[j].second), 5, cv::Scalar(0, 0, 255), -1); 
        }
        // 绘制之前的预测点
        for (const auto& pred : predicted_points) {
            cv::circle(display_img, cv::Point(pred.first, pred.second), 5, cv::Scalar(255, 0, 0), 2); // 蓝色
        }
        // 预测新的点
        auto predicted = kf.predict(ball_positions[i].first, ball_positions[i].second);
        predicted_points.push_back(predicted);
        // 绘制当前预测点
        cv::circle(display_img, cv::Point(predicted.first, predicted.second), 5, cv::Scalar(255, 0, 0), 2);
        // 添加图例
        cv::putText(display_img, "Red: Actual Path", cv::Point(30, 30), cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 0, 255), 2);
        cv::putText(display_img, "Blue: Predicted Path", cv::Point(30, 60), cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 0, 0), 2);
        // 显示图像
        cv::imshow("Kalman Filter Tracking", display_img);
        cv::waitKey(1); // 等待1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 暂停0.5秒
    }
    cv::waitKey(0);
    cv::destroyAllWindows();
    return 0;
}

确保环境中有OpenCV之后在终端输入如下命令进行编译及运行即可:

bash 复制代码
#编译
g++ kalman_filter.cpp -o kalman_filter `pkg-config --cflags --libs opencv4` -std=c++11
#运行
./kalman_filter

接下来我们来介绍如何使用Eigen库实现卡尔曼滤波,Eigen是一个高性能的开源 C++ 数学库,主要用于线性代数、矩阵运算、几何变换等计算,它以简洁、灵活、易用著称,广泛应用于计算机视觉、机器人学、物理仿真和机器学习等领域大家可以将其类比于Python中的Numpy,首先,我们需要引入一些必要的库:

c++ 复制代码
#include <Eigen/Dense>  // 引入Eigen库,用于矩阵运算
#include <vector>  // 向量容器库
#include <iostream>  // 输入输出流库
#include <opencv2/opencv.hpp>  // OpenCV图像处理库
#include <thread>  // 线程库
#include <chrono>  // 时间库

接着我们创建一个卡尔曼滤波器的类,方便我们进行使用,这个类中包括对卡尔曼滤波器的初始化、预测和更新等方法

c++ 复制代码
class KalmanFilter {
public:
    KalmanFilter();
    void predict();
    void update(float coordX, float coordY);
    std::pair<int, int> getState() const;

private:
    Eigen::Vector4f x; // 状态向量 [x, y, vx, vy]
    Eigen::Matrix4f P; // 误差协方差矩阵
    Eigen::Matrix4f F; // 状态转移矩阵
    Eigen::Matrix<float, 2, 4> H; // 测量矩阵
    Eigen::Matrix4f Q; // 过程噪声协方差矩阵
    Eigen::Matrix2f R; // 测量噪声协方差矩阵
};

接着我们在卡尔曼滤波器的构造函数中,我们初始化各个矩阵,每个矩阵是如何的得到的以及如何设置矩阵每一维度的值可以参考Python部分,这里我便不再详细介绍了

c++ 复制代码
KalmanFilter::KalmanFilter() {
    // 初始化状态向量 [x, y, vx, vy]
    x = Eigen::Vector4f::Zero();
    // 初始化误差协方差矩阵
    P = Eigen::Matrix4f::Identity() * 0.1f;
    // 初始化状态转移矩阵
    F = Eigen::Matrix4f::Identity();
    F(0, 2) = 1.0f; F(1, 3) = 1.0f; // 位置与速度的关系
    // 初始化测量矩阵
    H = Eigen::Matrix<float, 2, 4>::Zero();
    H(0, 0) = 1.0f; H(1, 1) = 1.0f; // 只测量位置
    // 初始化过程噪声协方差矩阵
    Q = Eigen::Matrix4f::Identity() * 0.03f;
    // 初始化测量噪声协方差矩阵
    R = Eigen::Matrix2f::Identity() * 0.01f;
}

接下来,我们实现卡尔曼滤波器的预测和更新方法

c++ 复制代码
void KalmanFilter::predict() {
    // 预测下一状态
    x = F * x;
    // 更新误差协方差矩阵
    P = F * P * F.transpose() + Q;
}
void KalmanFilter::update(float coordX, float coordY) {
    // 测量更新
    Eigen::Vector2f z(coordX, coordY); // 测量值
    Eigen::Vector2f y = z - H * x;    // 创新
    Eigen::Matrix2f S = H * P * H.transpose() + R; // 创新协方差
    Eigen::Matrix<float, 4, 2> K = P * H.transpose() * S.inverse(); // 卡尔曼增益
    x = x + K * y; // 更新状态
    P = (Eigen::Matrix4f::Identity() - K * H) * P; // 更新误差协方差矩阵
}

最后,我们还需要一个方法来获取当前的状态

c++ 复制代码
std::pair<int, int> KalmanFilter::getState() const {
    // 返回当前状态的 x 和 y
    return {static_cast<int>(x(0)), static_cast<int>(x(1))};
}

最后,在主函数中,我们类比于上述Python的部分初始化卡尔曼滤波器,创建图像背景,并手动输入我们要追踪预测的物体的运动坐标,然后,我们使用卡尔曼滤波器对这些真实轨迹点进行预测,并将预测点与真实轨迹点一起绘制在图像上

c++ 复制代码
int main() {
    // 初始化 Kalman 滤波器
    KalmanFilter kf;

    // 创建白色背景图像
    cv::Mat img(720, 1280, CV_8UC3, cv::Scalar(255, 255, 255));
    // 轨迹点
    std::vector<std::pair<int, int>> ball_positions = {
        {4, 300}, {61, 256}, {116, 214}, {170, 180}, {225, 148}, {279, 120},
        {332, 97}, {383, 80}, {434, 66}, {484, 55}, {535, 49}, {586, 49},
        {634, 50}, {683, 58}, {731, 69}, {778, 82}, {824, 101}, {870, 124},
        {917, 148}, {962, 169}, {1006, 212}, {1051, 249}, {1093, 290}
    };

    std::vector<std::pair<int, int>> predicted_points;

    // 创建窗口
    cv::namedWindow("Kalman Filter Tracking", cv::WINDOW_NORMAL);

    for (size_t i = 0; i < ball_positions.size(); ++i) {
        // 创建新的白色背景
        cv::Mat display_img = img.clone();
        // 绘制之前的实际轨迹点
        for (size_t j = 0; j <= i; ++j) {
            cv::circle(display_img, cv::Point(ball_positions[j].first, ball_positions[j].second), 5, cv::Scalar(0, 0, 255), -1); // 红色
        }
        // 绘制之前的预测点
        for (const auto& pred : predicted_points) {
            cv::circle(display_img, cv::Point(pred.first, pred.second), 5, cv::Scalar(255, 0, 0), 2); // 蓝色
        }
        // 预测新的点
        kf.predict();
        kf.update(ball_positions[i].first, ball_positions[i].second);
        auto predicted = kf.getState();
        predicted_points.push_back(predicted);
        // 绘制当前预测点
        cv::circle(display_img, cv::Point(predicted.first, predicted.second), 5, cv::Scalar(255, 0, 0), 2);
        // 添加图例
        cv::putText(display_img, "Red: Actual Path", cv::Point(30, 30), cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 0, 255), 2);
        cv::putText(display_img, "Blue: Predicted Path", cv::Point(30, 60), cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 0, 0), 2);
        // 显示图像
        cv::imshow("Kalman Filter Tracking", display_img);
        cv::waitKey(1); // 等待1毫秒
        std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 暂停0.5秒
    }
    cv::waitKey(0);
    cv::destroyAllWindows();
    return 0;
}
bash 复制代码
g++ kal.cc -o kalman_filter_eigen `pkg-config --cflags --libs opencv4` -I /usr/include/eigen3 -std=c++11

运行上述命令编译后,我们运行代码可以看到物体的真实轨迹(红色点)和卡尔曼滤波器预测的轨迹(蓝色点)在图像上被绘制出来,通过卡尔曼滤波器的预测,我们可以看到预测轨迹与真实轨迹非常接近,这说明卡尔曼滤波器在目标追踪方面具有很好的效果。

至此,我们的教程正式结束啦!!!!!!