ARTICLE DETAIL

资讯详情

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

九千张芒果成熟度图像分类实战:数据审查、训练与排坑

九千张芒果成熟度图像分类实战:数据审查、训练与排坑 简介芒果成熟度图像分类数据集面向计算机视觉与深度学习初学者、农产品智能分拣及图像分类项目开发者提供约9000张已标注图片覆盖成熟、未成熟、损坏三类芒果。数据已按训练集、验证集、测试集划分完毕目录内同类图片归置清晰便于直接用于分类模型训练、迁移学习及算法精度对比。包体共2000个文件以jpg图像为主另附show.py可视化脚本与json标签配置文件整体大小约261.44MB通过运行show脚本可快速预览样本及标注情况省去手动整理数据集的繁琐过程。全集图像命名规范批次来源清晰标注格式统一适合作为图像分类入门练习或成熟度判别项目的基准数据集目前已有61人学习下载可用于复现分类网络改进实验、搭建计算机视觉完整项目等场景帮助读者快速验证模型效果。1. 芒果成熟度图像分类数据集九千张已标注图像先别急着跑训练要做芒果成熟度图像分类手头这份九千张已标注数据集看起来比 CIFAR-10 还“够用”可一旦从图像分类论文的通用流程落到自家数据上最先翻车的往往不是模型而是数据本身。九千张听着不少但农业数据集天然夹带光照、拍摄距离、品种和批次差异若标注口径没统一好训练出来的“成熟度分类器”很可能只在验证集上自嗨。这篇文章面向准备拿这个数据集落地成熟度识别的工程师按“审数据、做划分、搭基线、排坑、调阈值”的顺序讲清楚每一步代码都按可复现的最小粒度写新手照着跑能通老手可直接跳过科普看参数和边界。2. 动手前先审数据类分布、标签口径与同源泄漏排查拿到“芒果成熟度图像分类数据集【已标注约9,000张数据】”第一步不是写 dataloader而是先回答三个问题标签是几级制、每个类占多少、图像是不是在同一批次里拍出来的。这三个问题的答案分别决定损失函数怎么设、类别要不要加权以及训练/验证集能不能按文件名单纯随机拆分。图像分类数据显示出的问题有一半藏在这一步审清楚能避免后续所有实验结论失真。2.1 人工标注的成熟度分级标到什么程度算“够用”芒果实成熟度的标注口径按项目阶段看至少有三种。第一种是二分类只分“可采收”和“不可采收”。标注成本最低分拣前置场景偶尔用但边界太模糊七八分和九分熟的图像可能只有皮色饱和度上的细微差别标注员自己都会犹豫。第二种是三分类常见是未熟、半熟、全熟各类别间有肉眼能对齐的分界这也是此类数据集最可能采取的方案对采摘分级和仓储调度都够用。第三种是四级以上比如 1 到 5 级或者按果皮着色面积百分比打标精度上去了标注一致性必然下降同一张图不同标注员很容易错到相邻档位。拿到“已标注”数据时我会先抽几十张小图自己看一眼确认标签到底落在哪个口径。如果数据集没带独立的说明文档就按目录名判断ripe/unripe 两个目录是二分类unripe/half_ripe/full_ripe 就是三分类起步。别小看这一步后面如果发现验证集 loss 总是压不下去相当一部分情况是“过熟”图被打进了“成熟”类模型左右互搏。确认口径后建议顺手把类别名与编号的映射固定成一份 json 或 yaml。做混淆矩阵和输出分级结论时类别编号错一位成熟和未熟可能全反。这类事故我见过不止三次原因是有人用手工维护的标签列表写到了旧顺序。2.2 写脚本统计类分布九千张图的类别不均衡一目了然图像分类数据集最常见的问题是类别不均衡农业数据尤其明显。芒果成熟期挂在树上就那么几天采集的人分几次去果园最后一次拍到的全熟样张数肯定远超前几次。九千张里可能五千张是全熟两千张是半熟剩下才轮到未熟不把分布看清楚后面模型对少数类的学习就是空转。用一段短脚本能立刻把分布摸出来# count_classes.py from pathlib import Path from collections import Counter root Path(mango_dataset) # 按需增加后缀 suffixes {.jpg, .jpeg, .png, .bmp, .webp} counts Counter() for subdir in root.iterdir(): if subdir.is_dir(): n sum(1 for p in subdir.rglob(*) if p.suffix.lower() in suffixes) counts[subdir.name] n total sum(counts.values()) print(f总图像数: {total}) for name, n in counts.most_common(): print(f{name}: {n} 张, 占比 {n / total:.1%})逻辑说明脚本用 rglob(*) 递归统计能覆盖两层甚至三层的子目录结构同时只统计指定图像后缀避免把 Thumbs.db、标注 txt 混进计数。跑完如果某类占比超过 60%决策就变了要么在训练时做加权采样要么在损失函数里加权重第三节再展开。再看一眼图像尺寸分布。很多农业数据集并不全是标准 224×224而是手机原图 3024×4032 与批量脚本压过的 512×512 混在一起# get_image_sizes.py from PIL import Image from pathlib import Path src Path(mango_dataset) sizes {} for img in src.rglob(*.jpg): with Image.open(img) as im: sizes[im.size] sizes.get(im.size, 0) 1 for size, n in sorted(sizes.items(), keylambda x: -x[1])[:10]: print(size, n, 张)尺寸分布的意义在于预处理策略如果大部分是大图RandomResizedCrop 直接裁即可如果多分辨率混杂就需要在 Dataset 类里统一先 resize 再增强否则模型会莫名其妙学到“分辨率特征”。2.3 同源图像最大的风险训练集和验证集不能一起洗这个坑如果不提前写出来几乎一定会在部署时爆雷。所谓同源就是同一台设备、同一天、同一棵树上连拍的一组图。假设标注员某天对着同一颗芒果拍了 200 张不同角度机器随机拆 train/val 时这 200 张会被打散进两边模型等于提前“见过”了验证集里同源图像的背景、光照和视角验证集准确率虚高到 98%换一台手机实拍立刻掉到 75%。常见的防御做法是文件路径里带批次标识就按批次划分没有批次标识就按 EXIF 拍摄时间分组# group_by_time.py from PIL import Image from PIL.ExifTags import TAGS from pathlib import Path def get_datetime(img_path): with Image.open(img_path) as im: exif im._getexif() if not exif: return unknown for tag_id, value in exif.items(): if TAGS.get(tag_id) DateTimeOriginal: return value return unknown groups {} for img in Path(mango_dataset).rglob(*.jpg): dt get_datetime(img)[:7] # 按年月合并 groups.setdefault(dt, []).append(img) for key, items in sorted(groups.items()): print(key, len(items))这段代码拿到拍摄时间的“年月”做粗分组目的是判断数据是否存在时间上的聚类。如果某几天的照片量明显高于其他天划分集合时就要以天为最小单位。前提是图像的 EXIF 还在很多聊天工具传输、压缩脚本生成的图会丢元数据这时只能回退到“文件名前缀 人工确认采集批次”。标题里没有附带分组信息时我一般默认按文件名里的批次段做划分宁可让 val_acc 掉一两个百分点也要保住部署时的真实泛化。3. 目录重整与数据划分把九千张图拆成训练/验证/测试三份数据审完之后进入工程化阶段。目标是把九千张已标注图像整理成 PyTorch 的 ImageFolder 能直接读的三目录结构train/类名/图、val/类名/图、test/类名/图。这里每一步都有可复现的命令和参数边界。3.1 统一图像目录结构让 ImageFolder 一步读全torchvision 的 datasets.ImageFolder 默认把“子目录名 类名”图像文件全部放进类目录下即可。新手最容易犯的错是把所有图平铺在 mango/all/ 下外边配一份 labels.txt这种做法不是不能训但标签和文件名的对应关系错位概率极高排查起来非常痛苦。我一般会先做一次目录重整# reorganize.py from pathlib import Path import shutil src Path(mango_dataset) out Path(mango_split) # 先建好三份目录骨架类名以你的实际标注为准 for split in (train, val, test): for cls in [unripe, half_ripe, ripe, overripe]: (out / split / cls).mkdir(parentsTrue, exist_okTrue) # 这里用文件名前缀划分实际请替换成你自己的批次/时间分组字段 for img in src.rglob(*.jpg): cls img.parent.name prefix img.name.split(_)[0] if prefix in {01, 02, 03, 04, 05, 06}: split train elif prefix in {07, 08}: split val else: split test shutil.copy2(img, out / split / cls / img.name)逻辑说明脚本按文件名前缀做了“假想”的 6:2:2 划分实际项目里这一条必须替换成按采集批次或拍摄时间的真实字段。copy2 而不是 move 的原因是划分规则一旦失误还能重新跑原图还在损失为零。类名必须全程保持一致原始目录如果叫 mature目标目录写 ripeImageFolder 的 class_to_idx 顺序会和标签映射表对不上后面混淆矩阵注释全乱。整理完再做一次数量核对防止目录骨架建错或 copy 过程丢了样本。这一步看似多余却是整个流程里最便宜的后悔药。3.2 划分策略为什么不能随机洗牌而要按采集批次分层随机洗牌在图像分类通用数据集上可用在农业数据集上不推荐。原因回到上一章说的同源问题同一批拍的图是强相关的随机洗牌会把这一批撕成两半一半进 train 一半进 val验证集失去意义。如果数据集本身能确认没有同源问题我用 md5 做确定性分桶而不是 random.shuffle保证脚本重复执行时每个文件名永远进同一个集合# split_by_hash.py import hashlib def assign_split(filename: str) - str: # 对文件名做 md5前 4 位十六进制转成 0-99 的整数 h hashlib.md5(filename.encode(utf-8)).hexdigest() r int(h[:4], 16) % 100 if r 60: return train elif r 80: return val else: return test参数说明60/80 两个阈值对应 6:2:2。md5 分桶的好处是可复现不会出现“这次这张图在 train下次在 val”的幽灵问题。它的局限是只能保证稳定不能解决同源所以有批次信息时仍然优先按批次分组再在批次组内部做 md5 切分。验证集和测试集建议各占 20%测试集只在最终评估时碰一次平时调参只看 val。3.3 增强与归一化参数九千张数据量下如何取舍九千张对图像分类来说属于中等偏小配合 ImageNet 预训练权重是能训练出可用模型的。增强不要无脑堆芒果图像有其特殊性翻转是安全的但随机裁剪若 scale 取得太低会把芒果主体裁掉模型转头去学背景纹理。我常用的起步参数如下# transforms.py from torchvision import transforms train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.05), 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]), ])需要解释的三个改点。RandomResizedCrop 的 scale 下限设到 0.6避免切掉芒果主体ColorJitter 的 hue 只给 0.05因为从青到黄的皮色本身就是成熟度信号动太多等于把标签信息抹掉评估阶段不做随机增强固定 Resize 到 256 再 CenterCrop 到 224保证每轮算出的 val_acc 是严格可比的。mean/std 沿用 ImageNet 统计值这是迁移学习里验证过无数次的通用形态不要自己发明一套归一化参数。4. 用 ResNet 搭成熟度分类基线图像分类模型的选型与训练数据整理到 ImageFolder 结构后进入模型训练环节。这里的任务是搭一个能快速确认数据质量、又能给后续优化提供对比基线的图像分类模型。4.1 选型逻辑为什么先上 ResNet 而不是 Transformer现在聊图像分类很多人第一反应是 Vision Transformer、Swin 或最新 ConvNeXt。但九千张图像、目标高度相似的芒果成熟度分类是一个典型的中小规模数据任务。除非有在果品数据集上预训练过的专用权重否则直接上 ViT训练稳定性和超参敏感度都不友好。我选择基线的逻辑是先用 ResNet18 或 ResNet50 验证数据通道再根据后续效果决定是否更换更大容量模型。ResNet 的残差结构在小数据集迁移上有两个实打实的优势一是对学习率波动的容忍度高调参窗口宽二是配套的 tricks比如 warmup、余弦退火、SGD momentum都是先在 ResNet 上被反复验证过的遇到问题能搜到大量现成经验。工程落地讲究可维护ResNet 优先是性价比最高的起点。4.2 最小可运行训练脚本从 ImageFolder 到 loss 曲线下面这段脚本基于 torchvision 新版本 weights 参数写法PyTorch 2.x 均可运行# train_mango.py import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, models, transforms BATCH_SIZE 32 EPOCHS 30 LR 1e-3 NUM_WORKERS 8 train_tf transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2, hue0.05), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(mango_split/train, transformtrain_tf) val_ds datasets.ImageFolder(mango_split/val, transformval_tf) train_loader DataLoader(train_ds, batch_sizeBATCH_SIZE, shuffleTrue, num_workersNUM_WORKERS) val_loader DataLoader(val_ds, batch_sizeBATCH_SIZE, shuffleFalse, num_workersNUM_WORKERS) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) num_classes len(train_ds.classes) model.fc nn.Linear(model.fc.in_features, num_classes) device torch.device(cuda if torch.cuda.is_available() else cpu) model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lrLR, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxEPOCHS) for epoch in range(1, EPOCHS 1): model.train() total_loss 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) model.eval() correct total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) val_acc correct / total avg_train_loss total_loss / len(train_ds) print(fEpoch {epoch:02d} | loss {avg_train_loss:.4f} | val_acc {val_acc:.4f}) scheduler.step()代码里有两个对初学者不友好的点必须说明。第一行 model.fc nn.Linear(model.fc.in_features, num_classes)ResNet18 原来最后是 1000 类的全连接层这里换成芒果的类别数不改这一行会直接报维度不匹配。第二个是验证集的 DataLoader 设了 shuffleFalse让评估时 batch 顺序固定结果可复现。训练过程中如果 val_acc 在某个 epoch 后只在小范围内波动就不要再傻跑 100 轮保存最优 checkpoint 后直接改参数重来。4.3 学习率、Epoch 与 BatchSize图像分类最容易调崩的三个参数很多工程师跑这种项目时上来就是 lr0.1、epoch100结果第一个 epoch 就把 ImageNet 预训练权重冲散后面 loss 出现锯齿状震荡。原因很简单预训练权重已经有不错的语义特征学习率过大等于把已经学好的表征一把抹掉。我常用的三档起点全量微调用 1e-3只训练最后一层、冻结主干时用 1e-4 到 3e-4数据量更小或类间高度相似时降到 3e-5。Epoch 在 30 到 50 之间配合 early stopping 兜底。BatchSize 默认 32同一个数据集从 32 调到 16val_acc 可能波动 2 到 3 个百分点这是因为批归一化层的统计量对 batch 大小敏感。显存不够就减小 batch同时同步调低学习率比如 batch 降到 16 时 lr 从 1e-3 调到 5e-4。5. 从训练到上线的常见坑泄漏、错标和加载瘫痪的排查记录整个流程走到这里最消耗时间的不是训练本身而是排查那些让 val_acc 虚高或训练直接中断的工程问题。这节列了五条我实际踩过或反复见过的坑每条都按“现象、原因、解决”来写方便你对照排查。5.1 验证集准确率高、实拍一测就掉点同源泄漏在作祟现象验证集准确率跑到 96%换到生产环境的分拣线或手机实拍准确率掉到 75% 以下。原因数据清洗阶段没有按采集批次划分训练/验证集。同一颗芒果在不同角度、不同光线下的连续帧被随机分到两边模型等于是背诵了验证集中的同源答案。解决回到第 2 章节的数据审查逻辑按拍摄时间或文件名批次重新划分三套集合重训后观察差距。名字改一下没有意义必须从分组源头改。如果怀疑存在复制缩放造成的近似重复图先做一次 MD5 去重# dedup.py import hashlib from pathlib import Path from collections import defaultdict seen defaultdict(list) for img in Path(mango_dataset).rglob(*.jpg): h hashlib.md5(img.read_bytes()).hexdigest() seen[h].append(img) for h, items in seen.items(): if len(items) 1: print(f重复组 {h}: {[str(p) for p in items]})MD5 只能发现完全相同的文件缩放后缀需要另外用感知哈希但先跑这步能把最明显的泄漏源清掉。5.2 训练 loss 正常、验证 loss 震荡学习率策略没配合数据量现象训练 loss 稳定下降验证准确率却在 82% 到 88% 之间反复横跳每个 epoch 都波动。原因余弦退火周期 T_max 没和实际 epoch 数对齐。比如设了 T_max100但只跑 30 epoch学习率在最后阶段仍处于高位或者用 lr1e-2 跑一个小数据集参数一直在深谷边缘弹跳。解决让 CosineAnnealingLR 的 T_max 等于 EPOCHS这是最直接的一步。更进一步在训练前加 5 个 epoch 的 warmup把学习率从 1e-4 线性升到 1e-3迁移初期 loss 跳变会明显收敛。同时每 5 个 epoch 存一次 checkpoint最后回滚到 val_acc 最高的那一个这是最朴素的后悔药。5.3 num_workers 一调高就崩多进程加载瘫痪与 CPU 瓶颈现象Windows 上把 num_workers 从 2 改到 8DataLoader 报 RuntimeError: DataLoader worker (pid xxx) is killed by signal或者训练时 GPU 利用率只有 60%CPU 占用却 100%。原因前者是 Windows 多进程采用 spawn 模式子进程里重初始化图像解码库导致崩溃后者是 num_workers 并不是越大越好每个 worker 都在做 JPEG 解码还没解码完 GPU 已经等闲了。解决确认代码有 ifname main 保护把 num_workers 降到 4 或 6。还崩就改用 batch_size8、num_workers2 并关闭 pin_memory。另一个有效手段是先把原图统一 resize 到 640 再存盘训练时再随机裁剪解码量直接下降一大截GPU 利用率能明显拉起来。5.4 类别不均衡让模型偷懒少数类召回率只有 60% 怎么办现象val_acc 有 91%但看混淆矩阵未熟类召回率只有 61%全熟类却高达 99%。原因第 2 章统计出的类别分布不对称少数类在训练过程中提供的梯度太小CrossEntropyLoss 被多数类主导。解决两种方案按资源选。一种是给损失函数加 class weight计算方式是每类样本数的倒数再归一化另一种是 WeightedRandomSampler它对样本按权重采样不改 loss 逻辑适合快速验证from torch.utils.data import WeightedRandomSampler, DataLoader labels [train_ds.targets[i] for i in range(len(train_ds))] class_count torch.bincount(torch.tensor(labels)) weight_per_class 1.0 / class_count.float() sample_weights [weight_per_class[labels[i]] for i in range(len(labels))] sampler WeightedRandomSampler(sample_weights, num_sampleslen(labels), replacementTrue) train_loader DataLoader(train_ds, batch_size32, samplersampler)参数说明num_samples 保持 len(labels) 表示每个 epoch 采样和原数据集同样多的样本replacementTrue 允许少数类图像被重复抽出。应用后少数类召回率一般能涨 10 个百分点以上代价是少数类反复看到同一批图像有过拟合风险。验证集评估必须用原始分布不要用加权后的分布否则 val_acc 会虚高。5.5 成熟与过熟互相错标注边界模糊的兜底策略现象混淆矩阵显示成熟和过熟两类互相错错误方向多数是“过熟被认成成熟”。原因标注员把黑斑、凹陷这些病斑当成了成熟特征。其实黑斑更可能是炭疽病或机械损伤与成熟度无关模型学到的是“皮面斑驳”而不是“色泽蜡黄”。这类错标是农业图像分类特有且高频的。解决在成熟度分类之前加一层病斑过滤如果数据集里本身没有病斑标注就先人工把带显著黑斑的过熟样本挑出来剔除。如果边界仍然模糊就不要在损失函数层面强行硬分而是在输出阶段处理把成熟和过熟两个类别的 softmax 概率合并成“可上市成熟度”低于阈值走人工复核。这个阈值调优放最后一章。6. 用混淆矩阵和概率阈值收尾把分类输出变成可用的分级结论训练完成后第一步是跑测试集看混淆矩阵。九千张数据集训出的模型成熟和过熟这两类一定会出现互错非对角项集中在相邻档位说明模型思路是正常的如果出现“未熟被频繁判成过熟”那更可能是标注本身有硬伤需要回到标签层面复查。# confusion_matrix.py import torch from sklearn.metrics import confusion_matrix preds, gts [], [] model.eval() with torch.no_grad(): for images, labels in test_loader: images images.to(device) out torch.softmax(model(images), dim1) preds.extend(out.argmax(dim1).cpu().tolist()) gts.extend(labels.cpu().tolist()) cm confusion_matrix(gts, preds) print(cm)拿到混淆矩阵后做一个选择如果错误集中在“成熟”和“过熟”部署时不直接用 argmax 的最大值决定档位而是对这两类设置概率阈值。比如“成熟”类概率低于 0.85 时判为“待复检”由人工介入。这个操作用准确率换召回率表面上牺牲了几个百分点实际却避免了一条分拣线上成百上千颗果子被误分。我对阈值有一个习惯先用验证集画出每类的 precision-recall 曲线再看混淆矩阵的调包位置选择精确率与召回率交叉点附近作为阈值不要在验证时拍脑袋用 0.5。所有阈值调完之后才允许碰测试集得到最终的泛化数字。成熟度分级这种边界模糊的图像分类任务宁可让机器犹豫也别让它强行二选一这一点是我在这个方向里踩了多次之后的固定解法。希望帮到你。本文还有配套的精品资源点击获取
返回列表