目标追踪是个看着简单、做着上头的问题。想象一个监控摄像头一个人从画面左侧走到右侧中间被柱子挡住两秒再出现时系统要能认出还是同一个人把轨迹连续画出来。就这么个场景背后其实压着两个问题检测器负责回答这一帧人框在哪追踪器负责回答这些框跨帧是不是同一个人。卡尔曼滤波就是连接这两者的胶水也是绝大多数多目标追踪算法里最实用的一块基石。这篇文章从卡尔曼滤波的数学原理一路讲到行人目标追踪里真正能跑的代码链路和调参经验适合刚接触目标追踪的开发者也适合算法落地时被检测框抖动、跳变、ID切换问题反复折磨的工程师。我当时刚把某个检测模型接到追踪流程里时遇到的第一个现象是做出来的轨迹一抖一抖人明明匀速走出来的轨迹像心电图。后来静下心把卡尔曼滤波手推了一遍才明白根源在于我没有给运动模型和观测噪声一个合理的描述。说到底卡尔曼滤波不是玄学它的每个参数都有物理含义你要做的只是把目标怎么运动和检测器有多不靠谱这两件事翻译成矩阵和方差。1. 行人追踪到底在解决什么问题1.1 检测与追踪的分工差异很多人刚接触追踪时容易有一个误解以为追踪就是把检测框连起来。真把检测器输出连起来看会发现同一帧里人一多目标一重叠跨帧配对根本对不上。检测器的输出是这一帧图像里有哪些目标、每个目标在哪它没有记忆每一帧都是独立推理。追踪器要做的是在时间维度上把这些独立的框串成人的连续轨迹本质上是跨帧的数据关联问题。卡尔曼滤波在其中的位置很微妙。它不负责判断这个框和那个框是不是同一个人它只负责回答一个更基础的问题假设我已经知道这个目标上一帧的位置和速度那么在下一帧它最可能出现在哪里以及这个预测有多可信。这个预测框可以大幅度缩小下一帧的匹配范围让跨帧关联从所有框之间任意组合变成预测框附近的局部匹配。1.2 为什么检测器输出需要滤波检测器不是完美的。同一个静止行人连续两帧的检测框可能相差好几个像素甚至宽高都不一样。如果直接把检测框当轨迹输出回放时就能看到明显的抖动和闪烁。卡尔曼滤波的价值在于它把检测器的观测值和一个假设的运动模型做加权融合运动模型说目标应该在这附近检测器说目标在那边两边都有一定误差最终输出一个介于两者之间、并且更接近真实状态的位置。这个融合逻辑在数学上有一个非常好的性质当系统是线性的、噪声是高斯分布时卡尔曼滤波是均方误差意义下的最优估计器。也就是说在所有只用一阶矩和二阶矩的估计算法里它已经做到极限了。实时追踪场景里行人运动近似线性、噪声近似高斯这恰好是卡尔曼滤波的主场计算量又小自然成了工业界的主流选择。1.3 为什么不是粒子滤波或均值漂移行内也常有人问为什么不用粒子滤波。粒子滤波确实能处理非高斯、非线性场景但它需要大量粒子逼近分布计算量和维度呈指数增长在一个多目标实时的监控系统里几百个目标同时跑粒子滤波帧率很难撑住。均值漂移这类方法又完全没有运动模型对遮挡和快速运动非常不敏感。卡尔曼滤波在这两类方案之间取了一个极佳的平衡有运动先验、有不确定性度量、计算量只和状态维度相关常规的八维状态向量无非是几个矩阵乘法CPU上跑几千个目标都没压力。2. 卡尔曼滤波核心理论五个公式背后的朴素逻辑2.1 状态空间建模把感觉翻译成数学卡尔曼滤波的第一步是定义状态向量。目标追踪里最直观的建模方式是把状态向量定义为检测框四个量加上对应的速度分量x [x, y, w, h, vx, vy, vw, vh]^Tx和y是框的左上角坐标w和h是宽高后面四个是它们各自的速度。这样定义可以捕捉目标位置变化和目标尺度变化两个层面的运动趋势。状态转移矩阵F描述的是匀速运动假设下的递推关系当相邻两帧时间间隔dt取1时F就是一个左上角单位阵、右上角单位阵、左下角零阵、右下角单位阵组成的块矩阵这个结构本质上是新位置 旧位置 速度。观测矩阵H的作用是把状态向量映射到观测空间。我们观测到的是检测器输出的框 [x, y, w, h]所以H就是一个提取状态向量前四维的矩阵。到这里模型已经把目标在怎么运动和我们能观测到什么表达成线性关系了。2.2 预测步闭着眼睛往前走卡尔曼滤波每一轮迭代分两步。预测步解决的是根据运动模型先猜一波。上一帧的后验状态经过状态转移矩阵F得到当前帧的先验状态同时误差协方差矩阵P也要经过一次传播加上过程噪声Q。x_prior F * x_postP_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) # 状态转移矩阵 dt1 的匀速模型 F np.array([[1, 1], [0, 1]], dtypenp.float32) # 观测矩阵只能直接观测位置 H np.array([[1, 0]], dtypenp.float32) # 过程噪声协方差 Q np.array([[0.1, 0], [0, 0.1]], dtypenp.float32) # 观测噪声方差这里只有一维观测 R np.array([[1.0]], dtypenp.float32) # 初始误差协方差设大一些快速收敛 P np.eye(2, dtypenp.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), dtypenp.float32) self.x[:4] np.array(bbox, dtypenp.float32).reshape(4, 1) # 状态转移矩阵dt1 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], ], dtypenp.float32) # 观测矩阵提取位置部分 self.H np.zeros((4, 8), dtypenp.float32) self.H[:4, :4] np.eye(4, dtypenp.float32) # 过程噪声前4维位置后4维速度量级不同 self.Q np.eye(8, dtypenp.float32) self.Q[:4, :4] * 0.01 self.Q[4:, 4:] * 0.1 # 观测噪声检测器框的抖动程度 self.R np.eye(4, dtypenp.float32) * 10 # 初始协方差给大一些让滤波器快速收敛 self.P np.eye(8, dtypenp.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, dtypenp.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, dtypenp.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_threshold0.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策略画面出现闪烁假轨迹没有轨迹确认机制误检直接出轨迹提高连续确认帧数删除未确认轨迹静止目标轨迹漂移速度状态没有归零机制检测速度分量长期低于阈值时强制归零帧率不稳导致预测偏差状态转移矩阵固定dt1接入时间戳动态计算dt并调整F6. 扩展方向从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开始手写一遍五个公式再往上套检测框这个过程本身比看多少综述都有用。