卡尔曼滤波单目标跟踪:遮挡重叠判定与状态机实践
简介:面向计算机视觉学习者的单目标跟踪实战项目,聚焦遮挡与重叠场景下跟踪易丢失的问题,以卡尔曼滤波结合最大IOU匹配构建跟踪器,适合具备Python与OpenCV基础、希望理解传统滤波与检测结果如何配合的中级读者。压缩包共338个文件,约25.87MB,以327个txt标签文件为主体,另含2个Python脚本、4个xml配置、1段mp4测试视频及少量工程配置,分别用于目标标注、算法实现与效果验证。已有514人学习下载。核心实现位于Kalman_Tracker.py与utils.py:前者封装滤波器初始化、状态转移与测量矩阵设置及预测—更新流程,后者提供图像处理、IOU计算等辅助函数。配合测试视频与逐帧标签,可跑通检测、匹配、滤波修正的链路,观察遮挡后目标重捕与轨迹平滑效果,并据此调整噪声参数、理解运动模型对稳定性的影响;目录按算法、工具、标签与测试素材分层,便于按模块查阅与复现。
1. 卡尔曼滤波跟踪器:遮挡时反而稳,重叠时才最容易翻车
直觉上做单目标跟踪,最怕的是目标被挡得一点不剩,画面里明明没东西了还得硬猜它的位置。真正跑过视频就会发现反着来:完全遮挡时检测器给不出框,跟踪器只靠运动模型外推,轨迹反而平滑;最容易出问题的是目标与另一个物体部分重叠——观测一直在,只是这个观测被邻近物体污染了,卡尔曼滤波照单全收,状态就被一点点带偏,最后要么框慢慢变大,要么身份跳到旁边那个人身上。
这份基于卡尔曼滤波的遮挡与重叠单目标跟踪器,解决的就是这件事:它把「看得见」和「看不见」明确区分开,用一套状态机加双通道判据,决定当前这一帧该信运动模型,还是该信检测框。想搞懂它,关键不在记住公式,而在理解协方差到底在表达什么,以及遮挡、重叠两类误判是怎么被分开处理的。
2. 状态空间:卡尔曼滤波凭什么能「猜」出被挡住的目标
遮挡下能继续跟踪,靠的不是滤波器的某种特殊能力,而是它天生维护了两样东西:一个状态估计,和一个关于这个估计有多不可信的协方差。前者告诉你目标在哪,后者告诉你这个「在哪」大概能错多少。理解这两者的分工,后面所有的遮挡判定才讲得通。
2.1 状态向量的选择:为什么是中心点加宽高,而不是左上右下
常见实现里,状态向量取 8 维:
x = [cx, cy, w, h, vx, vy, vw, vh]^T前四项是观测得到的,后四项是滤波器自己推出来的。也有 7 维版本,用长宽比 a 代替 w,靠w = a * h反算,本质是给宽高加了一个约束,因为大多数目标在跟踪过程中长宽比变化很小,把这一维固定住能减少一个噪声入口。
为什么不直接用检测框的[x1, y1, x2, y2]?因为左上右下这四个分量是强耦合的:目标一变大,x2 和 y2 同时变,观测噪声在四个分量之间产生了相关性,而工程实现里的 Q 和 R 基本都是对角矩阵——也就是假设各维噪声独立。用角点坐标,这个假设直接不成立,滤波器的更新会变得神经质。换到中心点加宽高,位置平移只影响前两项,尺寸变化只影响后两项,解耦好做得多。
给宽高也配速度项,是为了应付遮挡期间目标靠近或远离摄像头造成的尺度变化。如果只对位置建模速度而把 w、h 当常量,遮挡十几帧之后预测框的大小会明显对不上。但宽高的速度项必须配一个远小于位置速度的 Q,否则它自己就会让框持续膨胀,这个问题在排查章节里还会专门讲。
2.2 预测步的物理含义:协方差为什么会随外推一起膨胀
预测步只有两行:
x' = F x P' = F P F^T + QF 就是匀速运动模型,对角线是 1,位置行在对应的速度列上有一个 1,表示cx' = cx + vx。这一步的物理含义很直白:把上一帧的位置按速度往前推一格。
真正值得看的是第二行。协方差不是简单地复制过去,而是被 F 同时从左右两边乘了一遍,再叠加 Q。这个双侧变换的效果是——速度的方差会被积分进位置的方差里。举一个标量例子:位置方差 σ²_p,速度方差 σ²_v,外推一帧之后位置方差约等于 σ²_p + σ²_v。外推的帧数越多,位置方差增长得越厉害,因为速度本身有误差,推得越远,偏得越多。
这就是遮挡期间滤波器行为的内在逻辑:每预测一帧,P 就大一圈,滤波器对自己的估计越来越不自信。等目标重新出现,观测一进来,卡尔曼增益会因为 P' 变大而变大,状态会被迅速拉回观测值——不需要你手动写「遮挡结束后重新初始化」,机制自带这个效果。
Q 在这里不是抽象的「过程噪声」,它就是「匀速假设有多不靠谱」的量化。目标在转弯、急停、被遮挡物推着偏移时,匀速模型的误差全都落进 Q。Q 给太小,滤波器对自己的外推过分自信,恢复观测时会迟钝;给太大,预测帧之间又抖得厉害。
2.3 更新步的增益直觉:先验越不确定,越该听观测的
更新步的核心是卡尔曼增益:
K = P' H^T (H P' H^T + R)^-1如果只取位置这一维来看,公式退化成标量形式:
K = σ²_pred / (σ²_pred + σ²_obs)这个式子说穿了就是「按不确定度做加权平均」。σ²_pred 大(预测不可信),K 接近 1,输出贴近观测;σ²_obs 大(检测器不可信),K 接近 0,输出贴近预测。卡尔曼滤波在单次更新层面的全部含义,就是这个权重,没什么玄学成分,也不是什么神秘算法。
遮挡场景里常用的一个技巧就是动 R:一旦进入疑似遮挡状态,把 R 乘上一个放大系数,等价于压低 K,让状态更多地跟随预测而不是那个可能被污染的检测框。反过来,目标刚被重新捕获、外观匹配得分很高的时候,把 R 调小,让它快速收敛到新位置。
新息y = z - H x'和它的协方差S = H P' H^T + R也是在这条链路上算出来的,它们在后面会扮演一个重要角色:不只是判断「匹配得好不好」,还要判断「这个距离在统计上合不合理」。
3. 遮挡与重叠的判定机制:几何、外观、运动三者的分工
判断当前帧的检测框该不该被接受,只靠一个 IoU 阈值是撑不住的。遮挡和重叠是两类不同的问题,前者表现为「观测消失或严重变形」,后者表现为「观测存在但可能属于别人」,用同一把尺子量,必然在两边都失准。
3.1 IoU 判据的失效边界:重叠不等于遮挡
IoU 是个纯几何量,它只回答「两个框重合多少」,不回答「这两个框是不是同一个目标」。这个天花板决定了两类失效。
第一类是部分遮挡下的漏匹配。目标被人挡住一半,检测器往往只给出可见部分的框,尺寸被裁小,和运动模型预测出的完整框之间 IoU 可能掉到 0.3 以下。此时预测框其实是对的,但按固定阈值 0.5 走,这次匹配就被拒了,跟踪器误以为目标丢失。
第二类是重叠下的错匹配。两个目标互相靠近,跟踪器预测的位置有偏差,而邻居的检测框恰好落在预测位置附近,IoU 很高。这时匹配成功,但身份错了,表现为 ID 跳变。
这两类失效的共同点在于:IoU 没有用到任何关于时间的信息,也没有用到任何关于外观的信息。它只能做粗筛,用来快速排除一批明显不可能的候选,最后的判决必须交给别的判据。
3.2 马氏距离门控:用新息协方差给运动一致性「定标」
马氏距离解决的是「这个位置偏差算不算大」这个相对性问题:
d² = y^T S^-1 y, 其中 S = H P' H^T + R和欧氏距离的区别在于分母。S 是预测的不确定度加上观测的不确定度,预测越飘、S 越大,同样大小的位置偏差算出来的 d² 越小。这意味着门控是自适应的:遮挡刚开始,P 还不大,门就窄,只接受很近的检测;遮挡了几十帧,P 已经胀开,门自动放宽,允许更大的位置跳变被接受。
这个阈值的取值也不是凭感觉。在观测维度为 4 的情况下,d² 近似服从自由度为 4 的卡方分布,95% 分位点大约是 9.49,99% 分位点大约是 13.3。用这个值当门控上限,含义是「只接受统计上不太可能是巧合的匹配」,比手工调一个 0.3 或 0.5 的 IoU 阈值有依据得多。
外观通道则在另一个维度上补足身份信息。常见的实现思路是用颜色直方图或者轻量的深度特征,算余弦距离,只在通过了运动门控的候选集上比较。代价很低,但恰好能挡住「几何上很像、但根本不是同一个东西」的情况,而这正是重叠场景最典型的误判来源。
3.3 遮挡状态机:四个状态之间的迁移条件
把所有判据串起来的是一个显式状态机,它的价值在于让参数切换变得可见——不同状态下 Q、R、外观权重都不同,而不是一套固定参数勉强应付所有情形。
| 状态 | 进入条件 | 该状态下做什么 | 退出条件 |
|---|---|---|---|
| TRACKING | 连续匹配成功 | 正常预测更新,Q、R 取标称值 | 连续 N 帧无匹配 |
| OCCLUDED | 连续 N 帧无匹配,但位置仍在本帧画面内 | 只预测不更新,Q 逐步放大,R 同步放大 | 重新匹配成功则回 TRACKING;超出最大容忍帧数则进 LOST |
| LOST | 超过最大容忍帧数或预测框出界 | 停止更新,缓存最后一次可靠的外观特征 | 全局重检测命中且外观得分过阈值 |
| REID | LOST 状态下找到候选 | 高外观权重、低运动权重地确认身份 | 连续 K 帧确认后才回 TRACKING,否则回 LOST |
这张表里有几个设计取舍值得注意。OCCLUDED 和 LOST 分开,是因为「暂时看不见」和「已经不知道去哪了」的处理代价差很多:前者只需要外推,后者要跑全局重检测,开销高一个量级。REID 单独成一个状态并要求连续 K 帧确认,是为了防止一次错误的全局匹配直接把身份改掉——这种错误一旦发生,后面整段轨迹都是错的,且没有后悔药。
4. 一帧跟踪的计算链路:从检测框到状态更新
单看每一步都不复杂,但工程上真正决定效果的是这些步骤怎么排、门控加在哪、参数怎么标定。下面按一帧数据的流入顺序走一遍。
4.1 数据关联:代价矩阵怎么拼三项代价
每一帧拿到检测框集合之后,先和所有活跃轨迹算代价矩阵。一个稳健的拼法是三项加权,同时把运动门控做成硬性约束:
import numpy as np from scipy.optimize import linear_sum_assignment def build_cost(tracks, dets, w_iou=0.3, w_maha=0.4, w_app=0.3, chi2_gate=13.3): cost = np.zeros((len(tracks), len(dets))) for i, t in enumerate(tracks): for j, d in enumerate(dets): iou = 1.0 - t.iou(d) # 几何项 d2 = t.mahalanobis(d) # 新息的马氏距离 app = 1.0 - t.appearance_sim(d) # 外观余弦距离 cost[i, j] = w_iou * iou + w_maha * min(d2, chi2_gate) / chi2_gate \ + w_app * app if d2 > chi2_gate: # 硬门控:统计上不可能 cost[i, j] = 1e5 return cost cost = build_cost(tracks, dets) rows, cols = linear_sum_assignment(cost) # 全局最优,不是贪心三项代价分别对应三类信息:IoU 做粗筛和几何定位,马氏距离做运动一致性,外观距离做身份判别。权重分配没有普适值,遮挡场景通常把 w_maha 调高一些,因为遮挡时 IoU 不可靠而运动模型最稳;重叠场景里则要抬高 w_app。
匈牙利算法求全局最优而不是逐个贪心匹配,在密集场景下差别很大。贪心匹配在前一个目标抢走了正确的检测框之后,后面的目标就只好退而求其次,错误会被连锁放大。
chi2_gate这里取的是 4 维观测 99% 分位点,取消匹配时可以直接把这两行两列删掉。
4.2 预测与更新的源码级实现
import numpy as np class KalmanBoxTracker: """状态向量 x = [cx, cy, w, h, vx, vy, vw, vh]^T""" def __init__(self, box, q_scale=1.0, r_scale=1.0): cx, cy, w, h = box self.x = np.array([cx, cy, w, h, 0, 0, 0, 0], dtype=float).reshape(8, 1) # 匀速模型:位置 = 位置 + 速度 self.F = np.eye(8) for i in range(4): self.F[i, i + 4] = 1.0 # 观测矩阵:只观测得到前 4 维 self.H = np.eye(4, 8) # 过程噪声:宽高及其速度的不确定性远小于中心点和它的速度 self.Q = np.diag([1.0, 1.0, 0.1, 0.1, 1e-2, 1e-2, 1e-4, 1e-4]) * q_scale self.R = np.diag([1.0, 1.0, 1.0, 1.0]) * r_scale self.P = np.eye(8) * 10.0 # 初始不确定度 def predict(self, q_boost=1.0): self.x = self.F @ self.x # 遮挡时放大 Q:明确告诉滤波器「匀速假设别太自信」 self.P = self.F @ self.P @ self.F.T + self.Q * q_boost # 尺寸不允许出现非正数,否则框会退化成一条线 self.x[2] = max(self.x[2], 1e-3) self.x[3] = max(self.x[3], 1e-3) return self.x[:4].flatten() def update(self, z, r_boost=1.0): z = np.asarray(z, dtype=float).reshape(4, 1) R = self.R * r_boost S = self.H @ self.P @ self.H.T + R # 新息协方差 K = self.P @ self.H.T @ np.linalg.inv(S) # 卡尔曼增益 y = z - self.H @ self.x # 新息 self.x = self.x + K @ y # Joseph 形式:即使 K 因数值误差略有偏差,也能保持 P 对称正定 I_KH = np.eye(8) - K @ self.H self.P = I_KH @ self.P @ I_KH.T + K @ R @ K.T return float(y.T @ np.linalg.inv(S) @ y) # 返回 d²,供门控使用几个参数的含义和改动方式:
q_boost只在 OCCLUDED 状态下大于 1,比如取 1.05 到 1.2,随遮挡帧数递增。它的作用是让 P 膨胀得更快,门控随之放宽,避免目标恢复时因为门太窄而拒掉正确检测。
r_boost的使用方向反过来——遮挡期间把它调大(比如 3 到 5),让更新更弱。判断依据是外观得分:得分低于阈值的检测框按比例放大 R,而不是一刀切拒掉,避免丢失弱观测。
P的初值给 10 是「新轨迹一开始完全不可信」的表示,前几帧会被观测快速拉下来。如果检测器很稳,这个值可以降到 1 左右,新轨迹的收敛会更快,代价是一次误检就能带出几帧的抖动。
update返回的 d² 不是副产品,它是关联阶段做门控的输入,也是后面做参数标定的观测指标。
4.3 噪声标定:Q 与 R 怎么调,用 NIS 验证
Q 和 R 的调参经常被当成玄学,其实有可量化的做法。思路是先分离两个噪声源,再回头看统计一致性。
| 参数 | 物理含义 | 典型量级 | 调大后的直接后果 |
|---|---|---|---|
| Q 位置项 | 每秒位置的不确定性 | 1e-2 ~ 1 | 预测更灵活,但帧间抖动变大 |
| Q 尺寸项 | 尺度变化的不确定性 | 1e-2 ~ 1e-1 | 遮挡期间框的缩放更跟手,也更容易膨胀 |
| Q 速度项 | 运动突变的不确定性 | 1e-4 ~ 1e-2 | 对加速、急停响应更快 |
| R | 检测框本身的位置抖动 | 由检测器实测方差决定 | 输出更平滑,但跟随变慢 |
R 用最简单的方法就能标:拿一段目标匀速运动或者近似静止的片段,统计检测框中心的帧间方差,直接填进去。不要凭感觉写 1.0。
Q 更麻烦一些,因为它对应的是模型误差而不是测量误差。一个可行的做法是用轨迹的二阶差分估计加速度残差,再换算成过程噪声。
调完之后用 NIS(归一化新息平方)做一致性检验,这里的 NIS 就是update返回的那个 d²:
# 统计一段时间内的平均 NIS,观测维度为 4 nis_mean = np.mean(d2_history) print(nis_mean) # 理论期望值 ≈ 4这个指标的意义很明确:如果长期均值远大于 4,说明滤波器对自己的预测过分自信,真实残差比它以为的大——R 给太小或者 Q 给太小;如果远小于 4,说明滤波器过于保守,观测里其实还有信息没被利用。跑一段基准视频,平均 NIS 落在 3 到 5 之间,基本上就是标定到位了。这比盯着画面主观感觉「框抖不抖」靠谱得多。
5. 常见问题排查:框膨胀、ID 跳变与滤波器发散
5.1 遮挡几十帧后跟踪框越变越大
现象:目标被完全挡住之后,预测框每一帧都往外扩一点,恢复时已经比目标大出一圈。
原因:宽高的速度项vw、vh只要在上一次更新时是正数(哪怕只是检测框抖动带来的一点点正量),匀速模型就会让框持续放大;同时位置与尺寸的方差都在涨,恢复时的更新又不足以立刻把它拉回来。
解决:把 Q 中尺寸速度项压到位置速度的百分之一量级,并在 OCCLUDED 状态下给vw、vh加一个衰减系数(比如每帧乘 0.9);更直接的做法是遮挡期间把尺寸速度直接置零,只让中心点外推。宽度和高度的变化通常比整体平移慢,把这两项固定住,损失很小。
5.2 重叠恢复瞬间 ID 跳到邻居身上
现象:两个目标交错之后,跟踪框突然跳到旁边的目标上,此后一直跟着错的走。
原因:关联阶段只用了 IoU,重叠时邻居的检测框恰好和预测位置重合,几何代价最低;而外观特征要么没用,要么权重太低,挡不住这种误判。
解决:把外观距离加进代价矩阵,并在 REID 状态要求连续 K 帧确认;同时对刚刚从 OCCLUDED 恢复到 TRACKING 的那一帧,临时提高外观权重并收紧马氏门控。血泪经验是——恢复的第一帧宁可多等一帧,也不要仓促接受,一旦 ID 换错,后面整段都救不回来。
5.3 协方差矩阵失去正定性,更新直接发散
现象:跟踪框某一帧突然飞到画面外,或者数值变成 inf/nan,之后再也恢复不了。
原因:np.linalg.inv(S)在 S 接近奇异时数值极不稳定,浮点误差累积会让 P 失去对称正定性,增益 K 随之一塌糊涂;连续多帧无更新时 P 已经很大,逆运算更容易出问题。
解决:改用 Joseph 形式的更新(代码里那两行),它在数学上等价,但能保证 P 始终对称正定;每次更新后做一次P = (P + P.T) / 2的对称化;在求逆前给 S 的对角线加一个极小的 jitter(1e-9 量级)。这些看起来是防御性代码,但在长时间遮挡场景里能救回整条轨迹。
5.4 检测框的抖动被原样搬进跟踪结果
现象:目标静止时跟踪框一直小幅晃动,输出轨迹的平滑性还不如检测框本身。
原因:R 给得太小,滤波器认为观测几乎可信,K 接近 1,于是每次更新都在完整复制检测框的抖动;或者把 R 设成了各维相同的标量,而检测器的垂直方向抖动明显大于水平方向。
解决:用实测的检测框方差去填 R 的对角线,允许水平和垂直方向取不同值;如果检测器本身抖动较大,把 R 整体乘上 2 到 3 倍,靠 Q 保证跟随能力。验证方式还是回到 NIS——平均 NIS 明显小于 4,基本可以断定 R 给小了。
6. 设计取舍:为什么不用更深的外观模型替掉卡尔曼滤波
做到这里总会有人问:既然外观模型这么强,为什么不直接端到端训一个网络,把运动模型扔掉?
我的理解是这两者解决的不是同一个问题。深度外观模型判断的是「这个框里的东西像不像目标」,它对身份判别很强,但它不携带时间信息,也没有「我现在有多不确定」这个量。目标被完全遮挡的那几十帧里,外观模型拿不到任何输入,它能做的只有沉默;而卡尔曼滤波在同样的几十帧里持续输出一个带不确定度标注的位置估计,这个东西是状态机、门控、重捕获逻辑能运转下去的基础。把它理解成一块「带误差条的短期记忆」比理解成一个预测器更准确。
计算成本也是取舍的一部分。一次 8 维矩阵的预测更新,在现代 CPU 上是微秒级的事情,可以轻松跑到几百帧;而每帧多跑一次特征提取网络,开销可能高两个数量级。在遮挡频繁、场景不复杂的长视频上,纯运动模型的性价比往往更高。真正需要上外观模型的,是同类目标密集重叠、必须靠细节区分身份的场景。
验证这套机制是否有效,不能只看总体指标。整体 MOTA 或 HOTA 会把遮挡帧和平常帧混在一起平均掉,遮挡处理得好不好完全看不出来。比较实在的做法是按轨迹的遮挡时长分桶统计,单独看「连续无检测帧数大于 20」这个子集上的 ID 保持率和中心点误差;再配合前面提到的 NIS 曲线,看遮挡前后有没有出现统计上明显失衡的区段。这两条曲线一对,问题基本就定位了。
进阶方向上有几条路:把匀速模型换成 IMM,用多个运动模型并行跑加权,应付急转弯;或者用 UKF 处理非线性观测(比如极坐标下的检测输出);再进一步,把新息 y 和马氏距离 d² 作为特征喂给一个小网络,让滤波器自己学习什么时候该信观测。这些改动都建立在同一套状态空间和门控逻辑上,机制不变,只是把假设放松。
我最早做这类跟踪器时,吃过最大的亏是凭感觉设 R=1,结果在检测器换代之后整条链路全崩,调了两天才想起来回去测检测框的帧间方差。从那以后我每次接入新的检测器,都强制走一遍「实测 R → 跑 NIS 曲线 → 再调 Q」的流程,再也不敢跳步。希望帮到你。
本文还有配套的精品资源,点击获取