目标追踪是个看着简单、做着上头的问题。想象一个监控摄像头,一个人从画面左侧走到右侧,中间被柱子挡住两秒,再出现时,系统要能认出还是同一个人,把轨迹连续画出来。就这么个场景,背后其实压着两个问题:检测器负责回答"这一帧人框在哪",追踪器负责回答"这些框跨帧是不是同一个人"。卡尔曼滤波就是连接这两者的胶水,也是绝大多数多目标追踪算法里最实用的一块基石。这篇文章从卡尔曼滤波的数学原理,一路讲到行人目标追踪里真正能跑的代码链路和调参经验,适合刚接触目标追踪的开发者,也适合算法落地时被检测框抖动、跳变、ID切换问题反复折磨的工程师。
我当时刚把某个检测模型接到追踪流程里时,遇到的第一个现象是做出来的轨迹一抖一抖,人明明匀速走,出来的轨迹像心电图。后来静下心把卡尔曼滤波手推了一遍,才明白根源在于我没有给"运动模型"和"观测噪声"一个合理的描述。说到底,卡尔曼滤波不是玄学,它的每个参数都有物理含义,你要做的只是把"目标怎么运动"和"检测器有多不靠谱"这两件事翻译成矩阵和方差。
1. 行人追踪到底在解决什么问题
1.1 检测与追踪的分工差异
很多人刚接触追踪时容易有一个误解:以为追踪就是把检测框连起来。真把检测器输出连起来看会发现,同一帧里人一多,目标一重叠,跨帧配对根本对不上。检测器的输出是"这一帧图像里有哪些目标、每个目标在哪",它没有记忆,每一帧都是独立推理。追踪器要做的是在时间维度上把这些独立的框串成"人"的连续轨迹,本质上是跨帧的数据关联问题。
卡尔曼滤波在其中的位置很微妙。它不负责判断"这个框和那个框是不是同一个人",它只负责回答一个更基础的问题:假设我已经知道这个目标上一帧的位置和速度,那么在下一帧它最可能出现在哪里,以及这个预测有多可信。这个"预测框"可以大幅度缩小下一帧的匹配范围,让跨帧关联从"所有框之间任意组合"变成"预测框附近的局部匹配"。
1.2 为什么检测器输出需要滤波
检测器不是完美的。同一个静止行人,连续两帧的检测框可能相差好几个像素,甚至宽高都不一样。如果直接把检测框当轨迹输出,回放时就能看到明显的抖动和闪烁。卡尔曼滤波的价值在于,它把检测器的观测值和一个假设的运动模型做加权融合:运动模型说目标应该在这附近,检测器说目标在那边,两边都有一定误差,最终输出一个介于两者之间、并且更接近真实状态的位置。
这个融合逻辑在数学上有一个非常好的性质:当系统是线性的、噪声是高斯分布时,卡尔曼滤波是均方误差意义下的最优估计器。也就是说,在所有只用一阶矩和二阶矩的估计算法里,它已经做到极限了。实时追踪场景里,行人运动近似线性、噪声近似高斯,这恰好是卡尔曼滤波的主场,计算量又小,自然成了工业界的主流选择。
1.3 为什么不是粒子滤波或均值漂移
行内也常有人问,为什么不用粒子滤波。粒子滤波确实能处理非高斯、非线性场景,但它需要大量粒子逼近分布,计算量和维度呈指数增长,在一个多目标实时的监控系统里,几百个目标同时跑粒子滤波,帧率很难撑住。均值漂移这类方法又完全没有运动模型,对遮挡和快速运动非常不敏感。卡尔曼滤波在这两类方案之间取了一个极佳的平衡:有运动先验、有不确定性度量、计算量只和状态维度相关,常规的八维状态向量无非是几个矩阵乘法,CPU上跑几千个目标都没压力。
2. 卡尔曼滤波核心理论:五个公式背后的朴素逻辑
2.1 状态空间建模:把感觉翻译成数学
卡尔曼滤波的第一步是定义状态向量。目标追踪里最直观的建模方式,是把状态向量定义为检测框四个量加上对应的速度分量:
x = [x, y, w, h, vx, vy, vw, vh]^T
x和y是框的左上角坐标,w和h是宽高,后面四个是它们各自的速度。这样定义可以捕捉"目标位置变化"和"目标尺度变化"两个层面的运动趋势。状态转移矩阵F描述的是匀速运动假设下的递推关系,当相邻两帧时间间隔dt取1时,F就是一个左上角单位阵、右上角单位阵、左下角零阵、右下角单位阵组成的块矩阵,这个结构本质上是"新位置 = 旧位置 + 速度"。
观测矩阵H的作用是把状态向量映射到观测空间。我们观测到的是检测器输出的框 [x, y, w, h],所以H就是一个提取状态向量前四维的矩阵。到这里,模型已经把"目标在怎么运动"和"我们能观测到什么"表达成线性关系了。
2.2 预测步:闭着眼睛往前走
卡尔曼滤波每一轮迭代分两步。预测步解决的是"根据运动模型,先猜一波"。上一帧的后验状态经过状态转移矩阵F,得到当前帧的先验状态;同时误差协方差矩阵P也要经过一次传播,加上过程噪声Q。
x_prior = F * x_post
P_prior = F * P_post * F^T + Q
这里的P是状态估计的不确定性度量。预测之后不确定性一定变大,因为即使是匀速模型也有猜不准的地方。Q代表过程噪声,也就是"模型自身到底有多少偏差"。行人突然加速、突然转弯,这些运动模型没描述的部分,全部都靠Q吸收。Q太小,滤波器会盲目自信,真实目标转向时根本追不上;Q太大,运动模型等于没有约束,滤波退化成直接用观测值。
2.3 更新步:睁开眼修正猜测
更新步的逻辑是"看看观测值,再决定听谁的"。先算观测残差,也就是检测器实际观测值和我们预测值之间的差距。然后算卡尔曼增益K,这一步是精髓:增益的大小同时取决于预测的不确定性(P)和观测的不确定性(R)。
K = P_prior * H^T / (H * P_prior * H^T + R)
这个式子的直观理解是:预测越准、观测越不准,K就越小,我们越倾向于相信预测;反之预测越飘、观测越准,K就越大,我们越倾向于跟着观测走。最终的状态更新就是把K乘上残差,加到预测值上;同时误差协方差也要按比例收缩,表示融合之后不确定性降低了。
整个过程用一个生活类比:你在黑暗里凭记忆往前走,这是预测步;睁眼看到地标,这是观测;地标很清楚而记忆模糊,你就多信眼睛;记忆很清楚而地标模糊,你就多信记忆。卡尔曼增益就是那个"多信谁"的开关。
2.4 卡尔曼滤波的迭代闭环
把预测和更新放在一段完整循环里看:
1. 用运动模型预测当前帧状态 2. 用预测结果缩小匹配范围 3. 获得当前帧观测(检测框) 4. 计算残差和卡尔曼增益 5. 更新状态和协方差,进入下一帧这个结构看起来简单,却是实际代码里最容易写乱的地方。常见错误是把预测和更新的顺序搞反,或者把Q和R的维度定义错了。我建议初学者先在一维场景里把流程跑通,再去碰八维状态向量。
3. 从公式到代码:一个最小可复现的卡尔曼滤波器
3.1 一维匀速运动的最小演示
先看一个最简单的一维例子,状态只有位置和速度。用Python和NumPy实现,代码很短,但能完整展示K和Q、R的关系。
import numpy as np # 状态: [位置, 速度] x = np.array([0.0, 1.0]).reshape(2, 1) # 状态转移矩阵 dt=1 的匀速模型 F = np.array([[1, 1], [0, 1]], dtype=np.float32) # 观测矩阵:只能直接观测位置 H = np.array([[1, 0]], dtype=np.float32) # 过程噪声协方差 Q = np.array([[0.1, 0], [0, 0.1]], dtype=np.float32) # 观测噪声方差(这里只有一维观测) R = np.array([[1.0]], dtype=np.float32) # 初始误差协方差,设大一些,快速收敛 P = np.eye(2, dtype=np.float32) * 100 def predict(): global x, P x = F @ x P = F @ P @ F.T + Q def update(z): global x, P S = H @ P @ H.T + R K = P @ H.T @ np.linalg.inv(S) y = z - H @ x x = x + K @ y P = (np.eye(2) - K @ H) @ P # 模拟观测序列:真实位置 0, 1, 2, 3, ... 加上高斯噪声 for i in range(10): predict() z = np.array([[i + 0.2 * np.sin(i)]]) # 模拟带噪声观测 update(z) print(i, x.ravel())这个demo跑出来的轨迹会让你直观感受到:滤波输出比原始观测平滑很多,且观测噪声越大,输出曲线越"钝"。建议你自己改一改R和Q的数值,观察轨迹的响应速度和光滑度变化,这是理解卡尔曼滤波最快的方式。
3.2 检测框版的卡尔曼滤波类
一维demo跑通后,直接上检测框版本。状态向量取八维 [x, y, w, h, vx, vy, vw, vh],封装成类方便追踪器调用。
class KalmanBoxTracker: def __init__(self, bbox): # 初始状态:位置用检测框填充,速度置0 self.x = np.zeros((8, 1), dtype=np.float32) self.x[:4] = np.array(bbox, dtype=np.float32).reshape(4, 1) # 状态转移矩阵,dt=1 self.F = np.array([ [1, 0, 0, 0, 1, 0, 0, 0], [0, 1, 0, 0, 0, 1, 0, 0], [0, 0, 1, 0, 0, 0, 1, 0], [0, 0, 0, 1, 0, 0, 0, 1], [0, 0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 1, 0, 0], [0, 0, 0, 0, 0, 0, 1, 0], [0, 0, 0, 0, 0, 0, 0, 1], ], dtype=np.float32) # 观测矩阵:提取位置部分 self.H = np.zeros((4, 8), dtype=np.float32) self.H[:4, :4] = np.eye(4, dtype=np.float32) # 过程噪声:前4维位置,后4维速度,量级不同 self.Q = np.eye(8, dtype=np.float32) self.Q[:4, :4] *= 0.01 self.Q[4:, 4:] *= 0.1 # 观测噪声:检测器框的抖动程度 self.R = np.eye(4, dtype=np.float32) * 10 # 初始协方差:给大一些,让滤波器快速收敛 self.P = np.eye(8, dtype=np.float32) * 100 self.last_bbox = bbox def predict(self): self.x = self.F @ self.x self.P = self.F @ self.P @ self.F.T + self.Q return self.get_bbox() def update(self, bbox): z = np.array(bbox, dtype=np.float32).reshape(4, 1) S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) y = z - self.H @ self.x self.x = self.x + K @ y self.P = (np.eye(8, dtype=np.float32) - K @ self.H) @ self.P self.last_bbox = self.get_bbox() def get_bbox(self): return self.x[:4].flatten().astype(int)这个类已经可以直接嵌入一段追踪循环。需要强调的是,速度分量的初始值是0,这会带来一个冷启动问题:跟踪目标刚建立时,前几帧的预测会偏向上一时刻位置,如果检测框本身在快速运动,可能跟不上。解决办法是把初始P设大,让滤波器通过观测快速修正速度估计,这也是P初始为100的原因。
3.3 一个值得注意的细节:宽高比建模
用 [x, y, w, h] 直接建模有一个隐性缺陷:它假设宽和高是独立变化的。实际上行人的宽高比基本稳定,行走过程中高度变化可能大,宽度变化却很小。更好的做法是用中心坐标加宽高比的结构:状态取 [cx, cy, s, r, vcx, vcy, vs, vr],其中s是框面积,r是宽高比。这样建模后,宽和高的耦合关系被显式地放进状态里,滤波输出更稳定,也更能抵抗检测框宽高抖动。
两种建模方式的取舍可以这么理解:直接框坐标的好处是直观、容易调,缺点是滤波器会跟着检测框的宽高抖动做无用功;中心加宽高比的方案稍绕,但模型假设更贴合行人的物理特征,实测在多目标场景里ID切换更少。做工程落地,我更推荐后者,DeepSORT等主流算法也沿用这个设计。
4. 行人追踪实战:检测框、数据关联、轨迹管理
4.1 从检测框到运动状态:完整链路
追踪系统的输入是视频流加检测器输出的每帧检测框。检测框格式常用 [x1, y1, x2, y2] 或 [x, y, w, h],我们把它转成卡尔曼滤波需要的观测向量。整个链路按帧推进:
每一帧先拿到检测器输出,然后对所有已存在的轨迹做卡尔曼预测,得到预测框。预测框和检测框之间算IoU矩阵,通过匈牙利算法求最大匹配。匹配上的轨迹用检测框更新;没匹配上的检测框新建轨迹;没匹配上的轨迹标记一次未命中。最后清理长期未命中和尚未确认的轨迹。
这个流程就是SORT算法的骨架,在行人追踪场景里非常能打。
4.2 数据关联:IoU矩阵与匈牙利算法
IoU是检测框匹配的常用指标。两个框重叠率越高,是同一个目标的可能性越大。计算IoU本身很简单,但要注意处理好框的坐标边界:
def iou(bbox1, bbox2): x1_min, y1_min, x1_max, y1_max = bbox1 x2_min, y2_min, x2_max, y2_max = bbox2 inter_xmin = max(x1_min, x2_min) inter_ymin = max(y1_min, y2_min) inter_xmax = min(x1_max, x2_max) inter_ymax = min(y1_max, y2_max) inter = max(0, inter_xmax - inter_xmin) * max(0, inter_ymax - inter_ymin) area1 = (x1_max - x1_min) * (y1_max - y1_min) area2 = (x2_max - x2_min) * (y2_max - y2_min) union = area1 + area2 - inter return inter / union if union > 0 else 0所有轨迹预测框和所有检测框两两算IoU,组成一个形状为 [轨迹数, 检测数] 的代价矩阵。匈牙利算法的目标是找到"全局最优"的匹配组合,让匹配上的代价总和最小。在Python里直接用scipy的linear_sum_assignment就能完成:
from scipy.optimize import linear_sum_assignment # iou_matrix: [num_tracks, num_detections] cost_matrix = 1 - iou_matrix row_ind, col_ind = linear_sum_assignment(cost_matrix)匹配完成后,还需要一个IoU阈值过滤掉明显不合理的匹配。比如两个框的IoU只有0.05,就算匈牙利算法硬分配到了一起,实际也不可能是一个目标。常见的阈值取0.3左右。这里的经验是:检测器质量高、遮挡少,阈值可以适当提高;遮挡频繁的场景,阈值压低一些,宁可让匹配不那么确定,也要防止轨迹被错误分配。
4.3 轨迹生命周期:三态管理
目标追踪不是简单的一对一匹配,目标会进来、会出去、会被遮挡。所以每条轨迹要有自己的状态机。
通常分三态:未确认、已确认、已删除。新目标检测到后先进入未确认状态,此时不能直接输出轨迹,避免检测器误报产生大量假轨迹。只有当这条轨迹连续若干帧都匹配上检测框,才升级为已确认。反之,如果未确认状态下连续几帧匹配不上,直接删除。已确认轨迹在某一帧没有找到匹配的检测框,先不急着删,允许它继续存在一段时间,因为目标可能被遮挡了。每多一帧没匹配上,未命中计数加一,超过阈值就标记为消失。
这里有一个工程上很关键的细节:遮挡期间,轨迹的卡尔曼滤波器还在持续工作,它每帧都在用运动模型预测目标位置,只是没有观测值更新。实际情况是,预测误差会随着遮挡时间快速积累,行人转弯、加速等行为都会让预测框越漂越远。所以max_age这个阈值不能设太大,一般场景10到30帧是合理范围。
4.4 追踪主循环的核心逻辑
把卡尔曼滤波、数据关联、轨迹管理串起来,主循环大致如下:
for frame_idx, frame in enumerate(video_frames): detections = detector(frame) # 检测器输出 # 1. 对每条轨迹做卡尔曼预测 pred_boxes = [track.kf.predict() for track in tracks] # 2. 计算IoU代价矩阵 iou_matrix = compute_iou_matrix(pred_boxes, detections) # 3. 匈牙利匹配 matched, unmatched_dets, unmatched_tracks = \ associate_detections(iou_matrix, iou_threshold=0.3) # 4. 匹配上的用检测框更新轨迹 for track_idx, det_idx in matched: tracks[track_idx].kf.update(detections[det_idx]) tracks[track_idx].miss_count = 0 # 5. 未匹配的检测框新建轨迹 for det_idx in unmatched_dets: tracks.append(KalmanBoxTracker(detections[det_idx])) # 6. 未匹配的轨迹更新未命中计数,超出阈值或未确认直接删除 for track_idx in unmatched_tracks: tracks[track_idx].miss_count += 1 if tracks[track_idx].miss_count > max_age or \ tracks[track_idx].confirm_count < min_trials: remove_track(tracks[track_idx])这个流程在行内已经被验证过很多次。要保持警惕的是:卡尔曼预测和更新必须在正确的位置调用,匹配之前预测,匹配之后更新。一旦顺序错了,预测框用的就不是上一帧的最新状态,整个追踪精度会明显下降。
5. 调参与踩坑:从跑通到稳定
5.1 噪声协方差矩阵Q和R的调参顺序
卡尔曼滤波最让人头疼的就是Q和R。我的建议是:先定R,再调Q。R代表观测噪声,也就是检测框自身的不确定性。你可以先统计一下检测器静止目标时的检测框抖动幅度,比如某个检测器对静止行人的框输出波动在正负5像素以内,那么R的位置部分可以取25左右,也就是方差为25平方像素。宽高部分通常会更大,因为检测框的尺度估计本身就比位置估计更不稳定,可以给到50到100的方差。
R确定以后,再调Q。Q是过程噪声,代表运动模型的偏差。一个实用的起点是让Q的量级是R的十分之一左右。Q设得小,滤波输出平滑,但追踪快速运动的目标会滞后;Q设得大,响应快,但抖动变大。实际调试中我习惯在画面上同时画出预测框、检测框和滤波输出框,观察三者偏差:如果滤波输出明显落后于检测框,说明运动模型预测太自信,适当增大Q;如果滤波输出在检测框附近来回抖动,说明观测噪声在Q里的权重被估计得太低了,适当减小Q或增大R。
5.2 检测器不稳定时的兜底策略
真实场景中检测器经常漏检、误检。漏检时卡尔曼滤波还能靠预测撑几帧,但误检会造成虚假轨迹。一个常见的踩坑点是把误检直接当成新轨迹创建,导致画面里出现一堆短暂闪烁的框。
对付误检的有效手段是提高轨迹确认门槛:新轨迹必须连续三帧以上匹配成功才输出。检测器偶尔跳帧导致的漏检,则可以通过在匹配阶段保留低IoU的候选匹配来兜底。还有一个经验是,对检测框做时域中值滤波,把单帧异常框先压下去再送入卡尔曼,这个操作对比直接调大R效果更直接。
5.3 行人遮挡、ID切换与静止目标
行人追踪最容易翻车的场景就是目标互相遮挡。参考SORT原论文的结论,卡尔曼滤波加IoU匹配,在行人密集场景里ID切换次数非常多,因为只靠位置信息没办法区分穿同样衣服的人。我在实际项目里的感受是,对于遮挡频繁的室内监控,光靠位置模型不够,必须引入外观特征,这也是DeepSORT要加ReID网络的原因。
静止目标则是另一个容易忽略的问题。摄像头静止时,行人坐下、站定,检测框几乎不动,但卡尔曼滤波的速度分量如果已经积累了一个非零值,预测框就会持续漂移。处理办法是检测速度分量的数值,长期低于某个阈值时,把速度状态强制归零,或者给运动模型增加一个静止偏置。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查与调整方案 |
|---|---|---|
| 轨迹剧烈抖动 | R设太小,滤波追上检测噪声 | 统计检测器抖动幅度,按实测值放大R |
| 目标转弯时被跟丢 | Q太小,运动模型过于自信 | 增大Q,尤其是速度分量对应的过程噪声 |
| 遮挡后轨迹换人 | 只有位置关联,没有外观特征 | 引入ReID特征,升级为DeepSORT策略 |
| 画面出现闪烁假轨迹 | 没有轨迹确认机制,误检直接出轨迹 | 提高连续确认帧数,删除未确认轨迹 |
| 静止目标轨迹漂移 | 速度状态没有归零机制 | 检测速度分量,长期低于阈值时强制归零 |
| 帧率不稳导致预测偏差 | 状态转移矩阵固定dt=1 | 接入时间戳,动态计算dt并调整F |
6. 扩展方向:从SORT到DeepSORT与非线性场景
6.1 卡尔曼滤波的局限在哪里
卡尔曼滤波在线性高斯假设下是最优的,但真实场景不可能完全线性。行人突然急停、跑步冲刺、镜头快速平移,这些情况下匀速模型都会失真。卡尔曼滤波不是不能处理,而是会让预测误差变大,表现就是追踪框在目标变化剧烈时反应迟钝。
如果只是轻度非线性,扩展卡尔曼滤波(EKF)通过泰勒展开把非线性函数一阶线性化,实现简单,但线性化误差可能在大曲率场景下失控。无迹卡尔曼滤波(UKF)用Sigma点传播分布,精度更高,计算量也大一些。对行人追踪这个场景,我个人建议先不要急着换算法,大部分问题靠调Q、增加状态维度、加入更丰富观测就能解决。
6.2 DeepSORT的改进思路
DeepSORT在SORT基础上做了两个重要改动:一是用级联匹配替代了单纯的IoU匹配,优先匹配出现帧数更早、更"老"的轨迹;二是引入外观特征距离度量,通过一个ReID网络提取检测框内的行人特征,在遮挡时靠外观特征把人重新找回来。
这套方案在实际项目里的收益非常明确:ID切换减少,遮挡后恢复能力显著提升。代价是ReID网络的推理开销和模型训练成本。如果你的场景是稀疏行人、遮挡不严重,SORT级别的方案完全够用;如果场景是商场、地铁这种人流密集区域,DeepSORT几乎是必须的。
6.3 再往前走一步:融合更多信息
卡尔曼滤波不只吃检测框位置,理论上任何能反映目标状态的观测都能融合。比如多摄像头场景下,把多个视角的目标位置映射到同一地面坐标系,用卡尔曼滤波做融合跟踪;或者结合IMU、雷达等传感器,对行人进行更精细的3D跟踪。核心思路不变,只是观测矩阵H和观测噪声R的维度变大了。卡尔曼滤波这套"预测-更新"框架可以平滑地扩展到多传感器场景,这也是它在现代机器人、自动驾驶系统里依然是底层核心组件的原因。
我个人最后想分享的一点是:卡尔曼滤波看起来只是矩阵运算,真正落地的时候,决定成败的往往是建模细节。状态向量怎么设计、噪声协方差怎么标定、轨迹状态机怎么管理,这些问题的答案都不是公式能直接给你的,必须在真实视频上一帧一帧地试。建议你从一维demo开始,手写一遍五个公式,再往上套检测框,这个过程本身比看多少综述都有用。