
简介基于PyQt5的YOLOV5目标检测可视化界面项目面向深度学习入门者与计算机视觉开发者帮助解决算法训练后缺少直观交互界面的问题。压缩包共包含73个文件主要有29个Python源码、19个YAML配置文件以及预训练权重yolov5s.pt、依赖清单requirements.txt和Dockerfile等涵盖模型定义、训练推理、界面调用与部署环境等完整环节。整个资源压缩后仅13.4MB轻量易用。目前已有273人学习适用于快速搭建带图形界面的检测演示工具。使用者可直接运行detect_qt5.py启动界面结合ui_yolov5.py中的布局逻辑理解PyQt与YOLO推理的对接方式配套的YOLOv5原生源码、模型导出脚本和Docker配置也为二次开发、迁移到其他检测任务提供了良好基础。1. 为什么是PyQtYOLOV5把模型从命令行搬进桌面窗口目标检测模型跑通是一回事让别人能点开就用是另一回事。YOLOv5 的推理脚本和detect.py终端命令只能解决“能跑”的问题但实际交付给质检、安防、农业或医疗场景的使用者时对方要的不是--source参数而是一个能拖图片进去、能开摄像头、能看检测框还能导出结果的窗口程序。PyQt 在这里的价值不是画几个控件而是把 YOLOv5 的推理流程包装成一个完整事件循环里的常驻任务模型加载、视频读取、推理、绘制、交互必须并行不打架。这篇文章直接从 QThread 线程模型讲起落到环境配置、界面骨架、实时视频瓶颈和 PyInstaller 打包四个实操环节目标是把一个能用、不卡、能交付的桌面检测工具搭出来。2. YOLOV5推理核心先跑通命令行再写界面2.1 环境配置顺序Python版本、PyTorch和CUDA的选择YOLOv5 的代码对 PyTorch 版本敏感度不高但这不代表可以随便装。我的习惯是先定 Python 版本再定 PyTorch而不是反过来。Python 3.8 到 3.10 是 yolov5 官方测试覆盖最好的区间Python 3.11 和 3.12 在部分 torchvision 版本下会出现算子和数据类型兼容问题尤其是做model.half()半精度推理时某些老版本 CUDA 算子在新解释器下会直接报类型不匹配。先创建虚拟环境再装依赖是绕开系统 Python 污染的唯一可靠方式conda create -n yolo_gui python3.9 conda activate yolo_gui # 先装 PyTorch再装 YOLOv5 依赖 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 git clone https://github.com/ultralytics/yolov5.git cd yolov5 pip install -r requirements.txttorch和torchvision的版本必须匹配--index-url里的cu118表示 CUDA 11.8。如果机器没有 NVIDIA 显卡或显存低于 4GB建议直接装 CPU 版YOLOv5s 在 CPU 上对单张 640x640 图片的推理时间约 300 到 500 毫秒做界面原型够用。装完验证一下 GPU 是否可用这一步不能跳过import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))第一次跑如果报AttributeError: module numpy has no attribute int是 numpy 版本太新导致常见做法是降级到numpy1.24。这类问题在 PyQt 界面里出现时更难排查因为错误被信号槽吃掉所以命令行阶段先把环境验证干净。2.2 最小推理代码不写界面先验证模型界面程序的所有逻辑都建立在“模型能对单帧输入返回结构化结果”这个前提上。先用torch.hub.load写一个最小推理脚本验证模型加载、推理和结果输出都正常import torch # 加载模型yolov5s 是轻量版适合界面原型 model torch.hub.load(ultralytics/yolov5, yolov5s, pretrainedTrue) model.conf 0.45 # 置信度阈值低于此值的检测框被过滤 model.iou 0.5 # NMS 的 IoU 阈值重叠框合并的严格程度 model.classes None # 置为 None 表示检测全部 80 个类别 # 推理单张图片 results model(bus.jpg) # 打印结构化结果 print(results.pandas().xyxy[0])conf是界面里最需要暴露给用户调的参数它在 PyQt 里可以绑一个 QSlider。iou值越大两个重叠的框越容易被合并成一个密集场景下要调小。.pandas().xyxy[0]返回一个 DataFrame每一行代表一个检测目标后面的界面绘制逻辑全部依赖这个表格结构。2.3 输出结构解析tensor如何变成框、置信度和类别理解推理结果的字段结构是写界面绘制的第一步。results.xyxy[0]是一个 shape 为(N, 6)的 tensorN 是检测到的目标数6 列含义如下列含义类型界面用途0x1左上角 x 坐标float绘制矩形框1y1左上角 y 坐标float绘制矩形框2x2右下角 x 坐标float计算框宽3y2右下角 y 坐标float计算框高4置信度float显示在标签文本中5类别索引int映射到类别名字典类别索引不是字符串需要用模型自带的model.names做映射import numpy as np import cv2 # 读取图片 img cv2.imread(bus.jpg) # 推理结果转换为 numpy 数组方便逐行处理 detections results.xyxy[0].numpy() for det in detections: x1, y1, x2, y2, conf, cls det[:6] name model.names[int(cls)] # 用 cv2 绘制矩形框 cv2.rectangle(img, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) label f{name} {conf:.2f} cv2.putText(img, label, (int(x1), int(y1) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 2)这里把检测结果转成 numpy 再逐行取坐标是因为后续接入 PyQt 时需要把坐标和原图尺寸做换算。YOLOv5 内部自动做了 letterbox 填充所以返回的坐标是相对原始输入图的可以直接画在cv2.imread读到的图像上不需要额外缩放。3. PyQt界面架构QThread把YOLOV5推理挂进事件循环3.1 为什么必须用QThread界面卡死的根因PyQt 的 GUI 事件循环是单线程的如果在主线程里直接写while True: cap.read(); model(frame)界面会完全失去响应鼠标拖不动窗口按钮点了没反应。原因是主线程被推理循环占据没空处理 Qt 的鼠标和绘制事件。解决思路是把推理循环放进独立的QThread主线程只负责接收结果并刷新界面。线程间的数据传递用 Qt 的信号槽机制不能用共享变量加锁的粗暴方案因为pyqtSignal是线程安全的而普通 Python 对象跨线程访问容易造成界面无征兆崩溃。常见的反模式是在QThread.run()里直接操作 QLabel 或 QPixmap这种写法在 PyQt5 中不会立即报错但会在高帧率下出现花屏或 segmentation fault。正确做法是子线程只发数据主线程通过槽函数更新界面。下面给出一个可以直接套用的完整线程模型import cv2 import torch import numpy as np from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtGui import QImage class DetectThread(QThread): # 信号1每帧检测完成后的图像 frame_ready pyqtSignal(QImage) # 信号2检测统计信息用于状态栏展示 stats_ready pyqtSignal(int, float) # 目标数, 推理耗时ms def __init__(self, model, source0): super().__init__() self.model model self.source source # 0表示默认摄像头 self.running True # 用标志位控制退出避免线程无法停止 def run(self): cap cv2.VideoCapture(self.source) while self.running: ret, frame cap.read() if not ret: self.running False break # 推理 results self.model(frame) # 绘制检测框 plotted results.render()[0] # 返回带框的numpy数组 # 转QImage rgb_image cv2.cvtColor(plotted, cv2.COLOR_BGR2RGB) h, w, ch rgb_image.shape bytes_per_line ch * w qimage QImage(rgb_image.data, w, h, bytes_per_line, QImage.Format_RGB888) self.frame_ready.emit(qimage.copy()) # 统计信息 det_count len(results.xyxy[0]) infer_time results.speed[inference] self.stats_ready.emit(det_count, infer_time) cap.release()关键点在于qimage.copy()因为QImage构造时引用的是 numpy 数组的内存而 numpy 数组在下一次循环会被重新赋值不 copy 的话界面显示会出现残影或花屏。results.render()是 YOLOv5 自带的绘制函数它返回一个列表每个元素是绘制完检测框的 BGR 图像省去了手动调cv2.rectangle的代码。推理耗时从results.speed字典里取单位是毫秒。运行这个线程前建议先做一次预热推理因为第一次调用model(frame)会触发 CUDA 内核初始化耗时比正常推理高 10 倍以上统计信息会误导人。3.2 界面骨架QMainWindow、按钮和画布布局界面布局遵循工具软件的标准结构顶部放操作按钮中间大面积区域放视频画布底部放状态栏显示帧率和检测数。下面这个骨架代码可以直接作为基础后续再加入更多控件只需要在工具栏区域追加from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog) from PyQt5.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(YOLOv5 目标检测界面) self.setMinimumSize(900, 600) # 中央部件 central_widget QWidget() self.setCentralWidget(central_widget) layout QVBoxLayout(central_widget) # 控件区 control_layout QHBoxLayout() self.btn_open_image QPushButton(打开图片) self.btn_open_camera QPushButton(打开摄像头) self.btn_stop QPushButton(停止) self.btn_stop.setEnabled(False) control_layout.addWidget(self.btn_open_image) control_layout.addWidget(self.btn_open_camera) control_layout.addWidget(self.btn_stop) control_layout.addStretch() layout.addLayout(control_layout) # 画布 self.lbl_canvas QLabel(等待加载图片或视频) self.lbl_canvas.setAlignment(Qt.AlignCenter) self.lbl_canvas.setMinimumSize(640, 480) self.lbl_canvas.setStyleSheet(background-color: #2b2b2b; color: white;) layout.addWidget(self.lbl_canvas, stretch1) # 状态栏 self.status_label QLabel(就绪) self.statusBar().addWidget(self.status_label) # 线程引用 self.detect_thread NonesetMinimumSize(900, 600)保证窗口在小屏幕上也能完整显示控件。画布的stretch1让它可以随着窗口缩放自动填充剩余空间这是界面不显得空旷的关键设置。按钮的setEnabled(False)用来避免用户在推理进行中重复触发摄像头启动后面会在槽函数里补充恢复逻辑。3.3 推理线程与主线程的通信信号槽传递检测结果线程和界面之间的交互有三种场合启动线程、接收结果显示、停止线程时释放资源。下面这段代码把它们完整串起来同时处理了重复启动和线程未结束就销毁的边界情况def open_image(self): # 选择并加载本地图片 path, _ QFileDialog.getOpenFileName( self, 选择图片, , 图片文件 (*.jpg *.png *.bmp)) if not path: return # 如果摄像头在跑先停掉 if self.detect_thread and self.detect_thread.isRunning(): self.stop_detection() # 直接在主线程做单张推理不需要开线程 results self.model(path) plotted results.render()[0] self.display_frame(plotted) def open_camera(self): if self.detect_thread and self.detect_thread.isRunning(): return self.detect_thread DetectThread(self.model, source0) self.detect_thread.frame_ready.connect(self.display_frame) self.detect_thread.stats_ready.connect(self.update_stats) self.detect_thread.start() self.btn_open_camera.setEnabled(False) self.btn_stop.setEnabled(True) def stop_detection(self): if self.detect_thread and self.detect_thread.isRunning(): self.detect_thread.running False # 通知线程退出循环 self.detect_thread.wait(2000) # 最多等2秒 self.btn_open_camera.setEnabled(True) self.btn_stop.setEnabled(False) def display_frame(self, qimage): # 缩放显示到画布区域 scaled qimage.scaled( self.lbl_canvas.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation ) self.lbl_canvas.setPixmap(QPixmap.fromImage(scaled)) def update_stats(self, count, infer_ms): self.status_label.setText(f检测目标数: {count} | 推理耗时: {infer_ms:.1f} ms)results.render()[0]返回的是 numpy 数组但它不能直接给display_frame用因为 display_frame 接收的是 QImage。所以要在open_image里先做一次转换转换方式与线程中的一致。停止线程不能直接调terminate()那是强制杀死线程会导致 CUDA context 残留下次启动模型推理报context leak警告。正确方式是先把running置为 False再用wait(2000)等待循环退出。2 秒是保守值如果摄像头读取阻塞循环可能无法及时响应此时需要cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)减小读取延迟。4. 实时视频场景下PyQtYOLOV5的三个性能瓶颈4.1 摄像头读取与检测帧率cap.read()的隐藏开销很多人以为界面的卡顿来自模型推理实际上cap.read()本身就是一个容易忽略的耗时点。默认情况下 OpenCV 的摄像头会维护一个内部缓冲区读取时拿到的是缓冲区里最新的帧这会让画面产生明显的延迟感。更重要的是如果推理速度跟不上摄像头帧率缓冲区会不断堆积画面越来越滞后。标准解法是关闭缓冲区并主动跳帧cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)同时把摄像头分辨率降到合理范围。YOLOv5s 输入尺寸默认 640摄像头采集 1080p 的画面多出来的像素都要靠letterbox压缩这一步虽然不算太耗时但会额外消耗内存带宽。一般做法是摄像头的宽度设置为 960 或 1280高度等比缩放。下面是完整优化后的读取循环cap cv2.VideoCapture(self.source) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 960) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 540) cap.set(cv2.CAP_PROP_FPS, 30) frame_count 0 while self.running: ret, frame cap.read() if not ret: break # 跳帧策略每2帧只推理1帧提升流畅度 frame_count 1 if frame_count % 2 ! 0: continue results self.model(frame) # ...绘制和信号发射这里设置CAP_PROP_FPS是为了让摄像头以 30 帧输出而不是在采集层浪费资源。跳帧策略适合对实时性要求不高的场景比如安全监控或农林业巡检。如果是工业质检跳帧可能会导致漏检这时候应该反过来降低摄像头帧率保证每一帧都被推理。4.2 模型推理提速半精度、批处理与图像尺寸YOLOv5 在界面里的推理性能和命令行有本质区别命令行可以串行跑完整个文件夹但界面要求每帧都在一个可接受的时间窗口内返回。以下三个参数是影响推理耗时的关键按照从易到难的顺序排列优化手段操作方式适用条件预期收益半精度推理model.half(), 输入帧转.half()GPU 显存大于 2GB推理速度提升约 30%-50%降低输入尺寸model(frame, size416)小目标不多速度提升约 40%小目标召回率下降批处理多帧拼接成一个 batch离线视频处理吞吐量翻倍但单帧延迟无改善半精度推理有一个容易被忽视的坑model.half()只把模型参数转成半精度输入图像也要同步转换否则会报数据类型不匹配。写界面代码时建议做一次类型判断def preprocess_for_model(self, frame): if self.model.half is True: return cv2.cvtColor(frame, cv2.COLOR_BGR2RGB).astype(np.float16) / 255.0 return framesize416的用法是results self.model(frame, size416)YOLOv5 会自动对输入做 letterbox 缩放。这个参数适合车辆检测这类目标较大的场景但小目标例如远距离行人会明显漏检。实际调参时可以先用 640 跑看推理耗时是否在可接受范围如果超过 100ms 再考虑降到 512 或 416。4.3 界面刷新与绘制QPixmap缩放和矩形框绘制的取舍推理线程发出的帧频率和界面刷新的频率必须解耦。YOLOv5s 在 GTX 1660 上推理一帧大概 30-40ms加上摄像头读取和绘制整体帧率约 20-25 FPS。但 PyQt 的QLabel.setPixmap在高频刷新下会占用主线程事件循环的大量时间如果每帧都做scaled缩放界面点击响应会变钝。常用的解决策略是降低界面刷新率而不是降低推理频率import time class DetectThread(QThread): def __init__(self, model, source0, ui_fps15): super().__init__() self.model model self.source source self.ui_fps ui_fps # 界面上限帧率 self.running True def run(self): cap cv2.VideoCapture(self.source) interval 1.0 / self.ui_fps last_emit_time 0 while self.running: ret, frame cap.read() if not ret: break results self.model(frame) plotted results.render()[0] now time.time() if now - last_emit_time interval: # 到时间才发射信号 qimage self.numpy_to_qimage(plotted) self.frame_ready.emit(qimage) last_emit_time now # 注意这里不跳帧推理仍然每帧都做 cap.release()把ui_fps设为 15意味着推理可能每帧都做但界面只刷新 15 次。这样做的意义是让画布刷新频率低于推理频率主线程有更多时间处理按钮点击和拖拽。界面显示上会有一点视觉卡顿但操作流畅度反而更高。矩形框的绘制已经在results.render()里完成了不需要额外用QPainter因为 OpenCV 的绘制速度比 Qt 快一个量级。如果你需要画自定义的框比如不同类别不同颜色建议还是走cv2.rectangle绘制结果再转 QImage。5. 模型加载与PyInstaller打包交付界面前的最后几步5.1 模型路径与权重热切换实际使用中模型文件经常需要切换例如同一个界面既想跑yolov5s.pt又想跑自己训练好的custom.pt。写死在代码里的路径在交付时会变成灾难。常见做法是做一个统一的模型加载函数并在界面上提供“选择模型”按钮def load_model(self, weights_path, devicecpu): # 释放旧模型占用的显卡显存 if hasattr(self, model) and self.model is not None: del self.model if torch.cuda.is_available(): torch.cuda.empty_cache() self.model torch.hub.load(ultralytics/yolov5, custom, pathweights_path, force_reloadFalse) self.model.conf 0.45 self.model.iou 0.5 self.model.classes None # 自动选择设备有GPU用GPU没有用CPU if device auto: device cuda if torch.cuda.is_available() else cpu self.model.to(device)torch.hub.load的custom模式会读取path指向的.pt文件中的模型结构。自己训练的模型需要把.yaml配置文件一起放在 yolov5 目录下或者训练时直接继承yolov5s.yaml的默认结构否则会报Model not found错误。切换模型时注意概率参数是否保留有些自定义模型训练时改了conf_thres此时需要用self.model.conf 0.45强制覆盖。如果出现显存不足torch.cuda.empty_cache()后仍报 OOM说明显卡显存碎片化最直接的方案是让用户重启程序不要在代码里做复杂的内存整理。5.2 PyInstaller打包时的隐藏导入问题PyInstaller 打包 PyQt YOLOv5 最常见的坑是打包后的 exe 能启动但一点“开始检测”就崩溃错误信息显示找不到某个模块。这通常是因为 PyInstaller 的静态分析无法识别torch.hub.load动态加载的模块。处理办法是在 spec 文件的hiddenimports里加上必要模块pyinstaller --windowed --onefile \ --hidden-importtorchvision.io.image \ --hidden-importtorchvision.transforms \ --hidden-importutils \ --hidden-importmodels \ --add-data yolov5;yolov5 \ run_gui.py--windowed表示不带控制台窗口--onefile产出单文件。注意--add-data的路径分隔符在 Windows 下用分号Linux 下用冒号。这里把整个 yolov5 目录打进包里是为了确保hub加载时能找到yaml配置和utils工具函数。实际上更稳妥的做法是放弃torch.hub.load的在线拉取逻辑改成直接加载本地模型目录。run_gui.py里的启动逻辑建议这样实现先os.chdir到解压后的临时目录再加载模型避免相对路径问题。打包后第一次启动会稍微慢一点因为 PyInstaller 需要解压依赖文件。5.3 显存清理与异常恢复界面长期运行的内存稳定桌面程序不像命令行脚本跑完就退出它可能被挂机一整天。YOLOv5 的 Python 推理循环在长时间运行后会累积显存碎片尤其是每次推理都新建 tensor 的情况下显存占用会从 2GB 慢慢涨到 3GB。这里的核心问题是 PyTorch 的缓存分配器不会主动释放已缓存的显存块而是留待下一次分配复用。如果检测对象是间歇性的比如按一下检测一下建议在两次检测之间清理缓存def safe_infer(self, frame): try: results self.model(frame) torch.cuda.synchronize() # 等待所有CUDA操作完成 return results except RuntimeError as e: if out of memory in str(e): torch.cuda.empty_cache() time.sleep(1) # 清空缓存后再试一次若再失败就提示重启 results self.model(frame) return results raisetorch.cuda.empty_cache()不会完全解决碎片问题但能释放空闲块通常可以把显存占用回落 20% 左右。如果程序在 Ubuntu 服务器上跑还可以考虑用torch.cuda.memory_summary()打印详细分配信息定位是哪个 tensor 持有显存不释放。CPU 版本的程序同样需要关注内存泄漏主要来源是 QImage 没有调用deleteLater()不过在qimage.copy()后由 Qt 的引用计数管理一般不会出现严重泄漏。另一个容易被忽略的是 CUDA context 与线程销毁的关联。每次stop_detection()之后如果又重新open_camera()子线程的 CUDA context 会有残留。出现CUDA error: illegal address时最有效的恢复手段是让线程彻底退出、等待然后重新创建模型实例。界面代码里给“停止”按钮绑定一个强制的清理流程先running False再wait()最后del self.detect_thread这样下次启动时线程对象完全是新的CUDA context 也不会混叠。最后建议在界面的状态栏上放一个显存占用显示用torch.cuda.memory_allocated() / 1024**2实时查看。长时间运行时如果显存持续上涨优先检查是不是有多个模型实例没有被del或者results.render()的返回值在界面线程里被长期持有。本文还有配套的精品资源点击获取