☰
人头计数实战:YOLOv8训练到ONNX部署与PySide6 GUI全流程
2026/10/2 3:05:57 网站建设 项目流程

简介:基于YOLOv8的人头计数检测系统,提供完整Python工程和预训练ONNX模型,内置PyQt5图形界面,适合入门视觉检测的开发者使用,可用于商场、教室等场所的实时人头统计。压缩包内共三十个文件,主要包含Python源码、onnx权重、xml标注信息、jpg和png示例图片,以及说明文档,覆盖模型推理、界面显示和资源配置等模块,大小约十点一六兆字节,便于下载和学习。目前已有六百一十一人学习,模型使用两万零七百九十张图训练、一千九百八十张图测试,平均检测精度达百分之九十六点一,精度为百分之九十七点四,召回率为百分之九十二点二,并附带评估指标曲线文件。读者可直接运行程序启动图形界面进行图片检测,也可参考检测器核心代码和界面脚本,理解YOLOv8与PyQt5的集成方式,适合作为毕业设计或课程设计的参考。

1. 人头计数不是数人头:这个YOLOv8项目从训练到GUI是一条完整链路

“基于yolov8的人头计数检测系统python源码+onnx模型+评估指标曲线+精美GUI界面.zip”这个标题听起来像是一个交付包,但拆开看其实是一条很典型的技术闭环:用YOLOv8训练一个能识别“人的头顶/头肩”的检测模型,把它导成ONNX格式脱离PyTorch跑推理,再用评估指标曲线证明模型没白训,最后用一个GUI把整个流程包装成非工程师也能点的界面。它解决的是商场客流、工地安全区人数、园区出入口人流量统计这类真实场景问题,适合正在做人头检测课题的学生、准备把模型部署到边缘盒子上的工程师,以及想快速验证YOLO系模型产出物的开发者。一个反直觉的结论先说在前面:在这种场景里,检测“人头”比检测“整个人体”更抗遮挡,而项目里的坑也恰恰集中在数据集标注和ONNX后处理这两块。

2. 训练一个认识人头的YOLOv8:数据集、标签与评估指标

人头计数系统最怕的不是模型跑不快,而是模型压根不知道“人头”长什么样。所以这一章的落地目标只有一个:把一份普通图片集变成一份能训练出合格人头检测器的完整数据链路,并知道训练产物里的评估指标曲线到底在说什么。

2.1 为什么偏偏是YOLOv8:anchor-free、C2f和decoupled head

做人头检测,可选模型很多,SSD、Faster R-CNN、旧版YOLOv5都能跑,但YOLOv8在密集小目标场景下有几个结构层面的优势。YOLOv8的骨干网络用C2f模块替换了旧版的C3,把梯度流做了更细的分支融合,对头顶这种纹理少、边缘弱的对象能提取到更多浅层特征。它的检测头是decoupled head,分类和回归分支分离,避免了分类损失和回归损失互相干扰;同时它转成了anchor-free,不需要预设锚框尺寸,人头框宽高比例不固定时反而更稳。

用一句话给项目选型:如果你要部署的目标是CPU或边缘盒子,YOLOv8s是性价比起点;如果标注样本很少,YOLOv8n可以快速迭代出第一版,再决定要不要换大模型。很多新手一上来就选yolov8x,结果训练时间翻了十几倍,精度提升可能不到1个百分点,这在小目标任务里得不偿失。

2.2 用Labelme标注人头并转成YOLO格式

训练YOLOv8需要的数据格式不是Labelme的JSON,而是每张图片对应一个同名TXT文件,里面每行是一类目标的归一化坐标。常见做法是用Labelme标注,然后写脚本转换。标注时只需要画一个覆盖头顶到下巴的外接矩形,不需要精确分割。

import json import os def labelme2yolo(json_path, out_dir, img_w, img_h): with open(json_path, 'r', encoding='utf-8') as f: data = json.load(f) shapes = data.get("shapes", []) lines = [] for shape in shapes: if shape["label"] != "head": continue # Labelme 的 points 是多边形点集,取外接矩形转 YOLO 框 xs = [p[0] for p in shape["points"]] ys = [p[1] for p in shape["points"]] cx = (min(xs) + max(xs)) / 2.0 / img_w cy = (min(ys) + max(ys)) / 2.0 / img_h w = (max(xs) - min(xs)) / img_w h = (max(ys) - min(ys)) / img_h lines.append(f"0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}\n") out_name = os.path.basename(json_path).replace(".json", ".txt") with open(os.path.join(out_dir, out_name), "w") as f: f.writelines(lines)

这段代码的关键点有三个:第一,Labelme的多边形点集转换成了外接矩形,而不是保留不规则框,这符合YOLO目标检测的标注方式;第二,cx、cy、w、h都必须除以图片真实宽高做归一化,否则训练时框会严重偏移;第三,类别标签写死为0,对应后续head数据集的class name。img_w和img_h必须与图片解析后实际尺寸一致,不能拿文件属性里的宽高乱填,否则标注框位置整体错位。

转换完成后,还需要按大概8:1:1划分train、val、test目录,并写一个heads.yaml文件,内容最小如下:

path: ./heads_dataset train: images/train val: images/val test: images/test names: 0: head

2.3 训练参数怎么定:imgsz、batch、epochs与patience

很多人拿到YOLOv8第一件事就是跑默认训练命令,但默认值不一定适合人头检测。我一般用这条:

yolo train data=heads.yaml model=yolov8s.pt \ epochs=150 imgsz=640 batch=16 workers=4 \ patience=20 device=0 project=runs/heads

参数含义如下表:

参数推荐值说明
imgsz640训练输入尺寸;人头多属于小目标,不要盲目降到320,否则密集场景会漏检
batch16由显存决定;显存不够就8,再用workers=4做数据加载缓冲
epochs150小数据集150轮足够,超过200轮容易过拟合
patience20验证集mAP连续20轮不涨就早停,省时间,这是后悔药
device0用GPU训练;CPU版本能跑但很慢,想验证流程可以拿几百张图先把epochs设10跑通

在Ubuntu 20.04上装CPU版本YOLOv8环境很容易,但训练不要指望它,CPU一轮几百张图可能要跑十几分钟。这里还有个容易被忽略的参数是cache=True,它会把图片提前缓存到内存,第一次训练后第二次明显提速,缺点是占用内存,16G内存以下慎用。

2.4 评估指标曲线不是摆设:PR曲线与损失函数曲线怎么看

训练结束后,ultralytics会在runs/heads/train目录下生成一堆png,比如PR_curve.png、confusion_matrix.png、results.png。很多人只看一个mAP就宣布完事,这是不对的。人头场景要看两个东西:一是PR曲线的膝盖位置,如果precision掉得太早,说明模型为了检测全人头牺牲了大量误检;二是loss曲线是否收敛平稳,尤其是box_loss和cls_loss,如果训练最后阶段还在剧烈震荡,大概率是学习率过大或数据里有脏标签。

YOLOv8训练过程中会自动记录results.csv,我习惯用一个小脚本把损失函数曲线单独画出来:

import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("runs/heads/train/results.csv") loss_cols = [c for c in df.columns if "box_loss" in c or "cls_loss" in c] for c in loss_cols: plt.plot(df[c].values, label=c) plt.legend() plt.xlabel("epoch") plt.ylabel("loss") plt.title("Head Detector Loss Curve") plt.savefig("loss_curve_custom.png", dpi=150)

这段脚本做的事是把训练日志里的各类损失列取出来,按epoch画折线图。判断标准不是损失越低越好,而是验证集损失和训练集损失不要出现明显背离。mAP50和mAP50-95的区别也要清楚:mAP50是IoU阈值0.5时的平均精度,mAP50-95是把IoU阈值从0.5到0.95每隔0.05算一次再取平均,后者更严格,头部遮挡严重的场景mAP50-95会比mAP50难看很多,这是常态,不代表模型废了。

3. 从PyTorch权重到ONNX模型:导出、动态轴与Runtime推理

训练完拿到best.pt,这只代表模型在PyTorch环境里能用。项目标题里出现了onnx模型,说明最终交付态是脱离训练框架的推理产物。这章要解决的是:怎么把best.pt变成能扔到ONNX Runtime里跑的best.onnx,以及这个过程中的算子坑在哪。

3.1 pytorch转onnx的最小导出命令

用ultralytics官方API导出即可,不需要手写torch.onnx.export:

from ultralytics import YOLO model = YOLO("runs/heads/train/weights/best.pt") model.export( format="onnx", opset=12, imgsz=640, dynamic=False, simplify=True )

导出逻辑说明:format指定onnx;opset是ONNX算子集版本,opset=12是我的习惯起点,它平衡了兼容性和算子支持度,如果你要转RKNN或NCNN,opset过高反而会引入新算子导致转换报错;dynamic=False表示输入尺寸固定为640x640,速度更快、转换兼容性更好;如果后续要接动态输入,再改成dynamic=True;simplify=True会调用onnx-simplifier把计算图中的冗余reshape和transpose清掉,模型体积更小,推理延迟通常也会降一点。

注意:model.export要求当前Python环境里装了onnx和onnxsim,否则这一步会卡在缺失依赖。导完会在同目录生成best.onnx,同时也可能生成best.torchscript,那个不是本轮要用的产物。

3.2 ONNX输入输出形状必须自己检查一遍

导完模型之后不要直接拿去推理,先做一次结构体检。很多部署上的玄学问题都出在这一步:

import onnx model = onnx.load("best.onnx") onnx.checker.check_model(model, full_check=True) for inp in model.graph.input: shape = [d.dim_value or d.dim_param for d in inp.type.tensor_type.shape.dim] print("input:", inp.name, shape) for out in model.graph.output: shape = [d.dim_value or d.dim_param for d in out.type.tensor_type.shape.dim] print("output:", out.name, shape)

如果dynamic=False,输入打印出来一般是[1, 3, 640, 640],输出是[1, 84, 8400]或[1, 6, 8400]。84这个数字的组成是4个框坐标加上类别数乘以8400个候选框。8400 = 80x80 + 40x40 + 20x20,对应三种特征层。看到输出只有1个节点时,说明这个ONNX模型里没有内置NMS,后处理需要自己在推理侧写。

3.3 .onnx怎么运行:NMS到底在模型里还是在外面

先说概念:onnx是模型格式,onnxruntime是推理引擎,两者不是同一个东西。项目里给你一个.onnx,你至少需要onnxruntime这个库才能真正跑起来。用ONNX Runtime做人头检测的最小流程如下:

import cv2 import numpy as np import onnxruntime as ort sess = ort.InferenceSession("best.onnx", providers=["CPUExecutionProvider"]) input_name = sess.get_inputs()[0].name def letterbox(img, size=640): h, w = img.shape[:2] r = min(size / h, size / w) new_w, new_h = int(round(w * r)), int(round(h * r)) resized = cv2.resize(img, (new_w, new_h)) canvas = np.full((size, size, 3), 114, dtype=np.uint8) canvas[:new_h, :new_w] = resized return canvas, r img = cv2.imread("test.jpg") img_letter, ratio = letterbox(img) x = img_letter[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 out = sess.run(None, {input_name: x})[0] # [1, 84, 8400] out = out[0].T # [8400, 84] scores = out[:, 4:].max(axis=1) mask = scores > 0.25 boxes = out[mask, :4] conf = scores[mask] # boxes 需要从 cx,cy,w,h 转成 xyxy,再除以 ratio 映射回原图

这段代码有两个关键点:第一,输入必须做letterbox,不能直接resize到640x640,否则框坐标会和原图对应不上;第二,输出是[1, 84, 8400],84列里前4列是box中心点加宽高,后面是类别得分,人头检测只有一个类,所以取第4列就是人头置信度。NMS没有包含在模型里,需要在boxes和conf出来后自己加一步cv2.dnn.NMSBoxes或自定义非极大值抑制。

这里要不要在导出时加NMS到模型里?ultralytics的export支持nms=True参数。我一般测试阶段不开启,原因有两个:一是带NMS的ONNX在CPU上会额外占用推理时间;二是转成RKNN或NCNN时,内置NMS的兼容性很差,后面会专门说这个坑。

3.4 CPU与GPU推理延迟,以及int8量化

无缝切换到实际部署时,.onnx怎么运行就成了性能问题。CPU机器装onnxruntime,GPU机器装onnxruntime-gpu,推理时用providers参数声明优先级:

sess = ort.InferenceSession( "best.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"] )

CUDAExecutionProvider是GPU推理,CPUExecutionProvider是兜底。如果只想在CPU上跑,直接写CPUExecutionProvider,不要同时塞CUDA,否则警告日志会刷屏。人头检测通常分辨率不会太夸张,YOLOv8s的ONNX在普通i5 CPU上跑640x640大概需要80到150毫秒,在GTX 1660 Ti这类老显卡上能压到20到40毫秒。如果CPU推理延迟不可接受,第一步不是换模型,而是做int8量化:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "best.onnx", "best_int8.onnx", weight_type=QuantType.QUInt8 )

量化后模型体积缩小约四分之一,CPU推理快1.5到2倍,但精度会掉,尤其对密集人头这种小目标,mAP可能从0.9掉到0.8附近。所以做不做int8量化,取决于现场是测检测框精度还是测CPU占用率。边缘部署如果遇到的是RK3588,常见路线不是直接跑onnx,而是把onnx转rknn,这一步对算子支持有要求,建议导出时opset不要超过12,且不要加dynamic=True。

4. 把GUI界面立起来:PySide6、推理线程与实时人头计数

标题里的“精美GUI界面”是很多人下载这个项目的第一动力。但GUI不是画一个窗口放张图就行,实时视频推理下,如果界面卡顿、丢帧、按钮点了没反应,那不管界面多漂亮,用户都会判死刑。这一章用最常见的Qt方案把推理和界面解耦。

4.1 GUI框架选型:PySide6还是Tkinter

Python GUI选择不多,Tkinter零依赖但做视频刷新很吃力,控件丑不说,往Canvas上贴帧还要自己管理图片生命周期。我做实时视频类工具习惯用PySide6,它是Qt官方Python绑定,信号槽机制天生适合做界面和推理线程通信。不要被“精美”两个字带偏,先把五个基础部件摆对:视频显示区、当前人数标签、置信度阈值滑块、开始/暂停按钮、模型文件名显示。

如果只是给内部测试用,Tkinter也能跑,但一旦要加“选择视频文件”“导出CSV统计”这类控件,PySide6的开发效率和维护性明显更好。

4.2 界面数据流:视频帧进队列,推理结果回界面

不要把检测函数直接写在QWidget的paint事件里,否则视频一播,界面线程就被推理占死。我习惯用queue.Queue做生产者消费者:

import queue from PySide6.QtCore import QThread, Signal class InferWorker(QThread): frame_ready = Signal(object, int) def __init__(self, sess, frame_queue, thresh): super().__init__() self.sess = sess self.frame_queue = frame_queue self.thresh = thresh self.running = True def run(self): while self.running: item = self.frame_queue.get() if item is None: break frame, frame_id = item # 这里调用 ONNX 推理,返回原图画框和当前帧人数 result_frame, count = self.run_detect(frame, self.thresh) self.frame_ready.emit(result_frame, count)

这段代码里的frame_queue是主线程往里丢读到的视频帧,InferWorker线程从队列取帧做推理,再把结果通过信号发回界面。关键点是推理绝不放在主线程,否则滑块拖动都会卡顿。frame_id可以用来做丢帧统计,如果队列积压超过5帧,直接丢最旧帧比强行处理完更合理。

4.3 置信度阈值滑杆与参数热更新

GUI界面上放一个QSlider,范围0.10到0.80,默认0.25。滑杆应该直接控制worker里的thresh,而不需要重启线程。Pyside6里滑杆valueChanged信号连接一个槽函数,把self.thresh赋值给worker即可。但要注意滑杆变化太快时会中途产生很多无效检测,建议做去抖:

def on_thresh_changed(self, value): self.thresh_slider.setValue(value) self.thresh = value / 100.0 self.worker.thresh = self.thresh

逻辑说明:滑块是整数,所以除以100转成浮点置信度。这样用户体验最好,阈值调低一点,模型会把一些半遮挡人头也检出来,计数上浮;调高一点,误检减少但可能漏。不要在小数点后留太多精度,0.05一档足够。

4.4 计数字段要平滑,不然数字跳得没法看

直接统计每帧检测框数量做显示,人数会疯狂跳动,因为晃头、遮挡、模型抖动都会让同一颗头在一帧里出现、下一帧消失。常见做法是做EMA平滑,用上一帧和当前帧的加权平均:

current_count = len(boxes) self.smooth_count = int(self.smooth_count * 0.7 + current_count * 0.3) self.count_label.setText(f"当前人数: {self.smooth_count}")

代码说明:0.7是历史权重,0.3是当前帧权重,权重大小决定跟随速度。如果现场人员流动缓慢,可以加大到0.8;如果检测的是扶梯口这种快速流动性场景,0.5会更跟手。这个参数必须在GUI里做成可调的,不能写死,因为我在不同场景里测过,光靠这一项就能把界面观感从“乱跳”变成“稳定”。

5. 避坑:人头计数项目最容易翻车的五个现场

这章直接写踩坑记录。每一条都是我在类似项目里见过的真实问题,按“现象 -> 原因 -> 解决”写清楚,不绕弯。

5.1 现象:检测框把肩膀也框进去了,计数虚高

俯拍或半俯拍场景里,模型经常在头顶下方多出一块肩膀区域,导致一个真实人头被同时输出两个框,一个在头,一个在肩,计数直接翻倍。 原因:标注数据里很多框把“头肩”整体框进去了,模型学到的是头肩区域而不是头顶。 解决:回头检查训练集标注,把凡是包含肩膀的框全部改成只贴着头顶到下巴的紧框;同时在GUI里把NMS的IoU阈值从默认0.45调低到0.35,重叠框会更容易被合并。如果这两招还不够,就重新裁剪一波训练集做第二轮finetune。

5.2 现象:评估指标曲线很好看,现场一跑就崩

训练时mAP50到了0.92,PR曲线也很饱满,但接到现场摄像头后漏检率突然提高。 原因:训练数据大多是平视或者轻微俯拍的公共人头数据集,现场是45度转角俯拍、背光、白色屋顶反光,数据分布变了。 解决:采集现场摄像头10分钟视频抽帧200张补进训练集重训。这里没有捷径,领域适配是最笨但最有效的一步。我在项目里总结的血泪经验是:不要迷信指标,训练集与部署现场的拍摄角度差异超过15度,就得考虑补数据。

5.3 现象:GPU上导出的ONNX在CPU上跑得极慢

在服务器上导出onnx后,放到客户CPU机器上,一帧推理要600毫秒,界面直接变成幻灯片。 原因:服务器导出的模型可能是FP32,且opset版本高、动态分支多,CPU执行时某些算子没有优化实现。 解决:在目标CPU上用export时指定half=False,不要尝试半精度;然后做动态量化。如果还慢,就用onnxsim再简化一遍。实在不行,把imgsz从640降到480,推理时间通常能降一半,精度损失在密集人群里可以接受。

5.4 现象:GUI运行时内存持续增长,最后被系统杀掉

视频播放一小时后,程序占用内存从500MB涨到2GB。 原因:queue里的帧没有被及时清理,视频帧是numpy数组,每一帧都占不少内存;或者是QImage创建后没有释放。 解决:检查frame_queue大小,插入前判断queue.qsize(),超过5就清空一次。另外,cv2.VideoCapture读取的帧要保留原始尺寸,但画完框后只显示不要积累历史帧。只要对队列做上限控制,这类内存泄漏基本能解决。

5.5 现象:ONNX模型在RK3588/NCNN上转换就报错

用onnx转rknn时,提示遇到不支持的算子或版本过高。 原因:ultralytics导出的ONNX里包含一些新版本算子,转RKNN或NCNN的编译器覆盖不到。 解决:导出时固定opset=12,dynamic=False,simplify=True。不要加nms=True,内置NMS算子在这类工具链里最容易翻车。实在绕不过去,可以在rknn层面尝试版本更高的rknn-toolkit2,但要保持模型简单直接,永远比适配工具链更省事。

6. 最后一章:用30张现场图给计数系统做一次交叉验证

GUI能跑、指标曲线齐全,只代表开发完成,不代表交付完成。我的习惯是交付前做一次“人工计数 vs 系统计数”交叉验证,成本很低但效果立竿见影。

采集30张不同人流密度的现场截图,每张图人工数一次人头数,存成truth.csv,再让系统跑一遍,输出predict.csv。

import csv def validate(truth_csv, predict_csv): errs = [] with open(truth_csv) as f1, open(predict_csv) as f2: truth = list(csv.reader(f1))[1:] pred = list(csv.reader(f2))[1:] for (img_t, t), (img_p, p) in zip(truth, pred): t, p = int(t), int(p) errs.append(abs(t - p) / max(t, 1)) avg_err = sum(errs) / len(errs) print(f"average count error: {avg_err:.2%}")

这段验证脚本输出平均计数误差。我给自己定的及格线是:平均误差小于10%,且不能出现任何一张图误差超过30%。如果平均误差超过15%,问题一定在标注或阈值,不在模型网络结构。

我在上一轮做人头计数交付时,训练指标很好,但没有做现场交叉验证,结果现场是一条带逆光的金属扶梯通道,系统检出的框一半落在窗户玻璃反光上,平均误差到了41%。后来重新只用了300张现场帧补训,误差降到8%以内。自那以后,不管多急的交付,我都会先做这个30图交叉验证,再决定能不能上线。这个习惯已经帮我拦下了至少三次看起来“已经完成”的翻车。希望帮到你。

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

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

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

立即咨询