简介:面向垃圾分类识别类课程作业与毕设项目,这份资源提供了一套完整的垃圾图像采集方案,覆盖厨余、有害、可回收、其他四类垃圾场景,包含类别名称清单、爬虫脚本和数据分布可视化工具,帮助学习者在缺少现成数据集时快速构建3万多张规模的自建图像库。压缩包共14个文件,其中7个txt用于存放各类垃圾的具体名称、源关键词和爬取清单,6个Python脚本按四类垃圾分别执行爬取任务,另有1张png图展示类别占比,整体仅35KB,轻量易用。txt与py相互配合,既可直接运行获取图片,也可修改关键词扩展采集范围,适合课设、期末大作业或入门图像分类项目。目前已有176人学习下载。通过这份资料,使用者能快速掌握从类别定义到数据爬取、从目录组织到可视化分析的全流程方法,为后续模型训练、评估与报告撰写节省时间,尤其适合需要独立完成数据准备环节的初学者参考。
1. 垃圾识别分类数据集 3 万多张怎么用:四分类从 zip 到模型的第一关
接手一个垃圾识别分类数据集,3 万多张图,按厨余、有害、可回收、其他四类分好,很多人解压后的第一反应是“够了,直接训练”。实际往前跑两步就会发现,这个量级正卡在“够跑通流程”和“能支撑上线”之间:目录干不干净、类别分布是否均衡、验证集切得科不科学,都会决定后面几周是顺推还是反复返工。这篇文章写给两类人,一是手里刚拿到这个 zip、想快速出一个四分类基线模型的开发者;二是被智能垃圾桶、环卫拍照识别这类项目缠住,需要在有限数据和算力下把准确率稳稳推到可用线以上的工程师。我会按解压、清洗、划分、训练、排错、复盘六步讲,每一步都给能直接复制的操作和参数,最后告诉你哪些坑值得绕路。
2. 解压后先查这三项:四类垃圾数据集的目录结构、坏图清洗与类别分布
2.1 两种常见 zip 布局:目录即标签,还是 CSV 配平铺图片
拿到 zip 之后我一般不急着解压,先用unzip -l只列压缩包内容而不落盘,先确认顶层结构。常见做法有两种:一种是根目录下四个文件夹,分别叫“厨余”“有害”“可回收”“其他”,每个文件夹里面是图片,标签就是文件夹名;另一种是图片全部平铺在一个目录,配一个 CSV 文件,CSV 里写了文件名和类别名。这两种布局决定了后面用torchvision.datasets.ImageFolder直接读,还是要自己写一个Dataset。
unzip -l 垃圾识别分类数据集3万多张_厨余_有害_可回收_其他4大类.zip | head -40head -40只截前 40 行,目的是快速看顶层目录有没有__MACOSX、隐藏文件这类干扰项。如果发现文件名显示成乱码,一般是从 Windows 压缩包里带出来的 GBK 编码中文名,解压时可以用unzip -O gbk指定编码,或者解压后用convmv做转码。这个细节不处理,后面所有读图脚本都会因为路径找不到而报错,而且报错信息是“No such file or directory”,特别容易让人误以为是图片缺失。
确认完布局,我一般会顺手把压缩包里的目录树导出来存成一份structure.txt。这一步看起来多余,但当你跑完模型想回查某一张图到底属于哪个批次、哪个桶时,这份文件就是后悔药。等全部清洗做完再回头找目录结构,往往已经记不清原始状态了。
2.2 清洗脚本:坏图过滤、通道统一、类别计数
解压之后不要直接开训,先跑一个清洗扫描脚本。3 万张图里混进去几十张坏图、灰度图、小尺寸图太常见了,尤其从网上爬取或从多个渠道拼起来的数据集,坏图比例在 0.5% 到 2% 都属于正常。这些图会在训练中途突然炸掉,或者静默拉低验证指标,属于典型的黑匣子问题。
import os import numpy as np import cv2 from pathlib import Path from collections import Counter def cv_imread(path): # cv2.imread 对中文路径支持差,用 np.fromfile + imdecode 替代 data = np.fromfile(str(path), dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) root = Path("garbage_dataset/train") ext_cnt = Counter() cls_cnt = Counter() bad_images = [] gray_images = [] small_images = [] for img_path in root.rglob("*"): if img_path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".bmp", ".webp"}: continue cls_name = img_path.parent.name ext_cnt[img_path.suffix.lower()] += 1 cls_cnt[cls_name] += 1 img = cv_imread(img_path) if img is None: bad_images.append(str(img_path)) continue if img.ndim == 2: gray_images.append(str(img_path)) if img.shape[0] < 32 or img.shape[1] < 32: small_images.append(str(img_path)) print("类别分布:", dict(cls_cnt)) print("扩展名分布:", dict(ext_cnt)) print("坏图数量:", len(bad_images)) print("灰度图数量:", len(gray_images)) print("小于32像素的图:", len(small_images))这段脚本做了三件事:统计每类样本数、统计图片扩展名分布、筛出三种问题图。坏图判定标准是imdecode返回None,这类文件多半是下载中断产生的截断 JPEG 或伪装的非图片文件;灰度图要单独统计,因为后续训练如果统一走三通道,灰度图会被复制通道,数量少没事,数量大了会影响颜色相关的特征;小于 32 像素的图插值之后已经失去可辨识特征,单独列出来人工判断是删是留。
cv_imread这个封装是必须的,不是锦上添花。在 Windows 上,路径含中文时cv2.imread会静默返回None,导致坏图误报,清洗报告就会失真。我自己第一次跑清洗脚本时就踩过这个坑,统计出 400 多张“坏图”,结果全是因为路径里带了“厨余”两个字。
清洗时的原则是“先报告,后动手”。把坏图、小图的路径导出成一个clean_report.csv,人工扫一眼再删。不要写个循环直接删除,因为有些全黑或全白的图能正常解码,但内容无意义,你单靠代码识别不出来。另外清洗完建议把删掉的文件清单保留,这能让你在后面复现结果时知道训练集经过了什么处理。
3. 数据划分与增强:3 万张图怎么切才不骗自己
3.1 用分层抽样切分 train/val/test:比例、随机种子与同源重复检测
数据集划分是决定模型评估可信度的第一道关。四类垃圾里,有害垃圾这类样本往往远少于其他类,如果直接随机划分,验证集里有害垃圾可能只有几十张,准确率波动极大,你会以为模型在收敛,实际只是噪声。我一般用两层train_test_split,先把整个数据集按类别比例留出 20%,再从这 20% 里各分一半作为验证集和测试集,最终比例是训练集 80%、验证集 10%、测试集 10%。
import pandas as pd from sklearn.model_selection import train_test_split # 假设 labels.csv 至少含 image_path, label 两列 df = pd.read_csv("garbage_labels.csv") train_df, temp_df = train_test_split( df, test_size=0.2, stratify=df["label"], # 按类别比例分层 random_state=42 ) val_df, test_df = train_test_split( temp_df, test_size=0.5, # 从 temp 里再各取一半 stratify=temp_df["label"], random_state=42 ) train_df.to_csv("split_train.csv", index=False) val_df.to_csv("split_val.csv", index=False) test_df.to_csv("split_test.csv", index=False)stratify参数让每次切分都保持类别比例不变,random_state固定成 42 是为了可复现。这里有个容易忽略的点:不要每跑一个实验就换一个随机种子,否则模型精度的上升到底是来自你的改动还是数据切分的运气,你根本分不清。固定同一个 seed,baseline 和实验组之间才有可比性。
切分之前还要做一道同源重复检测。所谓同源重复,是同一张图在压缩包里出现了两次,或者同一场景的连拍帧被同时分进训练集和验证集。这会造成数据泄漏,模型在验证集上表现好,不是因为它学会了“识别垃圾”,而是因为它记住了那张图本身。3 万张图里出现几十组重复很常见,尤其数据集来自多个采集批次时。我一般会对全量图片算一遍感知哈希,用汉明距离小于阈值的判定为重复,把重复项归到同一个组,整个组只放进训练集或只放进验证集。
运行完上面的划分脚本后,一定要打印一次验证集的类别分布,确认四类的比例和全量数据一致。这一步不用写复杂代码,df["label"].value_counts()一行就够,但它能拦住最傻的数据泄漏。验证集切得不科学,后面所有调参都是在给自己制造幻觉。
3.2 对垃圾图片真正有效的增强:翻转裁剪颜色抖动,别乱用擦除
垃圾分类识别的拍摄场景其实很固定:垃圾桶内、垃圾袋旁、传送带上、手持拍摄。相比 ImageNet,这个任务的域内变化小得多,增强策略要克制。我常用的训练增强和验证增强分别如下。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.RandomRotation(degrees=10), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])每个增强项都有对应的硬件条件。RandomResizedCrop模拟垃圾在镜头前的尺度变化——厨余垃圾经常被凑近拍,导致物体占比忽大忽小;ColorJitter模拟不同灯光和垃圾桶内阴影,黄色灯光下塑料瓶和纸质包装的颜色偏移很大;HorizontalFlip对垃圾完全安全,因为垃圾没有左右语义。RandomRotation我只给 10 度,因为易拉罐、纸板箱有明显竖直方向特征,转太多会把方向变成噪声,让模型花参数去学一个不存在的变换。
验证集和测试集只用Resize + CenterCrop + Normalize,不做任何随机变换。原因很简单:验证集要稳定,你今天跑和明天跑结果应该一样;一旦验证集带了随机翻转,同一个模型两次评估差 0.5 个百分点,你就很难判断是改动生效还是噪声波动。
禁用项方面,我尤其不建议一上来就上RandomErasing或CutMix。这个数据集的图片主体是单一目标,随机擦除很可能直接抹掉关键判别部位——比如去掉电池上的标识、抹掉玻璃瓶的反光特征,模型只能靠剩余背景去蒙。等你训练收敛、确定小类过拟合了,再考虑这些强增强不迟。另一个不要做的是旋转 90 度或 180 度的大角度增强,除非你的采集数据里确实有各种朝向的瓶子,否则它只会拉大类内方差。
4. 用 ResNet18 / EfficientNet-B0 跑通四分类:训练脚本与参数调优
4.1 最小训练脚本:torchvision 加载自定义数据集的四分类训练循环
这个数据集是 4 大类,不是检测框,所以直接用图像分类的套路就好。常见做法是选 ResNet18 或 EfficientNet-B0 起步:ResNet18 训练快、显存省,适合第一次跑通全流程;EfficientNet-B0 精度高一点、参数不算多,适合做最终模型。数据集规模 3 万张时,两个模型最后都能收敛到可用的水平,差别主要在训练时间和显存占用。
import torch import torch.nn as nn import torchvision from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder from torchvision import models train_ds = ImageFolder("garbage_dataset/train", transform=train_transform) val_ds = ImageFolder("garbage_dataset/val", transform=val_transform) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True) model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.IMAGENET1K_V1) model.classifier[1] = nn.Linear(model.classifier[1].in_features, 4) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) model = model.cuda() for epoch in range(30): model.train() for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() scheduler.step() torch.save(model.state_dict(), f"garbage4cls_epoch{epoch}.pth")这里的关键是ImageFolder要求目录结构严格按train/类别名/图片组织,第 2 章清洗完的数据如果原本是 CSV 格式,需要先按标签建目录并移动图片,否则换用自定义Dataset。加载预训练权重是必须的,垃圾图片虽然和 ImageNet 域差异比较大,但边缘、纹理、颜色这些底层特征还是能迁过来的。classifier[1]是 EfficientNet-B0 最后一层线性层,只替换它,前面的 backbone 参数保留预训练值。30 个 epoch 对这个任务规模是一个比较稳的默认值,配合CosineAnnealingLR,学习率从初始值平滑降到接近 0,比固定学习率省掉“到底哪个 epoch 收敛”的纠结。
如果用的是 ResNet18,替换分类头的代码改成model.fc = nn.Linear(model.fc.in_features, 4)即可。两个模型的参数量和显存占用不同,选型参考下面的表。
| 模型 | 参数量级别 | 单卡 224 输入典型显存 | 训练速度 | 适用阶段 |
|---|---|---|---|---|
| ResNet18 | 约 11M | 2GB 左右可跑 batch=32 | 快 | 第一版 baseline,快速验证流程 |
| EfficientNet-B0 | 约 5M | 3GB 左右可跑 batch=32 | 中等 | 主力模型,精度和速度均衡 |
| EfficientNet-B2 | 约 9M | 5GB 左右可跑 batch=32 | 较慢 | 追求精度且显存充足时 |
EfficientNet-B0 虽然参数量比 ResNet18 小,但计算量并不少,因为它在 depth 和 resolution 上做了折中,实际训练速度不比 ResNet18 快。所以别只看参数量选模型,要看你的显卡能承受多大 batch 和多少训练时间。
4.2 四个必调参数:学习率、batch size、weight decay 与类别权重
训练脚本能跑只是第一步,真正决定四分类效果的是接下来几个参数。我在垃圾分类这个任务上反复调过几次,稳定有效的组合基本固定在这组值上。
| 参数 | 推荐值 | 调整方向与副作用 |
|---|---|---|
| 学习率 | AdamW 下 1e-3,SGD 下 3e-2 | 调大 loss 震荡不收敛;调小前 10 个 epoch 几乎不降;建议前 5 个 epoch 从 1e-4 warmup 到 1e-3 |
| batch size | 32 起步 | 显存不够时不要直接砍半,先上梯度累积;小于 16 时 BN 统计不稳定,精度会明显掉 |
| weight decay | 1e-4 | 过拟合严重时提到 5e-4;但 3 万张图这个规模,太大 weight decay 会让模型欠拟合 |
| 类别权重 | 按样本数反比 | 能抬升有害垃圾这类小类的召回,但代价是大类精度下降,属于拿 A 换 B |
这里单独说类别权重。四类分布如果极不均衡,比如厨余占了一半、有害垃圾只有一两千张,直接训出来的模型会把所有不确定样本都猜成厨余,整体准确率看起来不低,但有害垃圾的召回率可能只有 20%。这种模型上不了真实场景,因为漏掉一个有害垃圾比多分错一个厨余后果严重得多。常见做法是用compute_class_weight算每类权重,把权重传给损失函数。
from sklearn.utils.class_weight import compute_class_weight import torch classes = train_ds.classes y = [train_ds.classes.index(p.parent.name) for p in train_ds.samples] weights = compute_class_weight("balanced", classes=classes, y=y) class_weights = torch.tensor(weights, dtype=torch.float32).cuda() criterion = nn.CrossEntropyLoss(weight=class_weights)balanced模式的计算公式是n_samples / (n_classes * n_class_samples),样本数少的类拿到的权重自动更大。但注意副作用:如果有害垃圾原本只有 1500 张,权重会被拉到 4 倍以上,模型会变得对有害垃圾过度敏感,把厨余里的骨头、鱼刺频繁误判成有害。所以加了类别权重之后,必须配合混淆矩阵检查是不是过矫正了。这个我放到第 6 章讲,先记住“加权不是免费午餐”。
学习率 warmup 对 EfficientNet 尤其重要。没有 warmup 直接上 1e-3,经常前两个 epoch 的 loss 不降反升,然后再慢慢降回来,白白浪费迭代次数。我一般在前 5 个 epoch 让学习率从 1e-4 线性涨到目标值,你可以用 torch.optim.lr_scheduler 里现成的LinearLR接CosineAnnealingLR实现。这属于花小钱办大事的调整,值得加。
5. 避坑手册:垃圾识别分类模型训练最常见的 5 个翻车现场
5.1 验证集涨到 90% 但新场景全崩:先查同源重复图片
现象:训练时验证集准确率一路涨到 90% 以上,你满心欢喜把模型部署到一台真实设备上,拍几张垃圾桶照片测试,结果一塌糊涂,连厨余和可回收都分不清。原因多半是数据泄漏,训练集和验证集之间存在同源图片或相似帧,模型在验证集上的高精度是“背题”背出来的。
解决:在切分数据集之前做一遍去重。下面这段用感知哈希去重的逻辑我每次都会跑,阈值 8 可以兜住压缩、缩放、轻微调色后的重复图。
from PIL import Image import imagehash def dedup_by_hash(paths, threshold=8): seen = [] dup = [] for p in paths: h = imagehash.phash(Image.open(p)) if any(h - old_h <= threshold for _, old_h in seen): dup.append(p) else: seen.append((p, h)) return dupphash感知哈希的汉明距离越小代表图片越相似。这里用 8 作为阈值是靠经验试出来的:太小漏掉缩放后的重复图,太大把不同垃圾但背景一样的正常图误杀。去重跑完后,把同一组重复图整体划到训练集,或者整体划到验证集,绝不能拆开。
5.2 训练中途崩出 broken image / truncated JPEG
现象:训练跑到第 7 个 epoch,程序突然抛OSError: image file is truncated,中断退出。有些读者会把这当成“随机崩溃”重新跑一次,结果第 9 个 epoch 又崩。原因就是数据清洗没做干净,解压过程或原始采集阶段产生了截断 JPEG,图片文件本身不完整,前面几个 epoch 没加载到,一加载就炸。
解决:两个方案。最省事的是一行代码:from PIL import ImageFile; ImageFile.LOAD_TRUNCATED_IMAGES = True,让 PIL 用已有数据把图拼出来;但我不建议直接这么干,因为这等于让坏图静默进入训练集,模型看到半张图学到的特征就是错的。正确做法是回到第 2 章的清洗脚本,用cv2.imdecode把传输中损坏的图全部检测出来,手动确认后剔除或替换。说实话,3 万张图里坏图重采也就个把小时的事,不比排查崩溃省时间。
5.3 厨余和其他类互相认错,调参压不住
现象:训练结束看分类报告,厨余垃圾的召回率 94%,其他垃圾的召回率只有 78%,而错误的去向里一大半是厨余;反过来把厨余错分到其他类的情况也不少。你加类别权重、调学习率、加数据增强,改了三轮,两个类别的准确率像跷跷板一样此消彼长,整体就是不涨。原因要从数据本身找:这两类的视觉差异本来就小。鱼骨放在厨余里,牙签放在其他里,二者都是细长条;沾了油的纸巾是厨余,干纸巾是其他,颜色和纹理几乎一样。模型在特征空间里找不到一个干净的分类边界,调什么参数都白搭。
解决:先统计混淆矩阵,确认矛盾集中在“厨余 vs 其他”这一对而不是其他配对。如果是这两类纠缠,我不会继续硬调,而是考虑改任务结构:先训一个“干垃圾/湿垃圾”二分类,再在湿垃圾内部细分厨余,这样每一层的分类难度都降低了。这个方案改动大一点,但比你对着混淆矩阵调一周学习率有效得多。
5.4 准确率高但混淆矩阵显示在“抄背景答案”
现象:验证集准确率 92%,你觉得可以收工了,但一眼扫过混淆矩阵发现一个诡异规律——几乎所有被正确识别的“有害”样本都是红桶拍的,其他颜色的桶几乎没有有害样本。这说明模型学的不是垃圾本身,而是背景:有害垃圾的图片背景高度一致,模型找到了一条捷径,直接根据背景色投票。
解决:按采集批次划分数据集,而不是按图片随机划分。也就是说,把同一个批次、同一个场景拍出来的图片尽量放进同一个集合,训练集和验证集拿到的背景分布才有差异,模型被迫去学物体本身。如果你手里这批数据没有记录拍摄批次,退一步用 5.1 的同源去重把相似图片聚一下类,按聚类结果分组划分,能减轻背景泄露。CutMix 也能打破背景与类别的强关联,但对小目标垃圾效果不稳定,我通常最后才试。
5.5 显存不足 OOM:梯度累积和混合精度
现象:CUDA out of memory,不是每次都在同一个 batch 崩,而是看运气,跑着跑着就崩了。原因很简单,EfficientNet-B2 加 batch 64 加 512 分辨率,8GB 显卡根本装不下。最常见的错误做法是把 batch 从 32 砍到 16,再砍到 8,结果 BN 统计量扭曲,模型精度跟着掉。正确思路是保 batch,降显存单次开销。
解决:梯度累积加混合精度双管齐下。下面这段是梯度累积的标准写法,相当于把 4 个 batch 的梯度攒齐后统一更新一步,等效 batch 从 32 变成 128,但单次显存占用不变。
scaler = torch.cuda.amp.GradScaler() accum_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): images, labels = images.cuda(), labels.cuda() with torch.cuda.amp.autocast(): logits = model(images) loss = criterion(logits, labels) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad()注意两个细节。第一,loss 除以accum_steps再 backward,这一步不做的话梯度会放大accum_steps倍,学习率等于白调。第二,混合精度下的 BN 统计仍然按单卡 batch 更新,所以单卡 batch 不要小于 16,梯度累积解决的是显存问题,不是 BN 统计问题。
6. 不算完:用混淆矩阵与 Bad Case 复盘把四分类模型推到可上线
6.1 混淆矩阵之外的第二份报告:哪两类在互相认错
训练结束拿到 90% 以上的准确率,工作只完成了一半。另一半在测试集上:用没参与过训练和验证的测试集,跑一次完整预测,打印混淆矩阵,把每一类的命中率和最大混淆对象列出来。
from sklearn.metrics import confusion_matrix import torch all_pred, all_true = [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: pred = model(images.cuda()).argmax(dim=1).cpu().numpy() all_pred.extend(pred) all_true.extend(labels.numpy()) cm = confusion_matrix(all_true, all_pred) for i, cls in enumerate(test_loader.dataset.classes): total = cm[i].sum() wrong = cm[i].copy() wrong[i] = 0 worst = wrong.argmax() print(f"{cls}: 准确率 {cm[i][i]/total:.2%}, 最大混淆 -> {test_loader.dataset.classes[worst]}")混淆矩阵会告诉你模型在哪儿犯糊涂,但不会告诉你为什么犯糊涂,所以第二步是导出 bad case。做法很简单:把所有pred != true的样本路径、真实标签、预测标签写进一个 CSV,然后按错误类型分组,把同一组错图的缩略图拼成网格,一张一张看。这里才是整个流程里最花时间但也最出效果的一步。我第一次认真做这个复盘时,发现模型总把透明塑料盒误判成玻璃,原因不是材质分不清,而是训练集里透明塑料盒的样本基本都出现在“可回收”类的深色背景下,模型学到了“深背景 + 透明物体 = 可回收”。后来补了一批浅色背景的透明塑料盒样本,误判率直接降了一半。
看完 bad case 之后,你手里会有两张清单:一类是数据问题(标注错误、遮挡严重、目标太模糊),需要回过去修数据;另一类是类间本质重叠(比如 5.3 说的厨余和其他),需要改任务结构。这两张清单比又一个 0.5 个百分点的准确率提升值钱得多。
我现在的习惯是:每训完一版模型,在测试集上重跑一次混淆矩阵和 bad case 导出再做小结,哪怕只改了一个学习率参数也看。不看清楚这两样就调参,等于白加班。希望帮到你。
本文还有配套的精品资源,点击获取