资讯动态

基于CNN的水果蔬菜识别系统:从模型搭建到部署的完整实战

发布时间:2026/10/2 14:26:02 来源:尧图企业网站定制
简介这份资源是面向计算机相关专业学生的深度学习实战项目包以卷积神经网络实现水果蔬菜图像识别适合用作毕业设计、课程设计或期末大作业。项目经过导师指导与评审源码在本地编译可运行并配有完整文档说明难度适中能帮助读者把CNN理论落到具体代码与实验流程中。压缩包共38个文件约2.54MB包含8个Python脚本、20张png与3张jpeg图像、1份pdf说明文档及README等覆盖数据划分、数据增强、模型训练、测试评估与界面交互等模块便于按目录结构逐层阅读。已有61人学习下载。读者可据此掌握图像预处理、CNN模型搭建、训练调参与性能评估的完整链路并参考文档中的设计思路与关键代码解读快速完成自己的识别项目或在此基础上做二次开发。1. 从一堆水果照片到能跑的模型这套 CNN 识别系统到底解决什么问题果园分拣线上摄像头每秒拍下几十张水果照片靠人眼盯着屏幕分类半小时就眼花。更现实的是很多做深度学习入门或毕设的开发者手里有一批水果蔬菜图像却卡在「怎么把 CNN 真正跑起来」这一步——环境配不通、数据格式对不上、训练完不知道模型到底行不行。这套「利用深度学习 CNN 技术的水果蔬菜识别系统」核心就是用卷积神经网络做多类别图像分类把苹果、香蕉、胡萝卜、西红柿这些常见果蔬的图片映射到对应的类别标签上。它适合三类人刚接触深度学习想找一个完整项目练手的入门者、需要交毕设或课程设计的学生、以及想把图像分类能力接进自己业务的小团队。整条链路不复杂但每一步都有具体的参数和坑下面按「先立住原理、再动手复现、最后排错调优」的顺序讲清楚。2. CNN 为什么适合做果蔬识别从卷积核到分类头的选型逻辑2.1 果蔬图像的三个特点决定了不能用全连接硬怼水果蔬菜图像有几个很明显的特征。第一类内差异大同一个苹果红富士和青苹果颜色差很多切开和整个又不一样。第二类间差异小青椒和青苹果在低分辨率下颜色形状都接近。第三背景干扰强很多数据集里的果蔬是放在桌面上拍的桌布纹理、光照阴影都会混进来。如果用全连接网络直接处理原始像素参数量会爆炸。一张 224×224 的 RGB 图展平后是 150528 维第一层接 512 个神经元就是七千多万个参数训练慢且极易过拟合。CNN 的核心优势在于局部感受野和权值共享一个 3×3 的卷积核在整张图上滑动用同一组权重提取边缘、纹理、颜色斑块参数量只有 9 个乘上通道数。这正好匹配果蔬图像「局部特征决定类别」的规律——判断是不是香蕉看的是黄色、弯曲的长条形状不需要看整张图的每个像素。常见做法是堆叠「卷积 → 激活 → 池化」这样的块。卷积层负责提特征ReLU 负责引入非线性池化层负责降维和提供一定的平移不变性。果蔬识别里浅层卷积学到的是颜色块和边缘深层卷积学到的是「圆形带柄」「长条形带弧度」这种组合特征最后接全连接层或全局平均池化做分类。2.2 选 ResNet 还是自己搭一个小 CNN参数量和数据的权衡这是实操里第一个要做的决策。自己搭一个 4 层卷积的小网络参数量大概几十万到一百万训练快在几千张图的数据集上就能跑出不错的效果。用 ResNet18 或 ResNet50 这种预训练模型做迁移学习参数量一千多万到两千多万但因为有 ImageNet 预训练权重在小数据集上收敛更快、精度更高。判断标准很简单你的果蔬数据集如果每个类别少于 500 张优先用迁移学习冻结前面的卷积层只训练分类头如果每个类别有 2000 张以上可以自己搭网络从头训或者用预训练模型做微调。我一般会先跑一个自己搭的小 CNN 作为 baseline确认数据管道没问题再换 ResNet 看精度能提升多少。这样出问题的时候容易定位是数据的问题还是模型的问题。下面是一个自己搭的 CNN 结构适合果蔬分类入门import torch import torch.nn as nn class FruitCNN(nn.Module): def __init__(self, num_classes10): super(FruitCNN, self).__init__() # 输入 3x224x224 self.features nn.Sequential( # 第一层提取颜色和边缘 nn.Conv2d(3, 32, kernel_size3, padding1), # 输出 32x224x224 nn.BatchNorm2d(32), # 加速收敛稳定训练 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 输出 32x112x112 # 第二层提取纹理和简单形状 nn.Conv2d(32, 64, kernel_size3, padding1), # 输出 64x112x112 nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 输出 64x56x56 # 第三层提取组合特征 nn.Conv2d(64, 128, kernel_size3, padding1),# 输出 128x56x56 nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 输出 128x28x28 # 第四层高层语义特征 nn.Conv2d(128, 256, kernel_size3, padding1),# 输出 256x28x28 nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d(1), # 全局平均池化输出 256x1x1 ) # 分类头 self.classifier nn.Sequential( nn.Dropout(0.5), # 防止过拟合 nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) # 展平 x self.classifier(x) return x # 实例化假设识别 10 种果蔬 model FruitCNN(num_classes10) total_params sum(p.numel() for p in model.parameters()) print(f总参数量: {total_params}) # 大约 40 万左右这段代码里几个关键点。BatchNorm2d放在卷积和 ReLU 之间作用是让每层输入分布稳定学习率可以设大一点收敛更快。AdaptiveAvgPool2d(1)把任意尺寸的特征图压成 1×1这样输入图片尺寸变化时不用改网络结构也大幅减少了全连接层的参数量。Dropout(0.5)只在训练时生效随机丢弃一半神经元是果蔬小数据集上防过拟合的常用手段。参数怎么改num_classes必须等于你数据集的类别数这个错了训练时 loss 会异常。卷积核数量 32/64/128/256 是逐层翻倍的常见模式如果显存不够可以整体减半。kernel_size3, padding1保证卷积后特征图尺寸不变这是最通用的配置。3. 从零把训练跑起来数据准备、训练循环与验证3.1 数据集目录结构和 DataLoader 配置果蔬数据集最常见的组织方式是每个类别一个文件夹文件夹名就是类别名。这是 PyTorchImageFolder能直接读的格式fruits_dataset/ ├── apple/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── banana/ │ ├── 001.jpg │ └── ... ├── carrot/ └── tomato/用ImageFolder加DataLoader的代码如下from torchvision import datasets, transforms from torch.utils.data import DataLoader, random_split # 训练时的数据增强模拟真实场景的多样性 train_transform transforms.Compose([ transforms.Resize((224, 224)), # 统一尺寸 transforms.RandomHorizontalFlip(), # 随机水平翻转 transforms.RandomRotation(15), # 随机旋转 ±15 度 transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), # 颜色扰动 transforms.ToTensor(), # 转成 tensor像素值归一化到 [0,1] transforms.Normalize(mean[0.485, 0.456, 0.406], # ImageNet 统计均值 std[0.229, 0.224, 0.225]) # ImageNet 统计标准差 ]) # 验证集不做增强只做尺寸统一和归一化 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]) ]) # 加载数据集 full_dataset datasets.ImageFolder(fruits_dataset, transformtrain_transform) class_names full_dataset.classes print(f类别: {class_names}) print(f总样本数: {len(full_dataset)}) # 按 8:2 划分训练集和验证集 train_size int(0.8 * len(full_dataset)) val_size len(full_dataset) - train_size train_dataset, val_dataset random_split(full_dataset, [train_size, val_size]) # 验证集要覆盖 transform这里用子集的方式需要额外处理简单做法是分别建 dataset这里有个容易翻车的点random_split之后两个子集用的是同一个transform验证集也会被增强导致验证指标不稳定。正确做法是分别创建两个ImageFolder或者用Subset配合自定义的 transform 切换。我一般直接建两个 dataset 对象训练集用增强验证集用干净版本虽然多读一次文件但逻辑清晰。Normalize的均值和标准差用的是 ImageNet 的统计值这是迁移学习场景的标准做法。如果你完全从头训练且数据集很大可以自己算数据集的均值和标准差替换进去但对果蔬识别这种任务用 ImageNet 的值影响很小。3.2 训练循环里必须盯住的四个量训练循环本身不复杂但有几个量必须每轮打印出来看否则模型训崩了都不知道为什么import torch.optim as optim from torch.utils.data import DataLoader # 分别创建训练和验证 dataset train_dataset datasets.ImageFolder(fruits_dataset, transformtrain_transform) val_dataset datasets.ImageFolder(fruits_dataset, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) device torch.device(cuda if torch.cuda.is_available() else cpu) model FruitCNN(num_classeslen(train_dataset.classes)).to(device) criterion nn.CrossEntropyLoss() # 多分类标准损失 optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) # Adam 权重衰减 scheduler optim.lr_scheduler.StepLR(optimizer, step_size10, gamma0.5) # 每10轮学习率减半 num_epochs 30 best_val_acc 0.0 for epoch in range(num_epochs): # 训练阶段 model.train() train_loss 0.0 train_correct 0 train_total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() train_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) train_total labels.size(0) train_correct (predicted labels).sum().item() # 验证阶段 model.eval() val_loss 0.0 val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) val_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) val_total labels.size(0) val_correct (predicted labels).sum().item() train_acc train_correct / train_total val_acc val_correct / val_total avg_train_loss train_loss / train_total avg_val_loss val_loss / val_total print(fEpoch {epoch1}/{num_epochs} | fTrain Loss: {avg_train_loss:.4f} Acc: {train_acc:.4f} | fVal Loss: {avg_val_loss:.4f} Acc: {val_acc:.4f} | fLR: {optimizer.param_groups[0][lr]:.6f}) # 保存验证集上最好的模型 if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_fruit_model.pth) print(f - 保存最优模型验证准确率: {best_val_acc:.4f}) scheduler.step()四个必须盯的量训练 loss、训练准确率、验证 loss、验证准确率。正常情况是训练 loss 持续下降验证 loss 先降后升升的时候就是过拟合了。如果训练 loss 不降检查学习率是不是太大或太小、数据标签有没有对错。如果训练准确率很高但验证准确率很低说明过拟合加数据增强、加 Dropout、加权重衰减。如果两个都低说明欠拟合换更大的模型或训更久。batch_size32是果蔬识别里比较通用的值显存不够就降到 16 或 8但太小会让 BatchNorm 统计不准。lr1e-3配合 Adam 是安全起点如果 loss 震荡就降到 1e-4。weight_decay1e-4是 L2 正则防止权重过大。StepLR每 10 轮把学习率乘 0.5让后期微调更精细。3.3 用混淆矩阵看模型到底错在哪准确率只告诉你「对了多少」不告诉你「错在哪」。果蔬识别里模型可能把青苹果认成青椒把柠檬认成橙子这些错误在准确率上看不出来但实际部署时很致命。用混淆矩阵能直接看到类别之间的混淆情况from sklearn.metrics import confusion_matrix, classification_report import numpy as np model.eval() 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().numpy()) all_labels.extend(labels.numpy()) # 打印每个类别的精确率、召回率、F1 print(classification_report(all_labels, all_preds, target_namesclass_names)) # 混淆矩阵 cm confusion_matrix(all_labels, all_preds) print(混淆矩阵:) print(cm)classification_report会输出每个类别的 precision、recall、f1-score。如果某个类别的 recall 特别低说明这个类别的样本被大量错分到其他类需要检查是不是这个类别的图片太少或者和其他类别太像。混淆矩阵的对角线是正确分类数非对角线是错分哪两个类别之间的数字大就说明模型分不清它们可以考虑增加这两个类别的区分性特征或补充更多样本。4. 果蔬识别项目里最容易翻车的五个地方4.1 现象训练准确率 99%验证准确率 60%曲线分叉原因典型过拟合。果蔬数据集如果每个类别只有几百张模型很容易记住训练集的噪声。另外数据增强太弱、Dropout 没加、权重衰减没设都会加剧这个问题。解决先加数据增强RandomHorizontalFlip、RandomRotation、ColorJitter都加上。然后确认Dropout在分类头里生效。再把weight_decay从 0 调到 1e-4 或 1e-3。如果还不行用预训练模型冻结卷积层只训分类头。最后手段是减少模型参数量把卷积核数量减半。4.2 现象loss 变成 NaN训练直接崩掉原因学习率太大导致梯度爆炸或者数据里有损坏的图片全黑、全白、尺寸异常或者标签越界比如 num_classes 设成 10 但数据里有第 11 类。解决先把学习率降到 1e-4 甚至 1e-5 试一轮。然后在 dataset 里加一个检查把读不出来的图片跳过。标签越界的问题用ImageFolder时类别数是自动从文件夹数量来的一般不会错但如果你手动映射标签就要仔细核对。另外加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)也能防梯度爆炸。4.3 现象验证集准确率波动很大这轮 85% 下轮 70%原因验证集太小或者验证集用了数据增强或者 BatchNorm 在验证时没有切换到 eval 模式。解决验证集至少每个类别 50 张以上。确认验证集的 transform 里没有 Random 开头的操作。确认model.eval()在验证前调用了model.train()在训练前调用了。如果验证集确实小可以用 K 折交叉验证把数据分成 K 份轮流做验证取平均指标。4.4 现象GPU 显存够但利用率很低训练速度慢原因num_workers设成 0 了数据加载成了瓶颈。或者图片尺寸太大224 以上没必要的分辨率白白消耗算力。或者 batch_size 太小GPU 一直在等数据。解决num_workers设成 CPU 核心数的一半左右比如 8 核设 4。图片统一 resize 到 224×224这是大多数 CNN 的标准输入。batch_size在显存允许范围内尽量大32 起步能到 64 或 128 更好。另外用pin_memoryTrue可以加速 CPU 到 GPU 的数据传输。4.5 现象换一台机器跑同样的代码报错或结果不一样原因环境版本不一致。PyTorch 版本、CUDA 版本、torchvision 版本之间有严格的对应关系。另外随机种子没固定导致每次运行的数据划分和权重初始化都不同。解决固定随机种子在代码开头加import torch import numpy as np import random def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False set_seed(42)环境方面用 conda 或 venv 把依赖锁死导出requirements.txt。PyTorch 安装时去官网查 CUDA 版本对应的命令不要凭记忆写。5. 把模型推到能用的程度迁移学习微调与推理部署的一个关键技巧自己搭的 CNN 在果蔬数据集上跑到 85% 左右通常就到头了想再往上走迁移学习是最省力的路径。用 torchvision 里预训练的 ResNet18把最后的全连接层换成你的类别数然后分阶段微调import torchvision.models as models import torch.nn as nn import torch.optim as optim # 加载预训练 ResNet18 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) # 替换分类头 num_features model.fc.in_features model.fc nn.Linear(num_features, len(class_names)) # 阶段一冻结卷积层只训分类头 for param in model.parameters(): param.requires_grad False for param in model.fc.parameters(): param.requires_grad True model model.to(device) optimizer optim.Adam(model.fc.parameters(), lr1e-3) # 训 5 轮让分类头先适应 # ... 训练循环同上 ... # 阶段二解冻最后两个卷积块用小学习率微调 for name, param in model.named_parameters(): if layer3 in name or layer4 in name or fc in name: param.requires_grad True optimizer optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr1e-4) # 再训 10-15 轮这个两阶段策略的关键在于先让随机初始化的分类头学会「怎么用预训练特征」再让卷积层做小幅调整。如果一上来就全网络微调随机分类头产生的大梯度会破坏预训练权重反而比从头训还差。阶段二的学习率必须比阶段一小一个数量级1e-4 或 5e-5 比较合适。推理部署时有一个容易忽略的点预处理必须和训练时完全一致。训练用了Resize((224,224))、ToTensor、Normalize推理时也要一模一样少一步或者顺序错了精度直接掉十几个点。我一般把 transform 定义成函数训练和推理共用def get_inference_transform(): return transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) # 推理 model.eval() img Image.open(test_apple.jpg).convert(RGB) input_tensor get_inference_transform()(img).unsqueeze(0).to(device) with torch.no_grad(): output model(input_tensor) _, predicted torch.max(output, 1) print(f预测类别: {class_names[predicted.item()]})unsqueeze(0)是给图片加一个 batch 维度因为模型期望输入是[batch, channel, height, width]。convert(RGB)是防止有些图片是 RGBA 或灰度图通道数不对会直接报错。最后说一个我踩过的坑有次模型在验证集上 92%部署到实际场景里连 60% 都不到。排查后发现训练集的图片都是白底商品图实际场景是果园自然光下的照片背景、光照、角度全变了。后来在训练集里混入了实际场景的图片做微调才把效果拉回来。所以如果你的模型要落地训练数据的分布一定要尽量贴近真实使用场景否则验证集上的数字只是自嗨。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑