简介:这是一份围绕 PyQt5 与 YOLOv5 构建的多目标检测图形界面实战项目,面向刚接触 PyQt5 界面开发、YOLO 算法,或希望用完整工程练手的初学者。项目以 PyTorch 为深度学习框架,将多目标检测后端与图形界面结合,重点体现界面设计与业务逻辑分离的组织方式,并覆盖 PyQt5 常用控件的使用场景,可作为课程设计、入门演示与二次开发的基础模板。压缩包共 112 个文件,约 83.46MB,主要由源码、模型权重、配置文件、界面设计文件、示例图片与演示视频,以及容器、脚本和说明类辅助文件构成,便于直接运行、阅读与改造。已有 8886 人学习下载,读者能从中了解完整图形界面工程结构、模型加载与推理调用方式、界面与后端解耦思路和常见控件实践,配合 PyQt5 开发书籍对照学习更易上手。
1. 从一台没外网的机器说起:这套 pyqt5 + yolov5 + python 桌面检测系统要解决什么
实验室那台机器连不上外网,评审只给十分钟看演示,这时候一个双击就能打开、把视频拖进去就出框的桌面程序,比任何网页 demo 都管用。这套 pyqt5 + yolov5 + python 的组合要解决的就是这件事:把 PyTorch 训练出来的检测权重,装进一个带 GUI 的本地应用,支持单图、文件夹批量、视频文件和摄像头四种输入,阈值能现场调、模型能现场换、结果能导出成表格。它适合正在做课程设计或毕设、需要交付一套跑得起来、讲得清楚、当场能演示的完整系统的同学,也适合要做一个离线可用检测工具原型的从业者——不联网、不依赖服务端,一个文件夹拷走就能跑。
2. 需求拆解与技术选型:四种输入源与一条不能堵的主线程
需求这一步最容易被草草带过,最后做出来一个"能检测但不好用"的东西:点了开始按钮界面灰掉,想停停不下来,换个阈值要重启程序。把验收口径提前写死,后面写代码时才知道边界在哪儿。
2.1 把"能检测"拆成六条可验收的需求
"做一个目标检测 GUI"这句话没法验收,得拆。我的习惯是从输入源、交互、输出三条线切,每一条都配上可测量的口径,最后交付时拿着这张表逐条打勾。
| 编号 | 需求描述 | 验收口径 | 优先级 |
|---|---|---|---|
| R1 | 单张图片检测 | 拖入 jpg/png,3 秒内出框并刷新结果表 | 必须 |
| R2 | 文件夹批量检测 | 200 张图不卡界面,有进度条,可中途停止 | 必须 |
| R3 | 视频文件逐帧检测 | 输出带框视频,帧数与源文件一致 | 必须 |
| R4 | 摄像头实时检测 | 预览延迟 ≤ 300 ms,随时可停 | 必须 |
| R5 | 参数现场可调 | 置信度、IoU、类别勾选即时生效,不重启 | 应该 |
| R6 | 结果可导出 | CSV/JSON 字段完整,Excel 打开不乱码 | 应该 |
这张表里真正决定架构的是 R2 和 R4。批量检测是长耗时任务,摄像头是持续流式任务,两者都必须在后台跑,只要有一帧的推理留在主线程,界面就会进入"未响应"状态——Windows 上转圈三秒,答辩现场基本就凉了。R5 看着简单,实际要求推理对象在运行期可以被改写参数,所以 Detector 不能是每次重新加载权重的函数式写法,得是一个常驻对象。
2.2 为什么桌面端选 PyQt5 而不是套一层网页
很多人第一反应是 Flask + 前端页面,看起来时髦,落到这个场景里全是麻烦:摄像头要额外做 WebRTC 或 MJPEG 推流,本地文件要上传再回传,打包成"双击即用"更是无从下手。PyQt5 的优势在离线本地场景里非常实在——文件对话框、拖拽、摄像头(通过 OpenCV 的 VideoCapture 拿本地设备索引)都是一等公民,部署就是把一个 exe 或一个文件夹拷过去。
跨线程通信也是选它的核心理由。Qt 的信号槽在跨线程时默认走队列连接,工作线程 emit 一个信号,槽函数在主线程执行,天然规避了"子线程直接改控件"这个最常见的崩溃源。用 tkinter 做同样的事,就得自己维护一个 queue 再拿 after() 轮询,写起来更脏。
坑在于 Designer 生成的.ui文件和手写逻辑的关系。我的做法是.ui只描述布局,业务逻辑全部写在ui/main_window.py里,用uic.loadUi或者pyuic5编译都不影响;一旦在 Designer 里挂槽函数,后面改布局就会出现"信号连了两次、槽执行两遍"的玄学问题。
2.3 YOLOv5 权重与 PyTorch 推理后端怎么定
YOLOv5 的权重是.pt文件,本质是序列化的模型对象,PyTorch 直接torch.load就能还原。工程里我一般用torch.hub.load的custom分支,把仓库放在本地目录、source="local",这样首次运行不会去联网拉代码——离线机器上这一条能省掉一整天的排查。
import torch class Detector: """对 YOLOv5 推理的薄封装,UI 层只认这几个方法""" def __init__(self, weight_path, device="cpu", half=False): # source="local" 指向本地 yolov5 仓库,避免首次运行联网下载 self.model = torch.hub.load( "./yolov5", "custom", path=weight_path, source="local", verbose=False ) self.model.conf = 0.25 # 置信度阈值 self.model.iou = 0.45 # NMS 的 IoU 阈值 self.model.classes = None # None 表示全部类别参与 self.device = device self.half = half and device != "cpu" def set_threshold(self, conf=None, iou=None, classes=None): # 运行期改参数,不必重新加载权重 if conf is not None: self.model.conf = float(conf) if iou is not None: self.model.iou = float(iou) if classes is not None: self.model.classes = list(classes) def infer(self, bgr_frame, size=640): # OpenCV 读出来是 BGR ndarray,YOLOv5 内部转换,直接传即可 results = self.model(bgr_frame, size=size) df = results.pandas().xyxy[0] # xmin ymin xmax ymax confidence class name return df.to_dict("records")infer返回的是原始像素坐标,不做归一化——这一点很关键。UI 层拿到坐标直接画在原图上,导出 CSV 时也是原图坐标系,中间任何一次缩放归一化都会让"检测框画歪一点"这种问题变得极难定位。size参数是推理分辨率,默认 640,改大到 1280 精度略升但帧率掉一半,答辩演示时用 640 就够。
设备选择上,有独显就device="cuda"并开 FP16,纯 CPU 也别慌,YOLOv5s 在普通笔记本上跑单张图大约几百毫秒,做图片和文件夹批量完全够看,只是摄像头实时会卡到 3~5 FPS,这一点要在答辩时主动说明,别等人问。
2.4 依赖版本与目录约定
版本冲突是这个组合里排第一的血泪经验。PyTorch、torchvision、OpenCV、PyQt5 四者互相牵制,尤其是 torch 2.x 之后部分接口变动,配老版 YOLOv5 仓库容易出weights_only相关的加载报错。稳妥的做法是锁版本并写进 requirements,交付时连同一个能跑的虚拟环境一起打包。
detect_gui/ ├── main.py # 入口,只做 QApplication 与主窗口装配 ├── ui/ │ ├── main_window.py # 主界面:输入区、预览区、结果表、参数面板 │ ├── widgets.py # 可复用控件:拖拽区、可缩放图像查看器 │ └── main_window.ui # Designer 布局文件 ├── core/ │ ├── detector.py # YOLOv5 推理封装 │ ├── worker.py # QThread 工作线程 │ ├── video.py # 视频与摄像头读取、帧率控制 │ └── exporter.py # CSV / JSON / 截图导出 ├── config/ │ └── app.yaml # 默认阈值、设备、最近使用路径 ├── weights/ │ └── best.pt ├── fonts/ │ └── simhei.ttf # 中文标签渲染用,必须随包发布 └── requirements.txt目录约定看着琐碎,但它决定了后面能不能顺利打包。凡是被代码用相对路径引用的东西——权重、字体、yolov5 仓库、配置文件——都必须落在项目根目录的子目录里,并且在代码中统一走一个resource_path()去解析,打包后才不会出现"开发机能跑、拷到别人机器上找不到 best.pt"的经典事故。
3. 总体架构设计:UI 层、推理层、数据层的职责边界
3.1 三层结构与模块划分
整套系统我按三层切:UI 层只负责画和收集用户意图,推理层只负责"给一帧返回一堆框",数据层负责结果的组织、缓存和落盘。三层之间不允许反向依赖,UI 不 import cv2,推理层不 import PyQt5——这条纪律听起来教条,但它能保证你换界面时不用动推理代码,换模型时不用动界面。
UI 层由主窗口和几个自定义控件组成。主窗口做四件事:装配布局、连接信号、维护当前任务句柄、管理最近使用路径。可缩放图像查看器单独抽出来,因为"图片按比例适应窗口 + 支持滚轮缩放 + 拖拽平移"这套逻辑写一次就够了,图片和视频预览都要用。
推理层核心是Detector和DetectWorker。Detector持有模型,是常驻的、无状态的;DetectWorker是一次任务的载体,继承 QThread,负责按输入源类型驱动循环。把"模型"和"任务"分开的好处是切换权重时只重建 Detector,而任务对象每次新建,不会残留上一次的状态。
数据层比较轻,一个DetectionRecord的字典结构加一个ResultStore,负责按帧号聚合、按类别统计、按需导出。批量检测时结果可能有几万条,不能全塞进 QTableWidget,我的做法是表格只显示前若干条或按类别汇总,完整数据留在内存的列表里,导出时才遍历。这样界面刷新的开销和结果总量脱钩,跑 500 张图也不会越跑越卡。
3.2 信号槽协议:UI 与工作线程之间只留一条通道
多线程 GUI 出事,九成是因为子线程碰了控件。规矩定死:工作线程只 emit 信号,绝不调用任何 UI 对象的方法;UI 只通过worker.stop()这种设置标志位的方式影响线程,不直接 terminate。
线程类上挂四个信号:frame_ready(object, list)每处理完一帧发一次,携带原图和检测结果;progress(int)发进度百分比;finished_ok(dict)正常结束时发汇总统计;failed(str)出异常时发错误信息。四个信号对应四种 UI 反应,边界清晰。
这里有个容易忽略的细节:frame_ready的参数类型要写成object。PyQt 的信号签名对 numpy 数组和 Python 列表支持不好,直接写pyqtSignal(np.ndarray, list)在某些版本上会报类型不匹配,用object兜住最稳。
另一个细节是信号频率。摄像头 30 FPS 意味着每秒 30 次跨线程投递,每次携带一张 1080p 的图,队列一旦堆积,界面就会"越跑越延迟"。解决办法是 UI 端做节流:槽函数里记录上次刷新时间,间隔小于 33 ms 就直接丢弃这次更新,只保留最新帧。人眼看不出来,内存压力立刻下来。
3.3 结果数据模型与字段定义
结果结构统一成一个扁平字典,不含嵌套,这样 CSV、JSON、表格三处导出可以共用同一份定义,不用写三套映射。
| 字段 | 类型 | 说明 |
|---|---|---|
| frame_id | int | 帧序号,单张图片固定为 0 |
| name | str | 类别显示名,可映射为中文 |
| confidence | float | 0~1 的置信度 |
| xmin/ymin/xmax/ymax | float | 原图像素坐标,未归一化 |
| source | str | 来源标识:文件名或 camera |
坐标统一用像素值而不是比例,是为了画框时零换算。代价是同一批结果不能直接跨分辨率复用,但在单机检测工具里这不是问题——每一帧的框都跟着那一帧走。类别名做一层映射表放在配置里,模型输出person、界面显示"人员",答辩时中文界面看着更完整,映射表也让"改个显示名不用改模型"成为可能。
3.4 权重、配置与资源的统一入口
配置文件用 YAML,只放会变的东西:默认置信度、默认 IoU、设备偏好、最近打开的目录、界面字体大小。权重路径不写死在配置里,而是启动时扫描weights/目录,把找到的.pt全部列进下拉框。
资源路径统一走一个函数解析,开发环境和打包环境共用一套逻辑:
import sys, os def resource_path(relative): """打包后 PyInstaller 会把资源解到临时目录,base 路径要动态取""" base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, relative)sys._MEIPASS是打包工具解压资源时设置的临时目录,非打包环境不存在,所以用getattr兜底。所有open()、ImageFont.truetype()、torch.hub.load()的路径都必须过这个函数,一个地方漏了,打包版就在那一处崩。
4. 关键模块实现:从读帧、推理到画框导出的完整链路
4.1 推理线程:把阻塞操作全部赶出主线程
工作线程是整个系统的心脏,写法直接决定界面卡不卡。核心原则是 run() 里只有一个循环,循环里只有读帧、推理、emit 三件事,所有异常就地捕获并转成failed信号,绝不让异常穿透线程边界——穿透了就是整个进程静默退出,连报错都看不到。
from PyQt5.QtCore import QThread, pyqtSignal import traceback class DetectWorker(QThread): frame_ready = pyqtSignal(object, list) # 原图帧, 检测结果列表 progress = pyqtSignal(int) # 0-100 finished_ok = pyqtSignal(dict) # 汇总统计 failed = pyqtSignal(str) # 错误详情 def __init__(self, detector, source, kind, parent=None): super().__init__(parent) self.detector = detector self.source = source # 文件路径 / 目录 / 摄像头索引 self.kind = kind # image / folder / video / camera self._stop = False def stop(self): # 只置标志位,不 terminate,避免资源半释放 self._stop = True def run(self): stats = {"total": 0, "frames": 0} try: for frame_id, frame in self._iter_frames(): if self._stop: break dets = self.detector.infer(frame) stats["total"] += len(dets) stats["frames"] += 1 self.frame_ready.emit(frame, dets) self.progress.emit(self._percent(stats["frames"])) self.finished_ok.emit(stats) except Exception: self.failed.emit(traceback.format_exc())stop()只置标志位是有意为之。QThread 的terminate()会在任意指令处杀掉线程,此时 OpenCV 的 VideoCapture 没释放、文件句柄没关、CUDA 上下文没清,第二次点开始就可能打不开摄像头。用标志位让循环自然退出,最坏情况多跑一帧,代价可以接受。
_iter_frames()按 kind 分派,图片返回单元素迭代,文件夹遍历目录,视频和摄像头走统一的读取循环。这样 run() 里只有一份逻辑,四种输入源共用同一套推理、emit、统计代码,改一处四处生效。
4.2 视频与摄像头读取:帧率对齐和丢帧策略
视频文件和摄像头的行为完全不同。文件是"能读多快读多快",摄像头是"按设备节奏出帧",用同一套读取代码会出两种毛病:视频跑得飞快导致进度条瞬间到底,摄像头则因为推理慢于出帧速度,缓冲区越积越多,画面延迟从 0.2 秒涨到 5 秒。
import cv2, time def iter_video(path, target_fps=25, stop_flag=lambda: False): cap = cv2.VideoCapture(path) interval = 1.0 / target_fps while not stop_flag(): t0 = time.time() ok, frame = cap.read() if not ok: break yield frame cost = time.time() - t0 if cost < interval: time.sleep(interval - cost) # 补齐节奏,别让文件播放像快进 cap.release() def iter_camera(index=0, stop_flag=lambda: False): cap = cv2.VideoCapture(index, cv2.CAP_DSHOW) # Windows 下指定后端,打开更快 cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) while not stop_flag(): ok, frame = cap.read() if not ok: break yield frame cap.grab() # 丢掉缓冲里的下一帧,防止延迟累积 cap.release()摄像头那段里cap.grab()是关键的一行。它把缓冲区里排队的旧帧取走丢掉,保证下一次read()拿到的是最新画面。没有这一行,推理一慢,缓冲区就变成单向增长的黑匣子,你看到的人永远是几秒前的。
提示:
cv2.CAP_DSHOW是 Windows 专用的采集后端,Linux 上要换成cv2.CAP_V4L2。跨平台交付时用sys.platform判断一下,否则换机器就打不开摄像头。
视频那条线的time.sleep也不是装饰。不加的话,处理一段 30 秒的视频可能 8 秒就跑完了,进度条一闪而过,用户会以为程序出 bug 了。按目标帧率补齐节奏,观感和"实时播放"一致,也方便演示时讲"这段视频包含 X 帧,处理耗时 Y 秒"。
4.3 画框与中文标签渲染
OpenCV 自带的cv2.putText只支持 ASCII,直接塞中文标签会变成一排问号。这是最经典的翻车点,也是很多人做到最后才发现的问题。解决办法是走 PIL 中转:ndarray → PIL Image → 用 truetype 字体绘制 → 转回 ndarray。
import cv2, numpy as np from PIL import Image, ImageDraw, ImageFont FONT_PATH = "fonts/simhei.ttf" _font_cache = {} def _font(size=20): if size not in _font_cache: _font_cache[size] = ImageFont.truetype(FONT_PATH, size) return _font_cache[size] def draw_boxes(bgr, dets, color=(0, 200, 120)): img = Image.fromarray(cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)) draw = ImageDraw.Draw(img) font = _font(20) for d in dets: x1, y1, x2, y2 = map(int, (d["xmin"], d["ymin"], d["xmax"], d["ymax"])) draw.rectangle([x1, y1, x2, y2], outline=color, width=2) text = f'{d["name"]} {d["confidence"]:.2f}' # 标签底板按文字实际尺寸算,避免长短标签宽度不一 tw, th = draw.textbbox((0, 0), text, font=font)[2:] draw.rectangle([x1, max(0, y1 - th - 8), x1 + tw + 8, y1], fill=color) draw.text((x1 + 4, max(0, y1 - th - 6)), text, font=font, fill=(255, 255, 255)) return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)_font_cache是必须的。ImageFont.truetype每次调用都要读磁盘、解析字体文件,逐帧调用会让帧率掉一大截,而且这个开销在 CPU 上特别明显。按字号缓存,进程内第一次读一次就够。
标签底板高度用textbbox实测而不是拍一个固定值,是因为中文和数字混排时高度不一样,写死 24 像素会让"人员 0.87"这种标签被裁掉下半截。底板 y 坐标做max(0, ...)保护,是为了框贴在图像顶边时不画到画布外面去。
4.4 结果导出:CSV、JSON 与截图
导出看着是边角功能,实际是评审最容易问的地方——"你这个检测结果能给别人用吗"。CSV 给 Excel 用户,JSON 给后续处理,截图给报告,三种输出共用一个记录列表。
import csv, json, os, time FIELDS = ["frame_id", "source", "name", "confidence", "xmin", "ymin", "xmax", "ymax"] def export_csv(records, out_dir): os.makedirs(out_dir, exist_ok=True) path = os.path.join(out_dir, time.strftime("detect_%Y%m%d_%H%M%S.csv")) # utf-8-sig 带 BOM,Excel 直接双击打开中文不乱码 with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore") writer.writeheader() writer.writerows(records) return path def export_json(records, out_dir, meta=None): os.makedirs(out_dir, exist_ok=True) path = os.path.join(out_dir, time.strftime("detect_%Y%m%d_%H%M%S.json")) payload = {"meta": meta or {}, "records": records} with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) return pathencoding="utf-8-sig"这一行是被 Excel 逼出来的。默认 UTF-8 的 CSV 在 Excel 里打开,中文类别名全是乱码,评审看到这个会直接怀疑数据正确性。带上 BOM,双击就是正常的。extrasaction="ignore"让记录里多余的键(比如内部的颜色字段)被自动忽略,不用手工裁剪字典。
meta里我一般塞进去:权重文件名、置信度阈值、IoU 阈值、检测时间、处理帧数。这五项凑齐,别人拿到 JSON 能复现出同样的结果,也方便自己回头对账。
4.5 打包发布:PyInstaller 与首次启动自检
开发机能跑不等于交付物能跑。打包时最容易漏的是数据文件——权重、字体、yolov5 仓库源码都不在 Python 的 import 图里,打包工具不会自动收。
pyinstaller -D -w main.py \ --add-data "yolov5;yolov5" \ --add-data "weights;weights" \ --add-data "fonts;fonts" \ --add-data "config;config" \ --hidden-import torch \ --hidden-import torchvision \ --collect-data torch--add-data的格式是"源路径;目标路径",Windows 用分号,Linux 和 macOS 用冒号,跨平台脚本里要做判断。-D出目录模式而不是单文件,启动快得多,也方便把权重单独替换;-w去掉控制台窗口,正式交付用,调试阶段一定要去掉这个参数,否则你连 print 都看不到。
启动自检是我后来加的习惯:主窗口显示前先检查权重目录非空、字体文件存在、OpenCV 能打开默认摄像头,任何一项失败就弹一个明确的中文提示框,而不是让程序带着残缺状态进入界面,等用户点了开始才崩。
注意:打包体积做不小是常态,PyTorch 加上 CUDA 相关库随手就是 2 GB 以上。如果只是课程设计交付,用 CPU 版 PyTorch 打包能压到 300 MB 左右,代价是实时性下降,这个取舍要在文档里写清楚。
5. 避坑与常见问题排查:五类最容易翻车的现场
5.1 点开始按钮界面就"未响应"
现象:图片检测没事,一切到视频或摄像头,窗口立刻灰掉,标题栏出现"未响应",几秒后系统提示强制关闭。
原因:推理或者读帧写在了主线程里,或者虽然开了线程,但把Detector的初始化放在了点击事件中。加载.pt权重本身就要一两秒,加上首次 CUDA 初始化,主线程直接被堵死,事件循环停转,Windows 就判定无响应。
解决:所有耗时操作一律进DetectWorker.run(),包括模型加载。我的做法是在 app 启动时用一个带进度提示的初始化把 Detector 建好,点击开始只做"拿现成对象 + 起线程",响应时间压到毫秒级。已经写死在点击事件里的,就把它挪到线程 run() 的第一行。
5.2 摄像头预览越跑越慢
现象:刚打开摄像头很流畅,跑了二三十秒之后画面明显滞后,动作和显示差了三四秒,越跑越严重,最后像是卡住了。
原因:采集端按 30 FPS 往外送帧,推理端只能处理 10 FPS,两者速率不匹配,帧在 OpenCV 的缓冲区里越积越多。程序本身没卡,它只是在一帧一帧地处理几秒前的旧画面。
解决:读取循环每帧之后加一次cap.grab()把排队的旧帧丢掉;同时在 UI 端做刷新节流,两次界面更新间隔小于 33 ms 就丢弃本次。两处一起改,延迟能稳定在 300 ms 以内。另外把采集分辨率从 1080p 降到 720p,推理负载直接少一半。
5.3 中文类别名显示成方块或问号
现象:代码里明明写的是"人员",画到图上一排问号;或者导出报表时中文全变乱码。
原因:绘制端用的是cv2.putText,它只认 ASCII;导出端用的是默认编码写 CSV,Excel 按本地编码解析。两个问题的根子都是"没指定字符集"。
解决:绘制走 PIL 中转,显式指定ImageFont.truetype的字体文件,并且把字体随包发布——依赖系统字体会导致换台机器字体缺失,又变回方块。CSV 用utf-8-sig,JSON 用ensure_ascii=False。这两条改完之后,"中文乱码"这一类问题基本绝迹。
5.4 打包后启动即崩,提示找不到权重
现象:开发环境一切正常,pyinstaller打出来的程序双击闪退,命令行跑能看到FileNotFoundError: weights/best.pt。
原因:相对路径是相对"当前工作目录"解析的,打包后的工作目录不是 exe 所在目录,而是启动时的目录。资源文件也可能被解压到临时目录,硬编码的相对路径两头都不对。
解决:所有资源访问统一走resource_path(),用sys._MEIPASS兜底;打包命令里把每个资源目录都用--add-data显式声明。交付前在一台没装过 Python 的机器上跑一遍,这一步别省——本地测试永远发现不了缺文件。
5.5 连续检测显存或内存一路涨
现象:单次任务跑完看内存正常,连续跑五六次批量检测之后,占用从 800 MB 涨到 4 GB,最后进程被系统杀掉。
原因:每次新任务都新建一个Detector,旧的模型对象没有被释放;或者工作线程结束后没有wait(),线程对象还挂在 Python 引用里;又或者每帧生成的 PIL Image 没有及时回收,靠 GC 慢慢收。
解决:Detector 全应用只建一个,切换权重时才重建,重建前把旧对象置 None 并torch.cuda.empty_cache();线程在finished信号里调deleteLater();逐帧生成的临时图不复用引用,画完立刻返回新对象。这类问题的排查手段很土但有效——任务管理器盯着内存曲线上跑十轮,曲线不回头就说明有泄漏。
6. 验收与答辩:用一张测试矩阵把系统讲清楚
验收这一步,我给自己的要求是"不靠嘴说,靠表打勾"。功能验收矩阵按需求编号排,每一行写清楚输入、预期、实测、结论,答辩时直接投出来,比讲十分钟架构有说服力。
| 编号 | 测试输入 | 预期结果 | 判定方式 |
|---|---|---|---|
| R1 | 三张不同分辨率的 jpg | 都出框,结果表有记录 | 目视 + 表格行数 |
| R2 | 200 张图目录 | 进度条走完,界面全程可拖动 | 拖窗口不卡顿 |
| R3 | 30 秒 mp4 | 输出视频帧数与源一致 | 播放器看总帧数 |
| R4 | 内置摄像头 | 延迟 ≤ 300 ms,可随时停止 | 挥手对比显示 |
| R5 | 把阈值从 0.25 调到 0.6 | 框数量减少,无需重启 | 前后截图对比 |
| R6 | 导出 CSV 并双击打开 | 中文正常,字段齐全 | Excel 打开检查 |
性能指标要测三个数,别只报 FPS。第一个是纯推理耗时,循环跑 100 帧取均值,这个数体现模型本身;第二个是端到端延迟,从读帧到界面显示的时间差,这个数体现架构质量;第三个是内存峰值,跑完 200 张图后的占用。三个数一起报,"实时检测"这个说法才站得住。测的时候把设备写清楚——CPU 还是 GPU、什么分辨率的输入,否则数字没有意义。
答辩演示脚本我一般压到三分钟:第一分钟打开程序,拖一张图进去,讲"这是单图检测,框、类别、置信度都在,右边是结果表";第二分钟切到摄像头,边挥手边把置信度滑块从 0.25 拉到 0.6,讲"阈值实时生效,低置信度的框被过滤掉";第三分钟打开批量目录跑一个 50 张的小批量,中途点一次停止再重新开始,讲"任务可中断、可重入"。这三段分别对应"能用""能调""能管",覆盖了评审最关心的三个维度。剩下时间留给追问,最常被问的是"为什么用 YOLOv5 不用别的"和"误检怎么办",答案提前准备成两句话,别现想。
从那以后我每次交付这类系统,都会在打包前强制走一遍这张矩阵,哪怕时间再紧也不跳步。因为翻车从来不是因为某个模块写不出来,而是因为"以为它能跑"——矩阵上每一个没打勾的格子,都是答辩现场可能被问到的那一句。希望帮到你。
本文还有配套的精品资源,点击获取