茶叶病害图像分类实战:4,000张样本从数据到上线
简介面向茶叶病害识别与图像分类任务这套常规茶叶叶片病害图像分类数据集提供了约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_okTrue) 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_okTrue) for f in items: shutil.copy2(os.path.join(src_folder, f), os.path.join(out_dir, f)) print(划分完成)分层划分的关键在于shuffle必须在每个类别内部执行而不是先把全量数据混合再切。如果某个病害样本特别少混洗后该类别可能整体落入测试集训练时就彻底看不到这个类别了。划分参数建议值说明train/val/test70% / 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 \ datatea_disease_split \ modelyolov8n-cls.pt \ epochs80 \ imgsz224 \ batch32 \ lr01e-3 \ optimizerAdamW \ cos_lrTrue \ patience20data指向第 2 章划分后的tea_disease_split该目录下必须有train/和val/两个子目录每个子目录里按类别建文件夹。yolov8n-cls.pt是 Nano 规模分类权重下载后自动缓存如果显存足够且希望精度更高可以换yolov8s-cls.pt。参数意义如下表参数建议值说明epochs60-1004,000 张数据在 80 轮附近基本收敛再多容易过拟合imgsz224 或 256病斑小可改 384显存占用增加约 1 倍batch16/32/64RTX 3060 用 32 即可过大不一定提升精度lr01e-3使用 AdamW 时的常用初始学习率cos_lrTrue余弦退火收敛更平滑避免最后阶段震荡patience20验证集指标连续 20 轮不提升就早停节省时间训练结束后在runs/classify/train/目录下会生成weights/best.pt、confusion_matrix.png和results.csv。confusion_matrix.png是第一份标注质量报告如果某两个类别互分严重先不要急着调模型回数据集里看原始图片是不是真的难以区分——有些病害早期症状本来就很像这时要考虑是否合并类别。验证和单张预测yolo classify val \ datatea_disease_split \ modelruns/classify/train/weights/best.pt yolo classify predict \ modelruns/classify/train/weights/best.pt \ sourcetest_leaf.jpg3.3 Transformer 图像分类路线timm ViT 微调要点YOLOv8-cls 胜在省事但如果你要做 Grad-CAM 或注意力可视化或者希望自定义损失函数和数据采样器用 timm 更灵活。ViT 在中小规模数据集上不如 CNN 抗过拟合因此模型选型上优先考虑vit_small_patch16_224而不是 ViT-Baseimport 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(p0.5), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(IMAGENET_DEFAULT_MEAN, IMAGENET_DEFAULT_STD), ]) train_data datasets.ImageFolder(tea_disease_split/train, transformtrain_tf) val_data datasets.ImageFolder(tea_disease_split/val, transformtrain_tf) train_loader DataLoader( train_data, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue ) val_loader DataLoader( val_data, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue ) model timm.create_model( vit_small_patch16_224, pretrainedTrue, num_classeslen(train_data.classes) ) optimizer torch.optim.AdamW(model.parameters(), lr5e-5, weight_decay0.05) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max40) criterion nn.CrossEntropyLoss(label_smoothing0.1) print(f类别清单: {train_data.classes}) print(f训练样本数: {len(train_data)})这里的关键参数是lr5e-5。ViT 对学习率比 CNN 敏感直接从 1e-4 起步容易在第一个 epoch 就把预训练特征冲坏。label_smoothing0.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(fepoch {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 fcrops/{cid} os.makedirs(out_dir, exist_okTrue) 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_splits5, shuffleTrue, random_state42) for fold, (train_idx, val_idx) in enumerate(skf.split(image_paths, labels)): print(ffold {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_sampleslen(sample_weights), replacementTrue ) train_loader DataLoader( train_data, batch_size32, samplersampler, num_workers4, pin_memoryTrue )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 fcoco_crops/{cat_id} os.makedirs(out_dir, exist_okTrue) 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, win672, step336, devicecuda): 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), dim1)[0] label int(probs.argmax().item()) votes[label] votes.get(label, 0) 1 return max(votes.items(), keylambda 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_label1 ) # 选择精确率与召回率差值最小的点 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。阈值选定后还要回到验证集上模拟一遍实际应用中的正负样本比例确认该阈值在真实分布下没有明显偏差。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →