简介:这份资源面向刚接触 PyQt5 界面开发与 YOLOv5 目标检测算法的初学者,提供一套可直接运行的多目标检测 GUI 项目,用于解决从算法源码到可视化界面之间缺少完整练手案例的问题。压缩包共 112 个文件,约 83.46MB,以 26 个 py 源码与 33 个 pyc 编译文件为主体,配合 25 个 yaml 模型与数据配置、3 个 pt 权重、1 个 ui 界面文件,另有 sh 脚本、jpg/png 图片、mp4 演示视频及 Dockerfile 等辅助内容,覆盖界面设计、后端逻辑与模型推理的完整链路。已有 8880 人学习下载。读者可从中掌握 PyQt5 常见控件的使用、界面与业务逻辑分离的组织方式,并对照 YOLOv5 源码理解检测流程;配合 PyTorch 框架与 PyQt5 开发书籍使用,能实现理论到实践的衔接,适合作为入门阶段的综合练习项目。
1. 桌面端目标检测工具:把 PyQt5 和 YOLOv5 拼成一个能交付的 GUI
上周有个做质检的朋友找我,说他们车间想做个离线的小工具,摄像头对着流水线,框出缺陷件就行,不要网页、不要服务器,双击 exe 就能跑。我第一反应就是 PyQt5 加 YOLOv5 这套组合:PyTorch 负责推理,PyQt5 负责界面,Python 把两边粘起来。这类 pyqt5 yolov5 python 的桌面目标检测项目,核心价值不在算法多新,而在于把模型封装成一个非技术同事也敢点的窗口程序。它适合两类人:一类是想把训练好的权重落地成演示工具的算法同学,另一类是拿它当 PyQt 与深度学习结合练手模板的 Python 开发者。下面我按自己拆项目的顺序,从目录结构、推理线程、界面联动一路讲到怎么改模型、怎么打包,把能抄的部分都摊开。
2. 工程结构与技术栈:先看清每个目录在干什么
2.1 技术栈分工与依赖清单
这套项目本质是三层:界面层 PyQt5、推理层 PyTorch + YOLOv5、胶水层 Python 标准库加 OpenCV。界面层只负责控件布局和信号槽,不碰张量;推理层只认 numpy 数组和模型权重,不认按钮;中间靠一个 QThread 把两边隔开。常见做法是把 YOLOv5 的models/、utils/直接拷进工程,而不是 pip 装,因为官方仓库更新快,锁死一份源码比追版本稳。
依赖大致如下,版本不必完全照抄,但 PyTorch 和 torchvision 要配套:
# 基础环境,Python 3.8~3.10 比较稳 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install PyQt5 opencv-python numpy pillow pyyaml # 如果要打包成 exe pip install pyinstaller这里有个血泪经验:opencv-python和opencv-contrib-python不要同时装,否则cv2会互相覆盖,运行时出现莫名其妙的imshow报错。我一般只留opencv-python,因为界面显示走 QLabel,根本用不到 highgui。
2.2 目录结构逐层拆解
一个能跑也能改的目录,我通常整理成这样:
yolo_gui/ ├── main.py # 程序入口,创建 QApplication ├── ui/ │ ├── main_window.py # 主窗口类,控件与信号槽 │ └── main_window.ui # Designer 设计文件(可选) ├── core/ │ ├── detector.py # 模型加载与单帧推理 │ └── worker.py # QThread 推理线程 ├── models/ # YOLOv5 模型定义 ├── utils/ # YOLOv5 通用工具(NMS、绘图等) ├── weights/ │ └── best.pt # 训练好的权重 ├── configs/ │ └── app.yaml # 置信度、IOU、输入尺寸等参数 └── assets/ # 图标、样式表core/detector.py是唯一 import torch 的地方,ui/里绝不出现torch字样。这条纪律很重要,后面你想把推理换成 ONNX Runtime 或者换成本地服务,只改detector.py一个文件,界面一行不用动。configs/app.yaml把conf_thres、iou_thres、imgsz抽出来,调参不用改代码重新打包,这是交付时最省事的设计。
2.3 模型加载与推理封装
detector.py的职责就三件:加载权重、预处理、后处理。YOLOv5 官方DetectMultiBackend已经能兼容 pt/onnx,但为了少踩坑,我一般直接torch.load加attempt_load:
import torch import numpy as np import cv2 from models.experimental import attempt_load from utils.general import non_max_suppression, scale_boxes from utils.augmentations import letterbox class Detector: def __init__(self, weight_path, device="cpu", imgsz=640, conf=0.25, iou=0.45): # 只加载一次,常驻内存,避免每帧重复读盘 self.device = torch.device(device) self.model = attempt_load(weight_path, device=self.device) self.model.eval() self.imgsz = imgsz self.conf = conf self.iou = iou self.names = self.model.names # 类别名映射 def preprocess(self, frame_bgr): # letterbox 保持长宽比,灰边填充到 640x640 img = letterbox(frame_bgr, self.imgsz, stride=32, auto=True)[0] img = img[:, :, ::-1].transpose(2, 0, 1) # BGR->RGB, HWC->CHW img = np.ascontiguousarray(img) tensor = torch.from_numpy(img).to(self.device).float() / 255.0 return tensor.unsqueeze(0) # 增加 batch 维度 @torch.no_grad() def infer(self, frame_bgr): tensor = self.preprocess(frame_bgr) pred = self.model(tensor)[0] # NMS 去重,返回 [x1,y1,x2,y2,conf,cls] det = non_max_suppression(pred, self.conf, self.iou)[0] results = [] if det is not None and len(det): det[:, :4] = scale_boxes(tensor.shape[2:], det[:, :4], frame_bgr.shape).round() for *xyxy, conf, cls in det: results.append({ "box": [int(v) for v in xyxy], "conf": float(conf), "label": self.names[int(cls)] }) return results逐段说下关键点。attempt_load会顺带把模型结构从权重里恢复出来,所以models/目录必须和训练时一致,否则报KeyError。letterbox的stride=32是 YOLOv5 下采样总倍数,改成别的值会导致坐标还原偏移。scale_boxes把 640 坐标系映射回原图尺寸,这一步漏了框就会整体缩小一圈,是新手最常见的翻车点。@torch.no_grad()必须加,否则显存或内存会随帧数缓慢上涨,跑十分钟就卡死。
3. 界面与线程联动:让推理不卡住主窗口
3.1 主窗口控件布局
主窗口我一般只放四块:左边视频显示 QLabel、右边参数面板、底部按钮栏、状态栏。参数面板里放置信度 QSlider、IOU QDoubleSpinBox、模型选择 QComboBox、设备切换 QComboBox。按钮就三个:打开摄像头、打开视频文件、停止。控件不多,但每个都要连信号槽,布局用 QHBoxLayout 套 QVBoxLayout 就够了,不必上 Designer。
from PyQt5.QtWidgets import (QMainWindow, QWidget, QLabel, QPushButton, QSlider, QComboBox, QHBoxLayout, QVBoxLayout) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("目标检测工具") self.video_label = QLabel("等待视频源") self.video_label.setAlignment(Qt.AlignCenter) self.video_label.setMinimumSize(800, 450) self.conf_slider = QSlider(Qt.Horizontal) self.conf_slider.setRange(5, 95) # 0.05 ~ 0.95 self.conf_slider.setValue(25) self.device_box = QComboBox() self.device_box.addItems(["cpu", "cuda:0"]) self.btn_cam = QPushButton("打开摄像头") self.btn_file = QPushButton("打开视频") self.btn_stop = QPushButton("停止") right = QVBoxLayout() right.addWidget(QLabel("置信度阈值")) right.addWidget(self.conf_slider) right.addWidget(QLabel("推理设备")) right.addWidget(self.device_box) right.addStretch() bottom = QHBoxLayout() for b in (self.btn_cam, self.btn_file, self.btn_stop): bottom.addWidget(b) left = QVBoxLayout() left.addWidget(self.video_label) left.addLayout(bottom) root = QHBoxLayout() root.addLayout(left, 4) root.addLayout(right, 1) container = QWidget() container.setLayout(root) self.setCentralWidget(container)setRange(5, 95)而不是 0 到 100,是因为置信度低于 0.05 基本全是噪声框,高于 0.95 又几乎检不出东西,把无效区间砍掉,用户拖动时手感更好。addLayout(left, 4)的 4 和 1 是拉伸比例,视频区占四分之三,参数区占四分之一,窗口缩放时比例不变。
3.2 QThread 推理线程与信号回传
界面卡死的根因永远是推理跑在主线程。正确做法是把Detector放进 QThread 的run()里循环读帧,每处理完一帧发一个信号,主线程收到信号再更新 QLabel。注意 QPixmap 只能在主线程创建,所以线程里传的是 numpy 数组或 QImage。
from PyQt5.QtCore import QThread, pyqtSignal import cv2 class InferWorker(QThread): frame_ready = pyqtSignal(object) # 传 BGR ndarray stats_ready = pyqtSignal(float, int) # 耗时 ms, 目标数 error = pyqtSignal(str) def __init__(self, detector, source=0): super().__init__() self.detector = detector self.source = source self._running = True def run(self): cap = cv2.VideoCapture(self.source) if not cap.isOpened(): self.error.emit(f"无法打开视频源: {self.source}") return while self._running: ok, frame = cap.read() if not ok: break t0 = cv2.getTickCount() results = self.detector.infer(frame) for r in results: x1, y1, x2, y2 = r["box"] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, f'{r["label"]} {r["conf"]:.2f}', (x1, max(y1 - 6, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cost = (cv2.getTickCount() - t0) / cv2.getTickFrequency() * 1000 self.frame_ready.emit(frame) self.stats_ready.emit(cost, len(results)) cap.release() def stop(self): self._running = False self.wait(2000) # 最多等 2 秒,防止退出时线程悬挂pyqtSignal(object)用 object 而不是自定义类型,是因为 numpy 数组跨线程传递时用 object 最省事,Qt 会做浅拷贝引用。stop()里wait(2000)是后悔药:不加的话点关闭窗口时线程还在读帧,进程会残留。cv2.getTickCount算耗时比time.time()精度高,用来在状态栏显示 FPS 更准。
3.3 信号槽绑定与参数热更新
主窗口里把按钮和线程接起来,同时让滑块变化能实时改阈值。这里有个细节:Detector的conf是普通属性,滑块一动直接赋值即可,不用重建模型。
def bind_events(self): self.btn_cam.clicked.connect(lambda: self.start_source(0)) self.btn_file.clicked.connect(self.choose_file) self.btn_stop.clicked.connect(self.stop_infer) self.conf_slider.valueChanged.connect(self.on_conf_changed) def on_conf_changed(self, value): if self.detector: self.detector.conf = value / 100.0 # 滑块整数转 0~1 self.conf_label.setText(f"{value/100:.2f}") def start_source(self, source): self.stop_infer() device = self.device_box.currentText() self.detector = Detector("weights/best.pt", device=device, conf=self.conf_slider.value() / 100.0) self.worker = InferWorker(self.detector, source) self.worker.frame_ready.connect(self.show_frame) self.worker.stats_ready.connect(self.update_stats) self.worker.error.connect(lambda msg: self.statusBar().showMessage(msg)) self.worker.start() def show_frame(self, frame_bgr): h, w, _ = frame_bgr.shape qimg = QImage(frame_bgr.data, w, h, w * 3, QImage.Format_BGR888) self.video_label.setPixmap(QPixmap.fromImage(qimg).scaled( self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))QImage.Format_BGR888是 Qt 5.14 之后才有的格式,老版本要用rgbSwapped()转一道。scaled里加Qt.KeepAspectRatio保证画面不变形,SmoothTransformation让缩放后不出现锯齿。start_source开头先stop_infer(),防止用户连点两次按钮起两个线程抢摄像头,这是实测最容易复现的 bug。
4. 避坑与排查:五个真实踩过的坑
4.1 现象:窗口能开,一点「打开摄像头」就无响应
原因几乎都是推理跑在主线程,或者Detector在 UI 线程里初始化时加载权重耗时过长。解决:把Detector(...)的构造也挪进InferWorker.run()开头,或者至少用QApplication.processEvents()前先显示一个「加载中」提示。我一般选前者,构造和推理都在线程里,主窗口永远秒开。
4.2 现象:检测框位置整体偏移或缩小
原因出在scale_boxes的参数顺序。它要的是(img1_shape, boxes, img0_shape),其中img1_shape是网络输入尺寸(含 padding),img0_shape是原图尺寸。有人把两者写反,框就会按比例乱飞。排查方法:拿一张 1280×720 的图,画一个已知坐标的框,打印scale_boxes前后的值,对不上就是参数顺序错了。
4.3 现象:打包成 exe 后提示找不到models或utils
PyInstaller 默认不收集动态 import 的模块。YOLOv5 里大量from utils.xxx import是运行时才解析的,必须显式加--hidden-import,或者写 spec 文件用collect_submodules。更稳的做法是把models、utils作为数据目录一起打进去,运行时用sys._MEIPASS拼路径。这个坑不解决,本地跑得好好的,一到别人机器上就黑屏。
4.4 现象:切换 cuda 后第一帧特别慢,后面正常
这是 CUDA 上下文初始化和 cuDNN 自动调优的固定开销,不是 bug。解决:在Detector.__init__里用一张全零的 640×640 假图先跑一次infer做 warmup,把这次耗时吃掉。另外torch.backends.cudnn.benchmark = True在输入尺寸固定时能再快一点,但尺寸会变就别开,否则每帧都在重新选算法。
4.5 现象:视频播完线程不退出,关窗口进程残留
InferWorker.run()里cap.read()返回 False 时直接break,但_running还是 True,如果外部没调stop(),线程对象不会被回收。解决:break之后把self._running = False,并在主窗口closeEvent里显式调worker.stop()。我习惯在closeEvent里先stop()再super().closeEvent(event),顺序反了会偶发崩溃。
5. 二次开发:换模型、加功能与打包交付的几条路子
改这套东西,最值钱的是「界面和推理解耦」这条线。想换 YOLOv8,只改detector.py里的加载和后处理,ui/一行不动;想加跟踪,在InferWorker里检测完接一个 ByteTrack,把results的 box 喂进去再画 ID;想加截图保存,主窗口加个按钮,把当前frame_bgr用cv2.imwrite落盘即可。下面这张表是我常改的几个点和对应文件,照着定位能省不少翻代码的时间。
| 改造需求 | 改动文件 | 关键点 |
|---|---|---|
| 换模型权重 | weights/+configs/app.yaml | 类别数变化要同步names |
| 换推理后端 | core/detector.py | 保持infer()返回结构不变 |
| 加报警逻辑 | core/worker.py | 在stats_ready后判断目标数 |
| 改界面风格 | assets/style.qss | 用setStyleSheet加载 |
| 打包 exe | build.spec | 收集models、utils数据目录 |
打包这块单独说一句。pyinstaller --noconsole --add-data "models;models" --add-data "utils;utils" --add-data "weights;weights" main.py能出包,但体积轻松上 800MB,因为 PyTorch 全家桶都进去了。如果目标机器没有 GPU,装 CPU 版 torch 能砍掉一大半;再进一步可以导出 ONNX,用onnxruntime替换torch,detector.py里把attempt_load换成ort.InferenceSession,后处理逻辑原样保留,包体能压到 100MB 以内。这个替换我做过两次,唯一要注意的是 letterbox 的归一化和通道顺序必须和导出时完全一致,否则精度掉得莫名其妙。
验证改造是否成功,我有个笨但有效的习惯:固定一段 10 秒的测试视频,改之前先跑一遍记录每帧目标数和平均耗时,改完再跑一遍对比。数字对不上就回退,别靠肉眼觉得「差不多」。从那以后我每次动detector.py或者换权重,都强制走一遍这段基准视频,再决定要不要继续往下改。希望帮到你。
本文还有配套的精品资源,点击获取