资讯动态

PyQt5+YOLOv5桌面目标检测GUI实战:从推理线程到打包交付

发布时间:2026/10/29 20:11:41 来源:尧图企业网站定制
简介这份资源面向刚接触 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_loadimport 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, devicecpu, imgsz640, conf0.25, iou0.45): # 只加载一次常驻内存避免每帧重复读盘 self.device torch.device(device) self.model attempt_load(weight_path, deviceself.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, stride32, autoTrue)[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的stride32是 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, source0): 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, devicedevice, confself.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是网络输入尺寸含 paddingimg0_shape是原图尺寸。有人把两者写反框就会按比例乱飞。排查方法拿一张 1280×720 的图画一个已知坐标的框打印scale_boxes前后的值对不上就是参数顺序错了。4.3 现象打包成 exe 后提示找不到models或utilsPyInstaller 默认不收集动态 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加载打包 exebuild.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替换torchdetector.py里把attempt_load换成ort.InferenceSession后处理逻辑原样保留包体能压到 100MB 以内。这个替换我做过两次唯一要注意的是 letterbox 的归一化和通道顺序必须和导出时完全一致否则精度掉得莫名其妙。验证改造是否成功我有个笨但有效的习惯固定一段 10 秒的测试视频改之前先跑一遍记录每帧目标数和平均耗时改完再跑一遍对比。数字对不上就回退别靠肉眼觉得「差不多」。从那以后我每次动detector.py或者换权重都强制走一遍这段基准视频再决定要不要继续往下改。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑