简介:针对垃圾分类场景的Python与深度学习实现方案,面向计算机视觉初学者、智能环保应用开发者及毕业设计人群,解决人工分拣效率低、垃圾类别识别难等现实问题。技术链路上覆盖数据收集(含Google图片爬虫脚本)、图像预处理(尺寸调整、归一化、去噪)、卷积神经网络模型构建、训练调优(交叉验证、超参数调整、Dropout防过拟合)、模型评估(准确率、精确率、召回率、F1)及部署应用(串口通信、图片识别),并配有shell训练脚本和readme说明,便于快速复现与二次开发。包体共10个文件,以5个Python源码为核心,另含sh脚本、txt文档及png/jpg示例图片,整体仅5.73MB,轻量便携。已有5115人学习下载,适合作为课程项目或毕业设计的代码基座,也可为搭建轻量化图像分类服务提供参考。资源内还包含香蕉、瓶子等示例图片,可直接测试识别效果,并可通过串口模块与外部硬件联动,拓展实际应用场景。
1. 从手工分类到深度学习:这个系统到底解决什么问题
垃圾分类这个事,落到代码层面其实就是一个图像分类任务:给一张垃圾照片,让模型说出它是可回收物、有害垃圾、厨余垃圾还是其他垃圾。传统做法里,最朴素的是用颜色直方图加SVM做分类,但换一个光照、换一个拍摄角度,准确率立刻崩盘,因为垃圾的外形差异太大——一个皱巴巴的塑料袋和一张白纸在颜色特征上几乎无法区分。深度学习方法把这事变成了「让网络自己学习什么是塑料、什么是纸张」,用CNN提取纹理、边缘、形状等高层语义特征,鲁棒性比手工特征高一个量级。
这个项目是一个很典型的「深度学习入门到系统落地」综合体,适合两类人:一是正在做毕业设计或课程设计的学生,需要一套能跑通、能演示、能写进论文的完整链路;二是刚入门深度学习、想用一个小而全的项目把数据加载、模型训练、模型部署走一遍的开发者。它不涉及目标检测、不涉及实例分割,就是一个纯粹的图像分类闭环,但正因为简单,才适合作为第一个「完整系统」来搭。顺便说一句,环境配置的坑比模型训练的坑多,我见过太多人在第一步装环境就卡了两天,后面会专门讲到。
2. 先把数据集收拾明白:目录结构、标签编码与数据划分
2.1 数据从哪来:公开数据集与自建数据的取舍
做这个项目,数据是第一个决定成败的环节。常用方案是使用公开的垃圾分类图像数据集,比如华为云当年公布的垃圾分类数据集,包含四个大类四十多个小类,图片质量参差不齐,正好拿来练手。不过实际使用时你会发现,这个数据集的类别分布并不均匀,有的类别上千张,有的类别只有几十张,如果直接拿去训练,模型会对样本多的类别过拟合。
另一个方案是自建数据集,用手机拍、从网上爬,但成本很高,而且标注工作极其枯燥。我的建议是:以公开数据集为主,自己补拍一些明显缺失的类别。比如你发现数据集中「废电池」这个类别的图片都是同一角度拍的,真实场景里电池可能是正着、反着、带包装的,那就可以自己补拍几十张加进去。这种做法在论文里写「数据增强与补充」也说得过去。
数据准备好之后,目录结构必须严格遵循PyTorch中ImageFolder的约定:train/类别名/图片和val/类别名/图片。这不是随便规定的,因为ImageFolder会自动把文件夹名当作类别标签。我见过有人把所有图片放在同一个文件夹,然后在CSV里写标签,再用自定义Dataset去读,这样当然也可以,但多了一层代码,对入门项目来说没必要。
2.2 目录整理与标签编码:三行脚本搞定
假设你已经下载好了原始数据集,原始结构可能是Dataset/train/图片1.jpg加一个标签CSV。我们先用一个脚本把它整理成ImageFolder需要的结构。这个步骤在论文里可以写为「数据集预处理」,实操中就三件事:读取CSV、按标签复制图片、划分类别子目录。
import os import shutil import pandas as pd from sklearn.model_selection import train_test_split # 读取标注文件,假设只有两列:filename, label df = pd.read_csv('dataset/annotations.csv') # 统计标签分布,顺便看看有没有类别样本数过少 print(df['label'].value_counts()) # 按8:2划分训练集和验证集,stratify保证类别比例一致 train_df, val_df = train_test_split( df, test_size=0.2, random_state=42, stratify=df['label'] ) # 创建目标目录 for split_name, split_df in [('train', train_df), ('val', val_df)]: for label in split_df['label'].unique(): os.makedirs(f'data/{split_name}/{label}', exist_ok=True) # 复制图片到对应目录 for _, row in train_df.iterrows(): src = f'dataset/images/{row["filename"]}' dst = f'data/train/{row["label"]}/{row["filename"]}' shutil.copy(src, dst) # 验证集同理,此处省略重复代码 for _, row in val_df.iterrows(): src = f'dataset/images/{row["filename"]}' dst = f'data/val/{row["label"]}/{row["filename"]}' shutil.copy(src, dst) print('目录整理完成,类别数:', train_df['label'].nunique())这段代码里有几个值得注意的点。train_test_split里的stratify=df['label']是分层抽样,保证训练集和验证集中每个类别的比例和原始数据一致,避免出现训练集中某类图片特别多、验证集中特别少的情况。random_state=42固定随机种子,保证每次运行划分结果一致,这在论文里写「可复现性」时是加分项。
实际中有一个更隐蔽的问题:类别名里可能包含空格、中文、特殊字符。比如「废纸张」和「废纸 张」,看起来一样,实际是两个文件夹。这会导致模型训练时类别数比预期多。建议在划分之前打印unique()检查一遍,这是血的教训。
2.3 数据加载与增强:transform的配置直接决定效果
图片整理好了,接下来要写数据加载器。PyTorch的torchvision.datasets.ImageFolder搭配DataLoader是标准做法。但这里有个关键点:训练集和验证集的transform必须不一样。训练集需要做数据增强(随机裁剪、翻转、色彩抖动),验证集只能做尺寸缩放和归一化,否则验证结果会虚高。
from torchvision import datasets, transforms from torch.utils.data import DataLoader # 训练集增强:随机裁剪到224,水平翻转,色彩抖动 train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), 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]) ]) train_dataset = datasets.ImageFolder('data/train', transform=train_transform) val_dataset = datasets.ImageFolder('data/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4) # 打印类别映射,确认类别数是否正确 print(train_dataset.classes) print(train_dataset.class_to_idx)RandomResizedCrop的scale=(0.6, 1.0)控制裁剪面积占原图的比例,(0.6, 1.0)表示最小裁剪原图的60%。默认值是(0.08, 1.0),对ImageNet这种大图没问题,但对垃圾照片这种本身就构图随意的图,裁剪太小会让模型只看到物体的局部,反而学不到完整特征。num_workers=4是数据加载的并行进程数,Windows上如果报错就改成0,但训练速度会明显变慢。
ColorJitter的应用要注意:如果这个垃圾分类系统要部署到手机端,用户的拍照环境不可控,色彩抖动是很有价值的增强;但如果你的应用场景是固定的摄像头(比如垃圾桶上的摄像头),色彩抖动反而会让模型对颜色不敏感,因为光照基本恒定。这是个很现实的设计取舍。
3. 模型选型与训练:ResNet还是MobileNet,这是个问题
3.1 预训练模型的选择:算力不够时别自己从头训
模型选择是整个垃圾分类系统的核心决策点。常见的坑是:有人直接定义了一个五六层的CNN从头训练,结果在四分类上准确率只有70%左右,怎么调都上不去。原因是垃圾分类数据集的规模通常只有几万张甚至几千张,远不足以支撑从零训练一个深层网络。
正确做法是使用预训练模型做迁移学习。torchvision.models里提供了ResNet18、ResNet34、ResNet50、MobileNetV3等主流模型,这些模型在ImageNet上预训练过,已经学会了纹理、边缘、颜色等通用特征。因为垃圾分类——尤其是可回收物和有害垃圾的区分——非常依赖纹理和材质特征,预训练模型的底层特征可以直接复用。
选型逻辑很简单:有GPU、要追求准确率就选ResNet50;没GPU或者要部署到嵌入式设备就选MobileNetV3。ResNet18是个折中方案,参数量约1100万,在CPU上跑一次推理300到500毫秒,训练速度也比ResNet50快很多。以下代码以ResNet18为例,因为它对入门者最友好,显存占用不到2GB,几乎不会OOM。
import torch import torch.nn as nn from torchvision import models # 加载预训练模型 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 获取全连接层的输入维度,ResNet18是512 num_features = model.fc.in_features # 替换最后一层为4分类输出 num_classes = 4 # 可回收物、有害垃圾、厨余垃圾、其他垃圾 model.fc = nn.Linear(num_features, num_classes) # 冻结除全连接层以外的所有参数,先只训练分类头 for name, param in model.named_parameters(): if name not in ['fc.weight', 'fc.bias']: param.requires_grad = False # 定义损失函数和优化器,只优化fc的参数 criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)这里有一个常见的误区和参数说明。models.ResNet18_Weights.IMAGENET1K_V1是PyTorch新版本的写法,旧版本可以直接传pretrained=True,但会提示警告。如果我们把所有层都设置requires_grad=False,只训练最后的全连接层——这是「迁移学习」的标准做法,因为底层卷积层学到的边缘和纹理特征在几乎所有图像任务上都通用,而最后一层需要针对当前任务的类别重新学习。
这个策略有个前提:你的新数据集和ImageNet的数据分布没有过大偏差。垃圾照片虽然和ImageNet里的物体不同类,但底层特征(边缘、纹理、颜色块)是通用的,所以这个前提成立。但如果数据非常特殊——比如全是热成像图——那冻结底层就不合适了,应该让所有层都参与训练,只是学习率调小一点。
3.2 训练循环与超参数:学习率、批量大小、Epoch怎么定
训练循环本身不复杂,复杂的是设置合理的超参数。对于迁移学习,我的经验是:冻结底层时学习率用1e-3,解冻底层后学习率降到1e-4或更低。批量大小视显存而定,8GB显存跑ResNet18可以用64,跑ResNet50就降到32。Epoch数不是越多越好,一般10到20个epoch就能收敛,多的会过拟合——训练集准确率接近100%,验证集反而下降了。
import copy def train_model(model, train_loader, val_loader, criterion, optimizer, num_epochs=15, patience=3): best_model_wts = copy.deepcopy(model.state_dict()) best_acc = 0.0 epochs_no_improve = 0 for epoch in range(num_epochs): # 训练阶段 model.train() running_loss = 0.0 correct = 0 total = 0 for inputs, labels in train_loader: # 如果有GPU就转cuda inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() epoch_loss = running_loss / total epoch_acc = correct / total # 验证阶段 model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) _, predicted = torch.max(outputs, 1) val_total += labels.size(0) val_correct += (predicted == labels).sum().item() val_acc = val_correct / val_total print(f'Epoch {epoch+1}/{num_epochs}, ' f'Train Loss: {epoch_loss:.4f}, Train Acc: {epoch_acc:.4f}, ' f'Val Acc: {val_acc:.4f}') # 记录最佳模型,并做早停 if val_acc > best_acc: best_acc = val_acc best_model_wts = copy.deepcopy(model.state_dict()) epochs_no_improve = 0 else: epochs_no_improve += 1 if epochs_no_improve >= patience: print(f'验证集连续{patience}轮没有提升,提前停止训练') break # 返回最佳模型参数 model.load_state_dict(best_model_wts) return model, best_acc这个训练循环里埋了几个实用的工程细节:第一,验证阶段必须用torch.no_grad()包裹,否则会额外计算梯度,白白占用显存;第二,model.train()和model.eval()切换不能省略,因为eval会让BatchNorm层使用全局均值方差,train则用当前batch的统计量;第三,用copy.deepcopy保存最佳模型参数而不是在训练结束时才取最后状态的模型——验证集准确率最高的那个模型才是最好的模型,而不是最后一个epoch的模型,损失曲线后期往往在平台期震荡,最后几轮基本是随机波动。
早停(patience=3)这个参数的意义是,当验证集连续3轮没有提升时就提前结束训练,既节约时间也防止过拟合。这在论文里的说法是「在验证集准确率不再上升时终止训练以避免过拟合」。
3.3 解冻全部层做微调:让准确率再上一个台阶
如果冻结底层训练后准确率到达瓶颈(比如85%左右),下一步是解冻全部层、用更小的学习率继续训练。这一步在论文里通常会写在「fine-tuning」小节中。这也是一次非常有技巧性的「二阶段训练」。
# 解冻所有层 for param in model.parameters(): param.requires_grad = True # 降低学习率,使用更小的lr optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) # 继续训练,这次只训5-8个epoch,注意观察验证集变化 model, best_acc = train_model( model, train_loader, val_loader, criterion, optimizer, num_epochs=8, patience=2 ) print(f'微调后验证集准确率: {best_acc:.4f}')为什么二阶段训练比一阶段效果更好?因为冻结底层时,底层的特征提取器还是ImageNet风格的,不一定完全适配垃圾图片的纹理特性。解冻后用1e-4这样的小学习率去微调底层,可以让底层特征微调得更贴合目标域。但学习率不能大,否则底层特征会被破坏,之前学的东西就「前功尽弃」了。
4. 垃圾分类系统落地避坑:训练与推理中最常见的5个问题
这一章专门写踩坑记录,不是理论,都是实打实出现过的问题。每一条都按「现象→原因→解决」来写,方便你复现时对照排查。
4.1 训练损失不降反升,准确率一直在随机水平
现象:训练了十几个epoch,损失函数在1.0到2.0之间来回横跳,验证准确率一直维持在25%左右——四分类的随机水平,模型完全没学到东西。
原因:最常见的原因是标签顺序和模型输出没对齐。ImageFolder的class_to_idx是按文件夹名字典序排列的,比如['other', 'kitchen_waste', 'recyclable', 'harmful']对应索引0、1、2、3。如果在自定义Dataset里用了自己的一套标签编码,和class_to_idx对不上,那模型预测的「0」可能实际是「厨余垃圾」,而训练时标签「0」被定义成「其他垃圾」,整个训练就是在学一套错误的映射。
解决:用train_dataset.class_to_idx作为唯一标签依据。打印出来看看,确认和你的四分类一致。另外,如果数据量特别少(每类只有几十张),模型确实学不动,先用数据增强把样本量提上来。
4.2 GPU利用率低,训练速度慢得让人怀疑人生
现象:显存占用正常,但GPU利用率只有20%左右,一个epoch要跑十几分钟。CPU核心数的占用倒是很高,风扇狂转。
原因:数据加载成了瓶颈。CPU在读图和做数据增强的速度赶不上GPU的计算速度,GPU一直在等数据。这在高分辨率图片和重的transform(比如RandomResizedCrop加ColorJitter)时尤其明显。
解决:调大num_workers。在Linux上可以开到CPU核心数的两倍,但在Windows上num_workers大于0会在每个epoch结束时卡顿甚至报BrokenPipeError,这是特化问题。还有一种治本的方式是先把所有图片Resize到256×256再保存,训练时只做随机裁剪和归一化,减少CPU的计算压力。另外把图片转成Tensor后直接变成RGB,ToTensor()会自动处理,但如果你用的是OpenCV读取,要注意默认是BGR,需要cv2.cvtColor(img, cv2.COLOR_BGR2RGB)手动转换——这个问题不会报错,只会在视觉上看起来颜色奇怪,非常隐蔽。
4.3 训练精度99%,验证精度只有60%:严重过拟合
现象:训练集准确率到了99%以上,验证集卡在60%上不去,每训练一轮差距还在拉大。
原因:典型的过拟合。有可能是数据量太小,模型把训练集的背景、拍摄角度都记住了;也有可能是数据划分有问题——同一个场景拍的多张图同时进了训练集和验证集,验证集的图片和训练集高度相似,验证结果虚高,换了真实图片立刻现形。
解决:先查数据划分,确保同一来源的图片不跨集合。如果数据总量确实小,加权重衰减(weight_decay=1e-4)和Dropout(在fc层前加nn.Dropout(p=0.5))是有效手段。模型层面,ResNet18比ResNet50更适合小数据集,因为模型容量小、不容易过拟合。如果还是不行,就回到数据增强,把RandomResizedCrop的scale下限调低到0.4,对裁剪后的图做更强力翻转,这些对策可以叠加使用。
4.4 部署到CPU推理时,速度慢得没法演示
现象:训练时有GPU,跑一次推理几百毫秒;部署到客户电脑或教室的CPU机器上,ResNet50一次推理要3到5秒,演示体验极差。
原因:模型没做任何优化,跑在原生态PyTorch上。ResNet50的参数量约2500万,CPU上的计算瓶颈在卷积层的矩阵运算上,「裸跑」当然是这个速度。另外没有批处理,每次只推理一张图片,CPU利用率也不高。
解决:推理时使用torch.no_grad()包裹,并把模型切到eval模式。更有效的方法是把模型导出为ONNX格式,再用ONNX Runtime加载,推理速度能提升50%到一倍;或者用torch.jit.trace导出TorchScript,两者选一个就能起效果。如果模型本身太重,直接从ResNet50换到MobileNetV3-Large,速度能提升3倍以上,准确率只掉2到3个百分点——这才是治理推理太慢的根本方法。
import torch # 导出ONNX格式模型,用于CPU端部署 model.eval() dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, 'garbage_classifier.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}}, opset_version=11 ) print('ONNX模型已导出')dynamic_axes参数允许推理时batch_size不固定,这个参数在写论文时说「支持动态批处理」也是一句技术点。但实际上如果你的应用就是单张图片识别,不设置dynamic_axes,固定batch_size=1也可以,稳定性和一致性更好。
4.5 量化后模型精度骤降,分类结果乱成一团
现象:为了追求加速,把模型从FP32量化到INT8,推理速度是快了,但四分类准确率从90%直降到70%左右,个别类别全部预测错。
原因:量化对权重中的离群值特别敏感。ResNet的BatchNorm层的均值和方差分布如果比较极端,量化误差会被放大。另外,如果量化方法是「训练后量化」(Post-Training Quantization)而没有做「感知量化训练」(Quantization-Aware Training),模型没有适应低精度表示,精度掉得就特别厉害。
解决:垃圾分类这种任务对精度敏感度不高,但四分类里「可回收物」和「有害垃圾」在视觉上有时确实接近(比如塑料瓶和药瓶),量化误差在边界样本上会放得更明显。建议选择量化感知训练,或在量化时用校准数据集做per-channel量化校准,而不用per-tensor。在代码上手操作时,最简单的方案是先用FP16半精度推理试一下——精度几乎不掉,速度在支持FP16的CPU上有明显提升。
5. 从模型到系统:推理接口、GUI界面与部署路径
5.1 封装推理类:把预测逻辑变成一个函数
现在模型训练好了,也导出了ONNX格式,下一步把它封装成一个便于调用的模块。在系统设计论文里,这一步叫「推理层设计」,在工程里就是一个类的事。关键要处理好图片预处理和结果解读——这两步最容易出错。
import numpy as np from PIL import Image import onnxruntime as ort class GarbageClassifier: def __init__(self, onnx_path, class_names): self.session = ort.InferenceSession(onnx_path) self.class_names = class_names def preprocess(self, image): # image是PIL.Image,Resize到224,转Tensor并归一化 img = image.resize((224, 224)) img = np.array(img).astype(np.float32) / 255.0 # 注意torchvision的Normalize用的mean/std是ImageNet统计值 mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) img = (img - mean) / std # 调整维度从HWC到CHW,并增加batch维度 img = img.transpose(2, 0, 1)[np.newaxis, ...] return img.astype(np.float32) def predict(self, image): input_tensor = self.preprocess(image) outputs = self.session.run( None, {self.session.get_inputs()[0].name: input_tensor} )[0] probs = np.exp(outputs[0]) / np.sum(np.exp(outputs[0])) # softmax pred_idx = int(np.argmax(probs)) confidence = float(probs[pred_idx]) return self.class_names[pred_idx], confidence # 使用示例 classifier = GarbageClassifier('garbage_classifier.onnx', ['可回收物', '有害垃圾', '厨余垃圾', '其他垃圾']) # 图片路径 from PIL import Image img = Image.open('test.jpg').convert('RGB') name, conf = classifier.predict(img) print(f'识别结果: {name}, 置信度: {conf:.2f}')这段代码里有几个考点。onnxruntime和PyTorch的预处理要保持完全一致,特别是归一化的mean和std,一旦不一致,模型输入分布就偏了,结果直接「翻车」。img.convert('RGB')很重要——如果用户传入的是带透明通道的PNG图,np.array(img)会多出第四个通道,数组形状不是(224, 224, 3)而是(224, 224, 4),后续的transpose会得到错误的维度甚至直接抛异常。
置信度阈值可以在预测时加一道判断:如果confidence < 0.6就返回「无法识别」,而不是硬给一个分类结果。这在真实场景中很有必要,因为用户可能拍一张空桌子或拍糊的照片,模型强行分类反而让用户体验更差。
5.2 用PySide6搭一个桌面GUI:开箱即用的演示壳
系统的「设计与实现」部分,GUI是逃不掉的。用PySide6(Qt的Python绑定)做一个简单的界面:左边显示图片,右边显示识别结果和置信度,底部一个「选择图片」按钮。这是毕业答辩时最容易获得直观印象的部分,也可以写成你系统设计的「前端展示层」。不要在GUI上花太多时间,能跑、够用就行。
import sys from PySide6.QtWidgets import (QApplication, QMainWindow, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QWidget) from PySide6.QtGui import QPixmap from PySide6.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle('垃圾分类识别系统') self.classifier = GarbageClassifier( 'garbage_classifier.onnx', ['可回收物', '有害垃圾', '厨余垃圾', '其他垃圾'] ) # 控件初始化 self.image_label = QLabel('请选择一张图片') self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setMinimumSize(400, 400) self.result_label = QLabel('识别结果:等待输入') self.btn = QPushButton('选择图片') # 布局 layout = QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(self.result_label) layout.addWidget(self.btn) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) # 信号连接 self.btn.clicked.connect(self.open_image) def open_image(self): from PySide6.QtWidgets import QFileDialog file_path, _ = QFileDialog.getOpenFileName( self, '选择图片', '', '图片文件 (*.jpg *.png *.bmp)' ) if not file_path: return pixmap = QPixmap(file_path) self.image_label.setPixmap( pixmap.scaled(400, 400, Qt.KeepAspectRatio) ) img = Image.open(file_path).convert('RGB') name, conf = self.classifier.predict(img) self.result_label.setText(f'识别结果:{name},置信度:{conf:.2f}') if __name__ == '__main__': app = QApplication(sys.argv) window = MainWindow() window.show() sys.exit(app.exec())Qt.KeepAspectRatio参数保证图片等比缩放,不会变形——这个细节在演示时很能说明你有没有认真对待用户体验。QFileDialog.getOpenFileName的第三个参数是起始目录,留空表示当前目录,第四参数过滤了非图片文件,防止用户选到别的文件类型。
GUI的布局用QVBoxLayout垂直排列三个控件,简单直接。如果你想做得更好看一点,可以把结果标签加粗、加颜色——可回收物显示绿色,有害垃圾显示红色,这对用户来说是一眼就能看明白的反馈。这些视觉细节写在论文里就是「人机交互设计」。
5.3 相机实时识别:把系统接到摄像头
桌面单张图片识别只是第一步,很多毕设题目会要求「实时识别」——接上摄像头,对视频流逐帧分类。这里要特别注意性能:对每一帧做全尺寸Resize再推理,CPU会吃不消,常见的优化手段是每隔N帧采样一次,或者把输入尺寸降到160。垃圾分类在实时场景下不变形的时候多,因为用户会把垃圾放在镜头前静止片刻再丢进去,这是个特殊的识别场景。
import cv2 cap = cv2.VideoCapture(0) if not cap.isOpened(): print('无法打开摄像头') frame_count = 0 while True: ret, frame = cap.read() if not ret: break frame_count += 1 if frame_count % 3 == 0: # 每3帧识别一次 # OpenCV是BGR格式,需转RGB rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(rgb_frame) name, conf = classifier.predict(pil_img) label_text = f'{name} ({conf:.2f})' # 在画面上绘制识别结果 cv2.putText(frame, label_text, (20, 50), cv2.FONT_HERSHEY_SIMPLEX, 1.2, (0, 255, 0), 2) cv2.imshow('Garbage Classification', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()帧采样间隔frame_count % 3是性能与流畅度的平衡点,但这个参数依赖具体机器性能。处理办法是:先用连续识别测出单帧推理时间t,然后把间隔设为30 / t乘以一个常数,这种动态帧采样的方式能兼顾响应速度和稳定性。如果你想更丝滑,可以引入一个last_label变量,采样帧之外的其他帧直接绘制上一次的识别结果,视觉上不会出现标签闪烁。这种细节在真实部署场景里面很有价值。
6. 进阶技巧:把系统做扎实的三个方向——数据增强可视化、类别置信度校准、轻量化部署
到了系统能跑通、演示没问题的阶段,下一步不是急着扩展新功能,而是做「验证与打磨」。这一章给三个具体的进阶方向,都是投入不大但效果明显、写在论文或简历里都能加分的事。
第一个方向是数据增强效果可视化——很多人写了数据增强代码,但从来没看过增强后的图片长什么样,这其实是隐患。实现方法很简单:把train_transform作用于一张具体图片,保存增强后的结果,增强前后对比一下。你会注意到:如果ColorJitter的brightness=0.5,图片可能亮到看不清细节,这时训练就会把「亮度异常」当作特征来学,反而让模型对正常亮度的图片识别变差。这个坑很隐蔽,但它直接决定你系统的鲁棒性。建议把增强后的图片批量保存到一个文件夹,隔几轮训练就抽看一次,确认增强后图片仍然保持「人眼可辨认」的状态,尤其要检查文本、标志这类细线条纹理是否被裁剪掉。
第二个方向是做类别置信度校准。交叉熵损失函数训练出的模型输出概率并不代表真实概率,模型在见过更多的类别上常常过度自信。如果你发现系统对某些类别总是给出0.95以上的置信度,但实际经常判错,这就是说过度自信。解决办法是温度缩放——用验证集学习一个最优温度参数T,把softmax输出除以T再归一化,校准后的概率更符合真实置信度。公式很简单,实现不超过20行代码,但能明显改善「置信度阈值判断」的可靠性。
import numpy as np from scipy.optimize import minimize_scalar def temperature_scale(logits, labels): # logits是验证集所有样本的原始输出 # 找到最优温度T,使负对数似然最小 def nll_loss(T): scaled_logits = logits / T exp_logits = np.exp(scaled_logits) probs = exp_logits / exp_logits.sum(axis=1, keepdims=True) return -np.mean(np.log(probs[np.arange(len(labels)), labels] + 1e-10)) result = minimize_scalar(nll_loss, bounds=(0.1, 10.0), method='bounded') return result.x # 最优温度 # 使用:预测时 prob = softmax(logits / T)温度缩放的原理在于,网络最后的logits往往过于尖锐——最大值比平均大得多。除以一个大于1的T可以让分布平坦化,让概率落在更合理的区间。这个技巧在「模型可信度」话题里经常出现,也是一个有价值的技术点,值得写进论文。
第三个方向是轻量化模型的选择对比。很多系统最后卡在部署上:客户要求CPU上跑、内存不超过512MB、无GPU环境。怎么做?建议做一个「模型选型对比表」:ResNet18、MobileNetV3-Large、MobileNetV3-Small、EfficientNet-Lite,在完全相同的训练超参数下跑一遍,记录准确率、模型大小、CPU推理耗时。这个表填完,你的系统就是有说服力的——不同场景选不同模型,这个选型逻辑本身就是论文里「系统设计」章节的核心论据。真的不建议在一棵树上死磕。
最后一个实践习惯:每次训练结束后保存完整的配置文件,包括数据划分的随机种子、transform参数、优化器配置和最终准确率,然后用CSV记录每次实验的记录。这不是一个可以省略的步骤。这类项目最痛苦的时刻不是训练不出来,而是某天你调了个参数准确率从90%掉到85%,翻回来想恢复之前的结果,发现早忘了当时的配置——这种「后悔药」只有实验记录才能给你。希望我的这些经验和教训能帮到你,省下你在这个项目上的排查时间。
本文还有配套的精品资源,点击获取