又是一年毕业设计季,每年这时候都会看到不少同学选视觉识别方向的题目。"基于AI深度学习训练识别草莓是否腐烂"这个题目,名字起得挺规整,看起来就是个标准的图像二分类任务——输入一张图片,判断草莓是好的还是烂的。但真正动手做起来你会发现,它横跨了数据集构建、模型选型、训练调参、评估分析和部署演示整个流程,每一步都有不少值得细说的细节。
这篇文章就把我做这个项目的完整过程拆开讲一遍,包括数据和标注怎么做、模型为什么这么选、训练参数怎么定、准确率之外还要看什么指标、以及怎么把它包装成一个能演示的网页应用。无论是你要做相似的水果识别题目,还是想搞清楚图像分类项目的完整工作流,这篇都能给你一个可以直接照搬的参考路径。
1. 任务定义:先搞清楚"腐烂"在模型眼里是什么
1.1 腐烂草莓的视觉特征拆解
很多同学拿到题目就开始找数据集、跑代码,但我建议先把问题定义想明白。模型不是学"草莓"这个概念,而是学"腐烂"这个视觉模式。
我观察下来,草莓腐烂主要有这么几类视觉信号:
- 霉菌斑:表面出现白色、灰色或黑色的绒毛状菌丝,这是最典型的腐烂特征
- 褐色软烂区:局部变褐、变软、凹陷,表皮失去光泽
- 汁液渗出:腐烂区域湿润反光,甚至流出汁水
- 整体萎缩:失水后表面发皱、体积缩小、颜色暗沉
这几种情况在建模时最好都覆盖到。如果只拍了一两种腐烂形态,模型很容易把"深红色草莓"或"有轻微磕碰的草莓"误判成腐烂,或者反过来漏判。
1.2 二分类还是多分类,直接决定项目复杂度
题目本身是"是否腐烂",天然是个二分类。但我做的时候想了想,额外加了一类"边界不清"的样本——比如有点软但还没长霉的、刚被撞了个坑的。这类样本在二分类里特别容易造成困惑,强行归到"健康"会让模型学歪,归到"腐烂"又不符合实际。所以我最终做的是三分类:健康、腐烂、疑似/过渡,真正展示和答辩时,重点用"健康 vs 腐烂"的判定结果,但中间态样本的存在明显提升了模型的识别稳定度。
如果你的指导老师要求必须严格二分类,那也要在标注阶段就把"过渡态"样本单拎出来看一眼,别直接一把梭。数据质量比数据数量更影响最终结果,这句话在视觉项目里永远成立。
2. 数据集构建:这个环节占掉了整个项目一半时间
2.1 数据从哪来:自采、爬取还是公开数据集
草莓腐烂方向几乎没有现成的、质量达标的公开数据集。网上有一些水果腐烂数据集,但物种、角度、光照、分辨率都不一样,直接用的话模型泛化能力会打折扣。我的建议是:能自采就自采,自采不够再用网络图片补充。
自采的方法其实很简单:买几盒新鲜草莓,放在室内自然环境中,每天固定时间拍一轮照片。草莓腐烂是有过程的,第1天到第7天,每颗草莓从新鲜到局部霉变到完全腐烂,你可以拍到同一颗果实的连续状态,这对模型学习"腐烂的程度变化"非常有帮助。我大概拍了5天,每天每个角度拍一个批次,最后筛选出健康类约400张、腐烂类约450张、过渡类约200张。
拍摄时要注意:
- 背景别始终用同一种(桌子、盘子、烘焙纸都换着来),避免模型记住背景而不是草莓
- 光线要有变化,自然光、台灯、室内暖光都拍一些
- 腐烂草莓拍摄时注意通风,和新鲜草莓分开放,别拍着拍着把健康的也捂烂了
- 记得戴一次性手套处理发霉的样本,有些霉菌孢子对呼吸道有刺激
2.2 标注规范:腐烂的边界怎么划
标注我用的是"整图标签",也就是每张图片对应一个类别,不需要画框。这种标注方式对分类任务来说是最省力的,一个人就能完成,不需要费神去协调团队标注标准。
但这里有个细节要提前定好规矩:如果一张图里有两颗草莓,一颗健康一颗明显发霉,这张归哪类?我的处理方式是直接弃用这类"多目标且状态冲突"的图片,或者把它们裁剪成单颗草莓再归类。原因很简单,分类模型会被混合标签搞糊涂——它学到的特征不确定是"健康"还是"腐烂",损失函数来回震荡,训练曲线看着就很挣扎。
2.3 数据增强:加多了反而坏事
自采数据大约1000张左右,这个量直接训练一个深层网络肯定不够,所以必须做数据增强(Data Augmentation)。我用的增强组合是:
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.RandomAffine(degrees=0, translate=(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])看着常规,但有几个坑我必须提醒:
- 旋转角度别超过15度。草莓的方向变化本来就有限,转太多的照片里草莓的形态已经失真了,反而干扰模型学习
- 别做RandomErasing/Cutout这种区域遮挡增强。腐烂特征是密集的纹理和颜色变化,随机遮挡会把真正的霉斑区域抹掉,等于教模型忽略关键信号
- 归一化的mean和std要用ImageNet的标准值,这是我们迁移学习能够生效的前提,后面部署的时候也要用同一套
验证集和测试集只做Resize、ToTensor、Normalize,绝对不做随机增强,否则评估结果不可信。这是新手最容易犯的错——把增强用到了测试集上,看着准确率挺高,实际是虚假繁荣。
3. 模型选型:从零训练CNN不如直接迁移学习
3.1 为什么我不建议自己搭网络
很多教材喜欢让你从零搭一个三层的卷积网络,AlexNet或者更简单的结构,然后自己从头训。但在这个项目上,我不推荐这条路:你的数据量一千多张,从零训练一个有效提取"霉斑纹理"特征的网络,它起先在浅层还能学边缘和颜色,到了深层要组合出"霉菌菌丝"这种中度复杂特征,需要的数据量和迭代轮数会非常感人,训练过程也容易陷入过拟合。
迁移学习的道理就是"站在巨人肩膀上"。我用了在ImageNet上预训练好的ResNet18作为特征提取器,只把最后的全连接分类层换成本项目的3分类输出。预训练网络已经学会了识别各种物体纹理、轮廓、颜色的通用能力,我只需要让它在草莓这个具体任务上做微调。这就相当于你已经有一个见多识广的实习生,只需要让他熟悉一下草莓这个具体品类,而不是从零培养一个什么都不懂的婴儿。
3.2 常用骨干网络对比
我实际对比过三个网络:ResNet18、ResNet50、MobileNetV3-Large。在同一个数据集和同一套训练策略下,测试集结果大致是这样的:
| 模型 | 参数量 | CPU单张推理耗时 | 测试集准确率 | 模型文件大小 |
|---|---|---|---|---|
| ResNet18 | 约1170万 | 约0.35秒 | 98.4% | 约45MB |
| ResNet50 | 约2560万 | 约0.8秒 | 98.7% | 约98MB |
| MobileNetV3-Large | 约550万 | 约0.12秒 | 97.8% | 约20MB |
ResNet50比ResNet18提升不到0.5个百分点,推理速度却慢了一倍多,性价比不高。MobileNetV3在部署上有明显优势,适合放到网页甚至手机端演示。我最终选用ResNet18作为主力模型,因为结构经典、调参资料多、答辩时解释起来也容易;同时把MobileNetV3也训了一个,作为部署端的轻量备选。
3.3 迁移学习的核心代码就三行
PyTorch里做迁移学习替换分类头非常简单:
import torchvision.models as models import torch.nn as nn # 加载预训练权重 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) # 替换最后的全连接层,原始输出是1000类,改成3类 num_ftrs = model.fc.in_features model.fc = nn.Linear(num_ftrs, 3)关键就这两句:weights=models.ResNet18_Weights.IMAGENET1K_V1指定了加载官方预训练权重,model.fc = nn.Linear(...)把最后一层换掉。其余网络结构完全不动。这样我们的模型初始化就不再是随机权重,而是已经具备图像底层特征提取能力的权重,训练收敛速度和最终效果都有保障。
4. 训练策略与调参:这些参数背后是有逻辑的
4.1 数据集划分
我按80% / 10% / 10%划分训练集、验证集、测试集,用的是带分层功能的划分方式,保证每一类在三个集合里的占比基本一致。这一步很重要,如果随机切分,有可能验证集里恰好没有腐烂样本,那验证损失和准确率就完全失真了。
from sklearn.model_selection import train_test_split # X是图片路径列表,y是类别标签 X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, stratify=y_temp, random_state=42 )stratify=y这个参数就是分层抽样,一定要加。
4.2 优化器、学习率和损失函数
这个项目是标准的单标签分类,损失函数直接用CrossEntropyLoss,不需要改。
优化器我推荐先用Adam做常规训练,它的自适应学习率特性让调参门槛大幅降低。但要注意,Adam默认的学习率是1e-3,这个值对于微调预训练模型来说偏大了,我一开始就是踩了这个坑——损失在前两个epoch猛烈下降,然后开始震荡,验证集准确率卡在91%上不去。后来把学习率降到1e-4,训练曲线立刻变得平滑,最终准确率也明显提升。
最终的训练配置是:
- 优化器:Adam,初始学习率1e-4,权重衰减(weight_decay)1e-4
- 学习率调度:
ReduceLROnPlateau,当验证损失连续4个epoch不降时,学习率乘0.1 - 批量大小:batch size = 32(显存不够就降到16)
- 训练轮数:最大50轮,配合早停(patience=8)
调度器的选择也有讲究。我不用StepLR这种每隔固定epoch就降学习率的方式,因为不同数据集收敛节奏差异很大,用ReduceLROnPlateau按真实训练状态来降,更稳。
4.3 分阶段冻结与解冻
迁移学习有个常见误区:把所有层都解冻,直接用1e-4的学习率全量微调。这样不是不行,但容易在前期把预训练学到的通用特征破坏掉。
我采用的策略是两阶段训练:
第一个阶段,冻结除全连接层以外的所有参数,只训练新替换的分类头。代码是这样:
for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True # 此时优化器只需要传入分类头的参数 optimizer = torch.optim.Adam(model.fc.parameters(), lr=1e-3)这个阶段训练5-10个epoch,让新分类头先学会在已有特征上做判别。第二个阶段再解冻全部参数,用1e-4的小学习率整体微调。这样做的好处是:前几个epoch不会因为全连接层梯度混乱而冲击到主干特征层,后面微调时又能让底层特征朝"草莓腐烂"这个方向做适应性调整。实际跑下来,这种两阶段策略的收敛效果和最后准确率都优于直接全量微调。
4.4 过拟合的识别与处理
一千多张图片训练ResNet18,过拟合是大概率事件。识别的信号是:训练损失持续下降,但验证损失在某个epoch之后开始回升,验证准确率停滞甚至下降。
我的处理手段按优先级排序是:
- 先降学习率(用ReduceLROnPlateau自动触发)
- 增强数据增强强度(把ColorJitter的幅度稍微加大)
- 加Dropout(在全连接层前加一层
nn.Dropout(p=0.3)) - 实在不行才考虑换小模型(MobileNetV3)或收集更多数据
在做过这些处理之后,训练集准确率约99.8%,测试集准确率约98.4%,验证损失和训练损失基本收敛到了同一个水平,这个状态就比较理想了,不再有明显的过拟合迹象。
5. 评估指标:腐烂检测真正该关心的是漏判
5.1 混淆矩阵比一个准确率数字有信息量大得多
传统分类项目喜欢报一个测试集准确率完事,但在这个项目里,准确率是有迷惑性的。如果数据集中90%是健康草莓,模型全预测成健康,准确率也有90%,但它其实完全没学会识别腐烂。所以必须看混淆矩阵,我训练好的模型在测试集上的混淆矩阵大概是这样的:
| 实际\预测 | 健康 | 腐烂 | 疑似 |
|---|---|---|---|
| 健康 | 96 | 1 | 3 |
| 腐烂 | 0 | 94 | 2 |
| 疑似 | 3 | 1 | 36 |
从这个矩阵能读出很多信息:健康类有1张被误判成腐烂,属于"宁可错杀也不放过"的方向,这在食品安全场景里是可以接受的;但腐烂类完全没有漏判,这是我最看重的——因为在实际应用中,漏掉一个腐烂草莓比冤枉一个健康草莓严重得多。真正要做质量分拣的话,漏检意味着腐烂果实流入市场,这是底线问题。
所以我把召回率(Recall)作为核心指标:腐烂类别的召回率 = 94 / (94+0+2) ≈ 97.9%,代表"实际腐烂的草莓里,模型成功找出了97.9%"。同时也会看精确率,因为如果模型把太多健康草莓误杀,会带来不必要的浪费。
5.2 用Grad-CAM看清模型的注意力区域
准确率高只说明结果不错,但要理解"模型为什么判对",Grad-CAM是必备工具。它能生成一张热力图,标出模型做出判断时主要参考的是图像里的哪些区域。
我拿几张错判和正确判别的样本分别做了可视化,发现一个有意思的现象:模型判"腐烂"时,注意力集中在霉斑和褐变区域,这是符合预期的;但有一小部分判"健康"的草莓,注意力聚焦在叶片和背景边缘上,而不是果实本身。这说明模型偷懒了——它可能在用背景特征辅助判断。这个发现直接促使我重新整理了一批背景更复杂的样本加入训练集,并且拍摄新照片时刻意使用不同桌面纹理,针对性解决了这个隐患。
Grad-CAM的计算和可视化PyTorch官方有参考实现,核心是拿到最后一个卷积层的梯度信息,这里不贴完整代码了,做一个能出热力图的可视化脚本,收益非常大。不仅自己排查问题用得上,答辩时展示这几张热力图,也会比单纯说"准确率98%"有说服力得多。
6. 部署演示:把模型变成能用的网页
6.1 用Flask搭一个上传预测服务
毕业设计最终是需要演示的,一个命令行脚本跑完输出个准确率,观感太单薄。我用Flask做了一个极简的网页应用:浏览器上传一张草莓图片,后台调用训练好的模型,返回预测类别和置信度,页面展示结果和Grad-CAM热力图。
核心代码大概是这样的:
from flask import Flask, request, jsonify, render_template from PIL import Image import torch import torchvision.transforms as transforms app = Flask(__name__) # 加载模型 model = torch.load('best_model.pth', map_location='cpu')['model'] model.eval() # 和训练时保持一致的预处理 infer_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]) ]) classes = ['健康', '腐烂', '疑似'] @app.route('/predict', methods=['POST']) def predict(): file = request.files['image'] img = Image.open(file.stream).convert('RGB') tensor = infer_transform(img).unsqueeze(0) with torch.no_grad(): outputs = model(tensor) probs = torch.softmax(outputs, dim=1) conf, pred = torch.max(probs, dim=1) return jsonify({ 'class': classes[pred.item()], 'confidence': round(conf.item() * 100, 2) })6.2 部署里的几个坑
部署环节有几个细节,我踩过坑之后特意记了下来:
第一,预处理要和训练完全一致。我一开始部署时忘了加Normalize,结果模型对大部分图片都输出"健康",置信度还都超过90%。原因很简单,训练和推理的数据分布不同,数值范围对不上,模型完全"不认识"输入了。这个问题排查了很久才发现是预处理漏了一步,所以部署前建议先拿测试集里的几张图,对照训练时的推理结果逐一验证。
第二,模型保存和加载。建议只保存state_dict而不是整个模型对象,然后加载时先构造相同结构的网络再load_state_dict。这样跨环境运行更稳定,不容易遇到PyTorch版本兼容问题。另外部署到没有GPU的机器上时,加载要用map_location='cpu'。
第三,模型要切换到eval()模式。忘记调用model.eval()的话,BN层和Dropout层的行为会停留在训练模式,推理结果随机波动,有时准有时不准,特别迷惑。这个我在联调时也遇到过,加一行代码就解决了。
如果要更进一步,可以用getUserMedia调用摄像头实时拍照识别,几行JavaScript就能实现。演示效果会好不少,评委站在屏幕前,摄像头拍一张草莓图片,网页立刻返回结果,这个交互感比上传文件的方式强太多。我在答辩演示时用的就是摄像头识别这个方案。
7. 复盘与经验:给后来者的三条建议
7.1 数据采集的时间规划要提前
草莓腐烂是一个不可逆的物理过程,你无法加速它,只能等。我因为临时补拍了几张局部霉变的特写,整个训练计划往后推了3天。另外网购草莓和线下超市买的草莓,品种、成熟度、抗腐性都不一样,拍摄出来的特征也有差异。如果时间宽裕,建议至少用两个不同品种的草莓做样本,这样模型学到的腐烂特征不会绑定在某一个品种的外观上。提醒一下,答辩时评委常问"你这个模型换个品种的草莓还行不行",如果数据里有品种多样性,这个问题就好回答得多。
7.2 别只看最终准确率,要复盘每个阶段的错误
训练过程中我养成一个习惯:每个epoch结束之后,手动从验证集里挑出预测错误的图片看一遍。第一次发现模型把"带水滴的草莓"判成腐烂,因为水滴的反光和汁液渗出的反光在图像上确实很像;第二次发现"深红偏暗的草莓"被反复判成腐烂,因为颜色暗沉和腐烂变褐在色域上有重叠。这些发现看似微小,但每一个都推动我去调数据均衡、调颜色增强参数。如果只看终端输出的准确率数字,这些深层问题永远不会暴露。
7.3 答辩视角:能讲清楚"为什么"比跑通代码重要
这个项目的技术难度不在"训练一个高准确率模型",而在于每一个环节的取舍逻辑是否经得起追问。比如为什么要用迁移学习而不是从零训练、为什么用交叉熵损失、为什么看召回率而不是只看准确率、Grad-CAM热力图能说明什么——这些才是答辩时真正会被深挖的地方。我在准备过程中把每个环节的替代方案都过了一遍,不是为了全做出来,而是为了在回答"你为什么不选另一个方案"时能讲出明确的理由。
最后再说一个做完整套流程后我印象最深的事:这个项目真正耗费心力的地方,不是模型代码,而是数据。从拍摄、筛选、裁剪、标注到增强,每一步都是细致活。但恰恰是这些不起眼的工作决定了模型的上限,模型选型和调参只是在逼近这个上限罢了。如果你也要做类似的视觉识别毕业设计,我建议把时间分配向数据倾斜,别一头扎进调参的深水区——把数据做扎实了,模型的表现自然会说话。