资讯动态

YOLOv5+DeepSORT目标跟踪计数:从检测到统计的工程实践

发布时间:2026/10/14 14:59:49 来源:尧图企业网站定制
简介基于Python和YOLOv5、DeepSORT联合实现的行人与车辆跟踪计数系统面向毕业设计、课程设计以及目标检测类项目开发者。资料内含完整可运行的源码、项目文档和演示视频从环境配置、模型加载到检测追踪与计数输出均有说明覆盖常用依赖与运行入口便于在验证后继续二次延展。压缩包共122个文件以Python源码、yaml模型配置、PyTorch权重、mp4演示视频和Markdown文档为主整体约129.7MB目录层级清楚可快速定位所需模块。目前已有66人学习下载。结合配套文档与演示录像可清晰理解YOLOv5检测与DeepSORT跟踪的协同流程掌握目标ID分配、轨迹管理、区域计数等关键实现细节同时可根据自带配置文件和测试视频复现运行效果适合需要快速搭建同类系统或深入拆解算法逻辑的读者参考。1. 一套能数清楚人流车流的系统YOLOv5DeepSORT 跟踪计数到底解决什么问题商场入口想知道一天进出多少人路口想统计早晚高峰的车流量。如果只做目标检测每帧框出来的目标无法跨帧关联同一辆车在不同帧会被当成好几个目标计数必然虚高。把 YOLOv5 的检测结果交给 DeepSORT 做跨帧跟踪每个目标拿到稳定 ID 后再计一次数才能真正落到客流统计、车流统计这类实际场景。这套方案对毕业设计、课程设计或工程预研都很合适YOLOv5 负责“看到”目标DeepSORT 负责“认出”同一个目标计数逻辑只关心 ID 是否越线或进出区域。上手门槛并不高Python 环境下把检测和跟踪串起来核心代码可以控制在几百行内。后面内容就是沿着这个链路逐步拆开讲每一步都给出可复现的做法和参数依据。2. 把检测先跑通YOLOv5 环境搭建与模型推理的最小闭环2.1 环境依赖怎么选Python 版本、PyTorch 与推理后端YOLOv5 对 Python 版本的要求不算苛刻3.8 到 3.10 都能稳定跑。真正影响体验的是 PyTorch 和 CUDA 的搭配。手头有 NVIDIA 显卡优先装 CUDA 版 PyTorch推理速度能比 CPU 快一个数量级没有显卡就老老实实装 CPU 版用 yolov5s 权重跑单路视频也能勉强实时只是分辨率要控制在 640 以内。# 创建独立虚拟环境避免污染系统 Python conda create -n track python3.8 conda activate track # 安装 PyTorchcu118 对应 CUDA 11.8需要根据你的驱动版本调整 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 安装 YOLOv5 依赖 pip install -r requirements.txt逻辑说明虚拟环境是第一步YOLOv5 的依赖项里有特定版本的 numpy、opencv-python、matplotlib 等直接装在系统环境里容易和已有项目冲突。PyTorch 的安装源选择取决于显卡驱动支持的 CUDA 版本可以在命令行执行nvidia-smi查看驱动对应的 CUDA 版本再决定 cu118 还是 cu121。参数说明from requirements.txt安装方式会按官方锁定版本安装注意装完后检查一下 numpy 版本如果后续 OpenCV 报错通常是 numpy 2.x 和旧版 opencv-python 不兼容pip install numpy1.24.4可以解决。2.2 用 YOLOv5 跑通第一帧检测从权重加载到输出解析环境就绪后先用单张图片验证检测链路。这里用torch.hub加载官方预训练权重第一次运行会自动下载 yolov5s.pt约 14 MB网络慢的话也可以手动放到项目目录下。import torch import cv2 import numpy as np # 加载预训练模型yolov5s 是轻量版速度和精度平衡 model torch.hub.load(ultralytics/yolov5, yolov5s, pretrainedTrue) # 推理时关闭梯度计算减少显存占用 model.eval() model.conf 0.25 # 置信度阈值低于该值的目标被过滤 model.iou 0.45 # NMS 的 IoU 阈值控制重叠框的合并力度 # 读取图片并推理 img cv2.imread(street.jpg) results model(img) # 输出结果: results.pandas().xyxy[0] 包含框坐标、置信度、类别 detections results.pandas().xyxy[0] person_boxes detections[detections[name] person][[xmin, ymin, xmax, ymax, confidence]].values print(f检测到 {len(person_boxes)} 个行人)逻辑说明model.conf和model.iou可以在调用前直接修改也可以作为参数传入model(img, conf0.25, iou0.45)。检测结果里xyxy格式是左上角和右下角坐标后续送给 DeepSORT 之前通常要转成xywh格式即中心点坐标加宽高。pandas().xyxy[0]这个接口能直接拿到结构化数据省去自己解析张量的麻烦。参数说明置信度阈值 0.25 是官方默认值实际项目中如果检测框频繁抖动可以提高到 0.4 以上代价是召回率下降。IoU 阈值 0.45 控制 NMS 合并值越大越倾向于保留更多重叠框值越小越容易合并掉同一目标的多余框。调试阶段建议把阈值调低观察漏检情况上线前再调高减少误检。2.3 检测结果的后处理类别过滤与坐标转换YOLOv5 的 COCO 预训练模型能识别 80 类目标但计数场景通常只关心行人和车辆。类别过滤不仅是业务需求还能显著降低后续跟踪器的计算压力避免把路牌、树影之类的误检目标纳入统计。# 只保留行人、汽车、公交车、卡车对应 COCO 类别 id: 0, 2, 5, 7 CLASS_IDS [0, 2, 5, 7] def filter_detections(detections, class_ids): 按类别过滤检测结果, 同时转换为 DeepSORT 需要的 xywh 格式 detections: pandas DataFrame, 来自 results.pandas().xyxy[0] filtered detections[detections[class].isin(class_ids)] if filtered.empty: return np.empty((0, 5)) # xyxy - xywh: 中心点坐标 宽 高 boxes filtered[[xmin, ymin, xmax, ymax]].values xywh np.zeros_like(boxes) xywh[:, 0] (boxes[:, 0] boxes[:, 2]) / 2 # cx xywh[:, 1] (boxes[:, 1] boxes[:, 3]) / 2 # cy xywh[:, 2] boxes[:, 2] - boxes[:, 0] # width xywh[:, 3] boxes[:, 3] - boxes[:, 1] # height # 拼接置信度, 组成 [cx, cy, w, h, conf] 的数组 conf filtered[[confidence]].values return np.hstack((xywh, conf))逻辑说明这段转换是检测环节和跟踪环节之间的桥梁。DeepSORT 的输入格式要求是[x, y, w, h, conf]其中 x、y 是目标中心点坐标这样设计是为了配合卡尔曼滤波的状态表示。直接传 xyxy 格式会导致跟踪器内部状态初始化出错这是新手最常见的报错点之一。参数说明class_ids的取值来自 COCO 数据集类别定义0是 person2是 car5是 bus7是 truck。如果场景里还有摩托车id 3和自行车id 1按需加进去即可。注意过滤时用class列而不是name列数值比较更稳定不受类别名大小写影响。3. 从检测框到稳定轨迹DeepSORT 跟踪原理与代码接入3.1 DeepSORT 的核心机制卡尔曼滤波与级联匹配的配合DeepSORT 的全称是 Deep Cosine Metric Learning for SORT它在 SORT 的基础上引入了外观特征ReID 特征来弥补单纯运动模型的不足。SORT 只靠卡尔曼滤波预测位置和 IoU 匹配关联目标一旦目标快速运动或发生遮挡ID 很容易丢失DeepSORT 的做法是把目标的运动特征和外观特征同时用于匹配跟踪鲁棒性明显提升。卡尔曼滤波负责预测目标在下一帧的位置同时估计预测误差。级联匹配则优先匹配近期出现过的目标避免丢失时间较长的轨迹抢占新目标的匹配机会。外观特征通过一个小型 CNN 网络提取每帧生成一个 128 维向量与历史轨迹的外观特征库计算余弦距离。匹配最终交给匈牙利算法求解代价矩阵由马氏距离和余弦距离加权得到。3.2 把检测结果接入 DeepSORTtracker 初始化与 update 流程DeepSORT 的工程实现有很多版本我常用的是把检测框和置信度传给update方法它内部会完成预测、匹配、更新三步并返回当前帧所有活跃轨迹的 ID 和位置。from deep_sort import DeepSort # 初始化跟踪器: model_path 指向训练好的 ReID 权重 deepsort DeepSort( model_pathckpt.t7, # ReID 特征提取模型权重 max_dist0.2, # 余弦距离阈值, 超过则认为不是同一目标 min_confidence0.3, # 检测置信度低于该值的框不进入跟踪 nms_max_overlap0.5, # 跟踪框 NMS 阈值 max_iou_distance0.7, # IoU 关联阈值, 用于短期匹配 max_age70, # 轨迹丢失后最多保留的帧数 n_init3, # 轨迹被确认前必须连续匹配的帧数 nn_budget100 # 外观特征库容量, 超出后丢弃最早特征 ) # 每帧处理: detections 是上一节 filter_detections 的输出, shape(N, 5) # 注意 DeepSORT 要求宽高比在合理范围, 过扁的框会被内部过滤 outputs deepsort.update(detections, frame) # outputs 格式: [x1, y1, x2, y2, track_id], 可以直接画框 for x1, y1, x2, y2, track_id in outputs: cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(frame, fID:{track_id}, (int(x1), int(y1)-10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)逻辑说明update方法接收的检测框是 xyxy 格式注意不同开源实现的接口有差异有的要求传入[x1, y1, x2, y2, conf]有的要求先转换。这里的outputs返回的是跟踪结果包含跟踪框坐标和稳定的 track_id。track_id是计数的核心依据同一个目标在连续帧中保持同一个 ID才能避免重复计数。参数说明max_dist0.2是外观匹配的核心阈值值越小对目标外观变化越敏感容易丢 ID值越大越容易把不同目标串到一起。max_age70控制目标丢失后的保留时间值越大对遮挡越鲁棒但也会让短暂离开视野的旧目标占用匹配资源。n_init3表示新目标需要连续 3 帧成功匹配才被确认为正式轨迹避免单帧误检导致虚假轨迹。3.3 跟踪参数怎么调从 ID 跳变现象反推参数方向调参不能靠猜要观察现象反推。最常见的两个现象是 ID 频繁跳变和目标跟踪断裂它们的调参方向正好相反。# 在调试代码里打印每个 track_id 的存活帧数, 观察稳定性 # 脚本片段: 统计每个 ID 出现的帧数 from collections import defaultdict id_frames defaultdict(int) # ... 在跟踪循环中 ... for x1, y1, x2, y2, track_id in outputs: id_frames[track_id] 1 # 结果类似: {1: 120, 2: 3, 3: 150, 4: 2} # 帧数极短的 ID 是跳变信号, 说明跟踪器的匹配策略需要调整如果大量 ID 只存活了几帧说明目标在不断丢失和重新初始化。此时应该调大max_age让轨迹在目标短暂遮挡后还能重新关联同时适当调大max_dist提高外观匹配的容忍度。反过来如果不同目标共享同一 ID出现两个目标串在一起的情况那就是max_dist太大需要调小同时降低max_iou_distance让空间重叠的约束更严格。实践里我一般先用默认参数跑通记录 ID 跳变频率再做单参数网格搜索。比如把max_dist分别设为 0.1、0.2、0.3 跑同一段视频对比 ID 存活帧数的分布比凭感觉调要靠谱得多。4. 计数器落地的两种方案虚拟线计数与区域进出计数4.1 方案一虚拟线越线计数一条线段完成单向统计越线计数的原理很简单在视频画面中定义一条线段跟踪到的目标中心点从前一帧到当前帧跨越了这条线就认为完成了一次穿越。配合 track_id 去重每个 ID 只在第一次越线时计数。def is_crossing(prev_point, curr_point, line_start, line_end): 判断目标中心点是否跨越了线段 prev_point: 上一帧中心点 (x, y) curr_point: 当前帧中心点 (x, y) line_start: 线段起点 (x1, y1) line_end: 线段终点 (x2, y2) 返回值: True 表示跨越, False 表示未跨越 def side(p): return (line_end[0] - line_start[0]) * (p[1] - line_start[1]) - \ (line_end[1] - line_start[1]) * (p[0] - line_start[0]) prev_side side(prev_point) curr_side side(curr_point) # 两点在直线异侧即跨越 return prev_side * curr_side 0 # 在跟踪循环中使用 crossed_ids set() # 记录已计数的 ID count 0 for x1, y1, x2, y2, track_id in outputs: cx, cy (x1 x2) / 2, (y1 y2) / 2 # 用字典保存每个 ID 的上一帧中心点 if track_id not in prev_centers: prev_centers[track_id] (cx, cy) continue prev prev_centers[track_id] if is_crossing(prev, (cx, cy), line_start, line_end): if track_id not in crossed_ids: count 1 crossed_ids.add(track_id) # 关键: 同一 ID 只计一次 print(fID {track_id} 越线, 当前计数: {count}) prev_centers[track_id] (cx, cy)逻辑说明is_crossing函数用的是向量叉积的符号变化来判断点是否跨线不需要计算交点坐标效率高且数值稳定。crossed_ids集合是防重复计数的关键一个目标从线的一侧走到另一侧只算一次即使它在线附近来回晃动也不会重复累加。参数说明line_start和line_end是手动标定的两个画面坐标点一般选在目标必经的通道位置。线不要放太靠近画面边缘否则目标刚进入画面就被计数跟踪状态还没稳定下来容易引入误判。线的方向决定了计数方向比如从左到右画一条线只有从左往右跨越才计数反向跨越则不计。4.2 方案二区域进出计数适合闸机口与复杂出入口越线计数在目标运动方向单一的场景下很好用但出入口存在徘徊、折返的情况比如有人在门口犹豫了一下又走回去此时越线计数会误统计。区域进出计数更适合这种场景定义一个多边形区域目标从区域外进入认为是一次“进入”从区域内出去则认为是一次“离开”。import cv2 def point_in_polygon(point, polygon): 判断点是否在多边形内部 point: (x, y) polygon: 顶点列表 [(x1,y1), (x2,y2), ...] x, y point n len(polygon) inside False p1x, p1y polygon[0] for i in range(1, n 1): p2x, p2y polygon[i % n] if (y min(p1y, p2y)): if (y max(p1y, p2y)): if (x max(p1x, p2x)): if p1y ! p2y: xinters (y - p1y) * (p2x - p1x) / (p2y - p1y) p1x if p1x p2x or x xinters: inside not inside p1x, p1y p2x, p2y return inside # 维护每个 ID 在区域内外状态 inside_status {} # track_id - True/False enter_count 0 exit_count 0 for x1, y1, x2, y2, track_id in outputs: cx, cy (x1 x2) / 2, (y1 y2) / 2 # 只取宽高比合理的框, 过滤掉极端形状的误检 w, h x2 - x1, y2 - y1 if w 0 or h 0 or w / h 5: continue is_inside point_in_polygon((cx, cy), zone_polygon) if track_id not in inside_status: # 新 ID: 记录初始状态, 不计数, 避免目标已在区域内时被误判 inside_status[track_id] is_inside else: # 状态变化: 从外到内是进入, 从内到外是离开 if not inside_status[track_id] and is_inside: enter_count 1 print(fID {track_id} 进入, 累计进入: {enter_count}) elif inside_status[track_id] and not is_inside: exit_count 1 print(fID {track_id} 离开, 累计离开: {exit_count}) # 状态更新 inside_status[track_id] is_inside逻辑说明区域计数的精髓在状态机——每个 ID 维护一个布尔状态只有当状态发生翻转时才计数。新出现的 ID 直接记录初始状态但不计数这样可以规避目标一开始就在区域内部的情况。point_in_polygon用的是射线法对任意凸多边形和凹多边形都有效。注意区域边界的处理目标中心点正好落在多边形边上时point_in_polygon的结果不稳定实际项目中可以在多边形上做一个内缩的小缓冲区或者直接忽略距离边界太近的检测框数出来的数据会更平滑。4.3 双向计数用中心点位移判断行进方向很多场景需要区分方向比如商场入口要分别统计进和出的人数道路要区分上行和下行。方向判断可以结合越线计数实现记录目标跨越线时的速度方向即上一帧位置指向当前帧位置的向量。def get_direction(prev_point, curr_point): 计算目标运动方向角度, 以水平向右为 0 度, 逆时针为正 返回值: 方向角 (弧度), 范围 [-pi, pi] dx curr_point[0] - prev_point[0] dy curr_point[1] - prev_point[1] return math.atan2(dy, dx) # 方向角到文字描述 DIRECTION_LABELS { right: (math.pi * -0.25, math.pi * 0.25), down: (math.pi * 0.25, math.pi * 0.75), left: (math.pi * 0.75, math.pi * 1.0), up: (math.pi * -1.0, math.pi * -0.75), } def direction_label(angle): 把方向角映射到四个主方向之一 # 处理角度区间重叠的简化实现, 实际可改为查表 for label, (low, high) in DIRECTION_LABELS.items(): if low angle high: return label return unknown # 在越线计数时结合方向 if is_crossing(prev, (cx, cy), line_start, line_end): angle get_direction(prev, (cx, cy)) label direction_label(angle) if label right: right_count 1 elif label left: left_count 1逻辑说明get_direction使用中心点前后两帧的位移来计算方向角比利用检测框宽高变化判断方向更直接。注意这里有一个陷阱单帧位移可能会受检测框抖动影响方向角会跳跃解决方法是把位移向量平滑一下用最近 3 帧的平均位移来代替单帧位移。参数说明方向区间用 90 度象限划分是简化做法实际场景中目标的运动方向很少完全水平或垂直建议做成可配置的扇形区间比如把 45 度范围内的位移都归为水平方向。5. 避坑指南ID 跳变、重复计数与性能瓶颈的排查记录5.1 检测框抖动导致目标 ID 反复切换现象视频中行人走路时身体晃动检测框大小有时 120 像素有时 80 像素导致同一目标的框与前几帧 IoU 过低匹配失败ID 每隔十几帧就换一个。原因YOLOv5 的检测框是目标外接矩形行人摆臂、姿态变化都会让框的尺寸和位置明显波动进而影响跟踪器的匹配结果。IoU 匹配对框的稳定性极其敏感而 DeepSORT 的级联匹配如果连续几帧失败就会把轨迹标记为丢失并赋予新 ID。解决把 YOLOv5 的置信度阈值从 0.25 提高到 0.4过滤掉低质量的检测框框的抖动幅度会明显减小。同时把 DeepSORT 的max_age从默认 70 提高到 100 甚至更高即使中间有几帧匹配失败也有余量等目标重新关联回来。如果还有抖动考虑用 EMA 对检测框做一次平滑# 简单指数平滑, 对同一 ID 的检测框做时间维平滑 smoothed_boxes {} alpha 0.6 # 平滑系数, 越大越依赖历史值 for x1, y1, x2, y2, track_id in outputs: box np.array([x1, y1, x2, y2], dtypenp.float32) if track_id in smoothed_boxes: smoothed_boxes[track_id] alpha * smoothed_boxes[track_id] (1 - alpha) * box else: smoothed_boxes[track_id] box5.2 同一目标被重复计数track_id 去重逻辑失效现象一个行人从左边走到右边计数从 10 直接跳到了 12对比视频发现是同一个目标被算了两次。原因目标在行进过程中中途有一段时间被其他行人遮挡DeepSORT 丢失了原轨迹目标重新出现后被赋予了新 ID。计数逻辑只认 ID 不认人新 ID 自然又触发了一次越线计数。解决单纯靠 ID 去重解决不了遮挡导致的 ID 切换需要引入位置约束如果在计数线附近出现一个新 ID且新 ID 的起始位置与旧 ID 最后一次出现的位置距离小于某个阈值比如 30 像素并且旧 ID 的消失时间不超过 1 秒就认为两者是同一个目标做一次 ID 合并。这个逻辑也叫轨迹融合实现时可以用相邻 ID 的起点和终点做配对。5.3 目标被遮挡后跟踪轨迹断裂现象一辆车被路过的卡车挡住 2 秒卡车开走后被挡的车重新出现在画面里但 ID 已经从 3 变成了 19后续的计数也被打乱。原因DeepSORT 的max_age默认值是 70 帧假设视频是 25 FPS70 帧只有 2.8 秒。目标被遮挡超过这个时间轨迹就被彻底删除重新检测到后只能初始化新轨迹。解决把max_age调大到 120~150同时确认nn_budget不要太小。nn_budget是外观特征库的容量上限如果目标被遮挡期间特征库里的旧特征被清掉了重新出现时外观匹配仍然可能失败。nn_budget100在长遮挡场景下不够建议调到 200 以上。5.4 夜间场景行人检测率骤降现象晚上 7 点后画面里的行人明显少了对比人工目测检测率大概只有白天的一半还不到。原因YOLOv5s 的夜间泛化能力有限黑暗环境下的目标对比度低特征提取难度大。加上摄像头自动增益导致噪声增加置信度普遍偏低大量真实目标被 0.25 的置信度阈值过滤掉了。解决备选项中效果最好的做法是换成 yolov5m 甚至 yolov5l 权重大模型对低质量图像的特征表达能力更强。没有算力升级的条件可以在推理前做一次 CLAHE 对比度增强预处理# 对低光照帧做 CLAHE 增强, 提升检测召回 clahe cv2.createCLAHE(clipLimit3.0, tileGridSize(8, 8)) gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) enhanced clahe.apply(gray) frame cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)5.5 视频推理卡顿显存溢出现象同时开两个测试视频程序跑了几分钟后提示 CUDA out of memory画面帧率只有几 FPS。原因YOLOv5 默认使用 FP32 精度推理显存占用较大。DeepSORT 的 ReID 模型也需要额外显存。两个模型叠加1080p 视频输入会轻松超过 4GB 显存。解决给 YOLOv5 开启半精度推理显存占用减少近一半。同时把输入分辨率限制在 640x640检测前把长边缩放短边等比缩放。推理频率也可以人为限制每 3 帧检测一次中间帧用跟踪结果直接画框对计数场景影响不大。# YOLOv5 开启半精度 model.half() # 限制输入尺寸, 长边不超过 640 def resize_for_inference(img, max_size640): h, w img.shape[:2] scale max_size / max(h, w) if scale 1: img cv2.resize(img, (int(w * scale), int(h * scale))) return img6. 让系统更实用的三个进阶做法从跑通到能交付6.1 每隔几帧检测一次用跟踪结果补全中间帧检测是整条链路里最耗时的环节而跟踪的卡尔曼预测几乎不花时间。做法每 3 帧只做一次 YOLOv5 检测中间 2 帧直接用 DeepSORT 的预测结果画框。帧率能从 8 FPS 提到 20 FPS 以上计数精度几乎不受影响因为计数的依据是跟踪 ID 而不是检测框本身。6.2 画面上画出计数线和实时统计方便现场验证交付时不能只给别人一个终端输出的数字。把计数线、区域边界、每个目标的 ID 和当前计数叠加到视频画面上直接输出 MP4 或者推流。这样验收方看到的效果和最终数字是一致的不用对着日志文件想象。涉及多目标时可以用不同色系区分不同 ID越线瞬间在画面上闪一下提示验证和展示的效果都更直观。6.3 把计数结果结构化输出用 CSV 记录时间序列相对完善的做法是把每次越线或进出事件的时间戳、目标 ID、方向写入 CSV。后续做时段分析直接读文件不用重新跑视频。逻辑上在计数事件触发时追加一行记录字段简单清晰推荐把这个功能放在开发早期就接入后续做数据分析顺便加字段比较麻烦。最后一句想用自己的教训来收尾整套系统的核心链路就三步——检测、跟踪、计数。检测决定上限跟踪决定稳定性计数决定业务价值。开发时先按这个顺序打通主链路再回头优化参数你会发现大多数问题并不在算法本身而在你的参数和业务场景匹配得够不够好。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑