简介这是一套面向PyQt5与YOLOv5初学者的GUI目标检测实战项目专为刚接触GUI开发和目标检测算法的学习者设计帮助用户快速搭建可运行的YOLOv5可视化检测界面掌握前后端分离开发模式。资源共112个文件包含26个核心Python源码含主程序、检测逻辑与UI事件处理、25个YOLOv5配置yaml文件、3个预训练.pt模型、1个.ui界面文件、6个Shell脚本用于环境配置与推理启动、以及jpg/png测试图像、mp4演示视频和Dockerfile等整体包大小83.46MB结构完整、开箱即用。已有8875人学习下载热度高且反馈积极。读者可直接复现多目标实时检测功能深入理解PyQt5控件协同机制、信号槽绑定、线程安全更新界面等关键实践并通过screenshot.gif、test.mp4等可视化素材直观掌握运行效果与交互流程。1. 把 YOLOv5 推理封装进 PyQt5 窗口不是“GUIAI”拼凑而是可部署、可调试、带状态反馈的完整推理链你花三天训好一个 YOLOv5s 模型导出.pt文件信心满满想做个界面给产线同事用——结果一写 PyQt5 就卡在「图片怎么传进模型」「检测框怎么画到 QLabel 上」「GPU 显存爆了怎么优雅降级」。这不是 GUI 和 AI 的简单叠加而是一条从图像加载、预处理、模型前向、后处理、坐标映射、UI 渲染到异常捕获的完整数据流。本资源是一套已实测通过、含完整源码结构、支持 CPU/GPU 自动切换、带实时 FPS 显示与日志回显、适配 OpenCV 图像通道与 PyQt5 QImage 格式转换的 PyQt5 YOLOv5 实战工程。它不教你怎么装 PyQt5也不讲 YOLOv5 的损失函数只解决你在真实交付场景中会立刻撞上的问题模型加载失败、QLabel 黑屏、检测框错位、多图连续推理内存泄漏、以及——最要命的——用户点“开始检测”后界面假死三秒却无任何提示。适合刚跑通detect.py想落地、或正在被甲方催“能不能加个按钮”的 Python 工程师也适合需要快速验证算法效果的算法同学。2. 构建可复现的运行环境PyTorch、YOLOv5 与 PyQt5 的版本协同策略2.1 为什么必须锁定 PyTorch 1.13.1 CUDA 11.7——YOLv5 官方兼容性黑匣子YOLOv5 官方requirements.txt声明支持 PyTorch ≥1.7但实际在 PyQt5 场景下高版本 PyTorch如 2.0与 Qt 的 OpenGL 上下文存在隐式冲突尤其在 Windows 上触发QOpenGLContext::swapBuffers()异常而低版本如 1.9又无法调用torch.compile()后引入的某些算子优化路径。经实测PyTorch 1.13.1 CUDA 11.7 是当前最稳组合它能完整支持 YOLOv5 v6.2/v7.0 的model.half()、model(torch.half)推理加速同时与 PyQt5 5.15.9 的QOpenGLWidget兼容无报错。安装命令如下# 清理旧环境关键避免 pip 与 conda 混装导致 cuda 库路径错乱 pip uninstall torch torchvision torchaudio -y # 安装指定版本注意CUDA 版本必须与本机 nvidia-smi 输出一致 pip install torch1.13.1cu117 torchvision0.14.1cu117 torchaudio0.13.1 --extra-index-url https://download.pytorch.org/whl/cu117提示执行nvidia-smi查看驱动支持的最高 CUDA 版本如显示CUDA Version: 11.8则选cu117或cu118若为12.1请降级驱动或改用cu118 PyTorch 1.13.1 —— 不要强行用cu121YOLOv5 v7.0 尚未完全适配。2.2 PyQt5 安装避坑为什么pip install pyqt5会失败——系统级 Qt 库冲突真相直接pip install pyqt5在 Ubuntu 22.04 或 macOS Monterey 后常报Could not find QtCore根源是系统自带 Qt5如 Ubuntu 的libqt5core5a与 pip 安装的 PyQt5 二进制包存在 ABI 不兼容。正确做法是绕过系统 Qt强制使用 PyQt5 自带的 Qt 运行时# Ubuntu / Debian先卸载系统 qt5-default sudo apt remove qt5-default libqt5core5a -y # 再安装--no-cache-dir 防止 pip 缓存损坏的 wheel pip install --no-cache-dir PyQt55.15.9macOS 用户需额外设置# 安装后立即执行否则启动时报 Library not loaded: rpath/libQt5Core.dylib export QT_QPA_PLATFORM_PLUGIN_PATH$(python -c import PyQt5; print(PyQt5.__path__[0] /Qt/plugins))2.3 YOLOv5 代码库的轻量化裁剪删掉 80% 无关文件只留models/,utils/,detect.py核心三件套官方 YOLOv5 仓库含train.py、val.py、export.py等 10 脚本但 GUI 推理只需models/yolo.py定义模型结构含Detect层models/common.pyConv,Bottleneck,C3,SPPF等基础模块utils/general.pynon_max_suppression,scale_coords,xyxy2xywhutils/plots.pyplot_one_box用于绘制检测框非必须但强烈建议保留删除以下目录可减少 320MB 占用且不影响推理data/测试图片可单独放assets/weights/模型权重独立存放runs/训练日志GUI 不需要tests/,docs/,.github/最终项目结构精简为yolov5-pyqt-gui/ ├── main.py # PyQt5 主窗口 ├── detector.py # 封装 YOLOv5 推理逻辑核心 ├── models/ # 仅保留 yolo.py, common.py ├── utils/ # 仅保留 general.py, plots.py ├── assets/ │ ├── test.jpg # 测试图 │ └── yolov5s.pt # 权重文件CPU/GPU 通用 └── requirements.txt3. detector.pyYOLOv5 推理引擎的四层封装设计3.1 第一层模型加载器ModelLoader——支持 .pt / .onnx / .engine 三种格式自动识别YOLOv5 官方导出的.pt是 TorchScript 格式但生产环境常需 ONNX 或 TensorRT 加速。detector.py的ModelLoader类通过文件后缀自动路由加载逻辑# detector.py import torch from models.yolo import Model from utils.general import check_img_size class ModelLoader: def __init__(self, weights_path: str, device: str auto): self.weights_path weights_path self.device self._auto_device() if device auto else device self.model None self.stride 32 # YOLOv5 默认 stride def _auto_device(self) - str: 自动选择 device优先 GPU无 GPU 则 fallback 到 CPU if torch.cuda.is_available(): return cuda:0 else: print(⚠️ CUDA not available, using CPU) return cpu def load(self): suffix self.weights_path.split(.)[-1].lower() if suffix pt: self._load_pt() elif suffix onnx: self._load_onnx() elif suffix engine: # TensorRT self._load_trt() else: raise ValueError(fUnsupported weights format: {suffix}) return self def _load_pt(self): # 关键禁用 gradient启用 eval 模式设置 half 精度GPU 下 self.model torch.load(self.weights_path, map_locationself.device)[model].float() self.model.to(self.device).eval() if self.device ! cpu: self.model.half() # 半精度加速 # 获取 stride从 model.stride 或 config 推断 self.stride int(self.model.stride.max()) if hasattr(self.model, stride) else 32参数说明map_locationself.device确保权重加载到目标设备model.half()仅对 GPU 有效CPU 下调用会报错故需if self.device ! cpu判断self.stride用于后续图像 resize 对齐错误值会导致检测框偏移。3.2 第二层图像预处理器ImagePreprocessor——OpenCV → Torch.Tensor 的零拷贝通道转换PyQt5 读图用QImageOpenCV 用cv2.imread()YOLOv5 输入要求torch.float32 [1,3,H,W]。常见错误是cv2.cvtColor(img, cv2.COLOR_BGR2RGB)后直接torch.from_numpy()但QImage默认是Format_RGB888OpenCV 是BGR混用导致颜色反转、模型误检。ImagePreprocessor统一以 OpenCV 为入口强制 BGR→RGB→归一化→permute# detector.py import cv2 import numpy as np import torch class ImagePreprocessor: def __init__(self, img_size: int 640, stride: int 32): self.img_size img_size self.stride stride def preprocess(self, img_bgr: np.ndarray) - torch.Tensor: # Step 1: Resize with stride alignment保持长宽比pad 黑边 h0, w0 img_bgr.shape[:2] r self.img_size / max(h0, w0) if r ! 1.0: interp cv2.INTER_AREA if r 1.0 else cv2.INTER_LINEAR img_bgr cv2.resize(img_bgr, (int(w0 * r), int(h0 * r)), interpolationinterp) # Step 2: Convert BGR to RGB and normalize to [0,1] img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) img_norm img_rgb.astype(np.float32) / 255.0 # Step 3: Pad to stride-multiple关键YOLOv5 要求输入尺寸为 stride 整数倍 h, w img_norm.shape[:2] padh (self.stride - h % self.stride) % self.stride padw (self.stride - w % self.stride) % self.stride img_padded np.pad(img_norm, ((0, padh), (0, padw), (0, 0)), constant) # Step 4: HWC → CHW → torch.Tensorzero-copy img_tensor torch.from_numpy(img_padded.transpose(2, 0, 1)).unsqueeze(0) return img_tensor # shape: [1,3,H,W]逻辑说明padh/padw计算确保H % stride 0 and W % stride 0否则model(img_tensor)会因 grid 尺寸不匹配而报错unsqueeze(0)添加 batch 维度YOLOv5 输入必须是 4D tensortranspose(2,0,1)将(H,W,C)→(C,H,W)这是 PyTorch 标准格式。3.3 第三层推理执行器InferenceRunner——带 warmup、timing、异常捕获的健壮调用GUI 最怕“点一下就卡死”InferenceRunner在forward()外包裹三层防护# detector.py import time from typing import Tuple, List, Dict class InferenceRunner: def __init__(self, model: torch.nn.Module, device: str, half: bool True): self.model model self.device device self.half half and device ! cpu # Warmup首次推理前执行 dummy input避免 CUDA 初始化延迟 self._warmup() def _warmup(self): dummy torch.zeros((1, 3, 640, 640), deviceself.device) if self.half: dummy dummy.half() _ self.model(dummy) def forward(self, img_tensor: torch.Tensor) - Tuple[torch.Tensor, float]: 执行推理并返回 (pred, inference_time_ms) pred shape: [1, num_boxes, 6] → [x1,y1,x2,y2,conf,cls_id] try: start time.time() if self.half: img_half img_tensor.half() pred self.model(img_half) else: pred self.model(img_tensor) # YOLOv5 输出是 list of tensors取第一个batch1 pred pred[0] if isinstance(pred, list) else pred # NMS 后处理YOLOv5 默认 conf_thres0.25, iou_thres0.45 from utils.general import non_max_suppression pred_nms non_max_suppression( pred, conf_thres0.25, iou_thres0.45, classesNone, agnosticFalse )[0] # [num_det, 6] infer_time (time.time() - start) * 1000 return pred_nms, infer_time except Exception as e: print(f❌ Inference failed: {str(e)}) return torch.empty((0, 6)), 0.0参数说明conf_thres0.25是 YOLOv5 默认置信度阈值太低0.1会出大量误检框iou_thres0.45控制 NMS 重叠抑制强度过高0.7会导致同一物体多个框残留classesNone表示检测所有类别若只想检测 person设为[0]COCO 中 person 是 class 0。3.4 第四层结果后处理器ResultPostprocessor——坐标反变换与可视化准备YOLOv5 输出是相对于640x640输入的归一化坐标需映射回原始图像像素坐标。ResultPostprocessor不仅做scale_coords还生成QPixmap可绘图的cv2.Mat# detector.py from utils.general import scale_coords from utils.plots import plot_one_box import cv2 class ResultPostprocessor: def __init__(self, original_shape: Tuple[int, int], resized_shape: Tuple[int, int]): self.orig_h, self.orig_w original_shape self.resized_h, self.resized_w resized_shape def postprocess(self, pred: torch.Tensor, img_bgr: np.ndarray) - np.ndarray: pred: [N,6] tensor, each row [x1,y1,x2,y2,conf,cls] Returns: img_bgr with boxes drawn (BGR format) if len(pred) 0: return img_bgr.copy() # Step 1: Scale coords back to original image size pred_np pred.cpu().numpy() pred_coords pred_np[:, :4] # [x1,y1,x2,y2] # scale_coords 返回的是 [x1,y1,x2,y2] 归一化坐标需乘以原图尺寸 pred_scaled scale_coords( (self.resized_h, self.resized_w), pred_coords, (self.orig_h, self.orig_w) ).round().astype(int) # Step 2: Draw boxes on original img_bgr for i, (x1, y1, x2, y2) in enumerate(pred_scaled): conf pred_np[i, 4] cls_id int(pred_np[i, 5]) label f{self._class_name(cls_id)} {conf:.2f} plot_one_box([x1, y1, x2, y2], img_bgr, labellabel, color(0,255,0), line_thickness2) return img_bgr def _class_name(self, cls_id: int) - str: # COCO 80类此处简化为前5类实际应读取 names.yaml names [person, bicycle, car, motorcycle, airplane] return names[cls_id] if cls_id len(names) else fclass_{cls_id}注意scale_coords是 YOLOv5 官方函数它内部做了 resize ratio 和 pad offset 的逆运算绝不能手动用(x1/resized_w)*orig_w粗暴缩放否则 pad 区域的坐标会错位。4. main.pyPyQt5 主窗口的信号-槽解耦设计与线程安全实践4.1 UI 布局QVBoxLayout QGroupBox 分区拒绝 QWidget 嵌套灾难新手常把QLabel、QPushButton全塞进一个QWidget结果 resize 时控件堆叠。本方案采用QVBoxLayout主布局分三组QGroupBox# main.py from PyQt5.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QPushButton, QFileDialog, QTextEdit) from PyQt5.QtCore import Qt, QThread, pyqtSignal from PyQt5.QtGui import QPixmap, QImage import sys class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(YOLOv5 PyQt5 Detector) self.setGeometry(100, 100, 1200, 800) # Central widget main layout central_widget QWidget() self.setCentralWidget(central_widget) main_layout QVBoxLayout(central_widget) # Group 1: Image display self.image_group QGroupBox(Detection Result) image_layout QVBoxLayout() self.image_label QLabel() self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setMinimumSize(800, 600) image_layout.addWidget(self.image_label) self.image_group.setLayout(image_layout) # Group 2: Control buttons self.ctrl_group QGroupBox(Controls) ctrl_layout QHBoxLayout() self.load_btn QPushButton( Load Image) self.detect_btn QPushButton( Run Detection) self.clear_btn QPushButton( Clear) ctrl_layout.addWidget(self.load_btn) ctrl_layout.addWidget(self.detect_btn) ctrl_layout.addWidget(self.clear_btn) self.ctrl_group.setLayout(ctrl_layout) # Group 3: Log FPS self.log_group QGroupBox(Status Log) log_layout QVBoxLayout() self.fps_label QLabel(FPS: -- | Device: --) self.log_text QTextEdit() self.log_text.setReadOnly(True) log_layout.addWidget(self.fps_label) log_layout.addWidget(self.log_text) self.log_group.setLayout(log_layout) # Add all groups to main layout main_layout.addWidget(self.image_group) main_layout.addWidget(self.ctrl_group) main_layout.addWidget(self.log_group)设计理由QGroupBox提供视觉分区与标题QVBoxLayout保证垂直流式布局setMinimumSize防止QLabel被压缩至不可见QTextEdit替代print()实现日志滚动查看。4.2 信号-槽绑定避免self.detect_btn.clicked.connect(self.run_detection)的阻塞陷阱直接connect到耗时函数会导致 GUI 假死。必须用QThread将推理移到后台# main.py class DetectionWorker(QThread): finished pyqtSignal(object, float) # (annotated_img_bgr, inference_time_ms) error pyqtSignal(str) def __init__(self, detector, img_bgr): super().__init__() self.detector detector self.img_bgr img_bgr def run(self): try: # 调用 detector.py 的完整流水线 annotated_img self.detector.run(self.img_bgr) self.finished.emit(annotated_img, self.detector.last_infer_time) except Exception as e: self.error.emit(str(e)) # 在 MainWindow.__init__ 中绑定 self.detect_btn.clicked.connect(self.start_detection) self.worker None def start_detection(self): if not hasattr(self, current_img_bgr) or self.current_img_bgr is None: self.log(⚠️ Please load an image first!) return # 禁用按钮防重复点击 self.detect_btn.setEnabled(False) self.load_btn.setEnabled(False) # 创建 worker 并启动 self.worker DetectionWorker(self.detector, self.current_img_bgr) self.worker.finished.connect(self.on_detection_finished) self.worker.error.connect(self.on_detection_error) self.worker.start() def on_detection_finished(self, annotated_img_bgr: np.ndarray, infer_time_ms: float): # 将 OpenCV BGR 图转为 QPixmap 显示 h, w, ch annotated_img_bgr.shape bytes_per_line ch * w q_img QImage(annotated_img_bgr.data, w, h, bytes_per_line, QImage.Format_RGB888) q_img q_img.rgbSwapped() # BGR → RGB pixmap QPixmap.fromImage(q_img) self.image_label.setPixmap(pixmap.scaled( self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation )) # 更新 FPS 和日志 fps 1000 / infer_time_ms if infer_time_ms 0 else 0 self.fps_label.setText(fFPS: {fps:.1f} | Device: {self.detector.device}) self.log(f✅ Detection done in {infer_time_ms:.1f}ms ({fps:.1f} FPS)) # 恢复按钮 self.detect_btn.setEnabled(True) self.load_btn.setEnabled(True) def on_detection_error(self, error_msg: str): self.log(f❌ Detection error: {error_msg}) self.detect_btn.setEnabled(True) self.load_btn.setEnabled(True)关键点QThread子类必须重写run()不能直接start()一个函数pyqtSignal用于跨线程通信finished传递结果error传递异常q_img.rgbSwapped()是必须的因为 OpenCV 是 BGRQImage 默认 RGB不 swap 会显示紫绿色调。4.3 图像加载与状态管理self.current_img_bgr作为唯一真相源所有操作围绕self.current_img_bgr展开避免QLabel.pixmap()取图导致的空指针def load_image(self): file_path, _ QFileDialog.getOpenFileName( self, Select Image, , Image Files (*.jpg *.jpeg *.png) ) if not file_path: return try: # 用 OpenCV 读取保证与 detector.py 一致 self.current_img_bgr cv2.imread(file_path) if self.current_img_bgr is None: raise ValueError(Failed to load image) # 显示原图未检测 h, w, ch self.current_img_bgr.shape bytes_per_line ch * w q_img QImage(self.current_img_bgr.data, w, h, bytes_per_line, QImage.Format_BGR888) pixmap QPixmap.fromImage(q_img) self.image_label.setPixmap(pixmap.scaled( self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation )) self.log(f️ Loaded: {file_path} ({w}x{h})) except Exception as e: self.log(f❌ Load failed: {e}) def clear_display(self): self.image_label.clear() self.image_label.setText(No image loaded) self.current_img_bgr None self.log( Display cleared)血泪经验cv2.imread()返回None时静默失败必须if self.current_img_bgr is None检查QImage.Format_BGR888对应 OpenCV 的 BGR 格式错用RGB888会导致颜色错乱。5. 避坑指南PyQt5 YOLOv5 实战中踩过的 5 个真实坑5.1 现象QLabel 显示黑屏但print(img_bgr.shape)正常原因QImage构造时bytesPerLine计算错误。OpenCV 图像img_bgr.strides[0]可能大于ch * w因内存对齐直接ch * w会导致QImage读取越界显示为黑或花屏。解决改用img_bgr.data.nbytes // img_bgr.shape[0]计算每行字节数或更稳妥地用cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB).data后再构造QImage。5.2 现象GPU 推理速度比 CPU 还慢nvidia-smi显示 GPU 利用率 0%原因PyTorch 默认使用torch.backends.cudnn.benchmark False小尺寸输入如 320x320下 cuDNN 未启用最优卷积算法。解决在ModelLoader.load()后添加torch.backends.cudnn.benchmark True # 启用 cuDNN auto-tuner torch.backends.cudnn.enabled True注意此设置对固定尺寸输入如 640x640有效若图像尺寸动态变化可能因反复 benchmark 导致首次推理变慢。5.3 现象连续点击“Run Detection”多次内存占用持续上涨最终 OOM原因QPixmap缓存未释放且cv2.imread()读取的np.ndarray被QImage持有引用Python GC 无法回收。解决在on_detection_finished开头强制释放# 释放旧 pixmap if hasattr(self, last_pixmap) and self.last_pixmap is not None: self.last_pixmap None # 显式删除 QImage 数据 q_img None并在load_image中每次新读图前self.current_img_bgr None。5.4 现象检测框位置严重偏移人物头顶出现框脚底消失原因scale_coords输入的img_size参数错误。例如resized_shape(320,480)但传入scale_coords((640,640), ...)。解决scale_coords第一个参数必须是实际 resize 后的尺寸即preprocessor.resized_h, preprocessor.resized_w而非模型img_size参数。5.5 现象打包成 exe 后启动报错ModuleNotFoundError: No module named utils原因PyInstaller 默认不递归打包utils/目录下的.py文件尤其当utils/plots.py被detector.py动态 import 时。解决打包时添加--add-data utils;utilsWindows或--add-data utils:utilsmacOS/Linux并在main.py开头加import sys import os if getattr(sys, frozen, False): # frozen base_path sys._MEIPASS else: # unfrozen base_path os.path.dirname(os.path.abspath(__file__)) sys.path.append(os.path.join(base_path, utils))6. 进阶技巧用 QTimer 实现“实时摄像头流检测”与帧率自适应降帧6.1 从单图检测升级到视频流QTimer cv2.VideoCapture 的最小可行方案QTimer每 33ms≈30FPS触发一次capture_frame但 YOLOv5 推理耗时波动大GPU 下 20~50ms直接硬等会导致卡顿。核心技巧是“推理完成才取下一帧”而非固定间隔# main.py from PyQt5.QtCore import QTimer class MainWindow(QMainWindow): def __init__(self): # ... previous init ... self.cap None self.timer QTimer() self.timer.timeout.connect(self.capture_and_detect) self.is_streaming False def start_stream(self): if self.cap is None: self.cap cv2.VideoCapture(0) # 或视频文件路径 self.is_streaming True self.timer.start(1) # 1ms实际由推理完成时间决定 def capture_and_detect(self): if not self.is_streaming or self.cap is None: return ret, frame self.cap.read() if not ret: self.log( Camera stream ended) self.stop_stream() return # 关键只在上一帧推理完成时才处理新帧避免队列堆积 if not hasattr(self, is_busy) or not self.is_busy: self.is_busy True self.current_img_bgr frame.copy() # 避免 video buffer 覆盖 self.start_detection() # 复用之前的 detection logic def on_detection_finished(self, annotated_img_bgr, infer_time_ms): # ... same as before ... self.is_busy False # 标记空闲允许下一帧 def stop_stream(self): self.is_streaming False self.timer.stop() if self.cap: self.cap.release() self.cap None self.log(⏹️ Stream stopped)逻辑说明timer.start(1)不是“每1ms执行”而是 Qt 的最小调度间隔self.is_busy是简易锁确保同一时刻最多一帧在推理frame.copy()防止cap.read()覆盖上一帧内存。6.2 帧率自适应当推理超时自动跳过中间帧保障显示流畅若infer_time_ms 100即 10FPS用户感知明显卡顿。此时应主动丢帧而非堆积# 在 capture_and_detect 中增强 def capture_and_detect(self): # ... cap.read() ... if not ret: return # 计算距上次推理完成的时间 now time.time() if not hasattr(self, last_infer_end): self.last_infer_end now # 若距上次完成 100ms跳过此帧保 FPS if now - self.last_infer_end 0.1: # 100ms 10FPS 下限 return self.last_infer_end now self.current_img_bgr frame.copy() self.start_detection()6.3 性能监控表格不同配置下的实测 FPS 对比RTX 3060 笔记本配置项输入尺寸设备half 精度平均 FPS备注YOLOv5s320x320GPU✅128适合移动端小目标漏检率↑YOLOv5s640x640GPU✅62官方推荐平衡精度与速度YOLOv5s640x640GPU❌38full precision精度略升但速度↓38%YOLOv5s640x640CPU—3.2i7-11800H仅作备用YOLOv5m640x640GPU✅31精度↑1.2mAP速度↓50%从那以后我每次交付 GUI 检测工具都强制走一遍这三步① 用nvidia-smi确认 GPU 是否被其他进程占用② 用cv2.imread读图后print(img_bgr.dtype, img_bgr.shape)验证数据完整性③ 在on_detection_finished里加一行print(fGPU memory: {torch.cuda.memory_allocated()/1024**2:.0f} MB)监控显存。这三步做完90% 的“黑屏”“卡死”“框错位”问题当场定位。希望帮到你。本文还有配套的精品资源点击获取