news 2026/11/2 13:35:46

PyQt5+YOLOv5桌面检测器工程化拆解与避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PyQt5+YOLOv5桌面检测器工程化拆解与避坑

简介:面向刚接触 PyQt5 与 YOLOv5 的初学者,这是一套可直接运行和练手的目标检测 GUI 项目源码,围绕多目标检测场景,重点处理界面设计、后端推理逻辑分离、常用控件使用以及 PyTorch 模型调用等问题。压缩包共 112 个文件,约 83.46MB,包含 26 个 py 源码、33 个 pyc 编译文件、25 个 yaml 配置、3 个 pt 权重,另有运行脚本、界面文件、图片视频等演示素材;既保留 YOLOv5 的源码与配置体系,也提供成型的 GUI 工程,便于对照理解界面层与算法层如何衔接。已有 8884 人学习下载,说明其作为入门实战案例具有一定参考价值。读者可从中熟悉 PyQt5 常见控件、信号槽交互、界面与后端分层,理解检测流程、权重加载和结果展示,并借助现成工程完成从理论到实践的过渡。

1. 从能跑到能交付:PyQt5 + YOLOv5 桌面检测器该拆成几块

前段时间帮一个做视觉质检的小团队看他们的桌面工具,PyQt5 界面写得很规整,点下“开始检测”窗口就变灰,内存一路爬到 8G 才崩。这类 PyQt5 + YOLOv5 的 GUI 项目检索热度一直不低,真正能交付的却不多——PyTorch 推理、OpenCV 取流、Qt 事件循环三套机制挤在一个进程里,任何一处没摆对位置都会互相拖累。这份资源是一套完整可跑的桌面检测应用:Python 打底,YOLOv5 负责检测,PyQt5 做外壳,图片、视频、摄像头三种输入,置信度与 IOU 阈值实时可调,结果能导出。适合已经跑通 YOLOv5 官方 detect.py、想把模型包成别人双击就能用的工具的从业者,也适合刚摸 PyQt5 想找真实项目练手的人。下面按我拆代码的顺序讲。

2. 工程骨架与线程模型:把推理从主线程里赶出去

2.1 目录分层与依赖版本锁定

拿到这类源码包,我第一件事不是点运行,是看目录结构。GUI 项目最怕的就是“所有逻辑堆在一个 main_window.py 里”,改一个阈值都能牵出三处引用。能长期维护的拆法大致是这样,UI 只负责呈现,core 只负责算法,worker 负责调度:

detector_gui/ ├── app/ │ ├── main.py # 入口,只做 QApplication 启动和窗口装配 │ ├── ui/ │ │ └── main_window.py # 控件引用、信号连接 │ ├── core/ │ │ ├── detector.py # 模型加载、预处理、NMS、结果结构 │ │ ├── video_source.py # 图片/视频/摄像头统一读取 │ │ └── draw.py # QImage 转换与框绘制 │ └── worker/ │ └── infer_worker.py # QThread 推理循环 ├── weights/ │ └── yolov5s.pt ├── config/ │ └── settings.ini # QSettings 落盘位置 └── requirements.txt

依赖这块有个血泪经验:YOLOv5 的依赖适配窗口并不在最新版本上,所以别图省事直接pip install torch opencv-python拿最新。numpy 2.x 移除了np.float_、np.int0这些老别名,而 4.8 系列的 opencv-python 是按 numpy 1.x 的 ABI 编译的,混着装就会在cv2.rectangle之类的地方抛 AttributeError。

# CUDA 11.8 环境:torch 必须走官方索引,否则容易装成 CPU 版 pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python==4.8.1.78 pip install PyQt5==5.15.9 pip install numpy==1.24.4 pip install Pillow==10.1.0

逻辑说明:torch 2.0.1 与 torchvision 0.15.2 是官方配套版本,错配会在 import 阶段就报 undefined symbol;numpy 锁 1.24.4 是 1.x 里对 numpy 2.x 兼容性最好的一个尾巴版本;Pillow 只在你要用 PIL 画中文标签时才需要,用 QPainter 画可以省掉,但保留它能让源码里的中文注释表正常渲染。

参数说明:--index-url后面的 cu118 对应驱动版本,装之前先nvidia-smi看右上角 CUDA Version,驱动低于 11.8 就换成 cu117。装完用一行命令验明正身,别等跑到一半才发现用的是 CPU:

python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'CPU')"

2.2 QThread + 信号槽:让推理循环离开事件循环

界面发灰的根因只有一个:cv2.read()和模型前向跑在 Qt 主线程里,事件循环被阻塞,窗口自然不刷新。正确做法是把“取帧 + 推理”整体放进一个 QThread 子类,用信号把结果送回主线程绘制。

from PyQt5.QtCore import QThread, pyqtSignal import time class InferWorker(QThread): # 原始帧、检测框列表、类别统计;object 用于传 numpy,避免被 Qt 元类型拒绝 frame_ready = pyqtSignal(object, list, dict) fps_update = pyqtSignal(float) error = pyqtSignal(str) def __init__(self, detector, source, parent=None): super().__init__(parent) self.detector = detector self.source = source self._running = False self._conf = 0.25 self._iou = 0.45 def set_thresholds(self, conf, iou): # 普通方法而非信号:GIL 下写两个 float 属性是原子的,够用且没延迟 self._conf = float(conf) self._iou = float(iou) def stop(self): self._running = False if not self.wait(3000): self.terminate() # 卡在 read() 里出不来的兜底 def run(self): self._running = True t_last = time.time() try: while self._running: ok, frame = self.source.read() if not ok: break boxes, stats = self.detector.infer( frame, conf_thres=self._conf, iou_thres=self._iou) self.frame_ready.emit(frame, boxes, stats) now = time.time() self.fps_update.emit(1.0 / max(now - t_last, 1e-6)) t_last = now except Exception as e: self.error.emit(repr(e)) finally: self.source.release()

逻辑说明:frame_ready跨线程走的是 QueuedConnection,Qt 会把参数打包进事件队列,等主线程空闲时再执行槽函数,所以主线程永远不会被推理卡住。阈值走普通方法是因为每帧推理时 worker 自己会去读这两个属性,不需要经过信号队列,拖动滑块能立刻生效。

参数说明:wait(3000)是必须的,不 wait 直接退出程序会打印QThread: Destroyed while thread is still running,在 Windows 上偶发直接崩溃;terminate()只作为最后手段,它会跳过 finally 里的 release,摄像头句柄可能被占住。fps_update单独发是为了让 UI 上的帧率数字平滑,不要混在 frame_ready 里。

2.3 模型加载时机与打包后的路径

第二个高频翻车点是模型加载。常见写法是在“选择图片”的槽函数里torch.hub.load(...),结果是每换一张图就重新反序列化一次权重,SSD 上能等十几秒。权重应该跟着“选择模型”这个动作走,图片切换复用同一个实例。

import os, sys, torch def resource_path(rel: str) -> str: # PyInstaller 打包后资源被解到 _MEIPASS 临时目录 base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, rel) class Detector: _pool = {} # 类级缓存:(权重路径, 设备) -> 实例 def __init__(self, weight_path, device=""): from models.common import DetectMultiBackend self.device = torch.device( device or ("cuda:0" if torch.cuda.is_available() else "cpu")) self.fp16 = self.device.type != "cpu" self.model = DetectMultiBackend( weight_path, device=self.device, fp16=self.fp16) self.names = self.model.names # 从 ckpt 里读,不写死 COCO 映射 self.stride = int(self.model.stride) @classmethod def get(cls, weight_path, device=""): key = (os.path.abspath(weight_path), device) if key not in cls._pool: cls._pool[key] = cls(weight_path, device) return cls._pool[key]

逻辑说明:DetectMultiBackend是 YOLOv5 仓库里统一的后端入口,同一个类能吃 .pt、.onnx、.engine,这意味着后面换加速方案时 UI 层不用改代码。names从模型里取而不是在界面里维护一张 80 类字典,是为了换成自训练权重后类别标签还能对上。

参数说明:fp16只在 GPU 上开,CPU 上开半精度要么报"addmm_impl_cpu_" not implemented for 'Half',要么慢得离谱。stride后面做 letterbox 要用,yolov5s 是 32,换小模型可能不同,所以从模型读。resource_path里_MEIPASS的存在与否就是“开发环境 vs 打包环境”的分水岭,所有权重、图标、配置模板都该走这个函数。

线程和加载这两件事理顺之后,剩下 80% 的问题都能归到推理管线本身,下一章专门拆这段。

3. 推理管线落地:从 BGR 帧到 NMS 框的每一步

3.1 letterbox 预处理与坐标还原

框偏、框飘、视频里框越走越远,十有八九是预处理用了cv2.resize(frame, (640, 640))。直接拉伸会改变宽高比,而模型输出的坐标是在拉伸后的图上,你再用原图尺寸去乘,得到的必然是错的。标准解法是 letterbox:等比缩放后补灰边,推理完再把这层“补边”从坐标里减掉。

import cv2, numpy as np def letterbox(im, new_shape=640, color=(114, 114, 114), stride=32, auto=True): shape = im.shape[:2] if isinstance(new_shape, int): new_shape = (new_shape, new_shape) r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) if not auto: r = min(r, 1.0) # 小图不放大,保清晰度 new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = new_shape[1] - new_unpad[0] dh = new_shape[0] - new_unpad[1] if auto: dw, dh = np.mod(dw, stride) / 2, np.mod(dh, stride) / 2 else: dw, dh = dw / 2, dh / 2 if shape[::-1] != new_unpad: im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) im = cv2.copyMakeBorder(im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return im, (r, r), (dw, dh)

逻辑说明:auto=True时补边量取 stride 的模,是为了让左右、上下的填充在 stride 上对齐,避免 stride 采样时出现半格偏差。round(dh - 0.1)和round(dh + 0.1)这组写法看着别扭,实际是在规避 Python 的银行家舍入——当填充量恰好是 x.5 时,两边各自取整会让左右相差 1 像素,这个误差在 4K 图上会被放大成肉眼可见的偏移。

参数说明:color=(114,114,114)是 YOLOv5 训练时的填充灰,改别的颜色会让模型在边缘区域看到没见过的分布;auto=False适合小分辨率图片检测,强行放大只会更糊。

还原坐标用的是同一套增益和填充量,YOLOv5 里叫scale_boxes,逻辑很短但必须和预处理严格配对:

def scale_boxes(img1_shape, boxes, img0_shape, ratio_pad=None): if ratio_pad is None: gain = min(img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1]) pad = ((img1_shape[1] - img0_shape[1] * gain) / 2, (img1_shape[0] - img0_shape[0] * gain) / 2) else: gain, pad = ratio_pad[0][0], ratio_pad[1] boxes[:, [0, 2]] -= pad[0] boxes[:, [1, 3]] -= pad[1] boxes[:, :4] /= gain boxes[:, [0, 2]] = boxes[:, [0, 2]].clamp(0, img0_shape[1]) boxes[:, [1, 3]] = boxes[:, [1, 3]].clamp(0, img0_shape[0]) return boxes

逻辑说明:先减 pad 再除 gain,顺序反了就是另一种偏移。最后一步 clamp 是防止框超出画面边界——模型在补边区域也可能给出预测,不裁掉的话画到 QPainter 上会画出界。

参数说明:ratio_pad建议直接传 letterbox 返回的那两个值,别用函数内部自己算的,因为auto=True时的模运算补边和这里按比例算的补边会有亚像素差,图片上看不出来,视频里逐帧累积会轻微抖动。

3.2 置信度与 IOU 阈值的热更新和 NMS

滑块拖到 0.05 就满屏框、拖到 0.9 框全消失,这类体验问题多半是阈值只在开始检测时读了一次,或者 NMS 的 IoU 参数被硬编码。阈值应该是每帧读取的运行时变量,NMS 用 torchvision 的实现,比自己写的 Python 循环快一个数量级。

import torch from torchvision.ops import nms @torch.no_grad() def infer(self, frame_bgr, conf_thres=0.25, iou_thres=0.45, max_det=300): img, ratio_pad = letterbox(frame_bgr, 640, stride=self.stride) x = np.ascontiguousarray(img[:, :, ::-1].transpose(2, 0, 1)) # BGR->RGB, HWC->CHW t = torch.from_numpy(x).to(self.device) t = (t.half() if self.fp16 else t.float()) / 255.0 if t.ndim == 3: t = t[None] pred = self.model(t)[0] # (N, 5+nc),v6.0+ 输出已是 xyxy 像素坐标 pred = pred[pred[:, 4] >= conf_thres] if pred.shape[0] == 0: return [], {} cls_score, cls_id = pred[:, 5:].max(1, keepdim=True) scores = (pred[:, 4:5] * cls_score).squeeze(1) # obj * cls,两级阈值合成一个 boxes = scale_boxes((img.shape[0], img.shape[1]), pred[:, :4].clone(), frame_bgr.shape, ratio_pad) keep = nms(boxes, scores, iou_thres)[:max_det] out = [] for i in keep.tolist(): x1, y1, x2, y2 = boxes[i].tolist() out.append((x1, y1, x2, y2, float(scores[i]), int(cls_id[i, 0]))) stats = {} for _, _, _, _, _, c in out: name = self.names[int(c)] stats[name] = stats.get(name, 0) + 1 return out, stats

逻辑说明:pred[:, 4]是 objectness,pred[:, 5:]是类别分数,两者相乘才是最终置信度,只过滤其中一个会出现“高 obj 低类别”的误检。nms在还原后的原图坐标上做,因为 IoU 对坐标系不敏感但可视化要一致,统一在原图空间处理省一次换算。

参数说明:conf_thres默认 0.25,工业场景建议 0.4 起步,宁可漏检也别误检;iou_thres默认 0.45,密集小目标(比如一排螺丝)要提到 0.6,否则相邻目标被合并;max_det默认 300 足够,但做整图盘点类任务时要提到 1000。不同版本 YOLOv5 输出格式有差异,v5.0 附近输出的是 xywh,需要自己xywh2xyxy,跑之前先打印一次pred.shape和一行数值确认。

3.3 结果绘制与中文标签

OpenCV 的putText不支持中文,标签写中文会变成一串问号。既然外层是 Qt,用 QPainter 直接画在 QImage 上最省事,中文、圆角背景、抗锯齿都能拿到。

from PyQt5.QtGui import QImage, QPainter, QPen, QColor, QFont from PyQt5.QtCore import Qt, QRect def bgr_to_qimage(frame): rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch = rgb.shape # .copy() 必须加:numpy 数组出作用域后 data 指针就悬空了 return QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888).copy() def draw_boxes(qimg, boxes, names, color=(0, 255, 0)): painter = QPainter(qimg) painter.setRenderHint(QPainter.Antialiasing) painter.setPen(QPen(QColor(*color), 2)) painter.setFont(QFont("Microsoft YaHei", 10)) for x1, y1, x2, y2, score, cls_id in boxes: rect = QRect(int(x1), int(y1), int(x2 - x1), int(y2 - y1)) painter.drawRect(rect) text = f"{names.get(int(cls_id), str(cls_id))} {score:.2f}" tw = painter.fontMetrics().width(text) + 8 painter.fillRect(QRect(rect.x(), max(0, rect.y() - 22), tw, 20), QColor(0, 0, 0, 160)) painter.setPen(QColor(255, 255, 255)) painter.drawText(rect.x() + 4, max(14, rect.y() - 6), text) painter.setPen(QPen(QColor(*color), 2)) painter.end() return qimg

逻辑说明:.copy()是这段代码里唯一不能省的一行。QImage构造时只是持有指针,不接管内存,等rgb这个局部变量被 GC 回收,那块内存就还给分配器了,界面表现是随机花屏或者跑几分钟后段错误——这类问题调试起来像玄学,实际上原因非常明确。

参数说明:ch * w是 bytesPerLine,OpenCV 转过来的数组是紧密排列的,正好等于这个值;如果你从别的来源拿 QImage,行对齐不是 4 的倍数时这个参数必须单独算。字体建议显式指定,默认字体在部分 Windows 上渲染中文会糊。

绘制放在主线程的槽函数里做,worker 只发原始帧和结构化数据。这样即使绘制偶尔超时,也不会把推理节奏拖乱。

4. 界面与业务解耦:三种输入源、参数面板与导出

4.1 图片、视频、摄像头统一成一个接口

三种输入如果各写一套循环,代码会迅速膨胀成三份重复逻辑,而且改一处漏两处。统一成read()/release()两个方法后,worker 完全不需要知道当前是什么源。

import cv2, numpy as np class FrameSource: def __init__(self, kind, target, loop=False): self.kind = kind # "image" | "video" | "camera" self.loop = loop self._done = False self.cap = None if kind == "image": # cv2.imread 不支持中文路径,Windows 下必须走 imdecode self.frame = cv2.imdecode(np.fromfile(target, dtype=np.uint8), cv2.IMREAD_COLOR) else: src = int(target) if kind == "camera" else target backend = cv2.CAP_DSHOW if kind == "camera" else cv2.CAP_ANY self.cap = cv2.VideoCapture(src, backend) if kind == "camera": self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # 只留最新帧 def read(self): if self.kind == "image": if self.frame is None or self._done: return False, None self._done = True return True, self.frame.copy() ok, frame = self.cap.read() if not ok and self.loop and self.kind == "video": self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0) ok, frame = self.cap.read() return ok, frame def release(self): if self.cap is not None: self.cap.release() self.cap = None

逻辑说明:图片走单帧模式,读一次返回 False 让 worker 自然退出,UI 侧不用加分支判断。视频循环播放通过重置帧号实现,比重新VideoCapture便宜得多。

参数说明:CAP_DSHOW是 Windows 上打开摄像头的推荐后端,默认的 MSMF 在部分机型上首帧要等三四秒;BUFFERSIZE=1让驱动侧只保留最新一帧,不设的话实时检测的画面会比真实延迟一两秒,看起来像“卡住了”。摄像头索引从 0 试到 3,外接 USB 相机经常不是 0。

4.2 参数面板与 QSettings 持久化

阈值这类参数,用户在现场调好一次,下次打开还要再拖一遍是很烦的。QSettings 存成 ini 文件,既方便持久化,也方便现场直接用记事本改。

from PyQt5.QtCore import QSettings class ParamPanel: def __init__(self, ui): self.ui = ui self.settings = QSettings("config/settings.ini", QSettings.IniFormat) conf = float(self.settings.value("infer/conf", 0.25)) iou = float(self.settings.value("infer/iou", 0.45)) self.ui.confSlider.setRange(1, 99) # 整数滑块映射 0.01~0.99 self.ui.confSlider.setValue(int(conf * 100)) self.ui.iouSlider.setValue(int(iou * 100)) self.ui.confLabel.setText(f"{conf:.2f}") self.ui.iouLabel.setText(f"{iou:.2f}") self.ui.confSlider.valueChanged.connect( lambda v: self.ui.confLabel.setText(f"{v / 100:.2f}")) self.ui.confSlider.sliderReleased.connect(self._commit) def _commit(self): # 只在松开滑块时落盘,拖动过程中不写文件 self.settings.setValue("infer/conf", self.ui.confSlider.value() / 100) self.settings.setValue("infer/iou", self.ui.iouSlider.value() / 100) self.settings.sync() def current(self): return (self.ui.confSlider.value() / 100, self.ui.iouSlider.value() / 100)

逻辑说明:滑块用整数 1~99 映射浮点阈值,避免 QSlider 浮点精度的麻烦。工作线程通过set_thresholds拿这两个值,每帧读取一次,拖动时立刻生效,不需要重启检测。

参数说明:value("infer/conf", 0.25)的第二个参数是默认值,第一次运行没有配置文件时用它;sliderReleased而不是valueChanged触发落盘,是因为拖动一次会发几十个信号,每次都写文件在机械硬盘上会明显卡顿。ini 放在 config 目录而不是注册表,好处是拷走整个文件夹就能迁移配置。

4.3 结果导出与日志

导出这块的坑集中在中文路径和视频写入器。cv2.imwrite遇到中文路径直接返回 False 且不报错,很多人以为是权限问题;VideoWriter的尺寸和帧尺寸不一致时,生成的文件大小是 0 字节。

from pathlib import Path import cv2 def imwrite_unicode(path, img): ext = Path(path).suffix or ".jpg" ok, buf = cv2.imencode(ext, img) if ok: buf.tofile(str(path)) # tofile 走 Python IO,绕开 C++ 层的编码问题 return ok def open_writer(path, fps, size): fourcc = cv2.VideoWriter_fourcc(*"mp4v") writer = cv2.VideoWriter(str(path), fourcc, fps, size) if not writer.isOpened(): # 静默失败是常态,必须显式检查 raise RuntimeError(f"VideoWriter 打开失败: {path}, fourcc=mp4v") return writer

逻辑说明:imencode把图编码到内存 buffer,再用 numpy 的tofile写盘,这条路完全绕开 OpenCV 内部的字符编码处理,中文、空格、括号路径都能过。视频写入器必须在第一帧拿到之后才能确定size,所以创建时机要放在拿到首帧之后,而不是打开文件时。

参数说明:fps用源视频的CAP_PROP_FPS,拿不到就退回 25;size是(width, height),顺序反了同样是 0 字节文件。导出路径建议默认生成“原名 + _result”而不是覆盖原文件,现场误操作的概率比想象的高。

5. 避坑排查:五处最容易翻车的现场

5.1 界面卡死与内存暴涨

现象:点开始检测后窗口标题栏出现“未响应”,任务管理器里内存以每秒几十兆的速度上涨,一两分钟后进程被杀;或者运行三五分钟不崩,但画面延迟越来越大。

原因:一是推理跑在主线程,事件循环被cap.read()阻塞;二是frame_ready信号发出的速度超过主线程绘制速度,QueuedConnection 把每一帧的 numpy 数组都堆在事件队列里,队列越拉越长。第二点比第一点隐蔽,因为界面看着是“能动的”,只是越来越慢。

解决:推理必须进 QThread;同时在 worker 里加一个“上一帧是否已消费”的标志,主线程处理完再置回,未消费就跳过当前帧。实时检测场景丢帧不丢信息,保住延迟比保住每一帧重要得多。信号里传 QImage 而不是 ndarray 也能省掉主线程的一次转换,但要注意 QImage 的隐式共享在跨线程时不能随便改。

5.2 检测框错位与类别错乱

现象:拿手机竖拍的照片测试,框整体偏右下,或者框的大小只有实际目标的一半;换自己训练的权重后,类别显示成“person”“car”这类不相关标签,甚至直接数组越界崩溃。

原因:错位是预处理用了cv2.resize而不是 letterbox,宽高比被破坏,还原时又按原图尺寸直接缩放;类别错乱是 UI 里写死了一张 COCO 80 类的中文映射表,用模型输出的 class id 去查这张表,自训练模型的 id 体系和 COCO 对不上,超出 79 就 IndexError。

解决:预处理和还原严格用 letterbox + scale_boxes 一对函数,ratio_pad原样透传;类别名一律从model.names取,中文映射只作为显示层的 fallback,查不到就显示英文或class_3这样的原始 id,保证不崩。

5.3 打包、摄像头与首帧

现象:源码跑得好好的,PyInstaller 打包后双击闪退,或者界面能起来但一检测就提示找不到权重;另一种是摄像头能打开,但画面停留在第一帧不动,后面全是同一张图。

原因:闪退多是路径问题——代码里写的相对路径weights/yolov5s.pt在打包后按当前工作目录解析,而 exe 双击时的工作目录可能完全不是程序所在目录;torch 的动态库和 YOLOv5 的模型定义文件如果没有通过--add-data带进去,import 阶段就挂了。摄像头卡首帧一般是read()之后没有把帧交出去,或者用了 MMJPG 后端在某些机型上的缓冲策略问题。

解决:所有资源路径统一走resource_path,打包命令加--add-data "weights;weights"(Windows 用分号,Linux 用冒号),先用--console打一个带控制台的版本,让 traceback 能打出来,这一步能省掉大量瞎猜。摄像头换CAP_DSHOW后端并设BUFFERSIZE=1,同时确认read()的返回值被正确判断——ok为 False 时frame是空数组,直接送去推理会报维度错误。

5.4 显存泄漏与模型反复加载

现象:连续切换几次模型或者反复开始/停止检测,显存占用一节节往上涨,最后抛 CUDA out of memory。

原因:每次开始检测都torch.hub.load一次,旧模型没有被引用计数回收,因为信号槽的 lambda 或者闭包里还持有它;另一种是推理时忘了包torch.no_grad(),中间张量带着计算图不释放。

解决:模型加载走Detector.get的类级缓存,UI 只换权重路径不重建对象;推理函数一律加@torch.no_grad()装饰器;切换模型时显式del旧实例并torch.cuda.empty_cache(),虽然空缓存不是万能药,但能立刻看到显存有没有真的降下来。

5.5 PyInstaller 大包与杀软误报

现象:打出来的 exe 有 2G 多,启动要十几秒;或者在自己机器上正常,拷到别人电脑上被安全软件直接拦掉。

原因:把整个 torch 和 CUDA 运行库全打进去了,包括训练用的torch.utils.tensorboard、torch.distributed这些根本用不到的部分;单文件模式(--onefile)每次启动都要把 2G 解压到临时目录,慢是必然的。

解决:用--onedir出目录版,启动快且便于替换权重;--exclude-module排掉 tensorboard、matplotlib、IPython 这些可选依赖;如果只做推理,导出 ONNX 后用 onnxruntime 跑,包体积能砍到几分之一,也不再有 CUDA 那一堆动态库的兼容问题。杀软这块没法完全避免,签名和固定哈希能缓解,现场部署更稳的做法是把目录整体拷贝而非做成安装包。

6. 提速与验收:把 FPS 从个位数拉到可用

6.1 三条提速路径的实际收益

GUI 里最容易感知的性能差异不在算法,在“有没有把没用的活干掉”。我一般按这个顺序调:先把 QPixmap 缩放做掉,再做半精度,最后才考虑换后端。

QLabel 显示 4K 图时,每帧QPixmap.fromImage加默认缩放能吃掉 40ms 以上,改成scaled(label.size(), Qt.KeepAspectRatio, Qt.FastTransformation),并且只在 QLabel 尺寸变化时重算缩放参数,这一项在 1080p 屏上通常能把帧率翻倍。半精度在 GPU 上稳赚,fp16=True大概有 20% 到 40% 的提升,代价是极小目标的置信度会略微下降。再往上就是导出 ONNX 用 onnxruntime-gpu,或者更激进的 TensorRT engine:

# 从 YOLOv5 仓库根目录导出,dynamic 用于兼容非 640 输入 python export.py --weights weights/yolov5s.pt --include onnx --opset 12 --dynamic # TensorRT 需要本机装好对应 CUDA/cuDNN,导出后是硬件绑定,不能换机器直接用 python export.py --weights weights/yolov5s.pt --include engine --device 0 --half

参数说明:--opset 12是兼容性较好的选择,高于本机 onnxruntime 支持上限会加载失败;--dynamic会让输入尺寸可变,代价是部分算子拿不到最优 kernel,固定 640 输入时去掉它更快;engine 是硬件绑定的,换显卡就得重导,所以现场部署的源码包里最好把 onnx 和导出脚本一起放进去。

方案1080p 摄像头实测 FPS部署成本适用场景
yolov5s .pt + CPU3~6最低演示、离线图片批处理
yolov5s .pt + fp16 GPU45~70需显卡主流桌面检测
yolov5s ONNX + onnxruntime-gpu55~85需装运行时免装 torch,包体小
yolov5s engine + TensorRT90+需匹配驱动固定机型量产

6.2 一份照着走就能过的验收清单

功能验收不用多复杂,按现场真实操作路径走一遍,八成问题会自己冒出来:图片、视频、摄像头三种源各跑一次,中间不重启程序连续切换五次,观察内存和显存曲线是否回落;置信度拖到 0.99 再拖回 0.01,看框是否实时变化而不是要重新点开始;用带中文和空格的路径导出图片和视频,检查文件大小非零;拔掉摄像头再点开始,确认程序给出提示而不是闪退;断网启动一次,确认没有隐藏的联网下载。

我以前吃过一次亏:开发机上一切正常,到现场第一台机器就白屏,查了半天是那台机器没有独显,代码里硬编码了device="cuda:0",异常被一个空的except吞掉了。从那以后我每次交付这类 GUI 检测工具,

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/2 13:35:18

全生命周期风控:智能闭环管理与技术实现

1. 项目概述:全生命周期风控范式的价值与挑战"全生命周期"风控范式正在重塑现代企业的风险管理体系。这个理念将传统割裂的雇前背景调查、在职行为监控和离职后管理整合为一个有机整体,形成从人才引进到持续管理的完整闭环。在金融、互联网、高…

作者头像 李华
网站建设 2026/11/2 13:33:23

从工具到管家:智能效率平台的技术架构与实践

1. 项目概述:从工具型应用到服务型平台的转型"从秘书到管家"这个产品定位转变,本质上反映了当代效率类应用从单一功能工具向综合服务平台的进化路径。五年前我刚入行时,市面上大多数效率应用还停留在"待办清单日历提醒"的…

作者头像 李华
网站建设 2026/11/2 13:33:21

华为MetaERP架构解析:事件驱动与实时数据处理

1. 华为MetaERP的架构革新与业务价值 华为MetaERP系统通过"交易即核算,核算即报告,报告即分析"的设计理念,彻底重构了传统ERP系统的数据处理流程。这套系统最显著的特点是实现了业务操作与财务处理的实时联动,让每个业务…

作者头像 李华
网站建设 2026/11/2 13:33:15

T6-WMS仓储管理系统的3+2+1架构解析

1. 什么是T6-WMS的"321"架构 在仓储管理领域,T6-WMS(Warehouse Management System)提出的"3条作业流2个异常池1套绩效塔"架构,正在成为提升仓储运营效率的新范式。这套架构不是简单的功能堆砌,而是…

作者头像 李华
网站建设 2026/11/2 13:33:14

Odiff:高性能图片对比工具在UI测试中的应用

1. 为什么需要专业的图片对比工具在日常开发工作中,图片对比是一个看似简单但实际复杂的需求。无论是前端开发中的UI回归测试、设计稿比对,还是图像处理算法验证,都需要精确的图片差异检测。传统做法中,开发者通常会选择ImageMagi…

作者头像 李华
网站建设 2026/11/2 13:32:15

腾讯开源WeKnora:Go语言打造RAG+Agent+Wiki企业知识管理框架

1. 为什么我会盯上 WeKnora 这个项目第一次看到 WeKnora 这个名字,是在一个 Go 语言技术群里。有人甩了个链接,说“腾讯拿 Go 写了个 RAG Agent Wiki 三合一的东西”,群里瞬间炸了锅。做企业知识库的、搞 RAG 应用的、玩 Agent 框架的&…

作者头像 李华