基于ResNet的水果分类识别系统:从数据准备到模型部署实践

基于ResNet的水果分类识别系统:从数据准备到模型部署实践 简介这是一套基于深度残差网络ResNet的水果图像分类识别系统完整代码主要面向机器学习初学者、算法工程师以及需要快速搭建图像分类演示项目的开发者。项目以水果分类为示例完整打通了从TFRecord数据预处理、TensorFlow/Keras网络构建、模型训练到分类预测与评估的整个流程数据集与标注文件均可按需替换稍作调整就能迁移到其他图像分类场景。压缩包内共8780个文件主体为8767张水果图片样本另含3个分步骤的Jupyter Notebook数据预处理、加载并训练模型、模型评估及预测、若干训练日志与checkpoint模型权重文件以及配套脚本整体大小约588.11MB目录结构按功能拆分便于直接运行和二次开发。目前该资源已有6500人学习下载既能帮助理解ResNet的实际工程用法也可直接作为课程项目或毕设系统的参考实现省去从零搭建的繁琐过程。 前阵子朋友找我帮忙搭一个水果分类识别系统场景是超市自助结算台摄像头一拍顾客把水果往秤上一放系统自动报出品名和单价。我一开始想得很简单觉得挪个现成的图像分类模型过来 Fine-tune 一下就完事结果真上手才发现水果分类这个任务虽然看起来“入门”但坑一点都不少苹果和番茄在某些成熟度下真的很难分不同光照环境下同一种水果的颜色能偏到姥姥家去拍摄角度一变模型的表现就忽上忽下。最后整个链路走下来选定基于深度残差网络ResNet来做把数据准备、训练、评估和部署完整跑通这篇就把这段实操记录和代码分享出来给想从零搭图像分类系统的朋友一个能直接照着抄的参考。1. 为什么是 ResNet水果分类任务的痛点和模型选型逻辑1.1 水果识别其实比想象中难很多人觉得水果分类就是“拍一张照片然后丢给模型”好像随便找一个 CNN 都能做到 99% 的准确率。但真实场景远没有这么乐观。首先水果的类内差异比类间差异还大比如苹果这个大类里红富士、嘎啦、青苹果、蛇果外观看上去颜色差异非常明显有的品种甚至比苹果和梨之间的差异还大。其次成熟度对颜色和纹理的影响很大香蕉绿色和黄色阶段都叫香蕉芒果也是这些因素会让模型的决策边界变得非常不干净。还有一个很容易被忽略的问题数据集里的图片背景太干净了。网上很多公开数据集都是在纯色背景下拍的你拿去实验室跑能到 98%一旦放到真实柜台反光、叶子遮挡、不同光源色温一起涌进来精度说崩就崩。所以水果分类识别系统的核心难点不是“选一个多厉害的模型”而是怎么让模型在变化多端的输入里学到真正稳定的特征。1.2 ResNet 的残差机制到底解决了什么在 ResNet 出现之前大家发现一个反直觉的现象网络层数越深训练集上的 loss 反而可能越高这个现象被称为“退化”degradation。并不是过拟合导致的而是深层网络在反向传播时梯度流要穿过很多层信息衰减太严重导致浅层参数几乎得不到有效的更新模型实际上退化成了一截很浅的“有效网络”。ResNet 的核心解决思路是引入恒等映射也就是让网络学习的是一个残差 (F(x)H(x)-x)而不是直接学习目标映射 (H(x))。具体实现就是加了一条跳跃连接shortcut把输入 x 直接加到后面的卷积输出上[ y F(x, {W_i}) x ]这样一来梯度在反向传播时可以沿着跳跃连接“抄近路”回到浅层即使深层模块学不到东西至少可以把输入原封不动传下去梯度不会爆炸也不会消失。这也是为什么 ResNet-50、ResNet-101 这种几十上百层的网络在实际任务里能稳定训练起来。对水果分类这种输入图像信息丰富、但很多判别细节比如果柄形状、表皮纹理需要在浅层提取的任务来说残差结构带来的训练稳定性和特征保留能力都很有价值。1.3 和 VGG、MobileNet、EfficientNet 的取舍做选型时我当时列了一个对比表把几个常见备选过了一遍模型优势劣势对水果分类的匹配度VGG16结构简单容易理解参数多、费显存、训练慢低移动部署更是别想ResNet18/50收敛稳定预训练权重丰富比轻量级模型重但对服务器足够高精度/速度均衡MobileNetV3体积小、速度快适合端侧精度上限略低需要更多调参技巧中边缘设备可考虑EfficientNet理论精度上限高带复杂缩放系数迁移时容易踩坑中需要较大算力才能体现优势最后我选 ResNet-50 当主力原因是两个。第一torchvision 里提供了大量高质量的预训练权重迁移学习时不用从零开始训这对小规模数据集来说是决定性的。第二ResNet 的“下限”很稳哪怕学习率、数据增强策略不是最优只要基本流程对它也能收敛到一个可接受的水平这对项目交付很重要——你不想在调参上无底洞地耗下去。2. 数据集准备目录结构、标签划分与数据增强的详细过程2.1 数据集来源与目录组织方式数据我用的是公开数据集加自采数据混合的方式。公开数据用 Fruits-360 这种做底座它的类别覆盖较广但图片大多是纯色背景、中心构图不能直接照搬。我又从线下渠道补充了几百张真实货架拍摄的水果照片重点补那些容易混淆的品类。深度学习的目录组织看起来是小事但它直接影响后面的训练代码能不能跑通。我习惯按照 PyTorch ImageFolder 的标准结构来组织data/ train/ apple/ apple_001.jpg apple_002.jpg banana/ ... tomato/ ... val/ apple/ apple_101.jpg banana/ ... test/ ...ImageFolder 会自动按子目录名生成类别标签省去了手动写标签映射的麻烦。分类名建议用英文小写避免中文路径在某些环境下引起编码问题。文件命名也不要带随机空格和特殊符号训练脚本读路径时如果没做好转义经常在这里翻车。2.2 训练/验证/测试划分要保持“同源隔离”划分数据集的时候有一个大多数人很容易犯的错直接把所有图片打乱按比例随机分成 train 和 val。这样做的后果是同一个水果的正面照和侧面照可能一张进了训练集、一张进了验证集。验证集看起来准确率很高但实际是“半开卷考试”模型已经记住过这个个体的细节。正确做法是尽量按“水果个体”或者按“拍摄批次”来分。如果数据来自多个不同的拍摄场景那么可以按场景目录来划分保证训练集和验证集中的场景不重叠。这个细节重要到什么程度呢我实测发现按个体划分后验证集准确率从 99.1% 掉到了 96.8%看起来数字“变差了”但这才更贴近真实测试环境的表现。宁可训练时看到不那么完美的指标也不要自欺欺人地拿一个虚高的数字去做交付。2.3 数据增强不是锦上添花是保命手段一开始我用很简单的增强RandomResizedCrop 加 RandomHorizontalFlip。后来在真实光线下测试结果一下就露馅了。真实柜台的光线色温变化很大早晨的自然光和晚上的荧光灯光会让同一种水果呈现完全不同的色调。所以我加了 ColorJitter把亮度、对比度、饱和度、色相的扰动幅度分别拉开另外还加了 RandomRotation。需要注意的一点是像水果这种物体旋转超过一定角度比如 90 度后并不违背常识因为人眼也经常看到横着放的香蕉所以旋转增强可以放心加大。但如果是识别“上面是果柄、下面是底部”这种有方向性任务就要控制旋转范围不要做过头。增强策略也直接决定了模型能不能学到一个稳定、通用特征当时用的具体配置是from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees15), transforms.ColorJitter(brightness0.4, contrast0.4, saturation0.4, hue0.1), 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]) ])验证集不需要随机增强但仍然要做 Resize 和 Normalize因为模型训练时的输入分布是归一化过的如果验证时不归一化BatchNorm 层的统计量不会自适应调整指标反而会被误导。3. 手写训练代码从加载预训练权重到完整训练流程3.1 模型加载与全连接层的改造用 torchvision 加载预训练 ResNet-50 很简单但关键一步是把最后的全连接层换成我们自己的分类头。ResNet-50 最后的特征维度是 2048ResNet-18 则是 512改的时候不要想当然写死最好用model.fc.in_features动态获取import torch import torch.nn as nn from torchvision import models def build_resnet(num_classes, model_nameresnet50, pretrainedTrue): if model_name resnet18: weights models.ResNet18_Weights.DEFAULT if pretrained else None model models.resnet18(weightsweights) elif model_name resnet50: weights models.ResNet50_Weights.DEFAULT if pretrained else None model models.resnet50(weightsweights) else: raise ValueError(fUnsupported model: {model_name}) in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, 512), nn.ReLU(inplaceTrue), nn.Linear(512, num_classes) ) return model这里加了两层全连接而不是直接用一层 Softmax好处是在分类头里引入一点可学习的非线性映射让模型能更好地适配水果这种大类内形态差异大的数据。Dropout 放在全连接前是为了减轻最后一个卷积层特征直接过拟合到训练集的问题。预训练权重的num_classes是 1000不影响我们直接替换model.fc因为前面卷积层输出的特征维度没有变化。3.2 损失函数、优化器与学习率调度分类任务首选CrossEntropyLoss它内部已经包含了 Softmax 层不需要再在网络末端手动加 Softmax否则训练时会出现数值不稳定和梯度问题。关于优化器我用的是SGD加动量而不是很多人默认的Adam。不是说 Adam 不行而是对迁移学习来说SGD 在 Fine-tune 阶段通常能收敛到更平滑的局部最优效果往往更好。Adam 收敛快但容易走到比较“尖锐”的极值点泛化能力会略差一些。不过我也不会把话说死如果你发现 SGD 调不动换 AdamW 跑两轮看趋势也不是不行。学习率调度我用的是ReduceLROnPlateau它会在验证集 loss 停止下降时自动把学习率乘一个系数照样是按需降低学习率比固定步长衰减更省心optimizer torch.optim.SGD(model.parameters(), lr0.001, momentum0.9, weight_decay1e-4) criterion nn.CrossEntropyLoss() scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3, verboseTrue )初始学习率 0.001 对预训练模型 Fine-tune 来说是一个比较稳妥的起点。如果是从零训练一个随机初始化 ResNet这个学习率会偏小收敛太慢但迁移学习场景下特征提取层已经学到了通用特征不需要太大的更新步长。3.3 训练循环与早停机制训练循环我习惯分成train_one_epoch和validate两个函数便于单独打印指标。另外早停也很关键不要让模型无限训下去否则后面必然过拟合。早停的判断条件用验证集 loss而不是准确率因为 loss 对模型置信度的变化更敏感def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (outputs.argmax(dim1) labels).sum().item() total images.size(0) return total_loss / total, correct / total验证函数也一样但要用torch.no_grad()且模型切到eval()模式。这里有一个容易被忽略的点model.eval()不只是关闭 Dropout还会让 BatchNorm 使用训练阶段统计好的全局均值和方法如果不切 evalBatchNorm 在推理阶段仍然会被当前 batch 的数据影响结果非常不稳定。早停实现起来非常简单我用一个耐心计数器如果验证集 loss 连续 5 个 epoch 都没有刷新历史最低就保存当前最低点的模型权重并退出best_loss float(inf) patience 5 bad_epochs 0 for epoch in range(epochs): train_loss, train_acc train_one_epoch(...) val_loss, val_acc validate(...) scheduler.step(val_loss) if val_loss best_loss: best_loss val_loss bad_epochs 0 torch.save(model.state_dict(), best_fruit_resnet.pth) print(fepoch {epoch}: saved best model, val_acc{val_acc:.4f}) else: bad_epochs 1 if bad_epochs patience: print(early stop triggered) break4. 实测踩坑记录loss不降、过拟合、精度波动的原因和修复4.1 训练集 loss 高居不下先排查这三件事我第一次跑这个水果分类项目的时候训练集 loss 卡在 2.3 左右怎么都降不下去准确率大概只有 20%跟随机猜测差不多。当时我一度怀疑是模型没选对后来按顺序排查发现是三个低级问题叠加。第一个是迁移学习时忘了冻结部分层初始学习率设置太大或者太小。这里有个经验法则全模型 Fine-tune 用 1e-3只训练分类头用 1e-3 到 1e-2 也行但如果同时解锁所有层学习率超过了模型能承受的范围loss 会在一个很大的值附近震荡永远落不下来。第二个是数据标准化配置没对齐。PyTorch 的 ResNet 预训练权重是基于 ImageNet 的 mean/std 做归一化的如果训练时忘了这一步或者用了完全不一样的归一化参数输入分布和预训练特征分布完全对不上模型就只能从头硬学效果自然差。第三个是分类头输出维度写错。比如数据集有 6 个类别但全连接层改成了 10 个输出训练的时候数据标签只有 0~5模型相当于在学一个含空类别的分布loss 也会一直卡着。这类问题最好用一小批数据跑通 forward 再正式训练减少无用功。4.2 验证集指标和训练集差距太大优先降低过拟合当训练集准确率能到 99.8%验证集只有 92% 的时候不需要犹豫这就是过拟合。水果数据集的类别多但同类别里相似样本也多模型很容易走捷径比如根据光斑位置、背景颜色来分类而不是真正学习水果本身。我在这个阶段用的最有效的手段有三个。第一是把数据增强的分支加粗特别是 ColorJitter 的幅度和 RandomErasing。RandomErasing 是随机擦掉图像中的一个小方块强迫模型不要只依赖某一个局部区域这一招对让模型关注水果整体形态帮助很大。第二是提高 Dropout 比例我在全连接层前把 dropout 从 0.2 提到了 0.5。第三是冻结浅层卷积层只训练深层的残差块和全连接层。预训练模型的前几层学到的是边缘、纹理等通用特征这些特征在水果任务上和 ImageNet 是通用的不需要费劲重新微调冻结它们可以显著降低过拟合风险。4.3 每个 epoch 精度忽高忽低的问题如果没有过拟合但每个 epoch 的验证准确率一会 98%一会 88%跳来跳去不稳这种情况通常和 batch size 有关系。batch size 太小的时候每个 batch 的样本分布差异很大梯度方向剧烈震荡模型参数就在最优解附近来回跳动。我当时用 32 的 batch size 训练 120 类水果后面把 batch size 调整到 64同时配合数据加载器的num_workers调优指标曲线立刻平滑了不少。另外还要确认验证集是不是太小了。如果验证集只有一百多张图那准确率标准差会非常大单看每一次的 validation acc 没有意义至少要结合 validation loss 和连续 3 个 epoch 的均值来评估。5. 模型评估与落地部署从混淆矩阵到可调用接口5.1 用混淆矩阵看错误到底出在哪准确率只是门面真正决定系统能不能用的是错误类型。我用 sklearn 的confusion_matrix和classification_report看了一眼发现问题主要集中在“苹果 vs 番茄”和“青苹果 vs 梨”这两组类别上。这一点都不意外从人类的视角看这几组在特定成熟度下确实像但既然要交付就得想办法解决。我的处理方式是给这些易混类别单独补充更多训练样本并做一个针对性增强把样本做水平翻转时还额外叠加随机色偏。另外在推理阶段可以引入一个“置信度阈值”如果模型对 top-1 的置信度低于比如 0.85系统就直接返回“无法识别”让人工介入而不是硬生生给出一个错误答案。这一点在零售场景里尤其重要识别错了的代价远大于识别不出来。5.2 导出 ONNX 并封装推理函数训练结束后我通常会把 PyTorch 模型导出成 ONNX再用 ONNX Runtime 来跑推理。原因很简单PyTorch 的部署环境重、依赖多而 ONNX Runtime 轻量、跨平台CPU 上也能有不错的推理速度。导出过程先写一个假的输入 tensor确定输入尺寸是 (1, 3, 224, 224)然后调用torch.onnx.exportmodel.load_state_dict(torch.load(best_fruit_resnet.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, fruit_resnet50.onnx, input_names[input], output_names[output], opset_version12, dynamic_axes{input: {0: batch}, output: {0: batch}} )推理端使用 ONNX Runtimeimport onnxruntime as ort import numpy as np from PIL import Image import torchvision.transforms as transforms sess ort.InferenceSession(fruit_resnet50.onnx, providers[CPUExecutionProvider]) input_name sess.get_inputs()[0].name def predict(image_path): img Image.open(image_path).convert(RGB) img val_transform(img).unsqueeze(0).numpy() outputs sess.run(None, {input_name: img})[0] pred np.argmax(outputs, axis1)[0] confidence np.exp(outputs) / np.exp(outputs).sum(axis1)[0] return pred, float(np.max(confidence))这里有个经验导出 ONNX 之前一定确保分类头里没有用到某些只在训练期存在的行为比如 Dropout。导出时模型正处于 eval 模式Dropout 会直接失效所以导出的推理图和训练图的差别不大。另外dynamic_axes建议还是加上因为部署时可能要支持一次多张图识别。5.3 推理速度与工程化经验模型最终部署在一台普通 x86 服务器上CPU 跑 ResNet-50 的 ONNX 单张推理大概 60~80ms这个速度对自助结算台来说完全够用。如果后续要换到 Jetson 这类边缘设备可以直接把一个更轻的 ResNet-18 或 MobileNetV3 重新训练导出权重文件小了快一半CPU 上的推理时间还能再压缩到 30ms 以内。工程化上还有一个值得注意的细节把图片缩放到模型输入尺寸时的插值方式要和训练一致。我训练时用的是双线性插值 (Resize默认就是双线性)如果部署时用 OpenCV 的cv2.INTER_AREA去缩放输入分布会略有偏移。这个问题在单张图上不一定看得出来但在批量测试时会造成 0.5%~1% 的精度抖动。还有一个建议是把推理接口封装成独立的 HTTP API输入图片路径或 base64 字符串输出类别名和置信度。这样前端、嵌入式端、甚至微信小程序都能通过统一接口调用不用在每次业务迭代时都重新写一遍推理逻辑。整个系统跑下来最深的体会是水果分类识别项目真正的难度从来不在模型本身而在于数据分布设计、过拟合控制和部署环节的细节把控。ResNet 本身是个“稳妥方案”它的价值在于能让你把精力放到真正影响交付质量的环节上去而不是整天和训练不收敛死磕。本文还有配套的精品资源点击获取