ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

YOLOv8人头计数系统部署实战:从ONNX推理到GUI与模型调优

YOLOv8人头计数系统部署实战:从ONNX推理到GUI与模型调优 简介基于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.pyPyQt5桌面程序入口封装了模型推理和结果展示src/utils/preprocess.pyletterbox预处理与还原工具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_thres0.35, iou_thres0.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_shapeself.input_size ) # 维度变换HWC - CHW - NCHW并归一化到0~1 blob img_input[:, :, ::-1].transpose(2, 0, 1) # BGR转RGB blob np.ascontiguousarray(blob, dtypenp.float32) / 255.0 blob np.expand_dims(blob, axis0) # 增加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.35iou_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, 41num_classes] # 这里人头只有1类最后一个维度是 4116 preds outputs[0][0] # 去掉batch维度 boxes_xywh preds[:, :4] scores preds[:, 4:] # 过滤低置信度框 mask scores.max(axis1) 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, ], axis1) # NMS抑制重复框 keep non_max_suppression(boxes_xyxy, scores.max(axis1), 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权重跑不强制要求有GPUCPU也能带起来。换在 ubuntu20.04 上搭 python 环境的话我一般这样装依赖# 创建虚拟环境避免污染系统python python3 -m venv .venv source .venv/bin/activate # 安装onnxruntime和opencvCPU版本即可 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-gpuCUDA 版本下能降到 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( formatonnx, imgsz640, dynamicTrue, opset12, simplifyTrue, # 计算图简化减小文件体积 )导出完成后会在同目录生成best.onnx文件用 onnxruntime 加载时可以通过get_inputs()[0].shape确认输入维度。如果dynamicTrue没设置模型固定输入 640x640设了之后输入尺寸可以变成[1, 3, H, W]方便适配不同分辨率的视频流。opset参数我建议至少用 12太低的话算子转换不全推理时容易报 not implemented 错误。simplifyTrue会做一些常量折叠和冗余节点删除在 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, imgsz640, conf0.25, verboseFalse) boxes_pt results[0].boxes.xyxy.cpu().numpy() # ONNX推理复用前面HeadDetector onnx_detector HeadDetector(best.onnx, conf_thres0.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_labelQLabel显示原图和检测框渲染结果btn_open_imageQPushButton打开本地图片文件btn_open_cameraQPushButton开启USB摄像头实时检测count_labelQLabel实时显示画面内检测到的人头数量log_textQTextBrowser打印每次检测的耗时和置信度信息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_thres0.35对遮挡小目标太严格。密集场景下真实人头的置信度可能只有 0.2 左右阈值一高就全部被过滤掉了。解决把conf_thres降到 0.15~0.25同时检查 NMS 的iou_thres如果降到 0.4 仍然出现大量重复框说明模型本身对密集场景区分能力不够这时优先去看评估指标曲线里的召回率——如果召回率在 0.8 以下说明训练数据里遮挡样本不够得补充数据而不是调阈值。调邀请阈值能提升数量但是会引入误检要用 PR 曲线找阈值。5.5 导出 ONNX 时报错提示 Decode 或 Constant 算子不支持现象model.export(formatonnx)跑一半报RuntimeError: ONNX export failed: Unsupported operator Decode或者推理时报NotImplementedError。原因opset 版本太低或者模型里带了一些动态结构调整的算子老版本算子集不支持。解决把opset参数提高一般 12 以上就够用如果还不行看一下模型是不是用了自定义的 NMS 层ultralytics 8.x 导出的 onnx 默认不带 NMSNMS 要在后处理代码里自己写。这个资源包的做法就是后处理用 python 实现 NMS规避了算子兼容性问题。如果后续要转 rknn 部署建议先simplifyTrue简化计算图很多 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, encodingutf-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(axis0) x_max, y_max pts.max(axis0) # 转成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 modelyolov8n.pt datadataset.yaml epochs100 imgsz640 batch8训练结束之后回到第 3 章的导出流程把新的best.pt转成 onnx替换掉资源包里的weights/head_detect.onnxGUI 和计数逻辑不用改任何代码。最后说一个真实教训我最早用这个资源包的时候没看评估曲线直接把 conf 阈值调到 0.1结果一个空房间里地面反光被识别成人头计数虚高一倍。后来养成习惯每次调整阈值之前先打开 PR 曲线图找出精确率和召回率的平衡点用数据而不是感觉来定参数。从那以后我每次部署到新场景都强制走一遍“看曲线 → 设阈值 → 跑单测 → 录视频验证”的流程。这套流程花不了二十分钟但是能把“模型能用”变成“模型好用”希望帮到你。本文还有配套的精品资源点击获取
返回列表