简介:本资源是一个基于YOLOv5的轻量级口罩检测系统完整实现,面向计算机视觉初学者、深度学习实践者及防疫类智能应用开发者,解决图像、视频与摄像头场景下的实时口罩佩戴状态识别问题,适用于课堂实验、课程设计或小型安防监控原型开发。压缩包共150个文件,含44个配置与训练参数yaml文件、40个核心py脚本(涵盖模型加载、GUI逻辑、帧处理与结果可视化)、16个jpg/jpeg测试图及3个pt模型权重文件,辅以Dockerfile、教程ipynb和README文档,整体139.86MB,结构清晰、开箱即用。已有1501人学习下载,提供从环境搭建、模型微调到PyQt5界面封装的全流程代码,包含多尺度检测适配、实时帧率优化逻辑及UI交互细节(如上传/启动/结果显示模块),是深入理解目标检测工程化落地的优质实践样本。
1. 为什么一个口罩检测系统要同时绑定 YOLOv5 和 PyQt5?——不是堆技术,而是解决真实交付断层
你手上有训练好的 YOLOv5s.pt 模型,detect.py跑通了,准确率 92.3%,但当客户(比如学校后勤处、社区服务中心或小型工厂安全部)点开终端输入python detect.py --source test.jpg时,他皱眉问:“这黑框框是啥?能不能像微信一样点一下图片就出结果?”——这就是工业级落地的真实断层:算法能力 ≠ 用户可用性。YOLOv5 提供的是检测能力内核,而 PyQt5 承担的是人机交互外壳:它把cv2.imshow()的临时窗口封装成带按钮、状态栏、进度条、结果高亮框的独立桌面应用;把--source 0的命令行参数转化为“点击【打开摄像头】按钮后自动拉起 USB 摄像头并实时渲染”;把results/下散落的exp/*.jpg输出路径,映射为界面上可一键保存的【导出检测图】按钮。本项目不涉及模型重训或结构修改,核心动作是:用torch.hub.load()加载预训练权重,用cv2.VideoCapture接入视频流,再用 PyQt5 的QGraphicsView+QPixmap实现毫秒级画面更新。适合刚跑通 YOLOv5 demo、但卡在“怎么让非技术人员也用得上”的 Python 开发者,以及需要快速交付可执行.exe文件的毕设/政企轻量项目。
2. 从 YOLOv5 检测逻辑到 PyQt5 界面线程的解耦设计
2.1 为什么不能直接在主线程里调用 model(img)?——PyQt5 的事件循环阻塞陷阱
PyQt5 是单线程 GUI 框架,所有界面刷新(按钮响应、绘图更新)都依赖QApplication.exec_()启动的主事件循环。若在按钮回调中直接执行results = model(img),而 YOLOv5 的前向推理(尤其 CPU 模式下)耗时 80–200ms,界面会卡死:鼠标悬停无反馈、按钮按下去不弹起、进度条冻结。常见错误写法:
def on_detect_clicked(self): img = cv2.imread("test.jpg") results = self.model(img) # ❌ 阻塞主线程! self.show_result(results)正确做法是将检测任务移出主线程,使用QThread+QObject.moveToThread()构建工作线程。关键不是“多线程”,而是分离计算与渲染:工作线程只负责model(img)和结果打包,主线程只负责接收信号并更新 UI 元素。这样即使检测耗时 300ms,界面仍能响应取消按钮、切换标签页等操作。
2.2 构建可复用的 DetectorWorker 类:封装模型加载、预处理与结果解析
我们定义一个继承自QObject的工作类,它不直接继承QThread(这是 PyQt5 官方推荐的现代写法),而是通过moveToThread绑定:
# detector_worker.py import torch import cv2 import numpy as np from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot class DetectorWorker(QObject): # 定义信号:检测完成时发射结果和原始图像 detection_finished = pyqtSignal(dict, np.ndarray) # {boxes, confs, classes}, original_img detection_error = pyqtSignal(str) def __init__(self, weights_path="yolov5s.pt", device="cpu"): super().__init__() self.weights_path = weights_path self.device = device self.model = None self._load_model() def _load_model(self): try: # 使用 torch.hub 加载,兼容 yolov5 v6.0+ 和 v7.0 self.model = torch.hub.load('ultralytics/yolov5', 'custom', path=self.weights_path, force_reload=False) self.model.to(self.device).eval() except Exception as e: raise RuntimeError(f"模型加载失败: {e}") @pyqtSlot(np.ndarray) def run_detection(self, img_bgr): """接收 BGR 格式 numpy 图像,执行检测并发射结果""" try: # YOLOv5 输入要求 RGB,且需保持原始尺寸(不强制 resize) img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) # 推理(注意:此处 batch=1,无需额外封装) results = self.model(img_rgb) # 解析 results.pandas().xyxy[0] 获取 DataFrame,或直接取 .xyxy[0] tensor pred_tensor = results.xyxy[0].cpu().numpy() # shape: (N, 6), [x1,y1,x2,y2,conf,class_id] # 过滤置信度 > 0.5 的口罩检测(class_id=0 对应 mask,需确认你的数据集标签顺序) mask_detections = pred_tensor[pred_tensor[:, 4] > 0.5] # 构建结果字典,便于主线程处理 result_dict = { "boxes": mask_detections[:, :4], # x1,y1,x2,y2 "confs": mask_detections[:, 4], # confidence "classes": mask_detections[:, 5].astype(int) # class id } self.detection_finished.emit(result_dict, img_bgr) except Exception as e: self.detection_error.emit(f"检测异常: {str(e)}")提示:
@pyqtSlot(np.ndarray)装饰器声明该方法可被 Qt 信号安全调用;results.xyxy[0]是 YOLOv5 默认输出格式,返回归一化坐标(若模型导出为 ONNX 或 TorchScript,需调整解析逻辑);class_id顺序取决于训练时data.yaml中names:字段顺序,务必验证口罩是否为索引 0。
2.3 主界面中启动工作线程:信号-槽连接与资源管理
在主窗口类(如MaskDetectionApp)中,创建线程与 worker 实例,并建立信号连接:
# main_window.py from PyQt5.QtCore import QThread from detector_worker import DetectorWorker class MaskDetectionApp(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("YOLOv5 口罩检测系统") self.init_ui() # 创建工作线程与 worker self.detector_thread = QThread() self.detector_worker = DetectorWorker(weights_path="weights/best.pt", device="cpu") self.detector_worker.moveToThread(self.detector_thread) # 连接信号 self.detector_thread.started.connect(self.detector_worker.run_detection) self.detector_worker.detection_finished.connect(self.on_detection_finished) self.detector_worker.detection_error.connect(self.on_detection_error) # 启动线程(注意:此时未触发检测,只是准备就绪) self.detector_thread.start() def on_detect_image(self): """点击【图片检测】按钮触发""" file_path, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Image Files (*.jpg *.jpeg *.png)") if not file_path: return try: img_bgr = cv2.imread(file_path) if img_bgr is None: raise ValueError("图片读取失败") # 发送图像到工作线程(注意:必须是 numpy.ndarray,不能是路径字符串) self.detector_worker.run_detection(img_bgr) # ✅ 此处触发线程内 run_detection except Exception as e: self.statusBar().showMessage(f"加载失败: {e}") def on_detection_finished(self, result_dict, original_img): """工作线程检测完成,主线程更新 UI""" # 在 original_img 上绘制检测框(BGR 格式) for i, box in enumerate(result_dict["boxes"]): x1, y1, x2, y2 = map(int, box) cv2.rectangle(original_img, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f"Mask {result_dict['confs'][i]:.2f}" cv2.putText(original_img, label, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) # 转为 QPixmap 显示在 QLabel 上 h, w, ch = original_img.shape bytes_per_line = ch * w q_img = QImage(original_img.data, w, h, bytes_per_line, QImage.Format_RGB888).rgbSwapped() self.result_label.setPixmap(QPixmap.fromImage(q_img).scaled( self.result_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) self.statusBar().showMessage(f"检测完成:{len(result_dict['boxes'])} 个口罩") def closeEvent(self, event): """退出时清理线程资源""" self.detector_thread.quit() self.detector_thread.wait() event.accept()注意:
self.detector_worker.run_detection(img_bgr)是在主线程中调用,但它内部通过@pyqtSlot机制被 Qt 自动路由到detector_thread中执行,无需手动thread.start();closeEvent中必须显式quit()+wait(),否则工作线程可能残留导致程序无法退出。
3. 实现三类检测模式:图片、视频文件、摄像头实时流的统一架构
3.1 图片检测:最小闭环验证,聚焦结果可视化与导出
图片检测是最基础模块,用于验证模型与界面集成是否正确。核心在于:一次加载 → 一次推理 → 一次绘制 → 一次显示。上述on_detect_image已实现此流程。但需补充两个实用功能:
- 结果导出:用户常需保存带框图用于汇报。添加按钮绑定:
def on_export_result(self): if not hasattr(self, 'last_result_img') or self.last_result_img is None: return save_path, _ = QFileDialog.getSaveFileName(self, "保存检测结果", "detected.jpg", "JPEG (*.jpg)") if save_path: cv2.imwrite(save_path, self.last_result_img) # 注意:self.last_result_img 应在 on_detection_finished 中缓存 self.statusBar().showMessage(f"已保存至 {save_path}")- 批量图片检测支持(可选增强):若需处理文件夹,用
QDirIterator遍历,但需改用队列机制避免阻塞:
from queue import Queue # 在 DetectorWorker 中增加 batch_run 方法,逐张处理并 emit 多次信号3.2 视频文件检测:帧级控制与进度反馈的关键参数
视频检测本质是连续图片检测,但需解决三个问题:
① 如何控制播放/暂停/停止;
② 如何显示当前帧位置与总帧数;
③ 如何避免因检测慢导致帧堆积(即“越检测越卡”)。
解决方案:使用cv2.VideoCapture的CAP_PROP_POS_FRAMES和CAP_PROP_FRAME_COUNT属性,并引入帧跳过逻辑:
def on_detect_video(self): file_path, _ = QFileDialog.getOpenFileName(self, "选择视频", "", "Video Files (*.mp4 *.avi *.mov)") if not file_path: return cap = cv2.VideoCapture(file_path) total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) fps = cap.get(cv2.CAP_PROP_FPS) or 25.0 # 启动定时器,每 40ms(约25fps)读一帧 self.video_timer = QTimer() self.video_timer.timeout.connect(lambda: self.process_video_frame(cap, total_frames)) self.video_timer.start(40) # 与视频原始 fps 解耦,保证界面流畅 def process_video_frame(self, cap, total_frames): ret, frame = cap.read() if not ret: self.video_timer.stop() cap.release() self.statusBar().showMessage("视频播放结束") return current_frame = int(cap.get(cv2.CAP_PROP_POS_FRAMES)) self.progress_bar.setValue(int(current_frame / total_frames * 100)) # 关键:仅对关键帧检测(如每3帧检测1次),避免计算过载 if current_frame % 3 == 0: self.detector_worker.run_detection(frame.copy()) # copy 防止多线程冲突参数说明:
current_frame % 3是平衡速度与精度的常用策略;frame.copy()避免 OpenCV 内部缓冲区被多线程覆盖;QTimer的 40ms 间隔确保 UI 响应,而非强求与视频帧率一致。
3.3 摄像头实时检测:设备枚举、自动适配与低延迟渲染
摄像头检测是体验最敏感的模块。用户插入 USB 摄像头后,期望“点开即用”,而非手动填设备 ID。需实现:
- 自动枚举可用摄像头:遍历
0,1,2,...直到cv2.VideoCapture(i)返回有效对象; - 动态分辨率适配:不同摄像头支持不同分辨率,优先尝试
1280x720,失败则降级; - 双缓冲防闪烁:使用
QGraphicsScene替代QLabel.setPixmap(),避免频繁QPixmap创建导致卡顿。
def init_camera(self, cam_id=0): self.cap = cv2.VideoCapture(cam_id) if not self.cap.isOpened(): self.statusBar().showMessage(f"摄像头 {cam_id} 打开失败") return False # 尝试设置分辨率(部分摄像头支持) self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) actual_w = self.cap.get(cv2.CAP_PROP_FRAME_WIDTH) actual_h = self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT) self.statusBar().showMessage(f"摄像头已启用,分辨率 {int(actual_w)}x{int(actual_h)}") # 初始化 QGraphicsView 场景 self.scene = QGraphicsScene() self.graphics_view.setScene(self.scene) # 启动摄像头定时器(比视频更短间隔,如 33ms ≈ 30fps) self.camera_timer = QTimer() self.camera_timer.timeout.connect(self.grab_camera_frame) self.camera_timer.start(33) return True def grab_camera_frame(self): ret, frame = self.cap.read() if not ret: return # 缩放至界面合适大小(如 640x480),减少 GPU 渲染压力 display_frame = cv2.resize(frame, (640, 480)) # 转为 QImage 并显示(此处用 QGraphicsPixmapItem 实现双缓冲) h, w, ch = display_frame.shape bytes_per_line = ch * w q_img = QImage(display_frame.data, w, h, bytes_per_line, QImage.Format_RGB888).rgbSwapped() pixmap = QPixmap.fromImage(q_img) # 清空旧项,添加新项(避免内存泄漏) self.scene.clear() self.scene.addPixmap(pixmap)提示:
cv2.resize()在 CPU 端完成,比在 GPU 上缩放更稳定;QGraphicsScene.clear()是防止addPixmap不断叠加导致内存暴涨的关键。
4. 模型与界面协同优化:YOLOv5 参数调优与 PyQt5 性能加固
4.1 YOLOv5 推理阶段必调的 3 个参数:影响速度与精度的杠杆
YOLOv5 的model()调用默认参数并非最优。在DetectorWorker._load_model()后,应显式配置:
| 参数 | 推荐值 | 作用 | 适用场景 |
|---|---|---|---|
half=True | True(仅 CUDA) | 启用 FP16 推理,速度提升 1.5–2x,显存减半 | GPU 设备,模型已转为 half |
dnn=False | False(默认) | 禁用 OpenCV DNN 后端,用原生 PyTorch | 避免 DNN 后端兼容性问题 |
agnostic_nms=True | True | 类别无关 NMS,合并重叠框(口罩/未戴口罩视为同类) | 单一类检测(仅口罩) |
# 在 DetectorWorker._load_model() 中修改 self.model = torch.hub.load('ultralytics/yolov5', 'custom', path=self.weights_path, force_reload=False) self.model.to(self.device) if self.device != 'cpu' and hasattr(torch.cuda, 'is_available') and torch.cuda.is_available(): self.model.half() # ✅ 启用半精度 self.model.eval()注意:
model.half()必须在model.to(device)之后调用;CPU 模式下half()无效,会报错,故加device != 'cpu'判断。
4.2 PyQt5 界面卡顿的 4 个根因与对应修复代码
| 症状 | 根因 | 修复方案 | 代码示例 |
|---|---|---|---|
| 拖拽窗口卡顿 | QGraphicsView默认启用 OpenGL | 强制禁用setViewportUpdateMode(QGraphicsView.FullViewportUpdate) | self.graphics_view.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) |
| 多次点击按钮重复触发 | 未禁用按钮 | 检测开始时button.setEnabled(False),结束时恢复 | self.detect_btn.setEnabled(False)→self.detect_btn.setEnabled(True) |
| 内存持续增长 | QGraphicsScene未 clear | 每次addPixmap前scene.clear() | self.scene.clear(); self.scene.addPixmap(pixmap) |
| 高 DPI 屏幕模糊 | 未启用高 DPI 支持 | 在if __name__ == '__main__':中添加QApplication.setAttribute(Qt.AA_EnableHighDpiScaling) | app = QApplication(sys.argv); app.setAttribute(Qt.AA_EnableHighDpiScaling) |
4.3 将系统打包为独立可执行文件:PyInstaller 最小可行配置
最终交付物需为.exe(Windows)或.app(macOS),避免用户安装 Python 环境。使用 PyInstaller 时,关键参数如下:
# Windows 打包命令(假设主脚本为 main.py) pyinstaller --onefile --windowed \ --add-data "weights;weights" \ # 包含模型文件夹 --add-data "images;images" \ # 包含示例图片 --hidden-import "torch" \ --hidden-import "cv2" \ --hidden-import "PIL" \ --name "MaskDetector" \ main.py--onefile:生成单个 exe 文件;--windowed:隐藏控制台窗口(PyQt5 应用必需);--add-data:将weights/文件夹复制到打包后目录,代码中用sys._MEIPASS定位路径:
import sys import os def resource_path(relative_path): """获取资源文件的绝对路径(兼容 PyInstaller 打包)""" try: base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(".") return os.path.join(base_path, relative_path) # 使用 model_path = resource_path("weights/best.pt")验证要点:打包后先在无 Python 环境的干净机器上测试,重点检查
cv2.VideoCapture(0)是否能正常打开摄像头、模型文件是否被正确解压到临时目录。
5. 实时检测中的帧率稳定性技巧:基于时间戳的动态帧采样策略
5.1 为什么固定QTimer间隔会导致实际 FPS 波动?
QTimer的timeout信号是“尽力而为”,当检测耗时超过定时器间隔(如设定 33ms,但某帧检测耗时 60ms),下一帧会立即触发,导致帧率飙升但内容重复;若连续多帧超时,则出现卡顿。根本矛盾在于:采集频率(硬件)≠ 处理频率(算法)≠ 显示频率(UI)。
5.2 实施基于系统时间戳的自适应采样:保证视觉流畅性的硬核方案
核心思想:不依赖QTimer计时,而是每次成功处理完一帧后,主动计算下一帧应采集的时间点,并用QTimer.singleShot()延迟触发。这样无论检测快慢,显示节奏始终稳定。
import time class MaskDetectionApp(QMainWindow): def __init__(self): # ... 其他初始化 self.last_capture_time = 0 self.target_interval = 1.0 / 25.0 # 目标 25 FPS,单位:秒 def start_camera_adaptive(self): """启动自适应摄像头采集""" self.camera_active = True self._schedule_next_capture() def _schedule_next_capture(self): if not self.camera_active: return now = time.time() next_time = self.last_capture_time + self.target_interval if now < next_time: # 距离下一帧还有时间,延迟触发 delay_ms = int((next_time - now) * 1000) QTimer.singleShot(delay_ms, self._capture_and_process) else: # 已超时,立即处理下一帧(不累积延迟) self._capture_and_process() def _capture_and_process(self): ret, frame = self.cap.read() if ret: # 执行检测(异步,不阻塞) self.detector_worker.run_detection(frame.copy()) self.last_capture_time = time.time() # 递归调度下一帧 self._schedule_next_capture()效果:即使某帧检测耗时 100ms,后续帧仍严格按 40ms 间隔显示,用户感知为“轻微卡顿”而非“疯狂跳帧”;
QTimer.singleShot比time.sleep()更符合 Qt 事件循环,不会阻塞 UI。
5.3 验证帧率稳定性的简易方法:在状态栏实时显示 FPS
在on_detection_finished中加入计时统计:
def __init__(self): # ... 其他 self.fps_counter = [] self.fps_window = 30 # 统计最近30帧 def on_detection_finished(self, result_dict, original_img): # ... 绘制逻辑 self.fps_counter.append(time.time()) if len(self.fps_counter) > self.fps_window: self.fps_counter.pop(0) if len(self.fps_counter) >= 2: fps = len(self.fps_counter) / (self.fps_counter[-1] - self.fps_counter[0]) self.statusBar().showMessage(f"检测完成:{len(result_dict['boxes'])} 个口罩 | 实时FPS: {fps:.1f}")此方案不依赖硬件 VSync,纯软件实现,且对 CPU/GPU 负载变化鲁棒。当你看到状态栏FPS: 24.8稳定波动在 ±0.5 范围内,说明实时检测模块已达到生产可用水平。
本文还有配套的精品资源,点击获取