简介:基于深度学习的垃圾分类完整项目资源,定位给人工智能与计算机视觉方向的初中级学习者,用于解决生活垃圾自动识别与分类问题。压缩包共十个文件,包含五个脚本、示例图片、说明文档和训练脚本,包体仅五点七三兆,其中脚本覆盖数据抓取、模型训练、实时检测与串口通讯等环节,结构清晰便于快速部署。已有169人学习。资源从数据预处理(图像缩放、归一化与翻转增强)、卷积神经网络模型搭建,到优化器与损失函数配置均有代码呈现,并针对过拟合给出验证集与早停策略,可帮助读者完整理解图像分类全流程。代码可适配主流深度学习框架运行,适合希望通过完整项目提升实战能力并关注环保应用的开发者。
1. 收到“基于深度学习的垃圾分类.zip”后,先从这三个角度拆项目
如果在答辩前一周,队友给你丢来一个“基于深度学习的垃圾分类.zip”,最稳妥的拆解思路是:别急着解压找权重,先把它当作一个“单标签图像分类”工程来看。任务边界很明确——给定一张图片,推它是厨余、可回收、有害还是其他垃圾;但真正的复杂度分布在三处:数据整理和标签质量、CNN 骨干网与训练参数的选择、训练完成后能否在摄像头或服务端完成稳定推理。
多数人把时间花在反复换网络上,最后演示时准确率反而“过山车”,原因往往是样本分布没看过、训练和推理预处理不一致。这套链路可以用 PyTorch 完整跑通:用 ImageFolder 组织数据,基于 ResNet18 做迁移学习,通过损失曲线和混淆矩阵调参,再导成 ONNX 或量化模型给边缘设备用。新手可以直接照着第3章的脚本从头跑一遍;有工程经验的同事可以看第2章的选型权衡和第4章的部署边界。
2. 垃圾分类项目的模型选型与数据集闭环
2.1 为什么垃圾分类必须用深度学习,而不是传统图像处理
垃圾分类的视觉难点在于“同类不同形、异类同形”。一张塑料袋和一张白纸,在颜色直方图层面可以非常接近,但语义不同;一个透明玻璃瓶和一个矿泉水瓶,形状和材质都接近,却分别属于可回收物和其他垃圾。传统方法用颜色、纹理、手工设计的边缘算子配合 SVM,在固定背景和固定灯光的实验室数据集上能到 90% 以上,一旦换到自然光、倾斜角度和有遮挡的环境,特征分布马上漂移。
深度学习在这里的实际体量不是“从零发明网络”,而是“预训练骨干网络+微调”。垃圾分类公开数据集的规模通常在几千到几万张,相比 ImageNet 小得多。从零训练 ResNet 会很快过拟合,而使用在 ImageNet 上预训练的权重做迁移学习,模型在低数据预算下也能学到通用视觉特征,再用垃圾图片微调高层语义。这就是“基于深度学习”的项目里最常见、也最可靠的落地方式。
2.2 骨干网络选型:从 ResNet 到轻量化模型的权衡
第一版实验用 ResNet18 最省事。它结构短、预训练权重好找、对四类垃圾图片的识别精度足够作为 baseline;跑通以后想提点,再换 ResNet50 或 EfficientNet-B0;如果目标是部署在带 NPU 的嵌入式板卡上,MobileNetV3-Small 通常比压一个 ResNet 更容易达到实时要求。一上来就用 EfficientNet 或 Vision Transformer 不一定划算,参数量大、推理慢,且需要更多数据支撑。
我的建议是“以部署目标反推模型”:做成网页 Demo,ResNet18 就行;做成固定角度摄像头实时分类,优先轻量化;如果要求批量离线分类,则追求精度优先。
| 模型 | 参数量级 | CPU 单图推理量级 | 适用阶段 |
|---|---|---|---|
| ResNet18 | 约11M | 20-50ms | 先跑通流程 |
| ResNet50 | 约25M | 50-120ms | 追求准确率 |
| MobileNetV3-Small | 约2.5M | 5-15ms | 边缘设备 |
| EfficientNet-B0 | 约5M | 20-40ms | 数据量充足且算力均衡 |
推理耗时会根据 CPU 型号、线程数、是否使用 TensorRT 有明显浮动,表里只是量级参考。这里的关键是:换模型时,训练输入尺寸和预处理方式不能跟着模型随意换,否则平均精度下降不一定来自模型能力,而来自输入分布变化。
2.3 把散装图片整理成 ImageFolder:一份可复制的目录脚本
PyTorch 训练代码里十个有八个会用 ImageFolder 加载数据,它的硬性要求是“类别文件夹”结构。而许多公开数据集的原始形态是 CSV 加图片文件,第一步要做的是把 CSV 改造成目录结构。由于中文路径在某些深度学习框架和日志系统里会带来莫名报错,我通常把目录建为英文 ID,另写一个 JSON 做 ID 到中文名的映射。
from pathlib import Path import os import shutil root = Path("garbage_src") # 把 CSV: <相对路径, 类别id> 转到 train/val 对应子目录 for split in ["train", "val"]: src_file = root / f"{split}.csv" with open(src_file, "r", encoding="utf-8") as f: for line in f.readlines(): rel_path, label_id = line.strip().split(",") # label_id: 0-厨余 1-可回收 2-有害 3-其他 label_dir = f"class_{int(label_id)}" dst = root / split / label_dir dst.mkdir(parents=True, exist_ok=True) shutil.copy2(root / rel_path, dst / os.path.basename(rel_path))对这段代码的解读:split用来区分训练集和验证集,把同一个文件放在 train 和 val 两个目录下;label_dir用class_0而不是中文命名,是为了避免后续 C++ 部署或日志打印时遇到编码问题;shutil.copy2会保留元数据,也能防止误改原始文件。做完这一步后,模型训练里直接用datasets.ImageFolder就能跑,不会再出现“图片读入后找不到标签”的报错。
2.4 类别不均衡先于网络结构:两种可落地的修正方式
我拿到任何数据集都会先跑一行统计,看每类图片数量相差多少。垃圾分类里“有害垃圾”的数量往往只有厨余的十分之一。如果直接训练,模型会为了最小化总损失而牺牲少样本类,验证准确率看起来不错,但实际部署时对电池、灯管这类物品基本失灵。这里有两个常用手段,且可以叠加。
第一种是训练集类别加权采样,让每个 batch 里少样本类出现的概率不低于一个阈值。第二种是在损失函数里加权重,把低频类别的错误放大。PyTorch 的写法如下:
import torch import torch.nn as nn class_counts = torch.tensor([1200, 800, 150, 900], dtype=torch.float) class_weights = 1.0 / class_counts class_weights = class_weights / class_weights.sum() criterion = nn.CrossEntropyLoss(weight=class_weights)这里class_counts要按 ImageFolder 返回的类别顺序填,也就是目录顺序,不是按 CSV 里的出现顺序,顺序反了权重就作用在错误的类别上。加了权重后,训练 loss 的绝对值会变大,这正常;需要重点观察每个类别的召回率,而不是总准确率。
3. 用 PyTorch 训练垃圾分类模型:最小实现与训练参数
3.1 深度学习环境配置:conda 与版本对齐
在进入代码前,先把环境配置步骤固定下来。深度学习环境配置的版本不匹配问题,比手写 loss 问题更容易卡住人。常见做法是用 Anaconda 建一个独立环境,项目结束可以直接删除环境,不影响其他项目。
conda create -n garbage python=3.10 -y conda activate garbage pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install pillow matplotlib scikit-learn tqdm第一行创建虚拟环境;第二行激活;第三行安装带 CUDA 12.1 的 PyTorch。如果在没有 NVIDIA 显卡的机器上训练,把 URL 中的cu121换成cpu,否则 torch 能装上但启动时会提示 CUDA 不可用。第四行的tqdm用来打印训练进度,scikit-learn是最后一章做混淆矩阵要用的,可以顺手装上。装完执行python -c "import torch; print(torch.cuda.is_available())",输出 True 才说明 GPU 可用。
3.2 用 torchvision 定义数据增强与归一化
数据加载阶段需要把“垃圾图片”转成“网络可吃的张量”。ResNet18 预训练权重默认期望 224x224 的输入,并期望输入按 ImageNet 的均值和方差归一化,所以 transforms 不能乱写:
from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_dataset = datasets.ImageFolder("garbage_dataset/train", transform=train_transform) val_dataset = datasets.ImageFolder("garbage_dataset/val", transform=val_transform)说明有几处容易被忽略:Resize((224, 224))直接拉伸,不做中心裁剪,是为了减少推理时要记住“先 Resize 再 CenterCrop”的隐式流程,虽然 CenterCrop 保留更多比例信息,但演示时经常忘记写,导致精度下降;随机翻转和旋转只在训练集上用,验证集必须和推理保持一致,都用固定的val_transform;Normalize的四个值是 ResNet 预训练模型的标准,不能全部替换为 0 和 1。
3.3 ResNet18 微调训练循环与配置说明
训练脚本给一个可复现的最小版本。核心逻辑是:加载预训练 ResNet18,替换全连接层输出为 4 类,然后跑若干 epoch。为了让代码在 CPU 机器上也能直接跑,设备判断写成“有 GPU 用 GPU,否则用 CPU”。
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import models from tqdm import tqdm num_classes = 4 batch_size = 32 epochs = 20 learning_rate = 1e-4 train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True, num_workers=2) val_loader = DataLoader(val_dataset, batch_size=batch_size, shuffle=False, num_workers=2) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) for epoch in range(epochs): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in tqdm(train_loader, desc=f"Epoch {epoch + 1}/{epochs}"): images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * images.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) epoch_loss = running_loss / total epoch_acc = correct / total print(f"epoch {epoch + 1}: train loss {epoch_loss:.4f}, acc {epoch_acc:.4f}")代码拆开说:models.ResNet18_Weights.IMAGENET1K_V1是 torchvision 新的权重参数写法,旧写法pretrained=True会提示 deprecation,但不影响训练;model.fc = nn.Linear(...)只替换最后一层,前面的卷积层参数保留在模型里,参与微调;criterion可以使用 2.4 节中带 class weight 的损失函数;optimizer = Adam(lr=1e-4),这个学习率在微调阶段是安全区间,换 SGD 时通常要用 0.01 量级。tqdm只负责打印,与模型精度无关。
3.4 训练参数调节表:epoch、学习率、batch size
训练过程中真正要动的是下面几个旋钮,初始值可以这样设:
| 参数 | 初始值 | 调整方向 | 典型观察信号 |
|---|---|---|---|
| batch_size | 32 | 显存不够降到 8/16;类别不均衡时考虑增大 | loss 震荡频繁可能是 batch 过小 |
| learning_rate | 1e-4 | Adam 下 loss 不降可短时升到 1e-3 | 训练 2-3 轮 loss 都不变化 |
| epochs | 20 | 看验证集是否连续多轮不涨 | 验证准确率不再提升时提前停 |
| optimizer | Adam | 抖动严重可换 SGD+momentum | 验证准确率在某一区间来回跳 |
| weight_decay | 0 | 过拟合时设为 1e-4 | 训练 acc 高,验证 acc 低 |
这里的逻辑是:先让模型在一个 batch 上过拟合,再谈调参。如果模型连 32 张图片都背不下来,大概率是学习率、预处理或代码链路有问题,而不是需要更复杂的网络。把 batch_size 设为训练集的一个子集,训练 50 轮看 train loss 能否降到接近 0,这是最快的冒烟测试。
3.5 loss 不降、验证抖动、显存不足的排查顺序
出现 loss 不降时不要立即改模型,按顺序排查:先打印一两张 transform 后的图片张量,确认像素范围、通道顺序是否是 RGB;确认 label 范围在[0, num_classes)内;把学习率从 1e-4 提到 1e-3 试跑 5 轮;若还是不降,就换一个预训练模型(如 MobileNet)跑通流程。验证准确率抖动通常由 batch_size 小或学习率偏大造成,可以试试余弦退火学习率调度。显存不足时,优先把num_workers调低、batch_size减半,而不是换小模型,因为识别流程里加载的是 224 尺寸,显存压力并不直接来自模型结构。
提示:Windows 上
DataLoader(num_workers=2)需要把训练代码放到if __name__ == "__main__":里,否则子进程会反复重启并报 RuntimeError。
4. 垃圾分类模型推理、ONNX 导出与边缘部署
4.1 预处理一致性与推理脚本
训练完成不等于能演示。最容易出 bug 的地方是推理脚本里把RandomHorizontalFlip也复制了过去,或者图像尺寸不一致。推理阶段只需要固定变换:
import torch from PIL import Image from torchvision import transforms def predict_one(model, image_path, class_names): model.eval() # 切换 BatchNorm 和 Dropout 行为 transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) image = Image.open(image_path).convert("RGB") tensor = transform(image).unsqueeze(0) with torch.no_grad(): logits = model(tensor) probs = torch.softmax(logits, dim=1) confidence, index = torch.max(probs, 1) return class_names[index.item()], float(confidence.item())函数里的细节集中在两处:model.eval()必须写,否则 BatchNorm 会使用当前 batch 的统计量而不是训练时的移动平均,造成输出概率漂移;with torch.no_grad()不构建计算图,推理显存明显下降,速度更快。输入图片.convert("RGB")是因为摄像头或 PNG 图片可能出现 RGBA 四通道,ResNet 的卷积层只接受三通道输入。
4.2 导出 ONNX 并在 ONNX Runtime 中验证
如果项目要交付给非 Python 环境,常见做法是先导出 ONNX。ONNX 是一份静态计算图,解除了对 PyTorch 版本和训练代码的依赖。导出代码很短:
dummy_input = torch.randn(1, 3, 224, 224).to(device) model.to("cpu").eval() torch.onnx.export(model, dummy_input, "garbage.onnx", input_names=["input"], output_names=["output"], opset_version=12)说明:dummy_input只决定输入的形状和维度,值本身不影响导出结果;model.to("cpu")是为了避免导出图中残留 GPU 设备信息,后续在只有 CPU 的服务器上加载更省事;opset_version=12覆盖面较广,如果目标设备需要更低版本,可降到 11。导出后要用 ONNX Runtime 跑一次,确认输出和 PyTorch 结果在数值上相差不大:
import onnxruntime session = onnxruntime.InferenceSession("garbage.onnx") ort_inputs = {"input": tensor.numpy()} ort_outputs = session.run(None, ort_inputs)[0] print(ort_outputs.shape, ort_outputs[0].argmax())4.3 部署到 K230 这类边缘设备:体积、量化与延迟
垃圾分类比赛和项目中常见“摄像头加一块集成 NPU 的板卡”的方案,比如工创赛里出现的 K230 场景。这类板卡和 PC GPU 完全不是一个量级,部署时重点不再是训练准确率,而是模型体积、算力匹配和内存占用。ResNet18 在 fp32 下权重约 45MB,直接移植到部分边缘板卡会慢或放不下。常规处理是:先用 MobileNetV3 这类轻量网络训练,再导出 ONNX,最后在板卡工具链上做 int8 量化,把模型压到几 MB 到十几 MB。推理端还要把头尾的图片预处理同步到板卡语言环境,在 C/C++ 里把 RGB 数据按相同 mean/std 归一化。只搬模型不搬预处理,推理结果会和本地完全不同。
4.4 用 FastAPI 包装分类服务
如果要交付的是 Web Demo,直接让浏览器调 PyTorch 进程不是好方案。常见做法是用 FastAPI 包一个 HTTP 接口,前端传图片,服务端返回类别和置信度:
from fastapi import FastAPI, UploadFile import torch from PIL import Image app = FastAPI() model = load_model("garbage_best.pth") model.eval() @app.post("/predict") async def predict(file: UploadFile): image = Image.open(file.file).convert("RGB") tensor = transform(image).unsqueeze(0) with torch.no_grad(): probs = torch.softmax(model(tensor), dim=1) idx = int(torch.argmax(probs, 1)) return {"class": class_names[idx], "confidence": float(probs[0, idx])}这段代码写给负责接入的同学看:load_model只应该在服务启动时执行一次,不要写进每个请求里;UploadFile拿到的文件是临时类型,Image.open后要立刻转为张量,避免文件句柄不释放。生产环境里建议在 Nginx 后面挂 Gunicorn,并且用共享内存缓存模型,这部分按团队已有基建决定即可。
5. 用混淆矩阵验证垃圾分类模型的真实水平
5.1 混淆矩阵:定位真正混掉的类别
训练完只看“验证集准确率 95%”不够。四分类任务即使不做任何学习,按多数类预测也能到 50% 上下。我拿到模型后的第一个动作就是生成混淆矩阵,看哪两个类别互相被认错:玻璃瓶被分到可回收物还是其他垃圾,塑料袋是否大量落进厨余。直接调用sklearn就有现成实现:
from sklearn.metrics import confusion_matrix, classification_report import torch all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, predicted = torch.max(outputs, 1) all_preds.extend(predicted.cpu().tolist()) all_labels.extend(labels.tolist()) cm = confusion_matrix(all_labels, all_preds) print(cm) print(classification_report(all_labels, all_preds, target_names=class_names))confusion_matrix的行是真实类别,列是预测类别;classification_report会给出每个类别的精确率、召回率和 F1。当“有害垃圾”召回率低于其他类别时,问题往往不在网络结构,而在类别不均衡或样本量太少。这时优先做的是回到 2.4 节把损失函数权重加回去,而不是换更深网络。
5.2 低置信度转人工复核:一个能真正提升体验的部署技巧
混淆矩阵看完,就剩最后一个落地技巧:不要求模型对所有图片都给出答案。垃圾分类的实际场景中,软塑料和纸制品、陶瓷和玻璃的边界本身存在歧义,强制返回一个类别会造成错误引导。
在部署脚本里加一个置信度阈值,低于阈值时返回“无法识别,请人工复核”。阈值通过验证集确定:统计所有正确样本置信度分布的 5% 分位,作为默认值;对高风险类别,比如有害垃圾,可以再单独调高。这样演示时模型报告“低置信度,请人工复核”是特性而不是缺陷,反而说明系统知道自己不知道什么。这个技巧在工业侧模型管理中很常用,能直接拉高用户对系统的信任度。
本文还有配套的精品资源,点击获取