
简介面向苹果腐烂识别的实际检测需求这份基于pythonpytorch的深度学习资源包贴合一分类任务从数据准备到界面落地的完整链路适合初学图像分类的开发者及农产品质检方向的学习者借鉴。压缩包共601个文件其中以531个jpg、63个png图片为主体涵盖新鲜苹果、腐烂苹果及多种增强角度样本另含3个py脚本和3个txt说明文档整体大小64.41MB目录结构清晰便于直接开展训练与测试。代码内置三项核心功能01脚本读取数据集并按类别生成训练验证标签02脚本完成模型训练并保存本地权重03脚本基于pyqt搭建交互界面可对苹果图片进行腐烂识别。预处理阶段通过灰色边缘补边、随机旋转和水平翻转完成数据扩增能有效提升模型泛化能力。资源包附带requirement环境配置说明并给出完整运行顺序已有129人学习适合希望快速跑通图像分类项目、免去自行收集标注数据的中初级学习者。1. 苹果腐烂识别是什么用图片数据集喂出一个能上线的分类器分拣线上最怕的不是烂果而是烂得不明显的果。我做过一个类似的项目用python深度学习对苹果是否腐烂识别输入一张苹果照片输出“正常”或“腐烂”。这个项目真正值钱的不是网络结构而是和你手里那份图片数据集配套的数据清洗、类别平衡和落地验证。适合两类人一类是刚跑通深度学习环境、想找个真实场景练手的开发者另一类是已经在做农产品质检自动化、被现场漏检率困扰的工程师。下文从数据集怎么拆开始讲到训练参数和评估指标再谈部署和排错每一步都能直接复现坑也挑常见的先排掉。2. 把苹果腐烂识别拆成二分类图片数据集的组织与预处理2.1 分类还是检测先看图片里是一个苹果还是多个苹果接到任务后我做的第一件事不是选模型而是确认任务类型。如果你解压数据集以后绝大多数照片是单个苹果居中、背景是传送带或纸箱那“苹果腐烂识别”就是一个标准二分类整张图判断好或坏。分类网络输出两个类别标注成本低训练速度快落地也简单。少数照片是多个果子挤在一起或者腐烂只出现在果蒂旁边一小块。这时候分类网络也能跑但会把整棵苹果的语义压缩成一个标签准确率天花板很低。常见做法有两种一种是把原始大图切成许多小图再做分类另一种是切到目标检测先框出每个苹果再分类。我一般这样拍板方案输入输出标注成本适合场景CNN二分类一张图 - good/rot每图一个标签单果分拣、传送带逐个落料YOLO目标检测一张图 - 检测框类别每果一个框多果混放、装箱前簇状质检判断方法很简单随机抽50张图片如果超过80%的图都是“一个苹果占画面主体”直接按二分类走否则就按目标检测准备数据。标题里的“是否腐烂识别”是典型的二分类命名下面所有代码都围绕这个方向展开。2.2 从压缩包到训练目录图片数据集拆分脚本拿到“图片数据集.zip”第一件事是先建立一个规范的目录结构。train/val/test三个目录每个下面再挂类别子目录Keras 的flow_from_directory和 PyTorch 的ImageFolder都认这套结构。我的划分比例是 70% 训练、20% 验证、10% 测试。测试集一旦留出来就不参与任何调参否则最后的评估数字只是自我安慰。# organize.py import pathlib import random import shutil random.seed(42) source_root pathlib.Path(raw_images) # 解压后图片所在目录 out_root pathlib.Path(dataset) splits [train, val, test] classes [good, rot] image_suffix {.jpg, .jpeg, .png, .bmp} for split in splits: for cls in classes: (out_root / split / cls).mkdir(parentsTrue, exist_okTrue)这段代码先创建六个目录train/good、train/rot、val/good、val/rot、test/good、test/rot。目录名我用英文rot代表腐烂。中文标签不是不能用但在命令行和脚本里容易出编码问题建议训练时再做中文映射。下一步是每个类别内先打乱顺序再按比例切三份。for cls in classes: files [ p for p in (source_root / cls).rglob(*) if p.suffix.lower() in image_suffix ] random.shuffle(files) n_all len(files) n_train int(n_all * 0.7) n_val int(n_all * 0.2) for i, src in enumerate(files): if i n_train: split train elif i n_train n_val: split val else: split test shutil.copy2(src, out_root / split / cls / src.name) print(f{cls}: {n_all} 张train{n_train}val{n_val}test{n_all - n_train - n_val})这里最容易被忽略的是random.seed(42)。如果删掉这行每次运行脚本划分都会变下次重新训练时验证集和训练集会互相污染导致评估结果虚高。还有一点我用copy2而不是move保留原始压缩包那份图片作为“后悔药”。等整个流程跑通、效果确认之后再删原始文件不迟。2.3 ImageDataGenerator参数设置数据增强怎么做才不改变腐烂语义图片数据集规模通常不大每类几百张就算不错了。直接硬训深层网络几乎必过拟合所以要靠数据增强让模型每次看到略微不同的苹果照片。from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen ImageDataGenerator( rescale1.0 / 255.0, rotation_range15, width_shift_range0.05, height_shift_range0.05, horizontal_flipTrue, brightness_range[0.7, 1.3], fill_modenearest, ) val_datagen ImageDataGenerator(rescale1.0 / 255.0) train_generator train_datagen.flow_from_directory( dataset/train, target_size(224, 224), batch_size32, class_modecategorical, classes[good, rot], shuffleTrue, ) val_generator val_datagen.flow_from_directory( dataset/val, target_size(224, 224), batch_size32, class_modecategorical, classes[good, rot], shuffleFalse, )代码逻辑不复杂但有几个参数是拿教训换来的。classes[good, rot]必须显式指定。flow_from_directory默认按目录名字母序排类别如果你哪天把目录改成中文类别索引顺序就会变训练和推理一旦对不上模型输出就乱了。brightness_range[0.7, 1.3]是我在这个场景里坚持保留的。分拣线的照明强度经常变上午自然光和下午冷白LED拍出来的苹果色差很大这个参数可以把亮度扰动范围拉宽。rotation_range15不要给太大苹果在传送带上姿态相对固定旋转范围过大会把果柄拉出奇怪的形变反而干扰语义。fill_modenearest用相邻像素填充旋转后的空白区而不是填纯黑防止模型学到“黑边等于腐烂”这种假规则。验证集和测试集只做rescale不做任何增强。这是一条纪律数据增强只用于训练过程衡量模型真实水平时要用原始分布。2.4 数据量底线与类别比例先算账再训模型动手训练之前建议先统计一下图片数据集里每类的数量。我用一个很简单的规则每类少于 80 张时Transfer Learning 加增强也很难稳少于 30 张时基本只能碰运气。若果rot类特别少常见做法是优先补充现场实际腐烂的样本而不是到网上找一堆风格不统一的图风格差异比样本量更致命。类别比例也需要看。两类数量相差 5 倍以内普通训练问题不大超过 10 倍就要在损失函数里加类别权重这个在第 5 章避坑里会专门说。先记住一点腐烂识别这类任务里腐烂样本是“少数派但也最该被识别出来”的类别。3. 用Python训练苹果腐烂识别模型MobileNetV2迁移学习代码3.1 选型理由Memory、速度和精度的平衡这个任务我默认选 MobileNetV2不选 ResNet50也不选 VGG16。原因很实际苹果腐烂识别要部署的地方大概率不是 A100而是一个工控机的小显卡甚至是一个 ARM 开发板。MobileNetV2 在 224x224 输入下CPU 单张推理通常几十毫秒级别显存占用也低4GB 显存就能训。ResNet50 准确率上限略高但推理速度慢、模型文件大优势和代价不成正比。只有在统计上确认腐烂斑点非常细小、224 分辨率下肉眼都难分辨时才值得换更高分辨率的输入例如 320 或 512同时把模型切到 ResNet50。这个决策在后处理环节很难补回来一开始就要想清楚。3.2 最小训练代码冻结主干训练分类头基于 python 深度学习训练模型的代码我一般用 TensorFlow/Keras结构直观改参方便。# train_rot.py import tensorflow as tf from tensorflow.keras import layers, Model from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau base_model MobileNetV2( input_shape(224, 224, 3), include_topFalse, weightsimagenet, ) base_model.trainable False inputs tf.keras.Input(shape(224, 224, 3)) x base_model(inputs, trainingFalse) x layers.GlobalAveragePooling2D()(x) x layers.Dropout(0.3)(x) outputs layers.Dense(2, activationsoftmax)(x) model Model(inputs, outputs) model.compile( optimizerAdam(learning_rate0.001), losscategorical_crossentropy, metrics[accuracy], )这段代码的思路是把 MobileNetV2 在 ImageNet 上训好的特征提取器先冻结只训练新增的分类头。trainingFalse很关键它让 BatchNorm 层在冻结阶段使用推理统计量避免小批次下统计量震荡。如果你漏了它训练曲线会忽高忽低验证集准确率像心电图。接着接数据生成器并开始训练。train_generator train_datagen.flow_from_directory( dataset/train, target_size(224, 224), batch_size32, class_modecategorical, classes[good, rot], shuffleTrue, ) val_generator val_datagen.flow_from_directory( dataset/val, target_size(224, 224), batch_size32, class_modecategorical, classes[good, rot], shuffleFalse, ) steps_per_epoch max(1, train_generator.samples // train_generator.batch_size) validation_steps max(1, val_generator.samples // val_generator.batch_size) model.fit( train_generator, steps_per_epochsteps_per_epoch, epochs30, validation_dataval_generator, validation_stepsvalidation_steps, callbacks[ EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue), ModelCheckpoint(apple_rot.keras, monitorval_accuracy, save_best_onlyTrue), ReduceLROnPlateau(monitorval_loss, factor0.2, patience3, min_lr1e-6), ], )三个回调各有用途。EarlyStopping防止后期过拟合patience5 表示连续 5 个 epoch 验证损失不下降就停。ModelCheckpoint只在验证准确率更高时覆盖保存模型文件这个文件就是后续部署的种子。ReduceLROnPlateau在验证损失走平时把学习率乘以 0.2把收敛过程拉得更细致。如果你用的是 TensorFlow 2.13 之前的版本模型后缀建议改成.h5.keras是较新格式。判断标志就是保存后load_model报不报错报错就换格式。3.3 微调阶段解冻一部分主干用小学习率继续训只训练分类头的模型往往准确率在 95% 左右就卡住。这个阶段要把主干网络的深层部分解冻让它针对苹果的纹理做微调。base_model.trainable True freeze_ratio 0.7 for layer in base_model.layers[: int(len(base_model.layers) * freeze_ratio)]: layer.trainable False model.compile( optimizerAdam(learning_rate1e-5), losscategorical_crossentropy, metrics[accuracy], ) model.fit( train_generator, steps_per_epochsteps_per_epoch, epochs20, validation_dataval_generator, validation_stepsvalidation_steps, callbacks[ EarlyStopping(monitorval_loss, patience4, restore_best_weightsTrue), ModelCheckpoint(apple_rot_finetune.keras, monitorval_accuracy, save_best_onlyTrue), ], )微调的学习率从 1e-3 降到 1e-5这是个硬规矩。主干网络的权重已经收敛过一次用大学习率会把预训练特征冲乱。freeze_ratio0.7的意思是前 70% 的层仍冻结只更新靠近输出端的深层特征这些特征和苹果表面纹理更相关。如果你数据集每类只有百来张冻结比例可以提到 0.8如果每类有上千张可以降到 0.5 让更多层参与更新。冻结比例调整后你拿一组验证图片跑一次看看腐烂类别的识别结果是否值得花更多训练时间。这一阶段跑完模型质量基本定型了。3.4 必调参数速查表参数我的常用值调整方向input_size224x224烂斑细小就升 320但推理时间变长batch_size32显存不足降到 8/16但 BatchNorm 更敏感初始学习率1e-3数据很少时降到 5e-4微调学习率1e-5微调后损失反弹就再降一半dropout0.3过拟合时升到 0.5freeze_ratio0.7数据多时降到 0.5数据少时升到 0.85epochs3020以 EarlyStopping 实际停止为准4. 评估和部署苹果腐烂识别模型用召回率而不是准确率做验收4.1 混淆矩阵看两种错误漏检和误检的代价完全不同训练结束后第一件事不是看准确率而是看混淆矩阵。准确率把好苹果和烂苹果的预测结果混在一起根本看不出业务风险。from sklearn.metrics import confusion_matrix, classification_report import numpy as np val_generator.reset() preds model.predict(val_generator, stepsvalidation_steps) pred_labels np.argmax(preds, axis1) true_labels val_generator.classes[: len(pred_labels)] print(confusion_matrix(true_labels, pred_labels)) print(classification_report(true_labels, pred_labels, target_names[good, rot]))这段代码里val_generator.classes是真实标签注意截取长度要和预测数量一致因为validation_steps可能截断最后一批样本。输出里会看到四组数字good-good、good-rot、rot-good、rot-rot。在苹果分拣场景中rot-good是哪一种错误这是漏检烂苹果当成了好苹果流到市场后产生客诉代价最大。good-rot是误检好苹果被当成烂苹果扔掉或回流代价是成本损耗。两种错误不是一回事我一般要求“腐烂召回率”不低于 95%也就是 100 个烂苹果至少识别出 95 个宁可多扔 5 个好的也不能放走一个烂的。很多项目翻车就是因为只盯 overall accuracy。两分类里如果好苹果占 90%你把全部图片预测成好苹果准确率也有 90%但烂苹果的召回率是 0这模型等于废了。所以验收指标只用classification_report里的rot类别召回率必要时再看 AUC。4.2 导出TFLite模型把训练好的网络塞进手机和边缘设备模型在电脑上跑得再快也没用现场部署通常在 Android 设备、树莓派或工控机上。TensorFlow 模型最常见的落地方式是转成 TFLite体积小量化后推理也快。converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() with open(apple_rot.tflite, wb) as f: f.write(tflite_model)代码逻辑是把 Keras 模型转成 TFLite 格式optimizations开启默认量化。量化会把部分浮点运算转成整数运算模型体积明显变小推理变快代价是准确率通常掉 0.5 到 2 个百分点。如果现场设备是 ARM CPU量化收益很大如果设备自带 GPU 加速器可以不加优化直接转。部署端读取 TFLite 的常见方式是用它的 Interpreter 接口。注意输入张量和输出张量的 shape训练时是(batch, 224, 224, 3)推理时 batch 维通常为 1。还要做和训练时一致的rescale1/255这一步漏掉的话整张输入分布就偏了模型输出基本不可信。4.3 推理验证脚本一张烂苹果图的端到端测试部署前我会写一个最简推理脚本手动传入没在数据集出现过的图片确认整个链路没断。import tensorflow as tf import numpy as np from tensorflow.keras.preprocessing import image def load_and_preprocess(path, target_size(224, 224)): img image.load_img(path, target_sizetarget_size) arr image.img_to_array(img) arr arr / 255.0 arr np.expand_dims(arr, axis0) return arr img load_and_preprocess(test_sample.jpg) interpreter tf.lite.Interpreter(model_pathapple_rot.tflite) interpreter.allocate_tensors() interpreter.set_tensor(interpreter.get_input_details()[0][index], img.astype(np.float32)) interpreter.invoke() output interpreter.get_tensor(interpreter.get_output_details()[0][index]) print(output, np.argmax(output))这段脚本模拟了真实部署的全部步骤图片加载、缩放、归一化、增加 batch 维、用 Interpreter 推理。如果一个新图片预测结果和视觉判断明显不一致优先检查是预处理不一致还是模型根本没见过类似角度。5. 苹果腐烂识别避坑记录标注、光照和过拟合的5个典型问题5.1 现象训练准确率95%现场一测乱套我一度以为分类器已经验收通过结果把同一个模型搬到分拣线的冷白 LED 灯下准确率掉了 10 个百分点。原因是训练图片大多是自然光拍摄现场灯光色温和亮度完全不同。模型学到了训练集的光照环境而不是腐烂本身。解决办法是把现场采集的短曝光样例补充到训练集哪怕只有几十张也有效。同时在ImageDataGenerator里把brightness_range调宽再加一点channel_shift_range0.1让模型对色偏不那么敏感。5.2 现象红苹果高光区域被识别成腐烂红苹果表面有一层天然反光在强光下会出现白色高光斑块边缘颜色突变模型很容易把它当成霉斑。最初我还以为是数据不够后来发现高光样本在训练集里几乎没出现过。解决方法是数据增强里加高斯模糊和亮度扰动让模型见过更多“半反光”形态。高光区域的真实标签必须是 good这类样本不要删掉删除会让训练分布和实际分布差得更远。5.3 现象同一个苹果两个人标注出两个类别实习标注员和正式标注员对“早期腐烂”理解不一致。果蒂附近的小褐斑有人觉得是正常氧化有人觉得是腐烂起点。这种现象大概率出现在腐烂面积很小的时候。我最后写了明确标注规范可见褐色或黑色湿斑、霉斑、软烂区域一律标为rot果梗自然氧化和机械碰伤不一定是腐烂单独标good但留出注释。规范出来后让两个人各标 50 张算标注一致率低于 90% 就继续校准。5.4 现象模型把所有苹果都预测成“好”数据集里 good 有 2000 张rot 只有 150 张模型发现全预测成 good 的损失最小结果烂苹果的召回率接近 0。这是类别不平衡不是网络问题。常见做法是给rot类更大的权重在 Keras 里可以这样实现class_weight { 0: 1.0, 1: 2000.0 / 150.0, } history model.fit( train_generator, class_weightclass_weight, )也就是让少数类在计算损失时按比例放大。更稳妥的方案是把 rot 类过采样到 good 类的一半左右再用class_weight做微调。千万不要靠单纯重复复制 rot 图片复制的样本不会带来新信息还会让模型在验证时对重复图过拟合。5.5 现象训练时正常部署后输出数字明显异常最常见原因是推理链路里的预处理和训练不一致。有人训练用rescale1/255部署时忘记做了有人训练用 224 分辨率部署时却直接传原图。解决思路是写一个统一的 preprocessing 函数部署端和后端脚本都调用它。函数里固定三件事resize、np.divide归一化、expand_dims增加 batch 维。以后任何人接手都不会再犯这个错。6. 进阶技巧用类激活热力图验证腐烂识别到底在看什么模型验收通过不代表万事大吉我习惯再用 Grad-CAM 把模型“看到”的区域可视化出来。一张热力图能告诉你模型判断烂果时到底在盯着腐烂斑块还是在盯着果柄、包装纸或某个意外的背景纹理。import tensorflow as tf def make_heatmap(model, img_array, layer_nameout_relu): grad_model tf.keras.models.Model( inputsmodel.input, outputs[model.get_layer(layer_name).output, model.output], ) with tf.GradientTape() as tape: conv_out, preds grad_model(img_array) class_idx tf.argmax(preds[0]) loss preds[0][class_idx] grads tape.gradient(loss, conv_out)[0] pooled_grads tf.reduce_mean(grads, axis(0, 1)) heatmap conv_out[0] pooled_grads[..., tf.newaxis] heatmap tf.squeeze(heatmap) heatmap tf.maximum(heatmap, 0) / tf.math.reduce_max(heatmap) return heatmap.numpy()这段代码的逻辑是让模型对某张图片做一次前向拿到预测类别对应的损失再反向求出特征图对损失的梯度用梯度加权特征图得到热力图。layer_nameout_relu是 MobileNetV2 最后一个卷积层常见的名字如果你用其他网络先跑model.summary()找到最后那个卷积层名再替换。我每处理完一版模型会挑一批肉眼最容易翻车的样本——半烂半好、果梗明显、高光反光——逐张看热力图。热力图集中在腐烂区域边上说明模型确实学到了关键语义热力图飘在外围背景说明模型被数据分布骗了得回到数据增强和标注规范环节补课。有一次我盯着热力图发现模型判断腐烂的根据是苹果表面的反光条纹而不是腐烂区域本身。那批数据是湿布擦过的苹果反光条纹在全图里太显眼了。后来我把这些图从训练集暂时拿掉、补了更多自然哑光表面的腐烂图热力图才回到腐烂斑块上。从那以后“每次训练结束先看三张热力图”成了我的固定习惯。如果你已经能把热力图看明白下一步再把任务从二分类升级到腐烂区域检测也不迟。分类网络啥都能做但你能信任它到几成这次实验会告诉你答案。希望帮到你。本文还有配套的精品资源点击获取