简介面向茶叶叶片病害识别与图像分类任务这份数据集提供约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(), keylambda 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(indexFalse)) # 识别可能重复的标签两两比较字符串相似度 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_size0.2, stratifylabels, random_state42 ) # 复制到目标目录按 类别/图片名 组织 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(parentsTrue, exist_okTrue) 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)} 张)stratifylabels保证训练集和验证集里每个类别的比例跟原来一致这是小样本数据划分里最重要的一个参数。random_state42固定随机种子让划分结果可复现方便后面两组实验直接对比。如果你后续要做模型对比建议把类别比例分布打印出来确认例如某个类在验证集里只有 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(p0.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(rootpath/to/train, transformtrain_transform) val_dataset datasets.ImageFolder(rootpath/to/val, transformval_transform) print(类别到索引的映射:, train_dataset.class_to_idx) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)Resize((256, 256))后再RandomCrop(224)是图像分类里非常经典的做法等价于随机裁剪加轻微缩放能在不改变病斑基本形状的前提下增加样本多样性。验证集用CenterCrop(224)保证评估时每次看到的都是同一块区域结果可对比。Normalize的 mean 和 std 直接沿用 ImageNet 预训练模型的统计值因为你后面要加载预训练权重输入分布必须一致。这里有一个很容易被忽略的细节如果你的叶片图片不是 RGB 三通道而是灰度图或者带了 Alpha 通道ToTensor()之后通道数不对模型会直接报维度错误。早早在数据体检阶段就把所有图片统一转成 RGB 三通道可以避免训练跑到一半才炸。num_workers4在 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 那套。茶叶叶片病害图像有两个特性一是病斑往往是局部区域增强时不能过度裁剪二是同类病害在不同成熟度的叶片嫩叶、老叶上表现差异很大需要色彩扰动来模拟不同光照和叶龄条件。推荐这套参数组合增强操作参数设置设置理由RandomResizedCropsize224, scale(0.6, 1.0)scale 下限设 0.6防止裁剪掉的区域太小导致病斑丢失RandomHorizontalFlipp0.5叶片正反面对分类影响小水平翻转不破坏语义ColorJitterbrightness0.3, contrast0.3, saturation0.3, hue0.05模拟不同季节、不同光照下的叶片颜色差异RandomRotationdegrees15轻度旋转模拟拍摄角度变化超过 15 度会让叶片朝向成为误导特征NormalizeImageNet 标准统计值配合预训练权重使用输入分布必须一致注意RandomResizedCrop的scale(0.6, 1.0)和常见默认值(0.08, 1.0)差异很大。茶叶病害的病斑区域可能只占全图的 10% 左右如果裁剪比例下限设为 0.08多数裁剪结果里根本没有病斑模型学到的全是叶片纹理背景验证集准确率看着不低实际泛化能力很差。这是这类数据集踩坑的高发区。4.2 模型选型与训练脚本ResNet 打底还是直接上 ViT4,000 张数据量下我的经验是优先用 ResNet18 或 ResNet50 加载 ImageNet 预训练权重当成基线。Vision Transformer 在这种小样本场景下如果没有大规模预训练权重兜底收敛速度慢且容易过拟合不是第一选择。先跑通一个强基线再决定要不要往上加复杂度。import torch import torch.nn as nn from torchvision import models # 加载预训练 ResNet18替换最后的全连接层 model models.resnet18(weightsmodels.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_weightbalanced, classesnp.unique(all_labels), yall_labels) class_weights torch.tensor(class_weights, dtypetorch.float32).to(cuda) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay5e-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(fEpoch {epoch1}, Val Acc: {acc:.4f}, Best: {best_acc:.4f})逻辑说明compute_class_weight根据每个类别的样本数自动计算反向权重样本少的类别在损失函数中权重更大避免模型把所有样本都预测成多数类。AdamW配合weight_decay5e-4是当前图像分类的主流组合相比L2正则对权重衰减的处理更干净。学习率3e-4是针对冻结预训练骨干、只微调分类头的典型取值。关于lr3e-4这个数值多说一句如果你用的是 ResNet50 或者更深的网络学习率可以降到1e-4如果你用的是 ViT 类模型通常需要1e-4以下并配合 warmup因为这批模型的优化器对学习率非常敏感。过高的学习率会让预训练权重很快被破坏这在小数据集上是致命的。参数说明class_weightbalanced会自动按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_nameslist(train_dataset.class_to_idx.keys()), digits3 )) 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_weightbalanced是第一步但如果效果不够还可以在训练时对少数类做离线过采样比如把该类的每张图做 5 组不同增强再送入训练等价于把该类样本数放大 5 倍。不要直接复制粘贴原图那样会让模型记住像素而非特征。5.5 预训练权重加载失败导致的效果归零现象模型收敛很快但准确率一直很低loss 下降缓慢。原因加载预训练权重时名称不匹配比如把model.fc替换成新分类头后加载state_dict时报了惰性错误有人直接把strictFalse传进去结果前面卷积层的权重全都随机初始化了预训练带来的优势直接归零。解决加载权重时先打印 missing keys 和 unexpected keys确认缺失的只有最后一层fc相关的参数。代码层面用model.load_state_dict(state_dict, strictFalse)前先人工核对一遍 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 个百分点说明无标签数据分布跟原始数据重合度高继续迭代意义不大停止扩张把精力放到人工标注真实分布差异更大的新数据上。最后说一个我自己养成很久的习惯每一轮伪标签筛选后都把被丢弃的样本留一份存档。后期模型改进后回看这些样本经常会发现当初丢弃它们只是因为阈值设置不合理而非模型能力不足。这种存档习惯能在复现实验和更换模型时省下大量重复标注成本。这套流程走完即使最终数据规模仍然不算大「手上数据到底够不够用」「模型上限在哪里」这些问题你心里基本有数了。希望帮到你。本文还有配套的精品资源点击获取
