
简介这份项目面向刚接触PyQt5图形界面开发与YOLO目标检测算法的初学者将YOLOv5模型封装到带界面交互的多目标检测工具中解决从算法到桌面应用落地缺少完整示例的问题。整体采用PyTorch框架在代码组织上刻意把界面设计与后端检测逻辑拆开便于读者学习PyQt5常用控件、布局方式、信号槽机制同时对照YOLOv5源码理解目标检测原理、模型配置与推理流程。压缩包共112个文件大小约83.46MB其中26个Python脚本负责界面与业务逻辑25个YAML文件描述网络结构和训练参数3个pt权重文件可直接加载使用另有UI界面文件、Shell脚本、测试图片和视频、Dockerfile等适合本地复现与二次开发。目前已有8877人学习下载对希望以实际项目练手并兼顾界面设计与深度学习代码阅读的初学者有较强参考价值资源包含多种类型文件覆盖从环境配置、代码阅读到运行调试的不同环节查找便捷。通过运行该项目能直观看到从加载模型、处理图像到界面展示检测结果的完整流程并借鉴工程化部署经验为独立开发同类桌面应用打下基础。1. 用 PyQt5 把 YOLOv5 检测器包成桌面工具先搞清这条技术链的重量级把 YOLOv5 的检测结果实时叠到 PyQt5 窗口里听起来像是把两个成熟轮子拼在一起真上手你会发现最难的既不是训练模型也不是画界面而是坐标系换算、线程同步和输出解析这三件小事。手里有一份训好的 YOLOv5 权重想直接做成桌面端检测工具PyQt5 YOLOv5 Python 几乎是绕不开的标准组合PyTorch 负责加载权重和推理YOLOv5 负责检测PyQt5 只承担一层窗口壳子。这个壳子看起来简单但所有让界面白屏、未响应、画框偏移的翻车现场都发生在壳子的细节里。这套方案适合两类人。一类是已经用命令行跑通 detect.py、想把模型交付给不会敲命令的同事的工程师另一类是刚接触 GUI 开发、想拿一个完整项目练手的 Python 开发者。整个过程不需要碰模型训练在预训练权重上做推理就能撑起一个能用的工具。下面从环境搭建开始把这条链路上的每一步和每个坑都过一遍。2. 环境搭建与工程目录规划PyTorch 版本、YOLOv5 分支与项目资源布局2.1 先装 PyTorchCUDA 匹配逻辑与一句验证命令很多项目卡死的第一步不是代码而是 PyTorch 和显卡驱动对不上。我习惯先打开终端执行nvidia-smi看右上角 Driver 版本和 CUDA Version。注意这个 CUDA Version 是驱动支持的最高版本不是已经装好的 CUDA 工具包PyTorch 的 CUDA 运行时是自带的只要驱动版本不低于 PyTorch 要求的底限就能跑。# 创建虚拟环境Python 版本选 3.8 到 3.10 之间 conda create -n yolo-gui python3.9 -y conda activate yolo-gui # 安装 PyTorch先确认驱动支持 CUDA 11.8 以上 # 不带 -c pytorch 也可以用官方 index-url 更直接 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118装完立刻验证不要急着往下走python -c import torch; print(torch.__version__, torch.cuda.is_available())这一步输出True说明环境没问题。如果输出False最常见的两个原因一是 pip 装成了 CPU 版检查安装命令里cu118有没有生效二是显卡驱动版本太老连 CUDA 11.8 都撑不起来这种只能升级驱动。环境这块没有太多玄学版本对上了就是一遍过。2.2 准备 YOLOv5 源码与 yolov5s.pt 权重YOLOv5 本身不需要训练直接拿官方源码和预训练权重做推理。下载源码后先看requirements.txt里面列了 opencv-python、pandas、seaborn 这些依赖。注意其中 seaborn 和 matplotlib 其实只在绘图工具里用推理主流程并不依赖它们但为了避免踩到缺失依赖的坑我一般直接全量安装。git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txt权重放到weights/目录下官方 Release 里下载yolov5s.pt即可。下载完先用官方脚本验证环境是否真的通了python detect.py --weights weights/yolov5s.pt --source data/images/bus.jpg如果能看到输出图片和检测框说明 PyTorch、YOLOv5、opencv 这条链路已经完整。很多人跳过这一步直接写 GUI最后跑起来发现是环境问题排查起来反而更难。2.3 工程目录按「GUI / 推理 / 入口」三块拆不要把所有代码堆在一个文件里这不是洁癖是给后面排查问题留后路。我常用的目录结构是这样detect_tool/ ├── app/ # GUI 相关代码 │ ├── __init__.py │ ├── main_window.py # 主窗口布局和控件 │ └── worker.py # QThread 工作线程 ├── core/ # 推理封装不依赖 PyQt5 │ ├── __init__.py │ └── detector.py # YOLOv5 推理类 ├── weights/ │ └── yolov5s.pt ├── assets/ # 图标、QSS 样式文件 ├── main.py # 程序入口 └── requirements.txt把推理封装在core/里、不 import 任何 PyQt5 模块是这套结构的关键。这样做的直接好处是以后换模型、换推理框架只需要改core/GUI 层完全不动反过来如果界面要改推理层也不受影响。我见过把torch直接 import 到main_window.py里的写法改一个按钮布局都要冒着碰坏模型加载的风险不值得。3. 推理层封装从载入权重到坐标解析的完整调用链3.1 先读懂 YOLOv5 返回结果的坐标结构推理封装之前必须先把 YOLOv5 的输出搞清楚否则后面画框全是歪的。YOLOv5 的 detect.py 内部做了这样一串处理输入图像先 letterbox 缩放成固定尺寸比如 640x640然后进网络推理输出经过 NMS 非极大值抑制最后把检测框坐标映射回原图尺寸。这里有个新手最容易翻车的点网络输出默认是cx, cy, w, h格式也就是中心点坐标加宽高而且是在 640x640 的 letterbox 图上计算的。映射回原图要先把中心点格式转成左上角和右下角格式也就是xywh转xyxy然后减去 padding 偏移再除以缩放比例# 假设 letterbox 缩放比例是 ratio左右或上下补边是 pad # 网络输出 cx, cy, w, h 先转成右下角表示 x1 (cx - w / 2 - pad_x) / ratio y1 (cy - h / 2 - pad_y) / ratio x2 (cx w / 2 - pad_x) / ratio y2 (cy h / 2 - pad_y) / ratio用官方detect.py做推理时这些换算已经封装好了但如果自己写推理类这些坐标转换就是必须补的功课。第一种做法是用 YOLOv5 自带的results.pandas().xyxy[0]接口它返回的已经是原图坐标系下的xmin, ymin, xmax, ymax省掉手写换算第二种做法是用results.xyxy[0]拿到原始 tensor自己处理。对桌面工具来说直接用 pandas 接口最简单少写代码就是少出 bug。3.2 用 torch.hub 加载本地权重推理类的最小实现推理类的核心是加载权重和对单帧图像做检测。我用torch.hub.load的custom方式加载本地权重这样不依赖 YOLOv5 源码目录换目录也能跑。# core/detector.py import torch class YoloDetector: def __init__(self, weights_path: str, device: str , conf_thres: float 0.25, iou_thres: float 0.45): # device 为空时自动选择 cuda 或 cpu self.device device if device else (cuda if torch.cuda.is_available() else cpu) # 从官方仓库加载模型结构权重用本地自定义的 .pt 文件 self.model torch.hub.load(ultralytics/yolov5, custom, pathweights_path, force_reloadFalse) self.model.to(self.device).eval() self.conf_thres conf_thres self.iou_thres iou_thres self.names self.model.names # 类别编号到名字的映射如 {0: person} def detect(self, frame_bgr): # 传入 BGR 格式的 numpy 数组内部自动处理 letterbox 和 NMS results self.model(frame_bgr, size640, conf_thresself.conf_thres, iou_thresself.iou_thres) df results.pandas().xyxy[0] # 已经是原图坐标 detections [] for _, row in df.iterrows(): detections.append({ box: [int(row[xmin]), int(row[ymin]), int(row[xmax]), int(row[ymax])], conf: float(row[confidence]), cls: int(row[class]), name: row[name], }) return detections这段代码的逻辑很直白detect()接收 OpenCV 读取的 BGR 帧返回一个列表列表里每个元素是一个字典包含检测框坐标、置信度、类别 ID 和类别名。用字典而不是元组是为了后面在 GUI 侧渲染时字段清晰可读。参数上conf_thres建议用默认 0.25太低会框出一堆误检iou_thres用 0.45控制重叠框合并的严格程度。size640是推理分辨率检测精度和速度的平衡点如果目标偏小可调到 800但帧率会明显下降。force_reloadFalse表示已经有缓存的模型就不重复下载改成True会在每次启动时强制拉取最新代码反而不稳定。3.3 预热与耗时统计别让第一次推理拖垮体验模型加载完之后第一次推理往往特别慢因为 GPU 要初始化上下文、CUDA kernel 要完成编译。直接上视频流的话第一帧会卡出天际。我一般在初始化逻辑里加一个预热步骤def detect(self, frame_bgr): # ... 上面的检测逻辑不变 ... return detections def warmup(self): # 用一张全黑图跑一次推理触发 CUDA 上下文初始化 import numpy as np dummy np.zeros((640, 640, 3), dtypenp.uint8) self.detect(dummy) print(f[info] model warmup done, device: {self.device})预热后再统计单帧推理耗时才有参考意义。我在实际项目里会简单算一下preprocess inference postprocess的总耗时打印到日志里。比如检测一帧耗时 35ms 到 60ms对应 16 到 25 帧左右的实时性这个数值对 GUI 显示和视频流畅度判断很有用。4. PyQt5 界面实现主窗口布局、QLabel 画框与三种输入源切换4.1 主窗口布局控制面板和显示区域的比例分配界面层的核心思路是左控制、右显示。左侧放一个固定宽度的控制面板右侧放一个可拉伸的显示区域窗口拉大时画面跟着变大控制面板保持稳定。# app/main_window.py from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QSlider, QLabel, QGroupBox, QFileDialog, QComboBox) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self, detector): super().__init__() self.detector detector self.setWindowTitle(YOLOv5 桌面检测工具) self.resize(1280, 720) central QWidget() self.setCentralWidget(central) root_layout QHBoxLayout(central) # 左侧控制面板 control_panel QGroupBox(检测控制) self.conf_slider QSlider(Qt.Horizontal) # 置信度阈值滑块 self.conf_slider.setRange(5, 95) self.conf_slider.setValue(25) self.source_combo QComboBox() # 输入源选择 self.source_combo.addItems([图片, 视频, 摄像头]) self.btn_browse QPushButton(选择文件) self.btn_start QPushButton(开始检测) self.btn_stop QPushButton(停止) panel_layout QVBoxLayout(control_panel) panel_layout.addWidget(self.source_combo) panel_layout.addWidget(self.btn_browse) panel_layout.addWidget(QLabel(置信度阈值)) panel_layout.addWidget(self.conf_slider) panel_layout.addWidget(self.btn_start) panel_layout.addWidget(self.btn_stop) # 右侧显示区域 self.video_label QLabel(等待输入) self.video_label.setAlignment(Qt.AlignCenter) self.video_label.setStyleSheet(background-color: #202020; color: #cccccc; border: 1px solid #444444;) root_layout.addWidget(control_panel) root_layout.addWidget(self.video_label, 1) # stretch 设为 1让显示区占满剩余空间布局部分用QHBoxLayout水平排布左侧面板和右侧标签之间靠addWidget的第二个参数控制拉伸比例。setStretchFactor也能实现同样效果但直接在addWidget里传权重更简洁。关键点是右侧QLabel要设1这个 stretch否则窗口拉伸时显示区域不会跟着变大。4.2 用 QPainter 在 QLabel 上画检测框在 PyQt5 里实时画检测框最直接的做法是把检测结果画到 QPixmap 上再交给 QLabel 显示。不要用子控件画框检测目标一多动态创建几十上百个 QLabel 会让界面卡死。from PyQt5.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QImage from PyQt5.QtCore import Qt import cv2 def update_frame(self, frame_bgr, detections): # BGR 转 RGB 并构造 QImage注意 stride 要按 3 * width 计算 h, w, _ frame_bgr.shape rgb cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) qimg QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888).copy() pixmap QPixmap.fromImage(qimg) painter QPainter(pixmap) painter.setFont(QFont(Arial, 20, QFont.Bold)) for det in detections: x1, y1, x2, y2 det[box] conf det[conf] name det[name] # 画矩形框 painter.setPen(QPen(QColor(0, 255, 0), 3)) painter.drawRect(x1, y1, x2 - x1, y2 - y1) # 画类别和置信度文本 label_text f{name} {conf:.2f} painter.drawText(x1, y1 - 8, label_text) painter.end() self.video_label.setPixmap(pixmap)这段代码有两个容易忽略的点。一是QImage的步长参数3 * w如果写错会出现图像偏色或者撕裂二是drawText的坐标文本要画在框上方所以用y1 - 8上移如果框贴近图片顶部文本会被截掉可以加个判断让其下移。.copy()是必须的否则rgb.data被垃圾回收后 QImage 会拿到无效内存。4.3 图片 / 视频 / 摄像头三种输入源的切换逻辑输入源选择用QComboBox控制三种模式的处理方式不一样def select_source(self): # 根据下拉框选择分别弹出文件对话框或启动摄像头 if self.source_combo.currentText() 图片: path, _ QFileDialog.getOpenFileName(self, 选择图片, , Images (*.jpg *.png)) if path: self.process_image(path) elif self.source_combo.currentText() 视频: path, _ QFileDialog.getOpenFileName(self, 选择视频, , Videos (*.mp4 *.avi)) if path: self.start_video_thread(path, is_cameraFalse) else: self.start_video_thread(0, is_cameraTrue) # 0 代表默认摄像头 def process_image(self, path): frame cv2.imread(path) detections self.detector.detect(frame) self.update_frame(frame, detections)图片是单帧处理直接imread加推理就行。视频和摄像头要循环读取帧并不断刷新界面这里就进入线程改造的范畴了。摄像头打不开时常见的情况是编号不对笔记本自带摄像头用0外置 USB 摄像头可能要用1建议做成下拉框让用户选。4.4 把循环检测挪进 QThread信号槽连接与停止事件视频检测如果直接在 GUI 线程里写while cap.read()界面会瞬间变成未响应。PyQt5 的界面事件循环被阻塞鼠标点击没反应窗口标题栏会出现未响应提示。解决方案是把视频循环放到 QThread 子线程里检测完成通过信号槽把结果传回主线程。# app/worker.py from PyQt5.QtCore import QThread, pyqtSignal import threading import cv2 class VideoWorker(QThread): frame_ready pyqtSignal(object, object) # 原始帧和检测结果 error_occurred pyqtSignal(str) # 错误信息 def __init__(self, source, detector, conf_thres0.25): super().__init__() self.source source self.detector detector self.conf_thres conf_thres self._stop_event threading.Event() # 停止标志用 Event不要用 bool def run(self): cap cv2.VideoCapture(self.source) if not cap.isOpened(): self.error_occurred.emit(f无法打开视频源: {self.source}) return while not self._stop_event.is_set(): ret, frame cap.read() if not ret: break detections self.detector.detect(frame, self.conf_thres) self.frame_ready.emit(frame, detections) # 每隔 1ms 检查一次停止标志保证 stop() 能及时响应 self._stop_event.wait(0.001) cap.release() def stop(self): self._stop_event.set()frame_ready信号携带两个 Python 对象跨线程传递PyQt5 的信号槽机制默认排队连接主线程槽函数不会同时被多个子线程调用所以不需要额外加锁。停止事件用threading.Event而不是普通布尔值因为子线程里用is_set()检查主线程里用set()置位Event 在跨线程状态同步上更可靠。wait(0.001)这一句很重要没有它stop()可能拖到下一次循环才生效。5. 避坑与排查六个让 GUI 直接翻车的高频坑5.1 torch.cuda.is_available() 返回 False但 nvidia-smi 明明有 CUDA现象环境装好了nvidia-smi显示 CUDA Version 12.x但 Python 里torch.cuda.is_available()返回 False。原因最常见的是 pip 装成了 CPU 版 PyTorch。torch.__version__如果带cpu后缀基本就是这个原因。另一种情况是 PyTorch 安装时选择的 CUDA 版本比驱动支持的还高也会导致 GPU 不可用。解决先执行python -c import torch; print(torch.__version__)看后缀。带cpu就强制重装 GPU 版pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118如果带cu118但仍返回 False就是驱动版本过低升级显卡驱动后重开终端再验证。5.2 模型加载报错 unexpected key in state_dict现象torch.hub.load(ultralytics/yolov5, custom, pathweights/yolov5s.pt)时抛出unexpected key in module.state_dict()一堆 key 对不上。原因.pt 文件的模型结构和当前 YOLOv5 代码版本不匹配。不同分支的模型定义有差异旧权重配新代码就会错位。这种情况经常出现在混用了不同来源的 YOLOv5 源码包。解决把 YOLOv5 源码和权重文件都换到同一个分支版本。我的习惯是直接从官方仓库下载归档包并同步下载同一 Release 里附带的权重不混用不同渠道的文件。现在很多第三方 fork 改了层名路径对不上很常见。5.3 视频检测时程序卡死窗口显示未响应现象点击开始检测后窗口立刻失去响应标题栏提示未响应两三秒后整个程序被系统强制结束。原因视频循环的while和cap.read()直接写在了主线程里。GUI 事件循环被无限循环阻塞PyQt5 收不到鼠标事件和重绘请求系统就会判定进程无响应。解决把视频循环整体迁移到 QThread 里用前面VideoWorker的方式跑。检测结果通过信号回传界面更新放回主线程槽函数。如果暂时不想动线程可以在while里加QApplication.processEvents()硬撑但治标不治本窗口还会卡顿最终还是得走线程方案。5.4 视频延迟越来越大画面跟不上实时检测现象检测框能出来但视频画面越来越慢画面卡顿越来越明显延迟积累。原因检测耗时和视频帧读取速度不匹配。摄像头的帧率假设是 30FPS检测一帧却要 80ms检测速度跟不上读帧速度cap.read()缓冲里积压的帧越堆越多消费不掉延迟自然越来越大。解决检测线程强制做帧率限制。常见做法是加一个固定间隔的丢弃逻辑或者直接用检测侧的速度主导整个循环不盲目逐帧读import time while not self._stop_event.is_set(): start time.time() ret, frame cap.read() if not ret: break detections self.detector.detect(frame, self.conf_thres) self.frame_ready.emit(frame, detections) # 检测耗时不足保底帧间隔时等一下再进下一帧 elapsed time.time() - start if elapsed 0.05: time.sleep(0.05 - elapsed)这里 0.05 秒对应约 20FPS 的上限如果检测耗时本身就超过这个值则不做修正由检测实际速度决定帧率。5.5 检测框偏移框的位置和画面里的目标对不上现象画面上绿色框画出来了但框的位置整体向上或向左偏移目标越大偏移越多甚至框出完全无关区域。原因QLabel 显示的是缩放后的图像但 drawRect 用的是原图坐标。video_label.setPixmap()会把图片按标签尺寸缩放显示如果窗口不是 1:1 缩放检测坐标就停留在原图分辨率上没有换算到显示尺寸于是偏移。解决在绘制前根据显示区域和图像尺寸的比例做坐标换算def update_frame(self, frame_bgr, detections): h, w, _ frame_bgr.shape label_w self.video_label.width() label_h self.video_label.height() scale_x label_w / w scale_y label_h / h for det in detections: x1, y1, x2, y2 det[box] x1 int(x1 * scale_x) y1 int(y1 * scale_y) x2 int(x2 * scale_x) y2 int(y2 * scale_y) # 后续 drawRect 使用缩放后的坐标如果希望保持图像比例不变画框速度也要跟着换这时候更推荐先把原图画好再把整张QPixmap交给 QLabel 按比例缩放显示让 Qt 统一处理缩放坐标。5.6 PyInstaller 打包后双击没反应控制台也无报错现象本地跑源码一切正常用 PyInstaller 打包成 EXE 后双击程序没有任何反应任务管理器里进程一闪而过。原因PyQt5 依赖一堆平台插件比如 Qt 的 windows 平台插件PyInstaller 默认没有把它们完整收集进包。另一个高频原因是torch.hub.load动态加载 YOLOv5 源码时依赖外部文件路径打包后路径失效。解决打包命令加上--collect-all收集 PyQt5 和 torch 的资源文件pyinstaller --noconfirm --onefile --windowed \ --name detect_tool \ --collect-all PyQt5 \ --collect-all torch \ main.py如果还闪退打开 Exe 所在目录的warn.txt看缺失模块比盲猜效率高得多。6. 打包与验证从脚本到可分发 EXE 的最后一公里验证检测效果不要只拿一张经典测试图来回看我一般直接录一段真实场景视频拿视频源跑完整流程。重点观察三类问题小目标漏检率、重叠目标框合并情况、低置信度误检。回放标注后的输出视频时我会特别注意地面停车场这类远处目标密集的场景这里最能暴露置信度阈值调得不合适的问题。帧率统计我建议在推理类里加一个轻量计时器不必引入 profiling 工具手动算就行import time def detect_with_timing(self, frame_bgr): start time.time() detections self.detect(frame_bgr) cost_ms (time.time() - start) * 1000 self.last_infer_ms cost_ms return detectionsGUI 界面上把last_infer_ms显示在控制面板底部调整 model size 或者置信度阈值时能立刻看到对性能的影响。这个习惯帮我避免过采样的坑之前为了压低误检把conf_thres调到 0.5结果小目标全丢了帧率先行数据直接否决了这个调法。打包时除了 PyInstaller 命令还要注意模型权重路径。打包后sys._MEIPASS是临时解压目录权重文件不能按相对路径找。我习惯在代码里做一次路径适配import os import sys def resource_path(relative_path): base getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base, relative_path) weights_path resource_path(weights/yolov5s.pt)路径适配这段是血泪经验换来的。第一次打包时我在主窗口里写死weights/yolov5s.pt本地跑通打包完同事双击就崩。从那以后我每次打包前都强制走一遍清理临时文件 → 重新打包 → 换一台干净机器跑这个流程确认权重和模型资源都被正确收进包里才敢交付。希望这份从环境到打包的完整链路笔记能帮到你少走一圈我已经替你走过的弯路。本文还有配套的精品资源点击获取