☰
多目标跟踪实战:从卡尔曼滤波到ByteTrack,彻底解决ID Switch
2026/10/28 6:42:11 网站建设 项目流程

简介:这份资源面向计算机视觉初学者与多目标跟踪方向的开发者,聚焦“VIBE前景检测+卡尔曼滤波跟踪”这一经典组合,帮助读者理解如何在复杂背景中先提取动态前景、再对多个目标进行持续定位与状态估计。压缩包共16个文件,以7个cpp源文件与7个h头文件为主体,分别承载前景建模、检测器、匈牙利匹配、卡尔曼滤波与跟踪器等模块,另含1个pro工程文件与1个user配置,整体约18KB,结构紧凑、便于直接编译实验。目前已有295人学习下载。通过阅读与调试这些代码,读者可以掌握VIBE背景建模的像素级更新思路、卡尔曼滤波器对位置与速度的预测更新流程,以及检测结果与跟踪轨迹之间的关联匹配方法,为视频监控、自动驾驶感知等场景下的多目标跟踪实践提供可复用的参考实现。

1. 多目标跟踪:为什么你的 ID 总在遮挡后“换人”

多目标跟踪(Multi-Object Tracking,MOT)要解决的问题很具体:一段视频里同时有多个人或车,算法不仅要在每一帧把它们框出来,还要给同一个目标从头到尾分配同一个 ID。检测模型只告诉你“这一帧这里有个人”,跟踪器要回答的是“这个人和上一帧那个人是不是同一个”。真正让从业者头疼的不是检测精度,而是 ID Switch——目标被遮挡几帧再出现,ID 变了,下游的客流计数、轨迹分析、行为识别全部作废。

这个方向适合两类人:一类是做视频结构化、智慧园区、交通流量统计的工程团队,需要把检测结果串成轨迹;另一类是刚接触 MOT 的算法同学,想搞明白 SORT、DeepSORT、ByteTrack 这条演进线到底在解决什么。下面按“先跑通最小闭环、再调参、再避坑”的顺序讲,代码基于常见的 Python + NumPy + SciPy 组合,检测器用任意能输出框和分数的模型即可替换。

2. 从检测框到轨迹:多目标跟踪的最小闭环怎么搭

2.1 跟踪器的本质是“预测 + 匹配 + 更新”三步循环

不管多复杂的跟踪器,骨架都是这三步。预测:用运动模型估计每个已有轨迹在当前帧应该出现在哪,最常用的是卡尔曼滤波,状态量取[cx, cy, aspect_ratio, height, vx, vy, va, vh],即中心点、宽高比、高度及其速度。匹配:把预测框和当前帧检测框做代价矩阵,用匈牙利算法求最优分配。更新:匹配上的轨迹用检测框修正卡尔曼状态,没匹配上的检测框新建轨迹,连续多帧没匹配上的轨迹删除。

选型上,SORT 只靠 IoU 做匹配,速度快但在遮挡和低帧率下容易断;DeepSORT 加了外观特征(ReID 嵌入),代价矩阵变成运动代价和外观代价的加权,ID 保持更稳但多了一个特征提取网络的开销;ByteTrack 的思路是把检测框按分数分成高低两组,先用高分框匹配,再用低分框去“捞”那些没匹配上的轨迹,对遮挡场景友好且不需要外观模型。工程上我一般先上 ByteTrack 类方案打底,确认检测质量没问题,再决定要不要引入 ReID。

2.2 用卡尔曼滤波加匈牙利匹配跑通第一版

先写一个最小可跑的跟踪器,不依赖任何跟踪框架,方便你理解每一步在干什么。下面这段代码实现单类目标的 SORT 式跟踪,输入是每帧的检测数组[[x1,y1,x2,y2,score], ...]。

import numpy as np from scipy.optimize import linear_sum_assignment class KalmanBoxTracker: def __init__(self, bbox, track_id): # 状态: cx, cy, aspect, h, vx, vy, va, vh self.id = track_id self.x = np.zeros((8, 1)) cx, cy, w, h = self._to_cxcywh(bbox) self.x[:4] = np.array([[cx], [cy], [w / h], [h]]) self.P = np.eye(8) * 10.0 # 初始协方差,给大一点 self.F = np.eye(8) for i in range(4): self.F[i, i + 4] = 1.0 # 匀速模型:位置 += 速度 self.H = np.eye(4, 8) # 只观测前四维 self.Q = np.eye(8) * 0.01 # 过程噪声 self.R = np.eye(4) * 1.0 # 观测噪声 self.time_since_update = 0 self.hits = 1 def _to_cxcywh(self, b): x1, y1, x2, y2 = b[:4] return (x1 + x2) / 2, (y1 + y2) / 2, x2 - x1, y2 - y1 def predict(self): if self.x[3] + self.x[7] <= 0: # 高度不能为负 self.x[7] = 0 self.x = self.F @ self.x self.P = self.F @ self.P @ self.F.T + self.Q self.time_since_update += 1 return self._to_xyxy() def update(self, bbox): z = np.array(self._to_cxcywh(bbox)).reshape(4, 1) S = self.H @ self.P @ self.H.T + self.R K = self.P @ self.H.T @ np.linalg.inv(S) self.x = self.x + K @ (z - self.H @ self.x) self.P = (np.eye(8) - K @ self.H) @ self.P self.time_since_update = 0 self.hits += 1 def _to_xyxy(self): cx, cy, a, h = self.x[:4].flatten() w = a * h return np.array([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2])

这段代码的关键在F和H两个矩阵:F把速度累加到位置上,H说明我们只能观测到位置和尺寸、观测不到速度。Q和R是调参重点,后面单独讲。time_since_update是判断轨迹是否该删除的依据。

匹配环节用 IoU 构造代价矩阵,再用linear_sum_assignment求最小代价分配:

def iou_batch(a, b): # a: (N,4) b: (M,4) -> (N,M) a = a[:, None, :] b = b[None, :, :] xx1 = np.maximum(a[..., 0], b[..., 0]) yy1 = np.maximum(a[..., 1], b[..., 1]) xx2 = np.minimum(a[..., 2], b[..., 2]) yy2 = np.minimum(a[..., 3], b[..., 3]) w = np.maximum(0.0, xx2 - xx1) h = np.maximum(0.0, yy2 - yy1) inter = w * h area_a = (a[..., 2] - a[..., 0]) * (a[..., 3] - a[..., 1]) area_b = (b[..., 2] - b[..., 0]) * (b[..., 3] - b[..., 1]) return inter / (area_a + area_b - inter + 1e-6) def associate(detections, trackers, iou_threshold=0.3): if len(trackers) == 0: return [], list(range(len(detections))), [] cost = 1.0 - iou_batch(np.array(trackers), np.array(detections)) rows, cols = linear_sum_assignment(cost) matches, unmatched_dets = [], [] for r, c in zip(rows, cols): if cost[r, c] > 1 - iou_threshold: unmatched_dets.append(c) else: matches.append((r, c)) matched_rows = {m[0] for m in matches} unmatched_trks = [i for i in range(len(trackers)) if i not in matched_rows] return matches, unmatched_dets, unmatched_trks

iou_threshold=0.3是 SORT 的常用起点,低于这个 IoU 就认为不是同一个目标。注意代价矩阵用1 - IoU,因为匈牙利算法求的是最小代价。匹配完之后,未匹配的检测新建轨迹,未匹配的轨迹time_since_update加一,超过max_age(常用 30 帧)就删除。

2.3 把检测器接进来:帧循环与轨迹生命周期管理

跟踪器本身不产生检测,帧循环里要做的是:读帧、跑检测、把检测喂给跟踪器、拿回带 ID 的框。下面是一个可复现的主循环骨架,检测部分用占位函数,替换成你自己的模型输出即可。

def run_tracking(video_frames, detect_fn, max_age=30, min_hits=3): trackers, next_id, results = [], 0, [] for frame in video_frames: dets = detect_fn(frame) # 返回 (N,5): x1,y1,x2,y2,score dets = dets[dets[:, 4] > 0.4] # 低分框先过滤,阈值按场景调 trk_boxes = [t.predict() for t in trackers] matches, unmatched_dets, unmatched_trks = associate(dets, trk_boxes) for r, c in matches: trackers[r].update(dets[c]) for c in unmatched_dets: trackers.append(KalmanBoxTracker(dets[c], next_id)) next_id += 1 # 清理长时间未更新的轨迹 trackers = [t for t in trackers if t.time_since_update <= max_age] frame_out = [] for t in trackers: if t.time_since_update == 0 and t.hits >= min_hits: frame_out.append((*t._to_xyxy(), t.id)) results.append(frame_out) return results

min_hits=3表示轨迹至少被连续匹配 3 帧才输出,能过滤掉检测器偶发的误检。max_age=30决定目标消失后 ID 保留多久,帧率 25fps 时约 1.2 秒,遮挡时间超过这个值 ID 必然断。这两个参数是新手最容易拍脑袋设的,后面避坑章节会讲怎么根据场景反推。

3. 多目标跟踪的参数怎么调:卡尔曼噪声、匹配阈值与轨迹寿命

3.1 卡尔曼滤波的 Q 和 R 决定“信运动还是信检测”

Q是过程噪声,代表你对匀速假设的不信任程度;R是观测噪声,代表你对检测框的不信任程度。Q 大,滤波器更相信检测,响应快但轨迹抖;Q 小,更相信运动模型,轨迹平滑但目标突然加速时会滞后。R 大则相反,检测框的抖动被压下去,但真实机动也被抹平。

实操上有个简单判据:如果检测框本身抖动明显(比如小目标、低分辨率),把R调大,比如np.eye(4) * 5.0;如果目标运动剧烈(快速转身、急刹),把Q调大,比如np.eye(8) * 0.1。我一般先用Q=0.01, R=1.0跑一段,看轨迹是否在目标静止时漂移、在目标加速时滞后,再单向调整。注意Q和R是相对关系,同时放大没有意义。

3.2 IoU 阈值与低分框补救:ByteTrack 思路的落地参数

纯 IoU 匹配在拥挤场景下会翻车:两个人挨得近,预测框和检测框的 IoU 矩阵出现多个接近的值,匈牙利算法可能分配错。常见做法是把匹配阈值从 0.3 提到 0.5 减少误配,但代价是遮挡后更容易断。ByteTrack 的解法值得抄:第一轮用高分框(score > 0.5)和所有轨迹匹配,阈值 0.3;第二轮用低分框(0.1 < score < 0.5)和第一轮没匹配上的轨迹再匹配一次,阈值放到 0.5。

def byte_associate(dets, trk_boxes, high_th=0.5, iou_th=0.3, low_iou_th=0.5): high = dets[dets[:, 4] >= high_th] low = dets[(dets[:, 4] < high_th) & (dets[:, 4] > 0.1)] m1, u_det1, u_trk1 = associate(high, trk_boxes, iou_th) # 第二轮:低分框只去捞第一轮剩下的轨迹 remain_trk = [trk_boxes[i] for i in u_trk1] m2, _, _ = associate(low, remain_trk, low_iou_th) return m1, m2, u_det1, u_trk1

第二轮阈值放到 0.5 是因为低分框位置本身不准,放宽 IoU 要求反而能接住被遮挡的目标。这个改动对遮挡场景的 ID 保持提升明显,代价是多一次匹配计算,几乎可以忽略。

3.3 轨迹寿命与确认帧数:按帧率和遮挡时长反推

max_age不能拍脑袋。先算你的场景里最长遮挡时长:比如路口场景,一辆车被公交车挡住约 2 秒,帧率 25fps,那就是 50 帧,max_age至少设 50,否则遮挡结束必然换 ID。但max_age太大也有代价:目标真正离开画面后,轨迹还挂着,新目标进来可能被误匹配到旧轨迹上。折中做法是max_age取最长遮挡帧数的 1.2 倍,同时配合min_hits防止误检建轨。

min_hits同理,检测器在低置信度区间容易产生闪烁的误检,min_hits=3能压掉大部分。但如果你的场景目标出现时间很短(比如快速穿过画面的行人),min_hits太大会导致目标还没确认就离开了,这时候降到 2 甚至 1,靠检测分数阈值来兜底。

4. 多目标跟踪避坑:ID Switch、轨迹漂移与误匹配的排查清单

4.1 现象:遮挡结束后 ID 变了

原因通常有三个:max_age小于实际遮挡帧数;遮挡期间检测框分数掉到阈值以下被过滤;卡尔曼预测在长时间无观测后漂移太远,重新出现时 IoU 已经不够。解决顺序是先看日志里该轨迹的time_since_update峰值,如果接近max_age,直接调大;如果检测分数在遮挡期间低于 0.4,把过滤阈值降到 0.3 并启用低分框第二轮匹配;如果预测漂移,检查Q是否过小导致模型过度自信。

4.2 现象:两个相邻目标的 ID 互换

这是拥挤场景的典型翻车。根因是 IoU 矩阵在两人重叠时出现歧义,匈牙利算法按全局最小代价分配,可能把 A 的检测配给 B 的轨迹。解决思路是引入外观特征做二次校验:对匹配上的轨迹和检测,算 ReID 嵌入的余弦距离,超过阈值就拆开重新分配。没有 ReID 模型时,可以加一个运动方向一致性约束——比较轨迹速度和检测框相对上一帧的位移方向,夹角过大则拒绝匹配。

4.3 现象:轨迹在静止目标上缓慢漂移

目标站着不动,框却慢慢偏移。原因是卡尔曼的速度分量在无观测更新时持续累加微小噪声,Q里的速度项给了它“漂”的空间。解决是把Q中速度维度的噪声调小,或者在目标连续多帧位移小于阈值时把速度分量强制置零。另一个常见原因是检测框本身在静止目标上有 1-2 像素抖动,R太小放大了这个抖动,适当增大R能缓解。

4.4 现象:新目标一出现就复用了旧 ID

目标离开画面后轨迹没及时删除,新目标进来时 IoU 恰好匹配上旧轨迹。检查max_age是否过大,以及删除逻辑是否只在time_since_update > max_age时触发。更隐蔽的情况是检测器在画面边缘有残留误检,让旧轨迹一直被“续命”。排查方法是把每条轨迹的出生帧、最后更新帧、匹配历史打日志,看旧轨迹在目标离开后是否还有匹配记录。

4.5 现象:帧率变化后跟踪效果整体变差

卡尔曼的匀速模型假设“每帧位移相同”,帧率从 25fps 降到 10fps 后,同样的速度对应更大的每帧位移,Q的相对尺度变了,预测滞后明显。解决是把速度状态按帧间隔缩放,或者干脆在预处理阶段把视频重采样到固定帧率再跟踪。这个坑在跨数据集验证时特别常见,血泪经验是:换数据源第一件事就是确认帧率和分辨率,别直接套参数。

5. 多目标跟踪的验证与进阶:用 MOT 指标定位瓶颈,再决定要不要上 ReID

调完参数不能靠肉眼看,得有量化指标。MOT 挑战赛的指标体系里,最该盯的是 MOTA、IDF1 和 ID Switch 三个。MOTA 综合了漏检、误检和 ID 错误,但对 ID Switch 不敏感;IDF1 衡量 ID 的长期一致性,是判断“换人”问题的核心指标;ID Switch 直接数 ID 变化次数。下面是一个轻量的评估脚本,输入是逐帧的预测轨迹和标注轨迹,输出这三个指标。

def eval_mot(gt_frames, pred_frames, iou_th=0.5): # gt_frames/pred_frames: list[list[(x1,y1,x2,y2,track_id)]] tp = fp = fn = idsw = 0 last_match = {} # gt_id -> pred_id for gt, pred in zip(gt_frames, pred_frames): if not gt or not pred: fn += len(gt); fp += len(pred); continue iou = iou_batch(np.array([g[:4] for g in gt]), np.array([p[:4] for p in pred])) rows, cols = linear_sum_assignment(1 - iou) matched_gt, matched_pred = set(), set() for r, c in zip(rows, cols): if iou[r, c] < iou_th: continue matched_gt.add(r); matched_pred.add(c) tp += 1 gid, pid = gt[r][4], pred[c][4] if gid in last_match and last_match[gid] != pid: idsw += 1 last_match[gid] = pid fn += len(gt) - len(matched_gt) fp += len(pred) - len(matched_pred) mota = 1 - (fn + fp + idsw) / max(1, tp + fn) idf1 = 2 * tp / max(1, 2 * tp + fp + fn) return {"MOTA": round(mota, 4), "IDF1": round(idf1, 4), "IDSW": idsw}

这个脚本是简化版,真实评估还要处理轨迹片段拼接和忽略区域,但足够定位瓶颈。用法很直接:先跑一版基线,看 IDSW 集中在哪些片段,回放那些片段确认是遮挡、拥挤还是检测漏检导致。如果 IDSW 高但 MOTA 还行,说明检测没问题、匹配策略要改,优先上低分框补救或外观特征;如果 MOTA 低且 FN 高,问题在检测器,调跟踪参数是白费力气。

要不要上 ReID,我的判断标准是:IDF1 低于 0.6 且 IDSW 主要发生在遮挡和交叉场景,才值得引入外观模型。ReID 的代价是每帧多一次前向推理,实时性要求高的场景(比如 30fps 以上、边缘设备)要慎重。一个折中技巧是只在匹配歧义时才算外观距离——先用 IoU 筛出候选对,只对代价接近的几对算 ReID,能把开销压到全量计算的十分之一左右。

最后说个习惯:每次改参数只动一个,改完在同一段固定视频上跑评估,把 MOTA、IDF1、IDSW 记到表格里。多目标跟踪的参数之间耦合很强,一次改三个你根本不知道是哪个起了作用,这个黑匣子只能靠单变量对比拆开。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询