简介:基于YOLOv8的人头计数检测系统面向需要人群密度分析与客流统计的开发者,提供完整Python源码和可直接调用的ONNX模型,并配有精美GUI界面,能够自动检测并计数画面中的人头目标。压缩包共30个文件、大小约10.16MB,囊括Python脚本、yolov8n.onnx模型、Qt界面资源与图片素材、测试图片和标注文件,目录中检测器类、主程序、模型说明分模块存放,便于二次开发或迁移到具体项目。模型在20790张训练图片上完成训练,测试集1980张,mAP达96.1%、精度97.4%、召回率92.2%,同时附带评估指标曲线,可直观判断模型表现。目前已有611人学习,适合计算机视觉初学者以及安防、零售、展会等场景的实时人头计数参考。
1. 从YOLOv8到人头计数:这份资源包里到底有什么
第一次打开这个压缩包的时候,我特意看了一眼目录结构:python源码、onnx模型、评估指标曲线、GUI界面,四个部分各归各的位置。这是一个典型的基于yolov8的人头计数检测系统,推理脚本可以直接跑,GUI是桌面应用的外壳,评估曲线是训练过程留下的指标产物。对做门店客流统计、教室人数清点这类场景的朋友来说,它省下的不是“从头训一个模型”的时间,而是“把模型变成一个能交付的软件”的那部分功夫。适合谁用:已经接触过目标检测、但没独立搭过完整推理链路的Python开发者;或者接到了类似需求、想直接改改就上线的工程师。权重已经是onnx格式,装好依赖就能跑通,不用碰训练。
2. 人头检测的原理与资源包结构:从YOLOv8网络说起
2.1 为什么人头检测选YOLOv8而不是Faster R-CNN或SSD
人头检测本质上是目标检测的一个细分场景,难点在于人头尺度小、密集遮挡多。YOLOv8相比两阶段检测器的核心优势是速度和精度的平衡:它的 backbone 使用 C2f 结构,在保持梯度流的同时减少了计算量;head 部分采用 anchor-free 的 decoupled 结构,分类和回归分支分开输出,收敛更快。和一阶段的 SSD 相比,YOLOv8 引入了更深的特征融合和更精细的损失函数,对密集小目标的召回率明显更好。在实际门店和教室场景里,人头不像行人检测那样有完整身体特征可以依赖,往往只能看到头顶和肩部轮廓,所以模型对局部特征的敏感度比网络深度更关键,YOLOv8 的 multi-scale 特征输出在这里正好够用。
2.2 压缩包目录结构与关键文件带什么
这份资源包解压之后,目录大致是下面这样的结构,每个文件对应一个明确的功能:
| 路径 | 作用 |
|---|---|
weights/head_detect.onnx | 训练好的onnx权重,人头检测专用,可直接被onnxruntime加载 |
src/detect.py | 核心推理脚本,输入图片输出检测框和计数 |
src/gui_main.py | PyQt5桌面程序入口,封装了模型推理和结果展示 |
src/utils/preprocess.py | letterbox预处理与还原工具 |
src/utils/postprocess.py | 置信度过滤、NMS非极大值抑制 |
eval/results.png | 训练评估曲线总图,包含损失曲线和PR曲线 |
eval/confusion_matrix.png | 混淆矩阵,用于观察误检方向 |
README.md | 使用说明和参数说明 |
从文件分布能看出,这个包的核心价值不在训练而在部署:onnx模型已经导出,源码把预处理、推理、后处理、界面四层分开了。对使用者来说,这意味着你可以只替换weights/下的模型文件,不改界面逻辑,就能换成自己的检测场景。
2.3 推理脚本的核心链路:预处理、模型调用、后处理
我打开src/detect.py看了一遍,标准的 YOLOv8 ONNX 推理链路应该是这样的,代码逻辑很清晰:
import cv2 import numpy as np import onnxruntime as ort from utils.preprocess import letterbox from utils.postprocess import non_max_suppression class HeadDetector: def __init__(self, onnx_path, conf_thres=0.35, iou_thres=0.45): self.session = ort.InferenceSession(onnx_path) self.conf_thres = conf_thres self.iou_thres = iou_thres # 从模型元数据里读取输入尺寸,一般就是640x640 self.input_size = self.session.get_inputs()[0].shape[2:] # [640, 640] self.class_names = ["head"] def predict(self, img_bgr): # 原始图像尺寸,后续坐标还原要用 orig_h, orig_w = img_bgr.shape[:2] # letterbox 缩放加灰边,保持长宽比 img_input, ratio, (dw, dh) = letterbox( img_bgr, new_shape=self.input_size ) # 维度变换:HWC -> CHW -> NCHW,并归一化到0~1 blob = img_input[:, :, ::-1].transpose(2, 0, 1) # BGR转RGB blob = np.ascontiguousarray(blob, dtype=np.float32) / 255.0 blob = np.expand_dims(blob, axis=0) # 增加batch维度 # 推理 outputs = self.session.run(None, {self.session.get_inputs()[0].name: blob}) # 后处理:解析输出、NMS、坐标映射回原图 boxes, scores = self._postprocess(outputs, orig_w, orig_h, ratio, dw, dh) return boxes, scores这段代码有几个关键参数需要注意。conf_thres是置信度阈值,人头场景因为遮挡多、小目标多,我一般会调到 0.25 而不是默认的 0.35;iou_thres是 NMS 的 IoU 阈值,如果画面里人头挨得特别近,调低到 0.4 能减少重复框。letterbox是推理前最关键的一步,很多部署翻车都翻在这里:如果直接用cv2.resize拉伸到 640x640,人头比例会失真,检测框偏得离谱。blob[:, :, ::-1]是把 OpenCV 的 BGR 通道转成模型训练时的 RGB 顺序,这个顺序错了模型输出会整体漂移。
后处理部分的逻辑是把模型输出拆解成边界框和类别分数:
def _postprocess(self, outputs, orig_w, orig_h, ratio, dw, dh): # YOLOv8输出形状一般是 [1, num_anchors, 4+1+num_classes] # 这里人头只有1类,最后一个维度是 4+1+1=6 preds = outputs[0][0] # 去掉batch维度 boxes_xywh = preds[:, :4] scores = preds[:, 4:] # 过滤低置信度框 mask = scores.max(axis=1) > self.conf_thres boxes_xywh = boxes_xywh[mask] scores = scores[mask] # 转换成xyxy格式 boxes_xyxy = np.concatenate([ boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2, boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2, boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2, boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2, ], axis=1) # NMS抑制重复框 keep = non_max_suppression(boxes_xyxy, scores.max(axis=1), self.iou_thres) boxes_xyxy = boxes_xyxy[keep] # 坐标还原到原图:去掉letterbox的灰边再除以缩放比例 boxes_xyxy[:, [0, 2]] = (boxes_xyxy[:, [0, 2]] - dw) / ratio boxes_xyxy[:, [1, 3]] = (boxes_xyxy[:, [1, 3]] - dh) / ratio # 越界裁剪 boxes_xyxy[:, [0, 2]] = boxes_xyxy[:, [0, 2]].clip(0, orig_w) boxes_xyxy[:, [1, 3]] = boxes_xyxy[:, [1, 3]].clip(0, orig_h) return boxes_xyxy, scores[keep]坐标还原的公式(坐标 - 灰边尺寸) / 缩放比例是从 letterbox 正向推导出来的,这一步忘了做的话,检测框会整体偏移到左上角。dw和dh是 letterbox 时在宽和高方向上的灰边像素数,ratio是缩放系数,这三个值必须从预处理那边传过来,不能重新算。
3. 把模型部署到ONNX Runtime:从PyTorch权重到推理验证
3.1 环境准备:依赖版本与CPU跑通的注意事项
这份资源包的使用路径是直接拿onnx权重跑,不强制要求有GPU,CPU也能带起来。换在 ubuntu20.04 上搭 python 环境的话,我一般这样装依赖:
# 创建虚拟环境,避免污染系统python python3 -m venv .venv source .venv/bin/activate # 安装onnxruntime和opencv,CPU版本即可 pip install onnxruntime opencv-python numpy # 如果之后要重新导出onnx或继续训练,再装ultralytics pip install ultralytics这里有个容易混淆的概念:onnx 是一种模型文件格式,onnxruntime 是运行这个格式文件的推理引擎,两者不是一回事。很多人下载了.onnx文件不知道怎么运行,其实只需要onnxruntime.InferenceSession(path)就能加载,不依赖 PyTorch。CPU 环境下,onnxruntime默认走 CPU 执行器,640x640 输入跑一帧人头检测大约在 80~150ms,够处理静态图片和低帧率视频流;如果后续要上摄像头实时识别,建议换onnxruntime-gpu,CUDA 版本下能降到 10ms 左右。
3.2 从PyTorch权重导出ONNX:导出脚本与参数边界
很多时候我们拿到的项目包里只有onnx模型,但万一要换自己的数据集重训,或者想调整模型输入尺寸,就绕不开“从ultralytics权重导出onnx”这一步。常见的做法是直接用 ultralytics 自带的 export 接口:
from ultralytics import YOLO # 加载训练好的pt权重 model = YOLO("runs/detect/head_detect/weights/best.pt") # 导出onnx,动态输入尺寸,opset固定为12以上 model.export( format="onnx", imgsz=640, dynamic=True, opset=12, simplify=True, # 计算图简化,减小文件体积 )导出完成后会在同目录生成best.onnx文件,用 onnxruntime 加载时可以通过get_inputs()[0].shape确认输入维度。如果dynamic=True没设置,模型固定输入 640x640;设了之后,输入尺寸可以变成[1, 3, H, W],方便适配不同分辨率的视频流。opset参数我建议至少用 12,太低的话算子转换不全,推理时容易报 not implemented 错误。simplify=True会做一些常量折叠和冗余节点删除,在 RK3588 这类边缘设备上转 rknn 之前最好先 simplify 一遍,能省一部分转换报错的时间。
3.3 推理验证与结果一致性检查
模型导出之后不能直接丢给 GUI 用,必须先做一次单测,确认 onnx 推理结果和 PyTorch 原模型一致。我一般会准备一张测试图,分别跑两遍,对比检测框的重合度:
import torch import numpy as np from ultralytics import YOLO torch_model = YOLO("best.pt") src = cv2.imread("test.jpg") # PyTorch推理 results = torch_model.predict(src, imgsz=640, conf=0.25, verbose=False) boxes_pt = results[0].boxes.xyxy.cpu().numpy() # ONNX推理,复用前面HeadDetector onnx_detector = HeadDetector("best.onnx", conf_thres=0.25) boxes_onnx, scores = onnx_detector.predict(src) # 计算IoU,判断两套推理结果是否吻合 iou = compute_iou(boxes_pt, boxes_onnx) print("平均IoU:", iou.mean())如果平均 IoU 低于 0.9,优先检查三件事:预处理是否统一(BGR/RGB、归一化系数)、动态轴是否导出成功、输入尺寸是否一致。90% 的“onnx 结果和 PyTorch 对不上”都是预处理环节不一致造成的,不是模型转换出了问题。这个单测脚本建议长期保留,每次换模型、换导出参数之后都跑一遍,能省下后面调 GUI 时的排查时间。
4. 把检测能力封装成GUI:桌面计数应用的设计与实现
4.1 GUI选型:PyQt5还是Tkinter
资源包里给的 GUI 是 PyQt5,这是个合理的选型。Tkinter 虽然不用额外装依赖,但做图像展示、按钮布局、状态刷新的体验要差不少;PyQt5 提供 QLabel 显示图像、QThread 做异步推理、信号槽机制做界面刷新,正好覆盖人头计数应用的全部需求。界面核心功能就三个:选择图片/开启摄像头、展示检测框、输出计数结果。我拆包之后把界面逻辑梳理成了下面这个控件表:
| 控件 | 类型 | 职责 |
|---|---|---|
img_label | QLabel | 显示原图和检测框渲染结果 |
btn_open_image | QPushButton | 打开本地图片文件 |
btn_open_camera | QPushButton | 开启USB摄像头实时检测 |
count_label | QLabel | 实时显示画面内检测到的人头数量 |
log_text | QTextBrowser | 打印每次检测的耗时和置信度信息 |
4.2 线程处理:推理不能堵住主界面
我看过很多初学 python 写 GUI 的人,直接在按钮的clicked回调里跑模型推理,结果就是点完按钮界面立刻转圈卡死。PyQt5 的界面刷新在主线程,推理是耗时操作,必须放到子线程里。资源包里的实现思路是标准的 QThread + 信号槽模式:
from PyQt5.QtCore import QThread, pyqtSignal import cv2 class DetectWorker(QThread): # 定义两个信号:检测结果信号和日志信号 result_ready = pyqtSignal(object) log_signal = pyqtSignal(str) def __init__(self, detector, source): super().__init__() self.detector = detector self.source = source # 可以是图片路径,也可以是摄像头编号 self.running = True def run(self): if isinstance(self.source, str): # 图片模式:单张检测 img = cv2.imread(self.source) boxes, scores = self.detector.predict(img) self.result_ready.emit((img, boxes, scores)) self.log_signal.emit(f"检测到 {len(boxes)} 个人头") else: # 摄像头模式:循环读取逐帧检测 cap = cv2.VideoCapture(self.source) fps_interval = 1.0 / 15 # 限制最高15fps,降低CPU压力 while self.running: ret, frame = cap.read() if not ret: break boxes, scores = self.detector.predict(frame) self.result_ready.emit((frame, boxes, scores)) self.msleep(int(fps_interval * 1000)) cap.release()在主界面类里,按钮的回调只负责启动线程,不碰推理逻辑:
class MainWindow(QMainWindow): def start_detect(self): self.worker = DetectWorker(self.detector, self.image_path) # 把子线程的结果信号连接到界面刷新函数 self.worker.result_ready.connect(self.update_frame) self.worker.log_signal.connect(self.log_text.append) self.worker.start()result_ready.connect是 PyQt5 信号槽的关键:子线程里emit出来的信号会被自动投递到主线程执行,主线程只做一件事——把检测框画在图上、更新计数文字。这样无论推理多慢,界面都能保持响应,用户至少能看到“正在处理中”的状态,而不是整个窗口变白。帧率控制上,我一般限制在 15fps,理由是摄像头场景人头不会高速移动,15fps 能满足计数需求且不至于把 CPU 跑满。
4.3 检测框渲染与计数逻辑
拿到检测框之后,渲染部分用 OpenCV 直接画就行,不用转 QImage 之前再缩放:
def draw_boxes(img, boxes, scores): for box, score in zip(boxes, scores): x1, y1, x2, y2 = box.astype(int) # 画红色矩形框,宽度2像素 cv2.rectangle(img, (x1, y1), (x2, y2), (0, 0, 255), 2) # 在框上方绘制置信度 cv2.putText(img, f"{score:.2f}", (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return img计数直接取len(boxes)就行,但要注意:如果 conf 阈值太低,同一个头可能会输出两个重叠框,计数虚高。解决方案不是单纯调 NMS 的 IoU 阈值,而是看一眼评估曲线里的 PR 曲线,确认模型在当前阈值下的精确率-召回率平衡点,再反推 conf 阈值设多少合适。这部分在第 6 章展开讲。
5. 人头检测部署避坑实录:五个高频问题的现象、原因与解决
5.1 ONNX 推理结果和 PyTorch 对不上,检测框整体偏移
现象:同一个测试图,PyTorch 权重检测正常,换成 onnx 之后所有框往左上角偏了一截,置信度也明显下降。
原因:预处理的通道顺序和归一化方式不一致。PyTorch 推理时 ultralytics 内部默认 BGR 输入并自动归一化到 0~1;手写 onnx 推理时如果把img[:, :, ::-1]漏了,或者归一化时忘了除以 255,特征分布就全错了。
解决:把预处理代码单独抽成一个函数,让 PyTorch 推理和 onnx 推理共用同一份预处理逻辑,再把 5.3 节的一致性检查脚本跑一遍,确认平均 IoU 在 0.95 以上再继续。从那之后我每次换模型都强制先走一遍这个对比脚本。
5.2 GUI 点击“开始检测”后窗口卡死
现象:按钮按下去,界面变成白色,标题栏显示“未响应”,过几秒才恢复,摄像头模式尤其严重。
原因:推理代码直接在按钮回调里跑,阻塞了主线程的事件循环,PyQt5 没办法重绘界面。
解决:严格按照 4.2 节的 QThread 模式改造,把所有耗时代码放进detect_worker,主线程只接信号。如果不想用 QThread,也可以把推理函数放到threading.Thread里跑,但信号回主线程还是需要用 Qt 的 signal,否则在子线程里直接改 QLabel 会随机崩溃。
5.3 摄像头模式下视频流掉帧严重,帧率只有个位数
现象:画面像幻灯片,CPU 占用率飙升,风扇狂转。
原因:没有对视频流做帧率限制,同时对 1080p 的摄像头原始画面直接做 letterbox,而不是先缩放再推理。
解决:两个优化点。第一,摄像头读帧后先cv2.resize(frame, (640, 640)),把传给推理的图缩小,而不是让 letterbox 在内部缩放;第二,引入跳帧策略,比如间隔 2 帧做一次检测,中间帧直接复用上一帧的结果。对于人头计数应用,1 秒做 5 次检测已经足够,不需要每帧都跑。
5.4 密集人群场景下人头漏检严重,框数量比实际少很多
现象:Picture 里 20 个人,检测框只有 12 个,主要集中在后排、互相遮挡的人头上。
原因:默认的conf_thres=0.35对遮挡小目标太严格。密集场景下真实人头的置信度可能只有 0.2 左右,阈值一高就全部被过滤掉了。
解决:把conf_thres降到 0.15~0.25,同时检查 NMS 的iou_thres,如果降到 0.4 仍然出现大量重复框,说明模型本身对密集场景区分能力不够,这时优先去看评估指标曲线里的召回率——如果召回率在 0.8 以下,说明训练数据里遮挡样本不够,得补充数据而不是调阈值。调邀请阈值能提升数量但是会引入误检,要用 PR 曲线找阈值。
5.5 导出 ONNX 时报错,提示 Decode 或 Constant 算子不支持
现象:model.export(format="onnx")跑一半报RuntimeError: ONNX export failed: Unsupported operator Decode,或者推理时报NotImplementedError。
原因:opset 版本太低,或者模型里带了一些动态结构调整的算子,老版本算子集不支持。
解决:把opset参数提高,一般 12 以上就够用;如果还不行,看一下模型是不是用了自定义的 NMS 层,ultralytics 8.x 导出的 onnx 默认不带 NMS,NMS 要在后处理代码里自己写。这个资源包的做法就是后处理用 python 实现 NMS,规避了算子兼容性问题。如果后续要转 rknn 部署,建议先simplify=True简化计算图,很多 RKNN 的转换报错都在这一步消掉。
6. 从评估指标曲线看模型能力:PR曲线与自训练闭环
6.1 评估指标曲线图怎么看:不要只盯着mAP数字
资源包的eval/目录里放着训练时生成的评估指标曲线,这些图不是摆设,它们直接决定你部署时该把置信度阈值设成多少。results.png里包含四个子图:box_loss、cls_loss、dfl_loss 三条损失曲线,以及 mAP50、mAP50-95 的精度曲线。看这张图时重点观察两条趋势:损失曲线是否在训练后期仍在下降,如果 loss 已经震荡不降,说明模型已经收敛,没必要再加大训练轮次;mAP50-95 和 mAP50 的差距,差距越大说明模型对框的定位精度越差,密集场景下这种误差会直接叠加到计数偏差上。
PR_curve.png是 Precision-Recall 曲线,横轴是召回率 Recall,纵轴是精确率 Precision。曲线越靠近右上角,模型整体能力越强。部署时最有用的一点是:曲线上每个点都对应一组置信度阈值,比如曲线上的 (0.85, 0.90) 点,含义是置信度阈值调到某个值之后,查全率和查准率能同时达到 85% 和 90%。我调整conf_thres时的依据就是先看这条曲线,找到适合当前场景的平衡点,而不是拍脑袋乱调。
6.2 用自己的数据集继续训练:从labelme标注到YOLO格式闭环
如果资源包自带的模型在你自己的场景里漏检率偏高,常规做法是收集几十张现场截图,用 labelme 标注人头,再转成 YOLO 格式训练。标注生成的 json 文件需要转成 YOLO 的 txt 格式,转换脚本核心逻辑如下:
import json import glob def labelme_to_yolo(json_path, class_names, save_dir): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) img_w = data['imageWidth'] img_h = data['imageHeight'] out_lines = [] for shape in data['shapes']: label = shape['label'] class_id = class_names.index(label) # 标注点是多边形,取外接矩形 pts = np.array(shape['points']) x_min, y_min = pts.min(axis=0) x_max, y_max = pts.max(axis=0) # 转成YOLO格式的归一化中心坐标和宽高 cx = (x_min + x_max) / 2 / img_w cy = (y_min + y_max) / 2 / img_h w = (x_max - x_min) / img_w h = (y_max - y_min) / img_h out_lines.append(f"{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") # 写入同名txt txt_path = json_path.replace('.json', '.txt') with open(txt_path, 'w') as f: f.write('\n'.join(out_lines))转换时注意:labelme 的坐标是绝对像素值,YOLO 格式要求归一化到 0~1,如果忘记除图像宽高,训练时会直接报错。标注脚本只是第一步,数据集准备好之后还需要一个dataset.yaml配置文件,加上train、val路径和类别名,才能启动训练:
yolo detect train model=yolov8n.pt data=dataset.yaml epochs=100 imgsz=640 batch=8训练结束之后回到第 3 章的导出流程,把新的best.pt转成 onnx,替换掉资源包里的weights/head_detect.onnx,GUI 和计数逻辑不用改任何代码。
最后说一个真实教训:我最早用这个资源包的时候,没看评估曲线,直接把 conf 阈值调到 0.1,结果一个空房间里地面反光被识别成人头,计数虚高一倍。后来养成习惯,每次调整阈值之前先打开 PR 曲线图,找出精确率和召回率的平衡点,用数据而不是感觉来定参数。从那以后我每次部署到新场景都强制走一遍“看曲线 → 设阈值 → 跑单测 → 录视频验证”的流程。这套流程花不了二十分钟,但是能把“模型能用”变成“模型好用”,希望帮到你。
本文还有配套的精品资源,点击获取