☰
基于CNN与迁移学习的青椒变质图像分类实战解析
2026/10/22 14:12:50 网站建设 项目流程

1. 从毕设题目到落地项目:先说清楚我为什么做这个

我毕设的完整题目是“基于深度学习训练识别青椒是否变质”,主技术栈是Python加CNN。先说点实在的:光看这个题目,大部分人会以为这只是个“图像分类”练手项目,但真做下来你会发现,它其实把数据采集、标注清洗、模型选型、训练调参、部署推理全链路都串了一遍,用来做毕业设计非常合适——工作量足够、技术栈有深度,又不会像目标检测那样数据标注做到崩溃。

先说我选这个题的原因。青椒变质识别本质上是一个二分类图像问题——好果和坏果。二分类在CV里属于最基础的场景,但基础不等于简单。真正难的部分在于:青椒变质的表现形式极其多样,有的是表面发软发皱,有的是出现黑色霉斑,有的是果蒂变褐,有的是整体失水发暗,甚至不同光线条件下同一个青椒拍出来差异都很大。这种“类内差异大、类间边界模糊”的特点,正好能逼着你把数据增强、正则化、迁移学习这些手段全都用上,而不是随便跑个模型交差。

这个项目适合谁参考?如果你是即将做毕设的本科生或研究生,方向上选了图像分类、CNN、农产品无损检测相关的课题,这篇东西可以帮你避开我踩过的雷。如果你只是对深度学习感兴趣,想找一个能完整跑通的数据集和训练流程,也可以直接参照我的做法。我尽量把每个环节的思考过程和参数理由写清楚,能直接照着抄的地方绝不含糊。

整个项目做到最后,我在测试集上的准确率稳定在94%以上,在自拍的真实场景图片上大概90%出头。这个数字不算惊艳,但对于一个受限于算力和数据量的毕设项目来说,已经足够支撑起一篇像样的论文了。更重要的是,我在这个过程中把所有环节都走了一遍,面试的时候能讲的东西比那些只跑通MNIST的人多太多了。

2. 技术选型:为什么是CNN,而不是别的方案

2.1 传统图像处理方案为什么不行

开题的时候,我先认真考虑过传统图像处理的路子。青椒变质时,颜色上通常会出现褐变或黑斑,纹理上会出现皱缩,这些特征理论上是可以手工提取的。常规做法是:先用HSV色彩空间提取颜色特征,再用灰度共生矩阵(GLCM)提取纹理特征,最后丢给SVM或随机森林做分类。

但问题很快暴露出来。第一,青椒品种太多,有些品种本身就是深绿色偏褐色,有些表面天然有波纹状的纹理,这些先验特征在不同批次、不同光照下极不稳定。第二,坏果的表现不是单一维度的——有的变色但不变软,有的长霉但颜色差异不大,用一个或几个固定的特征维度根本无法覆盖。第三,传统方案在前期特征工程上投入巨大,但泛化能力很差,换个拍摄环境效果立刻跳水。

相比之下,CNN的卷积核能自动从数据中学习特征,从低级边缘纹理到高级语义特征全都自己搞定,省去了人工设计特征的大量工作。对于青椒这种外观变体多、特征不固定的对象,这几乎是降维打击。

2.2 CNN的哪些设计特别适合这个任务

CNN之所以适用于图像分类,核心在于它的两个特性:局部感知和参数共享。

局部感知是指卷积核只关注图像的一个局部区域,而不是像全连接网络那样把整个图像摊平全部连接。这符合图像本身的特性——图像中邻近像素的关联性强,远处的像素关联性弱。青椒的霉斑就集中在局部,用3x3或5x5的感受野就能捕捉到,根本不需要全局信息。

参数共享是指同一个卷积核在图像不同位置做相同的运算,这让模型参数量大幅减少,同时保证了特征的平移不变性——青椒长霉的位置可能在左下角的果蒂,也可能在右上角的果肩,同样的特征不需要在不同的位置分别学一次。

这些底层机制叠加起来,CNN在青椒这类纹理和局部特征明显的图像上天然优于传统全连接网络,也优于我在初学时试过的那些经典机器学习算法。

2.3 为什么最终选了Python生态

Python作为实现语言几乎没有争议。我对比过其他选项,比如用纯C++配合OpenCV做传统方案,开发和迭代速度实在太慢。Python这边的深度学习生态成熟度是压倒性的:PyTorch做训练,OpenCV或PIL做图像预处理,Matplotlib画训练曲线,NumPy做数据统计,scikit-learn做评估指标,全链路不需要切换语言。

PyTorch和TensorFlow之间我选了PyTorch,理由是它在研究和调试时的灵活性更强,动态图机制让我可以随时打印中间张量的形状,出问题时排查起来非常直观。后面训练遇到loss不下降的情况,我能快速定位是哪一层的问题,这种体验在静态图框架里会痛苦很多。

3. 数据准备:最枯燥但是决定成败的环节

3.1 数据来源与采集策略

做这个项目初期,我天真地以为网上能直接找到现成的青椒变质数据集。结果搜遍了各大公开数据集平台,发现要么是纯果蔬菜品分类(包含青椒但都是正常果),要么是病虫害数据集(但主要针对叶片),真正针对“青椒变质”这种细粒度二分类的数据集几乎没有。

最后我只能自己采集。我用的策略是“市场采集+实验室补拍”的组合:先在几家农贸市场分批次拍摄不同品种的青椒,包括尖椒、圆椒、螺丝椒等,好果和坏果各拍若干张;然后回到实验室,在单色背景、固定光源条件下补拍一批,确保坏果的霉斑纹理清晰可辨。

这个步骤我踩了一个非常关键的坑:第一批采集的照片几乎全是市场老板摊位上的场景,背景杂乱,有塑料袋、标签纸、甚至其他蔬菜。模型在训练时学到的大量特征是背景特征而不是青椒本身的特征——所谓捷径学习。后面我重新整理数据时,把所有照片做了背景统一处理,即先做了分割和背景替换,又补拍了一批单色背景数据,问题才基本解决。

3.2 数据标注与清洗:比想象中费时间

标注二分类听起来很简单——好果标0,坏果标1。但真正标注的时候你会发现边界情况特别多:一个青椒只有果蒂处有一点变褐,这算变质吗?一个青椒失水发蔫但表面没有霉斑,这算变质吗?

我的处理原则是:

  • 有明显霉斑、腐烂、发软部位的,一律标为变质。
  • 仅失水发皱但颜色正常的,归入正常类,前提是它在市场上仍可售卖。
  • 表面局部小面积磕碰伤的,视面积大小判断,超过果面10%的归为变质。
  • 无法判断的图片直接丢弃,不硬标。

别小看这个环节,我总共拍摄了约800多张原始图片,最后清洗到约700张可用,标注耗时两个下午。如果你跳过清洗直接训练,测试集上的准确率可能还行,但模型一到真实场景就会疯狂误判,因为训练数据本身是脏的。

3.3 数据增强组合策略

700张图片对于CNN来说太少,直接训练必然严重过拟合。我的解决方案是数据增强,而且不是简单翻翻转转,而是针对青椒识别的实际场景做了组合增强。

我用的是PyTorch的transforms模块,采用的增强组合如下:

train_transforms = transforms.Compose([ transforms.RandomResizedCrop(size=(224, 224), scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.3), transforms.RandomRotation(degrees=30), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

我在增强策略上的考量是:RandomResizedCrop模拟不同距离拍摄造成的大小差异和局部取景差异;ColorJitter模拟不同光源和色温下的颜色偏移;旋转和翻转模拟手持拍摄时手机角度的变化。归一化参数直接用了ImageNet的均值方差,因为后面要用预训练模型做迁移学习,这个细节很重要,不能自己随便设。

增强后的等效数据量大约翻了20倍,训练时每个epoch看到的都是不同的图片组合,过拟合问题大幅缓解。

4. 模型搭建与训练:从零训练到迁移学习的挣扎过程

4.1 自己搭CNN的失败与反思

第一步我当然尝试了自己搭一个浅层CNN,结构大概是:3个卷积块(每个包含两层卷积+ReLU+MaxPool)+ 2个全连接层,参数量大概在200万左右,想着对于二分类任务应该足够了。

训练结果非常打击人:训练集loss从第5个epoch开始快速下降,但验证集loss在初期下降后迅速反弹,准确率一直徘徊在75%左右。这是教科书级别的过拟合——模型把训练集的背景噪声、光影模式背了下来,根本没有真正学习到青椒本身的特征。

我还尝试过加Dropout、加L2正则、减半模型容量,效果有改善但不显著,验证集准确率也就提到80%出头。这让我意识到,在千张级别的数据量下,从零训练CNN几乎不可能取得令人满意的效果。模型本身的容量只是问题的一小部分,更根本的问题在于监督信息不足。

4.2 迁移学习:让模型站在巨人的肩膀上

痛定思痛之后,我换成了迁移学习方案,这也是后面实际项目能够达到94%准确率的关键。

我选择了在ImageNet上预训练过的ResNet18作为backbone。选ResNet18的理由是:相比ResNet50、ResNet101这些更深的网络,ResNet18在参数量和计算量上更适中,而且对于青椒这种结构不算特别复杂的对象,18层的容量已经足够。更深网络在这个数据量下不会带来明显提升,反而会加重过拟合。

实现上我只替换了最后一层全连接,把原来的1000类输出改成2类输出:

import torchvision.models as models import torch.nn as nn model = models.resnet18(pretrained=True) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 2)

关键技巧是分阶段训练。我没有一开始就把所有层都放开,而是先把backbone的所有参数冻结,只训练新加的fc层,跑5个epoch左右,让分类头先收敛到一个合理的位置;然后再解冻最后一两个残差块的参数,用一个较小的学习率对整个模型做微调。

初始阶段的learning rate我用的是0.001,微调阶段降到0.0001。优化器选Adam,weight decay设为1e-4。这个分阶段策略看起来朴素,但非常有效——如果一上来就全量微调,预训练权重很快就会被破坏,模型表现反而不如只训练分类头。

4.3 训练过程中的loss曲线解读

这个部分多说几句,因为很多同学训练完根本不知道怎么从曲线诊断问题。我记录了几组典型的训练曲线,对应的操作决策完全不同:

第一组:训练loss下降但验证loss先降后升。这是过拟合信号,需要减少训练轮次、增强正则化或提前停止。 第二组:训练loss和验证loss都不下降。通常是学习率太大、数据预处理错误或模型结构有问题。 第三组:验证loss出现剧烈震荡。可能是batch size太小、学习率偏大,或者数据中存在标注错误。 第四组:训练loss已经很低但验证loss始终下不去。这是典型的领域差异问题,通常需要更多数据或更强的增强策略。

我的训练过程中出现过第一和第四种情况,分别用early stopping和调整增强策略解决了。建议你在训练时每个epoch都记录下loss和准确率,画成曲线图,这比看任何指标都直观。

5. 训练细节与调参实录:那些文档里不会告诉你的经验

5.1 超参数的选择逻辑

关于超参数我逐一说明我的选择理由,你可以直接参考:

  • Batch size:选了32,不算大也不算小。太大会让梯度方向过于固定,容易陷入sharp minima,泛化性差;太小则梯度噪声大,训练不稳定,而且GPU利用率低。
  • Epoch数:初始设了30,配合early stopping。实际训练中模型在第18个epoch左右达到最佳验证准确率,后面就开始过拟合了。
  • 学习率:训练阶段0.001,微调阶段0.0001。这个范围对ImageNet预训练模型基本是安全区间,再大容易破坏已有权重。
  • 优化器:Adam,beta1默认0.9,beta2默认0.999。Adam收敛速度快,对学习率的敏感度低,适合项目前中期探索。
  • Loss function:CrossEntropyLoss,PyTorch自带实现。二分类问题同样可以直接用这个,不需要单独套一个Sigmoid+BCELoss的组合。

5.2 训练代码核心框架

这里给出我完整训练流程中比较核心的代码片段,但保留了你需要根据实际环境调整的部分:

import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader, Dataset from torchvision import datasets, transforms, models # 数据集加载 train_dataset = datasets.ImageFolder(root='data/train', transform=train_transforms) val_dataset = datasets.ImageFolder(root='data/val', transform=val_transforms) 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) # 模型定义(迁移学习) model = models.resnet18(pretrained=True) num_features = model.fc.in_features model.fc = nn.Linear(num_features, 2) # 分阶段冻结参数 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.001) # 训练阶段一:只训练fc层 for epoch in range(5): model.train() for inputs, labels in train_loader: outputs = model(inputs) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() # 解冻最后两层并降低学习率微调 for name, param in model.named_parameters(): if 'layer4' in name or 'fc' in name: param.requires_grad = True optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=0.0001)

5.3 一个极其容易忽略的坑:验证集的transform不能加增强

我见过不少同学把训练用的RandomCrop、RandomRotation也用在验证集上,这是完全错误的。验证集的作用是模拟模型在真实数据上的表现,必须是原始图片的确定性处理。我的验证集transform只有Resize、CenterCrop、ToTensor和Normalize,没有添加任何随机扰动。

如果你在验证集里加入了随机变换,你会发现每次验证的准确率都不一样,而且是随机浮动的,你根本没法判断模型到底是进步了还是退步了,更无法做模型选择。这是一种毫无意义的“假评估”。

5.4 显存不足的应对方案

我用的GPU显存只有4GB,最开始训练ResNet18时,batch size调到32直接OOM。解决思路有三个:

第一,降低batch size到16,这是最直接的方法。但batch size过小会导致训练不稳定,所以还需要同步微调学习率。 第二,关闭PyTorch的梯度缓存,即在推理时用torch.no_grad()包住验证过程。很多同学在验证阶段忘了这一步,白白占用显存。 第三,使用混合精度训练。如果显卡支持,自动混合精度能把显存占用降低近一半,训练速度还能提升。

我当时的实际做法是batch size降到16,同时把image size从224降到192,准确率几乎没有损失,但显存压力小了很多。这个取舍在毕设场景下是非常值得的。

6. 模型评估:不只是看准确率

6.1 为什么不能只看准确率

青椒变质的二分类问题中,正负样本比例大概在1比1左右,看起来不存在严重的类不平衡,但准确率依然有它的盲区。准确率把所有错误都当作同等代价,但在实际场景中,把变质青椒识别为正常青椒,和把正常青椒识别为变质青椒,后果完全不同——前者可能让变质水果流入市场,后者只是造成一些误判损耗。所以,单看准确率并不足够,我综合评估了Precision、Recall、F1-Score和混淆矩阵。

我最终报告的关键数据:

指标数值
Accuracy94.2%
Precision93.6%
Recall95.1%
F1-Score94.3%

这个结果说明模型在“不漏检变质青椒”方面表现更好,这一点在实际应用中是更重要的方向。

6.2 混淆矩阵分析

混淆矩阵是理解模型错误类型的最直接工具。我的测试集样本量大约140张,混淆矩阵大致如下:

预测正常预测变质
实际正常665
实际变质366

从矩阵可以看出,模型更容易把正常青椒误判为变质,而不是反过来。我分析了一下原因:训练数据中的正常青椒形态变化比较大,有些正常青椒表面有天然的花纹或轻微的磕碰痕迹,模型在训练时可能把这些特征与变质早期特征混淆了。

这个分析思路很重要——如果你想提升模型表现,先看混淆矩阵,搞清楚模型错在哪里,再有针对性地补充数据或调整策略,而不是盲目加数据。

6.3 交叉验证的取舍

我最终没有做K折交叉验证,原因是训练成本太高。但为了验证模型的稳定性,我用不同的随机种子重跑了3次训练,观察最终准确率的波动范围。三次的结果在93%到95%之间浮动,说明模型性能是稳定的,不是偶然的一次好成绩。

这个做法既避免了交叉验证的耗时,又能在论文中说清楚模型的稳定性,是一个很实用的折中方案。如果你时间有限,建议至少重跑2到3次。

7. 部署与推理:把模型从实验环境拿到真实场景

7.1 模型导出与优化

训练完的PyTorch模型不能直接用于生产环境,需要导出为一个标准格式。我用的方案是导出TorchScript。TorchScript是PyTorch官方支持的序列化格式,可以在不依赖Python环境的情况下运行模型。

# 设置模型为推理模式 model.eval() # 构造示例输入,需要与训练时一致 example_input = torch.randn(1, 3, 224, 224) # 使用TorchScript导出 traced_model = torch.jit.trace(model, example_input) traced_model.save('pepper_model.pt')

导出后,模型的大小大约在40多MB。如果你对推理速度有更高要求,还可以转成ONNX格式,然后通过ONNX Runtime进行推理,速度会更快,在一些嵌入式设备上也能跑。

7.2 真实场景推理测试

训练时用的数据都是在相对理想条件下拍的,但真实的推理环境是千奇百怪的。我用手机在不同角度、不同光线下拍了一组真实场景的青椒照片做测试,结果发现问题真不少:

第一,逆光环境下青椒表面反光严重,模型会把反光区域误判为变质斑点,导致误报。 第二,近距离拍摄时青椒只占据了画面中央,边缘有很多模糊背景,模型有时会因为背景干扰产生误判。 第三,远距离拍摄时青椒在画面中占比过小,细节纹理丢失,模型几乎分不清好坏。

对于逆光问题,我后续做了数据增强补充,在色调和亮度调整上增加了一些随机变化,模型表现改善了不少。对于取景问题,我的推理代码中加了一个简单的预处理步骤,用轮廓检测大致定位青椒的位置,裁剪出主体区域后再送入模型,效果提升非常明显。

这个环节让我深刻理解了为什么工业界的模型需要持续收集真实场景数据来迭代,训练时的分布偏差如果不在这个环节修正,模型的实用性就会大打折扣。

7.3 轻量化方案:移动端部署的思考

如果你的毕设课题要求做移动端部署,那么TorchScript版本还不足够轻量。我当时调研了量化方案,PyTorch支持动态量化和静态量化,把模型权重从FP32降到INT8可以让模型体积缩小到原来的四分之一,推理速度也能提升2到3倍。

精度损失方面,量化后的模型在我的测试集上准确率下降了大约0.5个百分点,从94.2%降到93.7%,对于二分类任务完全可接受。如果你的应用场景有严格的存储和延迟要求,量化是必须要考虑的方向。不过,量化不是简单调用一个API就能完成的事情,需要准备校准数据集来计算合适的量化范围,这里面的细节也够单独写一篇了。

8. 踩坑实录:我走过的弯路,你未必需要再走一遍

8.1 数据标注中的标注者偏差

我第一批数据是自己标注的,第二批数据让同学帮忙标了一部分。后来我发现,同样是“果蒂轻微变褐”的图片,我标为正常,同学标成了变质。这种标注者之间的偏差会直接体现在模型的学习目标里,导致模型在这些边界情况上表现不稳定。

后来我的解决方案是:所有标注完成后做了二次审核,将标注一致的保留,不一致的单独拉出来重新判断。这个过程虽然耗时,但对模型上限的提升非常重要。标注质量直接决定模型上限,这个话怎么强调都不为过。

8.2 图像尺寸的陷阱

我最初直接把所有图片Resize到224x224就送了进去,没有考虑图片的长宽比问题。青椒本身是长条形,强制拉伸到正方形会改变其形态比例,导致特征失真。这个问题在训练初期被模型的鲁棒性掩盖了,但到了评测阶段就暴露出来。

输入尺寸的调整我是这样做的:保持原图比例的情况下,先resize到短边等于224,然后中心裁剪出224x224的区域。这样虽然丢失了边缘信息,但保留了主体的自然比例。如果你拍摄时青椒总是占据画面中心,这个方案非常合理。

8.3 迁移学习时冻结层数调的玄学

网上很多教程直接让你“冻结所有层,只训练最后一层”,但实际效果并不总是最好。我在实验中发现,冻结全部层只训练fc时验证准确率大约在89%左右,解冻layer4后提升到92%,再解冻layer3后到94%。继续解冻更多层收益就很不明显了,反而增加过拟合风险。

训练数据量、数据分布与ImageNet之间的差异,决定了需要微调多少层。对青椒这种视觉效果与自然图像有一定差异但又不是太大的对象,微调最后2个残差块是比较合适的经验值。你可以在自己的数据集上快速做个对比,每次解冻一层看验证集表现,选择那个“性价比最高”的冻结深度。

8.4 类别不均衡误判

虽然我前面说正负样本比例接近1比1,但实际上第一批数据中正常青椒占了大头,大概7比3。如果直接拿这个比例去训练,模型会倾向于把一切预测为正常——因为只需要达到70%的准确率就能有不错的表现,这是损失函数的天然倾向。

后续我重新平衡了数据集,让正常和变质样本的比例尽量接近1比1。如果某个类别实在难以采集,可以考虑用类别权重或者Focal Loss来缓解不均衡问题。

9. 总结与后续可以优化的方向

9.1 这个项目有什么值得固化的经验

整个项目做下来,我最大的体会是:深度学习的项目里,模型搭建只是很小的一部分,真正决定项目质量的环节是数据和工程化的细节。很多同学一开始就想去调模型结构、调超参数,但对数据的理解和准备不重视,方向反了。青椒变质识别这个项目让我把数据采集、标注、增强、模型微调、部署评估的全链路走了一遍,这种整体性的认知比任何一个单一技术点的深度都重要。

另外一个收获是迁移学习的实战理解。从零训练一个CNN模型,和站在预训练模型肩膀上做微调,效果差距是数量级的。这个经验直接迁移到任何视觉分类任务上都适用,属于一个通用技能。

9.2 后续可以沿哪些方向做深

这个项目后续的扩展空间其实很大,简单列举几个觉得值得做的方向:

第一,从二分类扩展到多分类。比如把变质阶段细分为初期、中期、晚期,或者把变质原因细分为霉变、失水、机械损伤。多分类任务对模型性能的要求更高,也更贴近实际应用中对品质评估的需求。

第二,做目标检测而不是图像分类。目前的方案假设输入图片中只有一个青椒且占据主体位置,但真实场景往往是一堆青椒混在一起。如果你用YOLO或Faster R-CNN这类目标检测模型,可以同时定位和识别多个青椒,这更接近实际的产品应用形态。

第三,加一个分割网络来辅助定位变质区域。分类模型只能告诉你“这个青椒坏了”,但没法告诉你是哪个部位坏了。结合语义分割或者异常检测算法,可以精确定位到变质区域,对于流水线自动分拣设备来说,这是更有价值的信息。

第四,把量化和轻量化做深。我的方案导出了TorchScript模型,也调研了INT8量化,但还没有在边缘设备上全面验证。结合GPU推理引擎做加速,再配一个简单的Web界面或手机App,就能变成一个完整的端侧应用。

9.3 一点掏心窝的话

如果你也是被导师安排了一个“基于深度学习识别XX是否变质”的课题,不要慌也不要抗拒,这种题目的价值远不止于把数据喂进模型跑个准确率出来。你有机会去接触数据工程的全流程、理解模型泛化能力的影响因素、体验从离线训练到在线推理的部署过程,这些东西在工业界实践里都要用。

最后分享一个已经在做的方向:我正在把训练好的模型接上了一个简易的微信小程序,通过手机拍照上传,后台推理后返回判断结果。这个demo已经可以演示,虽然前端还很粗糙,但在答辩现场的效果会比较直观,比只展示训练曲线好得多。如果你时间充裕,很推荐也做一个类似的端侧或前端demo——它会大幅度提升评委老师对项目完成度的认可。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询