☰
YOLOv8-deepsort车辆计数实战:检测跟踪、参数调优与端侧部署
2026/10/9 22:34:05 网站建设 项目流程

简介:这套基于YOLOv8与DeepSORT的车辆智能分析方案,面向计算机视觉开发者和智能交通方向学习者,旨在解决视频场景中的车辆目标检测、跨帧稳定跟踪以及实时数量统计等实际问题。方案将YOLOv8的高精度检测能力与DeepSORT的关联跟踪机制相互融合,能够为每辆车辆分配固定编号并输出连续轨迹,再根据跟踪结果完成车流计数,适合车流量统计、道路监控、智慧交通实验等多种应用场景。资源以ZIP压缩包形式提供,整体大小约293.75MB,共包含349个文件,其中既有86个Python源码和163个编译后的pyc字节码文件,也有38个YAML配置文件,以及样例视频、模型权重、说明文档和辅助脚本,目录结构清晰,方便按模块阅读、运行和二次开发。目前已有1634人学习使用。借助这套资料,使用者可以快速获得一套可运行的车辆检测与跟踪工程实现,深入理解检测器与跟踪器之间的衔接逻辑,掌握计数功能的设计思路,并利用配置文件和数据样例灵活适配不同的道路场景与相机视角。

1. 为什么车辆计数要先解决跟踪:YOLOv8 只负责“看见”

固定摄像头拍一辆车连续经过 30 帧,如果只看单帧检测,就会把这辆车数成 30 辆。车辆计数真正依赖的是跟踪器给出的唯一 ID,而不是每一帧的检测结果。标题里的 YOLOv8-deepsort 把两套成熟算法串成一个流水线:YOLOv8 输出每一帧的车辆目标检测框,DeepSORT 把这些框关联成持续存在的轨迹,计数逻辑只需要盯着轨迹 ID 变化。最反直觉的是,绝大多数计数误差不是 YOLOv8 没识别出车,而是跟踪器把一辆车拆成了两个 ID、又把两辆车并成一个 ID。这篇内容适合正在做交通流量统计、停车场出入管理或园区车辆调度的工程师,沿着“检测—跟踪—计数”顺序把流程跑通,并解决掉藏在参数里的坑。

2. 从 YOLOv8 到 deep sort:车辆跟踪的数据流与模型结构

2.1 为什么 SORT 不够用:从检测框到轨迹的第一次升级

DeepSORT 的前身是 2016 年的 SORT(Simple Online and Realtime Tracking)。SORT 的思路是:检测框之间直接做 IOU 匹配,再交给卡尔曼滤波预测下一帧位置。这个方案速度快、实现简单,但对遮挡和检测抖动非常敏感:车辆被大车挡住三帧,等再出现时,检测框的 IOU 和旧轨迹完全不相交,轨迹直接丢失,恢复后会被当作新车,计数从这个位置开始就错乱了。

DeepSORT 在 SORT 基础上补了一个外观特征分支:每个检测框内的图像被一个小的卷积网络编码成一维向量,匹配时同时计算运动特征的马氏距离和外观特征的余弦距离。车辆比行人更容易出现“同类车长得几乎一样”的情况,所以外观特征在车辆场景里并不总是可靠,但如果视频中车辆颜色、型号有明显差异,这个分支能大幅降低 ID 切换。我的判断是:室外交通场景保留外观分支,停车场深色车辆居多的场景可以调高余弦距离的权重,而不是直接关掉它。

2.2 YOLOv8 检测输出与 DeepSORT 输入的坐标转换

YOLOv8 和 DeepSORT 之间是纯数据对接关系。如果使用 ultralytics 库做推理,results[0].boxes.data 是一个形状为 [N, 6] 的张量,每一行依次是 x1、y1、x2、y2、置信度、类别。DeepSORT 的 update 方法接收的输入通常是 [框中心 x,框中心 y,框宽,框高,置信度],而且置信度必须放在最后一列。

这里存的坐标不只有格式问题。DeepSORT 内部要用框的宽高去裁剪特征图,如果传入的是归一化坐标或极坐标,跟踪不会报错但匹配结果完全失效;也要把类别列删掉,否则被混进输出的张量里,后续的卡尔曼状态维数对不上。常见做法是在喂给跟踪器之前先完成坐标转换和类别过滤:

import numpy as np def xyxy2xywh(boxes_xyxy): xywh = np.zeros_like(boxes_xyxy) xywh[:, 0] = (boxes_xyxy[:, 0] + boxes_xyxy[:, 2]) / 2 # 中心 x xywh[:, 1] = (boxes_xyxy[:, 1] + boxes_xyxy[:, 3]) / 2 # 中心 y xywh[:, 2] = boxes_xyxy[:, 2] - boxes_xyxy[:, 0] # 框宽 xywh[:, 3] = boxes_xyxy[:, 3] - boxes_xyxy[:, 1] # 框高 return xywh # results 为 YOLOv8 推理结果,conf 阈值在推理时已过滤 dets = results[0].boxes.data.cpu().numpy() dets = dets[np.isin(dets[:, 5], [2, 5, 7])] # car, bus, truck xywh = xyxy2xywh(dets[:, :4]) detections = np.hstack([xywh, dets[:, 4:5]])

上述代码中,np.isin 会生成布尔索引,一次把 car、bus、truck 三个类别都留在结果里。有人会在这里写成三个独立条件,功能相同,但执行次数更多;更重要的是 np.isin 不会改变原数组的列顺序,后续 hstack 拼出来的矩阵列结构是固定的。

2.3 YOLOv8 的网络结构里,C2f 和检测头为什么适合车辆检测

YOLOv8 的网络结构图里,骨干网最显眼的改动就是 C2f 模块。它把输入沿通道方向分成两支,其中一支经过若干个 Bottleneck 后与另一支做 concat;除了最后一层以外,Bottleneck 的输出每一步都在参与特征复用。论文视角说这是让梯度传播路径变短、深层特征保留更多细节。换成车辆检测的视角:马路上车辆尺度波动很大,近处一辆车占半个画面,远处一辆车不到 32 像素。C2f 在浅层保留了空间位置,在深层保留了语义,这让小目标检测的召回率比前代 C3 结构有可感知的提升。

检测头也是选择它的理由。YOLOv8 用的是解耦输出头,分类和回归分支各自走一个卷积,不用锚框,直接回归中心点和宽高。传统锚框方案的锚框比例是预设的,车辆横向 3:2、纵向 1:2、公交车 2:1,一套锚框很难同时讨好;anchor-free 检测器把模型从“拟合先验”里解放出来。这是车辆场景在工程上选 YOLOv8 的实际理由。

2.3.1 先想清楚:要不要用 YOLOv8 训练自己的数据集

官方预训练权重在 COCO 上训练过,car、bus、truck 这三个类别不需要训练就可以用。多数做车辆计数的项目,第一次跑通用的就是预训练模型。只有在以下三种情况才需要重训:机位是正俯视视角,车身只露出顶面;检测的是集装箱、工程机械等 COCO 里没有的长尾车型;夜间补光后图像风格和白天差异过大。热词搜索里很多人在问 YOLOv8 怎么训练自己的数据集,我的建议是先用预训练模型跑通整个链路,看到跟踪和计数效果以后,再标注几百张俯视图做增量微调,否则容易把第一版项目的进度压在数据标注上。

3. 用 YOLOv8-deepsort 在本地跑通车辆计数的代码级最小实现

3.1 环境与模型准备:GPU 不是必须,分辨率与帧率才是决定项

热词里反复出现“需要用到 GPU 吗”,可以直接给结论:验证流程不需要,做实时计数需要。DeepSORT 的卡尔曼滤波、级联匹配和特征提取都在 CPU 上执行,真正决定帧率上限的是 YOLOv8 推理速度。用 yolov8n 在 720p 视频上纯 CPU 推理,常见表现是 5 到 10 FPS,离线处理能接受;假如要接入现场摄像头并实时输出计数,建议用支持 CUDA 的 GPU,至少让 YOLOv8 的部分跑在 GPU 上。环境搭建步骤按常见的深度学习工程方式准备就可以:安装 PyTorch、CUDA 版 torchvision,再安装 ultralytics,DeepSORT 部分拉取一份可用的开源实现放入项目目录。

pip install ultralytics opencv-python

torch 和 torchvision 的版本要匹配,YOLOv8 对 torch 版本要求不苛刻,但版本差距过大会在加载权重时报出算子不支持的错。DeepSORT 的依赖更简单,通常只需要 torchvision 和 scipy。

3.2 主循环里的数据流:检测、过滤、转坐标、跟踪

把主程序写成一段能照抄的骨架,比逐个贴 API 更直观。下面这个循环完成了输入视频到输出视频的完整通路,计数逻辑先留个空位:

import cv2 import numpy as np from ultralytics import YOLO from deep_sort_pytorch.deep_sort import DEEPSORT # 不同实现的导入路径不同 # 初始化 YOLOv8,video 推理时直接传路径也可以 model = YOLO("yolov8s.pt") # 初始化 DeepSORT,具体参数含义见 3.3 节 tracker = DEEPSORT(max_age=30, min_hits=3, max_cos_dist=0.3, nn_budget=100) cap = cv2.VideoCapture("traffic.mp4") writer = cv2.VideoWriter("output.mp4", cv2.VideoWriter_fourcc(*"mp4v"), 25, (1920, 1080)) while cap.isOpened(): ret, frame = cap.read() if not ret: break # 第 1 步:YOLOv8 推理,conf 和 iou 是两个直接影响计数精度的入口参数 results = model(frame, conf=0.4, iou=0.5, verbose=False) dets = results[0].boxes.data.cpu().numpy() # [N, 6]:x1,y1,x2,y2,conf,cls # 第 2 步:剔除非车辆类别,车辆在 COCO 中为 car=2, bus=5, truck=7 if len(dets) == 0: continue vehicles = dets[np.isin(dets[:, 5], [2, 5, 7])] # 第 3 步:坐标由 xyxy 转 xywh,并只保留 [cx, cy, w, h, conf] xywh = xyxy2xywh(vehicles[:, :4]) detections = np.hstack([xywh, vehicles[:, 4:5]]) # 第 4 步:交给 DeepSORT 更新轨迹,返回值通常为 [x1,y1,x2,y2,id] tracks = tracker.update(detections, frame) for track in tracks: x1, y1, x2, y2, ident = track center = ((x1 + x2) / 2, (y1 + y2) / 2) # 计数逻辑写在这里,见第 4 章 cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(frame, f"ID {int(ident)}", (int(x1), int(y1) - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) writer.write(frame) cap.release() writer.release()

这段代码值得解释的细节有三个。第一,model(frame, conf=0.4, iou=0.5) 里的 conf 在 YOLOv8 早期版本叫 conf_thres,不同版本 API 有差异,接入前先查一下当前版本支持什么写法。第二,tracker.update 的返回值不是所有实现都一样,有的实现返回 NDArray,有的返回 dict,拿到后先打印类型再按对应方式解析 ID。第三,DEEPSORT 初始化参数在 3.3 节给出建议值,实际项目中这些参数属于“第一版就能用、第二版才需要精调”的类型。

注意:不同实现的 DeepSORT 对 update 的返回值定义并不统一,接入新代码前先打印 track 的类型和长度。

3.3 deep sort 常见初始化参数的含义与车辆场景建议值

不同开源实现的参数名略有差异,但核心参数基本一致:

参数常见默认值作用车辆场景建议
max_age30目标丢失后,轨迹保留的帧数30~50,路口遮挡严重取 50
min_hits / n_init3新轨迹需要连续匹配成功几帧才被确认3,过低会出现闪现的噪声轨迹
max_cos_dist0.2外观特征的最大余弦距离,越小要求越严格0.3~0.5,车辆外观趋同导致默认值过紧
nn_budget100每个轨迹保留的特征向量个数50~100,特征太多匹配慢

max_cos_dist 是车辆场景最容易被忽视的参数。行人重识别特征在同一个人变换角度后仍相对接近,但车的颜色、反光、车牌角度都会让特征离得很远。如果一辆车在画面里反复变道,特征匹配失败后会退化成纯 IOU 匹配,ID 切换概率上升。建议值 0.3 到 0.5 的意思是“外观不好区分时,多相信一点位置信息”。

3.4 为什么第一步只需要让“检测框上的 ID”保持住

拿到一个能跑的 YOLOv8-deepsort 版本后,先别急着写车辆计数逻辑。在视频上把画了框和 ID 的中间结果完整看一遍:理想情况是一辆车从进入画面到离开,ID 始终是同一个。只要有 ID 跳变,计数逻辑写得再正确也无济于事。这个阶段的排查方法很简单:找一段车辆少、行驶方向单一的视频,观察 ID 序列是否连续递增且不重复;跳变明显时,优先调 max_age,再动 conf。

4. 车辆计数的置信度、IOU 与跟踪生命周期:三个必调参数与常见坑

4.1 confidence 阈值:漏检和误检在车辆计数里的代价并不对称

YOLOv8 的 conf 参数控制检测框的保留条件。置信度阈值调低,会留下很多背景噪声框,DeepSORT 会把这些噪声框当成新目标并分配新 ID,车辆计数会被“幽灵车”拉高;阈值调高,远处的小目标被滤掉,车辆轨迹断裂,计数被拉低。两者都不好,但后果不同:误检多产生的是一次性噪声轨迹,跟踪器的 min_hits 可以滤掉大部分;漏检会让一辆车数成两辆,这是计数项目里最致命的问题。

调阈值有一个直接有效的笨办法:把视频抽出一帧,用 conf=0.1 跑一次检测,把所有框画出来,统计那些压在树上、路灯上、路面标识上的假框;再把 conf 逐步往上加,直到假框基本消失。这个值就是当前机位的合理起点。不要直接用 0.25 这类默认值,车辆场景里的远距离目标本身置信度就在 0.2 到 0.4 之间。

目标检测评价指标里的 mAP 在这个环节基本帮不上忙。mAP 衡量的是整个测试集上的检测精度和召回,跟具体机位、具体远近尺度没有直接关系。计数项目的调参基准应该是“这一条计数线上发生过多少次真实事件”。

4.2 deep sort 三个生命周期参数:max_age、min_hits、max_cos_dist

4.2.1 max_age 决定车辆被遮挡后还能不能回来

车辆被大型车短暂遮挡、进入立柱盲区、或者检测置信度连续几帧低于阈值时,轨迹并没有立即删除,而是进入“丢失”状态。max_age 是轨迹在丢失状态下保留的帧数,超过这个值轨迹才会被删除。对这个参数最直觉的理解是:车辆消失的帧数如果小于 max_age,那么它再次出现时还能拿回原来的 ID;大于 max_age,就会被当作新车。城市道路上一辆轿车被公交车遮挡 2 到 3 秒,按 30FPS 算就是 60 到 90 帧。max_age 给到 30 只覆盖 1 秒,明显不够,推荐在 30 到 50 之间。

4.2.2 min_hits 滤掉噪声轨迹,却不适合所有场景

新产生的轨迹要连续若干帧都匹配上检测,才会被确认为正式轨迹,这就是 min_hits。它的作用是把 4.1 节残留的噪声框挡在计数逻辑外面。min_hits 设成 3,一辆车第一次进入画面到第 3 帧之前存在一个半瞬态轨迹,可能造成计数延迟 2 到 3 帧。如果视频里进出车辆速度很快,60km/h 的车速下 25FPS 的每帧移动约 0.66 米,延迟几帧意味着计数线位置偏差几米。极端场景下 min_hits 设 1,但要接受偶尔多计的噪声框。

4.2.3 max_cos_dist 在车辆场景里的特殊问题

车辆外观的相似度天然高:黑色轿车和黑色 SUV 在特征空间可能比同一辆车在两个光照条件下的距离还近。所以车辆场景的 max_cos_dist 不能照搬行人重识别项目的默认值。我的调法是把默认 0.2 调到 0.4,然后观察夜间场景。夜间车辆的特征受车灯和反射影响很大,同一辆车出地库前后特征差距明显;调到 0.4 后 ID 保持时间有明显改善。

4.3 基于虚拟线的车辆计数逻辑:方向判定与重复计数消除

计数模块在跟踪器之上。最常用的做法是画一条虚拟线:车辆质心从线的一侧运动到另一侧,就算一次通过。方向由质心跨越线的方向决定。下面这段代码是直接把计数逻辑嵌入到跟踪循环里的实现:

line_a = (0, 600) # 计数线起点 line_b = (1280, 600) # 计数线终点 MAX_POINTS = 20 # 每个轨迹只保留最近 20 个质心 # 计算点在线哪一侧,正负表示方向 def side(p): ta = (line_b[0] - line_a[0]) * (p[1] - line_a[1]) tb = (line_b[1] - line_a[1]) * (p[0] - line_a[0]) return ta - tb hist = {} # ID -> 质心历史 counter = {"in": 0, "out": 0} for track in tracks: x1, y1, x2, y2, ident = track cx, cy = (x1 + x2) / 2, (y1 + y2) / 2 tid = int(ident) hist.setdefault(tid, []).append((cx, cy)) if len(hist[tid]) > MAX_POINTS: hist[tid].pop(0) if len(hist[tid]) >= 2: prev = hist[tid][-2] curr = hist[tid][-1] if side(prev) * side(curr) < 0: # 前后两帧位于线两侧时判定为跨线 if side(curr) > 0: counter["out"] += 1 else: counter["in"] += 1

这段逻辑本身没有任何花哨的地方,真正的问题是内存和误判。hist 如果无限追加,视频长跑几小时就会积累出内存问题,所以要限制每个 ID 只保留最近 20 个质心;跨线条件用side(prev) * side(curr) < 0比分别判断正负更快,且能把刚好压在线上重复计数的情况挡在外面。

车辆停在线旁、原地掉头、倒车入库,这些情况会让质心来回穿越虚拟线。如果业务是“进出双向统计”,建议在线两侧各加一个“过线后的冷却时间”,比如同一个 ID 在 5 秒内只计一次。如果业务只关心“驶入地库的车数”,则计数条件要加方向的判定。

4.3.1 车辆被遮挡后的技术与业务两难

停车场道闸是经典的坑:车在道闸前停了 10 秒,如果这 10 秒内车被道闸杆或记录仪干扰,检测框消失,max_age 耗尽后轨迹被删。杆一抬、车子驶入,DeepSORT 会视为新车,计数变成两次。工程上两种常用补救办法:把 max_age 调到能覆盖停车等待时间的帧数;或者在横杆前后画两道线,只统计“完全越过第二道线且没有在杆前停留超过 max_age 的轨迹”。后一种做法的误差来源更可控。

4.3.2 “已测”二字背后,建议用事件级指标验证

车辆计数是一个离散事件统计系统,用目标检测的评价指标 mAP 来验收是非常不匹配的。常见做法是人工标注一条 5 分钟视频里所有跨越计数线的事件,程序输出的事件按时间窗口与人工结果做对齐,然后计算事件级的精确率和召回率。精确率反映“多计了多少”,召回率反映“漏计了多少”。如果两者差距过大,回到 4.1 的阈值去调整。按我的经验,第一版能跑到精确率 0.9 以上就算“已测”过关,剩下的误差来源大多是跟踪 ID 切换。

5. 把 YOLOv8-deepsort 落地到 RK3588 与端侧提速技巧

5.1 先用 ONNX 导出,后端推理不一定需要 PyTorch

YOLOv8 自带导出接口:model.export(format="onnx", opset=12)。在 ultralytics 环境下,这一行代码会完成权重转换。导出 ONNX 后再换 onnxruntime 推理,速度通常比 PyTorch eager 模式提升不少。要注意 RKNN 工具链对算子的支持范围:优先锁定 opset 12 或 13,输入尺寸固定为 640x640,避免动态尺寸带来的兼容问题。导出后要验证,用同一个视频分别跑两个后端,比对检测框数量和坐标,而不是只看能否加载模型。

5.2 RK3588 部署时的常见分工:NPU 跑检测,CPU 跑跟踪

RK3588 提供 6 TOPS 的 NPU 算力,YOLOv8 的卷积部分可以转换到 RKNN 格式后在 NPU 上推理,YOLOv8 目标检测流程的耗时主要被 NPU 消化。DeepSORT 的卡尔曼滤波和级联匹配是纯数值计算,不适合搬上 NPU,常规做法是留在 CPU 上跑。工程结构一般是:采集线程只负责读帧,NPU 推理线程输出检测框,DeepSORT 线程拿到检测框后更新轨迹,最后一个线程负责绘制和计数。四线程之间的数据用队列传递,检测帧率和跟踪帧率解耦。

部署在 RK3588 时会遇到两个高频问题。模型转换时如果提示不支持的算子,优先检查模型里是否带了 NMS 后处理节点,YOLOv8 导出的 ONNX 可能包含 NMS,转换前建议把后处理留在 CPU 端实现;输入的图像缩放方式要保持 letterbox 一致,否则检测框坐标映射到 1080p 原图上会整体偏移。

5.3 检测降频:用卡尔曼预测补足中间帧

端侧算力有限时,不必每一帧都跑 YOLOv8。常见做法是每 3 帧或每 5 帧做一次推理,中间帧用 DeepSORT 的卡尔曼预测输出来绘制框,这样视频看起来仍然是连续的,计数逻辑也不需要改动。这个技巧能显著拉高端侧 FPS,缺点是最多延迟几帧发现新的目标。车辆计数场景下,目标出现位置是逐渐靠近计数线的,延迟影响通常可以接受。

另一个低成本的优化是降低检测端输入分辨率。监控画面是 1080p,YOLOv8 输入缩放到 640x640 后,远处车辆只有几十个像素,收益有限;把输入从 640x640 缩到 416x416,检测召回率略有下降,但端到端实时性有明显提升。如果硬要把远处小目标保住,就要换用更大的输入和更强的模型,这是计算量和召回率之间的一个工程取舍。

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

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

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

立即咨询