朋友接了个社区环保的活儿,想在投放点装个摄像头,屏幕上实时框出塑料袋、纸箱、饮料瓶,顺便统计一天各类垃圾的数量。需求听起来不复杂,但从数据标注一路折腾到界面卡顿、打包后模型文件找不到,前后花了将近三周才跑顺。这套基于 YOLOv8 深度学习的生活垃圾分类目标检测系统,本质上就是四件事拼起来:一份能用的数据集、一段能收敛的训练代码、一个把推理包起来的 Python 接口,加一个 PyQt5 图形界面。中间任何一个环节掉链子,最后看到的都是"框不出来"或者"界面转圈"。下面我把这套东西从头到尾拆一遍,包括每个参数的取舍理由、界面线程的正确写法,以及那些文档里不会写、只有真跑过才会撞上的问题。
1. 这套垃圾分类检测系统到底由哪几块拼起来
很多人一上来就急着 clone 代码、装环境,结果卡在依赖版本上耗掉两天,其实先把系统的数据流画清楚,后面每一步都会顺很多。这套系统从摄像头画面到屏幕上的方框,中间经过的环节比想象中多。
1.1 从一张垃圾照片到界面方框的完整链路
整条链路大致是这样:图像输入(图片文件 / 视频文件 / 摄像头帧)→ 尺寸调整与归一化 → YOLOv8 前向推理 → 后处理(置信度过滤 + NMS 非极大值抑制)→ 类别编号映射回中文名称 → 坐标反算回原图尺寸 → 绘制方框与标签 → 统计面板累加计数。
这里有两个容易被忽略的点。第一,YOLOv8 推理时输入的尺寸会被统一 resize 到imgsz(默认 640),输出的坐标是基于 640 这个尺度的,必须按原图的宽高比例反算回去,否则框会偏移甚至飞出画面。第二,NMS 是把同一物体上重叠的多个候选框合并成一个,iou阈值设得太低会误删相邻的两个瓶子,设得太高又会保留重复框,这个参数在界面里最好做成可调滑条。
理解这条链路之后,你就会明白为什么"界面卡死"是个必然问题:如果推理直接跑在 Qt 的主线程里,一帧要 30 到 80 毫秒,主线程被占住,界面就没法刷新,鼠标点击也没响应。
1.2 四个模块各自的职责边界
把这套系统拆开看,它其实是四个互相独立、只通过文件和数据接口耦合的模块:
| 模块 | 输入 | 输出 | 主要依赖 |
|---|---|---|---|
| 数据集与标注 | 原始图片、类别定义 | images/ 与 labels/ 目录、data.yaml | labelImg / X-AnyLabeling |
| 训练脚本 | data.yaml、预训练权重 | best.pt、训练日志、评估图表 | ultralytics、PyTorch |
| 推理引擎封装 | best.pt、单帧图像 | 检测框列表、类别、置信度 | ultralytics、OpenCV、NumPy |
| PyQt5 界面 | 用户操作、推理结果 | 可视化画面、统计表格 | PyQt5、Pillow |
这样拆的好处是调试时可以逐个击破:模型不准,只动训练脚本;界面卡,只动线程;程序崩,先看推理封装有没有返回异常。我见过不少人把model.predict()直接写在按钮的槽函数里,模型加载、推理、绘制全挤在一起,出了问题根本不知道是哪一段。
1.3 硬件与软件底座的现实选择
关于硬件,网上常见的说法是"没有独立显卡就别做深度学习",这话对训练成立,对推理不完全成立。训练阶段,一块 6GB 显存的卡(比如常见的 GTX 1660Ti 这个档位)跑yolov8n或yolov8s,imgsz=640、batch=8是完全可行的;如果显存报 OOM,优先降 batch,其次降 imgsz,最后才考虑换更小的模型。推理阶段,CPU 跑yolov8n在 640 分辨率下大约每帧 100 到 200 毫秒,做图片检测够用,做实时视频就明显掉帧,这时候可以把imgsz降到 416 或换更轻量的权重。
软件底座上,我一般推荐 Python 3.9 或 3.10。3.11 及以上在部分 PyTorch 版本上会有兼容问题,3.8 又太老,很多新的 ultralytics 版本不支持。PyQt5 建议用pip install PyQt5装的版本,别用系统包管理器装的,版本混乱时会出现"界面能显示但没有反应"这种诡异现象。
提示:先把
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"跑通,确认能打印出 True,再动其他依赖。这一步能省掉后面大量的无效排查。
2. 数据集这一关:生活垃圾标注的脏活累活
模型效果的上限,在数据集定型的那一刻就基本确定了。训练参数调得再花哨,也没法把标错的框救回来。这一节讲的是怎么把类别体系定清楚、图片采够、标注做对。
2.1 类别体系怎么定:四分类还是细分品类
垃圾分类的标准分法大家熟悉:可回收物、有害垃圾、厨余垃圾、其他垃圾。但如果你直接把nc设成 4,让模型去判断"这是可回收物",准确率通常很难看。原因很直白:模型看到的是像素特征,它更容易学会"蓝色塑料瓶"、"纸箱"、"易拉罐"这些具体的物体形态,而不是抽象的"是否可回收"这个由规则定义的类别。
所以更靠谱的做法是两层映射:模型识别具体物体类别,界面上再把物体映射到四分类。比如:
| 模型类别(names) | 归属大类 |
|---|---|
| plastic_bottle | 可回收物 |
| carton | 可回收物 |
| can | 可回收物 |
| glass_bottle | 可回收物 |
| battery | 有害垃圾 |
| medicine_box | 有害垃圾 |
| peel | 厨余垃圾 |
| leftover | 厨余垃圾 |
| cigarette_butt | 其他垃圾 |
| disposable_box | 其他垃圾 |
这么做还有个额外好处:后续需求变化时,比如某个社区要求把"玻璃瓶"单独统计,你只需要改映射表,不用重新训练模型。
不过类别粒度也不是越细越好。每增加一个类别,至少需要几百张标注样本,而且相邻类别之间容易混淆。易拉罐和铁罐、奶茶杯和一次性纸杯,这些如果硬拆成两类,模型会长期在这两类上打架,confusion_matrix.png里能看到明显的对角线外的高值。我的经验是:先把类别数控制到 6 到 10 个,跑通全流程、看到 mAP 有 0.7 以上之后,再考虑细分。
2.2 采集与标注:从公开数据到自拍补充
公开数据集可以用,但几乎一定需要补。公开数据集的拍摄场景和你实际部署的环境差异太大:人家是在白底棚拍,你是放在小区垃圾桶旁边,逆光、阴影、部分遮挡、背景杂乱,模型到现场就露馅。
我一般建议的训练集构成是这样的:公开数据集占 60%,自己按实际场景拍的占 40%。自拍的时候注意几个细节:
- 每个类别至少覆盖 5 种不同的光照条件(顺光、逆光、阴影、夜间灯光、阴天)。
- 同一个物体要从至少 3 个角度拍,正面、侧面、俯视。垃圾袋这种软材质,形状变化极大,角度覆盖不够模型会认死一个形状。
- 一定要拍一些"被遮挡一半"和"堆在一起"的画面,真实投放点的垃圾不会是单件摆放。
- 负样本(画面里没有垃圾的纯背景)也要放一些,能显著降低误检。
标注工具上,labelImg 够用但功能少,X-AnyLabeling 支持预标注,可以先用一个粗略模型跑一遍再人工修正,效率能提升好几倍。标注格式选 YOLO 格式,每张图对应一个同名.txt文件,每行是class_id x_center y_center width height,后四个值都是相对图片宽高的归一化数值,范围 0 到 1。
这里有个新手常犯的错误:把像素坐标直接写进 txt。模型完全训不起来,因为坐标值超过 1 会被当成异常处理,loss 从头到尾不降。判断方法很简单,随便打开一个 label 文件,只要有任何一个数值大于 1,就是没归一化。
2.3 data.yaml 的每个字段都要对得上
data.yaml是训练脚本和数据集之间的唯一契约,写错一个字段就会训练出莫名其妙的模型。一个标准的写法:
path: ./dataset # 数据集根目录 train: images/train # 相对 path 的路径 val: images/val test: images/test nc: 8 # 类别数量,必须和 names 长度一致 names: 0: plastic_bottle 1: carton 2: can 3: glass_bottle 4: battery 5: peel 6: cigarette_butt 7: disposable_box目录结构必须严格对应:
dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/images/train/xxx.jpg必须能在同级labels/train/xxx.txt找到对应标注。找不到会怎样?ultralytics 不会报错,它默认把这张图当成"没有目标的负样本",于是你的模型学到的就是"这图里什么都没有"。大量图片标注文件丢失,训练出来的模型会倾向于不输出任何框,表现就是界面上干干净净一个框都没有。
nc和names的长度不一致同样危险。如果nc写大了,那些多出来的类别永远没有样本,评估时这些类的 AP 是 0,拉低整体 mAP;如果nc写小了,超出编号的标注会被忽略。
注意:修改
data.yaml后一定要重启训练进程。ultralytics 会缓存一部分配置,同一进程内反复改文件容易读到旧值。
2.4 标注质量自查与脏数据清理
标完不等于标对。我习惯在训练前跑一遍自查,主要看四类问题:
第一类是框贴边甚至超界。方框的边界正好压在图片边上,说明这个物体在图里被截断了,模型学到的是残缺形态。这类样本的比例最好控制在 10% 以内。
第二类是漏标。一张图里明明有三个瓶子,只标了两个。这不仅让模型学不会第三个,还会把第三个当成背景,产生"正确检测被判定为误检"的副作用,直接拉低 precision。
第三类是类别错标。这个最隐蔽,因为框的位置是对的,只有类别编号错了。批量肉眼检查不现实,可以训一个初版模型,用它去跑训练集,把"预测类别和标注类别不一致且置信度很高"的样本挑出来人工复核,这批数据里错标比例往往不低。
第四类是小目标。宽或高小于 8 像素的框,在 640 输入下经过下采样后信息基本丢失,留着只会增加噪声。要么放大图片重新截取,要么直接剔除。
最后还有一个容易被忘掉的细节:把文件名的中文、空格、特殊符号改掉。某些读取流程处理这些字符会出错,为了省事,统一改成英文加数字最稳。
3. YOLOv8 训练:参数不是玄学,是取舍
数据集准备好之后,训练本身反而是整套流程里最"确定性"的一环。但确定性不代表随便填,每个参数背后都有明确的物理含义,理解它们才能在效果不好时知道往哪调。
3.1 从预训练权重出发的迁移学习策略
一定要用预训练权重,不要从零开始训。yolov8n.pt、yolov8s.pt这些权重是在大规模通用数据集上训出来的,它们的骨干网络已经学会了提取边缘、纹理、形状这些通用特征。你只需要让模型把最后几层学到的"通用特征"重新映射到你的垃圾类别上。
选哪个尺度的权重?这是一道性价比题:
| 权重 | 参数量级 | 适用场景 | 相对速度 |
|---|---|---|---|
| yolov8n | 最小 | 边缘设备、CPU 推理、类别少 | 最快 |
| yolov8s | 小 | 平衡之选,多数项目首选 | 快 |
| yolov8m | 中 | 类别多、小目标多 | 中 |
| yolov8l / x | 大 | 追求精度、显存充足 | 慢 |
生活垃圾检测这个场景,类别通常在 10 个以内,物体尺寸中等偏大,yolov8s基本够用。如果目标是部署在算力有限的设备上,从yolov8n开始试,效果不够再往上加。别一上来就用yolov8x,训练时间翻好几倍,精度提升可能不到两个点。
3.2 关键超参逐项拆解
一个能跑通的训练命令长这样:
yolo detect train \ model=yolov8s.pt \ data=dataset/data.yaml \ epochs=150 \ imgsz=640 \ batch=16 \ device=0 \ workers=4 \ lr0=0.01 \ lrf=0.01 \ patience=50 \ project=runs/garbage \ name=exp_s逐个说清楚每个值的来历:
epochs=150:整个数据集过 150 遍。太少学不透,太多过拟合。判断标准是看验证集指标什么时候不再提升,而不是固定数字。如果你只有几百张图,50 到 100 足够;图多的话 200 到 300 也正常。
batch=16:一次喂给模型多少张图。这个值直接吃显存。6GB 显存下yolov8s加imgsz=640,batch 16 可能刚好,跑不起来就降到 8。注意 batch 变了,学习率最好跟着微调,因为 batch 越大,梯度的噪声越小,可以用更大的学习率。
lr0=0.01:初始学习率。YOLOv8 用的是 SGD 加余弦退火,lr0是起点,训练过程中会逐步衰减到lr0 * lrf,也就是 0.0001。如果 loss 一开始就爆成 NaN,先把 lr0 降到 0.001 试试。
patience=50:50 个 epoch 内验证指标没有提升就提前停止。这个机制能省下大量时间,尤其是数据集小的时候,模型往往在 60 到 80 轮就到顶了。
freeze参数值得一提。它可以冻结骨干网络的前 N 层,只训练检测头。数据量特别少(比如只有三五百张)的时候,冻结部分层能有效防止过拟合,训练也更快。一般freeze=10是个可用的起点,也就是冻结前 10 层。
3.3 数据增强对垃圾类别的实际影响
YOLOv8 默认开着 Mosaic 增强,把四张图拼成一张。这个策略对检测任务很友好,因为它一次就让模型看到四种不同的背景和目标组合,等效于扩大了数据集。但放到垃圾检测上,有几个增强需要手动关掉或调小。
上下翻转(flipud)默认是关的,这个默认值是对的,别去打开。垃圾不会倒挂在天花板上,上下翻转会制造大量现实中不存在的形态,反而干扰学习。左右翻转(fliplr=0.5)可以保留。
色相、饱和度、亮度扰动(hsv_h、hsv_s、hsv_v)建议适当加大。垃圾检测最大的挑战之一就是光照差异,同一个饮料瓶在日光下和夜间路灯下颜色差别巨大,亮度扰动能显著提升模型对光照的鲁棒性。我一般把hsv_v设到 0.5 左右。
Mosaic 有个副作用:训练最后 10 个 epoch 建议关掉它。因为 Mosaic 拼出来的图,目标的尺度和位置分布和真实图片有差异,最后阶段用原始图片微调一下,能把指标再拉一点。这个开关在 YOLOv8 里是close_mosaic=10,意思就是最后 10 轮关闭。这个小设置经常被忽略,但对最终 mAP 有实际影响。
3.4 看损失曲线判断训练到底有没有跑偏
训练跑起来之后,控制台会滚动输出三个 loss:box_loss、cls_loss、dfl_loss。它们的含义是:
box_loss:边界框回归误差,衡量框的位置和大小准不准。cls_loss:分类误差,衡量类别判断对不对。dfl_loss:分布焦点损失,是 YOLOv8 用来优化框边界精细度的,可以简单理解为"框有多贴边"。
健康的训练曲线是这样的:三个 loss 都稳步下降,早期降得快,后期趋于平缓,在某个值附近小幅震荡。如果出现下面几种情况,就要警惕:
box_loss降到很低但cls_loss一直高。说明框的位置能找准,但类别分不清。大概率是类别标注有问题,或者类别之间视觉差异太小,需要考虑合并类别或补数据。
验证 loss 开始上升,训练 loss 还在下降。这是过拟合的典型信号。处理方式是加数据、加大增强、开freeze、或者直接靠patience提前停止。
三个 loss 从第一轮就几乎不动。先怀疑数据,八成是 label 路径没对上、坐标没归一化、或者nc和实际标注的类别编号对不上。
在runs/garbage/exp_s/目录下会自动生成results.png,里面把上面这些曲线都画在一起,比在控制台里看滚动日志直观得多。另外那个yolov8画损失函数曲线图的需求,其实不用额外写代码,results.csv里存了每一轮的数值,用 pandas 读出来画一下就行。
3.5 mAP 之外更该关注的指标
训练结束,控制台会打印一行汇总。很多人只盯着mAP50,但真正决定系统能不能用的是下面这几个:
mAP50-95比mAP50严格得多,它要求预测框和真实框的 IoU 在 0.5 到 0.95 之间多个阈值下都达到要求。mAP50有 0.9 而mAP50-95只有 0.5,说明框大致位置对,但不够精确。
precision和recall要一起看。precision 高 recall 低,意味着模型很保守,只框它非常确定的,漏检多;recall 高 precision 低,意味着模型框得很多,误检多。垃圾分类这个场景,实际使用中我更在意 precision,因为屏幕上频繁跳出错误框比少框一两个更影响体验。
每一类的 AP 分散在results.csv里,重点看哪个类别拖后腿。通常有两三个类别 AP 明显偏低,这些就是需要补数据的对象。
confusion_matrix.png和confusion_matrix_normalized.png是另一个宝藏文件。归一化版本里,如果某个类别的对角线值很低,而某一列特别亮,就说明这个类别被大量误判成了另一类。比如"can"被误判成"glass_bottle",那就针对性补这两类的区分样本。
4. PyQt5 界面:把模型塞进窗口里的正确姿势
模型训好了,best.pt拿到手,接下来才是真正折磨人的部分。界面这东西看似简单,但线程、刷新、资源释放,每一处都有坑。
4.1 界面布局与线程模型设计
先想清楚界面有几个区域。我习惯分成三块:左边是操作区,放"打开图片"、"打开视频"、"开启摄像头"、"停止"这些按钮,加两个滑条控制置信度阈值和 IoU 阈值;中间是显示区,用一个QLabel承载画面,尺寸跟着窗口自适应;右边是结果区,一个表格显示当前帧检测到的物体、类别、置信度,下面放累计统计。
关键是线程模型。Qt 的规则很明确:所有界面控件的更新必须在主线程做,耗时的计算放到子线程,子线程通过信号(signal)把结果传回主线程。
from PyQt5.QtCore import QThread, pyqtSignal import cv2 class InferWorker(QThread): frame_ready = pyqtSignal(object, list) def __init__(self, detector, source, parent=None): super().__init__(parent) self.detector = detector self.source = source self._running = True def run(self): cap = cv2.VideoCapture(self.source) while self._running and cap.isOpened(): ok, frame = cap.read() if not ok: break result = self.detector.infer(frame) boxes = [] for b in result.boxes: xyxy = b.xyxy[0].cpu().numpy().astype(int) boxes.append((xyxy, int(b.cls[0]), float(b.conf[0]))) self.frame_ready.emit(frame, boxes) cap.release() def stop(self): self._running = False self.wait(2000)主线程里把frame_ready信号连到一个槽函数,在槽函数里画框、更新表格。这样即使单帧推理花 80 毫秒,界面依然能响应鼠标操作。
必须提醒一点:子线程里绝对不能碰任何 Qt 控件。有人图省事,直接在run()里调self.label.setPixmap(...),程序在开发机上可能跑得好好的,一换环境就随机崩溃,而且崩溃信息指向的位置和真正的原因完全无关,极难排查。
4.2 模型只加载一次:单例与推理封装
YOLO(weights)这个构造过程要读文件、初始化网络、可能要往显存搬参数,耗时通常在 1 到 3 秒。如果每次检测都重新构造一次,界面就会一顿一顿的。
正确的做法是用单例模式,把模型封装成一个只初始化一次的类:
from ultralytics import YOLO class GarbageDetector: _instance = None def __new__(cls, weights="best.pt", device="cuda"): if cls._instance is None: obj = super().__new__(cls) obj.model = YOLO(weights) obj.device = device obj.names = obj.model.names cls._instance = obj return cls._instance def infer(self, frame, conf=0.25, iou=0.7, imgsz=640): results = self.model.predict( frame, conf=conf, iou=iou, imgsz=imgsz, device=self.device, verbose=False ) return results[0]verbose=False这个参数一定要加。默认情况下 ultralytics 每推理一次就往控制台打印一堆速度统计,视频跑起来控制台会疯狂刷屏,既拖慢速度又让人看不清真正的报错。
另外model.predict()返回的是列表,即使只传一张图也要取[0]。这个细节错了会得到"object has no attribute boxes"这类报错。
4.3 图片、视频、摄像头三种输入的统一处理
三种输入源的差别只在于数据从哪来,后面的处理完全可以统一。图片读一次就停,视频和摄像头是循环读帧。用一个source参数就能覆盖:
- 图片:
source是文件路径字符串,读一帧,处理,显示。 - 视频:
source是视频文件路径,cv2.VideoCapture能直接打开。 - 摄像头:
source传0。
但视频和摄像头的帧率控制要单独处理。如果模型推理速度跟不上视频帧率,cap.read()会一直读、一直积压,内存蹭蹭往上涨,最后卡死。解决办法是控制读取节奏,或者在界面上做一个"跳帧"选项:每处理一帧,丢掉后面不处理的帧。实时监控场景下,丢帧完全可接受,反正人眼也看不出差别。
还有一个坑是资源释放。切换输入源的时候,如果旧的VideoCapture没释放,摄像头会被一直占用,下次再打开就报"设备已被占用"。所以切换前一定要调cap.release(),并且确保线程已经退出。我在stop()里加了self.wait(2000)就是在等线程真正结束,不加这一句,release()可能在read()还在执行时被调用,行为不可预期。
4.4 中文标签绘制与结果统计面板
OpenCV 自带的cv2.putText()不支持中文,直接往里传中文会画出一串问号。解决办法是用 Pillow 绘制,再转回 OpenCV 格式:
import cv2 import numpy as np from PIL import Image, ImageDraw, ImageFont FONT = ImageFont.truetype("simhei.ttf", 22, encoding="utf-8") def draw_chinese_label(frame, text, org, color=(0, 255, 0)): img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)) draw = ImageDraw.Draw(img) draw.text(org, text, font=FONT, fill=color[::-1]) return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)注意颜色要反转一下,因为 PIL 用的是 RGB,OpenCV 用的是 BGR。字体文件建议随程序一起打包,别指望目标机器上一定装了黑体。
为了让框更清楚,可以给每个类别分配固定颜色,用字典存起来。颜色固定之后,看视频时一眼就能分辨出哪个框是什么类别。标签底色建议画一个半透明的填充矩形,纯文字在杂乱背景上根本看不清。
统计面板用一个QTableWidget,列可以是"类别 / 本次数量 / 累计数量 / 平均置信度"。每次检测完更新一次。如果帧率高,每帧都刷新表格会导致界面闪烁,我的做法是每 10 帧刷新一次,或者用一个定时器每 500 毫秒刷一次,人眼看不出延迟,性能却能提升不少。
4.5 打包成 exe 时的路径与依赖坑
用 PyInstaller 打包,权重文件、字体文件、图标这些外部资源必须显式声明:
pyinstaller -F -w main.py \ --add-data "best.pt;." \ --add-data "simhei.ttf;." \ --hidden-import=ultralytics \ --hidden-import=torch \ --collect-all ultralyticsWindows 上--add-data的分隔符是分号,Linux 和 macOS 上是冒号,这个差异经常导致打包出来在别的系统上跑不了。--collect-all用来把 ultralytics 的配置文件一起打进去,不加的话运行时可能报"找不到默认配置"。
打包后资源路径要用下面的方式取,直接用相对路径在打包后必然找不到:
import sys, os def resource_path(rel): base = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base, rel)-w是隐藏控制台窗口,但调试阶段千万别加,否则所有报错都看不见。我的习惯是调试时带控制台打包,确认没问题了再去掉-w。
5. 那些真的会卡住你的问题:排查链路实录
前面讲的都是"应该怎么做",这一节讲"做不出来的时候怎么找原因"。我把实际踩过的几个问题按排查思路完整记录一遍,因为只给答案没有排查过程,下次遇到变种问题还是没辙。
5.1 环境配置:torch 与 CUDA 版本对不上
现象是训练命令一执行就报错,或者更隐蔽的,代码能跑但速度慢得离谱。第一步永远是确认 GPU 到底有没有被用上:
import torch print(torch.__version__) print(torch.version.cuda) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果cuda.is_available()返回 False,但机器确实有独立显卡,通常是装的 torch 是 CPU 版本。判断方法看版本号,2.x.x+cpu后面带 cpu 后缀的就是纯 CPU 包。删掉重装,指定 CUDA 版本对应的索引地址:
pip uninstall torch torchvision torchaudio -y pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121版本对应关系上,cu121表示 CUDA 12.1。驱动版本决定你能用哪个 CUDA 运行时的 torch,不是 CUDA Toolkit 的版本。用nvidia-smi看右上角的驱动版本,然后去 PyTorch 官网查对应表。很多人在这里绕了很久,以为必须装完整版 CUDA Toolkit,其实只跑 PyTorch 的话,装带 CUDA 运行时的 torch 包就够了,系统层面不需要额外装 Toolkit。
还有一个隐性问题:虚拟环境里装了多份 torch。pip list | findstr torch看一下,如果出现多个版本,先全部卸干净再装。
5.2 界面无显示、窗口一片空白
PyQt5 界面不显示有好几种表现,对应的原因完全不同。
第一种是程序启动了但窗口根本没有出来,任务管理器里能看到进程。这种情况先看控制台有没有 "could not find or load the Qt platform plugin"。这个问题多半是环境变量或者插件路径的问题,可以试着设置QT_QPA_PLATFORM_PLUGIN_PATH指向 PyQt5 安装目录下的Qt5/plugins/platforms。
第二种是窗口出来了,但中间显示画面的区域一片白或一片黑。这个通常是绘制逻辑的问题,检查一下传给QLabel的QPixmap是不是空的。常见错误是把QImage用完就释放了,导致QPixmap引用了一块已经失效的内存。安全的写法是让QImage保留一份数据的拷贝:
from PyQt5.QtGui import QImage, QPixmap def to_pixmap(frame_bgr): h, w, c = frame_bgr.shape rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) qimg = QImage(rgb.data, w, h, w * c, QImage.Format_RGB888).copy() return QPixmap.fromImage(qimg)注意那个.copy(),不加的话在某些情况下画面会花屏或者干脆不显示。
第三种是在远程桌面或者虚拟环境里运行,窗口一闪就没。这跟图形渲染的后端有关,可以试试在程序最开头加上:
import os os.environ["QT_OPENGL"] = "software"软件渲染不依赖显卡驱动,兼容性最好,代价是刷新稍慢。
5.3 训练 loss 不降、mAP 一直为 0
这个问题的排查顺序我固定下来了,从概率最高的开始:
第一,检查标签文件数量。训练启动时会打印一段扫描结果,里面有 "train: Scanning… images, N backgrounds" 之类的信息。如果 N 等于你的训练图片总数,说明所有图片都没配上标签文件,模型在学"所有图都是空的"。
第二,检查标签内容格式。随便打开一个 txt,看是不是0 0.512 0.334 0.221 0.180这种归一化格式。如果有整数坐标、有负值、有大于 1 的值,都需要重新生成。
第三,检查 data.yaml 里的nc。如果nc设成 4,而你的标签里出现了类别编号 5,这个标注会被忽略或者报错。数一下标签里出现的最大类别编号,加一,就是nc的最小值。
第四,检查图片和标签的文件名是否严格一一对应。.jpg对应.txt,.JPG也对应.txt,但如果你把图片改名成img_01.jpg而标签还是1.txt,就对不上了。批量重命名的时候特别容易出这个问题。
第五,检查学习率。如果标签和路径都没问题,loss 还是从头到尾是 NaN 或者极大值,把lr0从 0.01 降到 0.001 再试。这种情况在小数据集上偶有发生。
5.4 推理速度慢与显存溢出
推理慢的排查,先量化再优化。在推理代码里手动计时,分别测出图像预处理、model.predict()、后处理绘制三段的耗时。绝大多数情况下瓶颈在model.predict()里。
优化手段按投入产出比排序:把imgsz从 640 降到 480 或 416,速度能提升近一倍,精度损失通常在一两个点以内;开half=True用半精度推理,在有 Tensor Core 的显卡上能提速三到五成,精度几乎无损;确认没有重复构造模型;确认verbose=False。
显存溢出的报错是 "CUDA out of memory"。除了降 batch 和 imgsz,还有一个容易被忽略的原因:显存碎片。长时间跑视频推理,如果每帧都创建新的张量而不释放,显存会慢慢耗尽。用torch.cuda.empty_cache()定期清理有帮助,但更根本的是确认没有把每帧的结果累积存起来。我在界面上就犯过这个错,把每一帧的检测结果都追加到一个列表里用于"回放",跑十分钟就爆显存了。
5.5 中文路径与 OpenCV 读取失败
cv2.imread()在遇到中文路径时,在某些环境下会静默返回 None,不报错,只是读不到图。然后下游代码拿到 None 去做 resize,报出一堆莫名其妙的错误。
解决办法是用字节流读:
import cv2 import numpy as np def imread_unicode(path): data = np.fromfile(path, dtype=np.uint8) if data.size == 0: return None return cv2.imdecode(data, cv2.IMREAD_COLOR) def imwrite_unicode(path, img): ext = os.path.splitext(path)[1] ok, buf = cv2.imencode(ext, img) if ok: buf.tofile(path) return ok同理,保存检测结果的图片时也要用imwrite_unicode。另外把数据集路径、模型路径尽量都改成英文,能从源头上避开这一整类问题。
6. 从能跑到好用:准确率与体验的二次优化
第一版跑通之后,mAP 大概在 0.7 到 0.85 之间,能演示,但离"好用"还有距离。这个阶段要做的事情,比调参更具体。
6.1 针对小目标和遮挡的处理
垃圾检测里最常漏的是两类:远处的小物体,和堆在一起互相遮挡的物体。
小目标的本质问题是分辨率。640 输入下,一个原图里 40 像素宽的物体,缩放后只剩十几像素,特征已经很稀薄了。可以尝试的路径有:把训练和推理的imgsz提到 960 或 1280,让特征更丰富;在模型结构上增加 P2 检测层,专门负责更小的尺度;或者用切片推理的思路,把大图切成若干小图分别检测再合并结果。第一种最省事,代价是速度,实际用下来 960 是个不错的平衡点。
遮挡的解决思路主要靠数据。去投放点专门拍几组"垃圾堆"的照片,标的时候注意:被遮挡超过 70% 的物体标注意义不大,可以跳过;遮挡 30% 到 70% 的反而要重点标,这是模型最需要学习的形态。另外可以在增强里适当加点随机遮挡,让模型习惯"不完整的目标"。
6.2 置信度阈值与 IoU 阈值怎么定
这两个参数决定了屏幕上最终显示什么,比模型本身还影响观感。
conf是置信度阈值,低于它的框全部丢掉。默认 0.25 是通用值,但不同场景要调:
| 场景 | 建议 conf | 理由 |
|---|---|---|
| 演示展示 | 0.4 ~ 0.5 | 只显示高置信结果,看着干净 |
| 实际统计 | 0.2 ~ 0.3 | 宁可多框,避免漏计 |
| 报警触发 | 0.5 ~ 0.6 | 误报代价高,宁可漏 |
| 数据筛选 | 0.15 | 用来挖出难例 |
iou是 NMS 的阈值。默认 0.7。如果发现同一个物体上出现两个重叠的框,说明 iou 太高,降到 0.5 或 0.6。如果发现相邻的两个瓶子只框出来一个,说明 iou 太低,调高到 0.8。
这两个参数都应该做成界面上的滑条,让使用者在实际画面里现场调。我的经验是,用滑条调出来的值,比在训练脚本里写死一个值要准得多,因为不同摄像头、不同光照下最优值真的不一样。
6.3 类别混淆的针对性补数据
训练完看confusion_matrix_normalized.png,找那些明显被混淆的类别对。常见的几组是:易拉罐和玻璃瓶、纸盒和纸杯、塑料袋和其他薄膜类。
找到之后,不要急着调参,先补数据。补的方法有讲究:不能只补被误判的那一类,要把成对的类别都补,而且最好补一些"放在一起对比"的样本,让模型学会区分它们的差异点——易拉罐有金属反光,玻璃瓶有透光和折射,这些特征只有对比样本才能强化。
另一个技巧是检查标注一致性。同一类物体,有的人标成 A 有的人标成 B,这种标注噪声造成的混淆,无论怎么补数据都治不好,必须先统一标注规范。
6.4 导出 ONNX 与推理加速的可行性
如果目标是部署到边缘设备或对速度有更高要求,导出 ONNX 是第一步:
from ultralytics import YOLO model = YOLO("runs/garbage/exp_s/weights/best.pt") model.export(format="onnx", imgsz=640, simplify=True, opset=12)simplify=True会用 onnx-simplifier 做一次图优化,去掉冗余节点。opset版本要和后续推理引擎匹配,12 是个比较通用的值。
导出之后,可以用 ONNX Runtime 推理,也可以用 OpenVINO、TensorRT 这类专门的推理引擎进一步加速。这里要说实话:加速的收益和投入是严重不成比例的。从 PyTorch 到 ONNX Runtime,CPU 上大概能快一到两倍;从 ONNX 到 TensorRT,在 NVIDIA 设备上还能再快。但每换一个推理后端,预处理和后处理都要重写,输出格式也常常不一样,调试成本很高。
我的建议是:先用 PyTorch 原生推理把功能跑完整,确认需求确实需要更高性能,再考虑转换。很多项目根本没到需要转换的程度,纯粹是为了"看起来更专业"而多花了两周。
界面层面还有一些体验优化的空间。比如把检测历史存下来,支持导出 CSV;加一个"只看某一类"的过滤开关;把当前帧的检测结果按置信度排序,最高的排最上面。这些都不难做,但使用者感知很强。
提示:导出前先把权重路径确认清楚。
runs/目录下会有多个实验子目录,last.pt和best.pt是两个文件,last是最后一轮的,best是验证指标最好的。绝大多数情况下你要用的是best.pt,拿错文件会导致效果莫名下降。
最后分享一个小经验:这套系统在开发机上跑得再顺,也别急着直接拿去现场用。找个类似的真实环境,连着跑上两三个小时,看内存和显存会不会慢慢涨、看长时间运行时有没有偶发的崩溃。我见过太多次"演示五分钟没问题、连着跑一天就挂"的情况,而这类问题往往就是某个资源没释放、某个列表一直在追加导致的,只有长时间运行才能暴露出来。