ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于CNN的垃圾分类系统:PyTorch训练到PyQt5 GUI部署全流程

2026/9/15 14:26:44 拓冰建站 浏览量
基于CNN的垃圾分类系统:PyTorch训练到PyQt5 GUI部署全流程 简介面向图像分类初学者与垃圾分类项目开发者这份资源提供了一套基于卷积神经网络的垃圾分类系统实现覆盖模型设计、实验优化与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也就是padding1让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卷积padding1stride1 print(conv_out_size(224, 3, 1, 1)) # 输出 224尺寸不变 # 224x224输入3x3卷积padding1stride2 print(conv_out_size(224, 3, 1, 2)) # 输出 112尺寸减半 # 核对input_size224, kernel5, padding0, stride1 print(conv_out_size(224, 5, 0, 1)) # 输出 220边界信息丢失这段代码对应PyTorch的nn.Conv2d四个参数input_size是输入特征图的宽或高kernel_size是卷积核边长padding是在输入四周补零的像素数stride是卷积核每次滑动的像素距离。第三个print演示了常见误区——不设填充时特征图每过一层就缩小五六层卷积后空间尺寸从224掉到个位数浅层边界信息被快速丢弃。画卷积神经网络结构图时padding1、stride1的卷积层可以理解为只做特征提取、不改变空间尺寸后续算参数量和内存时心理负担小很多。2.3 池化层垃圾分类里为什么默认选最大池化池化层在CNN里承担两件事降采样和保留主要响应。常见选择是最大池化max pooling或平均池化average pooling。垃圾分类场景下我基本只用最大池化。原因是垃圾类别的判别信息集中在局部强响应区域比如矿泉水瓶的标签纹理、易拉罐顶部的压痕、纸箱的瓦楞线条这些特征在网络里表现为某些通道上出现明显的峰值激活。最大池化把邻域里的最强响应挑出来正好对应这里存在某类纹理的证据平均池化则把这些峰值连同大量平坦背景一起平均强响应被摊薄对类间差异小的垃圾分类不利。以厨余垃圾和纸张为例两者都有不规则边缘和大量表面纹理区分线索经常集中在很小的局部区域上。如果池化层把响应抹匀这两类更容易混淆。实现层面PyTorch的nn.MaxPool2d通常设kernel_size2, stride2效果是宽高各减半也可以把stride留空默认等于kernel_size。另一种在垃圾分类里值得用的池化是全局平均池化GAP把每个特征图直接压成一个标量替换展平加全连接的做法。GAP几乎没有可学习参数天然抑制过拟合末尾再接一层Linear分类头是性价比最高的收尾结构。2.4 一个够用的骨架LeNet-5结构扩展到40类的参数表参考LeNet-5的双卷积池化展平线性层骨架针对40类垃圾分类做通道扩展可以得到一张可以直接照抄的结构表。下面这张表以224×224输入为例包含了每层的核大小、填充、输出尺寸和职责层核/参数输出尺寸输入224x224职责Conv13x3, 32, padding1224x224x32提取边缘、纹理低级特征MaxPool12x2, stride2112x112x32降采样保留强响应Conv23x3, 64, padding1112x112x64组合局部纹理模式MaxPool22x2, stride256x56x64降采样Conv33x3, 128, padding156x56x128提取高层语义特征MaxPool32x2, stride228x28x128降采样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(p0.5), transforms.ColorJitter(brightness0.3, contrast0.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, transformtrain_transform) val_data datasets.ImageFolder(root./data/val, transformval_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_classes6): super().__init__() self.features nn.Sequential( # 卷积 - ReLU - 池化padding1 保持尺寸 nn.Conv2d(3, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) # 全局平均池化替代展平操作 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_classeslen(train_data.classes)) print(model)这里每个Conv2d的padding都显式写成1stride用默认的1MaxPool2d的kernel_size2, stride2把特征图宽高各砍半。AdaptiveAvgPool2d((1,1))是全局平均池化的官方写法不管前面特征图尺寸是多少输出都是1×1模型因此对输入分辨率有一定容忍度。classifier是最后一层Linear(128, num_classes)输出每一类的原始得分配合CrossEntropyLoss在内部做softmax。注意代码里没有手动写softmax训练和验证都直接拿logits算损失和准确率即可。3.3 训练循环与关键超参数学习率、momentum、batch size垃圾分类这个量级的数据训练超参数有固定套路直接抄经验值比反复搜索划算。一个在6类垃圾分类数据集上10个epoch就能到85%以上的配置如下参数取值说明optimizerSGDAdam早期更快后期精度常不如SGDlearning rate0.01若loss震荡则降到0.003momentum0.9平滑梯度方向减少震荡batch size32224×224下普通显卡内存适中epochs20主要看验证集配合早停SGD加momentum0.9在图像分类里是经典组合它不像Adam那样对每个参数单独调步长泛化性通常更好。学习率0.01是经验起点如果前两个epoch的loss完全不降大概率不是学习率问题而是Normalize的均值标准差写反了或数据路径读到了空文件夹。下面这段训练循环可以直接复用import torch.optim as optim from torch.utils.data import DataLoader train_loader DataLoader(train_data, batch_size32, shuffleTrue, num_workers4) loss_fn nn.CrossEntropyLoss() optimizer optim.SGD(model.parameters(), lr0.01, momentum0.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(fEpoch {epoch 1:02d}, Loss: {avg_loss:.4f})最容易被忽略的是optimizer.zero_grad()。PyTorch的梯度默认累加不清零时第二个batch的梯度会和第一个叠加loss曲线会无规律抖动。另一个坑是num_workersWindows下建议设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_classeslen(train_data.classes)) model2.load_state_dict(torch.load(garbage_model.pth, map_locationcpu)) model2.eval() print(权重加载成功类别列表, torch.load(classes.pth))map_locationcpu这一行对GUI部署至关重要。训练用的权重如果保存在GPU上文件里含cuda:0设备标识部署机器没有显卡时加载会直接报错指定map_locationcpu强制把所有张量放到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, dim1).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-2GUI里更实用的验证与调优技巧垃圾分类的GUI落地有个被低估的细节top-1精度高不等于用户认可度高。玻璃和陶瓷、塑料和金属在实拍图里经常互相混淆如果界面只显示一个最高概率类别用户看到玻璃瓶被认成陶瓷碗会直接判定系统失败但如果同时展示第二可能的类别用户会发现系统至少猜对了一半。因此我在GUI里默认显示top-2结果用torch.topk一次取两个最大概率值with torch.no_grad(): logits self.model(x) probs torch.softmax(logits, dim1).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置信度用进度条并排显示用户能直观看到模型在做选择时的犹豫这个反馈本身也是判断模型是否过拟合的窗口。本文还有配套的精品资源点击获取