多目标跟踪实战:给检测框一张稳定的“身份证”
做视觉的同学应该都有同感单看一张图检测模型能给出漂亮的框、准确的类别但一旦切到视频每一帧的框都是陌生人——没有ID、没有历史、没有前后联系。人眼能轻松追踪左边那个人刚才走到了右边算法不行。检测框和检测框之间缺的正是发身份证的机制。多目标跟踪Multi-Object TrackingMOT干的就是这件事。给检测框发身份证说起来形象做起来坑不少。Trackers 不是一个算法而是一整条链路检测负责拉出框跟踪器负责给框配稳定的ID、预测下一步位置、处理遮挡和丢失。这篇文章从实战出发把链路掰开揉碎讲清楚原理怎么理解、主流方案怎么选、代码怎么写、参数怎么调、坑怎么避。先说明一下这里的 Trackers 是计算机视觉里的目标跟踪器不是下载软件里那种 tracker 列表别混到一块去。适合两类读者刚接触计算机视觉、检测已经玩明白但不知道如何处理时序数据的朋友以及检测已经上线、想给业务加上目标身份能力的开发者。读完你可以直接用后面那套简化版 ByteTrack 代码跑自己的视频也可以借此搞懂 DeepSORT 那些开源实现的内部逻辑。提示文中的代码是教学向的简化实现生产环境建议直接使用 ByteTrack、StrongSORT 等开源仓库或者封装好的跟踪模块但原理、参数、踩坑经验是通用的跟着读不会白花时间。1. 为什么检测框需要一张身份证跟踪要解决什么问题1.1 检测是空间能力跟踪是时间能力从最底层说目标检测回答的是这一帧里有什么、在哪个位置每个检测框输出一个四元组x, y, w, h加上置信度和类别。这是纯空间维度的问题不关心这一帧和上一帧是什么关系。所以你会看到同一个行人在这帧是框1下一帧变成框12再下一帧又成框7——检测器根本不知道它们其实是同一个人。跟踪做的事情是在时间维度上把这些框串起来。每个目标分到一个全局唯一的ID连续帧里同一个目标的框ID保持不变于是就有了轨迹一个带时间戳的框序列。有了轨迹你才能回答这个场景出现了多少个不同的人而不是这一帧里有几个人才能预测目标下一步往哪走才能判断它有没有逆行、聚集、停留、插队。我常跟新人说检测框是路人脸一晃就忘跟踪器才是户籍警给每个路人建档、编号、持续更新档案。这里的档案编号就是那个ID它才是跟踪输出的核心资产。没有这张身份证你拿到手的只是一堆散装的框下游业务根本没法用。1.2 拿需求反推方案而不是拿算法套需求接过几个项目之后你会发现方案选型根本不是算法竞赛而是需求翻译。我一般先问业务方三句话是数人头还是认人如果只是统计客流、区域人数峰值按ID去重就够了如果要判断同一个顾客进店几次、同一个球员跑了多少公里ID就必须在遮挡和拥挤中保持稳定。遮挡严重吗柜台、货架、人群密集的通道目标互相遮挡是常态。遮挡一出现纯位置匹配的方案比如SORT几乎必掉ID就得考虑带外观特征Re-ID的方案。实时性多苛刻摄像头多、帧率高、算力有限那复杂的特征提取网络可能根本跑不动得从轻量方案做起。这三个问题的答案直接决定你未来几周的调试方向。所以开工之前把业务目标翻译成两个硬指标ID SwitchID切换次数和轨迹碎片数。一个目标是尽量少切ID另一个是一条轨迹别断成好几截。后面所有调参都是冲着这两个指标去的。2. 主流的 Trackers 方案怎么选三条路线一次说清2.1 单目标跟踪器、检测跟踪器、联体式差在哪很多初学者看到一堆名词就乱。其实多目标跟踪方案大致分三条路线。第一类是单目标跟踪器OpenCV 里的 KCF、CSRT、MOSSE 都属于这类。它们需要人工给一个初始框然后在后续帧里锁定这个框。速度确实快但容易漂移而且一次只跟一个目标。做多目标就得开十几个实例互相抢资源非常不优雅。除非场景是锁死一个固定目标慢慢跟否则不建议把它当多目标跟踪的主干。第二类是基于检测的跟踪Tracking-by-Detection也是目前工程落地最主流、最稳的路线。流程一句话每帧先跑检测器拿检测框再做数据关联把检测框分配给已有轨迹。SORT、DeepSORT、ByteTrack、OC-SORT、StrongSORT 都属于这类。它的好处是检测和跟踪解耦检测器升级了跟踪还能跟着变强每一步都能单独调试。第三类是联体式方案Joint Detection and TrackingFairMOT、CenterTrack、TransTrack 是代表。检测和跟踪在一个网络里完成省算力但调试不灵活部署门槛也高。我在真实项目里用得不多只在低算力设备上评估过。如果你是第一次做多目标跟踪强烈建议从 Tracking-by-Detection 入手它最符合直觉每一步都能单独看效果出问题也好定位。2.2 从 SORT 到 ByteTrack检测与关联的博弈SORT 是 2016 年的经典方案核心就两板斧卡尔曼滤波做运动预测IOU 加匈牙利算法做关联。速度极快能跑几百帧每秒但弱点也明显——目标一被遮挡或者检测框一抖ID 就乱。因为它完全不管外观只信框的位置。DeepSORT 在 SORT 的基础上加了一个 Re-ID 网络提取每个目标的外观特征关联时把位置距离和外观相似度结合起来。优势是遮挡下 ID 更稳代价是多一个特征提取网络的算力开销而且 Re-ID 模型的质量直接决定方案上限。这里有个容易忽略的点Re-ID 特征的判别力比你选多复杂的匹配算法影响大得多。同类别、长得像的目标一多外观特征反而会帮倒忙。ByteTrack 是近两三年工程圈很火的方案它改了一个看似不起眼的细节以前的方法会把低置信度检测框直接丢掉只拿高置信度框做关联。ByteTrack 发现这些低分框里往往藏着被遮挡的目标把它们也拉进关联流程反而能明显减少 ID Switch。它不依赖 Re-ID纯靠位置加分数策略就能在 MOT17 上拿到很好的成绩部署非常友好。我在监控类项目里做首版方案时经常先上 ByteTrack够用了就不再往深里加。2.3 一张对照表按场景做减法方案核心思路速度ID稳定性适合场景SORT运动预测IOU关联极快较低目标稀疏、遮挡少、ID要求不高的场景DeepSORT运动预测外观Re-ID中较高遮挡多、同类别目标密集的场景ByteTrack高低分框两阶段关联快较高监控、车辆、行人通用场景工程首选OC-SORTSORT改进处理遮挡恢复快较高目标频繁遮挡、运动方向突变的场景FairMOT等检测跟踪一体中中低算力部署、想省掉独立关联流程的场景选型就一句话先用低成本方案跑通再根据数据里的真实失败案例决定要不要上重武器。不要为了技术先进一上来就上最复杂的方案算力消耗和调试成本会拖垮你。3. 跟踪器内部原理拆解三个核心机制搞懂就够3.1 卡尔曼滤波跟踪器在猜什么Tracking-by-Detection 的第一个核心是运动预测工程里几乎都用卡尔曼滤波。它要解决的问题很朴素检测框还没出来之前先猜一下每条轨迹下一帧大概在哪个位置。猜得准后续关联就简单猜不准再好的匹配算法也没用。卡尔曼滤波的通俗理解是先按匀速直线运动外推再用新的检测结果修正。在 DeepSORT、ByteTrack 的实现里状态向量通常是 8 维的中心点坐标、宽高比、高度以及它们各自的速度。每次跟踪先做 predict得到预测位置和预测协方差拿到新检测框后做 update用卡尔曼增益权衡预测值和测量值谁更可信。简单说预测值连续且平稳测量值精确但会抖滤波器在两者之间取一个折中。如果你用 filterpy 库每个轨迹就是一个 KalmanFilter 对象关键参数是状态转移矩阵 F、观测矩阵 H、过程噪声 Q、测量噪声 R。Q 代表你对匀速运动假设的信任程度Q 越大预测越容易漂R 代表你对检测框的信任程度R 越大越不敢信测量值。实际调参时我通常把 R 相对调大一点因为检测框本身有抖动过度相信测量值会让轨迹跟着抖ID 反而更容易切。3.2 数据关联IOU 和匈牙利算法怎么配合关联这一步本质上是解决一个分配问题这一帧有 6 个检测框手里有 5 条轨迹谁和谁是一对最朴素的做法是算两两之间的 IOU交并比组成代价矩阵然后找一个最优匹配。工程上三个细节要注意。第一IOU 只适合目标移动不太剧烈的情况。如果摄像头帧率很低或者目标跑得飞快连续两帧的框完全不重叠IOU 直接归零关联自然就断了。监控帧率低于 10 帧每秒的时候纯 IOU 方案很吃力这时要么降低对检测帧率的依赖要么换 DeepSORT 这类有外观特征的方案。第二匹配不一定非得用匈牙利算法。匈牙利算法保证全局最优代价是 O(n^3) 复杂度几百个目标还好上千个就要心疼了。工程里常见的替代是先按 IOU 排序做贪心匹配速度快质量在多数场景下也差不了太多。我自己在目标特别多、算力紧张的边缘设备上会先用贪心跑不行再切回匈牙利。第三要理解优先级。DeepSORT 里有一个 cascade matching 的设计意思是那些很久没匹配上的轨迹快要丢的反而不急着抢新检测框优先让刚匹配过的轨迹去配对。这个先来后到的策略能明显减少长时间遮挡恢复后的 ID 错乱。你理解了这一点后面遇到一堆轨迹抢一个检测框的情况就不会一头雾水。3.3 轨迹生命周期建档、确认、丢失、注销我给检测框发身份证的手法最终落在轨迹状态上。一个典型的生命周期是这样的新检测框进来没有任何已有轨迹匹配上就先创建一条新轨迹ID 用全局递增编号此时状态是 tentative未确认。为什么要确认因为单帧检测可能是误报、可能是遮挡残留你要连续几帧都匹配上才敢相信它是一个稳定目标再允许对外输出。这个连续帧数就是 min_hits 参数。一旦确认轨迹进入正常状态每帧要么匹配到检测框更新一次要么没匹配上变成 lost。lost 不等于死亡跟踪器会保留一个宽限期靠卡尔曼滤波持续预测位置看后面几帧能不能再捡回来。这个宽限期就是 max_age。如果超过 max_age 还是没匹配上或者目标已经出画面轨迹终止ID 作废且不复用。这套状态机的价值是把跟踪过程变成确定性很强的流程。查问题时先看每条轨迹处于什么状态很快就能定位到问题出在检测、关联还是参数配置。4. 实战从零写出一个 ByteTrack 风格的跟踪器4.1 环境准备与数据选择先说环境。Python 3.8 以上核心依赖就三个numpy、opencv-python、filterpy再加一个 scipy 做匈牙利匹配。安装命令一行搞定pip install numpy opencv-python filterpy scipy检测部分用什么都可以YOLOv5、YOLOv8、自己训练的检测器都行甚至可以先拿别人预处理好的检测结果 JSON 来测跟踪逻辑。我经常建议先把检测和跟踪两个变量分开调先用固定检测结果把跟踪调明白再换真实检测器联调不然出了问题都不知道怪谁。数据上跑通演示直接用 MOT16 或 MOT17 的子集最省事或者自己录一段行人视频也行。关键是场景里要有遮挡、有目标进出画面不然显示不出跟踪器的本事。4.2 代码实现核心结构拆开讲下面这份代码刻意精简去掉了很多论文实现里的边角处理保留核心逻辑。它的用途是让你看清轨迹怎么被创建、更新、匹配、删除。先定义坐标转换和 IOU 计算的工具函数import numpy as np from scipy.optimize import linear_sum_assignment from filterpy.kalman import KalmanFilter def xyxy_to_xyah(bbox): x1, y1, x2, y2 bbox w x2 - x1 h y2 - y1 cx (x1 x2) / 2 cy (y1 y2) / 2 return np.array([cx, cy, w / h, h], dtypefloat) def iou(bbox1, bbox2): x1 max(bbox1[0], bbox2[0]) y1 max(bbox1[1], bbox2[1]) x2 min(bbox1[2], bbox2[2]) y2 min(bbox1[3], bbox2[3]) inter_w max(0, x2 - x1) inter_h max(0, y2 - y1) inter_area inter_w * inter_h area1 (bbox1[2] - bbox1[0]) * (bbox1[3] - bbox1[1]) area2 (bbox2[2] - bbox2[0]) * (bbox2[3] - bbox2[1]) union area1 area2 - inter_area return inter_area / union if union 0 else 0我把检测框从 xyxy左上角右下角转成 xyah中心点宽高比高度因为卡尔曼滤波器在 xyah 空间下宽高比比绝对宽高更稳定检测模型的标注格式本来就是这种。接着是单个轨迹的 Track 类。每个轨迹内部挂一个卡尔曼滤波器自己管理 hit_streak、time_since_update 这些计数class Track: _next_id 1 def __init__(self, bbox, score, cls): self.id Track._next_id Track._next_id 1 self.kf KalmanFilter(dim_x8, dim_z4) self.kf.x np.zeros((8,)) # 状态量: cx, cy, aspect_ratio, height, vx, vy, va, vh self.kf.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]], dtypefloat) self.kf.H np.array([ [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, 0, 0, 0, 0]], dtypefloat) state xyxy_to_xyah(bbox) self.kf.x[:4] state self.kf.P[4:, 4:] * 1000.0 # 初始速度未知方差放大 self.kf.P[:4, :4] * 10.0 self.kf.Q * 0.01 self.kf.R * 1.0 self.score score self.cls cls self.hit_streak 1 self.time_since_update 0 self.max_age 30 self.min_hits 3 self.confirmed False self.is_dead False def predict(self): self.kf.predict() self.time_since_update 1 if self.time_since_update self.max_age: self.is_dead True def update(self, bbox, scoreNone, clsNone): state xyxy_to_xyah(bbox) self.kf.update(state) self.time_since_update 0 self.hit_streak 1 if score is not None: self.score score if cls is not None: self.cls cls if not self.confirmed and self.hit_streak self.min_hits: self.confirmed True def get_bbox(self): cx, cy, a, h self.kf.x[:4] w a * h x1 cx - w / 2 y1 cy - h / 2 return np.array([x1, y1, x1 w, y1 h]) def get_state(self): if self.time_since_update self.max_age: return dead if not self.confirmed: return tentative if self.time_since_update 0: return lost return confirmed然后是 Tracker 类负责每一帧的核心调配这一帧检测到了什么手里的轨迹跟谁匹配。流程是 ByteTrack 的精髓高分框先匹配低分框再捡漏两条腿走路class Tracker: def __init__(self, high_thresh0.5, low_thresh0.1, iou_thresh0.3): self.high_thresh high_thresh self.low_thresh low_thresh self.iou_thresh iou_thresh self.tracks [] def update(self, detections): # detections: list of [x1, y1, x2, y2, score, cls] for t in self.tracks: t.predict() self.tracks [t for t in self.tracks if not t.is_dead] high [d for d in detections if d[4] self.high_thresh] low [d for d in detections if self.low_thresh d[4] self.high_thresh] # 候选轨迹还没被判死的 candidates [t for t in self.tracks if t.time_since_update t.max_age] # 第一轮高分检测框 与 候选轨迹 做 IOU 匹配 matched_tracks, matched_dets [], [] unmatched_tracks list(range(len(candidates))) unmatched_dets list(range(len(high))) if len(candidates) 0 and len(high) 0: matched_tracks, matched_dets, unmatched_tracks, unmatched_dets \ self._match(candidates, high) for i_t, i_d in zip(matched_tracks, matched_dets): candidates[i_t].update(high[i_d][:4], high[i_d][4], high[i_d][5]) # 第二轮低分检测框 与 第一轮未匹配的轨迹 做捡漏匹配 if len(unmatched_tracks) 0 and len(low) 0: low_src [candidates[i] for i in unmatched_tracks] m_tracks, m_dets, _, _ self._match(low_src, low) for i_t, i_d in zip(m_tracks, m_dets): low_src[i_t].update(low[i_d][:4]) # 未被匹配的高分检测框创建新轨迹 for i_d in unmatched_dets: d high[i_d] self.tracks.append(Track(d[:4], d[4], d[5])) self.tracks [t for t in self.tracks if not t.is_dead] return [t for t in self.tracks if t.confirmed] def _match(self, tracks, dets): cost np.zeros((len(tracks), len(dets))) for i, t in enumerate(tracks): pred t.get_bbox() for j, d in enumerate(dets): cost[i, j] 1.0 - iou(pred, d[:4]) rows, cols linear_sum_assignment(cost) matched_tracks, matched_dets [], [] unmatched_tracks, unmatched_dets [], [] for i in range(len(tracks)): if i in rows: j cols[list(rows).index(i)] if cost[i, j] 1.0 - self.iou_thresh: matched_tracks.append(i) matched_dets.append(j) continue unmatched_tracks.append(i) matched_dets_set set(matched_dets) unmatched_dets [j for j in range(len(dets)) if j not in matched_dets_set] return matched_tracks, matched_dets, unmatched_tracks, unmatched_dets主程序就很简单了读视频、跑检测、调 update、画框标 IDimport cv2 cap cv2.VideoCapture(demo.mp4) tracker Tracker(high_thresh0.5, low_thresh0.1, iou_thresh0.3) while True: ret, frame cap.read() if not ret: break # 检测器输出: [(x1, y1, x2, y2, score, cls), ...] detections my_detector(frame) confirmed_tracks tracker.update(detections) for t in confirmed_tracks: x1, y1, x2, y2 t.get_bbox().astype(int) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, fID:{t.id}, (x1, y1 - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) cv2.imshow(mot, frame) if cv2.waitKey(1) 0xFF ord(q): break cap.release() cv2.destroyAllWindows()代码其实只做了三件事预测、匹配、更新。你把它读一遍基本就能理解 DeepSORT、ByteTrack 这类框架在做什么了剩下的都是工程上的打磨。4.3 参数调节实操心得参数看着不多但每一个都对结果影响很大。我调参的习惯是一次只动一个变量并且用固定视频片段反复对比。high_thresh 和 low_thresh看检测器输出的分数分布来定。检测器质量好高分框多high_thresh 可以设在 0.5 到 0.6检测器误检多就要往上提。low_thresh 一般 0.1 到 0.2再低会把大量噪声框放进来反而引入一堆一闪而过的路人甲轨迹。iou_thresh 从 0.3 起步。目标运动快、帧率低就往下调到 0.2场景是密集人群、静态摄像头可以往上到 0.4 甚至 0.5。阈值太高容易把两个不同的目标粘在一起阈值太低则轨迹容易断。max_age 和 min_hits 是一对。max_age 默认 30 帧30 帧每秒的视频里就是 1 秒。步行场景 30 到 50 都合理车辆场景目标移动快遮挡后位置预测很快失效设太大反而会在错误的地方捡回错误的目标。min_hits 默认 3min_hits 设得越大误检被采纳成轨迹的门槛越高但代价是真正的目标也要多等几帧才会显示ID。还有一个容易踩的坑如果你为了性能让检测器每隔几帧才跑一次中间帧用上一帧的检测结果那么卡尔曼滤波的预测间隔就变长了Q 要适当调大否则预测会明显滞后。5. 常见问题与排查技巧实录ID 切换怎么根治5.1 高频问题速查表多目标跟踪的排查八成都在下表这几类问题里转圈现象可能原因排查方向同一个目标 ID 频繁切换遮挡、检测框抖动、外观特征判别力不足看切换发生的帧是遮挡还是检测丢失换低分框策略或加 Re-ID一条轨迹断成两截后半段变新 ID遮挡时间超过 max_age或检测持续漏检调大 max_age提升检测召回必要时轨迹插值画面里冒出大量短暂存在的轨迹误检多low_thresh 太低min_hits 太小提高 high_thresh 和 min_hits过滤噪声框两个目标靠近后 ID 互切同类目标距离近纯位置无法区分加外观特征或提高摄像头分辨率目标不动时 ID 也在跳检测框抖动卡尔曼过度相信测量值调大 R、适当调小 Q让轨迹更平滑表格只是方向真到了定位阶段我的经验是不要只看可视化。把每一帧的检测框、预测框、匹配关系、轨迹状态都打出来逐帧对着看。你说那个人 ID 变了怎么证明找一帧 ID 切换前后看看那里发生了什么是被挡住了 50%是检测框突然歪了还是两个目标离得太近大部分问题在那一帧的画面上都能找到答案。5.2 两个真实案例复盘先讲一个商场的客流统计项目。摄像头装在入口上方俯拍遮挡其实不严重但人们排队扫码的时候长时间不动。最初用 SORT人一停卡尔曼的运动预测就失去意义检测框还不停抖动关联经常出错ID 切个不停。后面换了 ByteTrack 的高分低分两阶段策略同时把 high_thresh 从 0.4 提到 0.6低分框兜住了那些被部分遮挡的行人ID Switch 大概降了三分之一。这个案例给我的教训是低分框不是垃圾是遮挡场景下的救命稻草。另一个是体育赛事的球员追踪。场上所有人穿着几乎一样的球衣Re-ID 的外观特征根本分不清人甚至会把不同队员的特征拉到一起。这种时候 DeepSORT 的外观分支不但不帮忙反而添乱不如直接用 ByteTrack 或者 OC-SORT靠位置和运动模型硬扛。所以我一直强调选型不是越贵越好要看数据里外观信息到底有没有判别力。5.3 评估跑分不能只看画面做多目标跟踪一定要跑量化指标否则看起来还行会骗人。常用指标有三个MOTA 综合考量误检、漏检、ID 切换的准确率IDF1 衡量 ID 匹配的 F1 分数ID Switch 就是单纯数切换次数。MOT17 这类数据集上这几个指标可以直接对比业务项目里我会额外统计自己的下游指标比如客流计数误差率轨迹断点数因为这些才直接关系业务。如果你的业务是数人那我说句实在话跟踪 ID 少量切换可以接受只要用户统计口径能兜住。但如果是分析每个人的行为轨迹那 ID 稳定性就是生死线参数要往宁可少发布轨迹也不乱发 ID的方向调。5.4 性能优化三板斧最后说性能。Tracking-by-Detection 的开销大头永远是检测器跟踪本身通常很轻。如果你发现整体跑不动先优化检测。三个通用手段第一检测输入降低分辨率比如 1080p 降到 720p跟踪框再映射回原图人眼基本看不出差别第二限制检测 ROI只检测画面里有业务价值的区域其余地方既不检测也不跟踪第三检测器用 TensorRT、ONNX Runtime 等推理加速多路摄像头时再考虑 batch 推理。如果跟踪自身成为瓶颈通常是因为目标数量太多、匈牙利匹配频繁。这时候可以先试试贪心匹配替代匈牙利再考虑压缩轨迹数量比如把长期静止的轨迹设为休眠状态不参与匹配。6. 最后分享几个落地感受我做了几年多目标跟踪的落地最大的体感是跟踪不是算法性能的比拼而是系统工程的平衡。你需要在检测、关联、状态管理、业务逻辑之间反复权衡。先把代码跑起来然后盯着 ID Switch 的记录反复看找出每一次切换发生的具体帧问自己三个问题这一帧发生了什么是遮挡还是检测丢失还是两个目标太近百分之九十的问题都能这样找到答案。最后再分享一个小技巧调试跟踪器时把每一帧的检测框、预测框、匹配关系、轨迹状态写进 CSV逐帧回放不要只盯着可视化看。你会发现很多看起来是跟踪问题的案例最后都定位到了检测端的漏检或抖动。先修检测再调跟踪这是我一直沿用的顺序。多目标跟踪这个方向资料多、坑也多但核心逻辑并不复杂。把预测、匹配、更新这三件事吃透剩下的就是数据和参数的体力活。希望这篇实战经验能帮你少走点弯路。