朋友接了个社区环保的活儿想在投放点装个摄像头屏幕上实时框出塑料袋、纸箱、饮料瓶顺便统计一天各类垃圾的数量。需求听起来不复杂但从数据标注一路折腾到界面卡顿、打包后模型文件找不到前后花了将近三周才跑顺。这套基于 YOLOv8 深度学习的生活垃圾分类目标检测系统本质上就是四件事拼起来一份能用的数据集、一段能收敛的训练代码、一个把推理包起来的 Python 接口加一个 PyQt5 图形界面。中间任何一个环节掉链子最后看到的都是框不出来或者界面转圈。下面我把这套东西从头到尾拆一遍包括每个参数的取舍理由、界面线程的正确写法以及那些文档里不会写、只有真跑过才会撞上的问题。1. 这套垃圾分类检测系统到底由哪几块拼起来很多人一上来就急着 clone 代码、装环境结果卡在依赖版本上耗掉两天其实先把系统的数据流画清楚后面每一步都会顺很多。这套系统从摄像头画面到屏幕上的方框中间经过的环节比想象中多。1.1 从一张垃圾照片到界面方框的完整链路整条链路大致是这样图像输入图片文件 / 视频文件 / 摄像头帧→ 尺寸调整与归一化 → YOLOv8 前向推理 → 后处理置信度过滤 NMS 非极大值抑制→ 类别编号映射回中文名称 → 坐标反算回原图尺寸 → 绘制方框与标签 → 统计面板累加计数。这里有两个容易被忽略的点。第一YOLOv8 推理时输入的尺寸会被统一 resize 到imgsz默认 640输出的坐标是基于 640 这个尺度的必须按原图的宽高比例反算回去否则框会偏移甚至飞出画面。第二NMS 是把同一物体上重叠的多个候选框合并成一个iou阈值设得太低会误删相邻的两个瓶子设得太高又会保留重复框这个参数在界面里最好做成可调滑条。理解这条链路之后你就会明白为什么界面卡死是个必然问题如果推理直接跑在 Qt 的主线程里一帧要 30 到 80 毫秒主线程被占住界面就没法刷新鼠标点击也没响应。1.2 四个模块各自的职责边界把这套系统拆开看它其实是四个互相独立、只通过文件和数据接口耦合的模块模块输入输出主要依赖数据集与标注原始图片、类别定义images/ 与 labels/ 目录、data.yamllabelImg / X-AnyLabeling训练脚本data.yaml、预训练权重best.pt、训练日志、评估图表ultralytics、PyTorch推理引擎封装best.pt、单帧图像检测框列表、类别、置信度ultralytics、OpenCV、NumPyPyQt5 界面用户操作、推理结果可视化画面、统计表格PyQt5、Pillow这样拆的好处是调试时可以逐个击破模型不准只动训练脚本界面卡只动线程程序崩先看推理封装有没有返回异常。我见过不少人把model.predict()直接写在按钮的槽函数里模型加载、推理、绘制全挤在一起出了问题根本不知道是哪一段。1.3 硬件与软件底座的现实选择关于硬件网上常见的说法是没有独立显卡就别做深度学习这话对训练成立对推理不完全成立。训练阶段一块 6GB 显存的卡比如常见的 GTX 1660Ti 这个档位跑yolov8n或yolov8simgsz640、batch8是完全可行的如果显存报 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 \ modelyolov8s.pt \ datadataset/data.yaml \ epochs150 \ imgsz640 \ batch16 \ device0 \ workers4 \ lr00.01 \ lrf0.01 \ patience50 \ projectruns/garbage \ nameexp_s逐个说清楚每个值的来历epochs150整个数据集过 150 遍。太少学不透太多过拟合。判断标准是看验证集指标什么时候不再提升而不是固定数字。如果你只有几百张图50 到 100 足够图多的话 200 到 300 也正常。batch16一次喂给模型多少张图。这个值直接吃显存。6GB 显存下yolov8s加imgsz640batch 16 可能刚好跑不起来就降到 8。注意 batch 变了学习率最好跟着微调因为 batch 越大梯度的噪声越小可以用更大的学习率。lr00.01初始学习率。YOLOv8 用的是 SGD 加余弦退火lr0是起点训练过程中会逐步衰减到lr0 * lrf也就是 0.0001。如果 loss 一开始就爆成 NaN先把 lr0 降到 0.001 试试。patience5050 个 epoch 内验证指标没有提升就提前停止。这个机制能省下大量时间尤其是数据集小的时候模型往往在 60 到 80 轮就到顶了。freeze参数值得一提。它可以冻结骨干网络的前 N 层只训练检测头。数据量特别少比如只有三五百张的时候冻结部分层能有效防止过拟合训练也更快。一般freeze10是个可用的起点也就是冻结前 10 层。3.3 数据增强对垃圾类别的实际影响YOLOv8 默认开着 Mosaic 增强把四张图拼成一张。这个策略对检测任务很友好因为它一次就让模型看到四种不同的背景和目标组合等效于扩大了数据集。但放到垃圾检测上有几个增强需要手动关掉或调小。上下翻转flipud默认是关的这个默认值是对的别去打开。垃圾不会倒挂在天花板上上下翻转会制造大量现实中不存在的形态反而干扰学习。左右翻转fliplr0.5可以保留。色相、饱和度、亮度扰动hsv_h、hsv_s、hsv_v建议适当加大。垃圾检测最大的挑战之一就是光照差异同一个饮料瓶在日光下和夜间路灯下颜色差别巨大亮度扰动能显著提升模型对光照的鲁棒性。我一般把hsv_v设到 0.5 左右。Mosaic 有个副作用训练最后 10 个 epoch 建议关掉它。因为 Mosaic 拼出来的图目标的尺度和位置分布和真实图片有差异最后阶段用原始图片微调一下能把指标再拉一点。这个开关在 YOLOv8 里是close_mosaic10意思就是最后 10 轮关闭。这个小设置经常被忽略但对最终 mAP 有实际影响。3.4 看损失曲线判断训练到底有没有跑偏训练跑起来之后控制台会滚动输出三个 lossbox_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, parentNone): 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, weightsbest.pt, devicecuda): 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, conf0.25, iou0.7, imgsz640): results self.model.predict( frame, confconf, iouiou, imgszimgsz, deviceself.device, verboseFalse ) return results[0]verboseFalse这个参数一定要加。默认情况下 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, encodingutf-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, fontFONT, fillcolor[::-1]) return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)注意颜色要反转一下因为 PIL 用的是 RGBOpenCV 用的是 BGR。字体文件建议随程序一起打包别指望目标机器上一定装了黑体。为了让框更清楚可以给每个类别分配固定颜色用字典存起来。颜色固定之后看视频时一眼就能分辨出哪个框是什么类别。标签底色建议画一个半透明的填充矩形纯文字在杂乱背景上根本看不清。统计面板用一个QTableWidget列可以是类别 / 本次数量 / 累计数量 / 平均置信度。每次检测完更新一次。如果帧率高每帧都刷新表格会导致界面闪烁我的做法是每 10 帧刷新一次或者用一个定时器每 500 毫秒刷一次人眼看不出延迟性能却能提升不少。4.5 打包成 exe 时的路径与依赖坑用 PyInstaller 打包权重文件、字体文件、图标这些外部资源必须显式声明pyinstaller -F -w main.py \ --add-data best.pt;. \ --add-data simhei.ttf;. \ --hidden-importultralytics \ --hidden-importtorch \ --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.xcpu后面带 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速度能提升近一倍精度损失通常在一两个点以内开halfTrue用半精度推理在有 Tensor Core 的显卡上能提速三到五成精度几乎无损确认没有重复构造模型确认verboseFalse。显存溢出的报错是 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, dtypenp.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(formatonnx, imgsz640, simplifyTrue, opset12)simplifyTrue会用 onnx-simplifier 做一次图优化去掉冗余节点。opset版本要和后续推理引擎匹配12 是个比较通用的值。导出之后可以用 ONNX Runtime 推理也可以用 OpenVINO、TensorRT 这类专门的推理引擎进一步加速。这里要说实话加速的收益和投入是严重不成比例的。从 PyTorch 到 ONNX RuntimeCPU 上大概能快一到两倍从 ONNX 到 TensorRT在 NVIDIA 设备上还能再快。但每换一个推理后端预处理和后处理都要重写输出格式也常常不一样调试成本很高。我的建议是先用 PyTorch 原生推理把功能跑完整确认需求确实需要更高性能再考虑转换。很多项目根本没到需要转换的程度纯粹是为了看起来更专业而多花了两周。界面层面还有一些体验优化的空间。比如把检测历史存下来支持导出 CSV加一个只看某一类的过滤开关把当前帧的检测结果按置信度排序最高的排最上面。这些都不难做但使用者感知很强。提示导出前先把权重路径确认清楚。runs/目录下会有多个实验子目录last.pt和best.pt是两个文件last是最后一轮的best是验证指标最好的。绝大多数情况下你要用的是best.pt拿错文件会导致效果莫名下降。最后分享一个小经验这套系统在开发机上跑得再顺也别急着直接拿去现场用。找个类似的真实环境连着跑上两三个小时看内存和显存会不会慢慢涨、看长时间运行时有没有偶发的崩溃。我见过太多次演示五分钟没问题、连着跑一天就挂的情况而这类问题往往就是某个资源没释放、某个列表一直在追加导致的只有长时间运行才能暴露出来。