☰
YOLOv8人头计数检测系统:从训练到ONNX部署的完整实践
2026/10/2 8:58:32 网站建设 项目流程

简介:基于YOLOv8的人头计数检测系统源码包,适合具备一定Python与深度学习基础的开发者,用于快速搭建人头检测与计数应用;资源集成PyTorch、Ultralytics框架及ONNX模型,附带精美GUI界面,可直接运行或二次开发。模型针对head类别训练,训练集20790张图片,测试集1980张,mAP达96.1%,精度97.4%,召回率92.2%,检测效果稳定。包内共30个文件,涵盖Python脚本、模型权重、XML标注、图片与评估图表等,压缩包约10.16MB;其中py文件实现检测逻辑与界面交互,onnx模型便于跨平台部署,png/jpg用于效果展示与测试,txt含类别与说明文档,整体结构清晰。已有611人学习浏览,适合需要人头计数、客流统计或目标检测入门参考的开发者快速上手,配套的评估指标曲线与模型说明可帮助验证训练效果,为后续调优提供参考。

1. 人头计数这事,为什么非得用 YOLOv8 加 ONNX 走一遍

做客流统计、车间在岗人数核查或者校园考场人数清点的时候,我见过太多团队直接拿 YOLOv5 的权重硬怼摄像头流,结果在密集小目标场景里漏检率飙到 30% 以上。人头检测和通用物体检测最大的区别在于:目标尺寸小、相互遮挡严重、俯视角度下特征退化,这三条叠加起来,很多模型在公开数据集上刷的漂亮 mAP 根本扛不住真实场景。基于 YOLOv8 的人头计数检测系统提供的是一套从训练到部署的完整闭环,核心价值在于把检测头和计数逻辑解耦,再用 ONNX 把模型固定下来,让 Python 推理和 GUI 展示各干各的活。

这套方案适合两类人:一类是要在本地摄像头或者离线视频里快速验证人头计数可行性的人,另一类是准备把算法移植到盒子或者嵌入式设备、想先摸清精度和速度底线的工程师。它不解决摄像机标定和多相机跨镜追踪,它解决的是单路视频里“人到底有几个、框到底准不准”这个最原始的问题。下面我会从模型选型、数据集组织、训练参数、ONNX 导出与推理、GUI 封装、踩坑记录这几个层面把整条链路拆开讲。

2. 为什么 YOLOv8 适合人头计数:模型结构、损失函数与场景对齐

2.1 人头检测为什么难:小目标、遮挡和俯视角度

先看一个反直觉的结论:人头检测的难度不在于“认出这是头”,而在于“在遮挡条件下稳住框的回归”。常规的行人检测数据集里,人体框是全身框,头部区域只占整张图的 5% 到 10%;但人头计数场景里,摄像头通常架在 3 到 6 米高度,俯视角度下人体信息被压缩,只剩下头顶和肩部轮廓。这时候全身检测器会大量漏检,不是因为它笨,而是因为它学到的特征分布和俯视场景根本不匹配。

YOLOv8 针对这类问题有几个结构上的优势。它的 C2f 模块在 Backbone 中把梯度流拆成多条支路再融合,比 YOLOv5 的 C3 模块提取小目标纹理特征的能力更强。Head 部分换成了 Anchor-Free 的 Decoupled Head,分类和回归分支不再共享同一个卷积输出,这让模型在“框位置微调”和“类别置信度”两个任务上各调各的,对遮挡严重的场景更友好。再加上它默认的 TaskAlignedAssigner 正样本分配策略,会把预测框和真实框的对齐质量纳入匹配分数,而不是单纯看 IoU,这直接影响了密集场景下正样本的选取质量。

2.2 训练数据怎么组织:SCUT-HEAD 还是自建标注

人头计数最容易翻车的点不是模型,是数据。SCUT-HEAD 数据集有两千多张校园场景图像和三万多个标注框,适合做预训练;但真实项目里摄像头角度千奇百怪,我一般建议先花一两天自建标注。用 LabelImg 或者 X-AnyLabeling 标注的时候,只需要框头部范围,从头顶到下巴,不包含脖子和肩膀。如果你的摄像头是斜上方 45 度角,宁可框得稍微紧一点,也不要框到肩膀,否则后处理 NMS 阶段会频繁误抑制相邻人头。

目录组织按 YOLO 格式来,这是最省事的做法:

dataset/ ├── images/ │ ├── train/ # 约 1200 张 │ └── val/ # 约 300 张 ├── labels/ │ ├── train/ # 每张图对应同名 .txt │ └── val/ └── data.yaml # 类别描述文件

data.yaml 内容如下,注意括号里是我实际项目里会微调的项目:

path: /home/user/dataset # 改成你自己的绝对路径 train: images/train val: images/val names: 0: head

这里最容易踩的坑是路径用了相对路径或者 Windows 风格的反斜杠。Ultralytics 在读取 data.yaml 时对路径解析很敏感,一旦找不到图片就会在训练开始时报AssertionError: train dataset not found。我习惯在 Linux 下统一用绝对路径,Windows 下也用正斜杠,省得后面排查。

2.3 从头训练还是 fins-tune:两个选择的关键区别

如果你只有几千张自建数据,直接model=yolov8n.pt或者yolov8s.pt从 COCO 预训练权重开始迁移学习是合理的。人头虽然不是 COCO 的独立类别,但 COCO 里 person 类别的浅层特征(纹理、边缘)对人头检测有很强的迁移价值。如果你的场景非常特殊,比如是红外热成像的人头,那预训练权重的意义就小很多,这时候可以考虑不加载权重从头训练,只是训练轮数要拉到 300 epoch 以上。

关键参数设置是这样的:

yolo detect train data=data.yaml model=yolov8s.yaml epochs=150 imgsz=640 batch=16 lr0=0.01

参数说明:epochs=150对小数据集够用,再多容易过拟合;imgsz=640保持默认分辨率的平衡;batch=16看显存,如果你是 8G 显存建议降到 8;lr0=0.01是初始学习率,配合 Ultralytics 自带的余弦退火会在后期把学习率压得很低,对收敛稳定很重要。训练完后看runs/detect/train/weights/best.pt,这是验证集上表现最好的权重,导出和后续推理都用它。

3. 把 PyTorch 权重转成 ONNX:固定动态轴、验证输出、踩三个常用坑

3.1 导出命令和动态轴为什么不能乱开

转换本身一行命令就能搞定,但真正决定部署命运的是三个细节:opset 版本、动态轴配置、输出张量的形状。

yolo export model=best.pt format=onnx opset=12 dynamic=True simplify=True

这份命令会产出best.onnx。dynamic=True的意思是允许 batch size 动态变化,这在检测任务里通常是推荐的,因为你不确定一次喂一张图还是多张图。simplify=True会调用 onnx-simplifier 做图优化,去掉一些冗余的 reshape 和 transposed 节点,推理速度能提升 5% 到 10%。

导出完成后,务必用下面这段脚本检查模型的输出结构:

import onnx model = onnx.load("best.onnx") onnx.checker.check_model(model) # 打印输出张量的名称和形状 for output in model.graph.output: print(output.name, [d.dim_value for d in output.type.tensor_type.shape.dim])

逻辑说明:onnx.checker.check_model是官方合规性检查,能发现不合法节点的存在。更关键的是第二段,输出张量的维度信息。YOLOv8 的 ONNX 输出形状为[batch, 84, 8400],其中 84 表示 4 个框坐标加 80 个 COCO 类别概率。如果你用的是自己训练的人头单类别模型,输出会变成[batch, 5, 8400],5 是 4 加 1。如果在后处理代码里想当然地按 COCO 的 84 去切,结果必然是灾难性的。所以导出后第一件事就是打印这个维度。

3.2 ONNX 输出是反直觉的 [C, N] 布局,后处理必须转置

这是所有 ONNX 推理中最容易翻车的一步。YOLOv8 的 PyTorch 模型输出是[batch, boxes, channels]的布局,但 ONNX 导出后变成[batch, channels, boxes]。具体来说,8400表示特征图所有格子总数,84或5表示每个格子的通道维度。如果不转置就直接做 NMS,你会在数组切片时得到大量重复的边界框。

正确做法是在 NMS 之前先把张量转成[batch, boxes, channels]的形状,然后对每一行的 0 到 3 索引取中心坐标加宽高,最后转成左上角右下角形式。ONNX 用的是[cx, cy, w, h],记得转化,不然框全画歪。

3.3 INT8 量化:为什么人头的场景收益大但风险也大

热词里反复出现的.onnx 量化 int8是部署环节的高频需求。人头检测非常适合 INT8 量化,因为头部纹理相对简单,量化对精度损伤通常小于通用物体检测。但前提是你有一段有代表性的校准数据集,一般是几百张验证集图片。用 onnxruntime 的QuantizationTool做静态量化的时候,校准数据集不能只有人头密集图,也要包含一些空场景或少量人头的图,否则量化后模型会在稀疏场景下输出诡异的高置信度误报。

量化的坑主要在两个地方:一是某些算子(比如 Sigmoid)在 INT8 下精度损失会累加,二是分支结构多的网络量化后更容易出现精度跳水。我的经验是导出 ONNX 后先跑 FP32 推理,记录 baseline mAP,然后量化后再跑一遍对比,如果 mAP 下降超过 3% 就放弃量化。不要在没跑 baseline 的情况下直接把量化模型接进 GUI,否则你很难判断精度下降是量化造成的还是后处理代码写错了。

4. 用 ONNX Runtime 做推理:从加载模型到画框计数的最小工程代码

4.1 推理代码的整体结构

ONNX Runtime 是整个系统里 Python 和 ONNX 模型之间的桥梁,它只负责把输入张量喂给模型、拿到输出张量,所有的预处理、后处理和计数逻辑都要你自己写。这是和直接使用 Ultralytics 库最大的区别:一旦转成 ONNX,你就失去了 PyTorch 的自动预处理,不能再指望库函数替你缩放图片、做 letterbox、解析输出。

下面是我常用的一段最小推理代码,直接可以用:

import cv2 import numpy as np import onnxruntime as ort # 创建推理会话并启用 CPU 优化 sess = ort.InferenceSession( "best.onnx", providers=["CPUExecutionProvider"] ) # 预处理步骤 def preprocess(image, input_size=(640, 640)): h, w = image.shape[:2] r = min(input_size[0] / h, input_size[1] / w) new_w, new_h = int(w * r), int(h * r) resized = cv2.resize(image, (new_w, new_h)) canvas = np.full((input_size[0], input_size[1], 3), 114, dtype=np.uint8) canvas[:new_h, :new_w] = resized # HWC 转 CHW,归一化到 [0,1] blob = canvas.transpose(2, 0, 1).astype(np.float32) / 255.0 return np.expand_dims(blob, axis=0), new_w, new_h, w, h # 后处理,按人头单类别处理 def postprocess(output, conf_thres=0.5): # output shape: [1, 5, 8400],先转成 [1, 8400, 5] preds = output.transpose(0, 2, 1) boxes, scores = [], [] for i in range(preds.shape[1]): cx, cy, bw, bh = preds[0, i, :4] score = preds[0, i, 4] if score > conf_thres: x1, y1 = cx - bw / 2, cy - bh / 2 x2, y2 = cx + bw / 2, cy + bh / 2 boxes.append([x1, y1, x2, y2]) scores.append(score) # 用 cv2.dnn.NMSBoxes 做 NMS,避免手写循环 indices = cv2.dnn.NMSBoxes(boxes, scores, conf_thres, iou_thres=0.45) final = [boxes[i] for i in indices.flatten()] if len(indices) > 0 else [] return final

逻辑说明:preprocess函数先按比例缩放图片,再用灰色填充到 640 乘 640 的画布,这是 letterbox 操作。对 ONNX 推理而言,这是最容易出问题的地方——如果你做的是直接 resize 而不是 letterbox,图像里物体形状会被拉伸,模型精度会明显变差。postprocess函数里先做转置,再过滤低置信度框,最后用 OpenCV 自带的 NMS。注意我这里没有做坐标缩放回原图的操作,实际项目中在postprocess返回值前需要按r反向换算,否则画在原图上的框缩小一圈。

4.2 计数逻辑:判断进与出,而不是只数瞬时人数

单纯统计画面里有多少个框是不够的,真实的计数系统需要统计“到今天一共进去了多少人”。这时候需要一个虚拟线或区域判定逻辑。我通常的做法是追踪每个检测框的中心点,用轻量级的 IoU 匹配做跨帧关联:

def count_crossings(centers, prev_centers, line_y): count = 0 for c in centers: for prev in prev_centers: if abs(c[0] - prev[0]) < 50 and abs(c[1] - prev[1]) < 50: # 跨过虚拟线: y 从线以上到线以下 if prev[1] < line_y and c[1] >= line_y: count += 1 break return count

参数说明:line_y是虚拟线的纵坐标位置;阈值 50 是像素距离,要按摄像头画幅调整,画幅是 1920 宽就当放大一点,1280 宽就缩小。用这类简化追踪逻辑能处理大部分单摄像头场景,但如果人流量大且密集,建议直接升级成 ByteTrack 或 DeepSORT,否则计数会明显偏低。

4.3 在 Ubuntu 20.04 CPU 环境跑通的完整依赖

很多人问 Ubuntu 20.04 上 CPU 版本怎么搭建环境。这条命令组合是我经过多次踩坑后确定的:

python3 -m venv venv source venv/bin/activate pip install onnxruntime==1.15.1 opencv-python==4.8.0.74 numpy==1.23.5

参数说明:onnxruntime==1.15.1配 Python 3.8 最稳定,如果你用 Python 3.10 或更高版本可以升到 1.16 以上;numpy==1.23.5这个版本要和 onnxruntime 兼容,装太新的 NumPy 会在加载模型时出现 ABI 错误。opencv-python版本尽量选 4.8 系列,4.10 之后有些 NMS 函数的返回类型有变化。CPU 环境跑 YOLOv8s 尺寸的 ONNX,640 分辨率单帧推理大约 200 到 400 毫秒,如果达不到这个水平,先怀疑是不是未开启ort的线程优化参数。

5. 人工头计数的精度与训练评估:mAP、损失曲线、置信度阈值三条线怎么读

5.1 mAP 曲线不只是看数值,要看 PR 曲线的形状

所谓评估指标曲线,Ultralytics 在训练完会在runs/detect/train目录下自动生成results.png和confusion_matrix.png。这个文件会把 mAP50、mAP50-95、精确率、召回率、训练/验证损失曲线都画在一张图里。大多数新手只看 mAP 数值,但我想强调的是三条线要对着看。

第一条是val/box_loss,验证集框回归损失。如果训练损失还在下降但验证损失开始上扬,说明过拟合了,对策是提前停止而不是继续撞上去。第二条是metrics/precision和metrics/recall的交叉点。人头计数场景普遍偏向保召回率,宁肯多框一个假头也不漏掉一个真头,所以置信度阈值要往低了调,比如 0.3 而不是默认的 0.5。第三条是 PR 曲线,如果曲线在召回率 0.8 附近出现明显的膝盖型拐点,说明模型对低质量目标已经有些乏力,再继续压召回率会引入大量虚警。

5.2 置信度阈值对计数偏差的影响有多大

我们来做一组很现实的估算:假设验证集有 1000 个真实人头,你选置信度阈值 0.5 时检出 900 个,精确率 95%,那计数偏差是漏检 100 个,虚报 47 个,最终统计值为 947,偏差率约 5%。但如果阈值抬到 0.7,漏检数可能飙升到 150,最后 counting 结果反而偏小。阈值这东西不是越高越好,先跑一段验证集,画出 F1 曲线和置信度之间的关系,找到 F1 最大的阈值再固定到配置里。

5.3 自建数据集的评估方法:划分验证集和计算误检率

相比公测数据集,自建数据集评估时我更关注一个指标位叫“单场景误检率”,定义是:

误检率 = 检测到的假头框数量 / 真实人头数量

这个指标比 mAP 更贴近业务。比如一个 50 人教室的监控画面,如果模型输出 52 个框,其中 3 个是椅子背或反光点,误检率就是 6%。实用角度来说,误检率大于 5% 就需要检查训练数据里有没有混入大量“类头背景”,比如圆形灯罩、球类装饰。把这些负样本单独建一个背景类别或者加入负样本图片重训,比调整 NMS 参数有效得多。

6. 人工头计数系统的 GUI 界面:用 PySide6 在 200 行内做出一版可交付的完整工具

6.1 GUI 分两栏:左侧实时画面,右侧计数面板与启动控制

热词里多次出现的 Python GUI 需求,在这个项目里通常指的是一个简易桌面端应用。最常见的布局是左侧显示摄像画面或视频帧,右侧显示当前人数、累计人数、置信度阈值滑动条和模型加载按钮。下面我写一个基于 PySide6 的最小骨架,并解释每一段的作用。

import sys import cv2 from PySide6.QtWidgets import QMainWindow, QLabel, QPushButton, QSlider, QVBoxLayout, QHBoxLayout, QWidget from PySide6.QtCore import QTimer from PySide6.QtGui import QImage, QPixmap class HeadCounterGUI(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Head Counter - YOLOv8 + ONNX") self.video_label = QLabel("视频画面") self.count_label = QLabel("当前人数: 0") self.total_label = QLabel("累计人数: 0") self.btn_start = QPushButton("开始检测") self.slider_conf = QSlider() self.slider_conf.setRange(10, 90) self.slider_conf.setValue(35) # 对应 0.35 置信度 layout = QHBoxLayout() left_layout = QVBoxLayout() right_layout = QVBoxLayout() left_layout.addWidget(self.video_label) right_layout.addWidget(self.btn_start) right_layout.addWidget(self.count_label) right_layout.addWidget(self.total_label) right_layout.addWidget(self.slider_conf) layout.addLayout(left_layout, 4) layout.addLayout(right_layout, 1) widget = QWidget() widget.setLayout(layout) self.setCentralWidget(widget) self.timer = QTimer() self.timer.timeout.connect(self.update_frame) self.btn_start.clicked.connect(self.toggle_detection) def update_frame(self): # 这里用 placeholder,实际项目中在此处读取摄像头帧、调用推理函数、绘制框并更新计数 frame = self.cap.read()[1] result_frame, current_count = run_inference_on_frame(frame) self.video_label.setPixmap(QPixmap.fromImage(convert_cv_to_qimage(result_frame))) self.count_label.setText(f"当前人数: {current_count}")

参数说明:QTimer是刷新机制,默认间隔要设在 30 毫秒左右,约 33 FPS,CPU 推理速度如果达不到这个频率,会自降 FPS。slider_conf的范围设 10 到 90 对应 0.1 到 0.9 的置信度阈值,实际使用中从 0.3 到 0.5 这一段调节对结果影响最明显。run_inference_on_frame是需要你自己接入第 4 节推理代码的接口函数,返回绘制好的帧和当前人数。

6.2 Windows 环境常见 GUI 缺失 DLL 问题和语言选项错乱

在 Windows 上跑 PySide6 时有一个很典型的坑:程序启动后报错缺少MSVCP140.dll或者VCRUNTIME140.dll,这不是代码问题,而是缺少 Microsoft Visual C++ Redistributable。解决方案是安装 VC_redist.x64.exe,而不是去折腾 Python 安装包。此外 PySide6 在中文 Windows 环境中有时会出现 Qt 平台插件加载失败,报platform plugin "windows" could not be loaded,这时候检查环境变量QT_QPA_PLATFORM_PLUGIN_PATH是否指向了PySide6/Qt/plugins/platforms。这两个属于高频场景,提前在 README 里写明能省掉用户大量排查时间。

6.3 GUI 里模型加载失败和视频源打开失败的错误处理

真实交付的时候这类代码需要保证不闪退。模型加载失败最常见的原因是best.onnx路径包含中文或者空格,onnxruntime 对包含中文路径的模型文件支持不完整。视频源打不开时,cap.isOpened()返回 False,后续读帧时read()返回的是空元组,如果在update_frame里直接索引下标就会报错。正确写法是在每次read()后检查返回值。建议在 GUI 里把错误信息弹出一个QMessageBox,而不是直接打印到控制台,因为使用 GUI 的用户大概率不会去看后台输出。

7. 把误检率打下来的三个落地技巧:负样本、置信度校准、视频帧抽帧策略

7.1 负样本图片的权重:让模型学会“这不是头”

很多做检测项目的人忽略了一个事:模型漏检往往不是因为它认不出头,而是它把太多“像头的东西”当成了头。在人头计数场景里,典型的假阳性是:深色圆形座椅、球类、圆形灯罩、肩部以上反光物。这类目标在特征上和俯视的人头非常接近。负样本策略很简单——单独建一个background目录,里面放 200 到 500 张不包含人头的现场背景图,做成一张张没有标注文件的训练图。Ultralytics 支持train: images/train和单独一个background目录混合训练,但一般做法是直接把负样本也放进images/train里、同时不生成对应的 label 文件,模型就会自动把他们当作背景类学习。

7.2 置信度校准为什么只对检测框有效,对计数结果不一定有效

置信度校准本质是把输出概率映射到真实概率分布,但人头计数系统追求的是总数准确,不是每个框的概率准确。有时候你把阈值调低导致召回率提升,一个画面里多人重叠导致同一个头被检测了两次,计数反而偏高。所以参数调整的最终评估标准应该落到业务指标上——单日计数偏差率、高峰时段漏检率。每次调参后在固定的 1 小时监控视频段上回放,观察计数曲线和人工统计数量的偏差,比盯着单个框的置信度数值有用得多。

7.3 抽帧检测是最后的保底手段:卡顿、掉帧和延时处理

CPU 推理速度跟不上视频流帧率的时候,不要试图强制同步。我的处理方法是设置一个采样间隔,比如每 3 帧检测一次,检测结果渲染到上一帧画面上。这样界面看起来流畅,计数实时性损失也不大。真遇到高帧率且密集场景,先降低推理分辨率到 480 或 416,这比换更大的模型更实用。别忘了,在 GUI 里的刷新逻辑中把检测和渲染拆成两个线程,否则推理阻塞会导致界面卡死,用户第一反应就是程序崩溃。

这套基于 YOLOv8 的人头计数系统,从数据准备到 GUI 交付全流程跑下来,最怕的不是技术实现,而是中途放弃。我见过太多人卡在 ONNX 输出维度不匹配这一步,然后开始怀疑模型导出出了问题,实际上只是忘了转置。希望这篇笔记能帮你把从训练到部署的路径理顺,少走弯路。

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

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

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

立即咨询