简介面向图像分类与农业智能化应用的芒果成熟度三分类数据集涵盖成熟、未成熟、损坏三种状态适合深度学习初学者及图像分类算法研究者开展模型训练、精度评估与网络结构改进实验。压缩包共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(parentsTrue, exist_okTrue) 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, chunk1024*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 不选 Transformer9,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.1hue 不动保持默认 0。垂直翻转不要开芒果挂在树上时上下方向有实际意义模型如果学到“翻转后还是芒果”没什么坏处但容易丢失果柄朝向的信息影响后续做检测时对果实姿态的判断。from torchvision import transforms train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p0.5), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1, hue0.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 给最后 fc3e-4 给 layer4这样既不会破坏前面学到的通用纹理特征又能让高层特征快速适应芒果数据。import torch, torch.nn as nn from torchvision import models model models.resnet50(weightsmodels.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_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss(label_smoothing0.1)label_smoothing0.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], digits3) print(report) cm confusion_matrix(y_true, y_pred) sns.heatmap(cm, annotTrue, fmtd, 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, devicecuda) torch.onnx.export( model.cpu(), dummy, mango_ripe.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, opset_version17, )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, dtypenp.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) arr (arr - mean) / std arr arr.transpose(2, 0, 1) # HWC - CHW return np.expand_dims(arr, axis0).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()), axis1, keepdimsTrue) 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()), axis1) 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_asciiFalse))这段脚本除了上线用还有一个隐藏价值拿它去批量跑一遍新拍的照片把置信度低于阈值的图捞出来这些图就是下一轮数据补充的重点。ONNX 模型换版本只需要替换文件路径接口不用变产线侧的集成代码可以长期稳定。6. 让成熟度分类更耐用生产验证和后续迭代的几个技巧数据集交付不是模型训练完就结束了9000 张图的分类项目至少还要做三件事。第一做“时间偏移验证”。把采集日期在前 80% 的图当训练集后 20% 当验证集看准确率掉了多少。芒果成熟过程中颜色变化快同一个果园两周前后的图像分布就差很多这种验证比随机划分更能暴露模型是不是只记住了当时的果色。我经历过随机划分准确率 0.93时间划分直接掉到 0.81 的项目从此不敢只看随机划分的指标。第二给困难样本单独建一个小库。把验证集里置信度在 0.5 到 0.85 之间的图存下来定期拿去人工复审。你会发现很多“困难样本”其实是标注错误修正标签后回头再训模型能立刻提升不少而且成本极低。这个动作比加数据增强有效得多。第三走到检测路线时用 YOLOv8 训练自己的数据集才是真正能落地到分拣线的方案。分类模型适合“相机正对果实且单果入镜”的场景实际产线上芒果是叠着来的需要先检测再分类。好在分类模型的结构可以作为 YOLO 里的分类头继续用90% 的预训练权重都能迁移。我做这类农业图像分类数据集的经验就一句话先弄清标签边界再谈模型选型最后用部署端的真实反馈来迭代数据。很多团队把 60% 的时间花在调模型上而真正的瓶颈永远是标注质量和场景覆盖。希望帮到你。本文还有配套的精品资源点击获取