
简介面向计算机视觉与深度学习入门者常规茶叶叶片病害图像分类数据集提供约4000张已标注图像涵盖褐枯病、灰枯萎病、红点病等5类常见病害图片已预先划分为训练集、验证集和测试集可直接用于分类模型的训练与评估。资源包共2000个文件其中1998张jpg图像构成核心图片数据另含1个Python可视化脚本与1个json标签文件整体压缩后仅21.68MB轻量易获取。目前已有104人学习下载适合作为图像分类入门的练手数据、课堂实验或算法验证基准。数据集目录层级清晰结合可视化脚本可快速检查样本质量与类别分布json文件则记录具体病害名称能减少数据预处理的重复劳动让使用者更专注于模型结构设计与调优。1. 常规茶叶叶片病害图像分类数据集先从“能不能直接训”说起做农业图像分类的人最容易卡住的不是模型而是数据。常规茶叶叶片病害图像分类数据集就是这样一份省事资源约 4000 张图片已经标注好分成褐枯病、灰枯萎病、红点病等 5 个类别并且按训练集、验证集、测试集分好目录。拿到手不用再花一整天整理文件也不用对着原图放大缩小猜标签解压之后先跑统计再跑训练半天内能出第一条基线。适合刚入门图像分类的新手也适合想快速验证一个图像分类模型改进思路的熟手。如果你正打算做茶叶病虫害识别或者想找一个“真实但不是太脏”的小型数据集练手这个资源比很多网上散装图片值得下。2. 数据集结构与标签格式先看清楚再动手2.1 5 个类别按目录存放天然适配 ImageFolder这份数据集最友好的地方是文件组织方式。训练集、验证集、测试集三个目录下面是各自的类别子目录同一类图片放在同一个文件夹里。常见的目录结构是这样的data/ ├── train/ │ ├── brown_blight/ │ │ ├── image_0_663.jpg │ │ └── image_0_1180.jpg │ ├── gray_blight/ │ ├── red_spot/ │ ├── ... ├── val/ └── test/这种组织方式意味着你用torchvision.datasets.ImageFolder或者keras.preprocessing.image_dataset_from_directory都能直接加载类别名自动变成标签索引不需要自己手写映射表。文件名大致能看出采集痕迹image_0_663.jpg这种像是脚本批量重命名过的20211227_132340.jpg像是直接保留了拍摄时间戳。这不影响训练但我建议你不要自己重命名整个目录因为 show 脚本和后续处理可能依赖文件夹名作为类别标签。有一点要注意5 个类的具体名称在摘要里只写了褐枯病、灰枯萎病、红点病“等”剩下的以 json 文件为准。我拿到这类数据的第一件事永远是打开 json 确认类别清单而不是凭文件名猜。2.2 读懂 json 标注文件不要假设字段先打印 keysjson 文件的作用是记录分类个数、类别名称有时候也会记录每个类别对应的图片列表。不同的发布者字段命名习惯不同我见过叫categories的也见过叫label_map或classes的。最稳妥的做法是直接打开看结构import json with open(categories.json, r, encodingutf-8) as f: info json.load(f) print(type(info)) if isinstance(info, dict): print(info.keys()) for key, value in info.items(): print(key, value)逻辑说明先确认整个 json 是字典还是列表再打印所有键名。如果info是字典且包含categories那大概率是一个列表每个元素里可能有id、name、count等字段。如果你发现info里直接是{0: brown_blight, 1: gray_blight}也不要奇怪那就是索引到类别名的映射。参数说明读取时务必指定encodingutf-8Windows 上不指定的话默认用 gbk很可能直接报UnicodeDecodeError。另外即便 json 文件损坏或者丢失你也不至于完全没办法——因为类别子目录本身就承担了标签的角色。ImageFolder会按子目录名的字母顺序生成索引所以只要目录结构完整照样能训。json 的意义在于告诉你“哪个索引对应哪个病”。2.3 动手统计样本分布类不均衡在一开始就要发现4000 张图听上去不少但如果某一类只有 200 张另一类有 1200 张训练时你会发现模型总是往多数类偏。所以拿到数据集后我建议先跑一个统计脚本把每个划分、每个类别的图片数量落成表格from pathlib import Path root Path(data) for split in [train, val, test]: split_dir root / split if not split_dir.exists(): continue print(f--- {split} ---) for class_dir in sorted(split_dir.iterdir()): if class_dir.is_dir(): jpgs len(list(class_dir.glob(*.jpg))) jpegs len(list(class_dir.glob(*.jpeg))) pngs len(list(class_dir.glob(*.png))) print(f{class_dir.name}: {jpgs jpegs pngs} ({jpgs}{jpegs}{pngs}))逻辑说明class_dir.glob(*.jpg)返回该类别目录下所有 jpg 路径的生成器list()把它展开再取长度。同时统计 jpeg 和 png 是因为很多采集设备或爬虫脚本会生成混合后缀如果只统计一个后缀数量会偏小。输出结果后重点看 train 目录里最小类和最大类的比例。如果最大类是最小类的 3 倍以上我会在训练阶段用重采样或者在损失函数里加类别权重这个后面第 4 章会具体说。3. 数据可视化与标注校验show 脚本是第一道质检3.1 直接运行 show 脚本先看数据长什么样资源里自带 show 脚本目的是可视化数据集。它的典型用法是这样python show.py --data_dir ./data --num_per_class 5 --save_dir ./vis脚本本质上是遍历指定目录下的类别文件夹每个类别随机抽取几张贴成网格图图片左上角标注类别名最后把结果保存成一个 png。参数说明--data_dir指向数据集根目录--num_per_class控制每个类别显示几张--save_dir是输出图片的保存路径。如果你跑完发现图片里某些叶片边缘有黑框或者有些图明显是缩放拉伸造成的形变不要急着删先判断是原始采集问题还是数据增强时 Resize 比例不对。跑这个脚本前先确认环境里有 matplotlib 和 Pillow。缺包的话会报ModuleNotFoundError安装一下即可。如果脚本源码里写死了相对路径比如默认data_dir./data但你解压后目录名不叫 data记得先改参数或者改代码不要傻傻地在当前目录下运行时才发现路径对不上。3.2 写一个更顺手的随机抽查器专门检查“是不是标错了”自带 show 脚本提供的是一键全览功能但我觉得它偏“展示”。实际核对标注质量时我更习惯写一个自己的抽查器每一类随机抽几张一次性拼成一个大网格挂在那里看两分钟。import random from pathlib import Path from PIL import Image import matplotlib.pyplot as plt random.seed(42) root Path(data) / train class_dirs [d for d in sorted(root.iterdir()) if d.is_dir()] num_per_class 3 fig, axes plt.subplots(len(class_dirs), num_per_class, figsize(12, len(class_dirs) * 3)) for row, class_dir in enumerate(class_dirs): images list(class_dir.glob(*.jpg)) list(class_dir.glob(*.jpeg)) chosen random.sample(images, min(num_per_class, len(images))) for col, img_path in enumerate(chosen): img Image.open(img_path) axes[row, col].imshow(img) axes[row, col].axis(off) axes[row, col].set_title(f{class_dir.name}\n{img_path.name}, fontsize8) plt.tight_layout() plt.savefig(check_sample.png, dpi150) print(saved to check_sample.png)逻辑说明外层按类别行遍历内层按图片列遍历。random.sample从图片列表里无放回抽样第三个参数min(num_per_class, len(images))防止某一类图片数量不够时报错。每个子图标题同时显示类别名和文件名这样看到某张图明显不对时能顺着文件名回去翻原图。参数说明figsize的纵向高度用len(class_dirs) * 3动态计算类别多时图不至于挤成一团random.seed(42)保证多次运行抽到同一批图方便复现和对照。这一步比直接训练更有价值。茶叶叶片病害里褐枯病和灰枯萎病都是大片枯黄肉眼很容易混淆。如果抽查时发现某个类别的目录里混进了其他病的图或者同一张图出现在两个类别目录里后期模型精度再高也白搭因为标签本身就是错的。3.3 画类别分布直方图把不均衡摆到桌面上除了打印数量我更推荐直接画一张柱状图这样类别间差距一眼就能看出来import matplotlib.pyplot as plt from pathlib import Path train_dir Path(data) / train class_names [] class_counts [] for d in sorted(train_dir.iterdir()): if d.is_dir(): class_names.append(d.name) class_counts.append(len(list(d.glob(*.jpg)) list(d.glob(*.jpeg)))) plt.figure(figsize(8, 5)) plt.bar(range(len(class_counts)), class_counts, tick_labelclass_names) plt.xticks(rotation30) plt.ylabel(image count) plt.tight_layout() plt.savefig(class_distribution.png, dpi150)逻辑说明这里只对 train 目录做统计因为真正参与训练的是它。d.glob(*.jpg)同样的方式统计每个子目录的图片数最后用plt.bar画柱状图。参数说明rotation30让横轴类别名倾斜 30 度防止字符串太长互相遮挡。保存成class_distribution.png后可以直接贴进实验记录也可以在写论文做数据集介绍时当配图。很多人在论文里只写“数据集包含 4000 张图片”但审稿人更想看到每类样本数量表格或柱状图这一步能帮你省不少事。如果柱状图显示某一类数量特别少后面选模型时就不要用参数量太大的网络也不要直接用默认的 CrossEntropyLoss 硬训。小样本类在 ResNet50 这种大模型上很容易过拟合还不如 ResNet18 加数据增强来得稳。4. CNN 分类基线迁移学习跑通 5 类茶叶病害4.1 用 ImageFolder 加载数据训练集和验证集套路不同数据集按类别目录分好加载是非常标准的一套流程。关键点在于训练集和验证集的 transform 必须分开写训练集要随机裁剪、翻转、颜色扰动验证集只用确定性的 Resize 和归一化。from torchvision import datasets, transforms from torch.utils.data import DataLoader mean [0.485, 0.456, 0.406] std [0.229, 0.224, 0.225] train_tf transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean, std), ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean, std), ]) train_ds datasets.ImageFolder(data/train, transformtrain_tf) val_ds datasets.ImageFolder(data/val, transformval_tf) test_ds datasets.ImageFolder(data/test, transformval_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers2) test_loader DataLoader(test_ds, batch_size32, shuffleFalse, num_workers2)逻辑说明ImageFolder会自动扫描子目录名按字母顺序生成类别到索引的映射。也就是说如果你在 json 里看到0: brown_blight但目录名排序后索引 0 对应的是另一个类别名那么打印train_ds.classes就能看到实际顺序。这种错位很隐蔽会导致评估时类别名对不上所以我每次加载完都会打印一遍train_ds.classes确认。参数说明RandomResizedCrop(224, scale(0.8, 1.0))表示先随机裁剪原图 80% 到 100% 的区域再缩放到 224×224模拟叶片尺度变化ColorJitter只加在训练集上模拟不同光照下拍摄的茶叶颜色差异num_workers2在普通台式机上比较稳Windows 上太高容易出子进程报错。4.2 迁移学习选型4000 张图ResNet18 比大模型更稳对 4000 张这种量级直接用 ResNet50 从头训练很容易过拟合训练时间还长。我一般选 ResNet18加载 ImageNet 预训练权重只替换最后的全连接层为 5 输出先把除全连接层外的参数冻结起来跑几个 epoch验证集精度能上来再决定要不要解冻。import torch.nn as nn from torchvision import models model models.resnet18(pretrainedTrue) in_features model.fc.in_features model.fc nn.Linear(in_features, 5) for name, param in model.named_parameters(): if fc not in name: param.requires_grad False逻辑说明model.fc是 ResNet 最后的全连接层先读出它原来的输入维度再换成一个输出维度为 5 的新全连接层。下面的 for 循环遍历所有参数只要名字里不含fc就把requires_grad设成 False。这样反向传播时只更新新加的 fc 层参数预训练提取特征的能力被保留下来训练速度快很多。参数说明如果你的显卡显存足够大数据增强也做得比较狠可以试着一轮训练后把requires_grad全部设置成 True 再做全量微调学习率降到 1e-4 或更小这是一个经验值。这里的 5 是类别数如果 json 里确认类别数不是 5改成实际数值。4.3 训练循环与关键超参数把 baseline 跑稳训练代码不需要花哨关键是让 loss、准确率、学习率的变化都能打印出来方便判断是否收敛import torch 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) loss_fn nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler CosineAnnealingLR(optimizer, T_max20) for epoch in range(20): model.train() total_loss 0.0 for x, y in train_loader: x, y x.to(device), y.to(device) optimizer.zero_grad() out model(x) loss loss_fn(out, y) loss.backward() optimizer.step() total_loss loss.item() model.eval() correct 0 total 0 with torch.no_grad(): for x, y in val_loader: x, y x.to(device), y.to(device) out model(x) pred out.argmax(dim1) correct (pred y).sum().item() total y.size(0) val_acc correct / total avg_loss total_loss / len(train_loader) print(fepoch {epoch:02d}: loss {avg_loss:.4f}, val acc {val_acc:.4f}) scheduler.step()逻辑说明训练阶段用model.train()打开 Dropout 和 BatchNorm 的训练模式验证阶段用model.eval()切到推理模式。out.argmax(dim1)取每个样本预测概率最大的索引跟标签 y 比较累加得到正确数。val_acc是验证集准确率如果连续几个 epoch 不涨就该考虑降低学习率或者增加数据增强强度。参数说明T_max20表示学习率在 20 个 epoch 内从初始值余弦降到接近 0如果训练轮数改成 30这里也要相应调大batch_size32比较通用显存紧张降到 16但要把lr也降到 7e-4 或 8e-4 附近因为小 batch 的梯度噪声更大太大学习率容易震荡。实际训练中你会发现一个现象loss 下降很快但 val acc 卡在某个位置不动。这不是模型坏了而是冻结了骨干网络之后fc 层能利用的特征范围有限。此时把fc之前的 layer4 也解冻再训几轮通常能再涨一两个点。4.4 评估指标与混淆矩阵准确率高不代表每个病都分得清准确率是整个训练过程里最直观的指标但对类别不均衡的数据集它可能骗人。比如红点病只有 300 张褐枯病有 1500 张模型只要把所有图都判成褐枯病准确率也能有 40% 甚至更高。所以测试集上一定要看分类报告和混淆矩阵from sklearn.metrics import confusion_matrix, classification_report import numpy as np all_preds [] all_labels [] model.eval() with torch.no_grad(): for x, y in test_loader: x x.to(device) out model(x) all_preds.extend(out.argmax(dim1).cpu().numpy()) all_labels.extend(y.numpy()) print(classes:, test_ds.classes) print(classification_report(all_labels, all_preds, target_namestest_ds.classes, digits3)) cm confusion_matrix(all_labels, all_preds) print(cm)逻辑说明classification_report会输出每个类别的 precision、recall、f1-score还带一个 macro avg这个宏平均才是判断模型整体表现更可靠的参考。confusion_matrix得到的是一个 5×5 矩阵cm[i][j]表示第 i 类的样本被预测成第 j 类的数量。对角线越大越好非对角线上的大数字就是你分类器最容易犯的错误。参数说明target_namestest_ds.classes让报告里的行名显示成真实类别名否则只有 0 到 4 的数字还得手动对照目录顺序。.cpu().numpy()是把 GPU 上的 tensor 转成 numpy 数组如果模型在 CPU 上跑x.cpu()可以省略。茶叶病害这件事里褐枯病和灰枯萎病在颜色和纹理上都非常接近混淆矩阵里这两个类别之间往往有较大数值。看到这种结果不要急着加数据先回去翻第 3 章的抽查图确认标注阶段没有把这两类混在一起。5. 避坑与排查数据集使用与训练的五个翻车现场5.1 读取 json 时 encoding 报错训练还没开始就卡住现象在 Windows 上直接执行open(categories.json)并调用json.load()报UnicodeDecodeError: gbk codec cant decode byte 0x... in position ...。原因Windows 的默认文本编码是 gbk而这份 json 保存时用的是 utf-8。json 内容里如果只有 ASCII 字符可能没事但类别名里有中文或特殊字符时gbk 解不开 utf-8 字节流直接抛异常。这类问题在 Linux 和 macOS 上不出现因为它们默认 utf-8所以经常是 Win 用户先踩坑。解决打开文件时显式指定编码写成open(categories.json, r, encodingutf-8)。我一般还会在后面加errorsreplace作为兜底防止个别字符解析失败导致整个文件读不出来但不要依赖这个参数最好还是确认文件原始编码。顺带说一句如果 json 里有路径字段读出来后用print看一眼分隔符是/还是\这跟第 5.5 条有关。5.2 图片读出来是 None训练到一半崩掉现象用cv2.imread(path)读图不报错但返回的img是None或者用 PIL 打开时提示OSError: broken data stream。训练脚本通常在第一个 epoch 就崩溃而且崩溃时间不定像玄学。原因这类图像数据集来自多个采集批次有的 jpg 文件实际是其他格式改了后缀有的文件头损坏或者只写了缩略图。cv2.imread对路径和损坏文件的容错不如 PIL路径里含中文或空格时也可能返回None。解决统一用 PIL 读取并在加载环节加一个安全检查。最简单的做法是把transforms.Compose里的第一步换成transforms.Lambda配合Image.open的 try-except或者在训练前先跑一遍扫描脚本把所有打不开的图列出来并移动到broken/目录。我常用的一个判断是如果你能看到某张图用系统图片查看器打不开那它十有八九已经损坏直接删掉比留着反复报错强。这个数据量只有 4000 张全量扫一遍几秒钟就结束。5.3 验证集和测试集写了数据增强指标忽高忽低现象训练时 loss 正常下降val acc 却随着 epoch 波动特別大有时 0.78 有时 0.62换一次测试集评估结果能差五六个点。原因最常见的错误是直接把训练用的 transform 套到了验证集和测试集上。训练集里有RandomResizedCrop、RandomHorizontalFlip、ColorJitter这些随机操作让每轮评估看到的是“变过形”的验证图结果当然不稳定。另一种情况是验证集忘了做Normalize导致输入图片均值和方差不匹配预训练模型整体 acc 偏低。解决验证集和测试集只保留确定的变换Resize((224, 224))再加ToTensor()加Normalize不去随机翻转、不去随机裁剪。这样每轮 eval 的结果同分布指标之间才有可比性。可以把训练 transform 和验证 transform 分别定义成两个变量写清楚注释别在赋值时不小心让两个变量互相覆盖。5.4 类别不均衡导致准确率虚高红点病一个都认不出现象训练结束后打印测试集准确率 0.91看起来很不错但打印classification_report后发现某个类的 recall 是 0.00precision 也是 0.00模型从未预测过这个类别。原因5 类样本数量不是均匀的。如果红点病样本只有两三百张而褐枯病有一千多张CrossEntropyLoss默认把每个样本等权看待模型为了降低整体 loss把所有输入都偏向多数类。整体准确率 0.91 可能只是因为多数类占的比例本身就高。解决有三个方案可以叠加。第一在DataLoader里给每个 batch 抽样时使用WeightedRandomSampler让少数类被采到的概率更高。第二在CrossEntropyLoss里传weight令少数类的权重更大。第三评估时以macro avg F1而不是准确率作为主要指标。最简单的做法是训练前用第 2.3 节的统计结果手动计算一个类别权重向量比如每类样本数的倒数。样本数越少loss 里被放大得越多模型才会真正去学那些叶片上的细小病斑。5.5 路径分隔符换了平台就找不到文件现象在 Windows 上训练完把整个项目压缩发给别人在 Linux 服务器上跑结果报FileNotFoundError: [Errno 2] No such file or directory: data\\train\\brown_blight\\image_0_663.jpg路径里明显带着反斜杠\。原因某些脚本或 json 里记录的路径是 Windows 风格的反斜杠分隔符Linux 只认正斜杠/。如果你在代码里用的是data/ folder / filename这种硬编码拼接跨平台时就会出问题。json 里保存的相对路径同样会有这个隐患。解决所有涉及路径的操作都用pathlib.Path比如Path(data) / train / class_name / filenamepathlib在拼接时自动使用当前系统的正确分隔符。如果 json 已经存了反斜杠路径读取时用replace(\\, /)做一次清洗或者干脆不用 json 里的路径直接用Path从目录层级重新构造。从那以后我每次把项目从 Windows 往 Linux 传都会先 grep 一遍代码里有没有手写的/和\拼接再跑一个最小读取测试确认所有分割文件能正常打开。6. 进阶技巧把 4000 张数据用出两倍效果数据量不算大所以进阶的核心思路是“榨干”。我在这个数据集上会用三个技巧5 折交叉验证确定超参、按图像哈希去重、类别合并处理极端不均衡。先看图像去重。茶叶叶片在采集时经常连续拍好几张曝光和角度几乎一样留在数据集里会让模型在验证时“偷看”训练分布。用感知哈希能做一次粗筛查import hashlib from pathlib import Path from PIL import Image seen {} for img_path in Path(data).rglob(*.jpg): img Image.open(img_path).convert(RGB).resize((64, 64)) h hashlib.md5(img.tobytes()).hexdigest() seen.setdefault(h, []).append(str(img_path)) for h, paths in seen.items(): if len(paths) 1: print(similar images:, len(paths)) for p in paths: print( , p)逻辑说明把每张图统一缩放成 64×64再计算文件内容的 md5 值。缩放之后哪怕原图分辨率不同只要缩略内容相近哈希也容易碰撞。参数说明64×64 是经验值越小越容易误判越大越容易漏判我这里用的是直接像素的 md5碰运气成分大更严格的方案是用imagehash库里的phash。遇到批量重复时只保留每个哈希组里第一张其余从训练目录移出去。第二个技巧是 5 折交叉验证。把 train 和 val 合并成完整数据用sklearn.model_selection.StratifiedKFold切 5 折每折训练一个短一点的模型取 5 个验证 fold 上的 macro F1 平均值作为超参得分。交叉验证真正解决的问题是“单次 val split 太随机”尤其少数类样本少换一个随机种子切分结果可能波动好几个点。第三个技巧是类别合并。如果决定做二分类简化实验比如“健康与否”那就可以把褐枯病、灰枯萎病、红点病都映射成“病害”剩余类别映射成“健康”在ImageFolder外部包一层自定义Dataset或者直接改目录名。我一般会在小类别做训练实验时先跑通这类简化任务确认流程没毛病再回到 5 类完整任务上跑长实验。这几招做完4000 张数据基本能榨出接近万级数据的效果。从那以后我每次拿新数据集第一件事跑统计第二件事抽图核对第三件事做 5 折交叉验证定超参最后才进入正式训练。这套流程帮我避免过太多“训练三天、发现标签是错的”这类事故希望帮到你。本文还有配套的精品资源点击获取