多目标跟踪核心:卡尔曼滤波与匈牙利算法的数据关联实战

发布时间:2026/9/28 9:17:31
多目标跟踪核心:卡尔曼滤波与匈牙利算法的数据关联实战 简介一套基于卡尔曼滤波与最大权值匹配实现的多目标跟踪Python项目源码面向计算机视觉、模式识别方向需要完成毕设、课程设计或期末大作业的学习者也适合借助完整实例快速上手目标跟踪的Python开发者。方案融合卡尔曼滤波的状态预测与最大权值匹配的数据关联是多目标跟踪中经典实用的技术路线。压缩包共219个文件大小约15.9MB含6个Python源码文件覆盖预测、匹配、主流程及工具函数207个txt文件提供详细代码注释与操作指引另有两段mp4演示视频、项目说明文档和演示图方便对照实际运行效果。已有432人学习/下载。资源提供可直接运行的完整工程代码注释细致读者既能逐步理解算法实现细节也可修改参数或扩展功能进行二次开发同时附带项目说明与使用指引作为毕设选题或课程大作业的参考蓝本具有较高的复用价值。1. 多目标跟踪到底在解什么问题卡尔曼滤波和最大权值匹配为什么总是成对出现多目标跟踪里最反直觉的一点是卡尔曼滤波的矩阵推导不难真正让你 Id 乱跳、遮挡后跟丢的是数据关联。多目标跟踪MOT在工程里的落地形态很固定检测器每帧吐出一堆框你要知道哪些框属于同一个目标并给每个目标一个稳定编号。这里有两个相对独立的问题要解决。运动预测交给卡尔曼滤波它负责把上一帧的位置推向当前帧数据关联交给最大权值匹配它负责把已有轨迹和当前检测一一对应起来。两者一前一后接力就是 SORT 这类轻量跟踪器的主干。很多人以为难点在卡尔曼滤波的公式推导实际上让你 Id 乱跳、遮挡后跟丢的往往是匹配阈值、轨迹存活时间这类工程细节。这篇文章会沿着一个带详细注释的 python 源码工程思路从状态设计、匹配矩阵到参数踩坑把整条链路讲清楚。适合要给检测器接跟踪、做人流车流统计、做视频结构化的开发者。2. 卡尔曼滤波在跟踪里到底做了什么状态设计与预测更新最小实现2.1 为什么跟踪器需要卡尔曼滤波而不是直接用检测框检测器输出的框有噪声还会间歇性漏检。如果直接把每帧检测框当跟踪结果输出轨迹会出现明显抖动Id 也容易闪变。卡尔曼滤波的价值在于它用一个带不确定性的状态向量描述目标预测阶段给出下一帧的期望位置和协方差更新阶段用当前检测修正状态。它输出的不是原始检测框而是经过运动模型平滑过的位置估计。这个源码方案里目标状态不用 (x, y, w, h) 四维直接进滤波而是用中心坐标加面积加宽高比。[u, v, s, r] 分别代表中心横坐标、中心纵坐标、包围框面积和宽高比 w/h。之所以这样设计是因为行人和车辆在短时间内的宽高比相对稳定而面积随距离连续变化。滤波时预测 s 和 r要用时再转换回 wsqrt(s*r)、hsqrt(s/r)。这样输出的 bbox 不会因为检测框宽高抖动而剧烈跳变。2.2 一个可直接运行的 KalmanTracker 类下面这个 KalmanTracker 类是多目标跟踪 python 源码里最核心的一段常见开源项目基本都是这套结构只是变量名不同。它假设目标短时间近似匀速运动状态向量用 7 维[u, v, s, r, du, dv, ds]后三项是速度。F 是不断把位置加上速度的状态转移矩阵H 是测量矩阵Q、R 分别是过程噪声和测量噪声协方差。import numpy as np class KalmanTracker: 基于线性卡尔曼滤波的单个目标状态估计器。 状态 x: [u, v, s, r, du, dv, ds] 测量 z: [u, v, s, r] def __init__(self, bbox): # 两个标准差系数直接决定 Q 和 R 的量级是调参入口 self.std_weight_position 1.0 / 20 self.std_weight_velocity 1.0 / 160 x, y, w, h bbox self.x np.zeros((7, 1)) self.x[0] x w / 2 self.x[1] y h / 2 self.x[2] w * h self.x[3] w / h # P 初值要偏大让第一帧测量主导状态 self.P np.diag([ 10 * self.std_weight_position, 10 * self.std_weight_position, 1e-2, 10 * self.std_weight_position, 10 * self.std_weight_velocity, 10 * self.std_weight_velocity, 1e-2, ]) def predict(self, dt1.0): # 过程噪声 Q 随 dt 放大帧率越低位置预测越要留余地 q np.zeros((7, 1)) q[0] self.std_weight_position * dt**2 q[1] self.std_weight_position * dt**2 q[4] self.std_weight_velocity * dt q[5] self.std_weight_velocity * dt Q np.diag(q[:, 0] ** 2) F np.eye(7) F[0, 4] dt F[1, 5] dt F[2, 6] dt self.x F self.x self.P F self.P F.T Q return self.get_bbox() def update(self, bbox): x, y, w, h bbox z np.array([[x w / 2], [y h / 2], [w * h], [w / h]]) H np.zeros((4, 7)) H[0, 0] H[1, 1] H[2, 2] H[3, 3] 1.0 R np.diag([ self.std_weight_position, self.std_weight_position, 1e-2, 1.0, ]) ** 2 S H self.P H.T R K self.P H.T np.linalg.inv(S) y z - H self.x self.x self.x K y self.P (np.eye(7) - K H) self.P def get_bbox(self): u, v, s, r self.x[0, 0], self.x[1, 0], self.x[2, 0], self.x[3, 0] w np.sqrt(s * r) h np.sqrt(s / r) return [u - w / 2, v - h / 2, w, h]先调用 predict 再调用 update是卡尔曼滤波的标准用法。predict 里做状态转移和协方差外推update 里用当前帧检测计算卡尔曼增益并修正。代码里有两个容易看漏的位置。第一Q 的构建不是固定常数而是用两个标准系数按 dt 缩放。这意味着检测帧率越高单帧过程噪声越小轨迹越平滑如果你为了省算力每隔一帧才推理一次就需要把 dt 传成 2.0让滤波器自动放大不确定性。第二R 在 update 里每次都重建宽高比 r 的测量方差直接设成 1.0不是随意取值而是给行人换姿态、车辆转向时宽高比变化留余量避免一帧偏差过大就拒绝测量。如果检测器漏检了某一帧正确做法是只调 predict 不调 update让 predict 返回的框继续参与后面的匹配。不要在一个目标漏检时新建轨迹否则同一个目标的轨迹会像切香肠一样一段一段产生。2.3 四个参数按这个顺序调真正决定滤波器行为的是四个参数std_weight_position、std_weight_velocity、P 的初值和 dt。第一个同时放大位置的过程噪声和测量噪声值偏大时滤波更信任测量、跟踪框贴合检测但有抖动值偏小时轨迹平滑、响应滞后。第二个控制速度项的机动能力视频里目标突然加速时这个值太小会让预测位置明显拖后腿。P 初值决定前几帧的收敛速度一般保留默认就能用。dt 必须和实际检测间隔一致否则所有滤波结果都会按错误时间步长外推。我一般先调速度系数再调位置系数。具体做法是把视频停到目标急停或急转弯的帧观察预测框是不是滞后半秒以上如果跟得上但来回抖说明位置系数给大了。卡尔曼滤波这个方向最近在学术界还有些新研究成果但落到多目标跟踪源码工程里绝大多数项目用的还是这套标准线性模型好在参数少、行为直观调起来不玄学。开跑之前把 python 环境和依赖装到位这个工程依赖其实很少numpy 和 scipy 就够别让环境配置耗掉一下午。3. 用最大权值匹配做数据关联IoU 权值矩阵与匈牙利算法的落地写法3.1 为什么关联阶段的目标是最大权值匹配多目标跟踪的数据关联本质是在候选轨迹-检测框组合里找一组互不冲突的配对使得总匹配收益最大。收益最常见的就是 IoU。有人会把匈牙利算法说成最大匹配但 scipy.optimize.linear_sum_assignment 求解的是最小代价指派。想最大化 IoU常见做法是取代价为 1 - IoU再让匈牙利算法做最小化这等价于做了一次最大权值匹配。举一个具体的例子。轨迹 A 与检测 1 的 IoU 是 0.6、与检测 2 是 0.5轨迹 B 与检测 1 是 0.59、与检测 2 是 0。按贪心思路先让 A 配检测 10.6B 只剩检测 20总权值 0.6。而匈牙利算法会算出 A 配检测 20.5、B 配检测 10.59总权值 1.09比贪心好得多。目标一多贪心逐条分配的失败率会明显上升所以源码里会用全局最优的指派算法而不是简单地按分数排序。3.2 关联矩阵构造和完整匹配函数常见做法是先把每条轨迹的预测框和每个检测框两两计算 IoU得到权值矩阵再转成代价矩阵。下面这段 match_tracks 函数可以单独放到 match.py 里是多目标跟踪 python 源码里的关键部分。import numpy as np from scipy.optimize import linear_sum_assignment def iou(bbox_a, bbox_b): ax1, ay1, aw, ah bbox_a bx1, by1, bw, bh bbox_b ax2, ay2 ax1 aw, ay1 ah bx2, by2 bx1 bw, by1 bh ix1, iy1 max(ax1, bx1), max(ay1, by1) ix2, iy2 min(ax2, bx2), min(ay2, by2) iw max(0.0, ix2 - ix1) ih max(0.0, iy2 - iy1) inter iw * ih union aw * ah bw * bh - inter return inter / union if union 0 else 0.0 def match_tracks(tracks, detections, iou_threshold0.3): 最大权值匹配入口返回匹配对与未匹配列表 tracks: 每条轨迹先调 predict() 得到的预测框 detections: 检测器输出的 [x, y, w, h] n_t len(tracks) n_d len(detections) if n_t 0 or n_d 0: return [], list(range(n_t)), list(range(n_d)) cost np.zeros((n_t, n_d)) for t in range(n_t): for d in range(n_d): cost[t, d] 1.0 - iou(tracks[t], detections[d]) row_idx, col_idx linear_sum_assignment(cost) matched_pairs [] for r, c in zip(row_idx, col_idx): if cost[r, c] 1.0 - iou_threshold: matched_pairs.append((r, c)) matched_track_ids {r for r, _ in matched_pairs} matched_det_ids {c for _, c in matched_pairs} unmatched_tracks [t for t in range(n_t) if t not in matched_track_ids] unmatched_detections [d for d in range(n_d) if d not in matched_det_ids] return matched_pairs, unmatched_tracks, unmatched_detections三个地方需要单独说明。第一双层循环构造 cost 矩阵是 O(n_t * n_d)每帧目标超过两三百时这里会变成瓶颈后续可以向量化或者在进入循环前先用距离门控缩小候选集合。第二linear_sum_assignment 一定会给每条轨迹分配一个检测但其中 IoU 太低的配对要被过滤掉过滤后对应的轨迹和检测都要回到未匹配列表否则会被误认为已匹配。第三阈值判断写成 cost 1.0 - iou_threshold等价于 IoU 大于阈值含义直白。匹配结果分三类处理匹配上的轨-检对立刻对轨迹调 update未匹配的检测用来创建新轨迹未匹配的轨迹进入生命周期管理在 max_age 帧内继续预测并尝试匹配。我不建议让新创建的轨迹立刻参与匹配否则它还没稳定就会和旧轨迹抢检测Id 会闪。常见做法是连续命中两帧以后再确认为正式轨迹。3.3 影响匹配质量的两个参数和一个门控首先是 IoU 阈值。阈值小于 0.2匹配容忍大位置偏差遮挡后更容易接回旧轨迹但也更容易把两个交叠目标错误配对阈值大于 0.5跟踪对检测框抖动很敏感一帧 IoU 掉下去就切换轨迹产生大量新 Id。默认值设 0.3检测框稳定可以提到 0.4检测框抖动明显就降到 0.25 左右。其次是代价矩阵的尺度。只要代价和阈值都基于 IoU匹配就完全由重叠面积决定。目标运动过快、两帧间位移接近自身尺寸时IoU 会趋近于 0匹配必然失败。常见做法是做运动补偿把轨迹最近两帧的中心位移加到预测框上再计算 IoU相当于先对齐目标位置再算重叠。门控gating是第三个关键。它负责在进入匈牙利算法之前排除物理上不可能的配对。我一般在构造 cost 之前先算一遍中心距离如果预测框中心和检测框中心距离超过目标高度的 1 到 1.5 倍就直接把 cost 置成一个大数不让它参与匹配。这样能减少密集场景下的错误关联也能让 3.2 里的过滤逻辑更稳定。还有一个顺序问题匹配前一定要先对轨迹做 predict拿预测框去和检测框计算 IoU。有人图省事拿上一帧检测结果直接参与匹配目标一移动IoU 立刻下降匹配失败率明显上升。预测框的用途就是为了抵消这一帧的运动跳过它等于放弃卡尔曼滤波一半的作用。4. 多目标跟踪避坑清单五条高频踩坑与其排查办法跟踪器跑起来容易跑得稳定需要过一遍这些高频坑。下面五条都是从现象出发按原因到解决的顺序写每一条都可以直接对号入座。4.1 目标静止时 Id 每秒闪几次先查匹配阈值再查轨迹确认机制现象红灯停车时同一辆车的 Id 在几个编号之间来回跳计数被重复统计。原因检测框在目标静止时有亚像素抖动卡尔曼滤波收敛后预测位置几乎不动多个轨迹的预测框和同一个检测的 IoU 差距很小。匈牙利算法在代价相近时会任意指派于是出现周期性错误切换。解决先把 IoU 阈值从 0.3 提到 0.45再做一次测试。如果还在闪就检查轨迹确认机制只有连续命中超过两帧的轨迹才进入匹配池未确认轨迹在第一次失配后立即删除。很多网上的 python 多目标跟踪源码工程没有确认状态才会出现这种每秒翻号的现象。排查时让视频暂停在目标静止帧把所有预测框和检测框的 IoU 打出来如果最高分和次高分差距小于 0.1匈牙利算法就会在它们之间乱跳。这时候可以再加一个滞回上一帧已经匹配上的配对下一帧只要 IoU 不跌到某个更低阈值以下就优先维持原配对。滞回能让 Id 稳定不少代价是目标真正离开时需要多等几帧才释放旧轨迹。4.2 遮挡几帧后目标消失再出现换了个新 Id现象行人被柱子挡住十帧左右重新出现后编号变了人员的进出统计也会多算一次。原因滤波器只有短时记忆。连续多帧没有观测时协方差 P 不断被过程噪声拉大预测框中心逐渐偏离真实位置。目标重新出现时预测框和检测框的 IoU 低于阈值匹配失败检测器把它当成新目标创建了新轨迹。多目标跟踪里真正控制这个行为的是轨迹生命周期参数不是卡尔曼参数。解决给轨迹容器加两个字段max_age 和 hit_streak。轨迹一旦失配只要它还活着就继续 predict 并参与后续每帧匹配超过 max_age 帧仍未匹配才删除。我常用的初始值是 max_age15hit_streak2。对应到代码匹配函数返回三类结果后是否对 unmatched_tracks 调 predict 并保留到下一轮是决定遮挡恢复能力的关键。不少简化版源码在 unmatched 时直接删轨迹省了生命周期管理结果就是漏检一两帧就换 Id。还有一个容易翻车的地方未确认轨迹不应该受 max_age 保护它第一帧失配就该删否则会被遮挡物残留误当成目标一直留着。判断标准是目标被遮挡 15 帧重新出现后 Id 应保持不变且预测框中心与目标真实位置的偏差在目标尺寸一半以内。4.3 跟踪框突然飞出画面或上下乱跳现象某条轨迹的框在几帧内跑到墙角甚至明显快于目标实际运动速度。原因最典型的是错误匹配。跟踪器把另一个目标的检测框喂给了当前轨迹滤波器的速度状态被错误测量带飞而线性匀速模型对速度没有上限约束一旦积累出很大的 du、dv预测框就飞了。另一个常见原因是在同一帧里对轨迹做了两次 predict状态被双重推进位置超前。解决predict 输出后加一个速度夹紧du、dv 不允许超过上一帧实际位移的三倍超过就按三倍截断。同时在更新循环里守规矩一个周期内每帧只 predict 一次、只 update 一次。排错时在 update 前后各打一行 bbox 和速度状态比较修正量如果修正量超过目标宽度的一半说明这次关联大概率是错误匹配。还有一种隐蔽情况检测器输出的是归一化坐标跟踪器内部用像素坐标单位没转对时速度项会按 0~1 的尺度计算输出框自然乱飞。我一般还会打印轨迹的 time_since_update 字段如果同一帧内这个值变化异常说明生命周期管理顺序写错了。4.4 两个人交叉走过回来时 Id 互换现象人群交叉场景中两个目标经过一次交叠后身份互换之后错误会一直保持甚至越错越自然。原因纯 IoU 加匈牙利匹配本质是单帧外观最优。交叉瞬间两个框高度重叠任何基于位置的办法都无法区分谁是谁。卡尔曼滤波也没有外观记忆错误关联一旦发生两个滤波器的状态都被污染之后的匹配会在错误基础上继续自洽。解决如果业务允许先加运动方向代价预测框中心到检测框中心的平移向量和轨迹历史运动方向夹角过大就惩罚该项这样至少能挡住迎面交叉的一部分错误。实践中我会把代价矩阵写成 cost 1 - IoU alpha * cos_distalpha 从 0 逐步加大alpha 为 0 时就是纯 SORT 行为随着 alpha 变大外观差异逐渐主导匹配。注意余弦距离量纲是 0~2IoU 是 0~1alpha 不是权重而是缩放系数不归一化就直接相加外观项会完全压过 IoU 项反而更难调。要彻底处理密集交叉需要把框架扩展成含外观特征的方法在代价矩阵里加入外观嵌入向量的余弦距离再配合级联匹配。基于卡尔曼滤波和最大权值匹配的 SORT 方案能力边界就在这里不要硬扛密集遮挡场景。4.5 检测类别切换导致同一目标计数两次现象骑电动车的人先被检测成行人几帧后被检测成骑车人跟踪结果里出现两个 Id类别统计也重复。原因不少 python 多目标跟踪源码按检测类别分池管理轨迹。类别一换旧轨迹池里没有匹配候选检测走未匹配检测分支创建新轨迹同一个物理目标就变成两条轨迹。解决改成全局单一轨迹池匹配时忽略类别差异关联成功后如果类别发生变化用新类别覆盖轨迹上的旧标签。如果业务必须按类别统计也建议先做全局跟踪等轨迹消亡后再统计轨迹生命周期内出现帧数最多的类别而不是在跟踪中按类别切割。拿到一份 python 多目标跟踪源码先看轨迹池定义的位置tracks {}在检测循环外定义是多类共用的全局池tracks {person: [], car: []}这类按类别初始化的写法就是按类分池。快速跑通后把检测器人为改一下类别再跑十帧如果 Id 翻倍基本就是这个问题。免费源码拿到手之后先确认这个细节能省掉后面大量排查时间。5. 从跑通到可信用单目标回归测试与 MOT 指标验证你的跟踪器5.1 单目标回归先证明单一目标不出错跑通源码后的第一件事是录一段十秒单目标视频用人工框标注目标中心再把检测器输出接进跟踪器比较卡尔曼预测中心与真值中心的偏差。误差超过目标尺寸的 5%就要回到第二章去调 Q 和 R。我在自己的工程里固定保留一个 debug 开关把预测框、检测框和协方差椭圆画在同一帧上异常运动一眼就能看出来。5.2 多目标效果盯 MOTA 和 IDF1 两个数就够了多目标跟踪效果评估常用 MOTA 和 IDF1。MOTA 衡量漏检和误报IDF1 衡量编号保持能力。做车流统计、客流统计这类业务IDF1 往往比 MOTA 更重要因为计数准确性依赖编号稳定。我的判断线是MOTA 低于 70% 先回头修检测器别急着调跟踪参数IDF1 低于 60%优先动 max_age 和门控距离如果一个视频里大量轨迹只跟到一半那多半是 max_age 设太短不是滤波器的问题。5.3 值得投入的扩展方向如果场景目标密集、遮挡频繁纯卡尔曼加最大权值匹配不够用常见做法是扩展成含外观特征的方法。你可以保留现在的匹配框架只把代价矩阵从 1 - IoU 变成 IoU 项和外观余弦距离项的加权和先让外观项权重小一些逐步加大。另一个方向是换无迹卡尔曼滤波对车辆转弯场景比线性匀速模型更稳但参数多一倍调参成本高我一般会先把线性版本指标拉满再考虑。我自己每次调完参数都会把验证视频和对应的 Id 序列存下来用同一段素材反复对比不凭印象下结论。跟踪系统里百分之八十的异常出在数据关联而不是滤波上这句话是我的血泪经验也算排错时的第一优先级。希望帮到你。本文还有配套的精品资源点击获取