简介:面向茶叶病害识别与图像分类任务,这套常规茶叶叶片病害图像分类数据集提供了约4,000张已标注图片,覆盖褐枯病、灰枯萎病、红点病等5个常见类别,适合高校学生、算法工程师及农业AI研究者用于模型训练、算法验证与基准测试,也可支撑茶叶种植智能化监测、病虫害预警等应用研究。资源包采用zip格式,共2000个文件,包含1998张jpg图像、1个Python可视化脚本和1个json标注文件,压缩包整体约21.68MB。数据已按训练集、验证集、测试集划分,各集合内图片按类别存放,目录结构规范,便于直接加载使用;json文件可查看具体类别映射,show脚本可一键可视化样本,帮助快速熟悉数据分布。目前已有103人学习,对于需要开展茶叶病害分类实验、调试CNN模型或进行网络改进对比的读者,是一份轻量、规范且可直接上手的数据集。
1. 常规茶叶叶片病害图像分类数据集:4,000 张标注样本的真正门槛
拿到一个约 4,000 张的已标注茶叶叶片病害图像分类数据集时,我第一反应不是急着跑模型,而是先算清楚这个量级能支撑什么。若按 5 到 8 类常见病害划分,平均每类只有 500 到 800 张,对深度学习图像分类模型来说正处于“勉强能训、容易过拟合”的档位。真正决定项目成败的往往不是选 YOLOv8 还是 Transformer,而是标注噪声、类别不均衡、切片策略这些数据侧问题。本文将沿着“验证数据 → 设计训练 → 迭代标注 → 上线调阈值”这条线,把每一步的做法、命令和参数讲清楚。适合做农业视觉、智慧茶园项目,以及刚拿到自定义数据集想快速起步的算法工程师参考。
2. 数据集验证:先摸清 4,000 张标注数据的真实分布
拿到数据先别急着拆训练集。图像分类任务里,模型性能的上限由标注质量决定,4,000 张的体量本来就不大,哪怕只有几十张错误标注,也会在混淆矩阵里形成明显的噪声簇。下面从标注形态、文件完整性和数据划分三个角度把数据盘一遍。
2.1 先确认标注形态:文件夹分类和 CSV 分类是两条路线
茶叶病害数据集最常见的组织方式是 ImageNet 风格:根目录下按类别建文件夹,每张图片直接放到对应病害类别里。也有少量数据集把图片集中存放,另附labels.csv或 JSON 文件记录image_id到label的映射。还有一部分数据集其实是检测标注,图片旁带着 XML 或 TXT 边界框文件。
标题明确写着“图像分类数据集”,所以优先按单标签分类处理,但建议拿到手先看一眼目录。如果是检测标注,后续要决定走检测路线,还是先裁剪出叶片再转分类。这个选择影响后面的全部代码,值得在第一天确认。
常见的图像分类数据集目录结构大致如下:
tea_disease_dataset/ ├── anthracnose/ # 茶炭疽病 │ ├── 0001.jpg │ ├── 0002.jpg │ └── ... ├── blister_blight/ # 茶饼病 ├── white_star/ # 茶白星病 └── unclassified/ # 如果存在,需要人工复核拿到目录后,我一般先做三件事:统计每类数量、检查图片能否正常打开、统计尺寸分布。千万别跳过「图片能否正常打开」这一步,我在不少数据集中见过扩展名是.jpg但实际损坏的文件,它会直接在训练中断言报错。
2.2 用脚本核对标注一致性并揪出脏数据
下面这段脚本可以在几分钟内把 4,000 张图的样本分布、损坏文件和尺寸分布全部扫出来:
import os from collections import Counter from PIL import Image root = "tea_disease_dataset" classes = sorted(os.listdir(root)) exts = (".jpg", ".jpeg", ".png", ".bmp", ".webp") print("=== 类别样本数统计 ===") for cls in classes: folder = os.path.join(root, cls) if not os.path.isdir(folder): continue files = [f for f in os.listdir(folder) if f.lower().endswith(exts)] print(f"{cls}: {len(files)} 张") print("\n=== 损坏文件检查 ===") bad_files = [] for cls in classes: folder = os.path.join(root, cls) if not os.path.isdir(folder): continue for f in os.listdir(folder): path = os.path.join(folder, f) try: with Image.open(path) as im: im.verify() except Exception as e: bad_files.append((path, str(e))) if bad_files: for path, err in bad_files[:20]: print(f"[损坏] {path} -> {err}") else: print("未发现损坏文件") print("\n=== 图片尺寸分布 Top 10 ===") sizes = Counter() for cls in classes: folder = os.path.join(root, cls) if not os.path.isdir(folder): continue for f in os.listdir(folder): if f.lower().endswith(exts): try: with Image.open(os.path.join(folder, f)) as im: sizes[im.size] += 1 except Exception: pass for size, cnt in sizes.most_common(10): print(f"{size}: {cnt} 张")im.verify()只读取文件头并做基础解码校验,不做完整像素解码,因此整批 4,000 张跑下来也很快。统计尺寸分布的意义在于确定训练时的imgsz参数:如果大量图片宽度集中在 500 到 800 像素,224 的输入尺寸就不会丢太多细节;如果同时存在几千像素的大图,后面通常要做滑窗切片,这个问题会在第 5 章展开。
样本数量统计有两个关注点:其一,如果某个类别的数量不足 100 张,这类模型基本学不出来;其二,如果出现空目录,大概率是标注导出时丢了一部分图,需要回源头找补。
2.3 按类别划分 train/val/test,别用默认顺序
直接按目录顺序切分是大忌。原数据集的图片排序往往是采集时间或者人工归类的顺序,直接切会导致某个病害类别在验证集中恰好缺失。正确做法是先shuffle,再做按类别比例的分层划分:
import os import random import shutil random.seed(2025) # 固定种子,保证每次划分一致 val_ratio, test_ratio = 0.15, 0.15 dst_root = "tea_disease_split" classes = [c for c in sorted(os.listdir(root)) if os.path.isdir(os.path.join(root, c))] exts = (".jpg", ".jpeg", ".png", ".bmp", ".webp") for split in ["train", "val", "test"]: os.makedirs(os.path.join(dst_root, split), exist_ok=True) for cls in classes: src_folder = os.path.join(root, cls) files = [f for f in os.listdir(src_folder) if f.lower().endswith(exts)] random.shuffle(files) n_val = int(len(files) * val_ratio) n_test = int(len(files) * test_ratio) n_train = len(files) - n_val - n_test parts = { "train": files[:n_train], "val": files[n_train:n_train + n_val], "test": files[n_train + n_val:], } for split, items in parts.items(): out_dir = os.path.join(dst_root, split, cls) os.makedirs(out_dir, exist_ok=True) for f in items: shutil.copy2(os.path.join(src_folder, f), os.path.join(out_dir, f)) print("划分完成")分层划分的关键在于shuffle必须在每个类别内部执行,而不是先把全量数据混合再切。如果某个病害样本特别少,混洗后该类别可能整体落入测试集,训练时就彻底看不到这个类别了。
| 划分参数 | 建议值 | 说明 |
|---|---|---|
| train/val/test | 70% / 15% / 15% | 4,000 张的体量下,验证集至少 500 张才稳 |
| 随机种子 | 固定值(如 42 / 2025) | 保证复现实验,不同模型对比在同一数据划分上进行 |
| copy 还是 move | 优先 copy | 保留原始数据集,后续切 Slide 窗口时要回原图重切 |
3. 用 YOLOv8-cls 与 ViT 训练茶叶病害分类模型
数据验证完成,下面进入训练环节。4,000 张图不足以从零训练一个深度卷积网络或视觉 Transformer,迁移学习是唯一现实的路线。这里给出两条可复现的路径:一是直接用 YOLOv8 自带的分类模块,配置成本最低;二是用 timm 微调 ViT,适合需要输出概率分布和做注意力可视化的场景。
3.1 为什么先拿预训练模型做迁移学习
茶叶病害的表观特征,比如病斑形状、颜色、纹理,和 ImageNet 里物体边缘、表面材质等通用视觉特征高度相关。预训练模型的前几层学到的是通用边缘和纹理检测器,在茶叶叶片上依然有效。微调只需要让高层的语义特征适配到具体病害类别。
此外,图像分类模型的收敛速度和样本量直接相关。4,000 张数据从头训练,通常要 200 个 epoch 以上且效果差;用预训练权重微调,50 到 100 个 epoch 就能收敛。这一点在对比实验里特别重要——如果基线模型跑得慢,后续做消融实验的时间成本会高很多。
3.2 YOLOv8-cls 最小复现命令与关键参数
YOLOv8 的分类模块把数据加载、训练、评估打包成了命令行,是快速验证一条数据是否可用的首选。安装并执行:
pip install ultralytics yolo classify train \ data=tea_disease_split \ model=yolov8n-cls.pt \ epochs=80 \ imgsz=224 \ batch=32 \ lr0=1e-3 \ optimizer=AdamW \ cos_lr=True \ patience=20data指向第 2 章划分后的tea_disease_split,该目录下必须有train/和val/两个子目录,每个子目录里按类别建文件夹。yolov8n-cls.pt是 Nano 规模分类权重,下载后自动缓存;如果显存足够且希望精度更高,可以换yolov8s-cls.pt。参数意义如下表:
| 参数 | 建议值 | 说明 |
|---|---|---|
| epochs | 60-100 | 4,000 张数据在 80 轮附近基本收敛,再多容易过拟合 |
| imgsz | 224 或 256 | 病斑小可改 384,显存占用增加约 1 倍 |
| batch | 16/32/64 | RTX 3060 用 32 即可,过大不一定提升精度 |
| lr0 | 1e-3 | 使用 AdamW 时的常用初始学习率 |
| cos_lr | True | 余弦退火,收敛更平滑,避免最后阶段震荡 |
| patience | 20 | 验证集指标连续 20 轮不提升就早停,节省时间 |
训练结束后,在runs/classify/train/目录下会生成weights/best.pt、confusion_matrix.png和results.csv。confusion_matrix.png是第一份标注质量报告,如果某两个类别互分严重,先不要急着调模型,回数据集里看原始图片是不是真的难以区分——有些病害早期症状本来就很像,这时要考虑是否合并类别。
验证和单张预测:
yolo classify val \ data=tea_disease_split \ model=runs/classify/train/weights/best.pt yolo classify predict \ model=runs/classify/train/weights/best.pt \ source=test_leaf.jpg3.3 Transformer 图像分类路线:timm + ViT 微调要点
YOLOv8-cls 胜在省事,但如果你要做 Grad-CAM 或注意力可视化,或者希望自定义损失函数和数据采样器,用 timm 更灵活。ViT 在中小规模数据集上不如 CNN 抗过拟合,因此模型选型上优先考虑vit_small_patch16_224,而不是 ViT-Base:
import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms from timm.data.constants import IMAGENET_DEFAULT_MEAN, IMAGENET_DEFAULT_STD import timm train_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(IMAGENET_DEFAULT_MEAN, IMAGENET_DEFAULT_STD), ]) train_data = datasets.ImageFolder("tea_disease_split/train", transform=train_tf) val_data = datasets.ImageFolder("tea_disease_split/val", transform=train_tf) train_loader = DataLoader( train_data, batch_size=32, shuffle=True, num_workers=4, pin_memory=True ) val_loader = DataLoader( val_data, batch_size=32, shuffle=False, num_workers=4, pin_memory=True ) model = timm.create_model( "vit_small_patch16_224", pretrained=True, num_classes=len(train_data.classes) ) optimizer = torch.optim.AdamW(model.parameters(), lr=5e-5, weight_decay=0.05) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=40) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) print(f"类别清单: {train_data.classes}") print(f"训练样本数: {len(train_data)}")这里的关键参数是lr=5e-5。ViT 对学习率比 CNN 敏感,直接从 1e-4 起步容易在第一个 epoch 就把预训练特征冲坏。label_smoothing=0.1是给标签加一点“软性”,在标注噪声存在时能小幅提升泛化能力。训练循环用标准写法即可:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) best_acc = 0.0 for epoch in range(40): model.train() total_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() scheduler.step() print(f"epoch {epoch + 1:02d}, loss={total_loss / len(train_loader):.4f}")注意timm.create_model默认会把分类头替换成目标类别数量,但预训练权重在num_classes不一致时会丢弃最后一层,前层参数仍会加载。这是迁移学习最方便的一点,不需要手工拼接backbone + fc。
3.4 如果数据集其实是检测标注,先裁剪再分类
标题写的是图像分类,但也有可能附带边界框。如果你手头的数据是这种格式,有两种做法:直接用 YOLOv8 检测;或者先把框裁出来,用裁剪后的叶片做分类。后者在病害分类场景更常见,因为分类模型对整幅图中叶片的位置和角度不敏感,裁剪后样本更干净。
import cv2 import os # 假设边界框存储在 label.txt,每行: class_id x1 y1 x2 y2 image_path = "leaf.jpg" img = cv2.imread(image_path) with open("label.txt") as f: for line in f: parts = line.strip().split() cid, x1, y1, x2, y2 = map(int, parts) crop = img[y1:y2, x1:x2] out_dir = f"crops/{cid}" os.makedirs(out_dir, exist_ok=True) cv2.imwrite(f"{out_dir}/{os.path.basename(image_path)}_{x1}_{y1}.jpg", crop)裁剪后还要过一遍第 2 章的统计脚本,重点看每个类别的裁剪数量。有些框可能落在叶片边缘,裁出来大半是背景,这类样本要么删除,要么做语义分割精细化。
4. 标注质量、类别不平衡与半监督补数据
训练完第一个版本,下一步不是疯狂调参,而是把模型当工具反过来审视数据集。数据标注决定了图像分类性能的上限,模型结构只决定能逼近上限多少。这一章专门处理标注噪声、样本不均衡和扩数据三个问题。
4.1 用分层 K 折交叉验证抓标注噪声
单次划分 train/val/test 只能验证一次,样本少时噪声很容易被隐藏。常见的做法是用分层 5 折交叉验证,每折训练一个模型,再汇总看哪些样本被反复分错:
import numpy as np from sklearn.model_selection import StratifiedKFold image_paths = [] labels = [] for cls in classes: folder = os.path.join("tea_disease_split", "train", cls) for f in os.listdir(folder): if f.lower().endswith((".jpg", ".jpeg", ".png")): image_paths.append(os.path.join(folder, f)) labels.append(cls) labels = np.array(labels) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, val_idx) in enumerate(skf.split(image_paths, labels)): print(f"fold {fold}: train={len(train_idx)}, val={len(val_idx)}") # 在这一折上训练模型,保存 val_idx 对应样本的预测结果K 折的真正价值在于:把 5 折中每折都被分错的样本挑出来,人工看一遍原始图片。这些通常是标注错误、多病斑混叠或背景干扰严重的样本。处理后重新标注,比增加模型复杂度更有效。
YOLOv8-cls 命令行不直接支持交叉验证,需要自己写脚本在每折生成对应的train/val目录,或者用 timm 的 PyTorch 接口配合StratifiedKFold手动循环。过程繁琐,但对 4,000 张的小样本来说值得做一次。
4.2 类别不平衡与加权采样策略
茶叶病害天然存在长尾分布:茶炭疽病可能占 40%,某些少见病害只有一两百张。直接训练会让模型偏向高频类别。常用三种调整手段,按侵入程度排序:
| 方法 | 实现方式 | 适用场景 |
|---|---|---|
| 过采样 | 对小类重复采样 | 各类数量差距在 5 倍以内 |
| 加权损失 | 按样本数反比调整权重 | 各类数量差距大,或小类确实难学 |
| Focal Loss | 降低易分样本的损失占比 | 背景占比高、难例多的场景 |
在 timm 路线下,用WeightedRandomSampler是最稳妥的过采样方式:
from torch.utils.data import WeightedRandomSampler class_counts = { cls: len(os.listdir(os.path.join("tea_disease_split", "train", cls))) for cls in train_data.classes } sample_weights = [ 1.0 / class_counts[train_data.classes[label]] for _, label in train_data.imgs ] sampler = WeightedRandomSampler( sample_weights, num_samples=len(sample_weights), replacement=True ) train_loader = DataLoader( train_data, batch_size=32, sampler=sampler, num_workers=4, pin_memory=True )sample_weights的做法是每个样本的采样概率与类别样本数成反比。比如某类有 200 张,另一类有 800 张,前者的每个样本权重是后者的 4 倍。注意保持num_samples等于总样本数,否则每个 epoch 的迭代长度会改变。
小类别数量实在过少(低于 100 张)时,我一般先不硬训,而是去采集更多数据。8 类 4,000 张的数据集里,单类不足 100 张意味着这类病害几乎没有可学习的变化模式。
4.3 半监督自动标注与 CVAT 人工修正工作流
数据不够时,常见的做法是用已训练模型生成伪标注,再人工修正。工具上推荐 CVAT,自建 Web 端标注工具对团队协作最方便。流程分四步:
- 用第 3 章训练的模型对未标注叶片图批量推理,生成预测框或类别标签。
- 把结果导入 CVAT,创建任务并分配标注员。
- 标注员只修正机器预标注的结果,不从头框选。
- 导出 COCO 格式或 YOLO 格式,转回训练集。
# 将 CVAT 导出的 COCO 格式转换为分类目录结构 import json import cv2 import os with open("annotations.json") as f: coco = json.load(f) # coco["annotations"] 里每项有 image_id, category_id, bbox for ann in coco["annotations"]: image_id = ann["image_id"] cat_id = ann["category_id"] x, y, w, h = [int(v) for v in ann["bbox"]] img_info = next(i for i in coco["images"] if i["id"] == image_id) img = cv2.imread(os.path.join("images", img_info["file_name"])) crop = img[y:y + h, x:x + w] out_dir = f"coco_crops/{cat_id}" os.makedirs(out_dir, exist_ok=True) cv2.imwrite(f"{out_dir}/{image_id}_{x}_{y}.jpg", crop)半监督数据的关键防线是置信度阈值。机器预标注的置信度低于 0.7 的样本不要直接进 CVAT,单独归到一个 “待讨论” 任务里,由人判断是标注错误还是模型表达力不足。常见误用是把预标注结果不经验证直接合并进训练集,这样会把模型自身的偏见固化,越迭代越偏。
5. 滑窗切片与阈值选择:把分类模型调到可上线状态
模型在验证集上达到 90% 以上准确率,离上线还差两步:处理整株茶树高清图,以及给每个类选一个合理的判定阈值。这两个问题在小目标病害上几乎一定会遇到。
5.1 滑窗切片解决整株扫描图的判别
验证集里的图片通常已经被人工裁剪成单叶,但实际拍摄的茶园照片往往是整株或者多个叶片共存。直接在整张大图上推理,病斑面积占比太小,模型很难给出稳定结果。常见做法是滑窗切片,把大图切成多个 672×672 的块,逐块预测再投票:
import torch import torchvision.transforms as transforms def predict_sliding(model, img, win=672, step=336, device="cuda"): tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), ]) h, w = img.shape[:2] votes = {} model.eval() for y in range(0, h - win + 1, step): for x in range(0, w - win + 1, step): crop = img[y:y + win, x:x + win] crop = crop[:, :, ::-1] # BGR -> RGB tensor = tf(crop).unsqueeze(0).to(device) with torch.no_grad(): probs = torch.softmax(model(tensor), dim=1)[0] label = int(probs.argmax().item()) votes[label] = votes.get(label, 0) + 1 return max(votes.items(), key=lambda kv: kv[1])[0]step小于win时窗口有重叠。重叠切片能让每个病斑出现在多个窗口里,投票结果更稳,但推理时间随重合率增加。茶叶叶片场景下step = win / 2是性价比不错的位置;如果图像很稀疏,可以加大到win * 0.75。
5.2 从验证集 PR 曲线选阈值
分类模型的默认阈值是 0.5,但样本不均衡时这个值几乎永远不是最优解。对每一个类别单独画精确率-召回率曲线,选择精确率和召回率交点附近的阈值:
from sklearn.metrics import precision_recall_curve # y_true: 真实标签的 one-hot, y_score: 模型的 softmax 输出 precision, recall, thresholds = precision_recall_curve( y_true[:, 2], y_score[:, 2], pos_label=1 ) # 选择精确率与召回率差值最小的点 f1_scores = 2 * precision * recall / (precision + recall + 1e-6) best_idx = f1_scores.argmax() print(f"最优阈值: {thresholds[best_idx]:.3f}, F1: {f1_scores[best_idx]:.3f}")上线时对每个类别使用独立阈值,替代统一的 0.5。这样做的好处在于:茶炭疽病可能 0.4 就能安全触发告警,而茶饼病因为危害性大,宁可精确率高一点,阈值放到 0.75。阈值选定后,还要回到验证集上模拟一遍实际应用中的正负样本比例,确认该阈值在真实分布下没有明显偏差。
本文还有配套的精品资源,点击获取