
简介面向刚接触 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 torch2.0.1 torchvision0.15.2 --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python4.8.1.78 pip install PyQt55.15.9 pip install numpy1.24.4 pip install Pillow10.1.0逻辑说明torch 2.0.1 与 torchvision 0.15.2 是官方配套版本错配会在 import 阶段就报 undefined symbolnumpy 锁 1.24.4 是 1.x 里对 numpy 2.x 兼容性最好的一个尾巴版本Pillow 只在你要用 PIL 画中文标签时才需要用 QPainter 画可以省掉但保留它能让源码里的中文注释表正常渲染。参数说明--index-url后面的 cu118 对应驱动版本装之前先nvidia-smi看右上角 CUDA Version驱动低于 11.8 就换成 cu117。装完用一行命令验明正身别等跑到一半才发现用的是 CPUpython -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, parentNone): 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_thresself._conf, iou_thresself._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跨线程走的是 QueuedConnectionQt 会把参数打包进事件队列等主线程空闲时再执行槽函数所以主线程永远不会被推理卡住。阈值走普通方法是因为每帧推理时 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, deviceself.device, fp16self.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_shape640, color(114, 114, 114), stride32, autoTrue): 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, interpolationcv2.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, valuecolor) return im, (r, r), (dw, dh)逻辑说明autoTrue时补边量取 stride 的模是为了让左右、上下的填充在 stride 上对齐避免 stride 采样时出现半格偏差。round(dh - 0.1)和round(dh 0.1)这组写法看着别扭实际是在规避 Python 的银行家舍入——当填充量恰好是 x.5 时两边各自取整会让左右相差 1 像素这个误差在 4K 图上会被放大成肉眼可见的偏移。参数说明color(114,114,114)是 YOLOv5 训练时的填充灰改别的颜色会让模型在边缘区域看到没见过的分布autoFalse适合小分辨率图片检测强行放大只会更糊。还原坐标用的是同一套增益和填充量YOLOv5 里叫scale_boxes逻辑很短但必须和预处理严格配对def scale_boxes(img1_shape, boxes, img0_shape, ratio_padNone): 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 返回的那两个值别用函数内部自己算的因为autoTrue时的模运算补边和这里按比例算的补边会有亚像素差图片上看不出来视频里逐帧累积会轻微抖动。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_thres0.25, iou_thres0.45, max_det300): img, ratio_pad letterbox(frame_bgr, 640, strideself.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, 5nc)v6.0 输出已是 xyxy 像素坐标 pred pred[pred[:, 4] conf_thres] if pred.shape[0] 0: return [], {} cls_score, cls_id pred[:, 5:].max(1, keepdimTrue) 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]是 objectnesspred[:, 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是 bytesPerLineOpenCV 转过来的数组是紧密排列的正好等于这个值如果你从别的来源拿 QImage行对齐不是 4 的倍数时这个参数必须单独算。字体建议显式指定默认字体在部分 Windows 上渲染中文会糊。绘制放在主线程的槽函数里做worker 只发原始帧和结构化数据。这样即使绘制偶尔超时也不会把推理节奏拖乱。4. 界面与业务解耦三种输入源、参数面板与导出4.1 图片、视频、摄像头统一成一个接口三种输入如果各写一套循环代码会迅速膨胀成三份重复逻辑而且改一处漏两处。统一成read()/release()两个方法后worker 完全不需要知道当前是什么源。import cv2, numpy as np class FrameSource: def __init__(self, kind, target, loopFalse): 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, dtypenp.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 在部分机型上首帧要等三四秒BUFFERSIZE1让驱动侧只保留最新一帧不设的话实时检测的画面会比真实延迟一两秒看起来像“卡住了”。摄像头索引从 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(fVideoWriter 打开失败: {path}, fourccmp4v) return writer逻辑说明imencode把图编码到内存 buffer再用 numpy 的tofile写盘这条路完全绕开 OpenCV 内部的字符编码处理中文、空格、括号路径都能过。视频写入器必须在第一帧拿到之后才能确定size所以创建时机要放在拿到首帧之后而不是打开文件时。参数说明fps用源视频的CAP_PROP_FPS拿不到就退回 25size是(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;weightsWindows 用分号Linux 用冒号先用--console打一个带控制台的版本让 traceback 能打出来这一步能省掉大量瞎猜。摄像头换CAP_DSHOW后端并设BUFFERSIZE1同时确认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 上稳赚fp16True大概有 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看框是否实时变化而不是要重新点开始用带中文和空格的路径导出图片和视频检查文件大小非零拔掉摄像头再点开始确认程序给出提示而不是闪退断网启动一次确认没有隐藏的联网下载。我以前吃过一次亏开发机上一切正常到现场第一台机器就白屏查了半天是那台机器没有独显代码里硬编码了devicecuda:0异常被一个空的except吞掉了。从那以后我每次交付这类 GUI 检测工具本文还有配套的精品资源点击获取