简介:这是一套面向深度学习图像分类学习与研究者的芒果成熟度已标注数据集,共约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( brightness=0.15, contrast=0.15, saturation=0.3, hue=0.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', pretrained=True, num_classes=4 # 改成你数据集的类别数 )这里有个容易忽略的点: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_samples=len(labels), replacement=True)当某类只有几百张而另一类有四千张时,这个设置对最终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(seed=42): 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( brightness=0.15, contrast=0.15, saturation=0.3, hue=0.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'), transform=train_transform) val_ds = datasets.ImageFolder(str(data_root / 'val'), transform=val_transform) labels = train_ds.targets counter = Counter(labels) weights = [1.0 / counter[i] for i in labels] sampler = WeightedRandomSampler(weights, num_samples=len(labels), replacement=True) train_loader = DataLoader( train_ds, batch_size=batch_size, sampler=sampler, num_workers=4, pin_memory=True ) val_loader = DataLoader( val_ds, batch_size=batch_size, shuffle=False, num_workers=4, pin_memory=True ) return train_loader, val_loader, train_ds.classes def main(): parser = argparse.ArgumentParser() parser.add_argument('--data', type=Path, default=Path('./mango_maturity_dataset')) parser.add_argument('--batch-size', type=int, default=32) parser.add_argument('--lr', type=float, default=1e-3) parser.add_argument('--epochs', type=int, default=40) parser.add_argument('--out', type=Path, default=Path('./output')) args = parser.parse_args() args.out.mkdir(parents=True, exist_ok=True) 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', pretrained=True, num_classes=len(classes) ).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=args.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(dim=1) correct += (preds == targets).sum().item() acc = correct / val_samples scheduler.step() print(f"epoch {epoch + 1}/{args.epochs} " f"train_loss={train_loss / train_samples:.4f} " f"val_loss={val_loss / val_samples:.4f} " f"val_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不降,我会手动停掉。代码里没写早停逻辑,是为了让初跑流程的人先看完整衰减过程;调优阶段我一般会补一个patience=5的早停。
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(dim=1).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_names=classes))看到矩阵后,先找两类错误:相邻类别之间的大面积混淆,以及跨类别误判。相邻混淆常见且可以容忍,因为本来就存在标注边界模糊;跨类别误判比如未熟直接判成过熟,几乎一定是数据或模型出问题了,需要回到数据体检阶段排查。
如果业务上要求对某一类误判做出惩罚,比如绝对不允许未熟果进成熟果渠道,可以在推理阶段调整阈值,而不是重新训练。做法是取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_idx=None): 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(dim=1).item() score = logits[0, class_idx] grad = torch.autograd.grad(score, activations['feat'])[0] weights = grad.mean(dim=(2, 3), keepdim=True) cam = (weights * activations['feat']).sum(dim=1).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, dim=1)[0] pred_idx = prob.argmax().item() print(classes[pred_idx], prob[pred_idx].item())台架验证结果如果比验证集低五六个百分点,通常不是模型问题,而是数据分布偏移。这时候不要急着加数据,先查一遍台架的拍摄参数是不是和训练集当时的采集条件一致。把台架图片按拍摄批次分组做对比,比笼统地看整体准确率更能定位问题。
注意:台架验证阶段必须记录“设备型号+曝光参数+光源色温+拍摄角度”四个字段。没有这批记录,后续现场效果一旦波动,你会分不清是光源老化还是模型退化。
5. 芒果成熟度分类避坑:5个数据与训练阶段的翻车点记录
以下五个问题是我在类似图像分类数据集上反复见过的坑。每一条都是先看现象,再给原因和对应处理办法。
5.1 数据侧:标签错位、交集样本和验证集水分
坑1:CSV行顺序和图像文件顺序不一致导致标签错位现象是训练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_grad=True 的参数 optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=5e-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格式,保持训练脚本不变。
我自己在这类图像分类数据集上走过不少弯路,最深的体会是:数据集的价值不在那九千张图,而在你如何对待它。原始数据只读、清洗版本分离、每个实验可复现,再加上台架验证和版本化增量更新,这套习惯让九千张图在项目里存活了不止一轮迭代。希望这些踩坑记录能让你少走几步弯路,也祝你的芒果分级项目早日落地。
本文还有配套的精品资源,点击获取