简介:这份资源是一套面向图像分类入门及农业视觉应用学习者的马铃薯叶片病害分类数据集,适合用于训练 CNN 分类模型或验证改进网络效果。数据集中包含约 2,100 张已标注图片,覆盖早疫病、晚疫病和健康叶子三个类别,且已划分好训练集与测试集,并附带 JSON 标注文件,便于直接开展模型训练与评估。压缩包共 2,000 个文件,主要由 1,998 张 JPG 图像、1 个 Python 显示脚本和 1 个 JSON 配置文件组成,整体大小约 38MB,结构简洁,便于下载后快速上手。目前已有 144 人学习下载。除数据本身外,资源内还提供了可视化脚本,运行后即可直观查看各类别样本分布;结合作者主页中关于 CNN 分类网络改进的系列文章,使用者可基于该数据集开展网络结构调整、精度对比实验,是一份兼具数据支撑与扩展学习路径的实用资源。
1. 2100张标注图,能撑起一个可用的马铃薯病害分类器吗
先说反直觉的结论:2100张标注图不算多,靠它从零训练一个深度卷积网络必翻车;但借助ImageNet预训练权重做迁移学习,配合一套针对叶片影像设计的数据增强,足以在早疫病、晚疫病和健康叶片的分类任务上跑到95%以上的验证准确率。这里说的“可用”,是指能进到真实田间部署验证的那种,而不是只在测试集上自嗨。
这个标题指向的是“已标注”的马铃薯叶片病害图像分类数据集,约2100张。它解决的是很多农业视觉项目最痛苦的第一公里:数据从哪来、标签怎么定义、怎么统一目录格式。你要是做过PlantDoc、PlantVillage这类公开数据集,就知道下载下来的图分辨率参差不齐、标注噪声不少,而一个按ImageFolder组织好的小数据集,反而能让baseline在三小时内跑出来。这篇笔记把数据集结构拆开、搭好训练流程、把坑列出来,适合第一次做农业图像分类的工程师,也适合想用CVAT或LabelImg自建数据集的团队作对照。
2. 先拆数据集:病害类别、目录结构与首个加载脚本
2.1 一张图一个标签:分类数据集长什么样
图像分类任务里,数据集最通用的组织方式就是ImageFolder:根目录下面每个文件夹一个类别,文件夹里的每张图都属于这个类。拿到数据集先执行一条命令:
tree -L 2 -d .如果返回结果是这样的:
data/ ├── train/ │ ├── Early_blight/ │ └── Late_blight/ │ └── healthy/ ├── val/ └── test/说明数据已经按训练集/验证集/测试集拆分好了,直接用torchvision就能加载。如果只有一个总目录加一个CSV标注文件,那需要先重排成ImageFolder,这步十分钟就能搞定。
这里有个容易忽视的细节:验证集和测试集的区别不要搞混。验证集是训练过程中用来调学习率、挑epoch的,模型会间接“见过”它;测试集只许碰一次。这个小数据集既然标了约2100张,那么常见拆分比例是train占70%~80%,val和test各占15%左右。实际做的时候别急着合并重拆,保持发布者的划分,结果才能和别人对比。
2.2 用torchvision在10行内跑通数据加载
不管后续用什么模型,第一步都是把图从硬盘里读出来,转成能进网络的张量。torchvision的datasets.ImageFolder加上transforms就能做到:
from torchvision import datasets, transforms from torch.utils.data import DataLoader # 训练集与验证集先用同一套基础变换,后面再优化 train_transform = transforms.Compose([ transforms.Resize((256, 256)), # 先把短边拉到一个固定尺寸 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) train_data = datasets.ImageFolder("data/train", transform=train_transform) val_data = datasets.ImageFolder("data/val", transform=train_transform) train_loader = DataLoader(train_data, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_data, batch_size=32, shuffle=False, num_workers=4) print(train_data.classes) print(len(train_data), len(val_data))这段代码的逻辑有三层:第一,ImageFolder会扫描子目录名生成类别索引,classes按字母序排;第二,Resize把输入统一到256乘256,为的是后续模型输入尺寸一致,避免到前向传播时才报维度错误;第三,Normalize用的均值方差是ImageNet的标准值,因为后面要加载ImageNet预训练权重。
参数上,batch_size=32在8GB显存上跑ResNet18勉强够用,如果你显卡只有4GB就降到16。num_workers在Windows上容易报错,设成0最稳,Linux下4到8都可以。注意这里的Resize只是基线,后面会换掉。
2.3 标注质量初检:统计类别分布和分辨率分布
从外部拿到的数据集,第一件事不是训练,而是用代码做个体检。常见问题有两类:类别不平衡,以及图片分辨率离散程度过高。一个脚本就能看出来:
from PIL import Image from collections import Counter import os root = "data/train" exts = (".jpg", ".jpeg", ".png") size_counter = Counter() class_counter = Counter() for label in os.listdir(root): class_dir = os.path.join(root, label) if not os.path.isdir(class_dir): continue for name in os.listdir(class_dir): if not name.lower().endswith(exts): continue img = Image.open(os.path.join(class_dir, name)) size_counter[(img.width, img.height)] += 1 class_counter[label] += 1 print(class_counter) print(size_counter.most_common(10))这个脚本跑完后,重点看两个指标。第一个是每个类别的样本数有没有超过1倍的差距,比如健康叶600张、早疫病200张,这就会让模型偏向样本多的类别,后面得用加权采样或WeightedLoss处理。第二个是分辨率分布,如果很多图是几百像素起步,个别图超过2000像素,你得在训练时用RandomResizedCrop统一裁剪,而不是粗暴Resize,否则模型会过度依赖叶片占据画面的比例。
标注层面也要扫一眼:把目录名当成标签是一种默认约定,建议抽查几十张图确认没有错放。曾有同事拿到一批标注数据,发现某个文件夹里混入了不少背景图,模型收敛之后对“地面”特别敏感,这样的标注噪声后面再清理成本就高了。
3. 训练一个能用的baseline:EfficientNet微调与关键参数
3.1 为什么不自己设计卷积网络
2100张图,自己搭一个VGG或ResNet的变体,从零初始化训练,几乎必然陷入过拟合:训练准确率冲到98%,验证准确率卡在75%。这不是代码问题,是小数据+大参数空间的统计必然。农业病害图像分类的主流做法是加载ImageNet预训练权重做微调,而不是从零训练。
EfficientNet系列是个稳妥起点。它用神经架构搜索定了一组复合缩放系数,在同样FLOPs下比ResNet精度更高。V2还修复了训练早期显存占用过大的问题。对这个尺寸的数据集,我一般用EfficientNet-B0或B1,既能吃下256输入,8GB显存也跑得动。如果你手里只有CPU或者老显卡,ResNet18也可以,但最终精度大约低两个点。
3.2 完整训练脚本:从冻结到解冻
把整个训练流程拆成两个阶段:先冻结backbone只训练分类头,让新加的全连接层先适应这个小数据集的类别分布;再解冻backbone用更低的学习率微调。这比一上来就全量微调稳定得多,能避免初始loss过大导致训练震荡。
import torch import torch.nn as nn from torch.optim import AdamW from torchvision import models model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.DEFAULT) num_classes = 3 model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) # 冻结 backbone,只训练 classifier for param in model.features.parameters(): param.requires_grad = False for param in model.classifier.parameters(): param.requires_grad = True optimizer = AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) criterion = nn.CrossEntropyLoss() # 阶段一:只训分类头 5 epoch for epoch in range(5): for images, labels in train_loader: outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() # 解冻 backbone,低学习率微调 for param in model.parameters(): param.requires_grad = True optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # 阶段二:全量微调 15 epoch,这个循环体实际跑项目和下面等价 for epoch in range(15): model.train() for images, labels in train_loader: images, labels = images.to(device), labels.to(device) loss = criterion(model(images), labels) optimizer.zero_grad() loss.backward() optimizer.step()这里参数要解释清楚。阶段一用1e-3的学习率,因为新分类头的随机初始化需要较大步长去拟合类别中心。解冻后的模型必须降到1e-4,而且我习惯加上weight_decay=1e-4,防止全量微调时backbone权重偏离预训练太远。AdamW替代原始的Adam,它对weight decay的处理更规范,能有效缓解过拟合。
注意两个坑:其一,model.classifier[1]是EfficientNet的最后一层,不同版本的torchvision写法可能不同,改成model.classifier[-1]更保险;其二,冻结阶段一定要确认哪些参数的requires_grad是False,否则AdamW会把所有参数的梯度都算一遍,冻结形同虚设。
3.3 超参数怎么调:先定尺寸、批次、学习率
超参数是新手最容易“玄学化”的东西。实际上有一个稳定的调法:先固定输入尺寸,再选batch,最后用余弦退火去调学习率峰值。
输入尺寸方面,马铃薯叶片病害纹理比较细微,晚疫病病斑边缘不规则,太小容易丢失纹理,太大显存扛不住。256是一个甜点值,224略糊,384能涨点精度但训练时间约多一倍。批次大小直接影响BatchNorm的统计量,太小的batch在微调阶段会让模型抖动,建议显存允许下尽量32起步。
学习率是个坑。迁移学习场景下,直接用ImageNet image size训练好的权重,学习率过大会把预训练特征破坏掉。我见过太多人带着1e-2的初始lr冲进去,训练几轮loss降到零但验证集乱跳,这就是典型的学习率翻车。
python train.py \ --model efficientnet_b0 \ --image_size 256 \ --batch_size 32 \ --lr_phase1 1e-3 \ --lr_phase2 1e-4 \ --epochs_phase1 5 \ --epochs_phase2 15建议把上述配置写成一个shell脚本,方便复现不同实验。一个可用的打印方式是在每轮结束输出train loss、val loss和当前最佳准确率,然后按val loss选模型,别只看train loss。约2100张图在单张RTX 3060上,256输入跑20个epoch大约需要20分钟,速度足够你多尝试几组学习率。
4. 把2100张“变”成更多:数据增强与类别不均衡处理
4.1 针对叶片病害的增强组合
数据增强是这个小规模数据集能work的核心环节。通用做法是随机翻转加颜色扰动,但叶片病害分类有个特殊性:病斑的颜色和纹理是关键判据,强度过高的ColorJitter会把病斑的色调漂没,反而伤害精度。
我常用的组合是:RandomResizedCrop模拟不同拍摄距离,HorizontalFlip加VerticalFlip模拟叶片朝向,Rotate(30)模拟倾斜摆放,ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05)做轻微颜色变化。其中hue一定要设小,马铃薯晚疫病的病斑呈褐色,色相偏移太大会让模型学到错误相关性。
from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(size=(256, 256), scale=(0.7, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.5), transforms.RandomRotation(degrees=30), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])RandomResizedCrop里的scale=(0.7, 1.0)意思是裁剪面积是原图的70%到100%,这模拟了镜头拉近拉远的效果。注意不要用Resize替代它,Resize会把整张图塞进固定尺寸,让模型对叶片占比过度敏感。验证集保持Resize(256)加CenterCrop(256)不要做随机增强,这样每次验证结果才可比。
4.2 用Albumentations封装训练流程
torchvision的变换在实际项目里够用,但对叶片病害想要更强效果,Albumentations提供了更丰富的变换,比如RandomBrightnessContrast和GaussNoise,而且因为底层是OpenCV,速度比PIL快一两倍。习惯做法是用albumentations定义好变换后,包一层成torch的Dataset接口。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_aug = A.Compose([ A.RandomResizedCrop(height=256, width=256, scale=(0.7, 1.0)), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.Rotate(limit=30), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), A.GaussNoise(var_limit=(10.0, 50.0), p=0.2), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ]) class LeafDataset: def __init__(self, paths, labels, transform=None): self.paths = paths self.labels = labels self.transform = transform def __len__(self): return len(self.paths) def __getitem__(self, idx): img = A.load(self.paths[idx]) # 读取为 BGR ndarray if self.transform is not None: transformed = self.transform(image=img) img = transformed["image"] return img, self.labels[idx]这里A.load是albumentations自带的图像读取函数,返回的是BGR ndarray,内部变换会自动处理。GaussNoise加噪可以提升模型对田间噪点的鲁棒性,但p=0.2是合理的上限,太高会让纹理细节失真。
要注意:ImageFolder虽然省事,但生成代码用自定义Dataset是更通用的方案,这样后面想换成CSV标注、或从数据库读路径都不用改训练主循环。
4.3 类别不均衡:先从loss层面处理
如果一个类别明显少于其他两类,不要让模型自己去硬扛。三种办法由轻到重:weight_decay加大到1e-3、给损失函数加类别权重、以及用WeightedRandomSampler重采样。
class_weights = torch.tensor([1.0, 2.0, 1.0]) # 按类别样本比的倒数估算 criterion = nn.CrossEntropyLoss(weight=class_weights)简单场景下,构建好class_weights传给CrossEntropyLoss就够。它的含义是:少样本类别的loss乘以对应权重,让梯度更大,迫使模型更关注这些类。如果这样训练后少样本类别的召回率还不行,再上WeightedRandomSampler。注意重采样会让同一个epoch内重复看到少样本,但同时也加剧了对重复样本的过拟合,所以SAMPLER配合一点DropOut效果更好。
5. 避坑指南:验证准确率虚高和真实场景失效的4个常见问题
5.1 验证集98%,下地实测掉到60%
现象:本地验证集效果好得惊人,Field测试却一塌糊涂。
原因:最大嫌疑人有两个。第一是数据泄漏,如果训练集和验证集来自同一批采集、同一光线下,网络其实记住了光线环境而不是病害纹理。第二是拍摄分布不一致,数据集里都是正面俯拍的完整叶片,你下地拍的时候镜头里的叶片是倾斜的、带露水的、有遮挡的,分布一变,模型就发懵。
解决:拿到这个标注数据集后,先按采集批次或图像来源做分组划分验证集,不要按文件名随机划分。这一点在公开数据集里尤其普遍——发布者往往用随机拆分,几千张图来自几十个园区,同一个园区的图可能同时出现在训练和验证里。
5.2 模型学到了叶片以外的背景
现象:错误分析时发现,模型把某些环境背景和类别关联起来了。比如在水泥地上拍的叶片都判成早疫病。
原因:标注的人不会特别注意拍摄背景,而背景在视觉上比病斑特征大得多,CNN天然会捡软柿子捏。
解决:数据增强里加RandomResizedCrop比例增大,强制模型看局部纹理;更有效的是做一次背景替换增强——把叶片抠出来随机贴在纯色背景上。虽然多了预处理工作量,但能让特征注意力离开背景。诊断方法也简单:把一张正常叶片图裁成左下角和右下角两块,分别预测,看预测结果是否一致,不一致就说明模型注意力飘了。
5.3 训练到第20个epoch验证loss回升但准确率还在涨
现象:验证集准确率一直在涨,Training loss也在降,但验证loss反弹了。
原因:这是选择模型的经典陷阱。准确率是离散指标,最后几个epoch只要少判错一张图就跳动一个点,它掩盖了概率输出的退化——模型开始对正确答案过度自信,对错误答案也过度自信,这会让概率校准变差,后续做置信度阈值或主动学习时很难用。
解决:盯val loss而不是val accuracy来存模型。代码里加一行:
if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), "best_leaf_model.pt")同时配合ReduceLROnPlateau,val loss连续三个epoch不降就把学习率衰减到原来的0.1,这样做十二个epoch就稳得住了。别省这个回调,它是防“过拟合反弹”的后悔药。
5.4 batch太小,BatchNorm开始抽风
现象:显存不够把batch压到8,结果训练loss剧烈震荡,收敛极慢。
原因:BatchNorm在小batch下统计量噪声太大,而EfficientNet的BatchNorm层又特别多。模型在训练和验证时用的是两套统计量,训练batch太小会让验证时统计量漂移。
解决:不要用BatchNorm系列的模型,换成没有全局统计依赖的变体。一个立竿见影的做法是把efficientnet_b0换成efficientnet_b0的GN变体,或者干脆用ResNet18加GroupNorm,但改动不小。更省事的是用混合精度训练,在不动batch的前提下节省显存:
scaler = torch.cuda.amp.GradScaler() with torch.autocast(device_type="cuda", dtype=torch.float16): outputs = model(images) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()想省事的话,最简单的办法是按原图resize到256后缓存成tensor存内存里,batch=8时也可以硬跑,只是吃内存。这个在小数据集上特别有效,2100张图也就几百MB。
6. 最后一章:训练结束后,做一次可信度自检并导出部署模型
训练完成不等于结束。在真正去写技术报告或部署之前,我习惯按下面三步做一次完整自检,确保模型不是黑匣子,别人接手也敢用。
第一步,五折交叉验证。把train和val合并,按类别分层拆五折,每折训20个epoch,记录每折在验证集上的准确率和召回率。平均后如果标准差大于两个百分点,说明模型对数据划分敏感,要么是某个类别样本太少,要么是图像采集分布不均匀。这时回去检查标注质量,比继续调参数划算得多。
第二步,看混淆矩阵和每类别的UAR(unweighted average recall)。对于马铃薯叶片病害这个任务,漏判晚疫病的代价远高于把早疫病误判成健康叶片,所以绝不能只看总准确率。如果晚疫病召回率低于90%,优先做类内数据收集或按5.3的方式重调类别权重。
第三步,导出ONNX并做一次推理单测。很多部署环境跑不了PyTorch,转ONNX是通用做法:
model.eval() dummy = torch.randn(1, 3, 256, 256).to(device) torch.onnx.export(model, dummy, "leaf_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}})导出时注意dynamic_axes打开batch维度,这样边缘设备上可以一次跑多张。然后用onnxruntime加载并和PyTorch输出做逐元素对比,误差在1e-4以内才罢休。
作为一线做农业视觉的人,我最深的感受是:这类小规模已标注数据集的价值不在“开箱即用”,而在于它帮你把数据管线、训练基线和评估流程一次性跑顺。踩过验证集泄漏的坑之后,我每个项目都强制要求按来源分组划分数据;踩过背景学习的坑之后,增强配置里永远加局部裁剪。这个数据集约2100张,你按本文流程走一遍,收获的不仅是一个模型,而是整套可复用到下一个病害分类任务的方法,希望帮到你。
本文还有配套的精品资源,点击获取