ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于轻量CNN的苹果品质识别:数据增强、模型设计与部署实践

基于轻量CNN的苹果品质识别:数据增强、模型设计与部署实践

做苹果品质分拣这个课题的第一周,我就意识到一个很现实的问题:真正限制毕设进度的不是模型选型,而是数据。这不是套话——我请了几位同学帮忙,对着同一批苹果拍了上千张照片,然后发现不同光照条件下,同一颗苹果的色差,比不同品质苹果之间的色差还大。这个问题不解决,任何CNN结构都白搭。

这篇博文就是围绕"基于深度学习卷积神经网络识别苹果品质"这个毕设课题的完整复盘。内容涵盖从数据集构建、数据增强、轻量CNN模型设计、训练参数调优,到测试集翻车后的排查链路,再到最后部署成一个可演示的图形界面。不管你是准备拿这个题做毕业设计,还是单纯想练手图像分类,这篇都值得看完。我会把每个环节的"为什么这样做"讲清楚,也会把项目里那些常规教程不会写的问题暴露给你看。

1. 苹果品质检测的需求拆解:为什么用CNN而不是传统视觉方案

1.1 传统图像识别方案卡在哪里

在项目立项之前,我调研过市面上常见的苹果分拣方案,发现早年间的主流做法是"手工特征+分类器"。具体来说,就是提取苹果的颜色均值、颜色直方图、表面纹理统计量、轮廓圆度、半径标准差,再交给SVM或随机森林做分类。这套方案在实验室理想条件下效果不错,但我们自己复现时发现,一旦换一批苹果、换一个光源环境,准确率掉得很快。原因并不神秘:苹果表面有蜡质反光,同一颗苹果的受光面与背光面颜色差异明显;磕碰伤、果锈、霉斑、褐变这些瑕疵的视觉形态各不相同,很难设计一组统一的手工特征把它们全部分开。传统方案的本质是"人先理解什么是好果、什么是坏果,再尝试用数学表达式描述它",而苹果品质这个问题恰恰很难用表达式描述清楚。

1.2 CNN在这个场景下的天然优势与毕设价值

卷积神经网络的不同之处在于,它不要求你手工设计特征,而是用大量标注样本去驱动模型自己学习特征。浅层卷积核学到的是边缘、颜色块、纹理走向这些低级特征;往深层走,网络会把低级特征组合成"果梗区域""碰伤斑块""霉斑边界"这类语义概念。这正好契合苹果品质识别的需求:瑕疵的种类多样、形态不固定,但它们的共性是"和正常果面有可区分的局部模式"。做毕业设计的角度来看,CNN课题还有几个实际好处:工作量可量化——数据集、训练脚本、评估报告、演示界面都能作为明确的交付物;答辩时有丰富的可视化内容可以展示,比如特征图、混淆矩阵、训练曲线;且模型不需要特别大,普通电脑就能跑完训练。这些因素叠加起来,让我最终确定了这个技术路线。

2. 数据准备:小样本场景下的采集与增强策略

2.1 采集与标注:从一张苹果照片到可用的训练数据

我的第一阶段任务不是建模,而是先把数据确认下来。参考多数类似课题的做法,我选了三分类:正常果、外部损伤果(磕碰伤、划痕、压伤)、病害果(果锈、霉斑、褐变)。外部损伤和病害在视觉特征上有部分重叠,但这类"模糊边界"正是CNN相对传统方法的优势所在——它可以学到比人眼更细的纹理差异。

采集时我用手机在纯色背景上逐张拍摄,每类分别拍300到400张,覆盖三个拍摄角度(正面、侧面、带果梗端),同时刻意把部分苹果放在不同亮度下拍,这样模型能学到光照不变性。最终我得到约1100张原始图。这个数量对深度学习来说偏小,所以标注环节尤其要谨慎。我请两位同学分别标注,遇到不一致的样本统一剔除。最终每类保留约280张,整理为:

  • dataset/train/good
  • dataset/train/bruised
  • dataset/train/defective

这里给一个重要的提示:如果你的样本来源是网络爬取的图片,务必检查是否有人为PS过的图像,尤其是加了水印、滤镜的图,这些会被模型当成"假特征"学进去,导致测试时误判。

2.2 数据增强:用"制造差异性"来拓宽数据分布

只有约850张训练图,直接喂给CNN会迅速过拟合。数据增强的核心思想是:在不改变语义标签的前提下,对原始图像做一系列随机变换,让模型每次都看到略有不同的输入,从而学习到更具泛化性的特征。

我采用的增强组合如下:

  • 随机水平翻转与随机旋转(±20度)
  • 随机缩放与裁剪(缩放范围0.8~1.1,裁剪后resize回224×224)
  • 色相、饱和度、亮度扰动(模拟不同光线环境)
  • Random Erasing:在图像上随机遮挡一块矩形区域,强迫模型不要只依赖单一区域来判断
  • 轻微的高斯模糊(模拟拍摄对焦略有偏差的情况)

需要强调的是,增强参数不宜过猛。我一开始把旋转角度设为±60度,结果训练集准确率都上不了80%——因为苹果的旋转出现了倒置特征,干扰了模型对果梗区域的判断。后来调整为±20度,问题就消失了。对刚性物体来说,过强的旋转增强反而有害。

2.3 预处理与数据加载的细节

所有图像统一resize到224×224,然后除以255归一化到[0,1]区间。也可以使用ImageNet的均值方差归一化,但对"从零训练的小网络"来说,简单归一化即可,差距不明显。

划分数据集时切忌随机打乱后直接按比例切分。我采取的策略是:先把同一颗苹果的多张照片归为一组,以"苹果个体"为单位划分到训练集、验证集、测试集,比例约8:1:1。否则训练集和验证集中出现同一颗苹果的不同照片,验证指标会虚高,后续换到真实场景时立刻现原形。

数据加载部分,我使用PyTorch的DataSet和DataLoader实现,核心代码大致如下:

from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import os class AppleQualityDataset(Dataset): def __init__(self, root_dir, transform=None): self.classes = ['good', 'bruised', 'defective'] self.image_paths = [] self.labels = [] self.transform = transform for label, cls in enumerate(self.classes): cls_dir = os.path.join(root_dir, cls) for fname in os.listdir(cls_dir): self.image_paths.append(os.path.join(cls_dir, fname)) self.labels.append(label) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img = Image.open(self.image_paths[idx]).convert('RGB') label = self.labels[idx] if self.transform: img = self.transform(img) return img, label train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(20), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = 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]) ])

数据这块合计下来约两整天工作量,但这部分直接影响最终模型的泛化能力。我在这个项目里最大的感触是:数据质量决定了精度的天花板,模型结构只是在逼近这个天花板。很多同学一上来就调网络结构,调了半天测试集卡在93%上不去,回头检查才发现训练集里混了一堆模糊图和重复图,换个干净数据集直接涨到97%。这种坑不值得自己踩一遍才信。

3. 模型设计与参数选型:轻量CNN比大模型更实用

3.1 为什么不用ResNet18直接做迁移学习

刚开始我也想过直接用预训练的ResNet18,只要把最后一层全连接改成3分类,再微调几轮就能获得不错精度,听起来很简单。但我做了几次实验后放弃了这个方案,原因有三条。第一,预训练模型学的是ImageNet里的通用物体特征,对"苹果表面瑕疵"这种细粒度纹理差异并不敏感,微调时需要冻结部分层、调整不同层的学习率,调参量并不比从零训练少。第二,ResNet18的参数量约1100万,在不到900张训练图的小数据集上极易过拟合,即便加数据增强,验证集也会出现震荡。第三,毕设最重要的部分之一是能讲清楚"网络为什么这样设计",如果直接拿预训练模型微调,答辩时能讲的内容非常有限。

3.2 我采用的网络结构:Conv-BN-ReLU的重复组合

综合精度、速度和可解释性,我决定设计一个参数量约40万的轻量CNN。整体结构如下:

网络总体呈"三组卷积特征提取 + 两层全连接分类"的模式。每一组卷积块内部都遵循"卷积→批归一化→ReLU激活→最大池化"的顺序。具体配置如表所示:

层块结构输出尺寸
输入224×224×3224×224×3
Block1Conv2d(3→16, 3×3) + BN + ReLU + MaxPool112×112×16
Block2Conv2d(16→32, 3×3) + BN + ReLU + MaxPool56×56×32
Block3Conv2d(32→64, 3×3) + BN + ReLU + MaxPool28×28×64
Flatten全局展平50176
FC1 + Dropout全连接(50176→128) + ReLU128
FC2全连接(128→3) + Softmax3

通道数从16起步翻倍到32、64,是一个被广泛验证的标配做法:浅层用少量卷积核学习基础纹理,深层用更多卷积核捕捉更丰富的语义组合。最大池化不仅降低分辨率,还引入了平移不变性,帮助模型容忍瑕疵位置偏移。批归一化放在卷积之后、激活之前,能够稳定每一层的输入分布,让我可以把学习率开大而不必担心梯度爆炸。

请注意,Flatten之后接两个全连接层的原因是:卷积层输出的是一个高维展平向量,如果直接映射到3个类别,中间缺少非线性变换,分类能力会明显下降。中间加一层128维的FC,配合ReLU,相当于让模型在低维空间里重新组合特征向量,实验下来比直接输出多3个百分点的准确率。

模型定义的参考代码如下:

import torch.nn as nn class AppleQualityNet(nn.Module): def __init__(self, num_classes=3): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Dropout(p=0.5), nn.Linear(64 * 28 * 28, 128), nn.ReLU(inplace=True), nn.Dropout(p=0.3), nn.Linear(128, num_classes), ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x

3.3 训练配置:交叉熵、Adam与动态学习率

损失函数使用最常见的交叉熵损失。分类问题用交叉熵的理由很直接:它把模型的输出转换成概率分布与真实分布的差距度量,配合Softmax使用时梯度回传稳定,且对"置信度错误"的惩罚是连续渐进的。

优化器我选Adam,初始学习率1e-3,批大小32。相比SGD,Adam在小型数据集上收敛更快,不需要过多手动调整动量参数,更适合毕设周期。同时我加入ReduceLROnPlateau调度器:连续5个epoch验证集loss不下降时,学习率乘以0.5。训练最多50个epoch,并开启EarlyStopping,验证集指标连续10个epoch不提升时终止。

训练循环的框架大概长这样:

import torch import torch.nn as nn import torch.optim as optim from torch.optim.lr_scheduler import ReduceLROnPlateau device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = AppleQualityNet().to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=1e-3) scheduler = ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=5) best_val_loss = float('inf') patience_counter = 0 for epoch in range(50): model.train() running_loss = 0.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() running_loss += loss.item() model.eval() val_loss = 0.0 correct = 0 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() _, predicted = torch.max(outputs, 1) total += labels.size(0) correct += (predicted == labels).sum().item() val_acc = 100.0 * correct / total avg_val_loss = val_loss / len(val_loader) scheduler.step(avg_val_loss) if avg_val_loss < best_val_loss: best_val_loss = avg_val_loss torch.save(model.state_dict(), 'best_apple_model.pth') patience_counter = 0 else: patience_counter += 1 print(f'Epoch {epoch+1}: Train Loss {running_loss/len(train_loader):.4f}, ' f'Val Loss {avg_val_loss:.4f}, Val Acc {val_acc:.2f}%') if patience_counter >= 10: print('Early stopping triggered') break

关于训练周期,我实际跑到第32个epoch就触发了早停。前8个epoch验证集准确率快速攀升到90%,之后涨幅明显变缓,到第22轮达到96.5%左右的平台期,后面轻微波动。最终保存下来的是验证集loss最小的那一轮权重,而不是最后一轮,这一点很重要——早停机制保存的最佳模型才是泛化性能最好的。

4. 训练过程与结果判读:从曲线到混淆矩阵的完整分析

4.1 训练曲线里藏着哪些信息

训练过程中我记录了每一轮的训练loss、验证loss和验证准确率。典型且健康的曲线是这样的:训练loss稳步下降,验证loss同步下降并收敛在同一量级;当验证loss开始上升而训练loss继续下降时,就表示过拟合开始了。如果出现验证loss大幅震荡,通常说明学习率偏大或数据增强过猛,我的第一个版本就栽在数据增强上,旋转角度太大导致验证集曲线像锯齿一样。

准确率曲线同样值得细看。如果训练准确率一直在95%以上,验证准确率却只有80%左右,说明模型已经在背训练集了。常规解法是增强数据多样性、加大Dropout比例,或者减少模型参数量。我在实验中发现,当把Dropout从0.3提高到0.5后,验证集准确率提升了大约1.5个百分点,说明小数据场景下正则化确实关键。

4.2 混淆矩阵:比总体准确率更能说明问题

单独看准确率容易自欺欺人。我在测试集上得到约96.8%的总体准确率,但进一步拆解混淆矩阵后发现,问题远没有结束。

实际类别预测为正常果预测为损伤果预测为病害果
正常果94.2%3.5%2.3%
损伤果4.1%90.6%5.3%
病害果0.5%2.8%96.7%

可以看到,损伤果的召回率最低,有4.1%被错判成正常果。复盘这类样本,发现主要是浅表划痕——在不放大、不转动角度的条件下,肉眼都较难分辨,模型判断模糊可以理解。但如果这是一个面向实际分拣的系统,把损伤果放进正常果的筐里,用户满意度会受影响。后来我通过增加"浅表划痕"类样本的拍摄角度、把这类样本在训练集中做适度过采样,将损伤果召回率提升到92.5%。这个案例也说明了一个道理:在毕设答辩里,如果你能主动指出"模型在哪些类别上存在混淆、原因是什么、我做了哪些改进",这比单说一个98%的准确率更有说服力。

4.3 与基准方案的对比

为了体现CNN方案的有效性,我额外实现了两个对比方案。第一个是"颜色阈值+HOG特征+SVM"的传统方案,在相同三分类数据集上得到的准确率约82.3%,其中病害果被错分到正常果的比例高达9.1%,原因是霉斑早期阶段的颜色与正常果面非常接近,直方图特征无法捕捉到细微纹理差异。第二个是"直接用MobileNetV2微调"的方案,由于其参数量更大,在小数据集上过拟合更严重,测试集准确率反而只有93.4%。把我的轻量CNN与这两个方案对比,既说明了深度学习选型的有效性,也说明了在数据量有限的约束下,"更小的结构"可能比"更强的结构"更稳妥。

5. 踩坑复盘:从"测试集98%"到"实际场景翻车"

5.1 高光、阴影与包装袋引发的背景干扰

模型在测试集上跑出96.8%后,我信心满满地拿着手机拍桌面上的苹果做实时演示。结果发现,只要苹果放在深色桌布上,或者旁边出现白色包装袋,模型就频繁把正常果判成损伤果。排查后确认:训练图像中我统一使用了白色背景板,而深色桌布会让光影环境与训练分布产生明显偏移;白色包装袋则在边缘形成了"白色-红色相邻"的模式,被模型误认成了碰伤区域的"红色-白色交叠"结构。

解决方案是"域随机化":重新拍摄了150张不同背景(深色、格子布、牛皮纸袋)下的样本,并把这部分数据扩充进训练集。同时,在增强管线里增加了随机灰度扰动和随机亮度梯度模拟,让模型不再依赖单一背景亮度。这轮改进后,深色背景下的误判率从15%降到了3%以内。这个坑让我形成了一个习惯:训练时一定要想清楚部署场景的差异,而不是让模型闭门造车。

5.2 类别不平衡导致模型"偷懒"

早期为了控制总工作量,我的三类样本数量并不均匀:正常果约400张,损伤果约280张,病害果约220张。虽然差距不算极端,但已经足够让模型产生偏好。具体表现是:即使把训练集中所有正常果样本删掉一半,模型对正常果的输出置信度依然偏高。交叉熵损失天然倾向高频类别,模型发现"输出正常果"经常能撞对,就不愿意花更多代价去区分损伤果和病害果。

我采用两种手段修正。一是给损失函数传入类别权重,按各类样本数的倒数归一化,让数量少的类别获得更大的梯度比重;二是对数量少的类别做数据过采样,每个epoch随机多抽几次。两权相较,类别权重对最终指标的贡献更大,因为过采样只是重复已有样本,没有引入新信息。调整权重后,模型在病害果上的召回率提升了4个百分点。

5.3 训练不收敛与Loss震荡的处理过程

我遇到的另一个常见问题是:某一轮训练loss从0.78下降到0.21之后突然跳到0.56,然后反复震荡。一开始我怀疑是代码bug,后来逐一排查数据加载、损失函数、梯度更新这几块,确认都没问题。最后发现根因竟然出在预处理上:我在数据加载时不小心对同样的图片施加了不同的随机裁剪参数,导致同一个batch内的同类样本"看起来"差别很大;而且学习率设置1e-2偏大,梯度在被裁剪的位置剧烈变化。把学习率降回1e-3,并固定验证集的前处理方式后,曲线恢复了平滑收敛。这个排查过程让我记住了顺序:先查数据,再查框架,最后才怀疑优化器,不要一上来就调超参数。

5.4 损失降到很低但准确率卡住不动

另一个有意思的坑出现在训练中期。训练loss降到了0.1以下,准确率却卡在94%附近怎么都上不去。观察预测结果发现,模型把大量"带果梗的正常果"判成了损伤果。原因是早期拍摄时,带果梗的苹果数量太少;后期补拍的图像中,果梗在画面里呈现为一段褐色凸起,与磕碰伤颜色和形态非常相似。数据层面的问题只能靠数据解决:我专门增加了带果梗样本的标注小组,把"果梗区域"在裁剪时尽量保留完整,并补充了果梗在侧光下的照片。补齐之后准确率很快突破96%。这个案例给我的启发是,所谓的"模型准确率瓶颈",很多时候不是结构问题,而是数据没有覆盖到真实场景中的常见子模式。

6. 从Notebook到可演示系统:模型部署与界面封装

6.1 模型导出与单张图片推理

训练完成之后,需要把网络从PyTorch的state_dict中导出并封装成便于推理的接口。我写了推理脚本,加载权重后对单张图片执行与验证集一致的预处理,输出三类的置信度分数。

import torch from PIL import Image from torchvision import transforms class_names = ['正常果', '损伤果', '病害果'] device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = AppleQualityNet(num_classes=3) model.load_state_dict(torch.load('best_apple_model.pth', map_location=device)) model.to(device) model.eval() def predict_image(img_path): transform = 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]) ]) img = Image.open(img_path).convert('RGB') tensor = transform(img).unsqueeze(0).to(device) with torch.no_grad(): outputs = model(tensor) probs = torch.softmax(outputs, dim=1).squeeze(0) confidence, pred = torch.max(probs, dim=0) print(f'预测类别: {class_names[pred.item()]}, 置信度: {confidence.item():.2%}') return class_names[pred.item()], confidence.item() if __name__ == '__main__': predict_image('test_samples/example1.jpg')

这里有一个实际经验:在CPU上运行一张224×224图片,单次推理耗时约18至25毫秒;如果换成TensorRT优化或ONNX Runtime导出的模型,可压缩到5至8毫秒,足以支撑流水线上的快速判断需求。毕设演示阶段不必过度追求速度,但提前了解这些加速方案,答辩时能显著加分。

6.2 快速封装一个简单的GUI演示界面

为了让老师能够直接拖拽图片看效果,我基于OpenCV和标准库写了一个极简的Tkinter界面,没有引入繁重的Web框架。界面只有三个部分:左侧是原图预览区,右侧是"打开图片"按钮与三个类别的置信度进度条。点击"开始识别"后,读取图片、预处理、推理,并把置信度最高的类别显示在标题栏。核心代码结构如下:

import tkinter as tk from tkinter import filedialog from PIL import ImageTk class App: def __init__(self): self.window = tk.Tk() self.window.title('苹果品质识别系统') self.canvas = tk.Label(self.window) self.canvas.pack() self.btn = tk.Button(self.window, text='打开苹果图片', command=self.open_image) self.btn.pack() self.result = tk.Label(self.window, text='等待识别') self.result.pack() self.window.mainloop() def open_image(self): path = filedialog.askopenfilename(filetypes=[('Image files', '*.jpg *.png')]) if path: self.canvas.config(image=ImageTk.PhotoImage(Image.open(path).resize((280, 280)))) label, conf = predict_image(path) self.result.config(text=f'类别: {label} 置信度: {conf:.2%}')

如果你熟悉Flask,也可以在外面包一层HTTP接口,让老师用手机访问一个网页来上传照片。但就毕设而言,本地GUI已经足够直观,且实现成本低得多。需要注意Tkinter中图片对象的生命周期,别让PhotoImage被垃圾回收掉,否则界面上一片空白。这个问题我当时排查了半天,后来在Label上显式保留引用就解决了。

6.3 总结一条适合后续扩展的路径

做完这个项目后,我的体会是:分类模型只是解决方案的第一层。实际苹果分拣产线上面临的问题往往不是"一颗苹果一个框",而是"一整筐苹果里哪些要挑出来"。因此后续可以扩展的方向有三条:第一,把单目标分类升级为多目标检测,用YOLO或Faster R-CNN直接定位画面中的多个苹果;第二,增加更细粒度的分级标准,比如把"正常果"继续按直径、色泽分成一级果和二级果;第三,把推理端接入摄像头与机械臂,做成实时分拣联动系统。这些方向都建立在本课题的CNN分类基础之上,扩展成本不高,但能让毕设内容有更广阔的应用想象空间。

最后分享两个我在实际项目里反复用到的技巧。第一,训练前先把数据加载器跑一遍,确认输出的图像尺寸、batch、类别比例都没有问题,再进入训练循环。这个检查只花几分钟,却能避免好几个小时的无用训练。第二,保存模型时不要只存最后一代,而是记录验证集最佳的那一份,并用"epoch、val_loss"做文件名后缀。这样后期对比实验时,一眼就能找到最靠谱的权重。做技术项目,细节管理永远是性价比最高的投资。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进