1. 从一堆垃圾照片说起:这个项目到底在做什么
去年夏天,我接手了一个社区垃圾分类督导的数字化改造小项目。说白了,就是小区里那排智能垃圾桶旁边装了个摄像头,居民扔垃圾的时候,系统得自动判断手里拿的是不是可回收物、有没有混投。一开始我们用的是最朴素的办法——人工看监控回放,结果三天下来,督导员眼睛都快瞎了,误判率还高得离谱。后来我决定上目标检测模型,选来选去,最终落在了YOLOv8上,并且针对生活垃圾这个特定场景做了一轮改进。
这个项目的核心,就是基于深度学习的垃圾分类识别目标检测系统。它要解决的问题很具体:给定一张生活垃圾的图像,系统需要同时完成两件事——定位(垃圾在画面中的哪个位置,用边界框标出来)和分类(这个垃圾属于可回收物、厨余垃圾、有害垃圾还是其他垃圾)。这跟普通的图像分类不一样,分类只告诉你“这张图里有塑料瓶”,而目标检测要告诉你“画面左下角那个塑料瓶是可回收物,右上角那团纸巾是其他垃圾”。
适合谁来参考这篇内容?如果你正在做YOLOv8训练自己的数据集、想了解目标检测算法在真实场景中的落地细节、或者单纯对深度学习图像识别感兴趣,那这篇东西应该能帮你省下不少查文档和踩坑的时间。我会把从数据准备、模型改进、训练调参到部署上线的完整链路拆开讲,重点放在那些官方文档里不会写、但实际做项目时一定会遇到的细节上。
2. 为什么选YOLOv8,以及我为什么还要改它
2.1 目标检测算法的选型逻辑
做目标检测,市面上主流的路子就那么几条:两阶段的Faster R-CNN系列、单阶段的SSD和YOLO系列、还有基于Transformer的DETR家族。我选YOLOv8,理由很直接:
- 速度与精度的平衡:垃圾分类是个实时性要求不低的场景,垃圾桶旁边的摄像头得在居民扔完垃圾之前给出反馈。YOLOv8在COCO数据集上的mAP和推理速度,在同等参数量下基本是第一梯队。
- 工程化成熟度:Ultralytics这个库的封装做得太舒服了,从训练到导出ONNX、TensorRT,一条命令的事。相比之下,DETR系列虽然精度不错,但训练收敛慢,小目标检测也容易翻车。
- 社区生态:遇到问题能搜到答案。你搜“yolov8训练自己的数据集”,能出来几百篇教程,虽然质量参差不齐,但至少说明用的人多。
但直接用官方预训练的YOLOv8n或者YOLOv8s,在垃圾分类场景下有几个明显的问题。第一,生活垃圾的类别间差异有时候非常细微——比如“用过的纸巾”和“干净的纸板”,在像素层面可能都是灰白色的块状物,模型很容易混淆。第二,垃圾图像里小目标特别多,比如烟头、瓶盖、药片板,这些在640×640的输入分辨率下,经过多次下采样后特征几乎消失。第三,实际场景的光照条件恶劣,垃圾桶旁边经常是背光或者夜间补光,图像质量不稳定。
2.2 我做的几处关键改进
针对上面这些问题,我在YOLOv8的基础上做了三处改动,实测下来对垃圾分类场景的提升比较明显。
第一处是注意力机制的引入。我在Backbone的C2f模块后面加了协调注意力机制(Coordinate Attention)。普通的SE注意力只关注通道维度,但垃圾图像里很多关键信息是空间相关的——比如一个瓶子,它的瓶身和瓶盖在空间上是分离的,但语义上是一个整体。CoordAttention把通道注意力分解成两个一维的特征编码,分别沿水平和垂直方向聚合信息,这样既能捕获通道间的依赖,又能保留空间位置信息。代码上就是在ultralytics/nn/modules/block.py里加了一个CoordAtt模块,然后在yaml配置文件里把对应的C2f替换掉。
第二处是Head部分的改进。官方YOLOv8的检测头是解耦的,分类和回归分支分开,这个设计本身没问题。但我发现对于垃圾这种类内差异大、类间差异小的数据,分类分支的感受野需要更大一些。我把分类分支的卷积核从3×3换成了5×5,同时增加了一个额外的下采样特征层,专门用来检测那些特别小的目标。这个改动会增加一些参数量,但换来的是小目标召回率提升了大概7个百分点。
第三处是数据增强策略的调整。官方默认的Mosaic增强对垃圾分类其实不太友好——它把四张图拼在一起,容易让模型学到一些不存在的上下文关系。比如一张图里同时出现香蕉皮和电池,模型可能会误以为它们经常一起出现。我改成了Mosaic和MixUp交替使用,并且在最后10个epoch关掉Mosaic,让模型在真实分布上做微调。另外针对光照问题,我加了随机Gamma校正和CLAHE(限制对比度自适应直方图均衡化),模拟不同光照条件下的垃圾外观。
注意:改进不是越多越好。我试过同时加注意力、换Head、改损失函数,结果训练直接不收敛。后来一个一个加,每次只改一个地方,观察验证集指标的变化,才找到真正有效的组合。
3. 数据准备:垃圾分类数据集的构建与清洗
3.1 数据来源与类别定义
垃圾分类的数据集,公开的其实不少,但质量参差不齐。我主要用了两个来源:一个是某环保组织公开的垃圾图像库,大概有8000张;另一个是自己用手机在小区垃圾桶旁边拍的,大概3000张。公开数据集的好处是标注相对规范,坏处是场景单一,很多都是在白色背景下的摆拍,跟实际垃圾桶旁边的环境差距很大。自己拍的数据更真实,但标注得从头来。
类别定义上,我按照通用的四分类法:可回收物、厨余垃圾、有害垃圾、其他垃圾。但实际标注的时候发现,有些东西的归属很模糊。比如“一次性餐盒”,如果干净的是可回收物,如果沾了油污就是其他垃圾。这种边界情况我最后统一按“其他垃圾”处理,因为在实际督导场景里,宁可让居民把可回收物扔进其他垃圾,也不能让污染物混进可回收物。
最终的数据集规模是11000张图像,类别分布如下:
| 类别 | 训练集 | 验证集 | 测试集 | 总计 |
|---|---|---|---|---|
| 可回收物 | 3850 | 550 | 550 | 4950 |
| 厨余垃圾 | 2450 | 350 | 350 | 3150 |
| 有害垃圾 | 980 | 140 | 140 | 1260 |
| 其他垃圾 | 1280 | 180 | 180 | 1640 |
| 合计 | 8560 | 1220 | 1220 | 11000 |
这个分布是不均衡的,可回收物最多,有害垃圾最少。不均衡带来的问题是模型会偏向多数类,对有害垃圾的识别率很低。我的处理方式是在损失函数里给少数类更高的权重,具体来说,在YOLOv8的BCE分类损失里,给每个类别的正样本损失乘以一个权重系数,权重跟类别频率成反比。这个系数不是拍脑袋定的,我用了sklearn.utils.class_weight.compute_class_weight算了一个初始值,然后根据验证集上的混淆矩阵微调。
3.2 标注规范与质量控制
标注用的是LabelImg,格式是YOLO的txt格式,每行是class_id x_center y_center width height,坐标都归一化到0到1之间。标注的时候有几个坑我踩过:
- 边界框不要贴太紧:有些标注员喜欢把框画得刚刚好包住物体,但这样在数据增强的时候,一旦做随机裁剪或者缩放,物体边缘容易被切掉。我要求框比物体实际边界外扩5%左右。
- 遮挡处理:垃圾堆叠是常态,一个瓶子被另一个瓶子挡住一半,这种情况我要求只要可见部分超过30%就标,否则不标。标的时候框只框可见部分,不要脑补被遮挡的部分。
- 小目标单独检查:烟头、瓶盖、药片这些小于32×32像素的目标,我专门抽了500张出来人工复核,确保没有漏标。漏标比错标更致命,因为模型会把漏标的目标当成背景,学到错误的特征。
标注完成后,我写了一个脚本做一致性检查:遍历所有标注文件,检查坐标是否越界、宽高是否为零、类别ID是否在有效范围内。这个脚本帮我抓出了大概200个有问题的标注文件,主要是坐标越界和宽高为负。
3.3 数据增强的实操配置
YOLOv8的数据增强配置在data.yaml和训练参数里。我的配置是这样的:
# data.yaml path: ./dataset train: images/train val: images/val test: images/test names: 0: recyclable 1: kitchen_waste 2: hazardous_waste 3: other_waste训练时的增强参数:
from ultralytics import YOLO model = YOLO('yolov8s.yaml') model.train( data='data.yaml', epochs=200, imgsz=640, batch=16, mosaic=1.0, # 前190个epoch开启Mosaic mixup=0.15, # MixUp概率 copy_paste=0.1, # 复制粘贴增强 degrees=10.0, # 随机旋转角度 translate=0.1, # 随机平移 scale=0.5, # 随机缩放 shear=2.0, # 随机剪切 perspective=0.0005,# 透视变换 flipud=0.0, # 上下翻转关闭,垃圾图像上下翻转不合理 fliplr=0.5, # 左右翻转 hsv_h=0.015, # 色调抖动 hsv_s=0.7, # 饱和度抖动 hsv_v=0.4, # 明度抖动 close_mosaic=10, # 最后10个epoch关闭Mosaic )这里重点说几个参数的选择理由。mosaic=1.0表示100%的概率做Mosaic增强,但我在最后10个epoch用close_mosaic=10关掉了,原因是Mosaic会让图像的真实分布发生偏移,最后阶段需要让模型在真实分布上收敛。mixup=0.15是一个比较保守的值,MixUp太强会导致图像语义模糊,垃圾本来就难分,再模糊就更分不清了。flipud=0.0是因为垃圾图像上下翻转后不符合物理常识,比如一个倒置的瓶子看起来很奇怪,模型学到的特征没有意义。
4. 模型训练:参数调优与损失曲线解读
4.1 环境配置与训练硬件
训练环境这块,我试过两种配置:本地一台带GTX 1660 Ti的机器,和云上的A100。GTX 1660 Ti跑YOLOv8s,batch size只能开到8,一个epoch大概要4分钟,200个epoch下来十几个小时。A100上batch size开到64,一个epoch不到1分钟,但成本高。对于这个项目,我建议如果是学习目的,GTX 1660 Ti完全够用,只是慢一点;如果是赶项目进度,云上租一张A100或者3090会舒服很多。
环境配置的步骤:
# 创建虚拟环境 conda create -n yolov8 python=3.9 conda activate yolov8 # 安装PyTorch(根据CUDA版本选择) pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cu118 # 安装Ultralytics pip install ultralytics # 验证安装 yolo checksyolo checks会输出当前环境的信息,包括PyTorch版本、CUDA是否可用、GPU型号等。如果CUDA不可用,检查一下驱动版本和PyTorch的CUDA版本是否匹配。
4.2 训练参数的含义与调优
YOLOv8的训练参数很多,我挑几个对垃圾分类场景影响最大的说。
学习率(lr0):初始学习率默认是0.01,我用的是0.001。原因是垃圾分类数据集不大,11000张图,学习率太大会导致损失震荡。我用了一个简单的策略:先跑50个epoch,观察损失曲线,如果震荡厉害就减半,如果下降太慢就加倍。最终定在0.001,配合余弦退火调度,训练很稳。
权重衰减(weight_decay):默认0.0005,我调到了0.001。垃圾分类的类别间差异小,模型容易过拟合到训练集的特定纹理上,增大权重衰减相当于加了一个L2正则,有助于泛化。
** warmup_epochs**:默认3,我改成了5。warmup阶段学习率从0线性增加到初始学习率,让模型在训练初期不要更新太猛。垃圾分类的预训练权重是在COCO上学的,跟垃圾图像分布差异大,warmup长一点更稳。
box损失权重和cls损失权重:YOLOv8默认box=7.5,cls=0.5。我调成了box=5.0,cls=1.5。原因是垃圾分类更看重分类准确率,定位稍微偏一点问题不大,但类别分错了就是大问题。增大cls权重让模型更关注分类分支的优化。
训练轮数(epochs):我设了200,但实际在150左右就收敛了。判断收敛的方法是看验证集的mAP50,如果连续20个epoch没有提升,就可以停了。YOLOv8自带早停机制,patience=50,我设成了30。
4.3 损失曲线与指标解读
训练过程中,YOLOv8会输出几个关键指标:box_loss、cls_loss、dfl_loss、precision、recall、mAP50、mAP50-95。我一般会画损失函数曲线图来观察训练状态。
import pandas as pd import matplotlib.pyplot as plt # 读取训练结果 results = pd.read_csv('runs/detect/train/results.csv') fig, axes = plt.subplots(2, 2, figsize=(12, 10)) # 损失曲线 axes[0, 0].plot(results['epoch'], results['train/box_loss'], label='train_box') axes[0, 0].plot(results['epoch'], results['val/box_loss'], label='val_box') axes[0, 0].set_title('Box Loss') axes[0, 0].legend() axes[0, 1].plot(results['epoch'], results['train/cls_loss'], label='train_cls') axes[0, 1].plot(results['epoch'], results['val/cls_loss'], label='val_cls') axes[0, 1].set_title('Classification Loss') axes[0, 1].legend() # mAP曲线 axes[1, 0].plot(results['epoch'], results['metrics/mAP50(B)'], label='mAP50') axes[1, 0].plot(results['epoch'], results['metrics/mAP50-95(B)'], label='mAP50-95') axes[1, 0].set_title('mAP') axes[1, 0].legend() # 精确率和召回率 axes[1, 1].plot(results['epoch'], results['metrics/precision(B)'], label='precision') axes[1, 1].plot(results['epoch'], results['metrics/recall(B)'], label='recall') axes[1, 1].set_title('Precision & Recall') axes[1, 1].legend() plt.tight_layout() plt.savefig('training_curves.png')看损失曲线有几个经验:训练损失和验证损失同步下降,说明没有过拟合;如果训练损失继续降但验证损失开始升,就是过拟合了,需要加正则或者减模型复杂度。分类损失如果下降很慢,说明类别区分度不够,可能需要检查标注质量或者增加分类分支的容量。
我最终的模型在测试集上的指标是:mAP50=0.892,mAP50-95=0.673,精确率0.901,召回率0.856。其中有害垃圾的召回率最低,只有0.78,主要原因是样本太少,而且有害垃圾(电池、药品、灯管)的外观差异很大,模型很难学到统一的特征。
5. 部署落地:从PyTorch到RK3588的完整链路
5.1 模型导出与格式转换
训练完的模型是PyTorch的.pt格式,要部署到边缘设备上,需要转成ONNX或者TensorRT。我用的边缘设备是RK3588,它支持RKNN格式,所以链路是:PyTorch -> ONNX -> RKNN。
from ultralytics import YOLO # 加载训练好的模型 model = YOLO('runs/detect/train/weights/best.pt') # 导出ONNX model.export(format='onnx', imgsz=640, simplify=True, opset=12)导出ONNX的时候有几个坑。simplify=True会调用onnx-simplifier做图优化,去掉一些冗余节点,但有时候会引入一些不支持的算子。opset=12是比较稳的版本,太高了RKNN可能不支持。导出后最好用onnxruntime跑一下推理,确认输出跟PyTorch一致。
import onnxruntime as ort import numpy as np session = ort.InferenceSession('best.onnx') input_name = session.get_inputs()[0].name dummy = np.random.randn(1, 3, 640, 640).astype(np.float32) output = session.run(None, {input_name: dummy}) print(output[0].shape) # 应该是 (1, 84, 8400) 对于4类5.2 RK3588上的部署实操
RK3588的部署需要用到RKNN-Toolkit2。首先在PC上把ONNX转成RKNN:
from rknn.api import RKNN rknn = RKNN(verbose=True) # 配置 rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_dtype='asymmetric_quantized-8', ) # 加载ONNX ret = rknn.load_onnx(model='best.onnx') if ret != 0: print('Load ONNX failed') exit(ret) # 构建 ret = rknn.build(do_quantization=True, dataset='./quant_dataset.txt') if ret != 0: print('Build failed') exit(ret) # 导出 ret = rknn.export_rknn('best.rknn')量化数据集quant_dataset.txt里放的是校准图像路径,一般准备100到200张有代表性的垃圾图像就行。量化后的模型大小会缩小到原来的四分之一左右,推理速度提升明显,但精度会掉一点。我实测量化后mAP50掉了大概2个百分点,从0.892降到0.871,可以接受。
在RK3588上跑推理,用的是RKNN的C API或者Python API。Python API适合快速验证:
from rknnlite.api import RKNNLite rknn_lite = RKNNLite() rknn_lite.load_rknn('best.rknn') rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1_2) # 推理 img = cv2.imread('test.jpg') img = cv2.resize(img, (640, 640)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) outputs = rknn_lite.inference(inputs=[img])RK3588有三个NPU核心,core_mask可以指定用哪几个。对于实时视频流,我建议用NPU_CORE_0_1_2,三个核心一起跑,帧率能到30fps以上。如果只是单张图片推理,用一个核心就够了,省电。
5.3 后处理与业务逻辑对接
模型输出的原始结果是8400个预测框,每个框有84个值(4个坐标+4个类别分数,对于4类)。后处理需要做三件事:置信度过滤、NMS去重、坐标还原。
def postprocess(outputs, conf_thres=0.25, iou_thres=0.45): # outputs shape: (1, 84, 8400) predictions = outputs[0].transpose(1, 0) # (8400, 84) # 置信度过滤 scores = predictions[:, 4:] max_scores = np.max(scores, axis=1) mask = max_scores > conf_thres predictions = predictions[mask] max_scores = max_scores[mask] # 坐标转换 boxes = predictions[:, :4] boxes_xyxy = xywh2xyxy(boxes) # NMS indices = nms(boxes_xyxy, max_scores, iou_thres) return boxes_xyxy[indices], max_scores[indices], np.argmax(scores[mask][indices], axis=1)业务逻辑上,我把检测结果映射到四个垃圾桶的类别,然后通过串口发给控制板,控制对应的桶盖打开。这里有个细节:如果一张图里检测到多个类别的垃圾,我取置信度最高的那个类别,而不是让多个桶同时打开。因为实际场景里,居民一次通常只扔一种垃圾。
6. 踩坑记录与常见问题速查
6.1 训练阶段的典型问题
问题一:损失不收敛,box_loss一直在0.5以上震荡。排查下来是学习率太大,而且warmup不够。把lr0从0.01降到0.001,warmup_epochs从3加到5,问题解决。另外检查一下标注文件里有没有坐标越界的,越界的标注会导致损失计算异常。
问题二:mAP50很高但mAP50-95很低。这说明模型能检测到物体,但边界框不够准。原因是标注框的质量不高,或者box损失权重太低。我把box权重从7.5调到5.0反而更差了,后来调回7.5,同时用了一个标注复核脚本重新检查了一遍边界框,mAP50-95从0.58提升到0.67。
问题三:验证集损失比训练集损失还低。这通常是因为验证集的数据分布比训练集简单,或者验证集的增强比训练集弱。检查一下验证集是不是用了跟训练集不同的预处理,确保两者一致。
6.2 部署阶段的典型问题
问题一:ONNX导出后推理结果跟PyTorch不一致。最常见的原因是输入归一化方式不同。YOLOv8的PyTorch模型内部做了0-1归一化,但导出ONNX后这个操作可能被融合或者丢失。检查ONNX的输入是否需要手动除以255。
问题二:RKNN量化后精度掉太多。量化校准集的选择很关键。如果校准集里全是可回收物的图像,模型对厨余垃圾的量化误差就会很大。校准集要覆盖所有类别,而且最好包含一些困难样本,比如光照差的、遮挡的。
问题三:RK3588上推理速度慢。检查一下是不是用了CPU推理而不是NPU。init_runtime的时候指定core_mask,确保用的是NPU核心。另外,输入图像的预处理(resize、归一化)如果在CPU上做,也会拖慢速度,可以考虑用RK3588的RGA硬件加速。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失震荡不收敛 | 学习率过大 | 观察loss曲线 | 降低lr0,增加warmup |
| mAP50高但mAP50-95低 | 边界框不准 | 可视化预测框 | 检查标注质量,调整box权重 |
| 某类别召回率极低 | 样本不均衡 | 看混淆矩阵 | 增加该类样本,调整类别权重 |
| ONNX推理结果异常 | 预处理不一致 | 对比PyTorch输出 | 检查归一化,手动对齐 |
| RKNN量化精度掉太多 | 校准集不具代表性 | 对比量化前后mAP | 扩充校准集,覆盖所有类别 |
| 边缘设备推理慢 | 未用NPU | 查看运行时日志 | 指定NPU核心,用RGA加速预处理 |
最后再分享一个小技巧:训练的时候开
plots=True,YOLOv8会自动生成混淆矩阵、PR曲线、F1曲线等图表。这些图比数字更能说明问题。比如混淆矩阵里如果“可回收物”和“其他垃圾”之间的误判很多,说明这两个类别的特征区分度不够,可能需要重新审视类别定义或者增加更多区分性强的样本。
这个项目从数据采集到部署上线,前前后后折腾了大概两个月。最大的体会是,目标检测在真实场景里的难点从来不是模型结构本身,而是数据质量和场景理解。YOLOv8已经足够强大,但如果你喂给它的数据是脏的、标注是乱的、类别定义是模糊的,再好的模型也救不回来。反过来,把数据做干净,把场景想清楚,哪怕用YOLOv8n这种小模型,也能跑出不错的效果。