简介:围绕 PyQt5 与 YOLOv5 搭建的多目标检测图形界面实战项目,面向刚接触 PyQt5 界面开发和 YOLO 算法的初学者,也适合希望借助现成工程练手、理解检测模型如何落地为桌面应用的学习者。压缩包共 112 个文件、约 83.46MB,内含 py 源码与 pyc 编译文件、yaml 参数配置、pt 模型权重、ui 界面文件,以及 jpg/png 图片、mp4 演示视频和 sh 脚本等,能够帮助读者快速还原运行环境并对照梳理工程目录。项目重点呈现界面设计与后端逻辑分离的思路,便于熟悉常用 PyQt5 控件、信号槽组织方式与 YOLOv5 源码调用流程;通过阅读源码和配置,读者可掌握从模型加载、图像输入到检测结果展示的基本链路,并理解界面模块与推理模块之间的调用关系。深度学习框架为 PyTorch,建议配合 PyQt5 开发书籍边学边练,把理论知识和实际项目结合起来。已有 8886 人学习下载,可作为入门图形化目标检测应用的完整参考。
1. 一个能拖图就检测的桌面工具:pyqt5 配上 yolov5 到底能干成什么事
上周有个做质检的朋友发来一张截图,界面里左边是原图、右边是检测结果,框上还带着置信度,下面一排按钮:打开、检测、批量、导出。他说这东西看着简单,自己照着网上的例子写了两天,界面一跑推理就白屏,关窗口还会抛异常。这类 pyqt5 + yolov5 的桌面小工具,检测那几行代码从源码仓库里抄过来就能跑,真正卡人的全在边上:模型加载占着主线程导致启动要等十几秒、QImage 和 numpy 的内存关系一不注意就花屏、批量处理没有进度条让用户以为死机。
这份 pyqt5 + yolov5 + pytorch 的 python GUI 项目,价值不在于“能检测”,而在于把界面层、任务调度层、推理层拆开了,每层各自能单独跑、单独换。适合两类人:手里已经有训练好的权重、想快速做个交付给非技术同事的界面;以及界面已经能跑、但想搞明白 QThread 和信号槽到底该怎么用的人。
2. 工程骨架:PyTorch 推理层与 Qt 界面层怎么分目录、怎么分职责
大多数从网上抄来的 yolo 界面,最后都会长成一个两千行的单文件:模型加载写在__init__里,推理写在按钮的槽函数里,画框写在paintEvent里,改一个参数要翻整个文件。这个项目的第一条设计约束就是把这三件事拆开,不是为了好看,是为了让推理层能在没有 Qt 的环境里跑测试。
2.1 三层拆分:界面、调度、推理各管一段
先把职责说清楚。界面层只做三件事:摆放控件、响应鼠标键盘事件、把回传的结果画出来,它不允许import torch,也不允许直接cv2.imread。调度层夹在中间,管任务队列、线程生命周期、进度上报、取消标志,批量处理时的“第 3/50 张”就是它发的信号。推理层是最干净的一层,入口是一张 numpy 的 BGR 图,出口是一个检测框列表,中间不产生任何 Qt 对象。
依赖方向是单向的:界面 → 调度 → 推理,反向不允许。这条边界带来的直接好处是,模型换成 onnx、换成 TensorRT,界面代码一行不用动;反过来,界面从 PyQt5 换成 PySide6,推理层也不受影响。我自己的习惯是,只要某个模块的import里同时出现了PyQt5和torch,就说明边界破了,得拆。
2.2 目录结构与依赖清单
按上面的分层,目录大致是这样排的:
yolo_gui/ ├── main.py # 入口,只做 QApplication 启动 ├── app/ │ ├── main_window.py # 主窗口,组装控件与信号 │ ├── widgets/ │ │ ├── image_view.py # 基于 QGraphicsView 的图像显示与画框 │ │ └── result_table.py # 检测结果表格 │ └── workers/ │ └── detect_worker.py # QThread 子类,调用 core 层 ├── core/ │ ├── detector.py # 模型加载 + 预处理 + 推理 + 后处理 │ ├── exporter.py # 结果导出 csv / json │ └── types.py # Detection 数据结构 ├── third_party/ │ └── yolov5/ # 官方源码整包放进来的位置 ├── weights/ │ └── best.pt ├── configs/ │ └── default.yaml # 类别名、默认阈值、窗口标题 └── requirements.txt把 yolov5 源码整包放在third_party/而不是pip install,是因为它的推理工具函数(letterbox、非极大值抑制、坐标还原)和模型结构是配套的,版本对不上时最典型的症状就是框位置整体偏移或者类别名对不上。依赖清单对应如下:
| 组件 | 在项目里的作用 | 落地时要注意的点 |
|---|---|---|
| PyQt5 | 界面、信号槽、QGraphicsView 绘图 | 开高 DPI 缩放后,坐标映射要额外处理 |
| torch / torchvision | 加载权重、张量运算 | 大版本尽量和训练权重时保持一致 |
| opencv-python | 读写图、letterbox、画框 | 如果只做后台处理,可用 headless 版本减小体积 |
| numpy | 图像矩阵、结果数组的传递载体 | 传给 QImage 时必须保证内存连续 |
| pyyaml | 读类别名和默认阈值配置 | yolo 的 data 配置里就是一份 name→id 映射 |
2.3 环境搭建与第一次跑通的顺序
环境这一段有个顺序上的讲究:先确认 torch 能用,再接界面,最后接 yolov5 源码。反过来的话,界面上任何一个报错你都会怀疑是模型的问题,排查成本翻倍。
python -m venv .venv source .venv/bin/activate # Windows 下用 .venv\Scripts\activate python -m pip install -U pip # 只装界面和图像处理相关,先不引入 torch pip install PyQt5 opencv-python numpy pyyaml # torch 按本机情况单独装,装完立刻验证 pip install torch torchvision python -c "import torch; print(torch.__version__, torch.cuda.is_available())"torch.cuda.is_available()返回False不是错误,只是说明会跑在 CPU 上,界面照样能用,只是单张图从几十毫秒变成几百毫秒。确认这一步之后,再把 yolov5 源码放到third_party/yolov5,并在推理模块顶部把它的路径加进sys.path:
import sys from pathlib import Path # 用 __file__ 反推,保证从任何工作目录启动都能找到源码 YOLO_ROOT = Path(__file__).resolve().parent.parent / "third_party" / "yolov5" if str(YOLO_ROOT) not in sys.path: sys.path.insert(0, str(YOLO_ROOT)) from models.common import DetectMultiBackend from utils.augmentations import letterbox from utils.general import non_max_suppression, scale_boxes, check_img_size from utils.torch_utils import select_device这里用insert(0, ...)而不是append,是因为环境里如果已经装了别的包也叫utils或models(这非常常见),append会让它们的优先级更高,导致 import 到错误的模块,报错信息通常是一句莫名其妙的AttributeError。用Path(__file__).resolve()而不是os.getcwd(),是因为工作目录取决于用户从哪里双击启动,打包成 exe 之后这个值完全不可控。这是第一条值得记住的落地经验。
第一阶段的验收标准很简单:写一个十行脚本,加载权重,读一张本地图,跑一次前向,把检测框数量打印出来。这个脚本能跑通,后面所有问题都只可能出在界面层;跑不通,问题一定在权重或环境,跟 Qt 没关系。
3. 推理链路:从一张 BGR 图到一份框列表
推理层的接口我一般定义成detect(image: np.ndarray) -> List[Detection],输入是 OpenCV 读出来的 BGR 图,输出是纯 Python 数据结构,中间的张量和 GPU 显存不往上抛。这一章按模型的四个阶段拆:加载、预处理、前向、后处理,每个阶段都有一两个容易写错的地方。
3.1 模型加载:用 DetectMultiBackend 兼容 pt 与 onnx
加载只做一次,放在窗口初始化时。之所以选DetectMultiBackend而不是直接torch.load,是因为它能按文件后缀自动识别 pt、onnx、engine 这些格式,后面做二次开发时换模型不用改代码。
from utils.torch_utils import select_device from models.common import DetectMultiBackend from utils.general import check_img_size class Detector: def __init__(self, weights, device="", imgsz=640, conf=0.25, iou=0.45): # device="" 表示自动选择:有卡用卡,没卡用 CPU self.device = select_device(device) # data 参数传 None 时类别名从权重里取;换成自定义模型时建议传 data.yaml 路径 self.model = DetectMultiBackend(weights, device=self.device, data=None, fp16=False) self.model.eval() # 必须,否则 BN 层行为不一致 self.stride = self.model.stride self.names = self.model.names # dict: {0: 'person', ...} # 把 imgsz 收敛到 stride 的整数倍,否则 letterbox 的后处理会错位 self.imgsz = check_img_size(imgsz, s=self.stride) self.conf, self.iou = conf, iou def warmup(self): # 首帧会包含算子编译和显存分配,先在空图上跑一次把这块耗时挪出用户视野 dummy = torch.zeros(1, 3, self.imgsz, self.imgsz, device=self.device) self.model(dummy)select_device("")的语义是自动挑选,想强制 CPU 就传"cpu",想指定第二张卡就传"1"。fp16=False在 CPU 环境下必须保持 False,否则会直接报算子不支持。warmup()这个方法看着可有可无,实际对体验影响很大:不做的话,用户点的第一张图会比后面每张多花一到两秒,很容易被当成“软件卡了”。
3.2 预处理:letterbox 与归一化里最容易写错的三个地方
预处理的目标是把任意尺寸的图变成(1, 3, 640, 640)的张量,同时把缩放比例记录下来,后处理要用它把框还原回原图坐标。
import numpy as np import torch from utils.augmentations import letterbox def preprocess(self, im0): # auto=True 时按 stride 自适应填充,而不是简单缩放到 640x640 im = letterbox(im0, self.imgsz, stride=self.stride, auto=True)[0] im = im.transpose((2, 0, 1))[::-1] # HWC -> CHW,同时 BGR -> RGB im = np.ascontiguousarray(im) # transpose 之后内存不连续,必须显式拷贝 im = torch.from_numpy(im).to(self.device).float() im /= 255.0 # 归一化到 0~1 if im.ndim == 3: im = im[None] # 补 batch 维度 return im三处容易错的地方:[::-1]这个通道翻转不能省,yolo 训练时用的是 RGB,而 OpenCV 读出来是 BGR,漏了这一步的典型现象是某些颜色接近的类别互相混淆,检出数明显偏低;ascontiguousarray不能省,转置后的数组内存是跳跃的,torch.from_numpy会直接报错或者得到乱序数据;除 255 必须在float()之后,在 uint8 上做除法会被截断成 0 和 1。
3.3 后处理:置信度过滤、非极大值抑制与坐标还原
后处理把网络输出的原始张量变成人能读的框。这一步的核心函数是两个:non_max_suppression负责去掉重叠框和低分框,scale_boxes负责把 640 坐标系下的框映射回原图。
from utils.general import non_max_suppression, scale_boxes def postprocess(self, pred, im, im0): # classes 传 None 表示保留所有类别;只想检测某几类时传 [0, 2, 5] pred = non_max_suppression(pred, self.conf, self.iou, classes=None, agnostic=False, max_det=300) det = pred[0] # 单张图,取第 0 个 results = [] if len(det): # 把框从 letterbox 后的尺寸还原到原图尺寸,并四舍五入成整数像素 det[:, :4] = scale_boxes(im.shape[2:], det[:, :4], im0.shape).round() for *xyxy, conf, cls_id in det.tolist(): x1, y1, x2, y2 = (int(v) for v in xyxy) results.append({ "box": (x1, y1, x2, y2), "conf": round(float(conf), 4), "cls": int(cls_id), "name": self.names.get(int(cls_id), str(int(cls_id))), }) return resultsscale_boxes的第一个参数是网络输入尺寸、第三个是原图尺寸,顺序反了不会报错,但框会整体缩小或放大,往往还能蒙对几个,很难发现。max_det=300是单张图保留的最大框数,密集场景可以调高,但调太高会让界面画框变慢。返回结构用字典而不是元组,是为了后续导出 csv 时不用再记住每个位置的下标含义。
3.4 阈值参数:改哪个值会带来什么变化
界面上一般只暴露 conf 和 iou 两个滑块,但真正影响结果的有四个参数,改之前最好先知道各自的作用域:
| 参数 | 默认值 | 作用位置 | 调大以后 | 调小以后 |
|---|---|---|---|---|
| conf | 0.25 | 非极大值抑制前过滤 | 漏检变多、误检变少 | 框变多、噪声变多 |
| iou | 0.45 | 非极大值抑制时合并重叠框 | 同一目标可能出现多个框 | 相邻目标容易被合并成一个 |
| imgsz | 640 | 预处理缩放目标 | 小目标检出率上升、耗时线性增长 | 速度快、小目标丢失 |
| max_det | 300 | 单图保留框数上限 | 密集场景更完整、绘制变慢 | 超出部分直接丢弃 |
实际调参的经验是:先固定 iou 为 0.45 不动,用一批样本图扫 conf 从 0.1 到 0.6,画出漏检和误检的拐点,再回头调 iou。反过来做的人很多,最后往往在两个参数之间反复横跳。imgsz 如果不是 32 的整数倍,check_img_size会向上取整,界面上显示的尺寸和你输入的不一致,这是正常的。
4. 界面链路:QThread 跑推理、信号槽回传、QGraphicsView 上屏
推理层能跑通之后,界面层的坑才真正开始。这一层的核心矛盾只有一个:推理是阻塞的,而 Qt 的主线程必须随时响应绘制和事件,两者不能放在同一个线程里。
4.1 为什么推理必须放进 QThread
在按钮的槽函数里直接调detector.detect(img),单张图几百毫秒的时候问题不明显,一旦切到 CPU 模式或者用户拖进来一张 4K 图,主线程就被占住了,窗口变成“无响应”,标题栏发灰,Windows 上还会弹一个系统的关闭对话框。这不是性能问题,是架构问题,加个QApplication.processEvents()只能缓解不能根治,反而会引入重入。
正确的做法是把推理封装成QThread子类,槽函数里只负责启动它:
from PyQt5.QtCore import QThread, pyqtSignal class DetectWorker(QThread): # 用 object 传结果,避免自定义类型注册的麻烦 succeeded = pyqtSignal(object, object, float) # 原图, 检测列表, 耗时毫秒 failed = pyqtSignal(str) progress = pyqtSignal(int, int) # 当前, 总数 def __init__(self, detector, image, parent=None): super().__init__(parent) self.detector, self.image = detector, image def run(self): # run() 里执行的是子线程代码,绝对不能碰任何 QWidget try: import time t0 = time.perf_counter() dets = self.detector.detect(self.image) cost = (time.perf_counter() - t0) * 1000 self.succeeded.emit(self.image, dets, cost) except Exception as exc: # 异常必须转成信号回主线程,子线程里弹窗会直接崩 self.failed.emit(f"{type(exc).__name__}: {exc}")三个细节:run()内部只能用信号和主线程通信,任何QMessageBox、setText都会让程序崩在不确定的地方;succeeded用object类型传参,省去了给自定义数据结构注册元类型的步骤,代价只是类型检查变弱;耗时的perf_counter要放在子线程里算,不要在主线程包一层计时,那样测到的是排队时间。
主窗口侧要做的只是连接信号,并且持有 worker 的引用:
def on_detect_clicked(self): if self.worker and self.worker.isRunning(): return # 防止用户连点导致两个线程抢同一个模型 self.worker = DetectWorker(self.detector, self.current_image) self.worker.succeeded.connect(self.on_detect_done) self.worker.failed.connect(self.on_detect_failed) self.worker.start()self.worker必须是实例属性。写成局部变量的话,函数一返回对象就被 GC 了,线程会在跑的过程中被销毁,症状是程序偶发崩溃、日志里看不到任何异常。这个坑我在不同项目里见过至少三次。
4.2 numpy 与 QImage 之间那条内存线
推理结果要在界面上显示,就得把 numpy 数组转成QImage。这段代码只有五行,但少写一个.copy()就会花屏。
import cv2 from PyQt5.QtGui import QImage def bgr_to_qimage(im_bgr): rgb = cv2.cvtColor(im_bgr, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape # bytesPerLine 必须显式给,否则宽度不是 4 的倍数时会错位 qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) # copy() 让 QImage 自己持有一份内存,脱离 numpy 缓冲区生命周期 return qimg.copy()QImage的构造函数不会复制数据,它只是借用rgb.data这块内存。函数返回后rgb是局部变量,引用计数归零被回收,QImage拿着的是一个野指针,显示出来就是彩色条纹或者整块花屏,而且刷新一次可能好一次,表现得非常随机。.copy()的代价是一次内存拷贝,对 4K 图大概几毫秒,换来的是不再有玄学问题,这笔买卖划算。另外bytesPerLine传ch * w在多数情况下没问题,但宽度不能整除 4 时会出现每行错开几个像素的斜纹,稳妥的写法是((w * ch + 3) // 4) * 4向上对齐。
4.3 在 QGraphicsView 上画框与鼠标坐标映射
用QLabel显示图也能跑,但一旦要做缩放、拖拽、点选某个框查看详情,QGraphicsView会省下大量代码。显示流程是:把QImage包成QPixmap,设进场景里的一个QGraphicsPixmapItem,再叠加一层自绘的框。
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsPixmapItem, QGraphicsView from PyQt5.QtGui import QPixmap, QPainter, QPen, QColor class ImageView(QGraphicsView): def show_result(self, im_bgr, dets): qimg = bgr_to_qimage(im_bgr) self.pixmap_item = QGraphicsPixmapItem(QPixmap.fromImage(qimg)) scene = QGraphicsScene(self) scene.addItem(self.pixmap_item) self.setScene(scene) self.dets = dets self.fitInView(self.pixmap_item, 1) # 1 = Qt.KeepAspectRatio def drawForeground(self, painter: QPainter, rect): # 在视图坐标系上叠加,避免修改原图 painter.setRenderHint(QPainter.Antialiasing) for d in getattr(self, "dets", []): x1, y1, x2, y2 = d["box"] painter.setPen(QPen(QColor(0, 220, 120), 2)) painter.drawRect(x1, y1, x2 - x1, y2 - y1) painter.drawText(x1 + 4, max(y1 - 6, 12), f'{d["name"]} {d["conf"]:.2f}')重写drawForeground的好处是框和图像分离,缩放时框线宽度不会跟着变粗,右键缩放、滚轮放大都不用额外处理。如果要做“点击某个框弹出详情”,就需要把鼠标坐标映射回原图像素坐标:
def mousePressEvent(self, event): scene_pos = self.mapToScene(event.pos()) # 场景坐标就是原图像素坐标,因为 pixmap item 没有做过缩放 x, y = int(scene_pos.x()), int(scene_pos.y()) for d in getattr(self, "dets", []): x1, y1, x2, y2 = d["box"] if x1 <= x <= x2 and y1 <= y <= y2: self.box_clicked.emit(d) break super().mousePressEvent(event)mapToScene和mapFromScene这一对方法要分清方向,写反了的表现是点击位置和框位置差一个缩放比例,图越大偏得越远。
4.4 批量处理与进度上报
批量模式的代码结构和单张几乎一样,区别在于run()里是一个循环,每处理完一张发一次progress信号。需要注意的是取消功能:用户点了“停止”不能直接terminate()线程,那会让模型停在半途,下次推理直接崩。
def run(self): total = len(self.paths) for i, path in enumerate(self.paths, 1): if self._cancel: # 布尔标志,由主线程置位 self.failed.emit("用户已取消") return im0 = cv2.imread(str(path)) if im0 is None: continue # 单张读失败不中断整批 dets = self.detector.detect(im0) self.progress.emit(i, total) self.result_ready.emit(str(path), dets)取消标志用一个普通布尔量就够,Python 的赋值是原子的,不需要加锁。真正需要注意的是,result_ready每到一张就发一次,主线程里往表格追加行的时候要用setUpdatesEnabled(False)包一下,否则几百张的时候界面会肉眼可见地变慢。
5. 踩坑排查:五条血泪记录,从模型路径到 exe 打包
这一章的内容全部来自实际复现过的问题,按“现象 → 原因 → 解决”记录,遇到类似症状可以直接对号入座。
5.1 模型加载与路径相关的三条
现象一:开发环境好好的,打包成 exe 之后双击就闪退,命令行里能看到FileNotFoundError: weights/best.pt。原因是指定权重时用了相对路径,PyInstaller 打包后运行目录变成了临时解压目录,相对路径的基准全变了。 解决是把所有资源路径统一走一个函数:判断getattr(sys, 'frozen', False),为真时用sys._MEIPASS作为根目录,否则用Path(__file__).resolve().parent。所有weights、configs都从这个根拼出来,不要在任何地方直接写"weights/best.pt"。
现象二:模型能加载,检测也能跑,但类别名全是数字。原因是DetectMultiBackend的data参数传了None,而权重文件里没有内嵌类别名(自己用train.py导出时常见)。 解决是传入训练时用的那份 data 配置路径,或者初始化后手动赋self.model.names = {i: n for i, n in enumerate(names_list)}。判断方法很简单,加载完打印一下len(model.names),为 0 就说明没读到。
现象三:第一次点检测要等三四秒,之后每张只要几十毫秒。原因是首帧包含显存分配和算子选择,属于正常的冷启动开销,不是 bug。 解决办法是在窗口初始化之后、用户操作之前,用一张全零图跑一次warmup()。如果启动时就想把这段时间也省掉,可以把加载和 warmup 一起放进一个启动线程,界面上显示“模型加载中”,加载完成后才启用检测按钮。
5.2 卡死与线程相关的两条
现象四:点检测之后窗口标题栏变灰,几秒后恢复,期间点叉号关不掉。原因有两个常见来源:一是推理直接写在槽函数里跑在主线程;二是DetectWorker被赋给了局部变量,线程对象在运行途中被回收。 解决是确认self.worker = DetectWorker(...)用的是实例属性,同时确认run()里没有任何QWidget相关调用。还有一种隐蔽情况是信号连接用了Qt.DirectConnection,槽函数会在子线程执行,跨线程操作控件同样会崩,保持默认的自动连接即可。
现象五:批量处理跑到一半程序退出,没有任何报错。原因是循环里某张图读取失败返回None,紧接着传给letterbox触发异常,而run()里的try只包了推理没包读图,异常直接终止线程。 解决是把读图和推理都放进try,单张失败就continue并计数,最后统一在完成信号里报告“成功 N 张、失败 M 张”。这类问题在用户给的图片目录里几乎必然出现,提前处理掉比事后排查省事得多。
6. 二次开发:换模型、加功能、提速度的三条具体路线
拿到这份工程之后,最常见的三种改造需求是换自己的权重、加一个导出功能、把 CPU 推理提提速。三条路线的改动量差别很大,下面按改动从小到大排。
换权重是最轻的,只需要改配置里的路径,但有个前提:类别名必须对得上。我一般会在Detector初始化后加一句断言,把len(self.names)和配置文件里的类别数比一下,不一致就直接抛异常。不做这个检查的话,程序能跑,框也在,只是标签全错,用户根本发现不了。
加导出功能建议新开一个core/exporter.py,输入是检测结果列表和原图尺寸,输出 csv 或 json,不要写进界面文件。csv 的列我习惯固定成filename,x1,y1,x2,y2,conf,cls,name,坐标保留整数像素,这样下游用 Excel 或者脚本都能直接消费。导出时用一个QFileDialog.getSaveFileName拿路径,写文件本身很快,可以留在主线程。
提速度有两条路。装好 CUDA 版 torch 是最直接的,模型加载时select_device("")会自动选中显卡,通常能从几百毫秒降到几十毫秒。没有显卡的话,把权重导出成 onnx 再用 onnxruntime 跑 CPU,对小模型往往有接近一倍的提升,而且省掉了整个 torch 的依赖,打包体积能从几百兆降到几十兆。导出这一步用仓库里的export.py,--include onnx --opset 12,然后把DetectMultiBackend的权重路径换成.onnx即可,界面层一行不用改——这正是第 2 章那层边界带来的好处。
最后说一个验证习惯。我给任何新模型接界面前,都会先强制跑一遍纯命令行的单张推理,确认权重能加载、类别名数量对、框的位置落在合理区域,再接 GUI。跳过这一步直接调界面的话,一旦结果不对,你会在“模型有问题”和“界面画错了”之间反复怀疑,排查时间至少翻倍。从那以后我每次接新模型都强制走一遍这个命令行检查,希望帮到你。
本文还有配套的精品资源,点击获取