ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PySide6 重搭 YOLOv7 检测界面:PyQt5 迁移与 QThread 线程隔离

PySide6 重搭 YOLOv7 检测界面:PyQt5 迁移与 QThread 线程隔离 简介一份面向程序开发与图像处理场景的YOLOv7可视化检测工具基于YOLOv7与PyQt5-YOLOv5设计思路提供PySide6图形界面支持对图像和视频进行快速目标检测。项目启动后界面会自动加载ptmodel文件夹中的预训练权重文件免去手动配置模型的麻烦尤其适合希望快速体验YOLOv7检测效果的开发者与学生。压缩包共88个文件大小约138.37MB主要包含Python源码、界面UI文件、图标资源、预训练pt模型、字体文件以及配置脚本等其中源码部分占比较大并附带Dockerfile方便在不同系统中部署使用。目前已有810人学习。资源内还提供了自定义标签绘制、鼠标交互模块以及CUDA测试脚本能够帮助读者深入理解YOLOv7推理流程、PySide6界面事件绑定与目标框标注逻辑也可作为二次开发的基础模板。1. 为什么用 PySide6 把 YOLOv7 检测界面从 PyQt5 重搭一遍同时出现 PyQt5-YOLOv5 与 yolov7-Pyside6 的项目通常不是要把两套模型塞进同一个可执行文件而是把旧界面代码当作参照重新搭一个能同时上传图像和播放视频的 YOLOv7 桌面检测工具。这里有个反直觉的点真正让旧项目跑不动的往往不是 YOLO 模型本身而是线程退出、坐标换算和信号槽类型差异PySide6 的 API 对 PyQt5 基本兼容所以迁移成本不在重写推理逻辑而在于把模型调用从 UI 线程里剔出去。我一般会先在 YOLOv7 源码目录里把纯推理部分写成一个YOLOv7Detector类不做任何界面感知再把 PyQt5-YOLOv5 里的pyqtSignal换成 PySide6 的Signal用QThread接收帧、返回结果。这样开发顺序是环境、线程、显示、参数。下面按这个顺序讲。适合准备把 YOLOv7 做成内部工具、巡检小工具或者带界面的实验台的人。2. 一条不会踩坑的安装顺序YOLOv7、PySide6、OpenCV 怎么混装2.1 PyQt5 和 PySide6 的差异集中在这三处PyQt5-YOLOv5 的老代码里控件、布局、绘图逻辑基本能平移过来真正要动手改的是这几个地方pyqtSignal变成SignalQAction从QtWidgets挪到了QtGuipyqtSlot变成Slot。如果界面里有exec_()这种写法PySide6 里推荐直接写exec()。这些差异不会影响 YOLOv7 的权重加载和 OpenCV 解码但要一次性改完。最常见的问题不是语法报错而是from PySide6.QtCore import pyqtSignal这种惯性写法直接导致 ImportError然后把本不相关的模型环境也带崩了。2.2 推荐 venv 安装命令与验证我一般用虚拟环境把 YOLOv7 和桌面框架装在一起避免跟系统 Python 里的 numpy、opencv 互相污染。mkdir yolov7-pyside cd yolov7-pyside python -m venv venv source venv/bin/activate # Windows PowerShell: venv\Scripts\Activate.ps1 pip install --upgrade pip pip install pyside66.5,6.8 opencv-python pyyaml tqdm pip install torch torchvision这段命令里pyside66.5,6.8是版本范围不是落死版本号目的是避开 PySide6 早期版本里 QImage 数据所有权的一些边界行为。opencv-python负责图像解码和画框pyyaml是 YOLOv7 读取 data yaml 的依赖。YOLOv7 的检测代码依赖项目源码里的models和utils两个目录。把 YOLOv7 源码 clone 到当前目录后确认根目录下有models、utils、data再继续写界面代码。安装完成后跑这条python -c import torch, cv2, PySide6; print(torch.__version__, cv2.__version__, PySide6.__version__)这条命令能同时暴露三个问题Python 位数不一致、OpenCV 被系统包覆盖、PySide6 装到了别的环境。三个版本能打印出来再往下一步走。2.3 import 避坑Signal、QAction 和小写模块从 PyQt5 切到 PySide6 时推荐直接把 import 写在文件顶部再全局搜索替换# PyQt5 写法 from PyQt5.QtCore import pyqtSignal, QThread from PyQt5.QtWidgets import QAction # PySide6 写法 from PySide6.QtCore import Signal, QThread from PySide6.QtGui import QActionQAction是这类项目里最容易漏的一处。PyQt5 里它在QtWidgetsPySide6 里它在QtGui。如果界面代码里用了多个from PySide6.QtWidgets import *不会报错但 IDE 和静态检查会一直给出模糊警告运行到QAction构造时才突然失败。信号参数写法也不一样。PySide6 用Signal(object, object, float)就能传递ndarray和检测结果列表但要注意float参数在绑定槽函数时要能兼容 Python 浮点类型否则会在连接时抛 TypeError。3. YOLOv7 QThread把推理隔离在独立线程里的最小实现3.1 直接在 UI 线程推理为什么必卡QTimer定时器默认跑在 GUI 线程。很多 PyQt5-YOLOv5 界面代码会在timeout里直接调用model(img)一次前向传播 300 到 800 毫秒期间QLabel无法重绘按钮 hover 状态也出不来用户感知就是“界面死了”。正确做法是让QTimer只做一件事读帧、把帧丢进队列。真正的letterbox、model()、non_max_suppression全部放进后台线程推完再通过信号丢回主线程。3.2 一个可以直接跑的 DetectorWorker先写不带界面的检测器import time import queue import cv2 import torch import numpy as np from models.experimental import attempt_load from utils.general import non_max_suppression, scale_coords from utils.dataloaders import letterbox from utils.plots import plot_one_box class YOLOv7Detector: def __init__(self, weights_path, device0, imgsz640, conf_thres0.25, iou_thres0.45, halfTrue): self.imgsz imgsz self.conf_thres conf_thres self.iou_thres iou_thres self.half half and torch.cuda.is_available() self.device torch.device(fcuda:{device} if torch.cuda.is_available() else cpu) self.model attempt_load(weights_path, map_locationself.device) self.model.eval() if self.half: self.model.half() self.names self.model.names if hasattr(self.model, module): self.names self.model.module.names torch.no_grad() def infer(self, bgr_frame): img0 bgr_frame img letterbox(img0, new_shapeself.imgsz, stride32)[0] img img[:, :, ::-1].transpose(2, 0, 1) # BGR - RGB, HWC - CHW img np.ascontiguousarray(img) tensor torch.from_numpy(img).to(self.device) tensor tensor.half() if self.half else tensor.float() tensor tensor / 255.0 pred self.model(tensor.unsqueeze(0))[0] det non_max_suppression(pred, self.conf_thres, self.iou_thres)[0] result [] if det is not None and len(det): det[:, :4] scale_coords(tensor.shape[2:], det[:, :4], img0.shape).round() for *xyxy, score, cls in reversed(det): x1, y1, x2, y2 [int(v) for v in xyxy] label f{self.names[int(cls)]} {score:.2f} plot_one_box(xyxy, bgr_frame, labellabel, color(0, 255, 0), line_thickness2) result.append((x1, y1, x2, y2, self.names[int(cls)], round(float(score), 4))) return bgr_frame, result这段代码里的attempt_load是 YOLOv7 源码提供的加载函数比直接用torch.load更稳会自动处理权重里的module前缀。letterbox会返回缩放后的图像而不是简单cv2.resizescale_coords再把模型输出坐标映射回原图尺寸。画框用的plot_one_box是 YOLOv7 自带工具颜色写死在鸟绿上实际做多分类时可以按cls换色。注意halfTrue只在 CUDA 可用时生效。如果强行在 CPU 上model.half()推理结果会大量丢失小目标。然后是 QThread 子类class DetectionWorker(QThread): result_ready Signal(object, object, float) finished_all Signal() def __init__(self, detector, parentNone): super().__init__(parent) self.detector detector self._queue queue.Queue(maxsize2) self._stop False def submit_frame(self, frame): if self._queue.full(): try: self._queue.get_nowait() except queue.Empty: pass self._queue.put(frame) def run(self): while not self._stop: try: frame self._queue.get(timeout0.1) except queue.Empty: continue t0 time.perf_counter() annotated, dets self.detector.infer(frame) cost_ms (time.perf_counter() - t0) * 1000.0 self.result_ready.emit(annotated, dets, cost_ms) self.finished_all.emit() def stop(self): self._stop Truemaxsize2是关键参数。视频画面每秒 25 到 30 帧而 YOLOv7 在 640 分辨率下推理时间远大于 33 毫秒队列如果不限长后台线程会越积越多延迟越来越大。设置成 2 后新帧会丢到旧帧保证界面看到的永远是相对新的画面。3.3 停止线程与窗口关闭顺序主窗口里这样做class MainWindow(QMainWindow): def __init__(self): super().__init__() self.detector YOLOv7Detector(weights/yolov7.pt) self.worker DetectionWorker(self.detector) self.worker.result_ready.connect(self.on_result) self.worker.start() def closeEvent(self, event): self.worker.stop() if not self.worker.wait(1500): self.worker.terminate() event.accept()stop()只改标志位wait(1500)等待 run 返回。如果推理前一帧花了超过 1.5 秒terminate()才会兜底。但terminate()可能弄坏 CUDA context所以不应该出现在正常流程里。更稳妥的做法是在界面里加一个“停止检测”按钮先停定时器再stop()最后等线程自然退出。4. 图像和视频检测的显示链路letterbox、坐标还原、QLabel 渲染4.1 图像输入首先要过 letterbox而不是直接 resize图像检测界面通常会有“打开图片”按钮用户选完图片立刻跑一遍检测。这一步最容易错的是把原图cv2.resize成 640x640 再送进网络。letterbox做的事是按最短边等比缩放然后补灰边到正方形。它保证目标的宽高比不被破坏。自己做缩放时公式是这样的ratio min(self.imgsz / h, self.imgsz / w) new_w int(round(w * ratio)) new_h int(round(h * ratio)) pad_w (self.imgsz - new_w) / 2 pad_h (self.imgsz - new_h) / 2 # 检测坐标从 letterbox 坐标还原到原图坐标 # x_original (x_letterbox - pad_w) / ratio # y_original (y_letterbox - pad_h) / ratioYOLOv7 的scale_coords已经做了这套还原所以界面代码里不需要手动写。但如果你从老版本 YOLOv5 迁移过来要确认老代码里没有自己实现的还原函数。常见坑是pad_w和pad_h写反宽高比一变框就错位。4.2 视频流用帧队列而不是信号每帧直接进 UI视频检测的主循环可以这样组织self.timer QTimer(self) self.timer.setInterval(30) self.timer.timeout.connect(self.read_frame) def read_frame(self): ok, frame self.cap.read() if not ok: self.timer.stop() self.cap.release() return self.worker.submit_frame(frame) def on_result(self, frame, dets, cost_ms): self.preview_label.setPixmap(cv_frame_to_qpixmap(frame, self.preview_label.size())) self.status_label.setText(f推理 {cost_ms:.1f} ms目标 {len(dets)} 个)QTimer只负责读视频帧不负责推理。这样本地视频即使读帧速度略快也不会把队列塞满。on_result收到的是已经画好框的完整帧主线程只做QPixmap转换和显示。如果视频分辨率很高比如 4Kcap.read()本身可能占用 10 到 20 毫秒这时要把VideoCapture也放到独立线程里否则定时器周期会被拖长。更简单的方式是先用cv2.CAP_PROP_POS_MSEC做抽帧不追求每一帧都推理。4.3 BGR 到 QPixmap 的快捷转换OpenCV 读出来的是 BGR 排列PySide6 的 QImage 要 RGB。转换函数我一般写成这样def cv_frame_to_qpixmap(frame, target_size): rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, _ rgb.shape qimg QImage(rgb.data, w, h, 3 * w, QImage.Format_RGB888).copy() pix QPixmap.fromImage(qimg) if target_size is not None: return pix.scaled(target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation) return pix最后那个.copy()不是多余。QImage(rgb.data, ...)不会复制 numpy 数组数据而frame在后台线程里可能马上被下一帧覆盖不 copy 会出现画面花屏或线条闪烁。先copy()再交给 QLabel 显示是最保险的顺序。显示速度优先时把Qt.SmoothTransformation换成Qt.FastTransformation能省掉大量重采样耗时适合 1080p 视频不需要放大的场景。5. 参数调整与落地经验conf_thres、imgsz、CUDA 显存5.1 三个常用参数怎么配合场景把检测参数暴露在界面上比每次改代码跑省太多时间。我一般在设置面板放三个参数置信度阈值、IoU 阈值、推理尺寸。它们的关系可以用这张表表示参数常规值我实际会怎么调conf_thres0.25对“必须检出”的小目标降到 0.1 到 0.15但误报会明显变多演示场景调到 0.4 以上更干净iou_thres0.45行人遮挡重叠多的场景建议降到 0.25 到 0.35要同时保留两个高度重叠的同类目标调到 0.6imgsz640实时视频追求流畅用 320 或 480画面里有车牌、远处小目标时用 1280耗时非线性增加halfTrue只在 CUDA 可用时开显存小于 6G 或老架构显卡不建议半精度掉点更明显这些参数推荐放在 worker 构造后通过 setter 更新。不要每次推理都重新构造 detector否则attempt_load会把整个权重重复读一遍界面点一次按钮卡三秒。5.2 显存不足和半精度显存不够时先打印两行现状再决定怎么优化if torch.cuda.is_available(): total torch.cuda.get_device_properties(0).total_memory // 1024**2 reserved torch.cuda.memory_reserved(0) // 1024**2 print(ftotal {total} MB, reserved {reserved} MB)这种场景下问题往往不是 batch 太大而是同一个进程里加载了多个模型。PyQt5-YOLOv5 老代码为了对比可能同时留下了两个 detector每个都占 1GB 到 2GB显卡一满就只能切 CPU。torch.cuda.empty_cache()可以在关闭一段视频、准备打开下一张图时调用一次。不要每帧调用因为 CUDA caching allocator 会把已经排布好的显存块全部释放下一张推理重新分配速度反而更慢。5.3 界面经常假死的最后检查点如果线程已经拆出去了界面还是卡按下面顺序查第一on_result里是否有耗时操作。不要在槽里做cv2.resize到超大尺寸也不要在这个函数里读文件。槽函数只做setPixmap和状态文字更新。第二信号传的frame是否被后台线程复用。submit_frame是引用传递后台线程拿到后开始推理推理结束会往这同一块内存画框。如果主线程显示时没有自动 copy画面闪烁是必然的。第三关闭窗口时是否调用了worker.stop()和wait()。很多程序只在closeEvent里worker.quit()但QThread::quit只退出事件循环不退出你自定义的run()死循环线程会一直占用 CPU 并阻止进程退出。第四terminate()不要成为默认出口。它虽然能强制结束线程但代价是模型权重和 CUDA 状态可能处于半坏状态。真正需要兜底时先确认已经释放了cap.release()再调用wait(2000)。最后提一个 PySide6 特有的细节连接信号时不要在 UI 线程里创建QPixmap后直接传给 worker。把图片传给 worker 只走submit_frame的队列所有绘制都在result_ready槽里做。把信号槽连接放在worker.start()之前、closeEvent 的wait放在worker.stop()之后这套 PySide6 加 YOLOv7 界面在视频检测关闭时就不会再抛 QThread 或 destroyed while thread is still running 的异常了。本文还有配套的精品资源点击获取
返回列表