ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

芒果成熟度图像分类数据集:9000张标注图如何支撑落地模型

芒果成熟度图像分类数据集:9000张标注图如何支撑落地模型 简介这是一套面向深度学习图像分类学习与研究者的芒果成熟度已标注数据集共约9000张真实拍摄图像按成熟、未成熟、损坏三类划分可用于果实成熟度识别、农产品质检等视觉任务也可作为图像分类网络改进的基准数据。压缩包内共2000个文件包含1998张JPG图片、1个JSON标注文件和1个Python可视化脚本整体体积约261.44MB。数据集已科学划分训练集、验证集与测试集同类图片分目录存放方便直接加载训练和评估JSON文件给出了类别与划分信息show脚本可快速预览样本帮助使用者理解数据分布。目前已有62人学习适合计算机视觉初学者动手实践也可供进阶者在作者博客提供的分类网络改进与完整项目基础上做算法验证和模型调优。1. 芒果成熟度图像分类数据集9,000张已标注图像能支撑一个怎样的落地模型做水果分级项目的人几乎都被同一件事卡过项目还没开工数据已经先让人失眠。芒果成熟度图像分类数据集提供了约9,000张已标注图像解决的就是这个“最先一公里”——你不用自己扛相机跑产地也不用为一张图该归“半熟”还是“成熟”纠结到深夜标注已经躺在目录里了。适合谁用给产线做分拣算法的工程师、研究图像分类但没有专业数据的学生、以及想快速判断“这套方案能不能落地”的小团队。九千张图不大不小微调预训练模型绰绰有余拿来做端到端的图像分类流程验证也刚好不浪费。2. 先拆9,000张标注图的底细类别体系、标注格式与数据体检拿到数据集第一件事不是急着训练而是确认三件事标签到底分了几类、标注文件长什么样、图里有没有脏数据。这一步做扎实后面能少熬几个通宵。2.1 成熟度分级怎么定三分类还是四分类边界画在哪里芒果成熟度在图像里不是一个连续刻度而是离散标签。果农判断成熟度靠的是果皮颜色、果蒂状态、硬度、香气但图像分类模型真正能拿到的信号主要是颜色、纹理、斑块和形状。所以标注方案通常落在三分类或四分类上。方案类别设计适用场景边界难点三分类未熟、成熟、过熟快速分拣、只分“能不能上架”半绿半黄果容易被机器归到交界处四分类未熟、初熟、成熟、过熟产线精细分拣、电商分级定价初熟和成熟的颜色区间有明显重叠如果目标是产线分拣四分类更贴近实际未熟的走长途冷链初熟的发次日达渠道成熟的做即时零售过熟的下架转加工线。但四分类的代价是标注一致性更难保证因为“初熟”和“成熟”之间没有清晰断点标注员也可能犹豫。另一个必须留意的点是品种差异。芒果品种非常多有些青皮品种成熟时果皮依旧是绿色只是果肉变甜这时候“成熟”不等同于“变黄”。所以拿到数据集第一件事我建议你确认标注字段里有没有“品种”或“批次”。如果带了训练时可以把品种作为辅助信息或干脆按品种拆分训练验证集避免模型把“黄色程度”当成唯一判据。2.2 ImageFolder、CSV与JSON三种常见标注组织方式和读取代码这类图像分类数据集最常见的组织方式是按类别分目录也就是PyTorch里ImageFolder能直接读取的结构。目录通常长这样mango_maturity_dataset/ ├── train/ │ ├── 0_unripe/ │ │ ├── img_0001.jpg │ │ └── ... │ ├── 1_half_ripe/ │ ├── 2_ripe/ │ └── 3_overripe/ ├── val/ └── labels.csv我一般拿到任何数据集都会先跑一个命令看整体结构而不是直接写训练脚本find mango_maturity_dataset -maxdepth 2 -type d | sort find mango_maturity_dataset -name *.jpg | wc -l这两个命令能快速暴露两个最常见的问题验证集有没有和训练集一样的子目录结构图片总数和标注记录的条数是否对得上。如果验证集缺了某个类别的目录训练完才发现就晚了。另一类常见格式是CSV标签文件。列名可能是filename,label也可能是image_name,class_id先读出来看看分布import pandas as pd df pd.read_csv(labels.csv) print(df.head()) print(df[label].value_counts())读取CSV时有一条血泪经验不要依赖Excel的行顺序和目录扫描顺序一致的假设。我踩过一次标注工具导出的csv顺序和文件系统排序不一致导致前200张图的标签整体错位训练损失怎么都降不下去。正确做法是显式建立“文件名到标签”的映射而不是用行号对应。也有些数据集会用JSON格式来承载更丰富的信息比如附加拍摄条件、果径、品种字段。读取方式同样简单import json with open(annotations.json, r) as f: annotations json.load(f) print(annotations[0])JSON结构不统一有的是一张图一条记录有的按类别分组。无论哪种第一步永远是把它拍平成“路径标签”的DataFrame或字典之后接训练逻辑才顺手。我习惯统一转换成ImageFolder目录结构因为torchvision的Dataset实现已经替我做完了路径扫描、标签索引、类别名称映射这些脏活。2.3 数据体检脚本损坏文件、重复图片和类别失衡一次查清九千张图不算多但人工一张张看会看到崩溃。用脚本做一次全量体检五分钟出结果from pathlib import Path from PIL import Image import hashlib root Path(mango_maturity_dataset) counts {} hashes {} corrupted [] for p in root.rglob(*.jpg): try: img Image.open(p) img.load() # 真正读入像素而不是只读到文件头 except Exception as e: corrupted.append((p, str(e))) continue data p.read_bytes() h hashlib.md5(data).hexdigest() label p.parent.name counts[label] counts.get(label, 0) 1 if h in hashes: hashes[h].append(p) else: hashes[h] [p] print(类别统计:, counts) print(损坏文件数:, len(corrupted)) for p, e in corrupted[:5]: print(损坏示例:, p, e) dups {k: v for k, v in hashes.items() if len(v) 1} print(重复图片组数:, len(dups))这段脚本做三件事统计每类图片数量、检查有没有PIL打不开的损坏文件、用MD5找出完全重复的图片。MD5相同的图片直接去重但需要保留一份路径映射记录防止后续想查“某张图来自哪一批采集”时无从下手。类别统计则是判断是否需要加权采样的依据。注意MD5只能识别像素级完全相同的重复图。同一张图被压缩或改尺寸后MD5会变这种情况需要靠感知哈希进一步判断但对九千张的数据集MD5已经能筛掉大部分采集阶段的重复。体检完我还会做一件事把每个类别随机抽20张拼成网格图用肉眼过一遍确认标签和图片内容对得上。这一步看着原始但能在十分钟内发现“图片方向和实际内容错位”“某类混入其他类别”这类标注工具的导入错误。数据体检不是锦上添花是给后面所有训练工作买保险。3. 用图像分类算法训练成熟度模型从预处理到参数调整的完整脚本数据确认没问题后进入正题训练一个能够对芒果成熟度做分类的图像分类模型。九千张的规模决定了不需要也不可能把模型做得很大关键是把预处理、增强、模型选型和训练参数组合好。3.1 预处理与数据增强为什么要小心颜色抖动芒果成熟度分类本质上是一个颜色主导的任务。果皮颜色从绿到黄再到出现黑斑和皱缩是模型最稳定的信号。但这意味着两点一是颜色的预处理要统一二是颜色增强不能乱来。先看基础预处理。常见做法是统一缩放到224×224因为目前绝大多数预训练图像分类模型都是基于ImageNet的224输入设计的。归一化用ImageNet统计值mean和std直接取ImageNet标准参数。这里有个细节如果拍摄环境颜色明显偏色比如暖色灯光下拍的照片整体偏黄直接用ImageNet的归一化参数会保留这个偏色。更稳妥的做法是在测试集上计算自己数据的均值和标准差。我把这个计算拆成一个独立脚本跑一遍通常只花几分钟。数据增强要考虑的是背景和遮挡问题。数据集里的芒果可能带背景也可能是单果抠图但产线上一定会遇到芒果互相遮挡、传送带纹路干扰、操作员手掌入镜的情况。我会用这组增强配置from torchvision import transforms train_transform transforms.Compose([ transforms.RandomResizedCrop( (224, 224), scale(0.7, 1.0) ), transforms.RandomHorizontalFlip(), transforms.ColorJitter( brightness0.15, contrast0.15, saturation0.3, hue0.02 ), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.228, 0.224, 0.225] ), ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop((224, 224)), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.228, 0.224, 0.225] ), ])参数上要特别注意两个地方。一个是RandomResizedCrop的scale从0.7起步不要把裁剪比例放得太低裁太少会把芒果主体切掉模型就只能去学背景。另一个是hue抖动只给0.02因为色相偏移会直接改变“绿色”和“黄色”的定义给大了会让模型对成熟度的判断失去依据。saturation给0.3是允许芒果表面光泽有一定变化这个对提升鲁棒性有帮助。验证集不增强但Resize和CenterCrop要保持和训练一致否则验证集的输入分布会和训练集对不上指标看着低实际是预处理差异造成的。3.2 模型选型为什么最后常用EfficientNet-B0九千张图、四分类任务模型容量不需要很大。我在类似规模的图像分类数据集上试过ResNet18、ResNet50、EfficientNet-B0和MobileNetV3体感差异很直接模型特点在小型数据集上的体验适合场景ResNet18参数量小、收敛稳定很快出结果准确率不差快速验证、教学基准ResNet50容量更大数据量不够时容易过拟合数据规模到几万张后再考虑EfficientNet-B0参数效率高验证集top-1通常比ResNet18略好性价比首选MobileNetV3极轻量精度稍有下降边缘设备部署如果只是为了跑通流程ResNet18是零调试成本的选择。如果希望一个模型兼顾效果和部署效率EfficientNet-B0通常更划算。用timm搭建很直接import timm model timm.create_model( efficientnet_b0, pretrainedTrue, num_classes4 # 改成你数据集的类别数 )这里有个容易忽略的点timm里不同模型的预训练权重分布和结构差异很大主线代码应该把模型名做成命令行参数方便后面串行对比ResNet18和EfficientNet-B0。我在项目里一般写成--model efficientnet_b0跑完一组换一组验证集结果摆在一起再决定用谁。3.3 训练参数与完整PyTorch脚本batch size、学习率、加权采样与早停模型结构选好了训练参数直接决定收敛质量。九千张图规模下我常用的参数组合是batch size 32、初始学习率1e-3、AdamW优化器、weight decay 1e-4、cosine学习率衰减、最多40个epoch。batch size太小会导致梯度震荡太大在小数据集上反而容易收敛到平坦的次优解32是折中值。如果类别分布不均必须在DataLoader层面做加权采样而不是只在loss里加权重。WeightedRandomSampler的逻辑是让每张图被采到的概率与类别频率成反比这样每个batch里稀有类别不会缺席from collections import Counter from torch.utils.data import DataLoader, WeightedRandomSampler labels train_ds.targets counter Counter(labels) weights [1.0 / counter[i] for i in labels] sampler WeightedRandomSampler(weights, num_sampleslen(labels), replacementTrue)当某类只有几百张而另一类有四千张时这个设置对最终recall的影响比换模型还大。完整的训练脚本如下import argparse import random from collections import Counter from pathlib import Path import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader, WeightedRandomSampler from torchvision import datasets, transforms import timm def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def build_loader(data_root, batch_size): train_transform transforms.Compose([ transforms.RandomResizedCrop((224, 224), scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter( brightness0.15, contrast0.15, saturation0.3, hue0.02 ), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.228, 0.224, 0.225]), ]) val_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.228, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(str(data_root / train), transformtrain_transform) val_ds datasets.ImageFolder(str(data_root / val), transformval_transform) labels train_ds.targets counter Counter(labels) weights [1.0 / counter[i] for i in labels] sampler WeightedRandomSampler(weights, num_sampleslen(labels), replacementTrue) train_loader DataLoader( train_ds, batch_sizebatch_size, samplersampler, num_workers4, pin_memoryTrue ) val_loader DataLoader( val_ds, batch_sizebatch_size, shuffleFalse, num_workers4, pin_memoryTrue ) return train_loader, val_loader, train_ds.classes def main(): parser argparse.ArgumentParser() parser.add_argument(--data, typePath, defaultPath(./mango_maturity_dataset)) parser.add_argument(--batch-size, typeint, default32) parser.add_argument(--lr, typefloat, default1e-3) parser.add_argument(--epochs, typeint, default40) parser.add_argument(--out, typePath, defaultPath(./output)) args parser.parse_args() args.out.mkdir(parentsTrue, exist_okTrue) set_seed(42) device torch.device(cuda if torch.cuda.is_available() else cpu) train_loader, val_loader, classes build_loader(args.data, args.batch_size) model timm.create_model( efficientnet_b0, pretrainedTrue, num_classeslen(classes) ).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lrargs.lr, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxargs.epochs) best_acc 0.0 for epoch in range(args.epochs): model.train() train_loss 0.0 train_samples 0 for inputs, targets in train_loader: inputs, targets inputs.to(device), targets.to(device) optimizer.zero_grad() outputs model(inputs) loss criterion(outputs, targets) loss.backward() optimizer.step() train_loss loss.item() * inputs.size(0) train_samples inputs.size(0) model.eval() val_loss 0.0 val_samples 0 correct 0 with torch.no_grad(): for inputs, targets in val_loader: inputs, targets inputs.to(device), targets.to(device) outputs model(inputs) loss criterion(outputs, targets) val_loss loss.item() * inputs.size(0) val_samples inputs.size(0) preds outputs.argmax(dim1) correct (preds targets).sum().item() acc correct / val_samples scheduler.step() print(fepoch {epoch 1}/{args.epochs} ftrain_loss{train_loss / train_samples:.4f} fval_loss{val_loss / val_samples:.4f} fval_acc{acc:.4f}) if acc best_acc: best_acc acc torch.save(model.state_dict(), args.out / mango_maturity_best.pth) if __name__ __main__: main()脚本里的训练损失和验证损失都按实际样本数做加权平均不是简单按batch平均这样最后一个batch不完整时不会产生偏差。WeightedRandomSampler已经处理了类别不平衡所以CrossEntropyLoss可以不加类别权重。训练到40个epoch时通常验证准确率能稳定但如果验证损失连续5个epoch不降我会手动停掉。代码里没写早停逻辑是为了让初跑流程的人先看完整衰减过程调优阶段我一般会补一个patience5的早停。4. 评估模型并部署落地混淆矩阵、Grad-CAM可视化与台架验证训练结束只是第一步。模型在验证集上的准确率会骗人真正敢上线之前需要从业务误判、模型关注点和现场拍摄条件三个角度做交叉验证。4.1 业务视角读混淆矩阵误判方向比准确率更重要芒果成熟度分类的误判不是等价的。四分类里把“初熟”判成“成熟”和把“未熟”判成“过熟”业务后果完全不同。前者可能让半生果子进冷链到了客户手里品质不达标后者是直接在产线端浪费一颗好果。全局准确率90%看起来不错但具体到某个类别召回率可能只有70%。验证集上我习惯打印完整混淆矩阵和分类报告import numpy as np import torch from sklearn.metrics import confusion_matrix, classification_report model.eval() all_preds, all_labels [], [] with torch.no_grad(): for inputs, targets in val_loader: inputs inputs.to(device) targets targets.to(device) outputs model(inputs) preds outputs.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(targets.cpu().numpy()) cm confusion_matrix(all_labels, all_preds) print(混淆矩阵:\n, cm) print(classification_report(all_labels, all_preds, target_namesclasses))看到矩阵后先找两类错误相邻类别之间的大面积混淆以及跨类别误判。相邻混淆常见且可以容忍因为本来就存在标注边界模糊跨类别误判比如未熟直接判成过熟几乎一定是数据或模型出问题了需要回到数据体检阶段排查。如果业务上要求对某一类误判做出惩罚比如绝对不允许未熟果进成熟果渠道可以在推理阶段调整阈值而不是重新训练。做法是取softmax概率要求“成熟”类别的置信度高于一个经验阈值才放行否则转人工。阈值在验证集上扫一遍选满足业务约束的最优值。4.2 Grad-CAM可视化看模型是在看果皮还是背景这是我一直坚持的模型体检步骤。芒果成熟度的关键信息在果皮颜色、果蒂附近和病斑区域但模型很可能偷懒去学背景里的纸箱颜色或传送带边缘。Grad-CAM能直观地让“模型在看哪里”这个黑匣子显形。下面是一段最小可用的Grad-CAM实现不需要额外库import cv2 import numpy as np import torch def grad_cam(model, img_tensor, target_layer, class_idxNone): activations {} def hook_fn(module, inputs, outputs): activations[feat] outputs handle target_layer.register_forward_hook(hook_fn) logits model(img_tensor.unsqueeze(0)) handle.remove() if class_idx is None: class_idx logits.argmax(dim1).item() score logits[0, class_idx] grad torch.autograd.grad(score, activations[feat])[0] weights grad.mean(dim(2, 3), keepdimTrue) cam (weights * activations[feat]).sum(dim1).relu() cam cam.squeeze().detach().cpu().numpy() cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8) cam cv2.resize(cam, (img_tensor.shape[2], img_tensor.shape[1])) return cam # 示例调用 # cam grad_cam(model, img_tensor, model.blocks[6][0].conv2) # heatmap cv2.applyColorMap((cam * 255).astype(np.uint8), cv2.COLORMAP_JET)代码逻辑是用目标类别的得分对指定卷积层的特征图求梯度梯度均值作为通道权重再对特征图做加权求和后取ReLU得到的就是模型决策时依赖的空间区域。target_layer要选最后一个卷积阶段的输出EfficientNet-B0里通常是靠近末端的卷积层换成ResNet时则是model.layer4[-1]。如果热力图集中地覆盖在芒果果皮上说明模型学到了合理的颜色纹理特征如果热力图边缘压在背景纸箱或手掌上说明模型被非目标信号带偏了需要回到数据增强和预处理阶段调整。4.3 台架验证统一相机、光源和角度让模型走出数据集数据集里的图片是静态的产线现场是动态的。光照方向变化、相机白平衡设置、传送带颜色、芒果摆放角度任何一个变化都会让验证集准确率虚高的模型当场掉链子。所以模型在评估完成后都必须在和目标产线一致的台架上跑一轮现场验证。台架采集我一般会做这几件事固定相机位置和拍摄距离固定光源色温记录当天实际光照条件用和训练集一致的分辨率拍100到300张新图人工标注后单独做一次评估。代码沿用验证流程即可只是数据换成台架图片model.eval() img cv2.imread(field_001.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_tensor val_transform(Image.fromarray(img)).unsqueeze(0).to(device) with torch.no_grad(): logits model(img_tensor) prob torch.softmax(logits, dim1)[0] pred_idx prob.argmax().item() print(classes[pred_idx], prob[pred_idx].item())台架验证结果如果比验证集低五六个百分点通常不是模型问题而是数据分布偏移。这时候不要急着加数据先查一遍台架的拍摄参数是不是和训练集当时的采集条件一致。把台架图片按拍摄批次分组做对比比笼统地看整体准确率更能定位问题。注意台架验证阶段必须记录“设备型号曝光参数光源色温拍摄角度”四个字段。没有这批记录后续现场效果一旦波动你会分不清是光源老化还是模型退化。5. 芒果成熟度分类避坑5个数据与训练阶段的翻车点记录以下五个问题是我在类似图像分类数据集上反复见过的坑。每一条都是先看现象再给原因和对应处理办法。5.1 数据侧标签错位、交集样本和验证集水分坑1CSV行顺序和图像文件顺序不一致导致标签错位现象是训练loss下降很慢验证准确率停留在50%上下像是模型在瞎猜。原因多半是标注工具导出的CSV顺序和文件系统扫描顺序不同而代码里用行号做了隐式对齐。解决方法是全程用文件名做键建立映射不要信任任何顺序。label_map dict(zip(df[filename], df[label])) # 后续 Dataset 每次都通过 img_path 查 label_map坑2验证集准确率高但台架验证崩盘现象是模型在验证集上准确率96%一到产线现场立刻掉到80%。原因是验证集和训练集同源随机划分时把相同光线条件下的图片分到了两边模型其实是记住了背景。解决方法是按拍摄批次或时间戳划分训练验证集而不是按图片随机划分台架验证数据永远独立于训练集存在。坑3“初熟”和“成熟”交界样本被标注成不同标签现象是某一类召回率明显偏低检查混淆矩阵看到大量相邻类别互混。原因是标注员对半绿半黄的图片判断不一致。解决办法是建立各类别的标准参考图让标注员先看标准图再判断训练时可以把置信度不高的交界样本从训练集拿掉单独放一个“待人工复核”目录。5.2 训练与部署侧过拟合、学习率过大和预训练权重被破坏坑4训练收敛很快但新批次图片准确率掉8%现象是训练集和验证集都表现完美换一箱新到的芒果就不行了。原因是四分类任务太简单模型把背景、阴影或反光纹理当成判别特征。解决方向是加强数据增强尤其是RandomResizedCrop的scale下调到0.6并加入RandomErasing同时用Grad-CAM检查模型关注区域是否还在芒果果皮上。坑5初始学习率过大预训练权重被直接打飞现象是loss不降反升甚至出现NaN。原因是一上来就用1e-2学习率全量微调把ImageNet预训练的特征完全破坏。芒果图片和ImageNet自然图像差距大需要更温和的微调节奏。我常用的做法是分两段训练前5个epoch冻结backbone只训练分类头学习率5e-4之后解冻backbone学习率降到1e-4。下面是冻训练头的写法for name, param in model.named_parameters(): if classifier not in name: # 以 EfficientNet 为例分类头通常叫 classifier param.requires_grad False # 优化器只传入 requires_gradTrue 的参数 optimizer torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr5e-4 )解冻后再把学习率调到1e-4通常能避免大幅震荡。6. 让9,000张图持续增值数据集版本化、增量标注与二次校验的工程习惯模型上线后原始数据集就不该再动了里面每一张图都是可追溯的历史资产。我现在的习惯是解压出来的原始目录改成只读所有清洗、去重、标签修正都在一个独立的工作副本里做并且每个版本都记录变更原因。版本管理不一定要用git数据文件太大git会越来越重。我倾向于用目录加JSON元数据的方式dataset_v1_original/ # 只读 dataset_v2_cleaned/ # 去重、损坏图移除后 train/ val/ labels.csv dataset_v2_meta.json # 记录清洗规则和变更记录meta文件里固定写训练配置的哈希值包括数据版本、transforms配置、模型名、optimizer参数、最终阈值。这样三个月后回来看还能复现当时的训练环境。增量数据是让模型持续保持效果的关键。产线运行一段时间后把模型预测置信度低于0.7的图片自动留存下来定期用标注工具复核再在新版本数据集中补充进去。这类难例比随机采集的图片对模型提升更大。标注工具上我会选支持分类标签导出的开源平台导出后统一转回ImageFolder格式保持训练脚本不变。我自己在这类图像分类数据集上走过不少弯路最深的体会是数据集的价值不在那九千张图而在你如何对待它。原始数据只读、清洗版本分离、每个实验可复现再加上台架验证和版本化增量更新这套习惯让九千张图在项目里存活了不止一轮迭代。希望这些踩坑记录能让你少走几步弯路也祝你的芒果分级项目早日落地。本文还有配套的精品资源点击获取
返回列表