资讯详情

资讯详情

花生叶片缺陷分类实战:770张标注数据训练与避坑指南

简介本资源为花生叶片缺陷图像分类数据集面向从事图像分类、农业病害识别及深度学习入门与进阶的开发者与研究者可用于训练和评估分类网络解决叶片病害自动判别问题。压缩包共780个文件以777张jpg图像为主体另含1个py可视化脚本、1个png与1个json标注文件整体约23.18MB数据已预处理可直接作为分类网络输入。数据集按3类划分疾病叶片、死掉的叶片、健康叶片并已划分训练集与测试集各类图片分目录存放便于直接加载。运行包内show脚本可快速可视化样本分布与类别情况。目前已有110人学习下载。读者可获得一份开箱即用的分类数据用于模型训练、对比实验与网络改进验证同时可结合作者在图像分类、分割方向的系列内容快速搭建完整实验流程。1. 花生叶片缺陷分类770 张已标注数据能跑出什么名堂拿到「花生Peanut叶片缺陷图像分类数据集【已标注约770张数据】」这个标题多数人第一反应是770 张够吗我一开始也这么想。去年帮一个做农业视觉的团队看花生病害识别方案他们手头只有几百张田间手机拍的叶片照背景杂乱、光照不均跟公开的 PlantDoc 那类干净数据集完全不是一个画风。但恰恰是这种「脏数据」才更接近真实部署场景。这个数据集的价值不在于量大而在于它把花生叶片缺陷这个细分场景的标注工作替你做了——分类任务里标注成本往往比模型本身更贵。它适合三类人想入门图像分类但不想碰 MNIST、CIFAR 这类玩具数据的开发者做农业信息化、需要快速验证病害识别可行性的工程团队以及想拿一个小数据集练迁移学习、数据增强、类别不平衡处理的学生和研究者。770 张不算多但足够你把一条完整的训练流水线跑通并且看清小样本分类的真实边界在哪。2. 先搞清楚这 770 张图到底该怎么读2.1 分类数据集的目录结构与标签约定图像分类数据集最常见的组织方式是按类别分文件夹每个文件夹名就是标签。花生叶片缺陷数据集大概率也是这个结构比如healthy/、leaf_spot/、rust/、blight/这样的目录。在动手写任何代码之前先用几条命令把数据的真实分布摸清楚这一步比急着搭模型重要得多。# 查看数据集根目录下的类别文件夹 ls -1 dataset/ # 统计每个类别的图片数量快速判断是否类别不平衡 for dir in dataset/*/; do echo -n $dir: find $dir -type f \( -name *.jpg -o -name *.png -o -name *.jpeg \) | wc -l done第一段命令列出所有类别目录确认标签体系和你预期的是否一致。第二段循环统计每个类别的文件数输出结果直接告诉你哪几个类样本多、哪几个类样本少。参数上注意-type f限定只统计文件后面用\( ... \)把多种图片扩展名括起来避免把.DS_Store或标注中间文件也算进去。如果发现某个类别只有三四十张而最多的类别有两百多张那类别不平衡就是你后面必须处理的核心问题而不是可选项。2.2 用 Python 做一次数据体检光看数量还不够图片尺寸是否统一、有没有损坏文件、通道数是否一致这些都会在训练时变成玄学报错。下面这段脚本做一次完整体检。import os from PIL import Image from collections import defaultdict root dataset stats defaultdict(list) corrupted [] for cls in sorted(os.listdir(root)): cls_dir os.path.join(root, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): fpath os.path.join(cls_dir, fname) try: with Image.open(fpath) as img: # 记录宽高和模式RGB / L / RGBA stats[cls].append((img.size, img.mode)) except Exception as e: corrupted.append((fpath, str(e))) for cls, items in stats.items(): sizes set(s for s, _ in items) modes set(m for _, m in items) print(f{cls}: {len(items)} 张, 尺寸种类{len(sizes)}, 色彩模式{modes}) print(f\n损坏文件数: {len(corrupted)}) for path, err in corrupted[:5]: print(f {path} - {err})这段代码的核心逻辑是逐类遍历、逐图打开用img.size和img.mode收集尺寸与色彩模式。关键点在于Image.open是惰性的不会立刻解码全部像素所以速度可以接受但遇到截断文件时会在打开阶段就抛异常正好用来筛损坏图。输出里如果某个类别的尺寸种类超过 5 种说明图片来自不同设备或经过不同裁剪训练前必须统一 resize。色彩模式如果混了L灰度和RGBDataLoader 拼 batch 时会直接报维度错误这个坑我踩过不止一次。提示如果发现大量图片长宽比差异很大不要无脑拉伸到正方形先考虑中心裁剪或 padding否则叶片形状会被扭曲模型学到的是变形特征而不是缺陷特征。2.3 划分训练集、验证集、测试集时别犯的错770 张数据常见做法是按 7:1.5:1.5 或 8:1:1 划分。但小数据集划分有一个隐蔽陷阱如果同一片叶子被拍了多张照片随机划分会让同一片叶子同时出现在训练集和验证集里验证准确率虚高上线就翻车。判断方法很简单看文件名是否有连续编号或相似前缀。import os import random import shutil random.seed(42) # 固定种子保证可复现 root dataset out split ratios {train: 0.7, val: 0.15, test: 0.15} for cls in os.listdir(root): cls_dir os.path.join(root, cls) if not os.path.isdir(cls_dir): continue files sorted(os.listdir(cls_dir)) random.shuffle(files) n len(files) n_train int(n * ratios[train]) n_val int(n * ratios[val]) splits { train: files[:n_train], val: files[n_train:n_train n_val], test: files[n_train n_val:] } for split, flist in splits.items(): dst os.path.join(out, split, cls) os.makedirs(dst, exist_okTrue) for f in flist: shutil.copy(os.path.join(cls_dir, f), os.path.join(dst, f)) print(f{cls}: train{len(splits[train])}, val{len(splits[val])}, test{len(splits[test])})random.seed(42)是后悔药保证每次运行划分结果一致否则调参时数据变了你都找不到原因。按类别分别划分而不是全局打乱是为了保证每个 split 里类别比例一致避免某个类别在验证集里一张都没有。如果文件名有规律比如leaf001_a.jpg、leaf001_b.jpg那应该在 shuffle 之前按叶片 ID 分组整组一起进同一个 split这一步多花十分钟能省掉后面一周的困惑。3. 从零搭一条能跑通的训练流水线3.1 用 torchvision 构建 Dataset 和 DataLoader小数据集不建议一上来就上复杂框架torchvision 自带的能力足够。核心是把前面划分好的目录接进来同时把增强策略写对。import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms train_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.3), transforms.RandomRotation(degrees25), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_ds datasets.ImageFolder(split/train, transformtrain_tf) val_ds datasets.ImageFolder(split/val, transformval_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4) print(类别映射:, train_ds.class_to_idx) print(训练样本数:, len(train_ds), 验证样本数:, len(val_ds))增强策略里水平翻转和垂直翻转对叶片图像是安全的因为叶片方向本身没有语义。旋转 25 度以内也合理再大就可能把缺陷转出画面。ColorJitter 模拟田间不同光照条件这对花生叶片这种户外拍摄场景特别重要。Normalize 用的均值方差是 ImageNet 的统计值因为后面要用预训练权重这一步不能省。num_workers4在 Windows 上如果报错就改成 0这是平台差异不是代码问题。class_to_idx打印出来一定要看一眼确认标签顺序和你理解的一致后面看混淆矩阵时全靠它。3.2 迁移学习选哪个骨干网络770 张图从头训练一个 CNN 基本等于随机猜迁移学习是唯一务实的选择。常见做法是用 ResNet18 或 EfficientNet-B0 这类轻量骨干把最后的全连接层换成你的类别数。骨干网络参数量适合场景小数据集表现ResNet18约 11M快速验证、边缘部署稳定不易过拟合EfficientNet-B0约 5.3M精度优先、算力有限收敛稍慢但上限高MobileNetV3-Small约 2.5M移动端/嵌入式快精度略低ViT-Base约 86M数据量充足时770 张严重过拟合我的建议是先用 ResNet18 跑一个 baseline确认整条链路没问题再换 EfficientNet-B0 对比。ViT 这类 Transformer 架构在 770 张数据上基本是自找麻烦除非你做大量强增强或者用更强的预训练权重否则验证集准确率会剧烈震荡。import torch.nn as nn from torchvision import models def build_model(num_classes, backboneresnet18): if backbone resnet18: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) elif backbone efficientnet_b0: model models.efficientnet_b0(weightsmodels.EfficientNet_B0_Weights.IMAGENET1K_V1) model.classifier[1] nn.Linear(model.classifier[1].in_features, num_classes) return model num_classes len(train_ds.classes) model build_model(num_classes, resnet18) print(model.fc)替换分类头时注意不同骨干的层名不一样ResNet 是fcEfficientNet 是classifier[1]写错不会报错但会训练一个随机初始化的头loss 降不下去。weights...IMAGENET1K_V1表示加载 ImageNet 预训练权重这是迁移学习的关键不要设成None。3.3 训练循环与关键超参设置import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) criterion nn.CrossEntropyLoss() optimizer optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30) best_acc 0.0 for epoch in range(30): model.train() running_loss 0.0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() model.eval() correct, total 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels imgs.to(device), labels.to(device) preds model(imgs).argmax(dim1) correct (preds labels).sum().item() total labels.size(0) acc correct / total scheduler.step() if acc best_acc: best_acc acc torch.save(model.state_dict(), best_model.pth) print(fEpoch {epoch1}: loss{running_loss/len(train_loader):.4f}, val_acc{acc:.4f}) print(f最佳验证准确率: {best_acc:.4f})学习率 3e-4 配 AdamW 是小数据集微调的常用起点太大 loss 会炸太小收敛慢。weight_decay1e-4是正则化770 张数据必须加否则训练准确率冲到 99% 而验证集只有 60% 多。CosineAnnealingLR 让学习率按余弦曲线衰减比固定学习率更容易在后期稳定。每个 epoch 结束保存最佳模型这样即使后面过拟合了你手里还有最好的那一版。如果验证准确率在前 5 个 epoch 就冲到很高然后不动了多半是学习率偏大或者数据增强太弱。4. 小样本分类的避坑与排查清单4.1 验证准确率远高于测试准确率现象验证集准确率 90% 以上测试集只有 60% 出头。原因通常不是模型问题而是划分时同一片叶子的多张照片泄漏到了不同 split。解决方法是回到 2.3 节按叶片 ID 分组划分而不是按图片文件随机划分。如果文件名看不出分组可以用感知哈希或简单的图像相似度做一次去重把相似度超过阈值的图片归为一组。4.2 训练 loss 不下降或震荡剧烈现象loss 在前几个 epoch 上下跳动甚至越来越大。原因可能是学习率过大、Normalize 参数和预训练权重不匹配、或者标签映射错位。排查顺序是先打印一个 batch 的图片和标签确认图片没被错误解码、标签和类别对得上再把学习率降到 1e-4 试几个 epoch最后检查 Normalize 的 mean/std 是否和预训练权重一致。我遇到过最隐蔽的一次是ImageFolder把子目录里的隐藏文件夹也当成了一个类别导致类别数多了一个loss 自然降不下去。4.3 某个类别准确率始终为 0现象混淆矩阵里某个类别的样本几乎全被预测成另一个类。原因基本是类别不平衡加上增强不足。解决方法是给CrossEntropyLoss加类别权重权重取该类样本数的倒数。from collections import Counter labels [label for _, label in train_ds.samples] counts Counter(labels) total sum(counts.values()) weights torch.tensor([total / (num_classes * counts[i]) for i in range(num_classes)], dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweights) print(类别权重:, weights)这段代码统计训练集每个类别的样本数然后按总数 / (类别数 * 该类样本数)计算权重。样本少的类别权重高loss 里它的贡献被放大模型不敢忽略它。注意权重只在训练时用验证和测试时用普通 CrossEntropyLoss 就行。4.4 数据增强后图片出现黑边或变形现象增强后的图片边缘有黑色填充或者叶片被拉长压扁。原因是RandomRotation默认用 0 填充Resize直接拉伸不保持长宽比。解决办法是把旋转的fill参数设成 255 或者用RandomAffine配合fillresize 前先做中心裁剪或 padding 到正方形。这个坑不影响训练启动但会悄悄拉低精度属于典型的「不报错但有害」问题。4.5 推理时单张图片预测结果和验证集不一致现象同一张图在验证集里预测正确单独拿出来推理却错了。原因通常是推理时的预处理和验证时不一致比如忘了 Normalize、resize 尺寸不同、或者通道顺序从 RGB 变成了 BGR。解决方法是把验证集的 transform 单独抽成一个函数推理时直接复用不要凭记忆重写一遍。5. 把 770 张用到极致进阶技巧与验证方法小数据集的瓶颈从来不是模型不够大而是信息量不够。我一般会从两个方向压榨这 770 张图的价值。第一个方向是交叉验证。与其只做一次 7:1.5:1.5 划分不如做 5 折交叉验证每折换不同的验证集最后看平均准确率和标准差。标准差比准确率本身更能说明模型的稳定性——如果 5 折准确率在 0.72 到 0.91 之间跳那这个模型根本不能上线换多少超参都没用。from sklearn.model_selection import StratifiedKFold import numpy as np all_files [s[0] for s in train_ds.samples] all_labels [s[1] for s in train_ds.samples] skf StratifiedKFold(n_splits5, shuffleTrue, random_state42) fold_accs [] for fold, (tr_idx, va_idx) in enumerate(skf.split(all_files, all_labels)): # 这里用 tr_idx / va_idx 重建 Dataset 和 DataLoader # 训练流程与第 3 章一致记录该折最佳验证准确率 print(fFold {fold1}: train{len(tr_idx)}, val{len(va_idx)}) # fold_accs.append(best_acc) # print(f5 折平均准确率: {np.mean(fold_accs):.4f} ± {np.std(fold_accs):.4f})StratifiedKFold保证每折里类别比例和整体一致random_state42保证可复现。实际跑的时候把第 3 章的训练循环包成一个函数传入不同的 train/val 索引即可。5 折跑完你得到的不只是一个数字而是一个分布这对判断方案是否值得继续投入至关重要。第二个方向是测试时增强TTA。推理时对同一张图做多次轻微变换水平翻转、小角度旋转把多次预测的概率平均通常能涨 1 到 3 个百分点代价只是推理时间翻几倍。对 770 张这种规模的数据集TTA 的性价比很高。def predict_with_tta(model, img_tensor, n_aug5): model.eval() probs torch.zeros(1, num_classes).to(device) with torch.no_grad(): probs torch.softmax(model(img_tensor), dim1) probs torch.softmax(model(torch.flip(img_tensor, dims[3])), dim1) for _ in range(n_aug - 2): # 这里可以加入随机旋转等变换 probs torch.softmax(model(img_tensor), dim1) return (probs / n_aug).argmax(dim1)TTA 的核心是概率平均而不是投票因为概率保留了模型的不确定性信息。注意翻转维度dims[3]对应的是宽度方向别写错成[2]否则翻转的是通道结果完全乱套。最后说一个我自己的习惯每次跑完实验把配置文件、类别映射、最佳 epoch、验证准确率和混淆矩阵存进一个带时间戳的文件夹。770 张数据的实验迭代很快不记录的话三天后你根本想不起来哪个准确率对应哪组参数。这个习惯看起来笨但帮我省下了无数次重复实验的时间。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →