简介:这份资源面向计算机视觉与信号处理方向的学习者,聚焦多目标跟踪(MTT)在MATLAB环境下的实现,适合希望从目标检测、状态估计到数据关联完整走通一遍流程的初中级读者。压缩包共3个文件,约584KB,包含一份可运行的m脚本、一段AVI视频素材和一份参考网站说明文本,脚本与视频配合即可复现跟踪过程,说明文件则便于延伸查阅算法背景。资源内容覆盖卡尔曼滤波、扩展与无迹卡尔曼滤波、匈牙利算法数据关联、粒子滤波及DeepSORT等深度学习方法,并涉及MOTA、MOTP、IDF1等评估指标,可帮助读者理解轨迹管理与目标重识别的关键环节。目前已有435人学习下载,适合作为视频监控、自动驾驶等场景下多目标跟踪算法的入门实践与原型验证参考。
1. 多目标跟踪matlab:一份能跑通卡尔曼滤波与匈牙利匹配的工程底稿
做视觉算法的人大多有过这样的经历:论文里的多目标跟踪公式看着都懂,真到写代码时,卡尔曼滤波的协方差矩阵维度对不上,匈牙利匹配的代价矩阵符号搞反,轨迹 ID 在遮挡后集体跳变。这份多目标跟踪 MATLAB 资源包,就是把这些“看着懂、写着崩”的环节拆成可运行脚本的工程底稿。它面向的是已经会用 MATLAB 做图像处理、想亲手把检测框串成轨迹的从业者和研究生,核心解决三件事:状态预测与更新怎么落地、检测与轨迹怎么关联、轨迹生命周期怎么管理。整套代码不依赖深度学习工具箱,纯 CPU 就能跑,适合拿来改参数、看中间变量、验证自己的理解,而不是黑盒调包。
2. 卡尔曼滤波与匈牙利匹配:跟踪器的两条腿怎么搭
多目标跟踪的骨架其实就两根:一根是运动模型,负责“猜”目标下一帧在哪;另一根是数据关联,负责“配”哪个检测框属于哪条轨迹。这份资源把两者拆成独立函数,中间用代价矩阵连接,结构清晰到可以逐行打断点。下面先讲清选型理由,再落到具体实现。
2.1 为什么用匀速模型加卡尔曼滤波做状态估计
跟踪场景里,目标在相邻帧之间的位移通常远小于目标尺寸,匀速运动假设在 25~30 FPS 的视频里足够用。资源里每个轨迹维护一个 8 维状态向量,常见写法是[x, y, w, h, vx, vy, vw, vh],前四维是框的中心坐标和宽高,后四维是对应速度。状态转移矩阵按帧间隔 dt 构造,观测矩阵只取前四维,因为检测器只给框、不给速度。
选卡尔曼而不是粒子滤波,理由很实际:线性高斯假设下卡尔曼有闭式解,计算量小,几十条轨迹在 MATLAB 里跑实时毫无压力;粒子滤波虽然能处理非线性,但粒子数和重采样参数一多,脚本调试成本陡增,对“想看清每一步”的学习场景反而是负担。资源里把过程噪声 Q 和观测噪声 R 都做成了可调参数,Q 调大跟踪更“跟手”但抖动明显,R 调大则更平滑但响应滞后,这个权衡后面避坑章节会细说。
2.2 匈牙利匹配的代价矩阵构造与门控
关联环节用的是匈牙利算法求全局最优分配,代价矩阵由检测框与预测框的 IoU 距离构成。直接对全图做匹配会出问题:一个检测可能被硬分配给八竿子打不着的轨迹。所以资源里加了门控,IoU 低于阈值的配对直接置为一个大代价,相当于禁止匹配。
function costMatrix = buildCostMatrix(tracks, detections, iouThreshold) % tracks: N×4 预测框 [x y w h] % detections: M×4 检测框 numTracks = size(tracks, 1); numDets = size(detections, 1); costMatrix = zeros(numTracks, numDets); for i = 1:numTracks for j = 1:numDets iou = computeIoU(tracks(i,:), detections(j,:)); if iou < iouThreshold costMatrix(i,j) = 1e6; % 门控:禁止低重叠配对 else costMatrix(i,j) = 1 - iou; % IoU 越大代价越小 end end end end这段代码的关键在iouThreshold,常见取值 0.2~0.3。设太高会让快速运动目标匹配不上、频繁新建轨迹;设太低则容易把相邻目标串号。1e6这个惩罚值不是随便写的,它要远大于正常代价(最大为 1),保证匈牙利算法在可行解里优先避开这些配对。代价用1 - IoU而不是直接用 IoU,是因为匈牙利求的是最小代价,符号方向不能反,这是新手最容易翻车的地方之一。
2.3 轨迹生命周期:新建、确认、丢失、删除
检测框串成轨迹后,还得管生老病死。资源里给每条轨迹设了三个计数器:命中数hitCount、连续丢失数missCount、以及一个确认标志。常见策略是连续命中 3 帧才把轨迹标记为“确认”,避免检测噪声催生一堆短命 ID;连续丢失超过maxAge(比如 30 帧)才删除,给遮挡留出恢复窗口。
% 每帧关联完成后更新轨迹状态 for k = 1:numTracks if matched(k) track(k).hitCount = track(k).hitCount + 1; track(k).missCount = 0; if track(k).hitCount >= 3 track(k).confirmed = true; end else track(k).missCount = track(k).missCount + 1; if track(k).missCount > maxAge track(k).active = false; % 标记删除 end end endmaxAge的取值和帧率强相关:30 FPS 下 30 帧约等于 1 秒,能扛住短暂遮挡;如果视频只有 10 FPS,同样的 30 帧就是 3 秒,轨迹会“赖着不死”,导致 ID 复用混乱。所以这个参数不能照抄,得按实际帧率换算。确认阈值 3 也是经验值,检测器漏检率高时可以降到 2,误检多时提到 4~5。
3. 从检测框到轨迹输出:主循环怎么串起来
单帧逻辑理清后,真正的难点在帧间循环的时序:先预测还是先关联,关联完先更新还是先清理,顺序错了结果会差很多。这一章把主循环拆开讲,并给出可直接套用的脚本结构。
3.1 主循环的五个阶段与执行顺序
一份能跑通的多目标跟踪主循环,通常按“预测 → 关联 → 更新 → 管理 → 输出”五段走。顺序不能乱:必须先对所有活跃轨迹做卡尔曼预测,得到当前帧的预测框,才能拿预测框和本帧检测算代价矩阵;关联完成后,匹配上的轨迹用检测做卡尔曼更新,没匹配上的只做预测外推;最后统一处理新建和删除。
for frameIdx = 1:numFrames img = readFrame(videoReader); detections = detectObjects(img); % 检测器输出 M×4 % 阶段1:对所有活跃轨迹做预测 for k = 1:numel(tracks) if tracks(k).active tracks(k) = predictState(tracks(k)); end end % 阶段2:构造代价矩阵并匈牙利匹配 predBoxes = vertcat(tracks(arrayfun(@(t) t.active, tracks)).state(1:4)); costMatrix = buildCostMatrix(predBoxes, detections, 0.25); [assignments, unassignedTracks, unassignedDets] = hungarianAssign(costMatrix); % 阶段3:匹配上的做更新,未匹配的只外推 tracks = updateMatched(tracks, assignments, detections); tracks = markMissed(tracks, unassignedTracks); % 阶段4:未匹配检测尝试新建轨迹 tracks = createNewTracks(tracks, detections, unassignedDets); % 阶段5:清理超龄轨迹并输出 tracks = removeDeadTracks(tracks, maxAge); drawTracks(img, tracks); end这里有个容易忽略的细节:predBoxes只取活跃轨迹,但assignments里的索引是对应这个子集的,回写更新时必须做索引映射,否则会把检测更新到错误的轨迹上。资源里用了一个activeIdx映射表来处理,改代码时千万别把这个映射省掉。
3.2 检测输入格式与坐标约定
资源默认检测结果是一个 M×4 矩阵,每行[x, y, w, h],其中 x、y 是框左上角坐标。如果你的检测器输出的是[x1, y1, x2, y2]或者中心点格式,必须在进主循环前统一转换,否则 IoU 计算会得到负数,匹配全乱。常见做法是写一个convertBoxFormat函数,在检测器输出后立刻调用。
function boxes = convertBoxFormat(boxes, fromFormat) % fromFormat: 'xyxy' 或 'cxywh' switch fromFormat case 'xyxy' boxes(:,3) = boxes(:,3) - boxes(:,1); boxes(:,4) = boxes(:,4) - boxes(:,2); case 'cxywh' boxes(:,1) = boxes(:,1) - boxes(:,3)/2; boxes(:,2) = boxes(:,2) - boxes(:,4)/2; end end坐标约定不统一是多目标跟踪里最隐蔽的坑之一:代码不报错,但轨迹框整体偏移半个目标,肉眼看视频才发现。建议在脚本开头固定一种内部格式,所有输入输出都在边界处转换,中间逻辑只认一种。
3.3 参数表:哪些能调、调到什么范围
资源里暴露出来的可调参数不多,但每一个都直接影响跟踪质量。下表是按实际调试经验整理的取值范围,具体还得看你的视频分辨率和目标密度。
| 参数 | 含义 | 常见范围 | 调大后的影响 |
|---|---|---|---|
| iouThreshold | 关联门控阈值 | 0.2~0.3 | 匹配更宽松,易串号 |
| maxAge | 轨迹最大丢失帧数 | 20~40 | 抗遮挡强,ID 复用风险高 |
| confirmHits | 确认所需连续命中 | 2~5 | 轨迹更稳,起跟踪延迟大 |
| Q 缩放系数 | 过程噪声 | 0.01~0.1 | 跟踪更跟手,抖动增加 |
| R 缩放系数 | 观测噪声 | 0.1~1.0 | 输出更平滑,响应滞后 |
Q 和 R 的绝对值取决于状态向量量纲,资源里用的是缩放系数乘以单位阵的写法,改的时候只动系数、不动矩阵结构,能少踩很多维度不匹配的坑。
4. 避坑与排查:五个让 ID 跳变的真实原因
跟踪脚本跑起来不难,难的是跑对。下面五条是按“现象 → 原因 → 解决”整理的踩坑记录,基本覆盖了新手调这份资源时八成以上的翻车场景。
4.1 现象:轨迹 ID 每帧都在跳
原因通常是关联阶段几乎没匹配上,每帧都在新建轨迹。先查代价矩阵:如果iouThreshold设得过高,或者预测框和检测框坐标系不一致(一个左上角、一个中心点),IoU 会普遍偏低甚至为负,门控把绝大多数配对都禁掉了。解决方法是打印前几帧的代价矩阵,看非惩罚项是否集中在合理范围;同时确认检测框和预测框用的是同一套坐标约定。
4.2 现象:遮挡后同一个人换了新 ID
这是maxAge太小或确认逻辑太严导致的。目标被遮挡十几帧,轨迹因missCount超限被删,遮挡结束后只能新建。解决是把maxAge按帧率换算后适当调大,并检查删除逻辑是不是在“未匹配”当帧就执行了,正确做法是累计丢失超过阈值才删。另外确认阈值别设太高,否则短暂遮挡后重新匹配上的轨迹迟迟不确认,输出里看不到。
4.3 现象:卡尔曼更新后框突然飞走
多半是协方差矩阵在预测阶段发散了。检查 Q 是否设得过大,或者状态转移矩阵的 dt 和实际帧间隔不符。还有一种情况是检测框出现异常值(比如宽高为 0 或负数),更新时把状态拉偏。解决是在更新前对检测做合法性过滤,宽高小于 2 像素的直接丢弃,同时把 Q 系数降一个量级观察。
4.4 现象:匈牙利匹配结果和预期相反
经典符号错误。匈牙利算法求最小代价,如果代价矩阵直接用 IoU(越大越相似),算法会把最不相似的配在一起。必须用1 - IoU或负 IoU。另一个隐蔽点是惩罚值设得不够大,比如只设成 10,而某些正常代价因数值异常到了 20,门控就失效了。惩罚值要显著大于正常代价上界。
4.5 现象:脚本越跑越慢,内存持续上涨
轨迹删除只标了active = false却没从数组里移除,或者每帧都在往一个 cell 数组里追加可视化句柄。MATLAB 里结构体数组反复扩容也拖性能。解决是每帧结束用逻辑索引压缩一次轨迹数组,可视化用句柄复用而不是每帧重画。跑长视频前先clear掉上一轮的持久变量,别让状态跨视频污染。
5. 进阶验证:用 MOT 指标给自己的跟踪器打分
代码跑通、视频看着顺,不等于跟踪质量过关。想判断参数调得好不好,得有个量化口径。常见做法是拿一段带标注的序列,算 MOTA 和 ID Switch 两个指标:MOTA 综合了漏检、误检和 ID 错配,ID Switch 直接反映身份保持能力。资源本身不带标注数据,但你可以用任意公开序列的标注文件,按帧读入真值框,和跟踪输出做匹配后统计。
% 简化版 ID Switch 统计:逐帧将跟踪结果与真值按 IoU 匹配 idSwitches = 0; prevMatch = containers.Map('KeyType','double','ValueType','double'); for f = 1:numFrames gtBoxes = gtAnnotations{f}; % N×4 真值框 trkBoxes = trackResults{f}; % M×5 [x y w h trackID] matchMap = matchByIoU(gtBoxes, trkBoxes(:,1:4), 0.5); for g = 1:size(gtBoxes,1) if isKey(matchMap, g) curID = trkBoxes(matchMap(g), 5); if isKey(prevMatch, g) && prevMatch(g) ~= curID idSwitches = idSwitches + 1; end prevMatch(g) = curID; end end end这段统计的关键在匹配阈值 0.5 和“同一真值目标 ID 变化才计数”这两个约束。阈值太低会把错配也算成匹配,掩盖问题;计数逻辑要按真值目标追踪,而不是按跟踪 ID 追踪,否则方向就反了。我一般会先用默认参数跑一遍记下基线,然后每次只改一个参数(比如只动maxAge),看 ID Switch 是升是降,避免多参数一起动导致归因不清。
还有个小技巧:把跟踪结果按帧写成文本,用可视化工具叠在视频上逐帧看,比盯着指标数字更容易发现“两条轨迹合并成一条”这类指标不敏感的问题。参数调优没有银弹,靠的就是固定基线、单变量对比、肉眼复核这三板斧。从那以后我每次改跟踪参数,都强制先跑一遍基线指标再动手,省得改完忘了原来是多少。希望帮到你。
本文还有配套的精品资源,点击获取