做移动机器人和目标跟踪这些年,最折腾我的一个数据问题就是:位置传感器和速度传感器,单独拿出来都有明显短板。UWB、视觉定位、GPS这类位置型传感器给的是绝对坐标,不漂移,但噪声大、帧率低,玩过的人都懂,它偶尔还能给你莫名跳一下;轮式编码器、IMU、多普勒雷达这类速度型传感器输出平滑,频率也高,可一旦积分成位置,漂移累积起来能让你怀疑人生。单个拎出来谁都不靠谱,两个结合倒是正好互补。
把它们在二维平面上融合起来,最经典的工程手段就是卡尔曼滤波(Kalman Filter),也就是这里要说的“二维卡尔曼滤波位置、速度融合”。这篇文章我先把建模思路讲透,为什么位置和速度能在同一个状态空间里互相修正,然后给出一套可以直接跑的Python实现,最后聊一聊在实际项目里调Q/R参数、处理跳变数据、应对时间戳不均匀这些教科书里不太会展开的坑。适合正在做定位、导航、目标跟踪,或者刚把卡尔曼滤波公式背下来但不知道如何落到二维场景的朋友。
1. 为什么位置和速度要“融合”,卡尔曼滤波解决的是什么问题
在做任何方案选型之前,先得想清楚一个问题:既然位置能测、速度也能测,为什么不直接把测量值拿来用,非要绕一圈做什么“融合”?
1.1 位置传感器和速度传感器的天然互补性
先说位置。UWB、视觉SLAM、GPS这类设备,输出的是一帧一帧的绝对坐标。它们的好处是误差有上界,不会随着运行时间越飘越远。但代价也很明显——单帧噪声常常是分米甚至米级的,而且刷新率有限,视觉定位在光照变化或者特征不足时还会出现跳变。如果你直接用原始位置做控制,机器人会抖得像得了帕金森。
再说速度。轮式编码器、IMU测速、光流这类设备,输出的速度信号短时内非常准,噪声小,频率可以做到几百赫兹。可它本质上是相对测量,你要是把它积分成位置,一个小小零偏经过几分钟就会变成肉眼可见的漂移。我见过一个项目,用了很贵的光电编码器,静态零偏才0.005m/s,积分三分钟位置也能漂出去快一米,在室内导航场景里直接不能用。
所以这两类传感器在信息论上是互补的:位置观测提供“绝对锚点”,校准掉速度积分的漂移;速度观测提供“高频平滑的先验”,把位置噪声压下来。关键是怎么在数学上把它们组合起来,而不是简单粗暴地“加权平均”。卡尔曼滤波干的就是这件事,而且它给出来的权重不是拍脑袋定的,而是每一帧根据当前噪声统计特性在线计算出来的。
1.2 卡尔曼滤波的本质:预测与更新的迭代博弈
很多教材喜欢从“最优线性估计”“最小方差估计”这些名词讲起,确实严谨,但不直观。我自己的理解是这样的:卡尔曼滤波本质上是在玩一个两方的博弈,一边是“运动模型预测”,另一边是“传感器观测”。
运动模型告诉你:上一秒你在这个位置、这个速度,按物理规律推算,这一秒你应该大概在哪。传感器观测告诉你:你测出来的位置/速度是多少。两边都有自己的误差——模型有过程噪声,传感器有观测噪声。卡尔曼滤波要做的事情就是根据两者的误差大小,动态决定到底信谁多一点。
这个“信谁多一点”的系数,就是卡尔曼增益K。每来一帧数据,它都会根据当前协方差矩阵重新算一遍。所以它虽然是迭代算法,但每一帧都在做最优加权,不是那种固定系数的低通滤波。
另一个关键点是,卡尔曼滤波估计的对象不是测量值本身,而是整个系统的“状态”。在我们的场景里,状态就是二维位置的px、py,以及二维速度的vx、vy。哪怕你只观测了位置,滤波器也会通过运动模型把速度“推断”出来,并且在下一帧用这个推断去平滑位置。这就是为什么它经常被用来从带噪位置观测中提取速度信息。
1.3 为什么是二维,为什么不是两个一维滤波器
这是初学者最容易问的问题:既然x轴和y轴看起来互不影响,那我写两个一维卡尔曼滤波不也一样吗?答案是:在x/y完全解耦、且都用同一个运动模型时,结果确实等价,但工程上我不建议这么干。
首先,二维状态空间模型用一个4x4的状态转移矩阵F就表达清楚了,代码结构非常统一。以后想扩展成匀加速模型(9维状态)、转弯模型(CTRV)、或者加上x/y方向的耦合项,你只需要改一个矩阵,而不是重写两套一维逻辑。
其次,二维场景下不同方向的噪声未必独立。典型例子是雷达的位置观测:极坐标系下距离误差和角度误差不相关,但转换到笛卡尔坐标系后,x、y方向的误差就是相关的了。这时候两个独立的一维滤波器会丢失掉这层相关性信息,估计结果就不再是最优的。
另外,实际运动本身也有耦合。比如一个机器人转弯时,vx和vy是协同变化的,用一个二维滤波器能够利用这种协同关系,输出更平滑的速度估计,两个一维滤波器做不到这点。所以别看“二维”这两个字容易让人懈怠,它背后是有实际物理意义的。
2. 二维卡尔曼滤波建模:状态方程、观测方程与噪声矩阵
搞懂原理之后,接下来就是把问题翻译成数学语言。这一节是整个实现的核心,也是后面所有代码的基础。
2.1 状态向量与匀速运动模型
在二维平面内做位置速度融合,最常用的状态向量是四维的:
x = [px, py, vx, vy]^T
其中px、py是x轴和y轴的位置,vx、vy是x轴和y轴的速度。选择“位置+速度”作为状态,意味着我们采用恒速模型(Constant Velocity,CV),即默认目标在短时间内匀速运动。
离散化之后,状态转移方程是:
x_{k} = F * x_{k-1} + w
其中F是4x4的状态转移矩阵,dt是采样时间间隔:
F = [1 0 dt 0] [0 1 0 dt] [0 0 1 0] [0 0 0 1]这个矩阵每一行的含义非常直观:新位置等于旧位置加上速度乘以时间;速度保持不变。w是过程噪声,代表匀速模型本身的误差——比如目标突然加速、转弯、或者被外力推了一下,这些都会让真实运动偏离我们的模型。
如果你觉得恒速模型太简单,目标机动性很强,可以把状态扩成九维,加上加速度项。但我的建议是先从四维版本做起。九维状态虽然看起来更“高级”,但参数多了之后调起来极其痛苦,而且对过程噪声非常敏感,新手很容易调出病态矩阵。
2.2 过程噪声Q与观测噪声R的物理含义
卡尔曼滤波里有两个需要人为给定的噪声协方差矩阵:过程噪声协方差Q和观测噪声协方差R。这两个矩阵直接决定了滤波器的“性格”,也是调参时最核心、最玄学的部分。
Q描述的是“运动模型预测得有多不准”。它的物理来源可以理解为:目标有一个随机加速度,这个随机加速度的方差是sigma_a^2(二维场景下,每个方向各有一个)。从加速度扰动推导到状态空间的Q矩阵,需要引入一个噪声驱动矩阵G:
G = [0.5*dt^2 0 ] [0 0.5*dt^2] [dt 0 ] [0 dt ]那么Q = G * diag(sigma_ax^2, sigma_ay^2) * G^T,展开后的形式是:
Q = [0.25*dt^4*sa2 0 0.5*dt^3*sa2 0 ] [0 0.25*dt^4*sa2 0 0.5*dt^3*sa2] [0.5*dt^3*sa2 0 dt^2*sa2 0 ] [0 0.5*dt^3*sa2 0 dt^2*sa2 ]这里sa2是随机加速度方差的简写。可以看到,Q的非对角项并不为0,这说明过程噪声对位置和速度之间是有关联影响的——随机加速度同时扰动位置和速度。很多简化实现直接把Q设成对角矩阵,也能工作,但在机动较大的场景下误差会比完整形式大一些。
R描述的是“传感器测量得有多不准”。它同样是个协方差矩阵,如果是只观测位置的方案,R就是2x2的对角矩阵,对角线元素分别是x、y方向位置测量的方差。如果再加上速度观测,R就变成4x4矩阵。
把握好Q/R的相对大小,就掌握了卡尔曼滤波的脾气。Q相对R越大,说明你越不信任运动模型、越相信测量值,滤波结果会更“跟手”但也更吵;反过来,Q相对R越小,滤波结果越平滑,但滞后也更明显。
2.3 两种观测方案:只测位置,还是位置速度都测
实际工程里,“位置、速度融合”有两种典型的观测配置,代码几乎一样,但物理意义不同,我建议先搞清楚再动手。
方案A:只观测位置。观测矩阵H是2x4:
H = [1 0 0 0] [0 1 0 0]观测向量z就是位置测量值[zm_px, zm_py]。这种情况下,速度不是直接“测”出来的,而是滤波器根据位置差分和匀速模型“估计”出来的。好处是传感器简单,坏处是速度输出会有一定的滞后,尤其是在目标突然变速的时候。
方案B:位置和速度都观测。观测矩阵H是4x4单位阵:
H = [1 0 0 0] [0 1 0 0] [0 0 1 0] [0 0 0 1]观测向量z同时包含位置测量和速度测量。这就是真正意义上的“多源融合”了:位置传感器提供绝对坐标,速度传感器提供高频相对速度,两者在卡尔曼滤波框架下互相校验。我实际做AGV时最喜欢这种方案,因为轮式编码器给的速度非常稳,相当于给了滤波器一个很强的先验,位置输出既平滑又不怎么滞后。
当然,方案B的前提是速度传感器的测量坐标系和位置传感器的坐标系对齐了。如果编码器装在驱动轮上而UWB标签装在车头,两者之间存在一个杆臂效应(lever-arm),融合前最好做一步外参补偿,不然会出现固定偏差。
2.4 预测-更新两步循环的直觉理解
卡尔曼滤波的主循环只有两步,每来一帧观测就执行一次:
第一步预测:
x_pred = F * x P_pred = F * P * F^T + Q这步是在用运动模型推算当前状态,同时把不确定性P放大——因为过程噪声会随时间累积误差,协方差只增不减。
第二步更新:
y = z - H * x_pred S = H * P_pred * H^T + R K = P_pred * H^T * S^(-1) x_new = x_pred + K * y P_new = (I - K * H) * P_predy叫新息(innovation),就是“测量值比预测值多出来的那一截”。K是卡尔曼增益,它根据预测协方差和观测协方差算出权重。最终状态就是把预测位置往新息方向推K这么大一个比例,协方差相应缩小。
我曾经用一句话跟刚入行的人解释这个循环:预测往回抽,更新往里拉,两者一平衡,就得到了既平滑又不迟钝的估计。这句人话虽然不够严谨,但对理解“滤波”的感觉确实管用。
3. 完整可运行的二维位置速度融合实现(Python + numpy)
理论说太多容易飘,下面直接上一个可以跑起来的实现。我会用一个仿真场景模拟“UWB位置观测 + 轮式编码器速度观测”的AGV,然后用二维卡尔曼滤波把位置和速度融合起来。
3.1 仿真场景设计
假设一辆AGV在平面上运动,前10秒沿着x轴正方向以1m/s匀速前进,第10秒之后改成沿着y轴正方向以1m/s匀速前进。采样周期dt=0.1s,一共20秒,200个采样点。位置观测带0.15m的标准差噪声,另外人为加了几个2米左右的跳变点,模拟UWB偶尔的野值;速度观测带0.05m/s的标准差噪声,模拟编码器测速。
生成仿真数据:
import numpy as np dt = 0.1 t = np.arange(0, 20, dt) n = len(t) true_vx = np.zeros(n) true_vy = np.zeros(n) true_vx[t < 10] = 1.0 true_vy[t >= 10] = 1.0 true_px = np.cumsum(true_vx) * dt true_py = np.cumsum(true_vy) * dt rng = np.random.default_rng(42) # 位置观测:真值 + 高斯噪声 + 偶发跳变 z_pos = np.stack([ true_px + rng.normal(0, 0.15, n), true_py + rng.normal(0, 0.15, n) ], axis=1) jump_idx = rng.choice(n, 5, replace=False) z_pos[jump_idx] += rng.normal(0, 2.0, (len(jump_idx), 2)) # 速度观测:真值 + 高斯噪声 z_vel = np.stack([ true_vx + rng.normal(0, 0.05, n), true_vy + rng.normal(0, 0.05, n) ], axis=1)这种轨迹故意做了一个“直角转弯”,因为转弯意味着匀速模型短时间内完全不成立,正好用来考验滤波器的机动适应能力。如果你用这个仿真发现滤波结果在10秒附近有滞后,不要慌,那正是过程噪声Q应该发挥作用的地方。
3.2 滤波器初始化和参数设置
初始状态x0,我习惯用第一帧的位置观测来初始化,速度设为0。初始协方差P0可以直接给一个对角阵,对角线取一个较大的值来表示“对初始状态不太确定”。
Q矩阵按第2.2小节的加速度摄动模型计算。这个场景里AGV切换方向时等效加速度很猛,我取sigma_a = 2.0m/s^2,让滤波器在转弯处不至于追丢。R矩阵直接按仿真数据的真实噪声方差来设:位置方差0.15^2,速度方差0.05^2。
# 状态转移矩阵F F = np.array([ [1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1] ]) # 过程噪声Q(基于随机加速度模型) sa = 2.0 sa2 = sa * sa G = np.array([ [0.5 * dt * dt, 0], [0, 0.5 * dt * dt], [dt, 0], [0, dt] ]) Q = G @ np.diag([sa2, sa2]) @ G.T # 初始状态与协方差 x0 = np.array([z_pos[0, 0], z_pos[0, 1], 0.0, 0.0]) P0 = np.eye(4) P0[0, 0] = P0[1, 1] = 0.5 P0[2, 2] = P0[3, 3] = 1.0 # 观测矩阵:位置+速度都观测 H = np.eye(4) R = np.diag([0.15**2, 0.15**2, 0.05**2, 0.05**2])如果你只有位置传感器,就把H改成前面说的2x4矩阵,R也换成2x2矩阵。两种模式可以在代码里用参数切换,我实际项目里就是这么设计接口的。
3.3 核心滤波循环
滤波主体用一个循环就写完了。每帧先预测,再用当前观测做更新,把估计状态存到结果数组里:
x = x0.copy() P = P0.copy() x_est = np.zeros((4, n)) for i in range(n): # 预测 x_pred = F @ x P_pred = F @ P @ F.T + Q # 观测向量:位置+速度 z = np.concatenate([z_pos[i], z_vel[i]]) # 更新 y = z - H @ x_pred S = H @ P_pred @ H.T + R K = P_pred @ H.T @ np.linalg.inv(S) x = x_pred + K @ y P = (np.eye(4) - K @ H) @ P_pred x_est[:, i] = x这段代码核心就十来行,没有任何花哨的东西。跑完之后,x_est的第0、1行是融合后的位置,第2、3行是融合后的速度。如果你用matplotlib画出来,会看到位置曲线明显比原始观测平滑,速度曲线也把编码器的高频噪声削掉了不少。
3.4 结果分析:位置精度与速度平滑度的权衡
我拿这个仿真跑过很多次,最值得关注的现象有两个。
第一个是跳变点的处理。位置观测里那几个2米的跳变,如果直接拿来做控制,机器人会猛地抖一下。但卡尔曼滤波在更新时,因为R和P_pred的比值决定K的大小,单帧跳变对状态的拉动力是有限的,跳变点的影响基本被压了下来,轨迹依然平滑。这就是为什么我说卡尔曼滤波天然有抗野值能力——前提是跳变不能太频繁,否则协方差会被撑大,滤波就形同虚设了。
第二个是10秒转弯处的滞后。直角转弯对恒速模型来说是严重的模型失配,滤波输出在转弯处会有一个明显的过渡弧,位置贴着真值但速度会有一段“降不下来”的延迟。这是恒速模型的固有缺陷,想改善有两个办法:提高Q中的加速度方差,让滤波器更激进地跟随测量;或者把模型升级成匀加速模型/CTRV模型。工程上我会先试试提高Q,如果还不行再换模型。
如果你想把结果量化,可以算一下融合位置与真值的RMSE,以及融合速度与真值速度的RMSE,然后把它们和“直接用位置观测”“直接用速度观测”做对比。实测下来,在有跳变的情况下,融合位置精度比直接用位置观测通常会好一个数量级,速度也比直接用编码器数据更平稳、没有积分漂移。
4. Q/R参数整定与工程避坑经验
很多教程把Q/R当成一个要靠“调”解决的抽象参数,但我觉得在工程落地里,它们是有一套相对理性、可复现的确定方法的。
4.1 R矩阵用实测数据标定,不要拍脑袋
R矩阵应该来自传感器实测,而不是主观猜。做法很简单:把传感器放在静止状态下采集几百个点,然后统计测量值的标准差,平方之后就是方差,填进R矩阵就可以了。
举个例子,静止时UWB输出位置在±0.12m范围晃动,标准差约0.08m,那R里的位置方差就填0.0064左右;编码器静止时速度波动标准差约0.03m/s,速度方差就填0.0009左右。这个值不需要非常精确,量级对就行。我见过有人用协方差交叉验证来估算更精细的R,但在绝大多数AGV项目里,静态统计法已经绰绰有余了。
补充一句,如果传感器在不同方向上的噪声特性差异很大,比如雷达测距方向精度高、切向精度低,那R矩阵不能盲目设成各向同性,要把两个方向的方法差分别填进去。这也是我们坚持用矩阵而不是单一数字作为参数的原因。
4.2 Q矩阵用“最大加速度”来确定初值
Q矩阵没有传感器可以标定,因为它描述的是“你没建模的运动”,天然只能靠估。我的经验是:
- 先估计平台在正常工况下的最大加速度a_max
- 把随机加速度标准差sigma_a设成a_max的0.3到0.5倍
- 代入第2.2小节的公式算出Q
比如AGV正常启停最大加速度是1.5m/s^2,那sigma_a先取0.5,算出Q,跑起来看效果。如果位置跟踪显得迟钝、转弯处跟不上,就增大sigma_a;如果输出太吵、抖动明显,就减小sigma_a。
调参方向总结成一个表:
| 现象 | 处理方向 |
|---|---|
| 位置滞后明显、速度偏低 | 增大Q(更相信观测) |
| 噪声滤不干净、轨迹抖动 | 减小Q(更相信模型) |
| 快速转弯时跟不上 | 增大Q中的速度项 |
| 速度输出太毛躁 | 减小Q速度项,或增大R速度项 |
注意,调整Q和R本质上是在调“Q/R比值”,单独动一个不一定有意义。我通常保持R不变,只动Q,这样变量少,出了问题好定位。
4.3 跳变数据和离群点的处理
前面仿真里加了几个跳变点,卡尔曼滤波能扛住一部分。但如果跳变幅度特别大,或者连续几帧都在跳,单靠R的被动压制是不够的,必须在融合前加一道“野值检测”的闸门。
最常用的方法是基于新息向量的马氏距离检查。新息y = z - Hx_pred,它的协方差是S = HP_pred*H^T + R。如果当前观测是正常的,那么y^T * S^(-1) * y应该近似服从自由度等于观测维数的卡方分布。超过阈值(比如95%置信度)就认为这一帧是野值,选择跳过更新或者把R临时放大。
y = z - H @ x_pred S = H @ P_pred @ H.T + R d = y @ np.linalg.inv(S) @ y.T if d < 9.0: # 2自由度,95%置信度阈值约5.99;4自由度约9.49 x = x_pred + K @ y P = (np.eye(4) - K @ H) @ P_pred else: x = x_pred P = P_pred这种“预测照走、更新跳过”的处理方式,比直接把野值改成平均值要干净得多。注意阈值的选取要和观测自由度匹配,代码注释里我给了参考值。加了这个闸门之后,就算UWB偶尔连续跳两帧,轨迹也不会起飞。
4.4 时间戳不均匀与实时系统的适配
仿真里假设dt恒定是0.1秒,但真实系统里,传感器帧到来时间总有抖动。如果你还是用固定的F矩阵做预测,误差会随着时间慢慢累积,严重时整个滤波器都会变“迟钝”。
解决方法是:每一帧预测前,取当前时间戳和上一帧时间戳的实际差值dt_real,用它实时重建F矩阵和Q矩阵。写成代码就是:
dt_real = (timestamp_now - timestamp_prev) F = np.array([ [1, 0, dt_real, 0], [0, 1, 0, dt_real], [0, 0, 1, 0], [0, 0, 0, 1] ]) # 同样用dt_real重建G和Q这个方法几乎不增加计算量,却能显著提升真实环境下的稳定性。另外,二维场景下S矩阵最大也就4x4,求逆的开销非常小,完全没有必要为了性能去用什么SR-UKF之类的高级变体,普通Kalman在嵌入式上跑1000Hz都毫无压力。
5. 常见问题排查速查表与调试技巧
卡尔曼滤波在上手阶段看起来就十几个矩阵运算,但真跑起来问题往往很隐蔽。我把自己踩过的坑整理成一张速查表,每一条都是真实项目中碰到过的。
| 现象 | 可能原因 | 排查方向与解决 |
|---|---|---|
| 滤波结果比测量值滞后很多 | Q设得太小,或R设得太大 | 增大Q,减小R,让滤波器更信任观测 |
| 滤波输出抖得比原始测量还厉害 | R太小,或Q太大 | 增大R,减小Q,加强平滑 |
| 速度估计噪声大、忽大忽小 | 只测位置方案中R位置噪声过大 | 加测速度传感器,或增大R速度项 |
| 位置估计整体偏了一个固定值 | 传感器外参没标定,或初始状态没对准 | 检查坐标变换,重新对齐传感器,修正x0 |
| 滤波发散,估计值无穷大/NaN | P或Q不正定,或H、F矩阵维度配错 | 检查矩阵是否正定,打印每一帧P/Q的特征值,先让仿真跑通再上真机 |
| 快速转弯时跟踪丢失,拉不回来 | 匀速模型失真,Q太小 | 增大sigma_a,或者升级匀加速/CTRV模型 |
| 初始化阶段前几帧乱跳 | P0设得太小,初始状态不准 | 把P0调大,让滤波器前几帧快速收敛 |
排查的时候我有个笨办法:把所有中间量全部打印出来——预测值、新息、增益、协方差特征值。因为卡尔曼滤波的循环是线性的,哪里不对劲几乎一定能从某个矩阵的数值异常里看出端倪。比如新息序列如果长期单边偏移,说明运动模型和真实运动有系统偏差,这时候去调Q是治标不治本,得检查是不是模型本身选错了。
再分享一个调试顺序:先跑“只测位置”的模式,确认位置估计没问题,再加速度观测。一上来就用四维完整观测,万一把两个传感器的坐标系搞反了,你只会看到一个“看起来好像没问题但细节全是错”的结果,排查起来特别痛苦。
6. 写在最后:项目落地过程中的几点体会
做二维卡尔曼滤波位置速度融合这个模块,前后我经手过好几轮,最大的感受是:真正的难点从来不在公式推导,而在工程细节。R矩阵用实测方差填,Q矩阵用最大加速度定初值,野值用马氏距离门控,时间戳用实际dt重建矩阵——这四个步骤做好了,滤波器基本不会翻车。
还有一点个人建议:刚开始做的时候别追求“高大上”,把四维恒速模型调通、画好轨迹和残差图、理解每一步在干什么,比直接上什么自适应卡尔曼、无迹卡尔曼重要得多。我见过太多人一上来就上个扩展卡尔曼,结果最后连Q和R的物理含义都没搞清楚,出了问题完全不知道怎么排查。基础版本跑通之后,你会发现后面换模型只是改矩阵的事。
如果你后续想把这套代码接到自己项目里,可以按这个思路扩展:把传感器观测封装成独立接口,位置和速度分别进队,卡尔曼核心只处理矩阵运算,参数全部放到配置文件里。这样不管是换一个位置传感器、还是多一路速度测量,都不需要动核心算法,改配置就行。二维卡尔曼滤波在一个平面上做位置速度融合,这条路走通之后,三维空间里的融合,原理上也就是把矩阵维度往上扩一扩而已。