• 前言

做机器人开发的同学几乎都绕不开卡尔曼滤波。编码器测速抖动、IMU姿态飘、GPS定位跳变,很多人的第一反应就是套卡尔曼代码。但很多人的现状是:代码能跑,不懂原理;只会改Q、R两个参数,不知道为什么这么调;换一个传感器、换运动模型,滤波直接失效。
这篇文章的目标,不是推导高斯分布、贝叶斯估计。而是从工程视角讲清楚:卡尔曼到底在干什么,五步公式每一步工程含义是什么,矩阵参数怎么理解,有哪些坑。读完你不仅能看懂代码,还能根据你的机器人硬件,修改模型、调试参数。

1. 卡尔曼滤波在干什么

机器人的核心痛点:数据永远不准。机器人所有传感器的测量值,都存在两类误差:

  1. 随机噪声:IMU、编码器、激光雷达的高频抖动,每次采样值上下跳;
  2. 模型误差/漂移:陀螺仪长时间积分漂移、里程计累积误差,越跑偏差越大。

简单粗暴的滑动平均滤波能抑制抖动,但会带来严重滞后。机器人高速运动、转弯的时候,滞后会直接导致控制不稳,定位跑偏。所以必须把它们合成一个“目前最可信”的状态,并且知道自己有多不确定。卡尔曼滤波(Kalman Filter, KF)就是在做这件事。它不是魔法,也不是“把噪声滤掉”那么简单。更准确的说法是:

卡尔曼滤波每来一拍,都在问两个问题:

  1. 按物理模型往前推一步,我现在应该在哪?我对这个猜测有多不确定?
  2. 传感器刚报了一个数,这个数跟我猜的差多少?我该信模型多一点,还是信传感器多一点?

信多少,由一个叫 卡尔曼增益 K 的量自动算出来。K 大,说明测量更可信,状态往测量那边靠;K 小,说明模型更可信,状态几乎不改。

假设你在给无人机做定位。IMU 积分出来的位置,短时间内很丝滑,但会慢慢漂;GPS 偶尔跳一下,但长期不会跑飞。你不会只用其中一个,你会下意识地做这种事:

  • IMU 告诉你“我刚往前走了 0.3 米”
  • GPS 告诉你“你现在在 10.8 米附近”
  • 你心里权衡一下:这拍 GPS 看起来还行,就往 10.8 靠一点;如果 GPS 明显跳飞了,就先信 IMU

卡尔曼滤波做的,就是把这种“权衡”写成可复现的算法。它比拍脑袋加权高级的地方在于:

  1. 它同时维护均值和方差。 不只告诉你“位置大概是 10.4”,还告诉你“我现在对这个数的信心有多大”。
  2. 权重是时变的。 模型漂得越久,你越不信模型;传感器越吵,你越不信传感器。这个权重每拍都在变。
  3. 它能估计你没直接测到的量。 比如你只测到位置,它能顺带把速度、加速度估出来。动态障碍物跟踪就是这么干的。

2. 卡尔曼滤波核心思想

先把矩阵全部丢掉,只看一维。

你有两个对同一件事的估计:

  • 模型预测:均值 x_pred,方差 P_pred(越大越不信)
  • 传感器测量:z,方差 R(越大越不信)

最合理的合成方式,不是取平均,而是 按可信度加权
x^=(1−K) xpred+K z \hat{x} = (1-K)\, x_{\text{pred}} + K\, z x^=(1K)xpred+Kz

其中
K=PpredPpred+R K = \frac{P_{\text{pred}}}{P_{\text{pred}} + R} K=Ppred+RPpred
这就是卡尔曼滤波最重要的一张面孔。把它读成人话:

  • 如果预测很准(P_pred 很小),K 接近 0,新估计几乎等于预测
  • 如果测量很准(R 很小),K 接近 1,新估计几乎等于测量
  • 如果两边差不多不准,K 大约 0.5,两边各信一半

更新之后,新的不确定度也会变小:
P=(1−K) Ppred P = (1-K)\, P_{\text{pred}} P=(1K)Ppred
这很符合直觉:你刚融合了两份独立信息,应该比只听其中一份更有把握。

在这里插入图片描述

多维卡尔曼滤波看起来吓人,其实只是把上面这套加权,从标量换成了矩阵。K 不再是一个数,而是一个矩阵,因为它要决定:位置测量该不该去改速度估计、哪个轴该信得多一点。但精神没变。

3. 卡尔曼公式常见记号说明

教科书喜欢一上来丢一串字母。我们反过来:先给每个符号一个岗位说明书。

符号代码里常见名字含义是否为可调参数
(x)states你想估计的状态。位置、速度、加速度、姿态……初值要给,后面算法自己更新
(P)P状态的协方差。对角元大 = 你对这个量没把握初值要给,后面算法自己更新
(F) 或 (A)A状态转移矩阵。描述“没有噪声时,状态怎么随时间走”由运动模型决定,一般不当超参拧
(B)B控制输入矩阵。油门、加速度指令怎么进状态有控制输入才需要
(u)u已知的控制量来自控制器,不是滤波参数
(H)H观测矩阵。状态里哪些量能被传感器直接看到由传感器模型决定
(z)z这一拍的测量来自传感器
(Q)Q过程噪声协方差。模型有多不可信可调
(R)R测量噪声协方差。传感器有多不可信可调
(K)K卡尔曼增益。这一拍信测量多少不要手调,每拍重算

有两个特别容易混的点。
第一,P 不是“误差”。P 是滤波器 自己认为 的不确定度。如果 QR 设错了,P 会骗你:它可能很小,但估计其实已经偏了。这叫“过度自信”。工程上最危险的情况之一,就是 P 很小、估计却是错的,因为后面的模块会把这个数当真理用。
第二,QR 的绝对大小往往没有相对大小重要。真正决定 K 的是 过程不确定度和测量不确定度的比值。把 QR 同时乘 10,稳态 K 几乎不变;只改其中一个,行为会明显变。

4. 五步核心公式:预测两步,更新三步

经典卡尔曼滤波每一拍都做同一件事。
设上一拍结束后的估计是x^k−1\hat{x}_{k-1}x^k1Pk−1P_{k-1}Pk1。这一拍控制输入是uk−1u_{k-1}uk1,测量是 zkz_kzk

4.1 预测:让模型先走一步

1)状态预测
x^k∣k−1=Ax^k−1+Buk−1 \hat{x}_{k|k-1} = A \hat{x}_{k-1} + B u_{k-1} x^kk1=Ax^k1+Buk1
人话:如果世界完全按你的运动学走,状态现在应该在这里。无人机里,这一步常常是用上一拍速度积分位置;障碍物跟踪里,常常是匀加速模型往前推 dt
2)协方差预测
Pk∣k−1=APk−1A⊤+Q P_{k|k-1} = A P_{k-1} A^{\top} + Q Pkk1=APk1A+Q
人话:两件事会让你更不确定。

  1. APA⊤A P A^{\top}APA:旧的不确定度被运动学放大。位置不准,积分后更不准;速度不准,时间越长位置越飘。
  2. +Q+Q+Q:模型本身就有错,每走一步都要再撒一把不确定性。

预测这一步 只让 P 变大(或至少不变),不会变小。因为你没有新证据,只是在用一个不完美的模型往前猜。

4.2 更新:让测量来纠偏

先算“我猜的测量”和“真实测量”差多少。这个差叫 新息(innovation)
yk=zk−Hx^k∣k−1 y_k = z_k - H \hat{x}_{k|k-1} yk=zkHx^kk1
新息是调参时最值得盯的量。如果滤波器健康,新息应该在 0 附近抖,不应该长期偏向一边。
新息的协方差是:
Sk=HPk∣k−1H⊤+R S_k = H P_{k|k-1} H^{\top} + R Sk=HPkk1H+R
SSS 的含义是:这个差,在当前信念下“应该有多大”。差得比 SSS 指示的还离谱,测量可能是野值。
然后才是卡尔曼增益:
Kk=Pk∣k−1H⊤Sk−1 K_k = P_{k|k-1} H^{\top} S_k^{-1} Kk=Pkk1HSk1
最后两步才是真正改状态和改信心:
状态更新
x^k=x^k∣k−1+Kkyk \hat{x}_k = \hat{x}_{k|k-1} + K_k y_k x^k=x^kk1+Kkyk
协方差更新
Pk=(I−KkH)Pk∣k−1 P_k = (I - K_k H) P_{k|k-1} Pk=(IKkH)Pkk1
有的实现会用 Joseph 形式,数值上更稳:
Pk=(I−KkH)Pk∣k−1(I−KkH)⊤+KkRKk⊤ P_k = (I - K_k H) P_{k|k-1} (I - K_k H)^{\top} + K_k R K_k^{\top} Pk=(IKkH)Pkk1(IKkH)+KkRKk
工程上,短周期、维度不高时,用简化形式通常够用。如果 PPP 出现非对称或负特征值,再换 Joseph 形式。

在这里插入图片描述
请特别注意图里的“没有测量”分支。真实机器人经常丢 GPS、检测框跟丢、雷达漏检。这时正确做法不是强行用旧测量,而是 只做预测PPP 会变大,这是诚实的:你已经有一段时间没看见了,因此不确定性增大。

5. 运动模型从哪来:机器人里最常用的三个线性模型

很多教程把 A 写得像天经地义。工程里 A 是你对目标运动的假设。假设错了,后面再怎么调 QR 都是在补洞。
机器人里最常用的三个线性模型:

5.1 恒定位置(几乎不动的东西)

状态:位置。适合停着的障碍、缓慢漂移的偏置。
A=1,xk=xk−1+w A = 1,\quad x_k = x_{k-1} + w A=1,xk=xk1+w
QQQ 这时表示“它其实可能在慢慢动”。

5.2 恒定速度 CV

状态 [p,v]⊤[p, v]^{\top}[p,v]。行人、匀速车辆、很多跟踪器的默认选择。
[pv]k=[1dt01][pv]k−1+w\begin{bmatrix} p \\ v \end{bmatrix}_{k} = \begin{bmatrix} 1 & dt \\ 0 & 1 \end{bmatrix} \begin{bmatrix} p \\ v \end{bmatrix}_{k-1} + w[pv]k=[10dt1][pv]k1+w
位置这一拍等于上一拍位置加 v⋅dtv \cdot dtvdt。速度默认不变,变化全部丢给过程噪声。

5.3 恒定加速度 CA

状态[p,v,a]⊤[p, v, a]^{\top}[p,v,a]。无人机、突然起跑的行人、动态障碍跟踪用的就是这个。
A=[1dt12dt201dt001] A = \begin{bmatrix} 1 & dt & \tfrac{1}{2}dt^{2} \\ 0 & 1 & dt \\ 0 & 0 & 1 \end{bmatrix} A=100dt1021dt2dt1
选模型有一条实用规则:

模型阶数低,平滑但跟不上机动;阶数高,跟得上但噪声会被放大。

能用 CV 就别上 CA。

QQQ 的职责,就是承认“加速度并不是真的恒定”。你把加速度过程噪声 e_q_acc 设大一点,等于告诉滤波器:匀加速只是近似,目标随时可能改加速度。这比把模型改成加加速度(jerk)模型便宜得多。

6. 卡尔曼滤波实现代码

以恒定速度、恒定加速度运动模型为例,给出卡尔曼滤波示例代码。

6.1 恒定速度

from __future__ import annotations

import numpy as np

class KalmanCV2D:
    def __init__(self, dt, q_acc=1.0, r_pos=0.05, p0=1.0):
        self.dt = dt
        self.x = np.zeros((4, 1))
        self.A = np.array(
            [
                [1, 0, dt, 0],
                [0, 1, 0, dt],
                [0, 0, 1, 0],
                [0, 0, 0, 1],
            ],
            dtype=float,
        )
        self.H = np.array(
            [
                [1, 0, 0, 0],
                [0, 1, 0, 0],
            ],
            dtype=float,
        )
        dt2, dt3, dt4 = dt**2, dt**3, dt**4
        q = q_acc
        self.Q = q * np.array(
            [
                [dt4 / 4, 0, dt3 / 2, 0],
                [0, dt4 / 4, 0, dt3 / 2],
                [dt3 / 2, 0, dt2, 0],
                [0, dt3 / 2, 0, dt2],
            ]
        )
        self.R = np.eye(2) * r_pos
        self.P = np.eye(4) * p0

    def predict(self):
        self.x = self.A @ self.x
        self.P = self.A @ self.P @ self.A.T + self.Q

    def update(self, z):
        z = np.asarray(z, dtype=float).reshape(2, 1)
        y = z - self.H @ self.x
        S = self.H @ self.P @ self.H.T + self.R
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + K @ y
        eye = np.eye(4)
        self.P = (eye - K @ self.H) @ self.P
        return y, K

    def step(self, z):
        self.predict()
        return self.update(z)

	def demo_cv_tracking():
	    print()
	    print("=" * 60)
	    print("示例 1:二维 CV 跟踪,行人沿 x 匀速,中途加速")
	    print("=" * 60)
	    rng = np.random.default_rng(0)
	    dt = 0.05
	    kf = KalmanCV2D(dt=dt, q_acc=2.0, r_pos=0.04, p0=1.0)
	
	    truth = np.array([0.0, 0.0, 1.0, 0.0])  # px, py, vx, vy
	    kf.x[:, 0] = [0.0, 0.0, 0.0, 0.0]
	
	    print(f"{'t':>6} {'true_px':>8} {'meas_px':>8} {'est_px':>8} {'est_vx':>8}")
	    for i in range(40):
	        t = i * dt
	        if t > 1.0:
	            truth[2] = 2.5  # 突然加速
	        truth[0] += truth[2] * dt
	        truth[1] += truth[3] * dt
	        meas = truth[:2] + rng.normal(0.0, 0.2, size=2)
	        kf.step(meas)
	        if i % 5 == 0:
	            print(
	                f"{t:6.2f} {truth[0]:8.3f} {meas[0]:8.3f} "
	                f"{kf.x[0,0]:8.3f} {kf.x[2,0]:8.3f}"
	            )

6.2 恒定加速度

from __future__ import annotations

import numpy as np

class KalmanCA2D:
    """状态 [px, py, vx, vy, ax, ay],观测同样 6 维。"""

    def __init__(
        self,
        dt,
        e_p=0.25,
        e_q_pos=0.01,
        e_q_vel=0.05,
        e_q_acc=0.05,
        e_r_pos=0.04,
        e_r_vel=0.3,
        e_r_acc=0.6,
    ):
        self.dt = dt
        dt2 = 0.5 * dt * dt
        self.A = np.array(
            [
                [1, 0, dt, 0, dt2, 0],
                [0, 1, 0, dt, 0, dt2],
                [0, 0, 1, 0, dt, 0],
                [0, 0, 0, 1, 0, dt],
                [0, 0, 0, 0, 1, 0],
                [0, 0, 0, 0, 0, 1],
            ],
            dtype=float,
        )
        self.H = np.eye(6)
        self.Q = np.diag([e_q_pos, e_q_pos, e_q_vel, e_q_vel, e_q_acc, e_q_acc])
        self.R = np.diag([e_r_pos, e_r_pos, e_r_vel, e_r_vel, e_r_acc, e_r_acc])
        self.P = np.eye(6) * e_p
        self.x = np.zeros((6, 1))

    def setup_from_box(self, px, py):
        self.x[:, 0] = [px, py, 0, 0, 0, 0]

    def estimate(self, z):
        self.x = self.A @ self.x
        self.P = self.A @ self.P @ self.A.T + self.Q
        z = np.asarray(z, dtype=float).reshape(6, 1)
        S = self.R + self.H @ self.P @ self.H.T
        K = self.P @ self.H.T @ np.linalg.inv(S)
        self.x = self.x + K @ (z - self.H @ self.x)
        self.P = (np.eye(6) - K @ self.H) @ self.P
        return self.x.copy(), K

	def demo_ca():
	    print()
	    print("=" * 60)
	    print("示例 2:6 维 CA,用历史帧差分造观测")
	    print("=" * 60)
	    rng = np.random.default_rng(1)
	    dt = 0.033
	    k_avg = 10
	    kf = KalmanCA2D(dt=dt)
	    kf.setup_from_box(0.0, 0.0)
	
	    hist_px = [0.0]
	    hist_vx = [0.0]
	    px, vx, ax = 0.0, 0.0, 0.0
	
	    print(f"{'t':>6} {'true_vx':>8} {'meas_vx':>8} {'est_vx':>8} {'P_vx':>8}")
	    for i in range(80):
	        t = i * dt
	        ax = 1.2 if 0.8 < t < 1.6 else 0.0
	        vx += ax * dt
	        px += vx * dt
	        meas_px = px + rng.normal(0.0, 0.05)
	        hist_px.insert(0, meas_px)
	        k = min(k_avg, len(hist_px) - 1) or 1
	        meas_vx = (meas_px - hist_px[k]) / (dt * k)
	        meas_ax = (meas_vx - hist_vx[min(k, len(hist_vx) - 1)]) / (dt * k)
	        hist_vx.insert(0, meas_vx)
	
	        z = [meas_px, 0.0, meas_vx, 0.0, meas_ax, 0.0]
	        kf.estimate(z)
	        if i % 10 == 0:
	            print(
	                f"{t:6.2f} {vx:8.3f} {meas_vx:8.3f} "
	                f"{kf.x[2,0]:8.3f} {kf.P[2,2]:8.3f}"
	            )

7. 什么时候选卡尔曼滤波

标准卡尔曼滤波(KF)有两个假设,工程上非常重要,不满足就不要硬套KF:

  1. 系统是线性的:状态变化满足线性方程,即运动模型是线性的。
  2. 噪声是零均值高斯白噪声:过程噪声、观测噪声服从高斯分布,即观测模型是线性的。

机器人很多场景是非线性的(例如旋转、角度),这时候标准KF失效,需要EKF/UKF。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐