用Python和迁移学习实现水果图像识别:从零到可部署的分类器
简介一套基于Python实现的水果图像识别程序面向图像识别初学者与进阶者适合作为毕业设计、课程设计或工程实训的完整参考。资源以一份包含苹果、香蕉、橙子等常见水果的完整图像数据集和识别代码为核心压缩包共607个文件、大小28.62MB。其中约300张JPG图片配有300个XML标注文件可直接作为目标检测或分类任务的训练/验证素材另有5个Python脚本负责图片读取、图像预处理、特征提取与分类识别1个Markdown文档说明了项目结构便于快速定位关键代码。同时代码定位为参考资料不能直接照搬但读者可借助完整数据集和脚本理解图像识别流程并自行调试、更换或增加水果类别。该项目目前已有229人浏览学习适合需要参考完整项目以积累实战经验的学习者。1. 从“认水果”这个需求到一份能跑的 Python 程序基于 Python 实现的水果图像识别程序听起来像是要从零搭建卷积神经网络但最靠谱的落地方式其实是迁移学习拿一个在 ImageNet 上预训练好的视觉模型换掉它的分类头只用几百张水果图片微调后就能干活。这里把这条路径完整走一遍从环境安装、数据集整理到训练和推理脚本每个步骤都有可直接复制运行的代码。适合写过 Python 脚本、但没正经碰过深度学习的开发者就算只有 CPU 机器也能在二十分钟内把一次训练跑完。你会得到的不是一个玩具 demo而是一套能部署到真实场景的水果分类器外加后面列出的七个我踩过的坑。2. 图像识别算法选型传统视觉为什么做不好水果分类2.1 颜色直方图和 SVM 方案的边界深度学习普及之前最常见的水果识别做法是用 OpenCV 提取 HSV 颜色空间里的颜色直方图配合 SVM 分类。颜色特征对水果确实有效苹果偏红、香蕉偏黄、橙子偏橙实验台上单目标、纯背景的数据能跑到 90% 以上。这个数字很容易误导人因为测试集和真实场景的分布根本不是一回事。光照是最先翻车的地方。同一个苹果朝阳面和背光面拍出来像素值能差 30% 以上颜色直方图直接对不上。其次是遮挡和堆叠一袋子水果里露出来的往往是半个苹果、一根被压住的香蕉颜色阈值分割会把纸箱、塑料袋一起划进前景。更麻烦的是品种内差异青苹果和红苹果不是同一个颜色单靠颜色阈值根本分不开青苹果和青梨。import cv2 img cv2.imread(apple.jpg) hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 红色范围只适合正对光源的摆拍 mask cv2.inRange(hsv, (0, 100, 100), (10, 255, 255))这段 HSV 阈值经典的代价就是你得为每个品种反复调上下限调完换一个灯泡又要重来。所以传统方案只适合“单一背景、固定光源、摆拍”的工业分拣线不适合“随手拍一张照片让程序认出来是什么水果”。新项目不建议再从 HSV 阈值起步除非你的输入图片从拍摄条件到背景都完全受控。2.2 深度学习图像识别与迁移学习的取舍深度学习图像识别的核心优势是模型能从像素里自动学出分层特征浅层学边缘、纹理深层学“果柄”“脐部”“轮廓”这些语义部件。水果分类不需要从头训练一个 CNN因为 ImageNet 上预训练好的权重已经把这些底层特征学完了我们要做的是丢掉最顶层的 1000 类分类头换成“苹果、香蕉、橙子”三个神经元让模型把已有的纹理和形状知识重新组合成水果类别。迁移学习的工程决策通常看三个东西训练数据量、可用算力和部署目标。数据量少几百张级别就冻结全部基座参数只训新的分类层数据量够几千张级别可以解冻最后十几层做微调让权重适应水果场景算力充足且追求极限准确率再考虑 ResNet50 或 EfficientNet。模型参数量约CPU 单张推理适合场景MobileNetV23.5M快移动端、CPU 机器本项目首选ResNet5025M中等服务器追求高准确率EfficientNetB05.3M中等数据量适中且想均衡准确率和体积一个反直觉的点小数据集上 ResNet50 不一定比 MobileNetV2 好。它的参数量大冻结后只训顶层容易过拟合解冻微调又需要更多数据支撑。MobileNetV2 输入尺寸 224×224推理快是 CPU 环境下最稳妥的起点。如果分类对象是芒果和木瓜这种细粒度差异再考虑解冻部分层做微调而不是一上来就换大模型。2.3 验证指标准确率之外还要看什么分类任务大家第一眼看准确率但对一个“水果识别程序”来说准确率只在测试集分布内有意义。至少要额外关注混淆矩阵和置信度阈值两件事。混淆矩阵能告诉你哪些水果互相打架。我跑过一个三类模型苹果和梨长得接近混淆矩阵里“苹果预测成梨”的格子特别高这时候单纯调模型结构没用要么补数据要么把相似类别合并成“仁果类”输出。准确率是单一数字混淆矩阵才是指引数据方向的图纸。置信度阈值决定了程序的“拒识”能力。模型最后输出的是每个类别的概率如果最高类别概率只有 0.3 几说明模型自己都没把握程序应该返回“无法识别”而不是硬给一个答案。推理脚本里加一个min_conf参数默认 0.5低于阈值输出 unknown比强行分类对真实业务友好得多。3. 用 TensorFlow 2 搭建水果识别环境依赖与数据集准备3.1 Python 环境和依赖安装如果你的机器上已经有 Python 3.8 或更高版本下面这套是依赖最少、踩坑最少的路径。先用 venv 建一个隔离环境防止和系统里其他 Python 项目互相污染。python -m venv fruit_env source fruit_env/bin/activate pip install tensorflow pillow opencv-pythonWindows 下的激活命令是fruit_env\Scripts\activate。如果你用 VSCode装好 Python 扩展后按CtrlShiftP选择解释器必须选到fruit_env里的 Python否则终端里 pip 装进去的 tensorflow 在编辑器里 import 不到这是新手最容易翻车的地方。用 PyCharm 的话直接在项目设置里指向 venv 路径即可。装完先跑一句验证python -c import tensorflow as tf; print(tf.__version__)能打印出版本号说明环境通了。安装时不用刻意选择 GPU 版CPU 版 TensorFlow 足够跑这个规模的水果分类如果后期训练太慢再考虑 CUDA 和 GPU 版本那时pip install tensorflow[and-cuda]会带上对应依赖但显存低于 4GB 不建议折腾。3.2 数据集获取与目录结构常见做法是直接用公开水果数据集或者自己拍。不管来源是哪里都要统一成“按类别分文件夹”的结构因为image_dataset_from_directory会自动按文件夹名生成标签。dataset/ train/ apple/ banana/ orange/ val/ apple/ banana/ orange/图片命名随意但整个路径不要出现中文原因后面排查章节会讲。自己拍摄时尽量覆盖不同角度、不同光照、不同成熟度比如青香蕉和熟香蕉各拍一半这张图比单纯增加张数更有价值。如果图片本来就散在一个目录里文件名带类别前缀可以用短脚本按比例切分import os import random import shutil source_dir fruit_photos train_dir dataset/train val_dir dataset/val val_ratio 0.2 random.seed(42) for cls in os.listdir(source_dir): cls_path os.path.join(source_dir, cls) if not os.path.isdir(cls_path): continue files os.listdir(cls_path) random.shuffle(files) split int(len(files) * (1 - val_ratio)) for f in files[:split]: os.makedirs(os.path.join(train_dir, cls), exist_okTrue) shutil.copy(os.path.join(cls_path, f), os.path.join(train_dir, cls, f)) for f in files[split:]: os.makedirs(os.path.join(val_dir, cls), exist_okTrue) shutil.copy(os.path.join(cls_path, f), os.path.join(val_dir, cls, f))这份脚本把每个类别随机抽 20% 当验证集保证训练集和验证集分布一致。random.seed(42)让每次划分结果相同二次调试时测试集不换。验证集和训练集之间不能有重叠哪怕来自同一张原始图的裁剪变体也不行否则验证准确率虚高上线就现原形。3.3 用 image_dataset_from_directory 加载图片接下来用 TensorFlow 自带的数据集 API 把文件夹读成训练批次不需要自己写图片读取循环。import tensorflow as tf IMG_SIZE 224 BATCH_SIZE 32 train_ds tf.keras.utils.image_dataset_from_directory( dataset/train, labelsinferred, label_modecategorical, image_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, shuffleTrue, seed42 ) val_ds tf.keras.utils.image_dataset_from_directory( dataset/val, labelsinferred, label_modecategorical, image_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, shuffleFalse )labelsinferred表示从子目录名推断标签label_modecategorical把标签转成 one-hot对应后面要用的categorical_crossentropy损失。image_size把图片统一缩放到 224×224正好是 MobileNetV2 的标准输入。BATCH_SIZE决定每次喂给模型的图片数CPU 上 32 稳妥小显存 GPU 可以降到 16。验证集shuffleFalse是为了后面做混淆矩阵时对得上顺序。可以用一行代码确认类别映射print(train_ds.class_names)输出应该是[apple, banana, orange]。注意这个列表是字典序不是文件夹的写入顺序推理脚本里的类别列表必须和它一致。也可以顺便打印一个 batch 看看形状for img_batch, label_batch in train_ds.take(1): print(img_batch.shape, label_batch.shape)正常会看到(32, 224, 224, 3)和(32, 3)。如果 shape 对不上后面的模型输入层也要跟着改。4. 训练水果分类模型用 MobileNetV2 迁移学习4.1 构建模型冻结基座、数据增强与归一化模型结构看起来很简洁MobileNetV2 基座加全局池化、Dropout 和分类头关键细节全藏在这几行里。from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras import layers, models NUM_CLASSES 3 data_augmentation models.Sequential([ layers.RandomFlip(horizontal), layers.RandomRotation(0.1), layers.RandomZoom(0.1), ]) backbone MobileNetV2( include_topFalse, weightsimagenet, input_shape(IMG_SIZE, IMG_SIZE, 3) ) backbone.trainable False inputs layers.Input(shape(IMG_SIZE, IMG_SIZE, 3)) x layers.Rescaling(1. / 127.5, offset-1)(inputs) x data_augmentation(x) x backbone(x, trainingFalse) x layers.GlobalAveragePooling2D()(x) x layers.Dropout(0.5)(x) outputs layers.Dense(NUM_CLASSES, activationsoftmax)(x) model models.Model(inputs, outputs) model.summary()第一层Rescaling(1./127.5, offset-1)把 0~255 的像素映射到 -1~1和 MobileNetV2 在 ImageNet 上训练时的输入范围一致。有人嫌麻烦只做1./255结果迁移学习权重基本是废的因为模型看到的数值分布和预训练时完全不同。backbone(x, trainingFalse)是第二个关键点。就算设置了backbone.trainable False这里漏掉trainingFalse基座里的 BatchNorm 层在训练时仍会更新批次统计量导致验证集第一次评估就崩。显式传trainingFalse把整个基座锁死在预训练模式。数据增强放进模型里而不是放在数据流水线中好处是导出模型时增强层会自动跟着走推理时 Keras 自动关闭随机变换没有副作用。Dropout 0.5 在样本量几百张的场景下收益明显太小压不住过拟合太大又拖慢收敛。4.2 编译、训练和回调训练部分最值得调的是学习率和回调组合。model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) early_stop tf.keras.callbacks.EarlyStopping( monitorval_loss, patience4, restore_best_weightsTrue ) lr_scheduler tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.2, patience2, min_lr1e-6 ) history model.fit( train_ds, epochs20, validation_dataval_ds, callbacks[early_stop, lr_scheduler] ) model.save(fruit_model.keras)初始学习率 1e-3 对“冻结基座只训顶层”是安全值。如果训练日志里 loss 震荡降到 3e-4 再试。categorical_crossentropy对应的就是前面label_modecategorical的 one-hot 标签两个设置必须配套换成sparse_categorical_crossentropy时要同步把label_mode改为int。EarlyStopping监控 val_loss连续 4 轮不降就停并restore_best_weights把模型权重恢复到验证集最好的那轮。这比训练满 20 轮靠谱得多我在第一次跑时没加这个回调结果保存的是第 20 轮过拟合权重部署效果一塌糊涂。ReduceLROnPlateau让学习率进入平台期后自动缩小factor0.2 表示每次降到原来的五分之一min_lr 限死下限。两个回调一起用基本能避免“训过头”和“loss 卡死”两个症状。模型保存用.keras格式是当前推荐格式权重和结构在同一个文件里。4.3 推理脚本单张图片预测与置信度过滤训练完成后识别程序的核心就是一个函数import numpy as np import tensorflow as tf from PIL import Image model tf.keras.models.load_model(fruit_model.keras) class_names [apple, banana, orange] def predict_fruit(img_path, min_conf0.5): img Image.open(img_path).convert(RGB) img img.resize((IMG_SIZE, IMG_SIZE)) x np.array(img) x np.expand_dims(x, axis0) preds model.predict(x, verbose0)[0] top_idx int(np.argmax(preds)) top_conf float(preds[top_idx]) if top_conf min_conf: return unknown, top_conf return class_names[top_idx], top_confnp.array(img)拿到的是 0~255 的 uint8 数组直接送进模型因为模型第一层已经有 Rescaling推理时不要再手动归一化否则等于把 -1~1 的区间又压缩了一遍预测概率会塌成均匀分布。这个坑后面单独讲。model.predict(x, verbose0)[0]返回第一个样本的三类概率np.argmax取最大概率下标class_names列表和训练时train_ds.class_names的字典序必须一致。min_conf是安全阀低于 0.5 返回 unknown适合对接后续“请重新拍摄”的提醒逻辑。如果要批量验证验证集表现可以配合val_ds做逐批预测preds model.predict(val_ds, verbose1) y_pred np.argmax(preds, axis1)model.predict直接吃验证集 Dataset返回的是全量预测结果配合val_ds不 shuffle 的特点可以按顺序和真实标签对齐生成混淆矩阵。5. 水果识别避坑五个高频故障与排查记录5.1 训练阶段loss 不降、过拟合与 BatchNorm 翻车1. 训练 loss 停在 1.0 附近准确率保持 33%现象三分类任务准确率一直等于随机猜loss 没有下降趋势。原因最常见的是学习率太大梯度在参数空间来回震荡另一种是归一化缺失预训练权重面对 0~255 的输入分布信息根本传不下去。解决先把learning_rate降到 3e-4 重试同时确认模型第一层是Rescaling(1./127.5, offset-1)而不是1./255。这两步做完通常几个 epoch 内 loss 就会明显下降。2. 训练准确率 98%验证准确率只有 60%现象训练曲线一路向上验证集 loss 从第二个 epoch 开始反弹。原因模型把训练集里的背景色、拍摄角度记住了而不是记住水果的形状。这就是过拟合几百张数据很容易出现。解决加大数据增强强度RandomRotation从0.1提到0.3RandomZoom从0.1提到0.2同时把 Dropout 从0.5提到0.6。如果还压不住就去补训练集拍新照片时故意换光照、换背景。3. 训练日志很好看保存模型后重新加载却预测全错现象model.fit里验证集准确率 90%保存成文件再load_model跑同一批验证图准确率掉到 30%。原因一是没开restore_best_weights保存的是最后一个 epoch 的过拟合权重二是backbone(x, trainingFalse)没写BatchNorm 统计量在训练过程中已经被污染。解决训练回调里保留restore_best_weightsTrue保存后立刻用model.evaluate(val_ds)复现验证集指标确认一致再进下一步。5.2 推理阶段双重归一化、中文路径和真实场景的坑4. 单张图片预测结果全是 0.33 左右的均匀概率现象不管喂苹果还是橙子返回的三类概率都在 0.33 附近没有主次。原因双重归一化。推理脚本里先写了np.array(img) / 127.5 - 1.0模型第一层又做了一次Rescaling(1./127.5, offset-1)输入被压到约 -1.01~-0.99模型看到的所有图片几乎都是同一个暗色小方块。解决确认推理输入来自Image.open(...).convert(RGB)保持 0~255直接送模型不手动归一化。排查时全局搜一下127.5只允许出现一处。5. 图片路径带中文时报错或训练时能读推理时读不出来现象Image.open(数据集/测试/苹果.jpg)抛 FileNotFoundError或者换台电脑后同样的代码打开失败。原因Windows 下部分 Python 环境的编码处理对中文路径不稳定OpenCV 的imread尤其明显PIL 在特定场景也会踩。解决最省事的方案是训练集、测试集路径全部用英文目录和文件名这是 3.2 节反复强调的原因。如果必须处理中文路径用pathlib.Path传给 PIL 能缓解但别指望所有机器都兼容生产环境尽量一刀切用英文。6. 网上下载的测试图预测很准手机拍一张就翻车现象测试集上准确率 95%对着真实桌面随手拍一张模型把苹果认成梨。原因公开数据集大多是去背景、正角度、单一光源的摆拍图真实场景有阴影、遮挡、背景杂物两者分布完全不同。模型没见过这个分布再好的权重也白搭。解决从采集阶段就按“多角度、多光照、多成熟度”拍训练后用真实场景照片建一个小测试集用精准率、召回率而不是整体准确率做衡量。真实场景差的话把那些翻车照片补进训练集重新微调这是最笨也最有效的方法。6. 识别效果验证与进阶Grad-CAM、摄像头和模型导出模型训完第一件事不是急着部署而是用 Grad-CAM 看它到底在看哪里。这个验证方法能直接回答“模型是靠果皮判断还是靠背景判断”的问题。import numpy as np import tensorflow as tf from PIL import Image sample Image.open(test_apple.jpg).resize((IMG_SIZE, IMG_SIZE)) x np.expand_dims(np.array(sample), axis0) # 找到 MobileNetV2 子模型如果报错先执行 model.summary() 确认层名 backbone model.get_layer(mobilenetv2_1.00_224) last_conv backbone.get_layer(out_relu) grad_model tf.keras.models.Model( [model.inputs], [last_conv.output, model.output] ) with tf.GradientTape() as tape: conv_out, preds grad_model(x) class_idx tf.argmax(preds[0]) loss preds[0][class_idx] grads tape.gradient(loss, conv_out)[0]把grads按通道求平均再乘以conv_out得到的就是热力图。我做过几次之后发现模型很可能会高亮背景而不是水果本体尤其当训练集背景单一时。如果热力图四角发亮而水果区域是冷的就得回去补数据别急着上线。这一步花十分钟能挡掉大半“准确率高、上线就翻车”的案例。验证通过后可以接摄像头实时识别。用 OpenCV 读帧裁出画面中央区域调用predict_fruit再把标签画回帧上。瓶颈主要在 resize 和模型推理普通笔记本摄像头场景下能做到每秒 8~12 帧对“看水果”这个动作来说完全够用。最后是模型导出。想部署到手机或嵌入式设备就转成 TensorFlow Liteconverter tf.lite.TFLiteConverter.from_keras_model(model) tflite_model converter.convert() open(fruit_model.tflite, wb).write(tflite_model)转出来的文件比原始.keras小很多推理时把图片 resize 到 224 直接送进去归一化已被打进模型。真实部署前用摄像头采集 50~100 张现场帧做独立测试集量化真实识别率而不是只看训练时划分出来的验证集。这个习惯帮我堵住了不止一次发布前翻车希望也能帮到你。本文还有配套的精品资源点击获取