基于CNN的垃圾分类系统:PyTorch训练到PyQt5 GUI部署全流程
2026/9/15 14:26:43 网站建设 项目流程

简介:面向图像分类初学者与垃圾分类项目开发者,这份资源提供了一套基于卷积神经网络的垃圾分类系统实现,覆盖模型设计、实验优化与GUI应用演示。包内共2000个文件,其中1978张jpg图像构成可供训练的垃圾分类图片数据集,7张png用于界面图标或展示,6个py文件包含模型训练、预测推理及基于PySimpleGUI的主程序,xml、txt等文件用于配置与环境记录,整体压缩包约146.47MB。目前已有202人学习下载。资源在原有两层卷积基础上,通过增加卷积层数量、引入Batch Normalization、调整Dropout与学习率策略等方式进行优化,使最终模型准确率达到90%左右;同时提供了可运行的图形界面代码,用户可自行选择文件夹和图片,直观查看预测类别,适合需要快速搭建垃圾分类演示项目的中级学习者参考。

1. 为什么垃圾分类的GUI落地,难点不在分类精度

在公开数据集上训练一个区分纸、塑料、金属的卷积神经网络,半天就能跑到95%的精度。但把模型塞进GUI应用、让用户随手拍一张真实照片去分类,精度跳水往往超过20个百分点。这个落差的根源不是模型过拟合,而是训练样本和真实场景存在系统性的域差异:数据集里瓶子是完整摆拍的,实拍图里它是压扁带泥的;数据集里的纸箱边缘锐利,实拍图里经常拖着阴影。所以"基于卷积神经网络的垃圾分类系统实现(GUI应用)"这个标题,本质是一条从卷积层设计、模型训练到GUI封装的完整工程链路。它适合已经能跑通分类Demo、但没完整做过从训练到展示闭环的开发者,也适合要做可视化演示项目的IT从业者。下面按卷积网络参数设计、PyTorch训练、PyQt5封装三步走,每一步都给可直接复用的代码。

2. CNN在垃圾分类上的结构选型与卷积/池化参数设计

2.1 先看懂垃圾分类的图像分布:类间近、类内远

做垃圾分类图像识别的第一件事不是写模型,而是看清数据分布。垃圾分类任务的类别数通常在6到60之间,Kaggle上的经典数据集是6类(纸板、玻璃、金属、纸张、塑料、厨余),华为云公开的常用版本是40类。这个规模下,直接用经典小骨架就能覆盖,没必要一上来就搬ResNet50。真正决定精度上限的,是垃圾图像"类间近、类内远"的特殊分布:玻璃杯和陶瓷碗在轮廓、反光上高度相似,塑料瓶和铝罐在压扁之后更接近,这是类间近;而同类垃圾里,牛奶盒、快递纸箱、报纸都算纸类,外观差异极大,这是类内远。

这种分布对卷积神经网络意味着什么?网络需要用卷积核捕捉局部纹理(瓦楞纸的条纹、金属的高光、玻璃的透明边缘),而不是依赖整体轮廓。相比在ImageNet上训练大模型,小模型配合针对性的数据增强反而更稳。经验上,ResNet18和LeNet-5的扩展结构是垃圾分类最常见的两个起点;前者在40类数据上更省事,后者结构简单、便于在GUI里展示中间特征图。我这里按LeNet-5骨架扩展,正好能覆盖"卷积、池化、核、填充"这几个核心概念的设定。

2.2 卷积核、步长、填充怎么定,用一段代码算清楚

CNN里三个最常被问的参数是核大小、步长、填充,它们在垃圾分类里都有明确的倾向值。核大小统一用3×3,需要更大感受野时堆叠两层卷积,两层3×3的感受野等价于一层5×5,但参数量只有后者的约18/25,且中间多一次ReLU激活,非线性更强。步长在普通卷积层设1,需要主动降分辨率时由池化层负责,而不是把卷积的步长设成2,这样浅层的边界细节保留更完整。填充统一设1,也就是padding=1,让3×3卷积不改变特征图尺寸。

这三个参数对输出尺寸的影响可以用一段代码验算:

import math def conv_out_size(input_size, kernel_size, padding, stride): # 卷积输出尺寸公式:(输入 - 核 + 2*填充) / 步长 + 1,向下取整 return math.floor((input_size + 2 * padding - kernel_size) / stride + 1) # 224x224输入,3x3卷积,padding=1,stride=1 print(conv_out_size(224, 3, 1, 1)) # 输出 224,尺寸不变 # 224x224输入,3x3卷积,padding=1,stride=2 print(conv_out_size(224, 3, 1, 2)) # 输出 112,尺寸减半 # 核对:input_size=224, kernel=5, padding=0, stride=1 print(conv_out_size(224, 5, 0, 1)) # 输出 220,边界信息丢失

这段代码对应PyTorch的nn.Conv2d四个参数:input_size是输入特征图的宽或高,kernel_size是卷积核边长,padding是在输入四周补零的像素数,stride是卷积核每次滑动的像素距离。第三个print演示了常见误区——不设填充时,特征图每过一层就缩小,五六层卷积后空间尺寸从224掉到个位数,浅层边界信息被快速丢弃。画卷积神经网络结构图时,padding=1、stride=1的卷积层可以理解为"只做特征提取、不改变空间尺寸",后续算参数量和内存时心理负担小很多。

2.3 池化层:垃圾分类里为什么默认选最大池化

池化层在CNN里承担两件事:降采样和保留主要响应。常见选择是最大池化(max pooling)或平均池化(average pooling)。垃圾分类场景下,我基本只用最大池化。原因是垃圾类别的判别信息集中在局部强响应区域,比如矿泉水瓶的标签纹理、易拉罐顶部的压痕、纸箱的瓦楞线条,这些特征在网络里表现为某些通道上出现明显的峰值激活。最大池化把邻域里的最强响应挑出来,正好对应"这里存在某类纹理"的证据;平均池化则把这些峰值连同大量平坦背景一起平均,强响应被摊薄,对类间差异小的垃圾分类不利。

以厨余垃圾和纸张为例,两者都有不规则边缘和大量表面纹理,区分线索经常集中在很小的局部区域上。如果池化层把响应抹匀,这两类更容易混淆。实现层面,PyTorch的nn.MaxPool2d通常设kernel_size=2, stride=2,效果是宽高各减半;也可以把stride留空,默认等于kernel_size。另一种在垃圾分类里值得用的池化是全局平均池化(GAP),把每个特征图直接压成一个标量,替换展平加全连接的做法。GAP几乎没有可学习参数,天然抑制过拟合,末尾再接一层Linear分类头,是性价比最高的收尾结构。

2.4 一个够用的骨架:LeNet-5结构扩展到40类的参数表

参考LeNet-5的"双卷积+池化+展平+线性层"骨架,针对40类垃圾分类做通道扩展,可以得到一张可以直接照抄的结构表。下面这张表以224×224输入为例,包含了每层的核大小、填充、输出尺寸和职责:

核/参数输出尺寸(输入224x224)职责
Conv13x3, 32, padding=1224x224x32提取边缘、纹理低级特征
MaxPool12x2, stride=2112x112x32降采样,保留强响应
Conv23x3, 64, padding=1112x112x64组合局部纹理模式
MaxPool22x2, stride=256x56x64降采样
Conv33x3, 128, padding=156x56x128提取高层语义特征
MaxPool32x2, stride=228x28x128降采样
GAP全局平均池化128每个通道压成一个值
FCLinear(128, 40)40类别打分

通道数32→64→128逐层翻倍,是因为越往后特征图越小,单靠空间位置能表达的信息变少,需要更多通道来容纳高层语义。全连接层只保留一层,配合交叉熵损失,尾部参数量小,不容易过拟合。这张表在6类数据上也能直接用,改一下最后的Linear输出维度即可。实际训练时,如果有人问"为什么卷积层一定要有padding",答案就在这张表里:没有padding,每层尺寸递减,第三个卷积层之后特征图早就缩到无法继续堆叠了。

3. 用PyTorch在本地跑通垃圾分类模型训练

3.1 用ImageFolder整理数据,文件夹名就是类别名

PyTorch训练图像分类最省事的方式是torchvision.datasets.ImageFolder,它要求目录结构是root/类别名/图片.jpg。比如在data/train下面建cardboard、glass、metal、paper、plastic、trash六个文件夹,ImageFolder会自动把文件夹名映射成从0开始的整数标签。这个约定对后续GUI开发尤其方便,因为最终展示给用户的类别名直接来自文件夹本身,不需要额外维护一份标签映射表。

from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.3, contrast=0.3), 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_data = datasets.ImageFolder(root="./data/train", transform=train_transform) val_data = datasets.ImageFolder(root="./data/val", transform=val_transform) print(train_data.classes) # ['cardboard', 'glass', ..., 'trash'] print(train_data.class_to_idx) # {'cardboard': 0, 'glass': 1, ...}

分隔训练集和验证集时,我一般按类别比例随机抽20%作为验证集,而不是直接取前几个文件夹,否则某几类会整体缺席。transform里的Resize统一到224×224,保证与卷积核尺寸适配;RandomHorizontalFlip和ColorJitter是轻量数据增强,模拟实拍时翻转和光照变化。Normalize用的是ImageNet统计出的均值和标准差,这是整个垃圾分类系统里最容易被GUI推理阶段遗忘的步骤,后面专门讲。验证集transform里一定不要加随机翻转和色彩扰动,否则每次验证结果都会有随机波动,看不出真实水平。

3.2 把结构表翻译成CNN代码:Conv2d的padding和stride

第2章的表格可以原样翻译成一个nn.Module,结构清晰,也方便后面截取中间特征图。用nn.Sequential组织卷积和池化块,分类头单独写:

import torch import torch.nn as nn class GarbageCNN(nn.Module): def __init__(self, num_classes=6): super().__init__() self.features = nn.Sequential( # 卷积 -> ReLU -> 池化,padding=1 保持尺寸 nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(kernel_size=2, stride=2), ) # 全局平均池化,替代展平操作 self.gap = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = self.gap(x) x = torch.flatten(x, 1) x = self.classifier(x) return x model = GarbageCNN(num_classes=len(train_data.classes)) print(model)

这里每个Conv2d的padding都显式写成1,stride用默认的1;MaxPool2d的kernel_size=2, stride=2把特征图宽高各砍半。AdaptiveAvgPool2d((1,1))是全局平均池化的官方写法,不管前面特征图尺寸是多少,输出都是1×1,模型因此对输入分辨率有一定容忍度。classifier是最后一层Linear(128, num_classes),输出每一类的原始得分,配合CrossEntropyLoss在内部做softmax。注意代码里没有手动写softmax,训练和验证都直接拿logits算损失和准确率即可。

3.3 训练循环与关键超参数:学习率、momentum、batch size

垃圾分类这个量级的数据,训练超参数有固定套路,直接抄经验值比反复搜索划算。一个在6类垃圾分类数据集上10个epoch就能到85%以上的配置如下:

参数取值说明
optimizerSGDAdam早期更快,后期精度常不如SGD
learning rate0.01若loss震荡则降到0.003
momentum0.9平滑梯度方向,减少震荡
batch size32224×224下普通显卡内存适中
epochs20主要看验证集,配合早停

SGD加momentum=0.9在图像分类里是经典组合,它不像Adam那样对每个参数单独调步长,泛化性通常更好。学习率0.01是经验起点:如果前两个epoch的loss完全不降,大概率不是学习率问题,而是Normalize的均值标准差写反了,或数据路径读到了空文件夹。下面这段训练循环可以直接复用:

import torch.optim as optim from torch.utils.data import DataLoader train_loader = DataLoader(train_data, batch_size=32, shuffle=True, num_workers=4) loss_fn = nn.CrossEntropyLoss() optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) for epoch in range(20): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = loss_fn(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) avg_loss = total_loss / len(train_data) print(f"Epoch {epoch + 1:02d}, Loss: {avg_loss:.4f}")

最容易被忽略的是optimizer.zero_grad()。PyTorch的梯度默认累加,不清零时第二个batch的梯度会和第一个叠加,loss曲线会无规律抖动。另一个坑是num_workers,Windows下建议设0,否则多进程数据加载偶尔会卡住;Linux下设4没问题。整个循环的核心逻辑是:前向算loss、反向算梯度、step更新权重,三轮交替直到收敛。

3.4 保存state_dict和classes,为GUI推理做准备

训练结束后,保存模型和类别列表是GUI应用的接缝处。我只保存state_dict,不保存整个model对象,原因是PyTorch版本升级会改变序列化格式,只存权重可以在任何环境里重建相同结构后加载:

torch.save(model.state_dict(), "garbage_model.pth") torch.save(train_data.classes, "classes.pth") # 重建模型并验证权重可加载 model2 = GarbageCNN(num_classes=len(train_data.classes)) model2.load_state_dict(torch.load("garbage_model.pth", map_location="cpu")) model2.eval() print("权重加载成功,类别列表:", torch.load("classes.pth"))

map_location="cpu"这一行对GUI部署至关重要。训练用的权重如果保存在GPU上,文件里含cuda:0设备标识,部署机器没有显卡时加载会直接报错;指定map_location="cpu"强制把所有张量放到CPU内存。model.eval()也必须调用,它能关掉训练模式下的随机行为(比如BatchNorm的统计更新),否则同一张图片每次预测出的概率会有细微波动。到这一步,训练链路已经闭合,得到的garbage_model.pth就是下一步GUI应用要加载的核心产物。

4. 用PyQt5把训练好的CNN装进GUI应用

4.1 布局分三区:选图区、预览区、结果区

GUI应用的布局跟着用户操作路径走,单窗口三区域是常见做法:左侧是选择图片按钮和文件路径展示,中间是图片预览,右侧是识别类别和置信度进度条。这样用户看完预览图马上能看到结果,不需要来回切换窗口。PyQt5里用QVBoxLayout和QHBoxLayout嵌套就能搭出这个结构,不需要引入其他绘图库:

from PyQt5.QtWidgets import (QWidget, QPushButton, QLabel, QVBoxLayout, QHBoxLayout, QProgressBar) from PyQt5.QtGui import QPixmap from PyQt5.QtCore import Qt class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("垃圾分类识别系统") self.resize(760, 600) self.setup_ui() self.model = None self.classes = ["cardboard", "glass", "metal", "paper", "plastic", "trash"] def setup_ui(self): self.select_btn = QPushButton("选择图片") self.path_label = QLabel("未选择文件") self.preview = QLabel() self.preview.setFixedSize(400, 400) self.preview.setAlignment(Qt.AlignCenter) self.result_label = QLabel("待识别") self.progress = QProgressBar() self.progress.setRange(0, 100) self.progress.setValue(0) left = QVBoxLayout() left.addWidget(self.select_btn) left.addWidget(self.path_label) right = QVBoxLayout() right.addWidget(self.preview) bottom = QVBoxLayout() bottom.addWidget(self.result_label) bottom.addWidget(self.progress) root = QVBoxLayout() root.addLayout(left) root.addLayout(right) root.addLayout(bottom) self.setLayout(root)

预览区的QLabel固定400×400,加载图片时用scaled等比缩放,避免大图把窗口撑爆。进度条用来显示置信度百分比,比纯文字直观。QProgressBar的setValue范围设在0到100,后面把softmax概率乘100直接填充。这里把类别列表先写死成六个默认值,实际项目中应该在加载模型时从classes.pth读,避免模型和标签对不上。

4.2 信号槽连接:按钮点击到推理结果的完整链路

PyQt5用信号槽机制把用户操作和逻辑函数绑定。按钮的clicked信号连接到open_image方法,用户点一下按钮,系统弹出文件选择对话框,选定图片后立即触发预测。这个链路要写成两个方法,职责分开,调试时更容易定位问题:

import torch from PIL import Image from torchvision import transforms as T def open_image(self): fpath, _ = QFileDialog.getOpenFileName( self, "选择垃圾图片", "", "图片文件 (*.jpg *.png *.jpeg)") if fpath: self.path_label.setText(fpath) pixmap = QPixmap(fpath).scaled( 400, 400, Qt.KeepAspectRatio) self.preview.setPixmap(pixmap) self.predict_image(fpath) def predict_image(self, fpath): if self.model is None: self.result_label.setText("模型未加载") return img = Image.open(fpath).convert("RGB") img = img.resize((224, 224), Image.BILINEAR) x = self.transform(img).unsqueeze(0) with torch.no_grad(): logits = self.model(x) probs = torch.softmax(logits, dim=1).squeeze(0) idx = torch.argmax(probs).item() conf = probs[idx].item() self.result_label.setText( f"{self.classes[idx]} {conf:.2f}") self.progress.setValue(int(conf * 100))

这里的信号槽链路是:用户点击按钮 → Qt框架发出clicked信号 → 槽函数open_image被调用 → 弹文件对话框读路径 → 路径展示在左侧 → 图片缩放显示到预览区 → 调用predict_image推理 → 结果写进右侧标签和进度条。整个流程是同步串行的,单张图片推理时间在CPU上约几十毫秒,不会卡界面。需要注意predict_image里新增了transform属性,需要在加载模型时一并初始化,否则推理用的预处理和训练时不一致。

4.3 推理预处理的三处对齐细节

GUI推理最常见的错误是预处理和训练管线不一致。训练时做了Resize、ToTensor、Normalize,推理时经常有人只做了resize就直接喂给模型,结果颜色分布全偏,置信度整体漂移。下面这张表列出必须对齐的每一项:

预处理项训练时GUI推理时不一致后果
尺寸缩放Resize((224,224))resize((224,224))卷积层输入维度对不上
像素归一化Normalize(mean,std)必须做同样变换颜色偏移,置信度失真
通道顺序ToTensor转RGBPIL convert("RGB")OpenCV读BGR会颜色错乱
随机增强RandomFlip等必须全部去掉结果带随机性

注意第三行是个高频坑。Python里如果用OpenCV的cv2.imread读图,拿到的通道顺序是BGR,直接转成张量送进模型,红色和蓝色是反的;用PIL的Image.open默认就是RGB,配合ToTensor可以避免这个坑。第四行强调随机增强只属于训练,推理阶段如果没关掉,同一个文件两次预测结果可能不同。把所有预处理封装成一个transform对象,训练和推理共用同一份代码,是减少这类问题最有效的做法。

5. 从top-1到top-2:GUI里更实用的验证与调优技巧

垃圾分类的GUI落地有个被低估的细节:top-1精度高不等于用户认可度高。玻璃和陶瓷、塑料和金属在实拍图里经常互相混淆,如果界面只显示一个最高概率类别,用户看到"玻璃瓶"被认成"陶瓷碗"会直接判定系统失败;但如果同时展示第二可能的类别,用户会发现系统"至少猜对了一半"。因此我在GUI里默认显示top-2结果,用torch.topk一次取两个最大概率值:

with torch.no_grad(): logits = self.model(x) probs = torch.softmax(logits, dim=1).squeeze(0) # 取前2个最大概率的索引和得分 values, indices = torch.topk(probs, 2) for rank, (val, idx) in enumerate(zip(values.tolist(), indices.tolist())): print(f"第{rank + 1}候选: {self.classes[idx]} {val:.3f}")

topk返回的第一个张量是得分,第二个是索引,按从大到小排好序。这个技巧的成本几乎为零,但对演示体验的提升非常明显。配合调优时,不要只盯着总体准确率,用混淆矩阵看清楚哪两类在互相打架:

from sklearn.metrics import confusion_matrix y_true = [...] y_pred = [...] print(confusion_matrix(y_true, y_pred))

如果发现塑料和玻璃频繁互相误判,说明两者的半透明反光特征没有区分开,可以针对性地在ColorJitter里加大brightness的扰动范围,或者加一个高斯模糊增强来模拟脏污样本。验证时用一个固定的实拍图片集做benchmark,而不是只在数据集val上自测;每类拍10张真实物体,跑一轮统计准确率,比看val loss更能反映GUI里用户会遇到的问题。最后留一个可操作的建议:在UI上把每个类别的top-1和top-2置信度用进度条并排显示,用户能直观看到模型在做选择时的犹豫,这个反馈本身也是判断模型是否过拟合的窗口。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询