多目标跟踪与视频记忆 本节摘要:跟踪 = 检测 + 关联。每帧检测,把这帧的检测按 ID 匹配到上帧的轨迹。本节区分跟踪即检测与基于查询的跟踪,命名算法家族(SORT、DeepSORT、ByteTrack、BoT-SORT、SAM 2 记忆跟踪器、SAM 3.1 Object Multiplex);从零实现 IoU 代价矩阵 + 匈牙利分配的经典跟踪即检测;讲清 SAM 2 的记忆库为何比基于 IoU 的关联更能扛遮挡,以及 SAM 3.1 Object Multiplex 如何用共享记忆高效跟踪同概念的许多实例;读懂 MOTA、IDF1、HOTA 三个指标并按用例挑。读完本节,你能为体育分析、监控、自动驾驶选对跟踪栈。 对应原课程:Phase 4 · Lesson 27 · (原英文 )。
本节摘要:跟踪 = 检测 + 关联。每帧检测,把这帧的检测按 ID 匹配到上帧的轨迹。本节区分跟踪即检测与基于查询的跟踪,命名算法家族(SORT、DeepSORT、ByteTrack、BoT-SORT、SAM 2 记忆跟踪器、SAM 3.1 Object Multiplex);从零实现 IoU 代价矩阵 + 匈牙利分配的经典跟踪即检测;讲清 SAM 2 的记忆库为何比基于 IoU 的关联更能扛遮挡,以及 SAM 3.1 Object Multiplex 如何用共享记忆高效跟踪同概念的许多实例;读懂 MOTA、IDF1、HOTA 三个指标并按用例挑。读完本节,你能为体育分析、监控、自动驾驶选对跟踪栈。
对应原课程:Phase 4 · Lesson 27 ·
multi-object-tracking(原英文phases/04-computer-vision/27-multi-object-tracking/docs/en.md)。
阅读完本节,你应当能够:
检测器告诉你单帧里物体在哪;跟踪器告诉你帧 t 的哪个检测与帧 t−1 的某个检测是同一物体。没有它,你无法数过线物体、跟踪穿过遮挡的球、或知道「4 号车已在这条道 8 秒」。
跟踪对每个面向视频的产品都不可或缺:体育分析、监控、自动驾驶、医学视频分析、野生动物监测、计数。核心构件共享:逐帧检测器、运动模型(卡尔曼滤波或更丰富者)、关联步(在 IoU/余弦/学得特征上的匈牙利算法)、轨迹生命周期(诞生、更新、消亡)。
2026 年带来两个新模式:SAM 2 基于记忆的跟踪(特征记忆替代运动模型关联)和 SAM 3.1 Object Multiplex(同概念多实例的共享记忆)。本节先走经典栈,再走基于记忆的方法。
2026 年你会遇到的每个跟踪器都是这个循环的变体,差异在:
卡尔曼滤波维护每轨迹状态 (x, y, w, h, dx, dy, dw, dh) 加协方差。每帧用恒速模型预测状态,再用匹配检测更新。预测不确定性高时更新更信检测。这给出平滑轨迹,且能穿过短遮挡(1~5 帧)延续轨迹。
每个经典跟踪器在运动预测步都用卡尔曼滤波。
给定 M×N 代价矩阵(轨迹×检测),找总代价最小的一对一分配。代价通常是 1 − IoU(轨迹框, 检测框) 或外观特征的负余弦相似度。运行时 O((M+N)³),M、N 到约 1000 时 Python 经 scipy.optimize.linear_sum_assignment 仍够快。
标准跟踪器丢掉低置信度检测(<0.5)。ByteTrack 把它们留作第二阶段候选:把轨迹与高置信度检测匹配后,未匹配轨迹用稍宽松的 IoU 阈值再试低置信度检测,恢复短遮挡、人群附近的 ID 切换。
SAM 2 通过维护每实例时空特征的记忆库处理视频。给定一帧上的提示(点、框、文本),它把实例编码进记忆。后续帧上,记忆与新帧特征做交叉注意力,解码器在新帧产出同一实例的掩码。
无卡尔曼、无匈牙利,关联隐含在记忆-注意力操作里。
优点:对大遮挡鲁棒(记忆跨多帧携带身份);配 SAM 3 文本提示时开放词表;无需独立运动模型。
缺点:对多目标跟踪比 ByteTrack 慢;记忆库增长,限制上下文窗口。
先前 SAM 2 / SAM 3 跟踪每实例一个独立记忆库,50 个物体就 50 个记忆库。Object Multiplex(2026 年 3 月)把它们折叠成一个带每实例查询 token 的共享记忆,代价随实例数次线性增长。
Multiplex 是 2026 人群跟踪的新默认:演唱会、仓库工人、交通路口。
1 − (FN + FP + ID 切换) / GT,按错误类型加权,单一指标但混淆了检测和关联失败。监控(谁是谁)报 IDF1;体育分析(数传球)报 HOTA;学术通用比较报 HOTA。
import numpy as np def bbox_iou(a, b): """ a, b: (N, 4) 的 [x1, y1, x2, y2] 数组。 返回 (N_a, N_b) IoU 矩阵。 """ ax1, ay1, ax2, ay2 = a[:, 0], a[:, 1], a[:, 2], a[:, 3] bx1, by1, bx2, by2 = b[:, 0], b[:, 1], b[:, 2], b[:, 3] inter_x1 = np.maximum(ax1[:, None], bx1[None, :]) inter_y1 = np.maximum(ay1[:, None], by1[None, :]) inter_x2 = np.minimum(ax2[:, None], bx2[None, :]) inter_y2 = np.minimum(ay2[:, None], by2[None, :]) inter = np.clip(inter_x2 - inter_x1, 0, None) * np.clip(inter_y2 - inter_y1, 0, None) area_a = (ax2 - ax1) * (ay2 - ay1) area_b = (bx2 - bx1) * (by2 - by1) union = area_a[:, None] + area_b[None, :] - inter return inter / np.clip(union, 1e-8, None)
固定恒速卡尔曼此处省略——这里用简单 IoU 关联;生产中卡尔曼预测必不可少。sort Python 包提供完整版。
from scipy.optimize import linear_sum_assignment class Track: def __init__(self, tid, bbox, frame): self.id = tid self.bbox = bbox self.last_frame = frame self.hits = 1 def update(self, bbox, frame): self.bbox = bbox self.last_frame = frame self.hits += 1 class SimpleTracker: def __init__(self, iou_threshold=0.3, max_age=5): self.tracks = [] self.next_id = 1 self.iou_threshold = iou_threshold self.max_age = max_age def step(self, detections, frame): if not self.tracks: for d in detections: self.tracks.append(Track(self.next_id, d, frame)) self.next_id += 1 return [(t.id, t.bbox) for t in self.tracks] track_boxes = np.array([t.bbox for t in self.tracks]) det_boxes = np.array(detections) if len(detections) else np.empty((0, 4)) iou = bbox_iou(track_boxes, det_boxes) if len(det_boxes) else np.zeros((len(track_boxes), 0)) cost = 1 - iou cost[iou < self.iou_threshold] = 1e6 matched_track = set() matched_det = set() if cost.size > 0: row, col = linear_sum_assignment(cost) for r, c in zip(row, col): if cost[r, c] < 1.0: self.tracks[r].update(det_boxes[c], frame) matched_track.add(r); matched_det.add(c) for i, d in enumerate(det_boxes): if i not in matched_det: self.tracks.append(Track(self.next_id, d, frame)) self.next_id += 1 self.tracks = [t for t in self.tracks if frame - t.last_frame <= self.max_age] return [(t.id, t.bbox) for t in self.tracks]
60 行。吃逐帧检测,吐逐帧轨迹 ID。真实系统加卡尔曼预测、ByteTrack 第二阶段再匹配、外观特征。
def synthetic_frames(num_frames=20, num_objects=3, H=240, W=320, seed=0): rng = np.random.default_rng(seed) starts = rng.uniform(20, 200, size=(num_objects, 2)) velocities = rng.uniform(-5, 5, size=(num_objects, 2)) frames = [] for f in range(num_frames): dets = [] for i in range(num_objects): cx, cy = starts[i] + f * velocities[i] dets.append([cx - 10, cy - 10, cx + 10, cy + 10]) frames.append(dets) return frames tracker = SimpleTracker() for f, dets in enumerate(synthetic_frames()): tracks = tracker.step(dets, f)
三个直线运动的物体应跨全部 20 帧保持 ID。
def count_id_switches(tracks_per_frame, gt_per_frame): """ tracks_per_frame: (track_id, bbox) 的列表的列表 gt_per_frame: (gt_id, bbox) 的列表的列表 返回 ID 切换次数。 """ prev_assignment = {} switches = 0 for tracks, gts in zip(tracks_per_frame, gt_per_frame): if not tracks or not gts: continue t_boxes = np.array([b for _, b in tracks]) g_boxes = np.array([b for _, b in gts]) iou = bbox_iou(g_boxes, t_boxes) for g_idx, (gt_id, _) in enumerate(gts): j = iou[g_idx].argmax() if iou[g_idx, j] > 0.5: t_id = tracks[j][0] if gt_id in prev_assignment and prev_assignment[gt_id] != t_id: switches += 1 prev_assignment[gt_id] = t_id return switches
这是简化的 IDF1 邻近指标:数真值物体换了多少次分配到的预测轨迹 ID。真实 MOTA / IDF1 / HOTA 工具在 py-motmetrics 和 TrackEval。
2026 生产跟踪器:
ultralytics——内置 YOLOv8 + ByteTrack / BoT-SORT,results = model.track(source, tracker="bytetrack.yaml"),默认之选。supervision(Roboflow)——ByteTrack 包装加标注工具。processor.track() 做基于记忆的跟踪。sort-tracker / OC-SORT / StrongSORT。挑选:
本节产出两个可复用文件(位于原课程 outputs/):
prompt-tracker-picker.md:按场景类型、遮挡模式、延迟预算,在 SORT / ByteTrack / BoT-SORT / SAM 2 / SAM 3.1 间挑。skill-mot-evaluator.md:写出对真值轨迹评估 MOTA / IDF1 / HOTA 的完整评估台。transformers)作替代后端,在 30 秒人群片段上跑 SimpleTracker 和 SAM 2,对比 ID 切换数,为 5 个显著人物手工标真值 ID。scipy.optimize.linear_sum_assignment,O((M+N)³)。下一节(本章最后一节)进入世界模型与视频扩散——让模型理解物理世界的动态,生成符合物理的视频,迈向具身智能的下一步。