简介:面向有图像处理与目标检测需求的开发者,这份资源提供了一套基于YOLOv7与PySide6/PyQt5的可视化检测工具,支持图像和视频两种输入方式。程序启动后会自动加载模型目录下的YOLOv7预训练权重,无需手动配置即可快速开展检测,适合希望快速搭建GUI检测原型或学习目标检测界面开发的初中级Python程序员。压缩包共88个文件,包含28个Python脚本与27个编译后的pyc文件,另有UI设计文件、图标/背景图、字体、模型配置与Dockerfile等辅助内容,整体约138.37MB。Python源码覆盖主程序、YOLO模型封装、类别定义、鼠标标注等模块,便于阅读与二次修改;模型目录中提供两个预训练权重文件,可直接运行体验。已有810人浏览学习,对于想要结合YOLOv7与Qt界面做检测应用的人来说,是一份结构完整、上手门槛较低的可直接运行参考。
1. 老界面配新模型:YOLOv7 和 PyQt5-YOLOv5 项目迁移到 PySide6 的常见路线
把 YOLOv7、PyQt5、YOLOv5、PySide6 放在同一个标题里,通常是这样的现场:你手里已经有一套能跑的 PyQt5 + YOLOv5 检测工具,另一边刚调通了 YOLOv7 的推理脚本,现在要把它们整合成一个 yolov7-Pyside6 可视化界面,并且同时支持图像和视频检测。这个需求的难点不在模型,而在界面代码和推理代码怎么解耦。如果直接把两个模型的预处理、坐标缩放、画框逻辑塞进按钮事件里,后面每换一个权重都要改一次界面。下面按我改造的实际路线来讲:先把 YOLOv7 和 YOLOv5 的推理抽成同一个 Detector 后端,再用 QThread 处理视频帧循环,最后把画框、保存和半自动标注接上,让界面只碰一个统一的检测结果格式。
2. 统一模型后端:把 YOLOv7 和 YOLOv5 的加载与推理参数对准
我不建议把 YOLOv7 或 YOLOv5 官方仓库里的 detect.py 直接复制到 GUI 工程里,因为这两个仓库的入口参数、输入预处理、输出格式都不一致。YOLOv5 的 hub 模型封装得很完整,YOLOv7 则更接近“手动挡”。界面层如果同时面对两套调用方式,后期维护成本会很高。常见做法是先写一个 Detector 类,让上层只认一个detect(frame_bgr, conf, iou, imgsz)方法,返回值统一成(N, 6)的 NumPy 数组,每行是x1, y1, x2, y2, conf, cls。
2.1 输入输出的差异以及统一推理层的边界
YOLOv5 的torch.hub.load("ultralytics/yolov5", "custom", weights)返回的模型自带 letterbox、归一化和 NMS,直接调用model(frame, size=640, conf_thres=0.25, iou_thres=0.45),然后从results.xyxy[0]拿到的就是xyxy + conf + cls。YOLOv7 不同,它的自定义模型是原始检测头,输入要先手动缩放填充,推理输出还要自己过non_max_suppression。两边的公共点只有一个:最终都可以整理成统一的检测数组。所以我把统一层的边界定在“坐标回到原图坐标系之后”,再往后就是界面的事了。
把边界划在这里还有一个好处:部署 YOLOv7 时如果显存紧张,只需要在 Detector 内部调整half()和imgsz,界面不用感知;以后换 TensorRT 或 ONNX 时,也只要给 Detector 增加一个engine分支。
2.2 一个可复用的 Detector 类:加载 .pt 和统一 detect 方法
下面的代码会调用 YOLOv7 仓库里的utils.general.non_max_suppression。最省事的做法是让 YOLOv7 的源码目录在项目里的相对路径固定,比如sys.path.append("yolov7")。
import sys import cv2 import torch import numpy as np sys.path.append("yolov7") # 改成你实际的 YOLOv7 仓库路径 def resize_and_pad(img, size=640): h, w = img.shape[:2] r = min(size / h, size / w) new_w, new_h = int(round(w * r)), int(round(h * r)) resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) top = (size - new_h) // 2 bottom = size - new_h - top left = (size - new_w) // 2 right = size - new_w - left padded = cv2.copyMakeBorder( resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(114, 114, 114) ) return padded, r, left, top class Detector: def __init__(self, arch="yolov7", weights="", device=""): self.arch = arch.lower() self.device = device if device else ("cuda:0" if torch.cuda.is_available() else "cpu") if self.arch == "yolov7": self.model = torch.hub.load( "WongKinYiu/yolov7", "custom", weights, force_reload=False ).to(self.device).eval() if self.device != "cpu": self.model.half() elif self.arch == "yolov5": self.model = torch.hub.load( "ultralytics/yolov5", "custom", path=weights, force_reload=False ).to(self.device).eval() self.names = getattr(self.model, "names", []) def detect(self, frame_bgr, conf=0.25, iou=0.45, imgsz=640): if self.arch == "yolov7": from utils.general import non_max_suppression img, ratio, pad_left, pad_top = resize_and_pad(frame_bgr, imgsz) rgb = img[:, :, ::-1].copy() tensor = torch.from_numpy(rgb).permute(2, 0, 1).float() / 255.0 tensor = tensor.unsqueeze(0).to(self.device) if self.device != "cpu": tensor = tensor.half() with torch.no_grad(): outputs = self.model(tensor) if isinstance(outputs, (tuple, list)): outputs = outputs[0] pred = non_max_suppression(outputs, conf, iou) if not pred or pred[0] is None: return np.empty((0, 6), dtype=np.float32) det = pred[0].float() boxes = det[:, :4].clone() boxes[:, [0, 2]] -= pad_left boxes[:, [1, 3]] -= pad_top boxes /= ratio result = torch.cat((boxes, det[:, 4:6]), dim=1).cpu().numpy() return result.astype(np.float32) else: results = self.model( frame_bgr, size=imgsz, conf_thres=conf, iou_thres=iou ) det = results.xyxy[0].cpu().numpy() return det.astype(np.float32) if len(det) else np.empty((0, 6), dtype=np.float32)这个类里没有把类别过滤写死,因为界面上通常会有一个“只显示指定类别”的选项,在调用处过滤更灵活。YOLOv7 分支在 GPU 上开启半精度后,显存占用差不多能减半;如果跑 CPU,就不要调用half(),tensor 保持 float32 即可。YOLOv5 分支不需要手动预处理,官方 hub 内部会处理。
提示:不要在界面里反复实例化 Detector。模型加载一次往往要几百毫秒,放在程序启动时做,失败时用 QMessageBox 弹窗提示,让用户去检查权重路径。
2.3 调用检测器时的超参数设置和调试入口
界面中最常暴露给用户的参数是conf、iou、imgsz,它们对速度影响很大。尤其imgsz,从 640 提到 1280,推理耗时可能涨三到四倍。
| 参数 | YOLOv7 里对应 | YOLOv5 里对应 | 建议范围 | 说明 |
|---|---|---|---|---|
| conf | conf | conf_thres | 0.15 ~ 0.5 | 越小框越多,误检也越多 |
| iou | iou | iou_thres | 0.35 ~ 0.7 | 越大重叠框越不会被合并 |
| imgsz | 640 | size | 416 / 512 / 640 | 决定延迟和显存的主要参数 |
| half | 模型 half() | 由仓库自动 | GPU 固定开启 | 半精度推理 |
| classes | non_max_suppression 的 classes | model.classes | 不填 | 仅保留指定类别 id |
我在界面上一般用滑块控制conf,imgsz做成下拉框而不是滑块,因为resize_and_pad对输入尺寸有严格约束,常见档位就 416、512、640、1280。调试时先拿单张图跑一次Detector.detect,确认输出坐标都是正数且没有 NaN,再接入视频,否则问题会混在一起。
这一步最常见的报错是ModuleNotFoundError: utils.general。不是模型坏了,而是sys.path.append("yolov7")没指向真正的 YOLOv7 源码目录。另一个高频报错是 CUDA out of memory,把imgsz降到 512,或者打开half(),基本能解决。
3. PySide6 与 PyQt5 的界面骨架:线程、信号槽和视频帧循环
PyQt5-YOLOv5 项目迁移到 PySide6 时,大部分代码可以直接复用,要改的主要是 import 名称和少量 API。PySide6 中信号叫Signal,槽叫Slot;PyQt5 里叫pyqtSignal和pyqtSlot。别以为只是换名字,QAction的包位置和主循环的exec写法也有变化。
3.1 迁移 PyQt5 到 PySide6 时最容易踩的 3 处语法差异
| 功能 | PyQt5 写法 | PySide6 写法 | 备注 |
|---|---|---|---|
| 信号声明 | change = pyqtSignal(int) | change = Signal(int) | 语义几乎一致 |
| 槽声明 | @pyqtSlot() | @Slot() | 不影响功能 |
| 主循环退出 | app.exec_() | app.exec() | PyQt5 中 exec_ 是兼容写法 |
| QAction 位置 | QtWidgets.QAction() | QtGui.QAction() | 大工程里很容易漏 |
工程里如果混装 PyQt5 和 PySide6,import 冲突很常见。安装时也是两套独立包名:PyQt5 用 pip 安装PyQt5,PySide6 用 pip 安装PySide6。我建议在正式工程里写一个qt_compat.py,统一的入口,后续切换时只改一个文件。
try: from PySide6.QtCore import Signal, Slot, QThread, QObject, Qt from PySide6.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton, QFileDialog from PySide6.QtGui import QImage, QPixmap, QAction QT_BINDING = "PySide6" except ImportError: from PyQt5.QtCore import pyqtSignal as Signal, pyqtSlot as Slot, QThread, QObject, Qt from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton, QFileDialog from PyQt5.QtGui import QImage, QPixmap, QAction QT_BINDING = "PyQt5"这样过渡期可以两套环境都跑。正式发布我建议固定 PySide6,两个包同时装进同一个虚拟环境,Qt 插件目录会互相干扰,轻则样式不对,重则启动崩溃。这是我的真实教训。
3.2 用 QThread 跑视频帧循环,界面标题栏才不会转圈
视频检测的耗时点有两个:cap.read()和detect()。如果直接在界面主线程里写 while 循环,窗口会变成“无响应”,系统会提示用户强行关闭。常见做法是把两个操作都放进 QThread,用信号把画好框的 QImage 发给主窗口。为了降低跨线程开销,我只在信号里传QImage,不传原始 numpy 数组。
import time import cv2 from qt_compat import QThread, QObject, Signal, QImage class VideoWorker(QObject): frame_ready = Signal(object) # 里面放 QImage,这里是 PyQt5/PySide6 通用写法 metrics = Signal(float, int) # fps, 当前目标数 finished = Signal() def __init__(self, detector, source, conf, iou, imgsz=640): super().__init__() self.detector = detector self.source = source self.conf = conf self.iou = iou self.imgsz = imgsz self._stop = False def stop(self): self._stop = True def run(self): cap = cv2.VideoCapture(self.source) if not cap.isOpened(): self.finished.emit() return prev = time.time() while not self._stop: ok, frame = cap.read() if not ok: break dets = self.detector.detect(frame, self.conf, self.iou, self.imgsz) frame = draw_boxes(frame, dets, self.detector.names) rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() self.frame_ready.emit(qimg) now = time.time() fps = 1.0 / (now - prev) prev = now self.metrics.emit(round(fps, 1), len(dets)) cap.release() self.finished.emit()启动线程的代码固定在主窗口里,看起来像这样:
self.thread = QThread() self.worker = VideoWorker(self.detector, video_path, conf, iou) self.worker.moveToThread(self.thread) self.thread.started.connect(self.worker.run) self.worker.finished.connect(self.thread.quit) self.worker.frame_ready.connect(self.show_frame) self.button_stop.clicked.connect(self.worker.stop) self.thread.start()这里moveToThread决定 worker 里的代码在哪个线程执行。frame_ready信号连接主窗口的槽函数,Qt 会自动在接收者线程里执行,所以显示 QLabel 的操作不会阻塞 worker。stop()只是把循环标志置位,线程会在读完当前帧后退出,比强行 terminate 安全得多。
4. 图像和视频检测的可视化:画框、显示置信度与结果保存
界面最后展示的是一张画好框的画面。画框代码不要散落在各个按钮事件里,我一般抽成draw_boxes,让图像检测、视频 worker 和保存结果共用同一个函数,这样字体粗细、颜色和标签格式才能统一。
4.1 画检测框和置信度标签的统一入口
def draw_boxes(img, dets, names, colors=None): if dets is None or len(dets) == 0: return img if colors is None: colors = {} for d in dets: x1, y1, x2, y2 = int(d[0]), int(d[1]), int(d[2]), int(d[3]) conf = float(d[4]) cls_id = int(d[5]) label = names[cls_id] if names and cls_id < len(names) else str(cls_id) label += f" {conf:.2f}" color = colors.get(cls_id) if color is None: color = tuple(int(x) for x in np.random.randint(0, 255, 3)) colors[cls_id] = color cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1) cv2.rectangle(img, (x1, y1 - th - 6), (x1 + tw, y1), color, -1) cv2.putText(img, label, (x1, y1 - 3), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA) return imgdets的列顺序在第 2 章统一成x1, y1, x2, y2, conf, cls,这里直接按位置索引。names来自Detector.names,一般是和训练时 class 顺序完全一致的列表。颜色用 BGR 传给 OpenCV,worker 最后会转成 RGB 给 Qt,不要提前转,否则画出来的框颜色全错。
4.2 图像检测的界面操作:选择文件、显示、保存
单张图片检测比视频简单,不需要长循环。但如果模型在 CPU 上跑,耗时有几百毫秒,界面还是会短时间无响应。我把可接受的范围定在 100ms 以内,超过的话最好再套一层 QThread。
def on_open_image(self): path, _ = QFileDialog.getOpenFileName( self, "选择检测图片", "", "Images (*.jpg *.png *.bmp)") if not path: return frame = cv2.imread(path) if frame is None: return dets = self.detector.detect(frame, self.conf, self.iou, self.imgsz) drawn = draw_boxes(frame.copy(), dets, self.detector.names) rgb = cv2.cvtColor(drawn, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape qimg = QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() self.label_result.setPixmap(QPixmap.fromImage(qimg).scaled( self.label_result.width(), self.label_result.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation )) self.current_result = drawn self.result_count = len(dets) def on_save_image(self): if self.current_result is None: return path, _ = QFileDialog.getSaveFileName( self, "保存结果", "", "PNG(*.png);;JPG(*.jpg)") if path: cv2.imwrite(path, self.current_result)显示时用scaled把大图压缩到 QLabel 尺寸,否则 4K 图片会把界面撑到只剩一块。保存时保存的是原始分辨率的画框图,跟 QLabel 上显示的那张缩放图无关。QImage.Format_RGB888要求数据是 RGB,所以一定要先cvtColor,否则颜色会明显偏蓝。
4.3 视频结果保存:编码器选择和保存流程
视频不要等全部检完再写文件,内存扛不住。正确姿势是在 worker 里创建VideoWriter,每帧检测完直接写盘。保存文件前先从视频流读取帧率和尺寸,避免最后写出的文件时长不对或黑屏。
fourcc = cv2.VideoWriter_fourcc(*"mp4v") fps = cap.get(cv2.CAP_PROP_FPS) or 25 w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer = cv2.VideoWriter(output_path, fourcc, fps, (w, h)) # 在 worker 的 while 循环里每帧做完 detect 和 draw_boxes 后: # writer.write(frame)编码器字符串直接影响文件能不能在普通播放器里播放。我整理过几个常用组合:
| 编码器 | 后缀 | 说明 |
|---|---|---|
| mp4v | .mp4 | 兼容性最好,多数环境直接可用 |
| XVID | .avi | 老设备兼容,文件体积偏大 |
| avc1 | .mp4 | H.264 编码,清晰度高,部分系统不支持 |
这里最容易被坑的是尺寸不一致。VideoWriter的宽高必须和实际写入的帧完全一致,如果你保存的是 QLabel 缩放后的 QPixmap,写出来的视频十有八九是黑屏。所以保存流程要在 worker 内部共用画框后的原始帧,界面只拿 QImage 做显示。
5. 收尾技巧:验证、调优与用界面做半自动标注
界面能跑起来只是开始。我在 YOLOv7 和 PyQt5-YOLOv5 这类工程里还会加三个小工具:模型预热、检测结果导出成 YOLO 格式、以及快速定位崩溃点。它们能省下大量排错时间。
5.1 模型预热和显存占用确认
def warmup(detector, imgsz=640): dummy = np.zeros((imgsz, imgsz, 3), dtype=np.uint8) for _ in range(2): detector.detect(dummy, conf=0.25, iou=0.45, imgsz=imgsz)第一次推理会把参数搬运到 GPU、建立 cuDNN 缓存,耗时会比后续推理高很多。在界面初始化时跑两次 dummy,能让视频第一帧不突然后掉帧。同时这个操作也能提前暴露显存不足:如果连一张 640 的零值图都跑不过,那就先降低imgsz。dummy 的通道顺序要和真实画面一样用 BGR,否则 YOLOv7 分支里的img[:, :, ::-1]没有被完整执行到,预热效果会打折扣。
5.2 把检测结果导出成 YOLO 标注文件
这是界面做半自动标注最快的方式。把第 2 章统一的检测结果从x1,y1,x2,y2,conf,cls转成归一化的center_x, center_y, width, height,按图片同名写入 txt,再交给 YOLOv5 训练自己的数据集。
def to_yolo_line(det, img_w, img_h): x1, y1, x2, y2, conf, cls = det xc = ((x1 + x2) / 2) / img_w yc = ((y1 + y2) / 2) / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h return f"{int(cls)} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}"我在界面里给这个功能加了一个min_conf输入框,默认 0.5。低于这个值的检测框不写入标注文件,避免把误检框也变成训练样本。如果是视频抽帧标注,记得每帧图片单独命名,不要覆盖同名文件。
5.3 三个高频故障的表现和解决方向
| 故障现象 | 根因 | 处理方向 |
|---|---|---|
| 打开界面后拖动窗口卡死 | 推理执行在 UI 线程 | 所有 detect 放进 QThread,worker 只发 QImage |
| 视频跑几分钟内存持续上涨 | 信号里传原始 numpy 大数组 | 转成 QImage 后.copy(),不要长期持有大数组 |
| 退出程序崩溃 | 线程还没退出就销毁窗口 | 先stop()再thread.quit(); thread.wait(2000) |
| PyQt5 和 PySide6 同时存在,import 报错 | 插件目录互相覆盖 | 用独立 venv,按QT_BINDING固定一套 |
界面的“酷炫”样式可以放到这些基础验证之后。PySide6 用qdarkstyle或自绘 QSS 都能换皮肤,但换皮不会替你做线程规划。一个更隐蔽的坑是保存视频的帧率来源:帧率要从cap.get(cv2.CAP_PROP_FPS)读,不要用检测耗时反推,否则导出视频的播放节奏会不对。把这个值在 worker 启动时读一次,和VideoWriter的 fps 参数对齐,最后落盘的文件时长才准确。
本文还有配套的精品资源,点击获取