写多目标跟踪(MOT)这几年,我最常被身边朋友问的一句话是:检测框不是已经有了吗,为什么还要单独做一个Trackers模块?这问题特别典型,相当于你问我“人脸都认出来了,为什么还需要身份证”。检测框只告诉你“这一帧里有个目标”,但它不告诉你“这个框是刚才第37号那个人,还是新入场的人”。多目标跟踪干的就是这件事:给检测框发一张长期有效的身份证,并且在整个视频里盯着他,保证他不被冒名顶替、不把身份证弄丢。
这期内容就围绕“给检测框发身份证”这条主线展开,我把项目里用到的Trackers多目标跟踪原理、算法选型、工程实现和踩坑记录一次性串起来讲清楚。内容面向两类人:一类是刚接触MOT、打算在项目里加跟踪能力的同学,另一类是检测已经跑通、但被ID Switch(ID切换)和轨迹断裂折磨得头疼的工程师。看完之后,你能搞懂SORT、DeepSORT、ByteTrack这些主流tracker到底在算什么,也能直接参考我的工程配置把一套基础的多目标跟踪流水线跑起来。
先说明一件事,有朋友看到“Trackers”这个词,第一反应是比特彗星这类下载工具里填的那个tracker列表。这两者完全不是一回事。下载工具里的tracker,是BT协议中用来协调各客户端互相发现、交换数据的调度服务器;而视觉里的tracker,是视频感知里的“跟踪器”模块,负责把逐帧的检测框串联成持续存在的目标轨迹。我在这篇里说的,只涉及后者。
1. 先搞清楚“Trackers”到底在跟踪什么
1.1 任务拆解:MOT不是单模块,而是检测加关联
多目标跟踪(Multiple Object Tracking,简称MOT)的标准做法是把任务拆成两步:第一步是目标检测,在每一帧图像里找出所有目标,输出一组检测框;第二步是数据关联,把当前帧的检测框和历史轨迹接起来,判断哪些框属于同一个人或同一个物体。Trackers这个词一般指第二步里的核心算法模块,但实际工程里它是包围检测结果、特征提取和运动预测在内的整套机制。
你可以把检测理解成“这一帧里发现了几个人、分别在哪个位置”,把跟踪理解成“这些位置对应的是几号人”。检测的输出是“帧内”信息,跟踪的输出是“跨帧”信息。如果只做检测不做跟踪,视频里一个人走两步、停一下,他的框就每帧重新编号一次,下游的计数、行为分析、轨迹回放全都没法做。
MOT还有一个分支叫SOT(单目标跟踪),两者的区别很关键。SOT在第一帧给定一个目标框,后面所有帧只追这一个目标,不负责发现新目标;MOT则是全程自动,每帧都要同时处理新目标出现、旧目标消失、目标之间互相遮挡这些情况。正因为要自动处理目标的增删和遮挡,MOT的数据关联才需要一套更完整的生命周期管理机制。
1.2 核心痛点:ID Switch才是难啃的骨头
做MOT的人嘴里最常说的一句黑话是“这个ID稳不稳”。这里说的ID,就是目标在整个视频片段里唯一编号,相当于身份证号。跟踪算法做得好不好,最直观的指标就是ID有没有频繁切换:明明是同一个人,走了一段路之后编号从3变成了17,这就叫ID Switch,也就是我常说的“身份证被换了”。
ID Switch出现的原因基本可以归为三类:检测框漏检导致轨迹中断、目标之间互相遮挡导致身份混淆、匹配算法在两种特征冲突时做出了错误取舍。最典型的一幕是两人交叉走过:在交叉点之前,A在左B在右;交叉的一瞬间,两个框重叠;交叉之后,A到了右边,算法却把A原来的ID给了B。这就是身份交换,跟踪效果差的视频里这种错误会刷屏。
所以多目标跟踪本质上不是在“画框”,而是在“守身份”。所有算法改进——不管是卡尔曼滤波、外观特征提取,还是两级匹配策略——目标都是同一个:让ID在遮挡、运动模糊、检测报警抖动的情况下,依然能保持连续。
1.3 把跟踪器放到完整感知链路里看
我画了一条完整的感知链路:视频流进入检测模型,输出第t帧的检测框集合;检测框经过预处理,和第t-1帧维护好的轨迹集合一起送进匹配模块;匹配模块用重叠度、运动预测、外观相似度等信息计算代价矩阵,再用匈牙利算法做最优分配;分配完成后,匹配上的轨迹更新位置,没匹配上的轨迹进入“待定”状态,等下一帧再找机会续上;太久没匹配上的轨迹直接删除,释放ID。
这条链路里,检测模型的质量决定了跟踪的天花板。检测频繁漏检,再好的关联算法也救不回来,因为派不由“无米之炊”。反过来也一样:检测每帧都很准,但关联算法不会分配,那就会看到同一批人每帧换一个编号,等于白做。
2. 给检测框“发身份证”的核心机制拆解
2.1 数据关联:用IoU把新框和旧轨迹绑在一起
数据关联要解决的问题是:当前帧有20个检测框,上一帧留下了15条轨迹,这20个框分别对应这15条里的哪几个?最简单的办法,是直接看几何重叠度。假设一条轨迹t在上一次更新时有个框,位置是(x1, y1, x2, y2),当前帧检测出一个新框,把这两个框做交并比(Intersection over Union,IoU),IoU越高说明两个框重合度越大,越可能是同一个目标。
IoU关联在目标低速移动、相机不抖的场景下极其好用,因为它只靠位置信息,不需要额外跑特征提取网络,速度非常快。但它的短板也很明显:如果目标跑得快,上一帧和下一帧的框完全不重叠,IoU就变成0,关联直接失败。这时候就需要运动预测来“猜”一下目标在当前位置:用上一帧的位置,结合目标的速度,推算出这一帧可能出现在哪里,再用预测框去和检测框计算IoU。
实际工程里,我会用一个经验值:IoU阈值一般设在0.3到0.4之间,低于这个值就不算同一个目标。阈值太高容易漏关联,目标稍微动得快一点就断轨迹;阈值太低又容易乱关联,挨得近的两个人框重叠度不高也强行绑在一起。要根据自己视频里目标的大小和运动速度去调。
2.2 匈牙利算法:解决“一个框只能跟一条轨迹”的分配问题
有了IoU或其他特征算出来的代价矩阵,下一步就是做“最优分配”。这里的难点在于约束条件:一个检测框只能分配给一条轨迹,一条轨迹也只能接受一个检测框,一个目标不能同时拥有两个身份证,这是硬性条件。
这类问题在计算机里是有标准解的——匈牙利算法。它做的事情可以这么理解:假设有3条轨迹、3个检测框,每对组合都有一个匹配代价,匈牙利算法能在多项式复杂度内找到一个总代价最小的配对方案,且严格满足一对一约束。相比之下,贪心算法虽然更快,但它每步只看当前最优解,很容易出现“强者通吃”的局部最优问题。比如轨迹A和轨迹B同时靠近检测框C,贪心先把C给了重叠度更高的A,B就只能干等着,下一帧B就断了。匈牙利算法则会考虑全局,宁愿让A匹配代价稍高一点的那个框,也不让B被饿死。
DeepSORT和ByteTrack的后端都用了匈牙利算法,区别在于它们构造的代价矩阵不同。DeepSORT使用外观特征的马氏距离和运动预测的IoU做加权组合,ByteTrack则干脆用IoU做单一代价。代价矩阵怎么构造,决定了匈牙利算法拿什么牌去发身份证。
2.3 卡尔曼滤波:像导航软件一样持续修正轨迹预测
给检测框发身份证,光靠当前帧的位置还不够。更稳的做法是让每条轨迹拥有一个“运动模型”,能够预测它下一时刻的位置。这时候就要请出卡尔曼滤波(Kalman Filter)。
卡尔曼滤波的直观理解,就是手机导航里的路径预测:它根据你上一时刻的位置和速度,先预测下一个点在哪;等到新的GPS信号(也就是新的检测框)来了,再拿预测值和观测值做加权融合,得到一个修正后的更准位置。这个“预测-修正-再预测-再修正”的循环,正是卡尔曼滤波的核心。
在MOT里,卡尔曼滤波通常会维护一个8维状态:中心点坐标、宽高比、高度,以及它们各自的速度分量。整个工作过程可以分成两步:
- 预测步:用上一帧的状态和恒速运动模型,推算当前帧的预测框位置。
- 更新步:用当前帧匹配到的检测框来修正预测值,让轨迹位置更贴近真实目标。
这套机制在目标做匀速直线运动时效果很好,但遇到突然转弯、急停急走,恒速模型的假设就不成立了,预测框会明显偏斜,ID切换率也会上升。这就是为什么后面很多改进算法的重点,不是换掉卡尔曼滤波,而是让预测更“懂”目标运动的不确定性。
3. 主流通用Trackers算法全景与选型建议
3.1 SORT开山:最朴素的“只用位置”方案
SORT是很多人入门MOT的第一个算法,全称Simple Online and Realtime Tracking,特别能体现它的气质:简单、在线、实时。SORT顺理成章地组合了检测、卡尔曼滤波和匈牙利算法,用IoU做关联,不需要任何外观特征。因为只算几何信息,SORT速度非常快,在CPU上就能跑,是很多实时系统的底线方案。
但SORT有个很明显的代价:ID切换非常普遍。因为一旦目标被遮挡或者检测漏了几帧,原本的轨迹可能丢了;等目标重新出现时,SORT只看到新检测框和旧轨迹已经彻底断联,只能给他发一张新身份证。SORT适合用在场景相对简单、目标运动平稳且遮挡少的固定摄像头监控里,比如仓库里的叉车任务、单通道排队场景。
3.2 DeepSORT:给身份证加了“人脸”防伪标识
既然SORT纯靠位置容易认错人,那自然想到给每条轨迹加一个外观特征作为“防伪标识”。DeepSORT就是在SORT的基础上,引入一个ReID(行人重识别)特征提取网络。具体来说,每条轨迹会保留一个由多个历史外观特征取平均得到的特征向量;每个新检测框也会算出一个特征向量。匹配时,除了IoU的几何关系,还要计算外观特征的余弦相似度,两者加权作为最终的代价矩阵。
有了外观特征的加持,DeepSORT在目标被短暂遮挡、重新出现后,能靠“长相”把ID接回来,而不是直接发新证。这是它相对SORT最大的改进。代价在于多跑了一个ReID网络,计算量明显上升,同时在目标尺度特别小、图像模糊时,重识别特征并不够可靠,反而会带来额外的误匹配。DeepSORT最适合行人为主、摄像头角度固定、遮挡较多的场景,是很多智慧零售、客流统计项目里的默认选择。
3.3 ByteTrack:让低置信度检测框不再被白白扔掉
ByteTrack是我在不少项目里最终落地的算法,它的思路特别接地气:大多数检测器输出框时会带一个置信度分数,传统做法是设定一个阈值,低于阈值的框直接扔掉,只留高置信度的框去做关联。但问题在于,当目标被遮挡或者较远时,检测器给出的置信度本来就会降低,这些低置信度框恰恰可能对应着真实的被遮挡目标。如果一扔了之,这些轨迹就断了,ID切换自然变多。
ByteTrack的解决办法是分两阶段匹配:第一阶段,把高置信度检测框与现有轨迹做关联,用的是跟踪框和检测框的IoU;第二阶段,把第一阶段没匹配上的轨迹,再用低置信度检测框去关联一次,试图找回那些被遮挡的目标。这样既保证了正常移动目标的准确跟随,又尽量让被短暂遮挡的目标不掉线。ByteTrack没有引入重识别特征,结构相对简单,在MOT17等数据集上表现突出,是“花小钱办大事”的典型案例。
3.4 其他改进方案与最终选型速查表
除了上面三个经典算法,近几年还涌现出一批针对性改进。OC-SORT针对“运动非线性”问题,在匹配时加入了“观察中心性”约束,有效减少目标在遮挡后轨迹插队、错位的情况;BoT-SORT则在ByteTrack基础上,融合了相机运动补偿(ECM)以及更强健的外观特征,让结果更平滑;StrongSORT在DeepSORT基础上加入了更强的ReID、轨迹插值和矫正模块,主打离线场景下刷高分。
算法并没有绝对的好坏,只有适不适合你的场景。我给自己的项目做选型时,会按照下面这个思路来:
| 场景特点 | 推荐方案 | 理由 |
|---|---|---|
| 实时性优先、目标少且运动平稳 | SORT | 计算量最小,延迟可接受 |
| 行人为主、遮挡频繁、对象外观差异明显 | DeepSORT | 外观特征能显著减少ID切换 |
| 检测质量波动大、目标经常被遮挡 | ByteTrack | 两级匹配策略能“捞回”低置信度目标 |
| 监控场景有相机轻微晃动 | BoT-SORT | 加入了相机运动补偿,更抗抖 |
| 离线分析、追求高准确率、不苛求实时 | StrongSORT | 可以牺牲速度换更精确的轨迹 |
| 高速路、车辆少、车速极快 | OC-SORT | 对运动模型假设要求更贴合动态场景 |
这个表格只是起点,真正干项目时,我会先用ByteTrack慢跑一两组数据,统计ID Switch出现的位置,再判断要不要升级到带ReID或相机补偿的版本。不要一上来就在所有算法里反复横跳,那样只会浪费时间在调参而不是解决问题的根源上。
4. 跑通一个实际工程:结构、代码与调参记录
4.1 一个最小MOT工程长什么样
我习惯把整个工程拆成四个文件:detector.py(负责接入检测模型)、tracker.py(负责轨迹管理和匹配)、visualizer.py(负责画框和ID)、main.py(串联整个流程)。下面是一段高度精简但结构完整的伪代码,展示在main里每帧的处理逻辑:
class ObjectTracker: def __init__(self): self.tracks = [] # 活跃轨迹集合 self.next_id = 1 # 身份证号发号器 self.max_age = 30 # 轨迹连续多少帧没匹配就删除 self.min_hits = 3 # 轨迹至少连续命中几帧才对外公布 def update(self, detections): # 1. 用卡尔曼滤波预测所有轨迹在当前帧的位置 for trk in self.tracks: trk.predict() # 2. 第一阶段:高置信度检测框与轨迹做IoU匹配 matched, unmatched_trks, unmatched_dets = \ self.match(self.tracks, detections, threshold=0.3) # 3. 更新已匹配轨迹的状态 for trk_idx, det_idx in matched: self.tracks[trk_idx].update(detections[det_idx]) self.tracks[trk_idx].age = 0 # 4. 第二阶段:用低置信度检测框匹配未命中的轨迹(ByteTrack思路) matched_low, unmatched_trks, unmatched_dets = \ self.match_low(self.tracks, unmatched_trks, detections) # 5. 为仍未匹配的新检测框创建新轨迹,发新身份证 for det_idx in unmatched_dets: new_trk = Track(self.next_id, detections[det_idx]) self.next_id += 1 self.tracks.append(new_trk) # 6. 清理超过max_age还没命中的轨迹,回收ID self.tracks = [trk for trk in self.tracks if trk.age <= self.max_age] return self.tracks这个框架虽然只有几十行,但已经包含了所有核心步骤:预测、匹配、更新、新建、删除。真实工程里,match函数里会调用匈牙利算法和代价矩阵计算,步骤2、4的匹配往往还要按score从高到低排列,保证高置信度优先分配。
4.2 轨迹生命周期:出生、在册、失联和注销
身份证不能乱发,所以轨迹的生命周期必须管理清楚。我把一条轨迹的状态拆成四个阶段:
- 候选期:新检测框出现时,先给一张临时凭证,连续命中几帧后转正,避免把误检当成新目标。
- 活跃期:轨迹正常匹配,持续更新位置和特征,对外输出ID和bbox。
- 失联期:连续多帧没有匹配到任何检测框,但还没超过阈值,保留预测状态,继续等待重新匹配。
- 注销期:失联超过阈值,直接删除轨迹,后续即使在同一位置出现目标,也按新目标处理。
min_hits这个参数非常容易忽略。我试过把min_hits设成1,结果检测器一个误检冒出来就会形成一条假轨迹,然后这个假ID会在画面里乱飘几秒;调高到3到5之后,单个孤立的误检基本就不会产生轨迹了。max_age则是给“遮挡记忆”设的时长,步行场景下我通常设在25到30帧,如果目标可能被立在眼前的卡车挡住很久,我会调到60帧以上。
4.3 关键超参对照表
| 参数名 | 推荐范围 | 作用 | 调参经验 |
|---|---|---|---|
| det_conf_thr | 0.4 ~ 0.6 | 检测框置信度阈值 | 调高会漏检,调低会引入大量候选框,需配合min_hits保护 |
| iou_thr | 0.3 ~ 0.4 | 第一、二阶段匹配的IoU阈值 | 目标速度快时调低,目标密集时警惕误配 |
| max_age | 25 ~ 60 | 失联轨迹最大存活帧数 | 目标被遮挡频繁的场景要调大 |
| min_hits | 3 ~ 5 | 新轨迹转正所需命中次数 | 误检多时调大,目标小且移速快时调小 |
| track_buffer | 30 ~ 120 | 输出轨迹最多保留的帧数 | 影响可视化拖尾稳定感,不影响核心关联 |
这组参数不是拍脑袋定的,而是我在一个固定路口的行人视频上逐项调过的结果。核心思路是:先用默认参数跑一遍,把视频拖到出问题的那几帧,看是“多了ID”还是“断了ID”,再反向去调对应参数。调任何一个超参都要同时观察ID Switch率和MOTA变化,不能只看tracking丢失的数量。
4.4 可视化输出:把轨迹画出来才能发现问题
跟踪做完之后,可视化不是摆设。我会在main里同时输出两个画面:一个只画检测框和ID号,另一个画轨迹尾巴,用最近N帧的位置点连成线。别小看轨迹尾巴这几根线,它能把匹配错误直观暴露出来:如果两个人的轨迹线交叉但ID没有交换,说明匹配基本没问题;如果轨迹线突然断掉又从一个角度冒出来,大概率是这里发生了ID Switch。
一个人从左边走向右边,他的轨迹点应该平滑地延展成一条曲线。一旦这条曲线上出现了一个明显跳变的“折角”,那附近一定藏着一次错误关联。我甚至会在调试阶段把每个ID的轨迹点单独记录成JSON,用脚本来统计ID Switch发生的帧号,这样比肉眼盯视频高效得多。
5. 实战中常见的坑与排查思路实录
5.1 现象:行人交替穿过时ID频繁互换
这是提得最多的问题,通常发生在两人相对而行、擦肩而过的瞬间。排查时我会先在视频里定位到ID互换的那几帧,把检测框的置信度、轨迹预测框和检测框的IoU值全部打印出来。如果发现互换发生时IoU阈值正好都在临界点,那说明是匹配逻辑分不清两个靠得太近的框,这时候最好升级方案:要么引入ReID特征计算外观相似度,要么调低IoU阈值来减少错误关联。
另一个思路是修改代价矩阵,增大“运动预测”的权重:两个人的运动方向相反,即使位置重叠,速度方向的差异也足以让匈牙利算法选择更合理的分配。我有时候会在代价矩阵里额外加一项“方向一致性约束”,实测下来ID交换能有明显改善。
5.2 现象:相机轻微晃动导致轨迹乱跳
固定摄像头并不等于画面静止。风把杆子吹得轻微晃动,或者设备安装支架存在微小位移,都会让整个画面周期性抖动。此时卡尔曼滤波的“恒速模型”会把相机抖动当成目标的真实运动,轨迹预测位置不断偏移,ID自然不稳定。
处理思路有两个:一是做相机运动补偿,用视频前后帧的特征点光流算出一个全局仿射变换矩阵,先把画面对齐再做tracking;二是开启“检测框平滑”模式,对同一轨迹的连续多帧检测结果做指数滑动平均,降低单帧抖动的影响。BoT-SORT里集成的ECM模块就是干这件事的。遇到这个坑想靠调IoU解决,基本没用。
5.3 现象:轨迹看起来一直贴着某个目标,但框不对齐
有时候跟踪没有ID中断,但轨迹框和真实目标总有半个身位的偏移,看起来像是尾巴拖着走。这种情况下问题大概率不在tracker,而在检测器和卡尔曼滤波的“反应速度”上。卡尔曼滤波更新时,会按比例把预测值和观测值融合,如果卡尔曼增益偏低,轨迹位置会更偏向预测值,导致框移动滞后,看起来像拖影。
做法是把更新公式里的噪声参数适当调大,让算法更信任检测结果,而不是死守预测值。也可以直接在代码里删掉卡尔曼预测那一行,只用检测框位置做更新,看是不是就没有偏移。如果删掉后问题消失,基本就能确定是运动模型参数背锅。
5.4 现象:不同类别的目标互相抢ID
当场景同时出现人和车,且类别多种时,tracker最容易犯的一个错误,是把一辆车的ID带到一个人身上。这是因为底层匹配只管框的几何重叠度,根本不关心框里是什么类别。出现这个坑,说明你的tracker没有把检测类别纳入关联条件。
解法很简单:在构造代价矩阵之前,先按类别把检测框和轨迹分流。类别不同的检测框与轨迹,直接不让它们参与匹配,代价矩阵对应位置填一个极大值。这条规则在工程里是零成本高性能的改进,永远不要忽略。
5.5 现象:目标长时间被遮挡后回来,无法续上ID
公交车把人完全挡住5秒,人从车尾走出来,此时旧轨迹早就超过max_age被删了。想续回ID,就只能靠外观特征。但注意,如果你用的是纯IoU方案,那无论如何都不可能续回来;如果用了ReID,也要看特征检索的阈值是否合理——阈值太严,同一个人的两次特征相似度也过不了门,那就等于没有ReID。
我从这个坑里学到的教训是:目标被遮挡场景多的项目,要么把max_age调得非常大,要么干脆在frame级别做“全局特征重匹配”,也就是在轨迹删除后不立即清除它的外观特征,而是保留一个“休眠轨迹”列表,定期用新检测框去和休眠轨迹匹配。这个思路类似于给目标发一张“可挂失”的身份证,而不是人一消失就销毁记录。
写在最后的一点个人体会
和检测模型的训练调参相比,多目标跟踪是个更需要“雕花”的活。检测网络指标看mAP就好,而tracker好不好,得看它在被遮挡、运动模糊、光照变化、相机抖动这些乱七八糟的真实场景里的整体表现。我做了几个项目之后最深的感受是:一切跟踪技巧的前提,是先把检测调理稳定,tracker解决不了检测反复横跳带来的根本问题。
现在我手里接新项目时,会先花半天时间用ByteTrack跑一遍视频,把输出结果里ID Switch的帧号统计出来,再决定要不要上ReID、上相机补偿,或者直接换OC-SORT。这样看起来绕了一圈,实则是效率最高的路径——你永远不知道自己的计算瓶颈和效果瓶颈卡在哪,只有跑了才清楚。最后再提醒一件事:坏结果不等于算法差。输出结果里出现ID切换,先查自己的检测阈值、遮挡环境和参数配置,不要一上来就把算法换了。跟踪工程做久了你会发现,大部分问题不是算法不够高级,而是使用它的姿势还需要再调整。