简介:一套面向目标跟踪学习与工程实践的多目标检测与跟踪仿真代码包,基于YOLOv5逐帧识别视频中的人物,再借助DeepSORT为每个目标分配唯一ID并持续跟踪。代码会计算人物中心点并累积运动轨迹,在视频上绘制边界框与轨迹线,同时定期将轨迹长度、停留时间、平均速度等信息写入CSV,便于后续统计与分析,同时支持将包含人物轨迹的画面与仅含轨迹的图像保存为文件,便于展示与复盘。包内共4个文件,以两个Python脚本为主:一个集成MHCNN面部模糊处理以保护隐私,另一个适用于红外热像仪等无需模糊的场景;另附Markdown说明文档和许可证文件,便于理解运行方式与使用约束。压缩包整体仅19KB,轻量易部署。目前已有144人学习,适合需要快速上手YOLOv5与DeepSORT组合方案,或希望实现人员轨迹统计、隐私保护功能的开发者和学习者参考迁移。
1. 基于YOLOv5和DeepSORT的多目标跟踪仿真与记录:为什么非跑通一次闭环不可
先给一个反直觉的结论:单帧检测做得再准,多目标跟踪也可能一塌糊涂。你拿一个广场监控视频,逐帧跑YOLOv5,人框框得几乎完美,但帧与帧之间没有“谁是谁”的概念——同一人走出画面再回来,ID已经在半秒内换掉了。DeepSORT就是补上这一段时序关系的标准方案:用YOLOv5做单帧检测,用卡尔曼滤波与ReID外观特征做帧间关联,把散落的检测框串成身份一致的轨迹。本文讲的是如何把这套链路在本地仿真环境里完整跑起来,并且把轨迹、参数、每一帧的关联结果记录成可回放的结构化数据。适合刚接到多目标跟踪需求、手头没有现成数据链路、又不想一上来就上真车真赛道的从业者。下面按检测与跟踪的分工、仿真搭建、代码路径、参数避坑、回归验证五步展开。
2. 先看清职责边界:YOLOv5检测器与DeepSORT跟踪器的分工与链路
2.1 检测与跟踪之间的协议:框、置信度与ID
要搭这套系统,第一步不是写代码,而是把两个组件之间的数据契约定义清楚。YOLOv5输出的是一组检测框,每个框至少携带三类信息:边界框坐标、类别ID、置信度。多目标跟踪里,我们几乎只关心person这一类,所以第一步通常是把输出先过滤成单一类别,再把坐标格式转换到跟踪器能吃的协议。
常见做法是,YOLOv5默认输出的是归一化后的xywh格式,中心点坐标加宽高。而DeepSORT内部使用的框格式是tlwh,也就是左上角x、左上角y、宽、高,并且必须是绝对像素值。这一步转换很容易被忽略,但它直接影响后续卡尔曼滤波器的状态预测,单位错了,运动模型就废了。
def yolo_xywh_to_tlwh(box, img_w, img_h): # box: [x_center, y_center, w, h],已归一化到0-1 x_center, y_center, w, h = box x1 = (x_center - w / 2) * img_w y1 = (y_center - h / 2) * img_h w_pix = w * img_w h_pix = h * img_h return [x1, y1, w_pix, h_pix]这段代码本身不复杂,但有一个隐含坑:YOLOv5在推理时,如果你的输入图像经过了letterbox,也就是等比缩放加灰边填充,那输出坐标是相对于填充后图像的。如果不做逆映射,直接拿原图宽高去乘,框会整体偏移。我一般会在推理时关闭letterbox,或者把scale_coords的结果透传出来,否则后续记录下来的轨迹框和原图对不上,回放时会看到框像“长了腿”一样往外跑。
置信度这个字段也不能只当展示用。DeepSORT在生成新轨迹时,对检测置信度没有强制下限,但低置信度检测框进入跟踪器后,会导致两类后果:一是频繁创建垃圾轨迹,二是干扰已有轨迹的IoU匹配。实操里,我会在YOLOv5的输出层先把conf低于0.25的直接丢掉,这个阈值不是固定的,后面讲避坑时再展开。
2.2 跟踪状态机:匹配、创建新轨迹、失联与遗忘的判定
DeepSORT的核心是一个状态机。每一条轨迹都有两个关键参数:n_init用于确认轨迹,max_age用于遗忘轨迹。一个检测框第一次出现时,跟踪器并不会立刻把它当作正式轨迹,而是创建一个“未确认”的轨迹,只有连续n_init帧都匹配上,它才升级为确认轨迹。这样做的目的是防止单帧误检直接污染轨迹列表。相应地,如果一个确认轨迹连续max_age帧没有匹配到任何检测框,跟踪器就认为目标已经离开,轨迹终止。
这里的匹配不是简单的IoU重叠,而是级联匹配加IoU匹配的两级结构。级联匹配优先处理那些最近才更新过的轨迹,用ReID外观特征计算余弦距离;IoU匹配处理剩余的老轨迹,用框重叠度兜底。两级匹配都要经过匈牙利算法做最优分配。
self.max_dist = 0.2 # 余弦距离阈值,越小越严格 self.max_age = 70 # 轨迹失联多少帧后删除 self.n_init = 3 # 需要连续命中多少帧才确认轨迹 self.nn_budget = 100 # 每个轨迹最多保留多少个外观特征样本这四个参数基本决定了你整个系统的性格。max_dist值设大了,两个长得像的人容易串ID;设小了,一个人换个视角就容易丢轨。max_age设大了,遮挡恢复能力强,但也会让已经离开的目标在画面里“僵尸附体”很久。n_init设大了,误检不容易产生假轨迹,但真正的短促目标可能还没来得及确认就被放弃。后面会给出这几个参数的调整路径,但现在先记住它们的作用域,后面调试才不会蒙圈。
2.3 仿真不等于随机:为什么要挑选“带缺陷”的输入序列
很多人以为仿真就是随便找个监控视频丢进去跑。真实情况恰恰相反:多目标跟踪仿真的核心价值,是把“难例”放在可控条件下反复执行。DeepSORT最怕的场景不是人多,而是遮挡、同色系目标交错、镜头抖动。这些场景在随机视频里出现的时机不可控,调试时你根本无法判断一个参数改了是变好还是变坏。
我一般会准备三类输入序列。第一类是基础正常流:白天、人少、行走方向清楚,用来验证链路能不能跑通。第二类是压力流:多人密度高、互相穿越、间隔小于一个身位,用来压测ID Switch。第三类是干扰流:树叶阴影、雨点、镜头轻微震动,用来测检测器的稳定性。每类序列最好压成固定帧率,比如25fps或30fps,并记录下场景描述。后续改参数做对比时,固定输入序列是唯一的对照基准,否则你改了一个阈值,看着指标变了,根本说不清是参数生效了还是视频内容本身就变了。
记录的落盘格式也建议在这一步就统一。我会把每一帧的帧号、轨迹ID、框坐标、置信度、时间戳写进CSV,同时把带标注的视频输出成MP4。CSV是给指标计算和回放用的,MP4是给人工查看用的。两套产物对应两种排查方式,缺一不可。
3. 自己搭一套可复现的仿真环境:最小工程目录与完整代码路径
3.1 环境与目录:先跑通用流程,再换自有数据
整个工程的依赖不外乎四块:PyTorch、OpenCV、YOLOv5推理逻辑、DeepSORT跟踪逻辑。不必追求最新版本,稳定复现才是第一优先级。我习惯用conda建独立环境,Python版本固定,避免后面换机器时因为依赖漂移跑不出同样的结果。
conda create -n mott python=3.8 -y conda activate mott pip install torch torchvision opencv-python pip install pandas tqdm pyyamltorch的安装方式视你的机器有无GPU而定。有GPU就装对应CUDA版本的轮子,没有GPU就把small size的模型跑在CPU上,也能完成全流程验证,只是速度慢一些。DeepSORT部分不依赖GPU,它的外观特征提取模型非常轻量,CPU跑完全无压力。
工程目录我会这样组织,输入视频、输出结果、模型权重、脚本分四块放置:
mott_project/ ├── data/ │ ├── videos/ # 原始输入视频,按场景分目录 │ ├── dets/ # 检测结果中间缓存(可选) │ └── outputs/ # 仿真输出:mp4 + csv + json ├── weights/ │ ├── yolov5s.pt # 先用通用权重跑通 │ └── deep_sort.pt # 外观特征提取权重 ├── scripts/ │ ├── run_track.py # 主仿真脚本 │ ├── replay.py # 回放与检查工具 │ └── export_metric.py # 指标统计这里有个常见分歧:有人会把YOLOv5的官方仓库整体clone下来,在上面改train.py和detect.py。我的习惯是只借用YOLOv5的模型定义与权重加载逻辑,自己在scripts里写推理管线,原因是后续要在检测和跟踪之间插入记录、过滤、调试逻辑,直接在官方仓库里改容易把代码改乱,升级依赖时也容易踩冲突。
3.2 检测数据管道:把任意视频切成YOLOv5能吃的帧流
跑通全流程的第一步,是写一个健壮的帧流读取器。OpenCV的VideoCapture是标准选择,但有三个参数需要留意:CAP_PROP_FPS、CAP_PROP_FRAME_WIDTH、CAP_PROP_FRAME_HEIGHT。有些视频文件头里写的帧率和实际不不一致,你可能读取后得到的帧率是错的,导致记录下来的时间戳完全离谱。我一般会显式读取代码并在日志里打印,跑完一帧就做一次帧数自检,保证进度可追踪。
import cv2 class VideoStream: def __init__(self, path, target_fps=25): self.cap = cv2.VideoCapture(path) if not self.cap.isOpened(): raise RuntimeError(f"cannot open video: {path}") self.target_fps = target_fps self.src_fps = self.cap.get(cv2.CAP_PROP_FPS) self.frame_gap = max(1, round(self.src_fps / self.target_fps)) def __iter__(self): frame_id = 0 while True: ret, frame = self.cap.read() if not ret: break if frame_id % self.frame_gap == 0: yield frame_id, frame frame_id += 1 self.cap.release()逻辑说明:frame_gap的作用是把高帧率视频抽样到目标帧率,例如源视频30fps、目标25fps时会计算一个合理的取帧间隔,保证后续记录的轨迹时间轴接近真实物理时间,而不是每一帧都处理、时间和空间冗余放大。参数说明:target_fps建议与真实应用场景一致,做仿真对比时所有输入视频都用同一个target_fps,否则不同视频的max_age在时间维度上不可比。
YOLOv5推理前必须处理色彩空间问题。OpenCV读出来的是BGR,但YOLOv5训练时用的是RGB。很多第一次搭的人把frame直接送进模型,跑出来检测框少了一半,还以为是模型权重坏了,其实只是通道顺序错了。
def preprocess(frame, imgsz=640): # BGR转RGB后,再做归一化和维度扩展 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) resized = cv2.resize(rgb, (imgsz, imgsz)) tensor = torch.from_numpy(resized).permute(2, 0, 1).float() / 255.0 return tensor.unsqueeze(0)这里用了一次直接resize而不是letterbox,原因是真当你跑仿真时,更在意的是tracker坐标映射简单可靠,而不是检测精度的极小提升。如果你检测的对象里有密集小目标,也可以换回letterbox,但需要在输出端做坐标还原。预处理函数的三个常量:imgsz决定了推理分辨率和速度的平衡,640是均衡值;除以255是匹配训练时的归一化;permute则是把HWC变成CHW。
3.3 tracker装配与参数说明:max_dist、max_age、nn_budget调哪个先
说句实话,DeepSORT的参数虽然多,但真正需要调的永远是少数几个。训练好的ReID模型负责区分“不同的人”,max_dist负责决定“两个人像到什么程度就算同一个”。我建议新手第一次跑通时,全用默认参数,只改一处:把min_confidence设为0.25。其余参数不动,先看效果,再针对性调整。
from deep_sort import DeepSort tracker = DeepSort( model_path="weights/deep_sort.pt", max_dist=0.2, # 外观特征余弦距离阈值 min_confidence=0.25, # 检测置信度下限 nms_max_overlap=1.0, # 检测框重叠抑制,1.0表示关闭 max_iou_distance=0.7, # IoU匹配距离阈值 max_age=70, # 轨迹失联保留帧数 n_init=3, # 轨迹确认所需命中帧数 nn_budget=100 # 外观特征样本上限 )参数说明:max_dist控制着外观重识别的松紧度,0.2是行人跟踪场景的常见起点。如果你做的是车辆,建议改成0.1,因为车辆同款车型的概率远高于行人同款衣着,特征模板重叠更严重。n_init单独提到3,是过滤单帧误检的有效手段,但如果你发现真目标只出现两三帧,就要降到2。nn_budget控制着内存,长时间仿真时这个值过高会让每个轨迹的特征队列变得臃肿、匹配耗时上升,但不至于使结果失真。
3.4 增量记录模块:把轨迹、置信度与标注帧落盘成可回放文件
记录模块是整个仿真系统的最后一块拼图,也是最容易被敷衍的部分。很多人直接在每一帧的循环里print看到框在动,就认为“完事了”,等真正需要调参对比时才发现手里没有任何可量化的产物。所以我在搭建时就会把CSV和MP4作为标准输出,每次仿真结束,必然产生一份轨迹记录和一份标注视频。
def flush_records(frame_id, tracks, csv_writer, frame, video_writer): ts_ms = int(frame_id / fps * 1000) for trk in tracks: if not trk.is_confirmed(): continue tlwh = trk.to_tlwh() csv_writer.writerow([ frame_id, trk.track_id, round(tlwh[0], 2), round(tlwh[1], 2), round(tlwh[2], 2), round(tlwh[3], 2), round(trk.confidence, 4), ts_ms ]) # 加标注绘制,写入视频 annotated = draw_boxes(frame, tracks) video_writer.write(annotated)这段代码有意识地做了三件事。第一,只记录确认轨迹,未确认的垃圾轨迹不写入CSV,回放时就不会看到一大堆一闪而过的短轨迹干扰视线。第二,CSV写出时用writerow逐行追加,用的是缓冲,不会每帧都产生磁盘I/O。第三,标注帧和CSV共用同一个frame_id,回放时就可以把两条文件流对齐。
主循环里,检测、跟踪、记录三步串在一起,控制流程大概是这样:
for frame_id, frame in video_stream: dets = detect_yolov5(frame, model, conf_thresh=0.25) # dets转换为tracker要求的格式: [x1,y1,w,h,conf,class_id] tracks = tracker.update(dets, frame) flush_records(frame_id, tracks, ...)每一帧的检测结果都需要保留原始置信度,因为DeepSORT内部会用它给检测框加权;转换时不要把conf字段丢掉,否则跟踪器会默认所有检测都是满置信,等于间接放宽了gating,增加了误关联的风险。
4. 避坑排查站:多目标跟踪仿真与实践里最常见的5个翻车现场
4.1 人走出画面再回来,ID从3变成19
现象:一个行人被树暂时遮住,重新出现在画面里时,轨迹ID完全变了,原本的轨迹3被放弃,新轨迹19被创建。
原因:遮挡时间过长,超过了max_age的保留期限,跟踪器认为原目标已离开,删除了轨迹。同时此人再次出现时外观特征与历史模板的余弦距离超过max_dist,级联匹配失败。
解决:先把max_age调大到100甚至120再试。如果遮挡发生在密集人群中,建议同时对检测器做一个小改动:当目标被遮挡前的连续20帧保持高置信度时,在跟踪器外部缓存它的特征模板,并在目标重现时直接注入匹配候选。这个做法不改变DeepSORT内部逻辑,只修改输入的detections列表,比改源码可维护得多。
4.2 检测框在目标身边抖动,同一人变成两条短轨迹
现象:一个人站在原地,身上的框左晃右晃,conf在0.22到0.28之间来回波动。结果跟踪器一会儿创建轨迹8,一会儿又创建轨迹12,统计时人数翻倍。
原因:min_confidence设得太低,检测框质量差,导致IoU在匹配阈值边缘反复试探,跟踪器把微小的框位移误判成目标移动,进而创建新轨迹。
解决:把min_confidence提到0.3,同时把nms_max_overlap从1.0改到0.6。这个组合能过滤掉大量低质量检测框,代价是漏掉一些远处小目标。如果你明确知道自己要检测远处目标,可以保留低阈值,转而在tracker外部加一个五帧EMA框平滑,把抖动抹平再送进DeepSORT。两条路都能解决同一个问题,选哪条取决于你对查全率的容忍度。
4.3 衣色相近的两个人交叉走过,互换ID
现象:两个人一前一后走过路灯下,穿同色系外套,交错后跟踪器把两人的ID对调,保住轨迹数量但丢失身份语义。
原因:外观特征网络被同类外观扰乱,两个嵌入向量的余弦距离低于max_dist阈值,跟踪器判定它们是同一目标。这是ReID方案的固有难点,不是配置错了。
解决:一档参数调优,把max_dist从0.2降到0.15,然后看ID Switch是否减少;整个过程保持同一条视频输入。二档换更强的ReID权重,比如用更宽的网络提取512维特征。三档加入运动门控,也就是在距离计算时叠加马氏距离约束,让候选框在位置预测范围内才参与外观匹配。这些都是DeepSORT标准论文里已经给出过的选项,实践时的顺序建议由上到下。
4.4 GPU利用率上不去,CPU却先跑满
现象:视频流畅播放,但watch -n 1 nvidia-smi看到的GPU使用率不到30%,CPU却接近100%,跟踪速度还不如纯跑YOLOv5时快。
原因:检测和跟踪串行执行。YOLOv5推理在GPU上本来就很快,但检测完还要等DeepSORT的特征提取和关联计算,而这两者都在CPU上跑,串联流程把GPU的空闲时间全浪费了。
解决:检测与跟踪解耦成流水线。用一个队列缓存检测结果,一个线程持续做YOLOv5推理,主线程同步从队列读结果喂给tracker。需要注意queue的深度,太浅会退化成串行,太深会引入延迟导致帧序列错乱。一般深度设在8到16之间,如果发现tracker跟不上检测速度,说明该换更强CPU或者缩小输入帧率了。
4.5 长时间仿真后CSV越写越慢,程序像卡死
现象:运行前三分钟一切正常,十分钟后每帧的耗时从30毫秒涨到200毫秒,再过一会儿干脆像死了。
原因:打开的CSV文件没有显式关闭,缓冲没有定期flush,或者把整个输出累积在内存里到最后一次性落盘,长时间仿真时内存被特征队列和帧引用占满。
解决:CSV每50帧主动flush一次,Record模块不保留历史帧的引用;视频Writer每500帧检查一次文件大小,超过1GB就轮转一个新文件。这样单次仿真持续几小时都不会出问题,并且中途崩溃的话,已落盘的数据仍然完整可读。
5. 拿记录数据做回归验证:ID Switch、指标计算与双通道回放的小技巧
有了CSV和MP4,你的仿真系统就拥有了“后悔药”。每改一次参数,都能用同一段测试视频重跑,然后对比两份CSV里的指标变化。我常用的验证流程是三步:先统计轨迹数量和ID Switch次数,再算MOTA和IDF1这两项标准指标,最后用回放工具肉眼确认关键难例段。
def analyze_tracking_csv(csv_path): df = pd.read_csv(csv_path) id_switches = 0 for track_id, group in df.groupby("track_id"): # 同一track_id内部出现大的空间跳变,视为一次隐含的ID Switch centers = group[["x", "y"]].values dists = np.linalg.norm(np.diff(centers, axis=0), axis=1) id_switches += int((dists > 300).sum()) return { "trajectory_count": df["track_id"].nunique(), "id_switches": id_switches, "max_track_len": df.groupby("track_id").size().max(), "total_frames": df["frame_id"].max() }逻辑说明:ID Switch没有标准实现,通常用MOTChallenge的官方评测代码计算,我这里给出的是快速自检版——同一轨迹内相邻帧中心点移动超过某个像素阈值时,视为跟踪质量异常的近似信号。300像素是一个经验值,使用前请根据你的视频尺寸按比例调整。
回放工具则做成双通道:左边播放标注视频,右边同步滚动CSV轨迹表,帧号对齐。
import pandas as pd import cv2 def replay(video_path, csv_path): df = pd.read_csv(csv_path) cap = cv2.VideoCapture(video_path) frame_id = 0 while True: ret, frame = cap.read() if not ret: break row = df[df["frame_id"] == frame_id] if not row.empty: for _, trk in row.iterrows(): x, y, w, h = int(trk["x"]), int(trk["y"]), int(trk["w"]), int(trk["h"]) cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(frame, str(int(trk["track_id"])), (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow("replay", frame) frame_id += 1 if cv2.waitKey(30) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()如果你做过几轮参数对比,会知道回放这一步才是最花时间的。我会把高概率出问题的区间——比如遮挡点、同色交错区——在参数改动前先标记好,比如记下来帧号范围,每次回放直接跳帧到该区间,而不是从第一帧开始看。这个习惯帮我少看了一多半的无用帧。
最后说一个我自己的血泪经验:CSV字段命名、视频编码格式这些看起来不重要的问题,会在两个人协作时变成灾难。你记录的CSV如果列名没有单位,比如宽高用的是像素还是归一化值,隔一天你自己都记不清。我后来规定,CSV列名里带pixel后缀,视频统一MP4编码,参数快照写入同目录的JSON文件,每次跑完仿真记录里都有一份完整的参数清单。这样即使过了三个月回来,也能准确说清“这个结果是在哪组参数下产出”。希望这套搭建与排查路径帮到你,尤其是别在参数上做无头苍蝇式的试错——先固定输入序列,再改一个参数,看差异,记下来,再动下一个。
本文还有配套的精品资源,点击获取