PyTorch水果蔬菜识别:迁移学习与CNN图像分类实战
简介这是一套基于深度学习CNN网络的水果蔬菜识别系统项目源码并配套论文报告和可视化界面面向毕业设计、课程设计及Python深度学习初学者与二次开发的在校学生。项目完整度高、难度适中从图像数据划分、数据增强、CNN训练与评估到登录式图形界面均有实现。压缩包共78个文件以Python脚本、图片、文本及PDF文档为主其中16个py文件覆盖数据处理、模型构建、训练测试和界面交互png/jpeg/jpg图片包含测试样本与评估曲线PDF为论文报告txt为说明文档整体约12.72MB结构清晰易上手。目前已有111人学习下载适合需要快速进入图像识别实战的低年级学生及备赛人员。通过该项目可以获得一整套完整工程不仅能看到损失曲线、准确率曲线和热力图等评估结果还能掌握从数据预处理到界面部署的链路有利于在原有基础上继续扩展或改进。 我们先用一组数字把直觉立起来在 Fruits-360 这类公开数据集上用 ResNet50 从零训练 50 个 epoch准确率通常不会超过 85%但同一个网络换成 ImageNet 预训练权重、只微调最后两层准确率能普遍冲到 97% 以上。这个差距不是网络结构不行而是水果蔬菜识别这类任务的数据规模、类别相似度和训练配置共同决定的。一个完整的 CNN 图像识别系统说白了由五块构成数据管线、网络结构、训练策略、评估手段和交付用的界面。这篇博文就把这五块逐层拆开围绕 python、深度学习、CNN 和图像识别这几个关键词给出可直接复制的代码与参数设置最后交代论文报告里评估曲线和对比实验该怎么组织。适合课程设计、毕业设计以及想系统走一遍图像分类流程的开发者五六年经验的工程师也能在训练配置和部署细节里找到可对照的边界条件。1. 水果蔬菜识别系统的真正瓶颈不在网络结构很多人拿到一个 CNN 图像识别任务第一反应是换更深的网络ResNet 不行换 DenseNet再不行换 EfficientNet。但水果蔬菜识别这个场景的特殊性在于数据集规模小、类别间视觉差异细微、光照和拍摄角度干扰大。以 Fruits-360 为例它包含几十类水果蔬菜每一类样本从几百到几千张不等苹果和青椒、柠檬和青柠之间的差异有时候连人都要凑近看才能分辨。这种任务里过拟合的风险远高于模型容量不足的风险真正的瓶颈往往出在数据增强策略、学习率调度和类别不均衡处理上。这篇博文要解决的问题很具体怎么用 python PyTorch 搭出一套能跑通的水果蔬菜识别系统训练过程中怎么生成评估曲线最终怎么看懂那几张准确率损失图以及如何把它们整理成论文报告里的实验章节。整个方案按「数据处理 → 模型与训练 → 评估与图表 → 界面交付」四段推进代码以可复现的最小单元给出参数全部标注适用场景。2. 数据管线和预处理决定 CNN 识别上限的第一道关口2.1 用 torchvision 把图片文件夹变成可训练的数据集水果蔬菜识别的数据来源通常有两种一是开源数据集二是自己用手机拍的照片。无论哪种我都建议先把图片统一整理成train/类别名/图片和val/类别名/图片的目录结构然后交给torchvision.datasets.ImageFolder处理。这个类会自动扫描子目录名作为类别标签省去手工维护 CSV 标签表的工作。import torch from torchvision import datasets, transforms from torch.utils.data import random_split # 数据路径目录结构必须是 data/fruits-360/train/类别名/*.jpg data_path data/fruits-360 # 训练阶段的 transform随机裁剪、翻转、颜色抖动是识别类任务的核心增强 train_tf transforms.Compose([ transforms.RandomResizedCrop(size224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 验证阶段不做增强只做尺寸对齐和标准化 val_tf 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]) ]) full_dataset datasets.ImageFolder(data_path, transformtrain_tf) torch.manual_seed(42) train_set, val_set random_split(full_dataset, [0.8, 0.2])RandomResizedCrop中的scale(0.7, 1.0)表示裁剪面积占原图的 70% 到 100%这个范围对水果蔬菜这类主体占据画面大部分的场景比较合适。ColorJitter的三个参数分别控制亮度、对比度和饱和度的扰动幅度0.3 是经验值数值过大会让青色水果的颜色失真过小则起不到模拟不同光照的作用。Normalize用的 mean 和 std 是 ImageNet 预训练统计量如果打算从零训练 CNN最好统计自己数据集的均值和标准差再替换。2.2 数据增强的参数怎么设一张表把策略定下来增强策略推荐参数作用随机裁剪size224, scale(0.7, 1.0)模拟不同拍摄距离增强尺度不变性水平翻转p0.5日常拍摄中常见角度变化成本最低颜色抖动brightness0.3, contrast0.3, saturation0.3应对不同光照环境下的色偏随机旋转degrees15角度过大会把竖长的香蕉变成横躺引入错误语义归一化ImageNet 均值/方差加速收敛配合预训练权重使用2.3 类别不均衡和划分随机性问题水果蔬菜数据集的类别样本数往往不一致有的类别几百张有的类别几十张。直接按比例划分训练集和验证集小样本类别的验证结果会有很大的随机波动。首先在random_split前固定torch.manual_seed保证每次实验划分方式一致其次在训练时使用WeightedRandomSampler让每个 batch 的采样概率与类别样本数成反比。from torch.utils.data import WeightedRandomSampler # 统计每个类别的样本数计算采样权重 class_counts torch.bincount(torch.tensor([full_dataset.targets[i] for i in train_set.indices])) weights 1.0 / class_counts.float() sample_weights torch.tensor([weights[full_dataset.targets[i]] for i in train_set.indices]) sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader torch.utils.data.DataLoader(train_set, batch_size32, samplersampler, num_workers4)WeightedRandomSampler的replacementTrue表示有放回采样小样本类别会被重复抽到相当于隐式做了样本复制。训练后期如果发现小样本类别准确率仍然偏低再考虑在损失函数上加类别权重一般不需要同时使用两种手段。3. CNN 网络结构选择与训练参数配置3.1 从零搭建 CNN 还是迁移学习先算清数据账从零搭建 CNN 的意思是自己定义卷积层、池化层和全连接层比如两个卷积块加一个全连接层这种结构在小数据集上很容易过拟合。迁移学习则是加载预训练权重保留浅层通用的边缘和纹理特征只微调高层语义特征。对水果蔬菜识别来说类别之间主要的差异就是颜色和纹理并不需要特别抽象的形状语义所以不需要解冻整个网络。这里有一个经验判断训练集单类样本低于 500 张不要从零训练单类样本低于 2000 张只微调最后两层到三层只有单类样本超过 5000 张才考虑整体微调。深层次原因是 CNN 浅层卷积核学的是边缘、角点这类通用特征任何图像数据集都能复用深层特征才和具体任务相关数据量不足时深层参数量反而成为负担。import torchvision.models as models # 使用 ResNet50 预训练权重把最后的全连接层替换成自己的分类头 model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 冻结全部参数 for param in model.parameters(): param.requires_grad False # 解冻最后一个残差块 layer4让深层特征适应水果蔬菜数据 for param in model.layer4.parameters(): param.requires_grad True # 替换分类头原输出是 1000 类改成自己的类别数 num_classes len(full_dataset.classes) model.fc torch.nn.Sequential( torch.nn.Dropout(0.3), torch.nn.Linear(2048, 512), torch.nn.ReLU(inplaceTrue), torch.nn.Linear(512, num_classes) )layer4的输出维度是 2048这是 ResNet50 最后的特征图通道数。中间加一层 512 维的瓶颈层有两个作用一是让分类头有足够的非线性拟合能力二是通过 Dropout 抑制过拟合。冻结策略上层数越深对原数据集ImageNet的偏置越强所以解冻layer4并用较小的学习率更新是迁移学习中性价比最高的配置。如果想理解卷积核到底学到了什么除了看输出特征图也可以配合李宏毅在公开课里对 CNN 各层可视化的讲解来对照那些边缘检测、纹理响应的图示比看论文里的公式直观得多。3.2 训练参数配置表照着抄再按数据量微调参数推荐值说明batch_size32 或 64显存充足时优先 64batch 过小导致 BN 统计不稳定初始学习率1e-3分类头、1e-4解冻层分组设置解冻层用更小学习率防止破坏预训练特征优化器AdamW, weight_decay1e-4Adam 的 L2 正则实现有 bugAdamW 是修正版epoch30 到 50配合早停不要固定跑满学习率调度CosineAnnealingLR 或 ReduceLROnPlateau前者省心后者需要监控验证损失早停 patience8 到 10 个 epoch验证准确率连续不升则停止3.3 训练循环与学习率调度的中文注释版代码import torch import torch.nn as nn from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) # 优化器分组分类头用 1e-3layer4 用 1e-4 optimizer AdamW([ {params: model.fc.parameters(), lr: 1e-3}, {params: model.layer4.parameters(), lr: 1e-4} ], weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss() best_acc 0.0 patience 0 for epoch in range(50): model.train() train_loss 0.0 for inputs, labels in train_loader: inputs, labels inputs.to(device), labels.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() # 验证阶段关闭梯度计算省显存 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_correct (predicted labels).sum().item() val_total labels.size(0) val_acc val_correct / val_total print(fEpoch {epoch1}, Loss: {train_loss/len(train_loader):.4f}, Val Acc: {val_acc:.4f}) # 早停逻辑连续 8 个 epoch 验证集不提升就终止 if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) patience 0 else: patience 1 if patience 8: break scheduler.step()CosineAnnealingLR的T_max30表示余弦周期是 30 个 epoch学习率从初始值降到接近 0 再回升。如果配合早停实际训练可能跑不满 30 个 epoch这种情况下余弦退火永远不会进入回升阶段表现为学习率单调递减这对迁移学习是合理的——最后几个 epoch 用小学习率收敛到局部最优。参数上注意torch.save保存的是state_dict而不是整个模型加载时需要先实例化一个相同结构的模型再调用load_state_dict。4. 评估曲线、混淆矩阵与论文报告素材的生成4.1 准确率和损失曲线怎么读过拟合不是看验证集波动的幅度训练完成后第一件事是画出两张图训练/验证的损失曲线和准确率曲线。这两张图是论文报告里最核心的评估素材也是判断模型是否过拟合的唯一可靠依据。一个常见的误判是看到验证准确率在 94% 到 96% 之间波动就认为是过拟合实际上这更可能是验证集太小导致的噪声。真正的过拟合形态是训练损失持续下降而验证损失在第 15 个 epoch 左右开始回升准确率曲线表现为验证集增长停滞训练集仍然在稳定上升。import matplotlib.pyplot as plt # history 是训练过程中记录的字典包含 train_loss, val_loss, train_acc, val_acc plt.figure(figsize(12, 4)) plt.subplot(1, 2, 1) plt.plot(history[train_loss], labelTrain Loss) plt.plot(history[val_loss], labelVal Loss) plt.xlabel(Epoch) plt.ylabel(Loss) plt.legend() plt.title(Loss Curve) plt.subplot(1, 2, 2) plt.plot(history[train_acc], labelTrain Acc) plt.plot(history[val_acc], labelVal Acc) plt.xlabel(Epoch) plt.ylabel(Accuracy) plt.legend() plt.title(Accuracy Curve) plt.tight_layout() plt.savefig(evaluation_curves.png, dpi300, bbox_inchestight)保存时用dpi300和bbox_inchestight前者保证印刷清晰后者避免坐标轴标签被截断。论文报告里一般要求位图不低于 300dpi这个参数直接满足了排版需求。观察曲线时重点看两个位置一是验证集峰值前是否有明显的平台期二是训练曲线是否在验证曲线下方。训练准确率永远高于验证准确率是正常的但如果两者差距超过 5 个百分点说明过拟合到需要干预的程度了。4.2 混淆矩阵找出哪些水果蔬菜类别在互相打架准确率只能回答整体表现论文评阅人更关心的是哪些类别容易混淆。水果蔬菜识别里典型的混淆模式有两类一是颜色相近的跨物种类别比如青苹果和青梨二是同一种水果的不同品种比如不同颜色的苹果。混淆矩阵能把这些问题一次性可视化出来。import numpy as np import seaborn as sns from sklearn.metrics import confusion_matrix, classification_report # all_preds 和 all_labels 是验证集所有预测结果和真实标签的列表 cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(14, 12)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsfull_dataset.classes, yticklabelsfull_dataset.classes) plt.xlabel(Predicted) plt.ylabel(True) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi300)fmtd表示矩阵里显示整数而不是科学计数法类别数超过 30 的时候把annot关掉只保留色块否则数字会挤在一起。除了矩阵classification_report会输出每个类别的 precision、recall、f1-score这三列数据可以直接复制到论文的表格里。F1 分数偏低的类别就是后续数据采集的重点对象。4.3 论文报告里的实验对比表格怎么组织论文报告不需要花哨三组对比就够撑起实验章节第一组是从零训练的浅层 CNN 作为 baseline第二组是 baseline 加上前面说的数据增强第三组是迁移学习 ResNet50。这个设计能同时说明数据增强的有效性和预训练权重的价值。表格格式可以参照下面的结构模型数据增强预训练参数量验证准确率训练时间浅层 CNN无无约 8M78.2%18 min浅层 CNN有无约 8M84.5%25 minResNet50有ImageNet约 25M97.3%45 min需要注意训练时间要写实际环境比如 GPU 型号没有 GPU 就如实写 CPU 及型号不要编造。论文报告里的评估曲线直接引用上面生成的图片文件每张图下面加两到三句分析说明曲线走势和混淆矩阵暴露的问题。这样整个实验章节既有量化指标又有可视化证据不需要额外堆砌文字。5. 用 Streamlit 把模型封装成可给老师演示的识别界面5.1 界面选型为什么不用 Tkinter 而用 Streamlit标题里明确提到了界面常见做法有二Tkinter 是 python 自带的标准库优点是无需额外安装缺点是布局代码繁琐且视觉效果老旧Streamlit 是独立 Web 界面框架优点是布局用纯 python 代码完成自动适配浏览器显示还能把评估曲线图片直接嵌入页面。我做这个系统会毫不犹豫选 Streamlit因为它天然支持「模型 图表 交互控件」的组合而 Tkinter 里显示 matplotlib 图片需要额外的FigureCanvasTkAgg适配平白多出不少工作量。import streamlit as st import torch from PIL import Image from torchvision import transforms # 加载训练阶段保存的模型权重务必保持网络结构和训练时一致 model models.resnet50(weightsNone) num_classes len(full_dataset.classes) model.fc torch.nn.Linear(2048, num_classes) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() # 和训练阶段保持完全一致的预处理流程 val_tf 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]) ]) st.title(水果蔬菜识别系统) uploaded_file st.file_uploader(上传一张图片, type[jpg, jpeg, png]) if uploaded_file is not None: image Image.open(uploaded_file).convert(RGB) st.image(image, caption原始图片, use_column_widthTrue) input_tensor val_tf(image).unsqueeze(0) with torch.no_grad(): outputs model(input_tensor) probs torch.softmax(outputs, dim1) top3 torch.topk(probs, 3) for i in range(3): idx top3.indices[0][i].item() conf top3.values[0][i].item() st.write(f{full_dataset.classes[idx]}{conf:.2%})map_locationcpu这条参数在无 GPU 环境加载模型权重时必须写否则 PyTorch 会报 CUDA 不可用的错误。use_column_widthTrue让上传的图片拉伸到页面宽度不会因为手机照片尺寸过大而溢出界面。置信度展示用torch.topk取前三名而不是只看最高概率这个设计在实际使用中很有价值——当系统把青柠误判为柠檬时第二名通常就是正确的柠檬说明模型学到了正确的颜色特征只是置信度排序颠倒这种信息对调试模型和论文分析都有用。5.2 评估曲线与模型的联合交付界面里除了识别功能还要有一块区域展示训练日志和评估曲线。我的做法是在 Streamlit 侧边栏放一个st.selectbox选项是「模型识别」「评估曲线」「混淆矩阵」。切换选项后分别加载对应的图片文件这样老师用浏览器打开页面时既能上传图片测试效果又能直接查看训练过程中的评估指标不需要再单独打开文件夹找图片。这个组合直接对应标题里的「界面和评估曲线」两个交付物。如果想让识别结果更稳定可以把置信度阈值调整从代码常量改成st.slider(置信度阈值, 0.5, 0.99, 0.7)低于阈值的预测结果统一显示为「不确定请重新拍摄」这一步能明显减少低质量图片造成的误判。本文还有配套的精品资源点击获取