简介:多目标跟踪是计算机视觉与信号处理领域的重要课题,旨在识别并连续追踪图像序列中的多个动态目标。该MATLAB项目完整实现了从前景检测、卡尔曼滤波预测、匈牙利匹配到轨迹创建与删除的闭环流程,适合正在学习目标跟踪算法或需要MATLAB参考实现的本科生、研究生及工程开发者。压缩包共3个文件,包含可直接运行的MATLAB主程序、演示视频以及源码使用说明,整体仅573KB,轻量易用。资源已有448人学习,内容精炼,覆盖高斯混合模型前景分割、匀速运动模型下的卡尔曼预测、基于成本矩阵的匈牙利指派等关键知识点,并附有演示场景,便于对照理解轨迹管理策略。通过这套代码,读者可快速搭建自己的多目标跟踪测试环境,为自动驾驶、视频监控等应用打下基础。
1. 多目标跟踪与 MATLAB:先解决“为什么难”
如果你只做过单目标跟踪,第一次跑多目标跟踪算法时最先崩掉的往往不是准确率,而是 ID Switch——同一个行人刚被标记为 ID 5,两帧之后变成了 ID 12。另外一个更隐蔽的坑是:在只有下一帧数据到达时,你的程序根本来不及跟当前所有目标挨个做“谁是谁”的穷举匹配,因为计算量随目标数量呈平方级增长。多目标跟踪(Multi-Object Tracking, MOT)就是在这种“实时性、不确定性和 ID 一致性”三条约束同时成立的前提下,解决“每一帧检测到一堆框,怎么把它们串成一条条干净的轨迹”的问题。MATLAB 在这个领域一直被低估:Sensor Fusion and Tracking Toolbox 提供了航迹管理、GNN/JPDA/多假设跟踪(MHT)等现成接口,而自己实现一个最小可运行的跟踪器也只要两百行左右。这篇博文面向已经会用 MATLAB 做图像处理和卡尔曼滤波的读者——我会从数据关联的内核讲起,给出一个不用工具箱也能跑通的多目标跟踪 MATLAB 实现,再谈参数怎么调、指标怎么算,最后落到 HOTA 这类对 ID 一致性敏感的新指标的 MATLAB 计算技巧上。
2. 多目标跟踪的核心机制:卡尔曼滤波、匈牙利匹配与 TrackGNN
2.1 先搞清楚多目标跟踪在解一个什么问题
多目标跟踪的数学本质,是一个“在线最优分配”问题。每一帧送入检测结果集合,系统需要回答两个问题:这个检测框是否属于一条已存在的轨迹?如果不属于,是否应该新建一条轨迹?如果它和某条轨迹关联,轨迹的状态(位置、速度)应该怎么更新?
这里最关键的是“关联”这一步。常见做法是先用运动模型预测每条轨迹在当前帧的位置,然后计算预测框与检测框之间的相似度,再用匈牙利算法求全局最优匹配。MATLAB 中,预测由predict完成,相似度计算通常用交并比(IoU)或马氏距离,匹配则由assignDetectionsToTracks封装了matchpairs来实现。这套流程的组合,在经典文献里常被叫作 Tracking-by-Detection,它的性能上限直接由检测器质量决定——这一点我会在第 4 章详细展开。
% 伪代码级流程:多目标跟踪主循环 for k = 1:numFrames detections = runDetector(frame{k}); % 检测:得到框和置信度 [tracks, assignments] = updateTracks(... % 更新:预测+关联+滤波 tracks, detections, maxNumTracks); end多目标跟踪与单目标追踪最本质的区别在于:单目标追踪假设场景中只有一个目标,目标消失后重新出现还是同一个目标;而多目标跟踪必须处理目标的出现、消失、遮挡后重新出现——这时它是全新的轨迹,而不是原来的 ID。所以,任何 MOT 系统都必须包含“轨迹生命周期管理”模块。它要解决两个问题:一条轨迹多久没有关联到检测就算死了?一个新检测连续被关联多少次才赋予正式 ID?
2.2 卡尔曼滤波在多目标跟踪里到底预测什么
卡尔曼滤波在 MOT 中的角色,是给每一条轨迹维护一个运动状态估计,用于预测下一帧目标可能出现的位置。最常见的状态向量是 7 维:[cx, cy, aspect, height, vx, vy, va, vh],即中心坐标、宽高比、高度以及各自的速度。SORT(Simple Online and Realtime Tracking)论文里用的是匀速(CV)模型,即使存在加速度,只要帧率足够高(25 FPS 以上),匀速假设在短时间内外推的误差仍然远小于检测误差本身。
% 定义等速模型的状态转移矩阵 dt=1 A = [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];A 是状态转移矩阵。注意这里的速度单位是“像素/帧”,不是“像素/秒”。在 MATLAB 中,如果使用trackingKF,你并不需要显式传入这个矩阵,因为它默认支持多种运动模型;但自己写predict步骤时,这个矩阵就是全部。实际工程中,最需要调的是过程噪声协方差 Q。Q 设得越大,滤波器越相信检测;设得越小,滤波器越相信预测。多目标跟踪中的经验是:检测器不稳定时适当调大 Q,让轨迹能跟上检测抖动;检测器非常准但帧率较低时,Q 要小一些,避免轨迹被误检带飞。
2.3 匈牙利匹配与代价矩阵
有了预测位置,下一步构造代价矩阵。矩阵的每一行是一条轨迹,每一列是一个检测框,元素是两者之间的代价(cost)。代价越小,说明越可能是同一个目标。选择用什么度量来填充矩阵,直接决定了跟踪器在高密度场景下的表现。
| 度量方式 | 计算开销 | 适用场景 | 典型问题 |
|---|---|---|---|
| IoU 距离 | 极低 | 行人、车辆等刚体,帧率≥20FPS | 目标快速形变时失效 |
| 中心点欧氏距离 | 极低 | 帧率极高、目标移动慢 | 两个目标靠近时无法区分 |
| 马氏距离(含速度信息) | 低 | 线性运动目标 | 目标急转弯时误差大 |
| 外观特征余弦距离 | 高 | 遮挡频繁、跨摄像头 | 需要额外训练 ReID 网络 |
SORT 用 IoU 距离,DeepSORT 用马氏距离加外观余弦距离的加权和。MATLAB 里实现 IoU 代价矩阵,最直接的做法是调用bboxOverlapRatio:
function costMatrix = iouCost(predBoxes, detBoxes) iouMatrix = bboxOverlapRatio(predBoxes, detBoxes); % 行=预测框,列=检测框 costMatrix = 1 - iouMatrix; % IoU 越大,代价越小 % 将小于阈值的 IoU 对应的代价置为 Inf,强制不允许匹配 costMatrix(iouMatrix < 0.1) = Inf; end这段代码里,bboxOverlapRatio返回一个 m×n 矩阵,元素取值范围是 [0,1]。1 - iou将相似度转成代价,IoU=1 时代价为 0,IoU=0 时代价为 1。最后一行把所有 IoU 低于 0.1 的配对全部设为 Inf,相当于告诉匈牙利算法:这些配对在物理上不可能属于同一个目标。
得到代价矩阵之后,用 MATLAB 的matchpairs函数求解全局最优匹配。需要特别注意的是,matchpairs默认求解最小化总代价的匹配,与我们的代价定义一致。它的第三个参数如果传'min',返回所有未匹配的行和列,正好对应“轨迹找不到检测”和“检测找不到轨迹”两种情况。实际使用时要区分这三个返回值,前一帧留下的轨迹匹配到检测则更新,未匹配的轨迹要看存活时长是否超限,未匹配的检测则纳入未确认轨迹集合。
2.4 TrackGNN:工具箱里的一个足够好的默认选择
如果你不想自己维护轨迹生命周期,MATLAB 的trackerGNN是一个可靠的起点。GNN 的全称是 Global Nearest Neighbor,即全局最近邻,本质上就是用匈牙利算法做逐帧贪心关联的滤波器组合。它的核心参数包括MaxNumTracks、AssignmentThreshold、DetectionProbability、FalseTrackRate等。值得专门一提的是:GNN 是陷阱最少的多目标跟踪算法,它的局限性在于密集场景中一个目标跟丢后会立刻把 ID 分配给另一个目标,但在目标数小于 50 且遮挡不严重的场景,它的表现稳定且调试成本最低。
简而言之,理解多目标跟踪的关键不是背卡尔曼公式,而是理清“预测—关联—更新—管理”的循环。接下来我用一段不需要任何工具箱的纯 MATLAB 代码把这个循环完整实现出来。
3. 用 MATLAB 从零写一个可跑的多目标跟踪最小实现
3.1 先准备一段可复现的合成数据
在正式引入真实视频之前,先用合成数据验证跟踪逻辑,这是开发多目标跟踪算法时效率最高的方式。我一般会生成两到三个匀速直线运动的目标,每个目标持续 50 帧左右,中途让两个目标交叉一次——这个交叉点是一切跟踪算法最容易被绕晕的地方。生成数据的代码如下:
rng(2024); numFrames = 100; groundTruth = struct('boxes', cell(1, numFrames)); % 三条轨迹:匀速直线运动,并在第 40 帧发生交叉 tracksTruth = [60, 100, 0, 100; % [x, y, vx, vy] 200, 100, 0, 80; 350, 80, -40, 100]; for k = 1:numFrames boxes = zeros(3, 4); for j = 1:3 t = tracksTruth(j, :); boxes(j, :) = [t(1) + t(3)*k, t(2) + t(4)*k, 40, 80]; end % 叠加高斯噪声模拟检测框抖动 boxes = boxes + randn(3, 4) * 3; groundTruth(k).boxes = boxes; end这段代码生成了三条起点分布在不同位置的匀速运动轨迹。噪声标准差设为 3 像素,对应检测框的定位误差。生成数据的目的是什么?目的是在多目标跟踪算法跑起来之前,先知道“正确答案”是什么——这样跟踪结果一出来,我们立刻能判断是关联逻辑出了错还是滤波参数不匹配。
3.2 最小跟踪器的完整 MATLAB 实现
下面的实现由四个部分组成:轨迹结构体数组、卡尔曼滤波的预测、匈牙利匹配、轨迹管理。按多目标跟踪惯用的“SORT 思路”组织,总共约 200 行,不依赖任何工具箱,只需要基础 MATLAB 环境。
function tracks = runMoTracker(groundTruth) % 初始化参数 tracks = struct('id', {}, 'x', {}, 'P', {}, ... 'age', {}, 'hits', {}, 'timeSinceUpdate', {}); nextId = 1; maxInvisible = 4; % 连续 4 帧未关联则删除轨迹 minHits = 2; % 连续命中 2 次才确认轨迹 A = [1 0 1 0; 0 1 0 1; 0 0 1 0; 0 0 0 1]; % 匀速模型 H = [1 0 0 0; 0 1 0 0]; % 观测矩阵(只观测 x, y) Q = eye(4) * 5; % 过程噪声 R = eye(2) * 10; % 观测噪声 for k = 1:length(groundTruth) dets = groundTruth(k).boxes; numDets = size(dets, 1); numTracks = length(tracks); % 第一步:预测所有轨迹,并保存预测位置 predPos = zeros(numTracks, 2); for t = 1:numTracks [tracks(t).x, tracks(t).P] = predictKF(..., tracks(t).x, tracks(t).P, A, Q); predPos(t, :) = tracks(t).x(1:2)'; end % 第二步:构造代价矩阵(用中心点欧氏距离) detCenters = [dets(:,1)+dets(:,3)/2, dets(:,2)+dets(:,4)/2]; costMatrix = zeros(numTracks, numDets); for t = 1:numTracks diff = detCenters - repmat(predPos(t,:), numDets, 1); costMatrix(t, :) = sqrt(sum(diff.^2, 2))'; end costMatrix(costMatrix > 50) = Inf; % 距离超过 50 像素不匹配 % 第三步:匈牙利算法求最优匹配 if numTracks > 0 && numDets > 0 [matchIdx, unmatchedTracks, unmatchedDets] = ... matchpairs(costMatrix, 30, 'min'); else unmatchedTracks = 1:numTracks; unmatchedDets = 1:numDets; matchIdx = zeros(0, 2); end % 第四步:更新匹配上的轨迹 for i = 1:size(matchIdx, 1) t = matchIdx(i, 1); d = matchIdx(i, 2); z = detCenters(d, :)'; [tracks(t).x, tracks(t).P] = updateKF(..., tracks(t).x, tracks(t).P, z, H, R); tracks(t).age = tracks(t).age + 1; tracks(t).hits = tracks(t).hits + 1; tracks(t).timeSinceUpdate = 0; end % 第五步:未匹配的轨迹标记为丢失 for t = unmatchedTracks tracks(t).timeSinceUpdate = tracks(t).timeSinceUpdate + 1; end % 第六步:未匹配的检测新建轨迹 for d = unmatchedDets tracks(end+1) = struct('id', nextId, ... 'x', [detCenters(d,:), 0, 0]', ... 'P', eye(4)*50, ... 'age', 1, 'hits', 1, 'timeSinceUpdate', 0); nextId = nextId + 1; end % 第七步:删除丢失过久的轨迹 toDelete = [tracks.timeSinceUpdate] > maxInvisible; tracks = tracks(~toDelete); % 输出当前帧的确认轨迹(hits >= minHits) confirmed = tracks([tracks.hits] >= minHits); fprintf('Frame %3d: %d active tracks\n', k, length(confirmed)); end end这段代码缺两个函数:predictKF和updateKF。它们就是标准的卡尔曼滤波两步。我给你可直接替换的写法:
function [xPred, PPred] = predictKF(x, P, A, Q) xPred = A * x; PPred = A * P * A' + Q; end function [xUpd, PUpd] = updateKF(x, P, z, H, R) y = z - H * x; % 新息(innovation) S = H * P * H' + R; % 新息协方差 K = P * H' / S; % 卡尔曼增益 xUpd = x + K * y; PUpd = (eye(size(P)) - K * H) * P; end注意这段代码的处理顺序是:所有轨迹先统一预测,再做匹配,再更新。为什么不能预测一条匹配一条?因为匈牙利算法需要完整的代价矩阵才能求全局最优。如果逐条预测并立即匹配,后面轨迹的匹配结果会受前面匹配顺序的影响,这在多目标跟踪里叫作“贪心匹配”,虽然速度快,但冲突区域内容易出错。
关于matchpairs的第三个参数 30:它表示匹配代价阈值。大于 30 的代价会被当作不可匹配。更严谨的做法是先将代价大于阈值的位置设为 Inf,再用matchpairs,这样门控逻辑更清晰。两种方式结果等价,但建议用后者,原因是在后续将 IoU 代价换成马氏距离时,代码更统一。
3.3 为什么不用 MATLAB 自带的 trackerGNN?
这一节讨论一个在实际选型中一定会遇到的问题:既然 Sensor Fusion and Tracking Toolbox 里有现成的trackerGNN,为什么要手写?两个理由。其一,工具箱的航迹管理逻辑依赖objectDetection对象,它要求输入必须是结构体数组且包含Measurement、MeasurementNoise字段,而很多做图像处理的工程师手头的数据是 m×4 的框坐标矩阵,转换成本不高但初次接触时容易踩数据格式的坑;其二,手写版本方便你改关联逻辑,比如把中心点距离换成 IoU,或者把匈牙利匹配换成贪心最近邻,这对于研究和对比实验是必需的。
工具箱版本也有它不可替代的价值:它内置了航迹合并、航迹碎片重组、确认/删除逻辑的成熟实现。我的建议是——先手写一遍核心循环确保你理解数据流,再在项目交付时切换到工具箱实现,因为你维护手写版本的成本会随着目标数增加而快速上升。
4. 参数调优与实战数据集上的多目标跟踪实验
4.1 检测器质量决定多目标跟踪的上限
多目标跟踪领域的常识是:MOTA 指标的头部差异,80% 来自于检测器的性能,而不是跟踪器本身。一个 AP 从 70% 提到 85% 的检测器,带来的 MOTA 提升远大于把匈牙利匹配换成 MHT。所以,开始调跟踪器参数之前,先拿到你检测器在自己场景的 Precision-Recall 曲线。当检测器的平均置信度高于 0.7 时,Q 矩阵的数值可以偏小;当检测器输出不稳定(比如车辆检测中把阴影误检成车),则需要把minHits调大到 3 或 4,否则大量碎片轨迹会不断刷新 ID。
还有一个常被忽略的点:检测框的格式统一。你的检测器可能输出[x1, y1, x2, y2],而跟踪器内部使用[cx, cy, w, h];MATLAB 的bboxOverlapRatio默认接受[x, y, w, h]格式,如果混用,IoU 计算出的结果必然错误。写一个统一的转换函数放在公共目录里,是所有多目标跟踪 MATLAB 项目的第一步。
function boxXYWH = convertBoxFormat(boxXYXY) % 从 [x1 y1 x2 y2] 转为 [x y w h] boxXYWH = [boxXYXY(:,1), boxXYXY(:,2), ... boxXYXY(:,3) - boxXYXY(:,1), ... boxXYXY(:,4) - boxXYXY(:,2)]; end4.2 轨迹生命周期参数的设置原则
| 参数 | 含义 | 推荐初始值 | 调参信号 |
|---|---|---|---|
maxInvisible | 轨迹最多丢失多少帧后被删除 | 3~5 | 目标频繁消失时调大,ID 跳变时调小 |
minHits | 新建轨迹连续命中几次才确认 | 2~3 | 误检多时调大,低帧率时调小 |
IoU threshold | 允许关联的最小 IoU | 0.3 | 目标重叠场景调大到 0.4,小目标场景调小到 0.2 |
AssignmentThreshold | 最大允许代价 | 30~50 | 代价度量改变时必须同步调整 |
参数调优的原则是训一调二,即一个参数调节必须对着一个具体的失效模式。运行完跟踪器之后,把轨迹可视化出来,重点观察三种现象:ID Switch 发生在遮挡前后,会表现为同一个目标的框上方 ID 标签在几帧内跳变;碎片化轨迹,即一个目标被切成多条轨迹,通常是因为maxInvisible太小或置信度阈值太低;轨迹漂移,即目标已经转弯,轨迹却继续直线外推,这时要检查状态模型是否包含加速度估计,或者检测器的置信度阈值是否需要下调。
4.3 在真实视频上跑通多目标跟踪的完整流程
拿一段 MOT 社区公开的行人视频做实验,流程分三步。第一步,用 detector 逐帧提取检测框并保存为.mat文件,字段建议用boxes和scores两个矩阵——这一步解耦检测与跟踪,方便 Debug。第二步,运行第 3 章手写跟踪器,输入是 boxes,输出是轨迹结构体。第三步,可视化叠加。
% 可视化轨迹(按 ID 着色) colors = lines(max([tracks.id])); figure; imshow(frame); hold on; for t = 1:length(tracks) if tracks(t).hits >= 2 box = tracks(t).x(1:2)' - [tracks(t).x(3)/2, tracks(t).x(4)/2]; rectangle('Position', [box, tracks(t).x(3:4)'], ... 'EdgeColor', colors(tracks(t).id, :), 'LineWidth', 2); text(box(1), box(2)-5, sprintf('ID %d', tracks(t).id), ... 'Color', colors(tracks(t).id, :), 'FontSize', 12); end end hold off;可视化验证是调参的最高优先级手段,任何一个指标都替代不了肉眼观察。具体说,你要看的是:两目标交叉后 ID 是否保持;目标短暂走到障碍物后面再出来时,是新 ID 还是原 ID;背景中的误检是否迅速被删除。观察 30 秒视频,你就能得出比 MOTA 分数更直观的结论。
4.4 MOTA 与 HOTA:多目标跟踪的两个维度
MOTA 是历史最悠久的指标,它把三项错误(FP、FN、ID Switch)加权重组成一个总分;它的缺陷是 ID Switch 在公式中只占了固定权重(1/numGT),即使连续切换多次同一个目标的 ID,MOTA 受的惩罚也远低于检测错误。HOTA 是后来的修正版,它把检测精度和关联精度分离开来,对 ID 一致性更敏感,尤其适合评估交叉、遮挡频繁的场景。
这就是为什么我今天特意把 HOTA 的计算单独拎出来放在最后一章:盲目追逐 MOTA 数值提高,会让你的跟踪器在 ID 一致性上偷工减料;而工程交付时最容易被用户感知到的恰恰是频繁的 ID 跳变。
5. 用 MATLAB 计算 HOTA 指标并定位 ID 跳变帧
HOTA 的计算核心是建立一个“预测轨迹点”和“真值轨迹点”之间的匹配,然后对每个匹配点计算三件事:检测是否准确(Det)、关联是否准确(AssA)、以及两者的几何平均。公式为:
$$HOTA = \sqrt{DetA \times AssA}$$
其中 DetA 就是常规的检测召回/精度综合值,AssA 则是衡量在两个各自连续轨迹片段上是否一致。用 MATLAB 实现时,关键是先构造匹配对,再分组计算关联矩阵。
function hotaScore = computeHOTA(gtBoxes, trkBoxes, maxDist) % gtBoxes: cell 数组,每个元素是 n_gt x 4 的真值框 % trkBoxes: cell 数组,每个元素是 n_tr x 4 的跟踪框 numFrames = length(gtBoxes); matches = cell(1, numFrames); % 第一层匹配:帧内按 IoU 匹配 for k = 1:numFrames if isempty(gtBoxes{k}) || isempty(trkBoxes{k}) matches{k} = zeros(0, 2); continue; end iouMat = bboxOverlapRatio(gtBoxes{k}, trkBoxes{k}); cost = 1 - iouMat; cost(iouMat < 0.5) = Inf; % IoU < 0.5 不算匹配 matches{k} = matchpairs(cost, 1, 'min'); end % 第二层匹配:根据匹配对分配 track ID 与 GT ID 的关联 % 统计每个 (gtID, trkID) 组合匹配的帧数,得到关联矩阵 % 这个矩阵的行是 GT 轨迹 ID,列是跟踪器轨迹 ID % 关联矩阵中共享相同 ID 组合的点构成一个“关联对” % 为简化示例,这里假设 gtID 就是 gtBoxes 的行号 % 实际使用时需要把检测框转换为具体轨迹 ID --- 这部分代码省略 % 最终 HOTA 计算需要遍历所有匹配点: tpCount = size(cat(1, matches{:}), 1); % 真实实现需基于轨迹 ID 求每个关联对的 DetA 和 AssA % 这里给出最简指示: hotaScore = tpCount / max(1, sum(cellfun(@(x) size(x,1), gtBoxes))); end这段代码只是演示了第一层 IoU 匹配的构建方式,真正的 HOTA 计算需要为每个跟踪器输出分配一个全局轨迹 ID。这正是我推荐你在跟踪器内部保存id字段的原因——没有稳定的轨迹 ID,你无法评估关联精度,也永远无法定位 HOTA 低效的根源。
定位 ID 跳变帧的一个实用技巧是:比较相邻两帧的匹配结果,统计前一帧轨迹 ID 与当前帧轨迹 ID 对应关系,任何一对多或多对一的关系都是可疑的 ID 跳变点。你可以把跳变帧号连同视频帧保存为图片:
frameID = find(idSwitchFrames); for f = 1:length(frameID) imwrite(extractFrame(videoObj, frameID(f)), ... sprintf('idSwitch_%04d.png', frameID(f))); end这套方法在调试 DeepSORT 或 ByteTrack 时同样有效。无论你最终选择工具箱还是手写实现,把 HOTA 的评估代码保留在工程目录中,持续观察它的走势——当检测器升级或场景迁移时,HOTA 比 MOTA 更能揭示真实质量变化。最后给出一个实操框:在matlab中跑完评估后,用disp输出TD(总检测数)、FP、FN、IDSW四个原始计数,这四个数字能告诉你数字背后的叙事,比单个百分比分数可靠得多。
本文还有配套的精品资源,点击获取