☰
芒果成熟度图像分类实战:9000张数据集微调与ONNX部署指南
2026/10/1 12:35:45 网站建设 项目流程

简介:面向图像分类与农业智能化应用的芒果成熟度三分类数据集,涵盖成熟、未成熟、损坏三种状态,适合深度学习初学者及图像分类算法研究者开展模型训练、精度评估与网络结构改进实验。压缩包共2000个文件,其中1998张JPG图片为样本数据,1个Python脚本用于本地可视化预览数据集,1个JSON文件记录类别标签与划分信息,整体体量261.44MB。数据已按训练集、验证集、测试集预先分类存放,约9000张已标注图片,免去手工标注与数据整理环节,打开即可训练调参;配套的show脚本还能辅助检查样本分布、直观核对分类效果。作者另在博客提供图像分类网络改进专题与计算机视觉完整项目,便于读者在数据集基础上深入拓展实验、对比不同改进策略。当前已有61人学习使用。

1. 芒果成熟度图像分类数据集:约9000张已标注图,能解决什么,不能解决什么

做果蔬分级的人看到“芒果成熟度图像分类数据集【已标注,约9,000张数据】”心里应该马上有数:这是一个单标签多分类任务,不是目标检测,也不是分割。也就是说,每张图已经告诉你“这一个芒果是几成熟”,模型要学的是从图像外观推测成熟度。它最直接的落地场景是产地分拣、冷库质检、无人货柜的水果状态判断,甚至是电商发货前的熟度抽检。但也要泼一盆冷水:9000张这个规模,能解决“分得动”的问题,解决不了“分得准到伤不起”的问题。青果、半熟果、熟果之间本来就是连续变化,标签边界一定会有争议。这篇文章按我实际做这类农业图像分类数据集的流程走一遍,从数据体检、微调、踩坑到部署,把能复现的步骤和参数都放出来。

2. 先摸清数据集再动手:标签级数、目录结构、坏图和类别偏斜统计

2.1 成熟度标签分几级:拿到手先做标签归一化

这类水果成熟度数据集最常见的标注方案是三级:immature、mid-ripe、ripe,也就是生、半熟、全熟。也有按 1 到 5 分级的,还有按“可采摘”和“不可采摘”做二分类的。不管标题里写没写具体类数,我拿到数据第一件事永远是确认标签到底有几类、标签名是否统一。很多公开的果实数据集是从不同果园、不同设备采集的,文件名里可能出现“ripe”“riped”“Ripe”这种同一含义不同写法,CSV 和文件夹名对不上也非常常见。

import pandas as pd from pathlib import Path from collections import Counter data_dir = Path("data") # 假设解压后的目录 # 常见标注形态一:CSV 边表,字段一般是 filename,label,split csv_path = data_dir / "annotations.csv" if csv_path.exists(): df = pd.read_csv(csv_path) print(df["label"].value_counts()) # 常见标注形态二:train/val/test 下的类别文件夹 else: for part in ["train", "val", "test"]: p = data_dir / part if p.exists(): label_counter = Counter(c.name for c in p.iterdir() if c.is_dir()) print(part, label_counter)

这段代码做的事情在训练前花不了两分钟,但能避免你后面训到一半才发现“青果”在训练集里叫img、在验证集里叫immature。先看label列的分布,确认类别数和语义;再看split字段是否已经有官方划分。我一般不看官方划分,至少会自己重抽样一次验证集,因为很多数据集发布时的划分并不是按“成熟度均匀分布”来的。

2.2 目录组织结构:ImageFolder 还是自定义 Dataset

芒果成熟度分类数据集大多逃不开两种组织方式。一种是train/ripe/001.jpg这种 ImageFolder 结构,PyTorch 的torchvision.datasets.ImageFolder直接能读;另一种是图片全在一个平铺目录里,靠 CSV 或 JSON 记录文件名和标签。前者简单,但想打折、抽稀、分层抽样都麻烦;后者灵活,但自己要小心拼接路径和过滤损坏样本。

data/ images/ # 平铺或按采集批次分 20240523_001.jpg 20240523_002.jpg labels.csv # filename, ripeness, split

平铺结构我更推荐,因为农业数据会不断补采,最怕每次新数据来了要移动文件改目录。读图的入口直接指向images/,标签动态从labels.csv读取。等到跑基线训练时,再按需生成 ImageFolder 的硬链接目录也不迟,别把原始数据复制成三份占磁盘。

import pandas as pd, shutil from pathlib import Path df = pd.read_csv("data/labels.csv") out_root = Path("dataset_linked") for _, row in df.iterrows(): src = Path("data/images") / row["filename"] dst = out_root / row["split"] / row["ripeness"] / row["filename"] dst.parent.mkdir(parents=True, exist_ok=True) if not dst.exists(): dst.symlink_to(src.resolve()) # 不复制,省空间

这里用符号链接而不是shutil.copy,因为三分类重新划分后文件会重复出现在多个 split 下,复制一遍就多占三倍空间。训练脚本用的是ImageFolder就不需要改数据加载代码。注意 Windows 上符号链接要管理员权限,跑不了就直接复制,问题也不大。

2.3 跑一遍数据体检脚本:坏图、重复图、类别分布

花 15 分钟把所有图过一遍,能省后面排错三个小时。检查顺序固定为:能不能解码、尺寸是否异常、有没有完全重复图、类别数量是否偏斜、有没有图像宽度小于 224 这种会直接崩 ResNet 的图。很多“我换了个模型突然报错”的玄学,源头都在数据里有坏的 JPEG。

from PIL import Image import hashlib from pathlib import Path from collections import Counter def file_hash(path, chunk=1024*1024): h = hashlib.md5() with open(path, "rb") as f: while data := f.read(chunk): h.update(data) return h.hexdigest() img_paths = list(Path("data/images").rglob("*.jpg")) hash_seen = {} bad, small = [], [] for p in img_paths: try: with Image.open(p) as im: im.verify() with Image.open(p) as im: w, h = im.size except Exception: bad.append(str(p)); continue if min(w, h) < 224: small.append((str(p), w, h)) h = file_hash(p) if h in hash_seen: print("可能重复:", p, "与", hash_seen[h]) else: hash_seen[h] = str(p) print("解码失败:", len(bad), bad[:5]) print("尺寸小于224:", len(small), small[:5])

Image.verify()只校验文件不加载像素,速度快;重复图用 MD5 全文件哈希,不用感知哈希,因为同一张图经过压缩软件微调后 MD5 就变了,全文件哈希查的是真正的重复文件。这类数据里经常出现同一批果图被复制进两个类别的情况,重复图会让验证集准确率虚高,属于数据泄漏的一种。

类别偏斜看Counter结果。9,000 张图如果熟果占了 5,000 张,青果只 1,000 张,按准确率评估没毛病,但因为成熟度分级恰恰是少数类容易被误判为多数类,所以我不只统计数量,还会统计每类的平均亮度、平均色相,后面增强参数要用。统计时用 HSV 色域算平均饱和度,芒果成熟度跟颜色强相关,这个数字能直接看出类别边界是否清晰。

3. 把准确率顶上去:从 ResNet50 基线到微调训练,参数怎么调

3.1 为什么先选 CNN 不选 Transformer

9,000 张图做图像分类,第一反应是拿预训练 ResNet50 或 EfficientNet 微调,而不是直接上 ViT。原因很简单:Transformer 图像分类模型动辄需要十几万张图才能发挥结构优势,9,000 张虽然够用,但 ViT 在这个规模下容易欠拟合,收敛慢、占用显存大,除非你有很强的数据增强和很长的训练时间。用torchvision里的预训练权重做迁移学习,ResNet50 在 ImageNet 上学到的边缘、纹理、果皮质感特征可以直接用在芒果上,只需要改最后的全连接层。

EfficientNet-B3 也可以作为备选,它在参数量和精度之间平衡更好,但训练时长比 ResNet50 多出一截。我的判断标准是:项目周期只有一周,选 ResNet50;项目周期一个月,可以跑 EfficientNet 和 ConvNeXt 做对比。别一上来就套大模型,农业数据的价值主要在标注,不在模型复杂度。

3.2 数据增强参数:芒果颜色敏感,增强别过度

水果成熟度分类和一般物体分类最大的不同,是颜色本身就是强特征。RandomResizedCrop 可以给,ColorJitter 里的hue一定要小心。芒果从青到黄的变化本质是色相在绿黄区间内移动,你把 hue 范围调到 0.1,等于人为制造了一批“青果看起来像黄果”的训练样本,模型会困惑。

我常用的增强配方如下:缩放原图到 256,随机裁剪 224;水平翻转概率 0.5;亮度调整范围 0.8 到 1.2;对比度 0.8 到 1.2;饱和度 0.9 到 1.1;hue 不动,保持默认 0。垂直翻转不要开,芒果挂在树上时上下方向有实际意义,模型如果学到“翻转后还是芒果”没什么坏处,但容易丢失果柄朝向的信息,影响后续做检测时对果实姿态的判断。

from torchvision import transforms train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.0), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

这组参数在芒果成熟度任务上的直观效果是:亮度变化模拟早晚光照差,对比度变化模拟背光和遮阴,饱和度轻微扰动模拟不同屏幕和传感器的渲染差异。hue 设为 0,是因为成熟度判断的核心就是色相位置,动它等于改标签。验证集只做尺度归一,不做任何随机扰动,保证评估稳定。

3.3 微调训练代码:冻结权重、分层学习率、早停

微调用 PyTorch 最顺手,代码量少,生态工具全。先把预训练 ResNet50 的最后一层 fc 替换成适配三类的全连接层。冻结除layer4和fc之外的所有层,初始学习率 1e-3 给最后 fc,3e-4 给 layer4,这样既不会破坏前面学到的通用纹理特征,又能让高层特征快速适应芒果数据。

import torch, torch.nn as nn from torchvision import models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, 3) # 3类成熟度 frozen_params, ft_params = [], [] for name, param in model.named_parameters(): if name.startswith("layer4.") or name.startswith("fc."): param.requires_grad = True ft_params.append(param) else: param.requires_grad = False frozen_params.append(param) optimizer = torch.optim.AdamW([ {"params": frozen_params, "lr": 3e-5}, {"params": ft_params, "lr": 1e-3}, ], weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss(label_smoothing=0.1)

label_smoothing=0.1对成熟度这种边界模糊的标注有帮助。青果和半熟果之间的点,人工标注本身就有分歧,硬标签会让模型输出极端置信度,平滑后模型预测概率更稳,也稍微缓解过拟合。学习率这块别偷懒,我见过不少人用同一个学习率微调所有层,结果前面层的预训练特征被破坏,训练集 loss 降到 0.2,验证集才 0.85。

训练循环不外乎取数据、算 loss、反传。唯一想强调的是早停必须看验证集 loss,不是验证集准确率。成熟度数据类别可能有轻微不平衡,准确率在验证集上会骗人,loss 更客观。每 epoch 算一次验证集 loss,连续 7 个 epoch 不下降就停,恢复最好的权重。

3.4 评估指标:不要只盯 Overall Accuracy

成熟度分类的业务代价是不对称的:把全熟判成半熟,顶多再放两天;把青果判成半熟卖出去,客诉直接炸。所以只看整体准确率完全不够,必须看混淆矩阵和每个类别的 Precision/Recall。用 sklearn 一行就能出分类报告,但真正值钱的是画出按类别的混淆矩阵,一眼能看出哪个边界在打架。

from sklearn.metrics import confusion_matrix, classification_report import numpy as np, seaborn as sns, matplotlib.pyplot as plt # y_true, y_pred 来自验证集推理结果 report = classification_report(y_true, y_pred, target_names=["immature", "mid-ripe", "ripe"], digits=3) print(report) cm = confusion_matrix(y_true, y_pred) sns.heatmap(cm, annot=True, fmt="d", xticklabels=["immature", "mid-ripe", "ripe"], yticklabels=["immature", "mid-ripe", "ripe"]) plt.savefig("cm.png")

如果 immature 被误判成 mid-ripe 的量很大,优先补 immature 的训练样本或合成更多成熟度偏低的图。我曾经在一个果蔬翻新项目里遇到过整体准确率 0.91,结果客户要的“未熟果漏检率”完全不合格,因为半熟果大量混入,而半熟果在产线里等于没人要的次级品。所以做这类数据集,从一开始就把混淆矩阵当作验收指标,而不是准确率。

4. 踩坑与排查:过拟合、背景干扰、增强翻车类别失衡

4.1 现象:训练集收敛到 0.1,验证集却卡在 0.7 上不去

这个问题在 9,000 张的规模下极其常见。原因只有两个,一是模型容量太大而数据量不够,二是在验证阶段用了和训练阶段不同的预处理管线。第二个坑尤其隐蔽,经常是训练用了 RandomResizedCrop,验证时忘了加 CenterCrop,直接把 256 的图塞进模型。ResNet50 本身可以适应任意输入尺寸,但预训练权重的感受野是 224 设计的,尺寸变了特征分布全乱。

解决方法是把验证管线单独打印出来核对,别写在同一个get_transform函数里带参数切换。另外把dropout或weight_decay提上来,我在芒果数据上习惯把 weight_decay 从 1e-4 提到 1e-3,效果比改模型结构明显。如果还不能解决,检查训练集和验证集是不是来自同一批果,因为同一个果园同一天拍的图,分到训练集和验证集后,验证集准确率虚高是一种“假过拟合”,真正上线测新果园才会露馅。

4.2 现象:模型盯着叶子看,不看芒果

用 Grad-CAM 一眼就能看出来,决策区域完全落在叶片和背景上。这类数据集最常见的坑是正负样本太“漂亮”,每张图芒果在正中央、背景干净得能当商品图。模型学到的是“特征分布符合训练集的芒果”而不是“芒果本身”。解决思路是强制模型关注果实区域,最简单的方法是训练目标检测模型拿到边界框,再用边界框裁剪训练分类模型。

如果不想走检测路线,也可以用随机裁剪的范围控制:把 RandomCrop 改成以中心为主的小偏移裁剪,减少模型对背景纹理的依赖。还有一个土办法,用 HSV 色彩空间对图像做掩膜,把绿色叶片区域随机替换成灰底,让模型不能靠“有没有大片绿色”来判断成熟度。这个办法听着粗暴,但对果蔬分类非常有效。

4.3 现象:青果和半熟果互相误判严重

这两类的视觉差在颜色上只是黄绿色比例的渐变,光照一变就完全分不清。我在一个用大棚芒果做的项目里看到,青果误判成半熟果的比例高达三成。主要原因不是模型不行,是标注标准不一致:同一颗半熟果,标注员 A 觉得“还有点青”,标注员 B 觉得“已经转黄了”。这类标签噪声在边界类里是不可避免的。

解决方法是先画特征分布。把所有训练图转成 HSV,算 20 个 bin 的色相直方图,拿半熟类看看是不是双峰分布。如果有两个峰,说明这个类内部混了两种外观,需要回去重新核标。核标不能只看一张图,要看同一批果的多张连续拍摄图,结合采摘日期判断真实成熟度。这类返工很费人力,但比起模型上线后被市场部追着骂,这点成本不算高。

4.4 现象:增强把青果变黄果,训练集内部都打架

这是 ColorJitter 的典型事故。hue 参数一旦大于 0,青果增强样本的色相会偏移到黄色区域,相当于把 immature 的图改成了 ripe 的样子,CrossEntropy 在同样一张图上既要学“绿”又要学“黄”,模型只会学到“颜色不确定”。另外 RandomResizedCrop 配合尺度范围 0.08 到 1.0 时,会截取到果皮上的一块纯色区域,青果截到一块黄斑,模型也容易懵。

解决方法是限制裁剪比例不小于 0.4,避免出现过小的局部图;ColorJitter 只调亮度、对比度,hue 永远为 0。如果一定要做颜色增强,建议在 HSV 空间只对 S 和 V 通道做正弦扰动,对 H 通道不做任何变化。这个“增强不过头”的原则对一切以颜色为主导分类特征的任务都适用,番茄、辣椒、香蕉同理。

4.5 现象:同一颗芒果换个角度就分类失败

这种情况来自训练数据视角单一,数据集里大部分图是正对果腹拍的,而实际产线摄像头可能是斜上方俯拍。角度一变,果实表面的光斑位置、果柄遮挡关系全变了,模型没有见过这种分布。这不是过拟合,是分布外样本问题。

解决的常见做法是采集时故意做多角度覆盖,如果数据集已经固定,就用在线增强把视角多样性补上。可以给RandomResizedCrop的 scale 范围扩大,并配合RandomRotation(15),让模型看到果皮表面纹理的轻微旋转。旋转角度别超过 15 度,芒果不是圆形,转多了会把椭球结构切成奇怪的形状。更强的做法是去采一批新角度数据补进训练集,专门标“侧视”“俯视”“被叶片遮挡一半”这三种困难样本。

5. 把模型从 Notebook 搬到推理端:ONNX、INT8 与前后处理对齐

5.1 ONNX 导出与输出张量名确认

训练完的 PyTorch 模型不能直接部署到大部分国产边缘设备和手机端,ONNX 是最通用的中间格式。导出时把model.eval()设好,输入用真实张量跑一次,torch.onnx.export会按 trace 结果生成静态图。输出节点名不指定的话是随机生成的,后续集成时找不到名字会很难受,所以导出时直接给输出起名logits。

import torch, onnxruntime as ort import numpy as np model.eval() dummy = torch.randn(1, 3, 224, 224, device="cuda") torch.onnx.export( model.cpu(), dummy, "mango_ripe.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=17, )

dynamic_axes设置 batch 为动态,意思是推理时一次丢 1 张还是 32 张都行。注意最后一个fc层是nn.Linear,导出的 logits 是不经过 softmax 的裸值,后面部署端要自己加 softmax,别省掉。ONNX 导出后先用onnx.checker.check_model做一次完整校验,再加载到 onnxruntime 跑同样的输入,比对输出和 PyTorch 原模型是否一致。

5.2 部署端的预处理:尺寸、均值方差、通道顺序最容易翻车

ONNX 模型里不包含预处理,训练时的 Resize、Normalize 需要在部署代码里原样复刻。这一步是部署事故高发区,最常见的翻车是 PyTorch 的ToTensor()会把像素从 0-255 归一化到 0-1,且把 HWC 转成 CHW,而 OpenCV 读图出来是 BGR 的 HWC,你在 Python 里调试没问题,一换 C++ 或 Java 就漏了通道转换。

import cv2, numpy as np from PIL import Image def preprocess(image_path: str) -> np.ndarray: img = Image.open(image_path).convert("RGB") img = img.resize((256, 256)) # 手工 CenterCrop,避免依赖 torchvision left = (256 - 224) // 2 top = (256 - 224) // 2 img = img.crop((left, top, left + 224, top + 224)) arr = np.array(img, dtype=np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) arr = (arr - mean) / std arr = arr.transpose(2, 0, 1) # HWC -> CHW return np.expand_dims(arr, axis=0).astype(np.float32)

PIL.Image.open出来是 RGB,省去通道转换的坑。crop参数是(left, upper, right, lower),很多人第一次写容易把顺序记错。整段预处理要能在 onnxruntime 的 Session 里跑出和 PyTorch 验证集一致的结果,判断标准是对同一张图的输出概率差值在 1e-4 以内,超过这个量就说明哪里有细节没对齐。

5.3 推理时用不用 Softmax:阈值与拒识

ONNX 输出 logits,直接argmax取类别也工作,但拿不到置信度,没法做“这图我认不准就送去人工复检”的拒识逻辑。在生产线上,成熟度判断错了的成本比拒绝判断高得多,所以推理端一定要加 softmax 和阈值。

import onnxruntime as ort sess = ort.InferenceSession("mango_ripe.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) logits = sess.run(["logits"], {"input": preprocess("test.jpg")})[0] probs = np.exp(logits - logits.max()) / np.sum(np.exp(logits - logits.max()), axis=1, keepdims=True) score = float(probs[0].max()) pred = int(probs[0].argmax()) classes = ["immature", "mid-ripe", "ripe"] if score >= 0.85: result = {"label": classes[pred], "confidence": round(score, 4)} else: result = {"label": "unknown", "confidence": round(score, 4), "action": "manual_recheck"} print(result)

softmax 用最大 logits 做减法再取指数,是为了数值稳定性,logits 数值大到几十时直接取指数会溢出。阈值 0.85 不是死的,要对验证集上每一类单独统计置信度分布来定。半熟类天然置信度低,0.7 可能是合理阈值;熟果类置信度普遍高,0.9 才能保证不漏。这个阈值就是产线上的“后悔药”,宁可不判断也别瞎判断。

5.4 顺手写完一个 JSON 输出脚本

边缘设备或产线 PLC 一般不走 Python,但很多自动化验证脚本还是 Python 最方便。把上面预处理和推理封装成一个函数,接收本地图片路径输出 JSON,方便集成进现有质检系统,也能在产线调试时手动跑单张图验证。

import json, sys def classify_image(image_path: str) -> dict: inp = preprocess(image_path) logits = sess.run(["logits"], {"input": inp})[0] probs = np.exp(logits - logits.max()) / np.sum(np.exp(logits - logits.max()), axis=1) idx = int(probs.argmax()) cls = classes[idx] conf = float(probs[0, idx]) if conf < 0.85: cls = "unknown" return {"file": image_path, "label": cls, "confidence": round(conf, 4)} if __name__ == "__main__": print(json.dumps(classify_image(sys.argv[1]), ensure_ascii=False))

这段脚本除了上线用,还有一个隐藏价值:拿它去批量跑一遍新拍的照片,把置信度低于阈值的图捞出来,这些图就是下一轮数据补充的重点。ONNX 模型换版本只需要替换文件路径,接口不用变,产线侧的集成代码可以长期稳定。

6. 让成熟度分类更耐用:生产验证和后续迭代的几个技巧

数据集交付不是模型训练完就结束了,9000 张图的分类项目至少还要做三件事。

第一,做“时间偏移验证”。把采集日期在前 80% 的图当训练集,后 20% 当验证集,看准确率掉了多少。芒果成熟过程中颜色变化快,同一个果园两周前后的图像分布就差很多,这种验证比随机划分更能暴露模型是不是只记住了当时的果色。我经历过随机划分准确率 0.93,时间划分直接掉到 0.81 的项目,从此不敢只看随机划分的指标。

第二,给困难样本单独建一个小库。把验证集里置信度在 0.5 到 0.85 之间的图存下来,定期拿去人工复审。你会发现很多“困难样本”其实是标注错误,修正标签后回头再训,模型能立刻提升不少,而且成本极低。这个动作比加数据增强有效得多。

第三,走到检测路线时,用 YOLOv8 训练自己的数据集才是真正能落地到分拣线的方案。分类模型适合“相机正对果实且单果入镜”的场景,实际产线上芒果是叠着来的,需要先检测再分类。好在分类模型的结构可以作为 YOLO 里的分类头继续用,90% 的预训练权重都能迁移。

我做这类农业图像分类数据集的经验就一句话:先弄清标签边界,再谈模型选型,最后用部署端的真实反馈来迭代数据。很多团队把 60% 的时间花在调模型上,而真正的瓶颈永远是标注质量和场景覆盖。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询