简介:本资源是一套面向计算机视觉研究者与算法工程师的离线测试平台,聚焦YOLOv7目标检测与多目标跟踪算法的横向对比,适用于视频监控、自动驾驶、机器人导航等场景的算法选型与性能评估。平台集成SORT、DeepSORT、ByteTrack、Bot-SORT等多种跟踪算法,并基于VisDrone2019无人机航拍数据集进行统一测试,便于在相同条件下比较各算法在遮挡、低分辨率等复杂环境中的表现。压缩包共393个文件,约229.98MB,以134个py源码与204个pyc编译文件为主体,辅以yaml配置、png结果图、mp4演示视频、txt说明及md文档,另含Dockerfile与ui界面文件,结构完整便于二次开发。已有107人学习下载。读者可获得完整的检测跟踪对比方案、算法运行脚本、可视化结果与操作说明,快速复现实验并定位各算法优劣,为项目选型与优化提供参考。
1. 无人机视角下的多目标跟踪:为什么你的 YOLOv7 检测框总在跳
把 YOLOv7 和 SORT、DeepSORT、ByteTrack、Bot-SORT 放在同一个离线测试平台上跑 VisDrone2019,这件事的价值不在于“集成了多少算法”,而在于你能在一个可控环境里回答一个很具体的问题:同一段无人机视频,同一套检测框,换不同跟踪器,ID Switch 到底差多少。VisDrone2019 的航拍视角决定了它的难点和地面监控完全不同——目标像素少、密度高、遮挡频繁、相机自身还在动。很多人在 MOT17 上跑得好好的跟踪器,换到 VisDrone 上 IDF1 直接掉十几个点,这不是玄学,是检测质量和运动模型同时退化导致的。
这套离线测试平台要解决的核心诉求是:把检测和跟踪解耦,固定 YOLOv7 的检测输出,让 SORT、DeepSORT、ByteTrack、Bot-SORT 在完全相同的输入下比较。适合谁用?做无人机视觉感知的工程师、需要选跟踪器落地到边缘设备的开发者、以及想理解“检测器换跟踪器到底影响多大”的研究者。下面从数据准备、检测推理、各跟踪器接入、参数调优到避坑,按可复现的路径讲清楚。
2. VisDrone2019 数据准备与 YOLOv7 检测输出固化
2.1 VisDrone2019 的目录结构与标注格式转换
VisDrone2019 的 MOT 子集标注格式和 YOLO 训练格式不一样,它用的是帧级 txt,每行包含 frame_index、target_id、bbox_left、bbox_top、bbox_width、bbox_height、score、object_category、truncation、occlusion。做跟踪比较时,你需要的不是检测训练标签,而是检测推理结果和跟踪评测用的 GT。常见做法是先把 VisDrone 的 MOT 标注转成 MOTChallenge 格式,方便后续用 motmetrics 或 TrackEval 评测。
import os import pandas as pd # VisDrone2019-MOT 标注转 MOTChallenge 格式 # 输入: VisDrone 原始标注文件, 每行 10 个字段 # 输出: MOTChallenge 格式 gt.txt, 每行 9 个字段 def visdrone_to_motchallenge(src_txt, dst_txt): rows = [] with open(src_txt, 'r') as f: for line in f: parts = line.strip().split(',') if len(parts) < 8: continue frame_id = int(parts[0]) target_id = int(parts[1]) left = float(parts[2]) top = float(parts[3]) w = float(parts[4]) h = float(parts[5]) score = float(parts[6]) category = int(parts[7]) # MOTChallenge: frame, id, x, y, w, h, conf, -1, -1 # 只保留行人类别(VisDrone 中 category=1 为 pedestrian) if category != 1: continue rows.append([frame_id, target_id, left, top, w, h, 1, -1, -1]) df = pd.DataFrame(rows) df.to_csv(dst_txt, index=False, header=False) # 参数说明: # src_txt: VisDrone 原始标注路径, 如 annotations/0000001_1.txt # dst_txt: 输出 MOTChallenge 格式路径, 如 gt/0000001.txt # category=1 表示行人, 做行人跟踪比较时只保留这一类这段转换的关键在于类别过滤。VisDrone 包含行人、车辆、自行车等多类目标,如果你要比较的是行人跟踪,必须把 category 限定为 1,否则 GT 里混入车辆会导致 ID 指标虚高。另一个坑是 VisDrone 的 target_id 在跨帧时可能不连续,转换后要检查是否有 ID 跳变。
2.2 用 YOLOv7 生成统一的检测结果文件
跟踪器比较的前提是检测输入完全一致。我一般会先用 YOLOv7 对 VisDrone2019 的测试序列做一次离线推理,把每帧的检测框存成 txt,格式为 frame, x1, y1, x2, y2, score, class。这样后面换跟踪器时不需要重复跑检测,也避免了不同跟踪器内部预处理导致的差异。
# YOLOv7 离线推理 VisDrone2019 测试序列 # 输出每帧检测结果到 det/ 目录 python detect.py \ --weights yolov7_visdrone.pt \ --source /data/VisDrone2019-MOT/test/sequences/ \ --img-size 1280 1280 \ --conf-thres 0.25 \ --iou-thres 0.45 \ --save-txt \ --save-conf \ --project runs/detect \ --name visdrone_det参数上,--img-size建议设成 1280,VisDrone 的小目标在 640 下召回率会明显下降。--conf-thres设 0.25 是一个折中,太低会引入大量误检,跟踪器会把噪声当目标跟;太高则漏检严重,ID Switch 反而增加。--save-txt和--save-conf必须同时开,否则后面跟踪器拿不到置信度,ByteTrack 这类依赖 score 分层的算法就没法工作。
检测结果固化后,建议做一次统计:每帧平均检测框数量、置信度分布、小目标占比。如果平均每帧检测框少于 10 个,说明置信度阈值偏高;如果超过 80 个,说明误检太多,需要先调检测器再谈跟踪。
2.3 检测结果与跟踪输入的格式对齐
不同跟踪器对检测输入的格式要求不同。SORT 和 ByteTrack 通常接受[x1, y1, x2, y2, score],DeepSORT 还需要额外的特征向量,Bot-SORT 则可能要求[x1, y1, x2, y2, score, class]。在平台里最好统一成一种中间格式,再用适配器转换。
# 统一检测格式适配器 # 输入: YOLOv7 输出的 txt 行 # 输出: 各跟踪器需要的 numpy 数组 import numpy as np def parse_yolo_txt(line): # YOLOv7 save-txt 格式: class x_center y_center w h conf (归一化) parts = line.strip().split() cls = int(parts[0]) xc, yc, w, h = map(float, parts[1:5]) conf = float(parts[5]) if len(parts) > 5 else 1.0 return cls, xc, yc, w, h, conf def to_xyxy(xc, yc, w, h, img_w, img_h): x1 = (xc - w / 2) * img_w y1 = (yc - h / 2) * img_h x2 = (xc + w / 2) * img_w y2 = (yc + h / 2) * img_h return [x1, y1, x2, y2] # 参数说明: # img_w, img_h: 推理时的输入尺寸, 必须和 detect.py 的 --img-size 一致 # 如果 detect.py 用了 --img-size 1280, 这里就填 1280这里最容易翻车的地方是归一化坐标还原。YOLOv7 的 save-txt 输出是相对于推理尺寸归一化的,如果你用 1280 推理但按 1920 还原,所有框都会偏。建议在平台里把推理尺寸写进配置,检测解析时直接读取,不要硬编码。
3. SORT 与 DeepSORT 在 VisDrone 上的接入与参数调优
3.1 SORT 的最小实现与卡尔曼滤波参数
SORT 的核心是卡尔曼滤波做运动预测加匈牙利算法做关联。在 VisDrone 这种相机运动的场景下,SORT 的匀速模型假设经常不成立,但它的优势是快,适合做基线。接入时最关键的是卡尔曼滤波的过程噪声和观测噪声参数。
from filterpy.kalman import KalmanFilter import numpy as np def create_kalman_filter(): kf = KalmanFilter(dim_x=7, dim_z=4) # 状态: [x, y, s, r, vx, vy, vs] # 观测: [x, y, s, r] kf.F = np.array([ [1,0,0,0,1,0,0], [0,1,0,0,0,1,0], [0,0,1,0,0,0,1], [0,0,0,1,0,0,0], [0,0,0,0,1,0,0], [0,0,0,0,0,1,0], [0,0,0,0,0,0,1] ], dtype=float) kf.H = np.array([ [1,0,0,0,0,0,0], [0,1,0,0,0,0,0], [0,0,1,0,0,0,0], [0,0,0,1,0,0,0] ], dtype=float) # 过程噪声: 位置小, 速度大 kf.Q[4:,4:] *= 0.01 kf.Q[:4,:4] *= 0.01 # 观测噪声: VisDrone 检测框抖动大, R 要适当放大 kf.R[2:,2:] *= 10.0 kf.P[4:,4:] *= 1000.0 kf.P *= 10.0 return kf # 参数说明: # Q 越大, 模型越信任观测, 跟踪框跟随检测框更紧, 但容易被误检带偏 # R 越大, 模型越信任预测, 跟踪框更平滑, 但遮挡后恢复慢 # VisDrone 建议 R 比 MOT17 大 2-5 倍, 因为航拍检测框抖动更明显SORT 的max_age和min_hits是两个必须调的参数。max_age控制目标丢失后保留轨迹的帧数,VisDrone 遮挡频繁,设太小会导致 ID 频繁切换,设太大则会在目标离开后产生幽灵轨迹。我一般从 30 开始试,根据序列帧率和目标运动速度调整。min_hits控制轨迹确认前需要连续匹配的帧数,设 3 可以过滤掉大部分误检产生的短轨迹。
3.2 DeepSORT 的外观特征提取与级联匹配
DeepSORT 比 SORT 多了一个外观特征分支,用 ReID 模型提取每帧检测框的特征向量,在关联时同时考虑运动距离和外观距离。在 VisDrone 上,外观特征的作用比较微妙:一方面航拍目标像素少,ReID 特征区分度有限;另一方面相机运动导致同一目标在不同帧的背景变化大,外观特征反而可能引入噪声。
import torch import torchvision.transforms as T from torchvision.models import resnet50 class FeatureExtractor: def __init__(self, model_path=None): self.model = resnet50(pretrained=False) self.model.fc = torch.nn.Identity() if model_path: self.model.load_state_dict(torch.load(model_path)) self.model.eval() self.transform = T.Compose([ T.ToPILImage(), T.Resize((128, 64)), T.ToTensor(), T.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]) ]) @torch.no_grad() def extract(self, image, boxes): features = [] for box in boxes: x1,y1,x2,y2 = map(int, box[:4]) crop = image[y1:y2, x1:x2] if crop.size == 0: features.append(np.zeros(2048)) continue tensor = self.transform(crop).unsqueeze(0) feat = self.model(tensor).cpu().numpy().flatten() # L2 归一化, 保证余弦距离在 [0,2] feat = feat / (np.linalg.norm(feat) + 1e-6) features.append(feat) return np.array(features) # 参数说明: # 输入 crop 尺寸 128x64 是 DeepSORT 原论文设置 # 特征维度 2048 来自 ResNet50 全局池化层 # 如果显存紧张, 可以换 ResNet18 或 MobileNet, 但区分度会下降DeepSORT 的级联匹配里,max_dist控制外观距离阈值,max_iou_distance控制运动距离阈值。VisDrone 上我一般把max_dist从默认的 0.2 调到 0.3,因为小目标外观特征本身就不稳定,阈值太严会导致匹配失败。另外nn_budget控制每个轨迹保留的特征数量,设 100 足够,太大占内存且对结果提升有限。
3.3 两个跟踪器在 VisDrone 上的实测差异
在同一段 VisDrone 测试序列上,固定 YOLOv7 检测结果,SORT 和 DeepSORT 的差异主要体现在遮挡恢复和 ID 保持上。SORT 在目标密集区域容易出现 ID Switch,因为纯运动匹配在目标交叉时无法区分。DeepSORT 在外观特征有效时能减少一部分 ID Switch,但代价是推理速度下降约 30% 到 50%,具体取决于 ReID 模型大小和 batch 策略。
实测中还有一个反直觉的点:DeepSORT 在 VisDrone 上的 MOTA 不一定比 SORT 高。因为 MOTA 主要受检测质量影响,跟踪器带来的提升有限。真正拉开差距的是 IDF1 和 ID Switch 指标。如果你的应用更关心 ID 稳定性,DeepSORT 值得上;如果只关心帧级检测覆盖,SORT 足够。
4. ByteTrack 与 Bot-SORT 的低分检测利用和轨迹管理
4.1 ByteTrack 的两阶段关联逻辑
ByteTrack 的核心思想是把检测框按置信度分成高分组和低分组,先用高分组做第一次关联,再用低分组和未匹配轨迹做第二次关联。这个设计在 VisDrone 上特别有用,因为航拍小目标的检测置信度普遍偏低,如果直接卡 0.5 阈值,大量真实目标会被丢掉。
def bytetrack_associate(detections, trackers, high_thresh=0.5, low_thresh=0.1): # 第一阶段: 高分检测 + 所有轨迹 high_dets = [d for d in detections if d[4] >= high_thresh] low_dets = [d for d in detections if low_thresh <= d[4] < high_thresh] # 用 IoU 距离做第一次匈牙利匹配 matched, unmatched_tracks, unmatched_dets = linear_assignment( iou_distance(trackers, high_dets), thresh=0.8 ) # 第二阶段: 低分检测 + 未匹配轨迹 # 只对第一次没匹配上的轨迹做二次关联 matched2, unmatched_tracks2, unmatched_dets2 = linear_assignment( iou_distance(unmatched_tracks, low_dets), thresh=0.5 ) return matched + matched2, unmatched_tracks2, unmatched_dets2 # 参数说明: # high_thresh: 高分检测阈值, VisDrone 建议 0.4-0.5 # low_thresh: 低分检测下限, 建议 0.1, 再低会引入大量背景噪声 # 第一次 IoU 阈值 0.8 偏严, 第二次 0.5 偏松, 这是 ByteTrack 的默认策略ByteTrack 在 VisDrone 上的调参重点是track_buffer,也就是轨迹丢失后保留的帧数。VisDrone 的无人机视频帧率通常 25 到 30,目标被遮挡后重新出现的时间可能在 1 到 2 秒,所以track_buffer设 30 到 60 比较合理。设太小会导致遮挡后 ID 切换,设太大则会在目标离开画面后产生长尾幽灵轨迹。
4.2 Bot-SORT 的轨迹重识别与相机运动补偿
Bot-SORT 在 ByteTrack 基础上增加了两个模块:相机运动补偿和轨迹重识别。VisDrone 是无人机视角,相机自身运动是常态,不做补偿的话卡尔曼滤波的预测位置会严重偏离。相机运动补偿通常用 GMC(Global Motion Compensation)实现,通过帧间特征点匹配估计仿射变换矩阵。
import cv2 import numpy as np class GMC: def __init__(self, method='sparseOptFlow', downscale=2): self.method = method self.downscale = downscale self.prev_img = None def apply(self, img, detections): # img: 当前帧 BGR 图像 # 返回: 补偿后的检测框 if self.prev_img is None: self.prev_img = self._preprocess(img) return detections curr_img = self._preprocess(img) if self.method == 'sparseOptFlow': # 稀疏光流估计帧间变换 prev_pts = cv2.goodFeaturesToTrack( self.prev_img, maxCorners=1000, qualityLevel=0.01, minDistance=1 ) curr_pts, status, _ = cv2.calcOpticalFlowPyrLK( self.prev_img, curr_img, prev_pts, None ) # 用 RANSAC 估计仿射矩阵 H, _ = cv2.estimateAffinePartial2D(prev_pts, curr_pts) else: H = np.eye(2, 3) self.prev_img = curr_img if H is None: return detections # 把变换应用到检测框 warped = [] for det in detections: x1,y1,x2,y2 = det[:4] pts = np.array([[x1,y1],[x2,y1],[x2,y2],[x1,y2]], dtype=np.float32) pts = cv2.transform(pts.reshape(1,-1,2), H).reshape(-1,2) nx1,ny1 = pts.min(axis=0) nx2,ny2 = pts.max(axis=0) warped.append([nx1,ny1,nx2,ny2] + list(det[4:])) return np.array(warped) def _preprocess(self, img): h, w = img.shape[:2] return cv2.resize(img, (w//self.downscale, h//self.downscale)) # 参数说明: # downscale=2 降低分辨率加速光流, 对精度影响可接受 # maxCorners=1000 控制特征点数量, VisDrone 纹理丰富可以适当加大 # estimateAffinePartial2D 比 estimateAffine3D 更稳, 适合无人机场景Bot-SORT 的轨迹重识别模块会在轨迹丢失后,用外观特征在后续帧中重新匹配。这个机制在 VisDrone 上对长时遮挡有效,但前提是 ReID 特征足够可靠。如果检测框本身只有十几个像素,ReID 特征基本是噪声,这时候重识别反而会引入错误匹配。我的经验是:VisDrone 上 Bot-SORT 的 ReID 权重不要设太高,让运动补偿和 IoU 匹配占主导。
4.3 四种跟踪器的速度与精度权衡
在离线测试平台上,速度对比必须在同一硬件、同一 batch 策略下做。SORT 最快,单帧处理通常在 1 到 3 毫秒;ByteTrack 因为两阶段匹配,大约 3 到 8 毫秒;DeepSORT 加上 ReID 推理,可能到 15 到 30 毫秒;Bot-SORT 加上 GMC 和 ReID,通常 20 到 40 毫秒。这些数字随目标数量和硬件变化很大,但相对关系稳定。
精度上,VisDrone 行人跟踪的 IDF1 通常呈现 ByteTrack 和 Bot-SORT 领先、DeepSORT 次之、SORT 垫底的格局。但差距没有想象中大,如果检测质量本身不高,换跟踪器带来的提升可能只有 2 到 5 个点。所以平台的价值在于让你用数据决定值不值得为跟踪器付出额外算力。
5. 避坑与常见问题排查
5.1 检测框坐标还原错误导致跟踪全乱
现象:跟踪框整体偏移,或者框的大小和检测框明显不匹配。原因通常是 YOLOv7 输出的是归一化坐标,而跟踪器按像素坐标处理,中间缺少还原步骤,或者还原时用的图像尺寸和推理尺寸不一致。解决方法是把推理尺寸写进配置文件,检测解析时统一读取,并在可视化时叠加检测框和跟踪框做对比,一眼就能看出是否对齐。
5.2 低置信度检测引发轨迹碎片化
现象:同一个目标在连续帧里 ID 频繁变化,轨迹长度很短。原因可能是检测置信度阈值设得太高,导致目标在部分帧漏检,跟踪器只能不断新建轨迹。解决方法是降低检测阈值,同时启用 ByteTrack 这类低分检测利用机制,或者增大max_age/track_buffer让轨迹在短暂漏检时保持。
5.3 相机运动补偿参数不当引入额外抖动
现象:开启 GMC 后跟踪框反而比不开更抖。原因是光流特征点匹配错误,估计出的仿射矩阵不稳定。解决方法是限制 GMC 的变换范围,对估计出的矩阵做合理性检查,比如平移量超过图像尺寸 10% 就丢弃该帧的补偿。另外可以降低特征点数量,提高质量阈值,让参与估计的点更可靠。
5.4 ReID 特征维度不匹配导致 DeepSORT 崩溃
现象:DeepSORT 初始化时报维度错误,或者匹配时距离矩阵形状不对。原因是 ReID 模型输出维度和跟踪器配置里的feature_dim不一致。解决方法是先跑一次特征提取,打印输出维度,再同步修改跟踪器配置。如果换过 ReID 模型,这个维度必须重新确认。
5.5 评测指标计算时 GT 与预测帧号错位
现象:MOTA 和 IDF1 异常低,但可视化看起来跟踪正常。原因是 GT 和预测的帧号起始不一致,VisDrone 有些序列从 1 开始,有些从 0 开始,而 MOTChallenge 格式通常要求从 1 开始。解决方法是在转换和推理输出时统一帧号偏移,评测前先检查两边帧号范围是否一致。
6. 用 TrackEval 做离线评测与参数扫描的实操技巧
6.1 TrackEval 的配置与 VisDrone 序列适配
TrackEval 是目前比较通用的 MOT 评测工具,支持 MOTChallenge 格式。用它评测 VisDrone 时,需要把 GT 和跟踪结果按序列组织成它要求的目录结构。每个序列一个 txt,GT 放在gt/mot_challenge/VisDrone/seqmap.txt对应的目录下,跟踪结果放在trackers/mot_challenge/你的跟踪器名/data/下。
# TrackEval 评测命令 python scripts/run_mot_challenge.py \ --BENCHMARK VisDrone \ --SPLIT_TO_EVAL test \ --TRACKERS_TO_EVAL sort bytetrack deepsort botsort \ --METRICS HOTA CLEAR Identity \ --USE_PARALLEL True \ --NUM_PARALLEL_CORES 4 # 参数说明: # BENCHMARK: 对应 gt/mot_challenge/ 下的数据集名 # TRACKERS_TO_EVAL: 多个跟踪器用空格分隔, 一次跑完 # METRICS: HOTA 综合指标, CLEAR 含 MOTA, Identity 含 IDF1 # USE_PARALLEL: 多序列并行, 序列多时明显加速TrackEval 的 seqmap 文件决定了评测哪些序列。VisDrone 测试集序列多,建议先在小规模子集上验证流程,确认指标计算无误后再全量跑。另外 TrackEval 对帧号连续性有要求,如果某个序列中间缺帧,需要在 seqmap 里正确配置,否则指标会异常。
6.2 参数扫描:用网格搜索找每个跟踪器的最优配置
固定检测结果后,每个跟踪器都有几个关键参数可以扫。SORT 扫max_age和iou_threshold,ByteTrack 扫track_buffer和high_thresh,DeepSORT 扫max_dist和nn_budget,Bot-SORT 扫track_high_thresh和proximity_thresh。我一般用粗扫加细扫两轮,第一轮每个参数取 3 到 4 个值,找到大致范围后再细化。
import itertools import subprocess import json # 参数网格扫描示例: ByteTrack param_grid = { 'track_buffer': [30, 45, 60], 'high_thresh': [0.4, 0.5, 0.6], 'low_thresh': [0.1, 0.15] } results = [] for combo in itertools.product(*param_grid.values()): params = dict(zip(param_grid.keys(), combo)) # 写入临时配置 with open('tmp_config.json', 'w') as f: json.dump(params, f) # 跑跟踪 subprocess.run(['python', 'run_tracker.py', '--config', 'tmp_config.json']) # 跑评测 out = subprocess.run( ['python', 'scripts/run_mot_challenge.py', '--BENCHMARK', 'VisDrone', '--TRACKERS_TO_EVAL', 'bytetrack', '--METRICS', 'HOTA'], capture_output=True, text=True ) # 解析 HOTA 分数, 这里简化处理 hota = parse_hota(out.stdout) results.append({**params, 'HOTA': hota}) # 按 HOTA 排序输出最优参数组合 results.sort(key=lambda x: x['HOTA'], reverse=True) print(results[:5]) # 参数说明: # track_buffer: 轨迹保留帧数, VisDrone 建议 30-60 # high_thresh: 高分检测阈值, 影响第一阶段匹配数量 # low_thresh: 低分检测下限, 影响第二阶段补充匹配扫描时要注意评测的随机性。如果跟踪器里有随机初始化或者 GPU 非确定性操作,同一组参数跑两次结果可能略有差异。建议对每组参数至少跑一次完整测试集,不要用单序列结果做决策。另外 HOTA 是综合指标,如果应用更关心 ID 保持,可以同时看 IDF1 和 AssA。
6.3 从评测结果反推检测与跟踪的瓶颈
拿到四个跟踪器的评测结果后,不要只看总分。把指标拆开看:DetA 低说明检测质量是瓶颈,AssA 低说明关联策略有问题,LocA 低说明定位精度不够。如果四个跟踪器的 DetA 都差不多但 AssA 差距大,那跟踪器选型就很重要;如果 DetA 普遍偏低,应该先回去优化 YOLOv7 的检测,换跟踪器收益有限。
我自己的习惯是每次跑完评测,把每个序列的指标单独列出来,找出最差的三个序列,然后逐帧可视化这些序列的跟踪结果。十有八九问题出在特定场景:密集人群、严重遮挡、或者相机剧烈运动。针对这些场景调参,比全局调参有效得多。
最后说一个血泪经验:离线测试平台最大的价值不是跑出一个漂亮数字,而是让你能快速复现问题。把检测结果、跟踪结果、GT、可视化脚本都固化下来,每次改参数只重跑跟踪和评测,不要重跑检测。这样一轮参数扫描从几小时压缩到几十分钟,迭代效率完全不一样。希望帮到你。
本文还有配套的精品资源,点击获取