拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于深度学习的水果识别系统:从数据清洗到模型部署的完整实战

基于深度学习的水果识别系统:从数据清洗到模型部署的完整实战

简介:这是一套面向计算机相关专业学生与项目实战学习者的深度学习水果识别完整项目,可直接用于课程大作业、毕业设计或算法练手。项目以Python为核心实现,配套数据集与训练模型,源码均经本地编译调试,可正常运行,难度适中,评审得分98分,适合需要快速搭建可演示识别系统、又缺乏完整工程参考的读者。压缩包共277个文件,约17.53MB,其中8个py文件承载模型训练与识别主逻辑,114个js、26个css与7个html构成前端交互与展示页面,另有gif、jpg、png等图像素材及字体、图标资源,整体结构清晰,便于按模块阅读与二次修改。目前已有141人学习下载。通过该资源,读者可掌握从数据准备、模型训练到界面展示的完整流程,理解水果分类项目的目录组织与关键代码,并在此基础上替换数据集或调整网络结构,完成自己的课题与答辩演示。

1. 水果识别系统到底难在哪:从一张误判的苹果照片说起

把一张红富士的照片丢给刚训完的模型,它信心十足地输出「番茄」,置信度 0.91。这不是段子,是我帮学弟调毕业设计时真实翻车的现场。基于深度学习的水果识别系统,表面看是个烂大街的入门题目,实际上它同时踩中了细粒度分类、类间相似、数据长尾三个坑。苹果和番茄、柠檬和青梨、不同成熟度的香蕉,这些类别在像素层面高度重叠,模型很容易学到「红色=番茄」这种捷径特征。这套 Python 源代码加数据集的完整项目,要解决的就是让模型真正区分这些长得像但语义不同的水果,而不是靠背景颜色蒙答案。适合正在做毕业设计、想找一个能跑通又有话可说的题目的同学,也适合想练手图像分类全流程的 Python 开发者。下面我按自己实际搭过一遍的顺序,把选型、数据、训练、部署和踩坑讲清楚。

2. 水果识别系统的技术选型:为什么是 CNN 而不是上 Transformer

2.1 从数据集规模反推模型复杂度

毕业设计的数据集通常不会太大。常见做法是自己爬或者用公开水果数据集,规模大多在几千到两三万张之间,类别数在 10 到 30 类。这个量级下,直接上 ViT 这类 Transformer 架构基本是自找麻烦:注意力机制缺少归纳偏置,小数据上极容易过拟合,训练轮次拉长后验证集准确率反而往下掉。我一般会推荐 ResNet18 或 MobileNetV3 作为 backbone,前者结构规整、残差连接成熟,后者参数量小、推理快,答辩演示时用普通笔记本 CPU 也能跑出实时效果。

选 ResNet18 的另一个理由是迁移学习友好。ImageNet 预训练权重里已经包含大量纹理和边缘特征,水果识别需要的颜色、形状、表面斑点这些低级特征可以直接复用,只需要微调后面的层。相比之下,从头训练一个 CNN 在几千张图上很难收敛到理想精度。这里有个参数要特别注意:如果冻结全部卷积层只训练全连接,精度往往卡在 70% 上下;解冻最后两个 stage 一起微调,通常能到 90% 以上。

2.2 数据增强策略与水果类别的特殊性

水果图像的增强不能照搬通用配方。水平翻转、随机裁剪、颜色抖动是标配,但颜色抖动幅度要控制。水果分类高度依赖颜色,把饱和度调过头会让青苹果变成红苹果的分布,模型学到的特征就乱了。我一般把 brightness、contrast、saturation 的抖动系数设在 0.2 以内,hue 干脆不动。

旋转增强也要谨慎。香蕉、黄瓜这类长条水果旋转 90 度后语义虽然没变,但和自然拍摄角度差异大,适度旋转(±15 度)比任意角度旋转更稳。另外建议加 RandomResizedCrop,让模型适应不同拍摄距离,这对手机拍照场景的泛化很有帮助。MixUp 和 CutMix 在小数据集上能提点,但会让训练曲线变得难读,毕业设计答辩时不好解释,看情况用。

2.3 用 Python 搭出可复现的训练骨架

下面这段是数据加载和增强的核心代码,基于 torchvision 实现,直接可跑。注意 normalize 的均值和方差用的是 ImageNet 统计值,因为我们要加载预训练权重。

import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms # 训练集增强:翻转、裁剪、轻度颜色抖动 train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), 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_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]), ]) train_ds = datasets.ImageFolder('data/train', transform=train_tf) val_ds = datasets.ImageFolder('data/val', transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) print('类别:', train_ds.classes) print('训练样本数:', len(train_ds))

逻辑说明:ImageFolder 要求数据按类别分文件夹存放,每个子文件夹名就是类别标签,这是最省事的组织方式。训练集和验证集用不同的 transform,训练集做增强,验证集保持确定性,否则评估指标会抖动。batch_size 设 32 是显存和梯度稳定性的折中,显存不够就降到 16,同时把学习率按比例调小。num_workers 在 Windows 上如果报错就改成 0,这是 DataLoader 多进程在 Windows 下的老问题。

参数说明:RandomResizedCrop 的 scale 下限设 0.7,意味着最多裁掉 30% 的画面,再小可能把水果主体裁没。RandomRotation 限 15 度,避免长条水果出现不自然姿态。Normalize 的三个通道值必须和预训练模型一致,写错会导致精度莫名其妙掉十几个点,这个坑我踩过。

3. 数据集准备与划分:别让数据泄漏毁掉你的评估

3.1 数据采集与清洗的实操要点

数据集来源无非三种:公开数据集、网络爬取、自己拍摄。公开数据集省事但类别可能和你的选题对不上,爬取要注意图片质量和版权,自己拍最可控但费时间。不管哪种来源,清洗这一步不能省。常见脏数据包括:同一张图重复出现、标签错标、非水果图片混入、严重模糊或遮挡的废图。

我一般写个脚本先做去重,用感知哈希(pHash)计算图片指纹,汉明距离小于 5 的判为重复。然后人工过一遍标签,重点看那些容易混的类别。清洗完的数据量如果每类少于 200 张,建议要么补数据,要么用更强的增强,否则模型对这类水果的召回率会很难看。

3.2 训练验证测试三分与类别平衡

划分比例常见 7:2:1 或 8:1:1。关键是划分要在类别层面分层抽样,保证每个集合里各类别比例接近。如果随机划分导致某个类别在验证集里只有两三张,评估结果就没有统计意义。用 sklearn 的 train_test_split 加 stratify 参数可以一步到位。

类别不平衡是水果数据集的常态,苹果可能上千张,榴莲只有几十张。处理方式有两种:一是对少数类做过采样加增强,二是在损失函数里加类别权重。我倾向后者,改动小且不引入重复样本。CrossEntropyLoss 的 weight 参数传入各类别样本数的倒数归一化即可。

import os import shutil from sklearn.model_selection import train_test_split root = 'data/raw' classes = os.listdir(root) train_files, val_files, test_files = [], [], [] for c in classes: files = [os.path.join(c, f) for f in os.listdir(os.path.join(root, c))] # 先分训练+验证 和 测试 tv, te = train_test_split(files, test_size=0.1, random_state=42) # 再从训练+验证里分验证 tr, va = train_test_split(tv, test_size=0.22, random_state=42) train_files += [(f, c) for f in tr] val_files += [(f, c) for f in va] test_files += [(f, c) for f in te] def copy_to(split_files, split_name): for rel, c in split_files: dst_dir = os.path.join('data', split_name, c) os.makedirs(dst_dir, exist_ok=True) shutil.copy(os.path.join(root, rel), dst_dir) copy_to(train_files, 'train') copy_to(val_files, 'val') copy_to(test_files, 'test') print('划分完成')

逻辑说明:对每个类别单独做划分,保证分层。先切出 10% 做测试集,剩下的再按 22% 切验证集,最终比例约为 7:2:1。random_state 固定住,保证每次运行划分一致,这对复现实验很关键。复制而不是移动,保留原始数据,改错了还能重来。

参数说明:test_size 和验证集比例可以根据数据量调整,数据少的时候测试集别低于 10%,否则评估方差太大。如果某个类别样本极少,train_test_split 可能报错,需要先检查每类数量。

3.3 数据集目录结构规范

训练代码依赖固定的目录结构,整理清楚能省很多调试时间。标准结构如下:

data/ ├── train/ │ ├── apple/ │ ├── banana/ │ └── orange/ ├── val/ │ ├── apple/ │ └── ... └── test/ └── ...

每个类别一个文件夹,文件夹名用英文小写,避免中文路径在部分环境下读取报错。图片格式统一成 jpg 或 png,混用虽然 ImageFolder 也能读,但统一格式能减少解码异常。

4. 模型训练与调参:让验证集准确率真正涨上去

4.1 迁移学习的两阶段训练法

前面提过,冻结全部卷积层精度上不去,全解冻又容易过拟合。我一般用两阶段:第一阶段冻结 backbone,只训练最后的全连接层,学习率设 1e-3,跑 5 到 10 个 epoch,让分类头先适应水果类别。第二阶段解冻最后两个 stage,学习率降到 1e-4,继续训练 20 到 30 个 epoch。这样既利用了预训练特征,又让高层特征适配了水果数据。

优化器用 AdamW 比 SGD 省心,weight_decay 设 1e-4 抑制过拟合。学习率调度用 CosineAnnealingLR,比 StepLR 更平滑,末期学习率趋近于零,收敛更稳。

import torch.nn as nn import torch.optim as optim from torchvision import models from torch.optim.lr_scheduler import CosineAnnealingLR device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') num_classes = len(train_ds.classes) # 加载预训练 ResNet18 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) model = model.to(device) # 第一阶段:冻结 backbone for name, param in model.named_parameters(): if 'fc' not in name: param.requires_grad = False criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=10) def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss, correct, total = 0, 0, 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() total_loss += loss.item() * imgs.size(0) correct += (out.argmax(1) == labels).sum().item() total += imgs.size(0) return total_loss / total, correct / total for epoch in range(10): loss, acc = train_one_epoch(model, train_loader, optimizer, criterion) scheduler.step() print(f'Epoch {epoch+1} loss={loss:.4f} acc={acc:.4f}')

逻辑说明:第一阶段只让 fc 层参与梯度更新,filter 过滤出 requires_grad 为 True 的参数传给优化器。train_one_epoch 里累加 loss 时乘了 batch 大小,最后除以总样本数,得到的是样本级平均损失,比 batch 级平均更准确。准确率同理按样本统计。

参数说明:lr=1e-3 是冻结阶段的常用值,因为只训练分类头,可以大一点。weight_decay 抑制权重过大。T_max 对应总 epoch 数,CosineAnnealingLR 会在 T_max 轮内把学习率从初始值降到接近零。第二阶段把解冻层的 lr 改成 1e-4,重新建优化器,因为不同参数组需要不同学习率。

4.2 第二阶段微调与早停

第二阶段解冻 layer3 和 layer4,学习率降到 1e-4。这时候要盯着验证集损失,如果连续 5 个 epoch 不下降就早停,保存验证集准确率最高的权重。早停能有效防止过拟合,也省训练时间。

# 解冻 layer3 和 layer4 for name, param in model.named_parameters(): if 'layer3' in name or 'layer4' in name or 'fc' in name: param.requires_grad = True else: param.requires_grad = False optimizer = optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30) best_acc, patience, wait = 0, 5, 0 for epoch in range(30): tr_loss, tr_acc = train_one_epoch(model, train_loader, optimizer, criterion) # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) out = model(imgs) correct += (out.argmax(1) == labels).sum().item() total += imgs.size(0) val_acc = correct / total scheduler.step() print(f'Epoch {epoch+1} val_acc={val_acc:.4f}') if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best.pth') wait = 0 else: wait += 1 if wait >= patience: print('早停触发') break

逻辑说明:解冻策略只放开高层特征,低层的边缘纹理特征保持冻结,避免小数据上把通用特征带偏。验证阶段用 model.eval() 关闭 dropout 和 batchnorm 的训练行为,torch.no_grad() 省显存。保存 best.pth 而不是最后一个 epoch 的权重,因为末期可能已经过拟合。

参数说明:patience=5 是经验值,数据噪声大可以设 8,数据干净设 3 也行。T_max 要和最大 epoch 数一致,否则学习率调度和训练轮次对不上。

4.3 评估指标不只看准确率

准确率在类别不平衡时会骗人。如果苹果占 80%,模型全预测苹果也有 80% 准确率。所以要同时看每类的 precision、recall、F1,以及混淆矩阵。sklearn 的 classification_report 一行就能输出。重点看哪些类别被混淆,比如柠檬和青梨如果互相误判多,说明特征区分度不够,可以考虑加数据或换更强的 backbone。

5. 避坑与排查:水果识别项目里最容易翻车的五件事

5.1 验证集准确率远高于测试集

现象:验证集 95%,测试集只有 70%。原因通常是数据泄漏,同一张图或高度相似的图同时出现在训练和验证里。解决:用感知哈希去重后再划分,划分时按类别分层,确保三个集合互不重叠。

5.2 训练损失不下降或震荡

现象:loss 在 2.3 附近不动,或者上下大幅震荡。原因可能是学习率太大、数据标签错乱、normalize 参数写错。解决:先把学习率降一个数量级试,再检查标签文件夹名和图片是否对应,最后核对 normalize 的均值和方差是否和预训练模型一致。

5.3 模型对某几类水果几乎全错

现象:混淆矩阵里某两类互相误判严重。原因:这两类视觉特征太接近,或者其中一类样本太少。解决:针对性补充这两类的数据,尤其是容易混的样本;或者在损失函数里给这两类更高权重。

5.4 推理时预测结果和训练时不一致

现象:训练时验证准确率很高,部署后单张图片预测乱跳。原因:推理时的预处理和验证集不一致,比如忘了 normalize,或者 resize 方式不同。解决:把验证集的 transform 封装成函数,训练和推理共用同一套。

5.5 显存溢出或训练速度异常慢

现象:CUDA out of memory,或者每个 epoch 要跑很久。原因:batch_size 太大、num_workers 设置不当、图片分辨率过高。解决:降 batch_size,Windows 下 num_workers 设 0,图片统一缩到 256 以内再送入网络。

6. 从能跑到好用:把水果识别系统做成可演示的完整项目

训练出权重只是半成品,毕业设计答辩要的是能演示、能交互的完整系统。我一般会加一个推理脚本和一个简单的界面。推理脚本负责加载 best.pth,对单张图片输出类别和置信度;界面用 Gradio 或 Streamlit 搭,几十行代码就能做出上传图片即时识别的效果,答辩时比命令行有说服力。

import torch from PIL import Image from torchvision import transforms, models import torch.nn as nn def load_model(ckpt_path, num_classes): model = models.resnet18(weights=None) model.fc = nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load(ckpt_path, map_location='cpu')) model.eval() return model infer_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]), ]) def predict(model, img_path, classes): img = Image.open(img_path).convert('RGB') x = infer_tf(img).unsqueeze(0) with torch.no_grad(): out = model(x) prob = torch.softmax(out, dim=1) conf, idx = prob.max(1) return classes[idx.item()], conf.item() model = load_model('best.pth', num_classes=len(train_ds.classes)) label, conf = predict(model, 'test.jpg', train_ds.classes) print(f'预测: {label}, 置信度: {conf:.4f}')

逻辑说明:推理时的 transform 必须和验证集完全一致,这是保证线上线下一一致的关键。load_state_dict 用 map_location='cpu' 是为了在没有 GPU 的机器上也能加载。softmax 把 logits 转成概率,取最大值对应的类别。

参数说明:num_classes 必须和训练时一致,否则 fc 层维度对不上会报错。classes 列表的顺序要和训练时 ImageFolder 的 classes 顺序一致,这个顺序是按文件夹名排序的,改文件夹名会导致标签错位。

进阶一点的做法是加一个置信度阈值,低于阈值就输出「不确定」,避免模型在没见过的水果上硬答。还可以用 Grad-CAM 可视化模型关注的区域,答辩时展示热力图,能直观说明模型确实在看水果本身而不是背景。这个技巧在解释模型行为时特别有用,也是区分「跑通」和「做好」的分水岭。

我自己做这类项目最大的教训是:别一上来就调模型结构,先把数据清洗和划分做扎实,八成的精度问题都出在数据上。模型换来换去不如把标签看一遍。希望帮到你。

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

返回列表