
简介本资源为常规茶叶叶片病害图像分类数据集面向从事图像分类、农业病害识别方向的学生与算法工程师可用于训练与评估CNN分类网络。数据集已标注共划分5个类别包括褐枯病、灰枯萎病、红点病等具体类别名称可查看包内json文件。资源已按训练集、验证集、测试集划分各类别图片分别存放于对应目录便于直接加载训练同时提供show脚本可快速可视化样本分布与图像内容。压缩包共2000个文件以1998张jpg图像为主另含1个py脚本与1个json标注文件整体约21.68MB体积轻量、结构清晰。目前已有104人浏览学习适合作为课程设计、毕业设计或分类网络改进实验的基准数据配合作者主页的CNN改进与图像分类项目可进一步完成模型对比与调参验证。1. 茶叶叶片病害图像分类数据集4000 张标注图能跑出什么结果茶叶叶片病害识别这件事真正卡住大多数团队的从来不是模型结构而是数据。你手上如果有一份约 4,000 张、已经标注好的常规茶叶叶片病害图像分类数据集那么从零到能出第一版可用模型其实只需要一个下午。问题在于很多人拿到数据集之后第一反应是直接ImageFolder一把梭训完发现验证集准确率 98%换一批自己拍的叶子照片就崩到 60% 以下——这不是模型的问题是没搞清楚这份数据集的分布、标注粒度和采集条件。这份数据集的核心价值在于「已标注」三个字。图像分类数据集和检测数据集不一样它不需要边界框每张图对应一个类别标签目录结构通常就是类别名/图片文件。约 4,000 张的规模放在图像分类任务里属于中小体量够训一个从零开始的轻量网络也够微调一个预训练 backbone。它适合谁适合做农业物联网终端识别的嵌入式工程师、做茶园巡检机器人的算法同学、以及想拿一个真实场景数据集练手图像分类全流程的开发者。下面我从数据检查、划分、训练到部署验证把这条链路拆开讲。2. 先别急着训4000 张茶叶病害图的清洗与分布核查2.1 目录结构确认与类别统计拿到数据集第一件事不是写训练脚本而是把目录结构和类别分布摸清楚。茶叶叶片病害常见类别包括茶饼病、茶炭疽病、茶白星病、茶轮斑病以及健康叶片但不同数据集标注体系不一样有的把「虫害」和「病害」混在一起有的只标了「病斑」和「健康」两类。你得先确认这份数据集的类别定义。import os from collections import Counter from pathlib import Path # 假设数据集根目录结构为 dataset/train/类别名/xxx.jpg # 先不管 train/val 划分直接扫描所有图片 root Path(tea_leaf_dataset) exts {.jpg, .jpeg, .png, .bmp, .webp} class_count Counter() corrupt_files [] for cls_dir in sorted(root.iterdir()): if not cls_dir.is_dir(): continue for img in cls_dir.rglob(*): if img.suffix.lower() in exts: class_count[cls_dir.name] 1 # 顺手检查文件大小0 字节基本是坏图 if img.stat().st_size 1024: corrupt_files.append(str(img)) print(类别分布) for k, v in class_count.most_common(): print(f {k}: {v} 张) print(f总图片数: {sum(class_count.values())}) print(f疑似损坏文件: {len(corrupt_files)}) for f in corrupt_files[:10]: print( , f)这段脚本做三件事统计每个类别的图片数量、检查小于 1KB 的疑似坏图、输出总样本数。参数上exts集合按你实际拿到的格式增减rglob(*)会递归子目录防止有人把图片又套了一层文件夹。如果类别之间数量差距超过 3:1后面训练必须做重采样或加权损失否则模型会偏向多数类。2.2 用感知哈希找出重复图和近似图4,000 张的数据集里重复图和近似图是准确率虚高的头号元凶。同一片叶子换个角度拍两张、或者从视频里连续抽帧都会让训练集和验证集出现信息泄漏。我一般用感知哈希pHash快速筛一遍。import imagehash from PIL import Image from pathlib import Path from collections import defaultdict root Path(tea_leaf_dataset) hashes defaultdict(list) for img_path in root.rglob(*): if img_path.suffix.lower() not in {.jpg, .jpeg, .png}: continue try: with Image.open(img_path) as im: # hash_size8 得到 64 位指纹对轻微旋转/缩放不敏感 h str(imagehash.phash(im, hash_size8)) hashes[h].append(str(img_path)) except Exception as e: print(f跳过 {img_path}: {e}) # 输出重复组 dup_groups {k: v for k, v in hashes.items() if len(v) 1} print(f发现 {len(dup_groups)} 组重复/近似图) for h, files in list(dup_groups.items())[:5]: print(f 指纹 {h}: {len(files)} 张) for f in files: print( , f)imagehash.phash的hash_size参数控制指纹精度8 表示 8×8 的感知哈希对 JPEG 压缩和轻微亮度变化鲁棒。如果两组图指纹完全相同基本可以判定是重复。处理策略同一组只保留一张其余删掉或移到_duplicates备份目录。这一步做完你的有效样本可能从 4,000 降到 3,500 左右但模型泛化会明显更稳。2.3 训练/验证/测试划分的三个硬约束划分不是随机train_test_split就完事。茶叶叶片数据集的划分要满足三个约束第一同一片叶子的多张照片必须落在同一个集合否则验证集准确率会虚高第二类别比例在三个集合中要一致第三测试集要留出至少 15%且全程不参与任何调参。import random from pathlib import Path from collections import defaultdict import shutil random.seed(42) root Path(tea_leaf_dataset) out Path(tea_split) splits {train: 0.7, val: 0.15, test: 0.15} # 按类别收集假设已人工确认无重复图 for cls_dir in sorted(root.iterdir()): if not cls_dir.is_dir(): continue imgs [p for p in cls_dir.rglob(*) if p.suffix.lower() in {.jpg, .png, .jpeg}] random.shuffle(imgs) n len(imgs) n_train int(n * splits[train]) n_val int(n * splits[val]) assigned { train: imgs[:n_train], val: imgs[n_train:n_train n_val], test: imgs[n_train n_val:], } for split, files in assigned.items(): dst out / split / cls_dir.name dst.mkdir(parentsTrue, exist_okTrue) for f in files: shutil.copy2(f, dst / f.name) print(f{cls_dir.name}: train{len(assigned[train])}, val{len(assigned[val])}, test{len(assigned[test])})random.seed(42)保证可复现shutil.copy2保留原文件元数据。如果你的数据集已经按叶子 ID 分好组把imgs换成按组划分即可。划分完检查一下每个 split 下每个类别的数量差距超过 10% 就重新调种子或手动补。3. 从 ImageFolder 到 DataLoader茶叶叶片分类的输入管线怎么搭3.1 用 torchvision 搭最小可跑管线图像分类的输入管线看起来简单但茶叶叶片这个场景有几个特殊点叶片在画面中的位置不固定、背景可能是茶园土壤或手指、光照从顺光到逆光都有。所以增强策略不能照搬 ImageNet 那套。import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader train_tf transforms.Compose([ transforms.Resize((256, 256)), # 先统一到稍大尺寸 transforms.RandomResizedCrop(224, scale(0.6, 1.0)), # 随机裁剪模拟不同拍摄距离 transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.3), # 叶片旋转在真实场景常见 transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.2, hue0.05), transforms.RandomRotation(degrees25), # 手机拍摄角度不固定 transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) train_ds datasets.ImageFolder(tea_split/train, transformtrain_tf) val_ds datasets.ImageFolder(tea_split/val, transformval_tf) test_ds datasets.ImageFolder(tea_split/test, transformval_tf) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_ds, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) test_loader DataLoader(test_ds, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) print(类别映射:, train_ds.class_to_idx) print(f训练集 {len(train_ds)} / 验证集 {len(val_ds)} / 测试集 {len(test_ds)})关键参数说明RandomResizedCrop的scale(0.6, 1.0)允许裁到原图 60% 区域模拟近距离拍摄病斑RandomVerticalFlip概率设 0.3 而不是 0.5因为叶片正反面在真实场景中不是等概率出现ColorJitter的hue只给 0.05茶叶的绿色色调是重要特征色相扰动太大会把病斑颜色改掉。num_workers在 Windows 上如果报错就设 0Linux 下按 CPU 核数一半设置。3.2 类别不均衡时的 WeightedRandomSampler如果第 2 章统计出来某个类别只有 200 张、最多的有 1,200 张直接训会让模型几乎只预测多数类。除了加权损失更稳的做法是用WeightedRandomSampler在采样阶段就做平衡。import numpy as np from torch.utils.data import WeightedRandomSampler # 基于训练集标签计算每个类别的权重 targets [label for _, label in train_ds.samples] class_counts np.bincount(targets) print(训练集类别计数:, class_counts) # 每个样本的权重 1 / 该类样本数 class_weights 1.0 / class_counts sample_weights class_weights[targets] sampler WeightedRandomSampler( weightstorch.DoubleTensor(sample_weights), num_sampleslen(sample_weights), replacementTrue, ) train_loader_balanced DataLoader( train_ds, batch_size32, samplersampler, num_workers4, pin_memoryTrue, )replacementTrue表示有放回采样少数类会被反复抽到。num_samples设成和训练集一样大保证每个 epoch 的步数不变。注意用了sampler就不能再设shuffleTrue两者互斥。这个方案比在损失函数里加weight更直接因为它在数据层面就平衡了梯度贡献。3.3 验证集增强的边界什么时候不该加验证集和测试集只能用Resize CenterCrop Normalize不能加任何随机增强。我见过有人在验证集上也加RandomHorizontalFlip结果每次验证准确率波动 3 个点调参完全没法判断。验证集的唯一作用是衡量模型在当前数据分布下的表现加随机性等于引入噪声。如果你发现验证集准确率远高于测试集先检查是不是验证集图片和训练集有重复而不是去调增强参数。4. 选 backbone 还是从零训4000 张图上的模型策略与训练参数4.1 小数据集上的迁移学习 vs 从零训练4,000 张图在图像分类里属于「微调够用、从零偏少」的区间。从零训一个 ResNet-18 大概能到 85% 左右但需要仔细调学习率和正则用 ImageNet 预训练权重微调通常能到 92% 以上而且收敛快得多。我的建议是除非你的部署环境对模型结构有硬性限制否则一律走迁移学习。import torch.nn as nn from torchvision import models def build_model(num_classes, pretrainedTrue, freeze_backboneFalse): # 用 ResNet-18 做 baseline轻量且够用 model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None) if freeze_backbone: # 冻结除 fc 外的所有层 for param in model.parameters(): param.requires_grad False # 替换分类头 in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes), ) return model num_classes len(train_ds.classes) model build_model(num_classes, pretrainedTrue, freeze_backboneFalse) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) print(f类别数: {num_classes}, 设备: {device})freeze_backboneFalse表示全网络微调。如果你的数据量更小比如每类不到 200 张可以先设True只训分类头 5 个 epoch再解冻全网络用更小的学习率微调。Dropout(0.3)放在全连接前对小数据集防过拟合有效。4.2 学习率、权重衰减与早停的实操参数训练参数没有万能值但茶叶叶片分类这个场景有一套比较稳的起点。import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR criterion nn.CrossEntropyLoss(label_smoothing0.1) # 标签平滑防过拟合 optimizer optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30, eta_min1e-6) def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (outputs.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / total torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) outputs model(imgs) loss criterion(outputs, labels) total_loss loss.item() * imgs.size(0) correct (outputs.argmax(1) labels).sum().item() total imgs.size(0) return total_loss / total, correct / totallr3e-4是微调预训练模型的常用起点weight_decay1e-4配合 AdamW 做解耦权重衰减。label_smoothing0.1把硬标签软化对标注可能有噪声的数据集特别有用——茶叶病斑的类别边界有时候连人都要犹豫。CosineAnnealingLR的T_max30表示 30 个 epoch 内余弦下降到eta_min。4.3 训练循环与最佳模型保存把上面的组件串起来加上早停和最佳模型保存。best_val_acc 0.0 patience, wait 7, 0 epochs 40 for epoch in range(epochs): train_loss, train_acc train_one_epoch(model, train_loader_balanced, criterion, optimizer, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch1:02d} | train_loss{train_loss:.4f} acc{train_acc:.4f} | val_loss{val_loss:.4f} acc{val_acc:.4f}) if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_tea_model.pth) wait 0 else: wait 1 if wait patience: print(f早停于 epoch {epoch1}最佳验证准确率 {best_val_acc:.4f}) breakpatience7表示验证准确率连续 7 个 epoch 不提升就停。保存的是state_dict而不是整个模型加载时更灵活。训练完在测试集上跑一次evaluate那个数字才是你对外汇报的准确率。5. 避坑与排查茶叶叶片分类训练中最容易翻车的五件事5.1 验证集准确率 99% 但实际部署全错现象训练日志里验证集准确率很快到 99%但拿手机拍几张茶园照片测几乎全部分错。原因验证集和训练集来自同一批采集数据光照、背景、拍摄设备完全一致模型学到的是「背景特征」而不是「病斑特征」。比如所有健康叶片都在白色背景下拍模型可能把「白色背景」当成健康类的特征。解决从原始数据里按采集批次划分而不是随机划分。如果数据集没有批次信息至少按图片的亮度、背景颜色做分层抽样。更彻底的做法是留出一批完全独立采集的图片做测试集哪怕只有 200 张。5.2 损失不下降准确率卡在随机水平现象训练 loss 从第一轮就不怎么降准确率在类别数倒数附近徘徊。原因最常见的是标签映射错了。ImageFolder按文件夹名排序生成class_to_idx如果你自己另外维护了一份标签映射两边对不上模型学的就是错标签。另一个可能是学习率太大梯度直接炸了。解决打印train_ds.class_to_idx和你的标签映射对比。学习率从3e-4降到1e-4试一轮如果 loss 开始降就是学习率问题。还可以检查Normalize的均值和方差是不是和预训练模型匹配用错归一化参数会让输入分布偏移。5.3 训练集准确率 100% 但验证集只有 70%现象训练集很快过拟合验证集准确率上不去。原因数据增强太弱、模型容量太大、或者训练集和验证集分布差异大。4,000 张图训 ResNet-50 就比 ResNet-18 更容易过拟合。解决先加增强——RandomResizedCrop的 scale 下限降到 0.5加RandomGrayscale(p0.1)让模型不依赖颜色。再加正则——Dropout从 0.3 提到 0.5weight_decay从1e-4提到5e-4。如果还不行换更小的 backboneResNet-18 换成 MobileNetV3-Small。5.4 推理时预处理和训练不一致现象训练时验证准确率 93%导出模型部署后准确率掉到 60%。原因部署端的预处理和训练时不一致。训练用了Resize(256) CenterCrop(224)部署时直接Resize(224)图像被拉伸变形。或者训练用了 ImageNet 归一化部署时忘了减均值除方差。解决把验证集的val_tf完整复制到部署代码里不要凭记忆重写。用同一张测试图分别在训练环境和部署环境跑一遍对比预处理后的 tensor 数值差超过 0.01 就是有问题。5.5 类别标签顺序在部署时错位现象模型输出概率最高的类别索引是 2但部署代码里索引 2 对应的类别名和训练时不一样。原因ImageFolder的class_to_idx是按文件夹名 ASCII 排序生成的比如healthy排在anthracnose后面。部署时如果手动写了一个类别列表顺序很容易错。解决训练完立刻把train_ds.class_to_idx保存成 JSON部署时加载这个 JSON 做索引到类别名的映射不要手写。import json with open(class_mapping.json, w, encodingutf-8) as f: json.dump(train_ds.class_to_idx, f, ensure_asciiFalse, indent2) print(类别映射已保存:, train_ds.class_to_idx)6. 把模型推到能用的程度混淆矩阵、置信度阈值与一个部署前检查习惯训练完拿到测试集准确率只是起点。我一般会做三件事来判断这个模型能不能真的拿去用。第一件是画混淆矩阵看错分集中在哪两个类之间。茶叶病害里茶炭疽病和茶轮斑病的病斑形态在早期很接近如果这两个类互相错分率高说明模型学到的特征还不够细。这时候可以针对性补充这两个类的样本或者把输入分辨率从 224 提到 320让病斑纹理更清晰。import numpy as np from sklearn.metrics import confusion_matrix, classification_report torch.no_grad() def get_all_preds(model, loader, device): model.eval() all_preds, all_labels [], [] for imgs, labels in loader: imgs imgs.to(device) outputs model(imgs) preds outputs.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) return np.array(all_labels), np.array(all_preds) model.load_state_dict(torch.load(best_tea_model.pth, map_locationdevice)) y_true, y_pred get_all_preds(model, test_loader, device) idx_to_class {v: k for k, v in train_ds.class_to_idx.items()} target_names [idx_to_class[i] for i in range(len(idx_to_class))] print(confusion_matrix(y_true, y_pred)) print(classification_report(y_true, y_pred, target_namestarget_names, digits4))classification_report里的f1-score比准确率更能反映类别不均衡时的真实表现。如果某个类的 recall 低于 0.8说明这个类的样本被大量漏判需要补数据或调采样权重。第二件是看模型对错误预测的置信度分布。一个可用的模型错分时的置信度应该偏低如果错分时置信度还是 0.95 以上说明模型过自信部署时不能只靠 argmax要加置信度阈值。import torch.nn.functional as F torch.no_grad() def confidence_analysis(model, loader, device, threshold0.7): model.eval() low_conf_count, wrong_high_conf 0, 0 total 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) outputs model(imgs) probs F.softmax(outputs, dim1) max_probs, preds probs.max(1) total imgs.size(0) low_conf_count (max_probs threshold).sum().item() wrong_high_conf ((preds ! labels) (max_probs 0.9)).sum().item() print(f总样本 {total}) print(f置信度低于 {threshold} 的样本: {low_conf_count} ({low_conf_count/total:.2%})) print(f错分但置信度高于 0.9 的样本: {wrong_high_conf} ({wrong_high_conf/total:.2%})) confidence_analysis(model, test_loader, device)如果「错分但高置信度」的比例超过 2%部署时建议加一个兜底策略置信度低于 0.7 的样本不自动判定转人工复核。这在茶园巡检场景里很实用宁可多拍一张让植保员看一眼也不要自动给出错误结论。第三件是做一个「部署前一致性检查」脚本把训练时的验证集图片随机抽 20 张走一遍部署端的完整推理流程和训练时的预测结果对比。这个习惯帮我拦下过好几次预处理不一致的问题。import random from PIL import Image def preprocess_for_deploy(img_path): # 这里必须和训练时的 val_tf 完全一致 tf transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) return tf(Image.open(img_path).convert(RGB)).unsqueeze(0) sample_imgs random.sample(test_ds.samples, 20) model.eval() mismatch 0 for img_path, true_label in sample_imgs: tensor preprocess_for_deploy(img_path).to(device) with torch.no_grad(): pred model(tensor).argmax(1).item() if pred ! true_label: mismatch 1 print(f不一致: {img_path} 真实{idx_to_class[true_label]} 预测{idx_to_class[pred]}) print(f20 张抽样中不一致 {mismatch} 张)这 20 张里如果有超过 2 张和训练时预测不一致说明部署端预处理有问题回去逐行对比val_tf。最后说一个我自己的习惯每次训完模型不管准确率多高我都会拿手机去楼下绿化带拍 10 张不同光照条件下的叶子照片手动过一遍模型。这 10 张不参与任何训练和调参纯粹是「真实世界抽检」。如果这 10 张里错超过 3 张测试集准确率再高我也不会把这个模型交出去。数据集是死的茶园是活的这个习惯帮我省了很多返工。希望帮到你。本文还有配套的精品资源点击获取