简介:面向茶叶叶片病害识别与图像分类任务,这份数据集提供约4000张已标注的常规茶叶叶片病害图像,涵盖褐枯病、灰枯萎病、红点病等5个类别,适合农业病害检测、深度学习图像分类教学及科研实验。压缩包内已按训练集、验证集和测试集分目录存放,类别信息存储于独立JSON标注文件中;另有Python可视化脚本,运行后可直接预览各类别样本,便于快速掌握数据分布。资源共2000个文件,以JPG图片为主,辅以1个py脚本和1个json配置文件,整体压缩包约21.68MB,数据规模适中、目录结构清晰。目前已有104人浏览学习,尤其适合作为CNN分类网络改进实验的基准数据,或用于课程设计与论文复现。通过这套数据,读者可省去繁重的图像采集与标注流程,直接获得已划分好的标准数据集和可视化工具,聚焦模型结构设计与调参优化。
1. 常规茶叶叶片病害图像分类数据集:拿到 4,000 张已标注图片后该做什么
很多人拿到一份「常规茶叶叶片病害图像分类数据集【已标注,约4,000张数据】」,第一反应就是直接扔进 ResNet 里训练,跑出个 90% 的准确率就觉得自己已经解决了茶园病害识别。实际上一线项目里最常见的翻车方式恰恰是这样:验证集指标很漂亮,模型一部署到真实茶园就崩,因为 4,000 张已标注数据属于典型的小规模图像分类数据集,它的价值不在于让你训出一个可以直接商用的模型,而在于帮你用最小的成本验证「图像分类模型在这个病害场景下到底可不可行」。这篇文章就把这条路径拆开讲清楚:先做数据体检确认标注真的能用,再做格式组织和模型选型,然后把训练、评估、踩坑和扩数据的方法一次讲完,适合刚接触图像分类、手上只有数据集但不知道下一步怎么下手的开发者。
2. 先别训练:数据体检决定后面 80% 的坑
2.1 目录结构与标注格式确认:先搞清楚手里的牌
已标注图像分类数据集的常见组织方式有三种:第一种是文件夹名即类别名,train/val 下每个子文件夹代表一种病害;第二种是文件名前缀携带标签,比如Blister_Blight_001.jpg;第三种是附带 CSV 或 JSON 映射表。拿到数据集后,第一件事不是写训练脚本,而是先用命令行把目录结构完整摸一遍。
# 查看数据集顶层结构 find . -maxdepth 2 -type d | sort | head -50 # 统计每个类别文件夹下的图片数量 find . -maxdepth 2 -type d | while read dir; do count=$(find "$dir" -maxdepth 1 -type f \( -name "*.jpg" -o -name "*.jpeg" -o -name "*.png" \) | wc -l) echo "$dir: $count" done这段命令的逻辑很简单:第一遍用find看目录层级,确认数据集是标准的 train/val 划分还是只有一个总目录;第二遍循环统计每个类别文件夹的图片数,这一步能立刻暴露类别不均衡的问题。这里需要特别关注输出里有没有 0 文件的空目录、有没有混入.DS_Store之类的隐藏文件,以及图片扩展名是否统一。常见做法是先把原始统计结果保存下来,作为后面划分训练集和验证集的基准。
参数说明:-maxdepth 2只往下看两层,避免统计到缩略图子目录;head -50防止类别太多把终端刷爆;文件名匹配里同时包含了 jpg、jpeg、png 三种常见格式,因为很多采集工具导出的图片扩展名并不统一。
2.2 图片可读性与像素尺寸分布检查:复现流程的第一步是排除坏数据
标注数据集听起来很可靠,但实际下载或拷贝过程中经常出现图片文件损坏、尺寸异常、色彩通道不一致的问题。训练时突然报错Image file is truncated或Cannot identify image file,往往就是这些坏图片在捣乱。
from PIL import Image from pathlib import Path img_dir = Path("path/to/dataset") errors = [] sizes = {} for img_path in img_dir.rglob("*.jpg"): try: with Image.open(img_path) as img: img.verify() # 只读文件头,不完全解码 sizes[img.size] = sizes.get(img.size, 0) + 1 except Exception as e: errors.append((str(img_path), str(e))) print(f"图片总数: {sum(sizes.values())}") print(f"尺寸分布 Top 5: {sorted(sizes.items(), key=lambda x: -x[1])[:5]}") if errors: print(f"损坏文件 {len(errors)} 个,示例:") for path, err in errors[:5]: print(f" {path}: {err}") else: print("没有发现无法读取的图片")这段代码用rglob("*.jpg")递归遍历所有图片,Image.open()后调用verify()只校验文件头而不做完整解码,速度比直接load()快得多,适合大批量扫描。sizes字典统计像素尺寸分布,是为了确认所有图片是否统一为某个固定分辨率,这直接影响后面 DataLoader 的resize参数设置。
如果检查出损坏图片,不要直接删除,先把路径导出到一个 txt 文件里备份,然后人工核对一遍再决定是重下还是丢弃。像素尺寸如果差异很大,比如有的是 800×600、有的是 1200×1600,建议统一缩放到同一短边再送入网络,而不是直接随机裁剪,避免病斑区域被裁掉。这一步做完,数据集的「黑匣子」状态就解除了一半,至少你知道手里这批图片真实可用。
2.3 类别分布与标签文本清洗:合并同类项前先看语义
4,000 张数据如果分成 8 类,平均每类只有 500 张,但真实数据集很少这么均匀。更常见的情况是,同一个病害因为采集季节不同,标签写成「茶饼病」「饼病」「茶饼病-老叶」三种写法,统计出来类别数虚高,模型训练时会被这些重复语义严重干扰。先写一段代码统计标签分布:
import pandas as pd from collections import Counter # 假设标签来自文件名前缀,按 "_" 分隔第一段作为类别名 from pathlib import Path img_dir = Path("path/to/dataset") labels = [] for img_path in img_dir.rglob("*.jpg"): label = img_path.name.split("_")[0] labels.append(label) counter = Counter(labels) df = pd.DataFrame(counter.most_common(), columns=["label", "count"]) print(df.to_string(index=False)) # 识别可能重复的标签:两两比较字符串相似度 from difflib import SequenceMatcher unique_labels = list(counter.keys()) duplicates = [] for i in range(len(unique_labels)): for j in range(i + 1, len(unique_labels)): ratio = SequenceMatcher(None, unique_labels[i], unique_labels[j]).ratio() if ratio > 0.8: duplicates.append((unique_labels[i], unique_labels[j], round(ratio, 2))) for dup in duplicates: print(f"疑似重复标签: {dup}")逻辑说明:Counter统计频次后用most_common()按数量降序输出,一份数据集的类别分布二八效应一眼就能看出来;SequenceMatcher计算标签字符串相似度,相似度大于 0.8 的就需要人工确认是否应该合并。这里要注意,合并标签必须回到原图去核对,不能只看文本相似就合,比如「茶白星病」和「茶饼病」文本不相似但症状接近,「炭疽病」和「炭殂病」拼写相似但可能就是同一个词,得回到实际图片确认病灶特征后再决定。标签清洗完成后,重新导出类别映射表,保存为一份 json,后面所有训练代码都统一从这份映射表读类别,而不是直接在代码里硬编码类别名。
3. 组织数据与加载:从文件夹到 PyTorch DataLoader 的完整链路
3.1 划分训练集与验证集:分层抽样比随机抽样稳得多
4,000 张已标注数据不是大样本,划分策略直接决定验证集指标的置信度。如果直接random.shuffle后再切,恰好把某个小类全部切进验证集,训练时就完全见不到这个类,模型对这个类的识别能力就是零。我用的是 sklearn 的train_test_split配合stratify参数做分层抽样。
import random import shutil from pathlib import Path from sklearn.model_selection import train_test_split random.seed(42) source_dir = Path("path/to/all_images") train_dir = Path("path/to/train") val_dir = Path("path/to/val") # 先收集所有图片路径和对应标签 images, labels = [], [] for img_path in source_dir.rglob("*.jpg"): label = img_path.name.split("_")[0] images.append(str(img_path)) labels.append(label) # 按标签分层划分,80% 训练、20% 验证 train_paths, val_paths = train_test_split( images, test_size=0.2, stratify=labels, random_state=42 ) # 复制到目标目录,按 类别/图片名 组织 def copy_files(paths, target_dir): for path in paths: p = Path(path) label = p.name.split("_")[0] dest_dir = target_dir / label dest_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(p, dest_dir / p.name) copy_files(train_paths, train_dir) copy_files(val_paths, val_dir) print(f"训练集: {len(train_paths)} 张, 验证集: {len(val_paths)} 张")stratify=labels保证训练集和验证集里每个类别的比例跟原来一致,这是小样本数据划分里最重要的一个参数。random_state=42固定随机种子,让划分结果可复现,方便后面两组实验直接对比。如果你后续要做模型对比,建议把类别比例分布打印出来确认,例如某个类在验证集里只有 20 张,那它的准确率波动会很大,评估时要单独关注。
比较稳妥的做法是不用shutil.copy2复制而是用os.symlink建软链接,可以省一半磁盘空间,但要注意数据集如果存储在 Windows 上,软链接需要管理员权限,复制最省事。4,000 张图片的存储开销通常不超过 2GB,复制更安全。
3.2 ImageFolder 加载与类别映射:避免手写 DataLoader 的三个理由
PyTorch 自带的torchvision.datasets.ImageFolder天然适配「文件夹名即类别」的组织方式,这也是我前面强调一定要把数据整理成标准目录结构的原因。手写一个 Dataset 类当然可以做更复杂的 label 映射,但在这个场景下没这个必要,ImageFolder更稳、更快、出错率更低。
from torchvision import datasets, transforms from torch.utils.data import DataLoader train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = 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]) ]) train_dataset = datasets.ImageFolder(root="path/to/train", transform=train_transform) val_dataset = datasets.ImageFolder(root="path/to/val", transform=val_transform) print("类别到索引的映射:", train_dataset.class_to_idx) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)Resize((256, 256))后再RandomCrop(224)是图像分类里非常经典的做法,等价于随机裁剪加轻微缩放,能在不改变病斑基本形状的前提下增加样本多样性。验证集用CenterCrop(224)保证评估时每次看到的都是同一块区域,结果可对比。Normalize的 mean 和 std 直接沿用 ImageNet 预训练模型的统计值,因为你后面要加载预训练权重,输入分布必须一致。
这里有一个很容易被忽略的细节:如果你的叶片图片不是 RGB 三通道而是灰度图或者带了 Alpha 通道,ToTensor()之后通道数不对,模型会直接报维度错误。早早在数据体检阶段就把所有图片统一转成 RGB 三通道,可以避免训练跑到一半才炸。num_workers=4在 Windows 上如果报多进程错误,改成 0 先跑通再说。
3.3 如果想拿这份分类数据集去做目标检测:yolov8 训练前的格式转换边界
总有人拿到分类数据集后问能不能直接用 yolov8 训练自己的数据集做检测,这里要把边界讲清楚:分类数据集只有图像级标签,没有目标框,不能直接转换成 YOLO 的txt标注格式。硬转的唯一办法是把整张图作为唯一目标框写进标注文件,模型学到的就是「整张图里有病斑」,完全没有定位能力,检测结果基本等于分类模型的热力图。
常见做法是先用这份分类数据训练一个分类模型,然后用 Grad-CAM 生成热力图,把热力图高亮区域作为候选框,再结合 LabelImg 或 X-AnyLabeling 人工修正,用这种方法标注几百张检测数据,再去微调检测模型。如果你后续要做的是旋转框检测,可以参照 mmrotate 训练 DOTA 数据集时的DOTA格式约定,但前提是你必须有带旋转角度的标注框,这份数据集的 4,000 张图片里没有这些信息,这一步需要人工介入。
所以我的建议是:不要指望从分类数据集白嫖检测模型。它的定位就是图像分类和预实验,能帮你快速验证特征是否可分、类别边界是否清晰,这几个问题搞清楚了,再投入成本做目标检测标注也不迟。
4. 训练与验证:4,000 张小样本场景下的模型选型和参数基准
4.1 数据增强参数设计:为什么这套增强组合适合茶叶病害图像
4,000 张图片对深度学习模型来说不算多,数据增强不是可选项而是必选项。但增强参数不能照搬 ImageNet 那套。茶叶叶片病害图像有两个特性:一是病斑往往是局部区域,增强时不能过度裁剪;二是同类病害在不同成熟度的叶片(嫩叶、老叶)上表现差异很大,需要色彩扰动来模拟不同光照和叶龄条件。
推荐这套参数组合:
| 增强操作 | 参数设置 | 设置理由 |
|---|---|---|
| RandomResizedCrop | size=224, scale=(0.6, 1.0) | scale 下限设 0.6,防止裁剪掉的区域太小导致病斑丢失 |
| RandomHorizontalFlip | p=0.5 | 叶片正反面对分类影响小,水平翻转不破坏语义 |
| ColorJitter | brightness=0.3, contrast=0.3, saturation=0.3, hue=0.05 | 模拟不同季节、不同光照下的叶片颜色差异 |
| RandomRotation | degrees=15 | 轻度旋转模拟拍摄角度变化,超过 15 度会让叶片朝向成为误导特征 |
| Normalize | ImageNet 标准统计值 | 配合预训练权重使用,输入分布必须一致 |
注意RandomResizedCrop的scale=(0.6, 1.0)和常见默认值(0.08, 1.0)差异很大。茶叶病害的病斑区域可能只占全图的 10% 左右,如果裁剪比例下限设为 0.08,多数裁剪结果里根本没有病斑,模型学到的全是叶片纹理背景,验证集准确率看着不低,实际泛化能力很差。这是这类数据集踩坑的高发区。
4.2 模型选型与训练脚本:ResNet 打底还是直接上 ViT
4,000 张数据量下,我的经验是优先用 ResNet18 或 ResNet50 加载 ImageNet 预训练权重当成基线。Vision Transformer 在这种小样本场景下如果没有大规模预训练权重兜底,收敛速度慢且容易过拟合,不是第一选择。先跑通一个强基线,再决定要不要往上加复杂度。
import torch import torch.nn as nn from torchvision import models # 加载预训练 ResNet18,替换最后的全连接层 model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) num_classes = len(train_dataset.classes) model.fc = nn.Linear(model.fc.in_features, num_classes) # 用类别权重处理样本不均衡 from sklearn.utils.class_weight import compute_class_weight import numpy as np all_labels = np.array(train_dataset.targets) class_weights = compute_class_weight(class_weight="balanced", classes=np.unique(all_labels), y=all_labels) class_weights = torch.tensor(class_weights, dtype=torch.float32).to("cuda") criterion = nn.CrossEntropyLoss(weight=class_weights) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=5e-4) # 训练循环里只保留最佳验证模型 best_acc = 0.0 for epoch in range(30): model.train() for images, labels in train_loader: images, labels = images.to("cuda"), labels.to("cuda") optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() # 每个 epoch 后跑验证 model.eval() acc = 0.0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to("cuda"), labels.to("cuda") outputs = model(images) acc += (outputs.argmax(1) == labels).sum().item() acc /= len(val_dataset) if acc > best_acc: best_acc = acc torch.save(model.state_dict(), "best_model.pth") print(f"Epoch {epoch+1}, Val Acc: {acc:.4f}, Best: {best_acc:.4f}")逻辑说明:compute_class_weight根据每个类别的样本数自动计算反向权重,样本少的类别在损失函数中权重更大,避免模型把所有样本都预测成多数类。AdamW配合weight_decay=5e-4是当前图像分类的主流组合,相比L2正则对权重衰减的处理更干净。学习率3e-4是针对冻结预训练骨干、只微调分类头的典型取值。
关于lr=3e-4这个数值多说一句:如果你用的是 ResNet50 或者更深的网络,学习率可以降到1e-4;如果你用的是 ViT 类模型,通常需要1e-4以下并配合 warmup,因为这批模型的优化器对学习率非常敏感。过高的学习率会让预训练权重很快被破坏,这在小数据集上是致命的。
参数说明:class_weight="balanced"会自动按n_samples / (n_classes * class_count)计算权重,比手动指定数值可靠。如果你的数据已经分层划分,直接用训练集的targets计算即可,不要去算验证集的权重。
4.3 评估指标不要只看准确率:小样本下宏平均 F1 才是关键
4,000 张数据按 8:2 划分后,验证集约 800 张,如果某类只有 30 张,即使全部预测错误,总体准确率也只掉了不到 4 个百分点,很容易被掩盖。这就是为什么我在评估阶段一定会看宏平均 F1 和混淆矩阵。
from sklearn.metrics import classification_report, confusion_matrix model.load_state_dict(torch.load("best_model.pth")) model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for images, labels in val_loader: images = images.to("cuda") outputs = model(images) preds = outputs.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report( all_labels, all_preds, target_names=list(train_dataset.class_to_idx.keys()), digits=3 )) print(confusion_matrix(all_labels, all_preds))classification_report会输出每个类别的 precision、recall、F1 以及宏平均和加权平均,比单一准确率信息密度大得多。肉眼判断标准是:如果某个类的 recall 明显低于其他类,说明这个类经常被误判成别的类;如果 precision 低,说明别的类经常被误判成这个类。这两种情况的处理策略完全不同,recall 低优先考虑增加该类样本或者调大类别权重,precision 低优先考虑看混淆矩阵里具体跟哪个类混淆。
关键要关注拥有最少训练样本的那个类别在小括号里单独看,4,000 张数据如果最少的类只有 50 张,它的 F1 低于 0.5 是正常现象,不要因此否定整个方案。此时真正的判断标准是:这个类的识别难度是因为样本太少(解决方向是扩数据),还是因为类别本身在视觉上就跟别的类高度相似(解决方向是换特征提取器或加细粒度信息)。
5. 茶病害数据集训练避坑指南:五条真实踩坑记录
5.1 验证集准确率很高但一发到真实茶园就失效:背景过拟合
现象:训练时验证集准确率 95%,拿手机在茶园拍了几张照片丢进去测试,准确率直接掉到 60% 以下。原因:数据集的图片大多是在统一背景下拍摄的,比如白纸背景、实验室台面背景,模型真正学到的是「背景是白色→大概率健康叶片」这种虚假关联,而不是病斑本身。解决:第一,训练时把增强里的RandomResizedCrop的 scale 下限调到 0.5,强制模型只能看到叶片局部;第二,做一次「背景替换实验」验证模型是否真的在看叶片——把测试图片里的叶片抠出来贴到陌生背景上,如果准确率骤降,说明背景过拟合严重;第三,如果条件允许,采集一部分自然背景的茶园图片混入训练集。
5.2 相邻拍摄产生的重复叶片导致数据泄露
现象:训练集准确率 99%,验证集准确率 88%,但测试集表现远低于验证集,且混淆矩阵里看不出规律。原因:采集时对同一片叶子的不同部位连续拍了好几张,这些构图高度重复的图片被随机分配到了训练集和验证集。模型在训练集里见过几乎一样的图片,验证时自然认得出,但真实场景里没见过这种「同源样本」。解决:数据划分前先做感知哈希去重,把内容和构图极度相似的图片挑出来归到同一组,然后按组划分而不是按单张图片随机划分。这一步在 4,000 张小数据集上尤其重要,因为重复比例可能高达 20%。
5.3 标注粒度前后不一致:有的是整叶病害,有的是病斑特写
现象:训练过程 loss 曲线反复震荡,验证集 F1 始终上不去,错误样本里同一个类别内部差异巨大。原因:数据集标注时,一部分图片是整片叶子的全貌,病斑只占很小区域;另一部分图片是病斑的微距特写。前者模型学到的是「整叶形态+病斑纹理」,后者学到的是「纯病斑纹理」,两类特征互相干扰。解决:对每张图记录一个额外的「尺度标签」,训练时把整叶图和特写图当作两个子任务分别计算损失,或者先按尺度分层训练两个模型再融合;最简单的做法是先人工挑出特写图,单独微调一个分类器专门处理特写输入。
5.4 类别不均衡导致模型「懒惰」:少数类几乎不被预测
现象:训练结束后,发现某个严重病害类别的 recall 为 0,模型从不预测这个类。原因:多数类占 60% 以上,模型只要把所有样本都预测成多数类,准确率就有 60%,损失函数里少数类的贡献被淹没。解决:上面 4.2 里的class_weight="balanced"是第一步,但如果效果不够,还可以在训练时对少数类做离线过采样,比如把该类的每张图做 5 组不同增强再送入训练,等价于把该类样本数放大 5 倍。不要直接复制粘贴原图,那样会让模型记住像素而非特征。
5.5 预训练权重加载失败导致的效果归零
现象:模型收敛很快但准确率一直很低,loss 下降缓慢。原因:加载预训练权重时名称不匹配,比如把model.fc替换成新分类头后,加载state_dict时报了惰性错误,有人直接把strict=False传进去,结果前面卷积层的权重全都随机初始化了,预训练带来的优势直接归零。解决:加载权重时先打印 missing keys 和 unexpected keys,确认缺失的只有最后一层fc相关的参数。代码层面用model.load_state_dict(state_dict, strict=False)前,先人工核对一遍 key 列表,这是小数据集下最便宜但最容易忽略的止损手段。
6. 进阶技巧:基于 4,000 张已标注数据搭建半监督自训练闭环
4,000 张已标注数据真正发挥价值的方式,是靠它作为种子数据去撬动无标签数据。茶园场景里无标签数据几乎零成本,拿手机去茶园随手拍,一天就能拍几千张。白嫖这批无标签数据的常见做法是自训练(self-training):用已经训练好的模型给无标签图片打伪标签,筛选出高置信度的加入训练集,再用扩充后的数据重新训练。整个过程可以迭代三轮以上。
第一步是教师模型选择。用第 4 章里训好的 ResNet18,在给无标签数据打标签时,不要只看最大 softmax 概率,还要看最大概率和第二大概率的差距。如果差距小于 0.2,说明模型在两个类别之间摇摆,这种样本的伪标签可信度低,直接丢弃。阈值可以按类别分别设置,因为模型对样本量少的类天然置信度低,这类数据的伪标签筛选阈值要放宽到 0.15,否则永远挑不出这个类的样本。第二步是伪标签清洗,把模型预测结果按类别分组,每组随机抽 10 张图人工过目一遍,如果某组里面超过 3 张是明显错标,说明这个类的决策边界有问题,要么调低它的置信度阈值,要么彻底放弃这个类的新增样本。这一步不能省,伪标签的噪声累积会把整个模型带偏。
第三步是重训和蒸馏。把筛选出的无标签数据与原始 4,000 张合并,重新训练一个学生模型。如果数据量从 4,000 涨到 8,000,学习率可以保持3e-4不变,但weight_decay可以适当减小到3e-4,因为数据量变大后过拟合压力本身就小了。训练完成后再次用新的模型打伪标签,反复迭代。我一般会做两个 epoch 的伪标签增强实验,对比增强前后的宏平均 F1,如果提升不足 1 个百分点,说明无标签数据分布跟原始数据重合度高,继续迭代意义不大,停止扩张把精力放到人工标注真实分布差异更大的新数据上。
最后说一个我自己养成很久的习惯:每一轮伪标签筛选后都把被丢弃的样本留一份存档。后期模型改进后回看这些样本,经常会发现当初丢弃它们只是因为阈值设置不合理而非模型能力不足。这种存档习惯能在复现实验和更换模型时省下大量重复标注成本。这套流程走完,即使最终数据规模仍然不算大,「手上数据到底够不够用」「模型上限在哪里」这些问题你心里基本有数了。希望帮到你。
本文还有配套的精品资源,点击获取