简介:这份番茄叶子缺陷图像分类数据集面向从事图像分类、农业病害识别与深度学习实践的开发者与研究者,提供可直接投入训练与验证的标注数据,帮助解决番茄叶片病害分类任务中样本获取与预处理成本高的问题。资源包共约2000个文件,以1998张jpg图像为主体,另含1个py脚本与1个json标注文件,压缩包大小约161.45MB,图像按训练集与测试集分别存放,同类数据归入同一目录,json文件记录细菌斑点、早疫病、健康、Septoria_spot等7个类别的具体划分信息,py脚本可用于数据集可视化预览。目前已有63人学习下载。读者可借助该数据集快速搭建分类网络输入管线,验证模型在真实叶片图像上的表现,并参考配套的图像分类与分割改进思路及计算机视觉完整项目内容,完成从数据加载、类别统计到训练评估的完整流程,适合作为课程设计、竞赛练习或算法对比实验的基础数据。
1. 番茄叶子缺陷图像分类数据集:3000 张已标注样本能跑出什么结果
去年帮一个做设施农业的朋友处理大棚巡检数据,他拿手机拍了两个月番茄叶片,攒了四千多张图,问我能不能直接训个模型识别早疫病和晚疫病。我打开一看,光照忽明忽暗、背景里全是滴灌管和地膜,标注只有文件夹名。这件事让我意识到,番茄叶子缺陷图像分类数据集这类资源真正的价值不在图片数量,而在于「已标注」三个字背后省掉的清洗成本。约 3000 张已标注数据,如果类别均衡、标注规范,足够从零训出一个可用的分类基线,也能做迁移学习的小样本验证。它适合两类人:一是想入门图像分类但被数据准备卡住的开发者,二是做智慧农业、植物表型分析、边缘端病害识别预研的工程师。下面我按「拿到数据集先看什么、怎么切分、怎么训、怎么避坑」的顺序,把这条链路走一遍。
2. 拿到番茄叶子缺陷图像分类数据集先做三件事:查类别、看分布、验标注
很多人拿到数据集第一反应是直接ImageFolder加载开训,结果训到一半发现某类只有几十张,或者标注文件里混进了非叶片图。番茄叶片缺陷分类的类别通常围绕早疫病、晚疫病、叶霉病、健康叶这几类展开,但不同来源的数据集类别命名和粒度差异很大。先花半小时做体检,比后面调三天参划算。
2.1 用脚本统计类别分布与图像尺寸
第一步不是写模型,是写一个统计脚本。把目录结构、每类数量、图像分辨率、通道模式全部打出来。这一步能暴露三个问题:类别是否均衡、是否存在损坏文件、尺寸是否统一。
import os from pathlib import Path from PIL import Image from collections import defaultdict root = Path("./tomato_leaf_defect") # 数据集根目录,按类别分子文件夹 stats = defaultdict(list) corrupt = [] for cls_dir in sorted(root.iterdir()): if not cls_dir.is_dir(): continue for img_path in cls_dir.glob("*"): if img_path.suffix.lower() not in {".jpg", ".jpeg", ".png", ".bmp"}: continue try: with Image.open(img_path) as im: im.verify() # 校验文件完整性 with Image.open(img_path) as im: stats[cls_dir.name].append(im.size) except Exception as e: corrupt.append((str(img_path), str(e))) for cls, sizes in stats.items(): w_set = {s[0] for s in sizes} h_set = {s[1] for s in sizes} print(f"{cls}: {len(sizes)} 张, 宽度种类={len(w_set)}, 高度种类={len(h_set)}, 示例={sizes[0]}") print(f"\n损坏文件数: {len(corrupt)}") for p, e in corrupt[:10]: print(p, e)这段代码的逻辑是:遍历每个类别文件夹,用verify()做完整性校验,再记录尺寸。参数说明:root指向数据集根目录,要求是「一类一文件夹」的结构;suffix过滤非图像文件,避免.DS_Store或标注文本被误读。跑完后重点看两件事:各类数量是否差距超过 3 倍,尺寸种类是否超过 5 种。如果某类只有一两百张,后面必须用加权采样或数据增强补;如果尺寸五花八门,统一 resize 到 224 或 256 是常规做法。
2.2 抽样目检标注质量与背景干扰
统计只能看数量,标注对不对必须肉眼抽检。从每类随机抽 20 张,拼成网格图看。番茄叶片数据最常见的标注问题是:把健康叶误标成早期病斑、把多片叶混在一起标成单类、背景里地膜反光被当成病斑。我一般会写个拼图脚本,一次性看完。
import random import matplotlib.pyplot as plt from PIL import Image from pathlib import Path root = Path("./tomato_leaf_defect") classes = [d.name for d in root.iterdir() if d.is_dir()] fig, axes = plt.subplots(len(classes), 8, figsize=(16, 2 * len(classes))) for i, cls in enumerate(classes): imgs = list((root / cls).glob("*.jpg")) + list((root / cls).glob("*.png")) samples = random.sample(imgs, min(8, len(imgs))) for j, p in enumerate(samples): axes[i][j].imshow(Image.open(p)) axes[i][j].axis("off") axes[i][0].set_ylabel(cls, fontsize=9) plt.tight_layout() plt.savefig("sample_grid.png", dpi=120)逻辑说明:每类抽 8 张横向排列,行标签是类别名。参数说明:random.sample保证不重复抽样,min(8, len(imgs))防止某类样本不足时报错。看拼图时重点判断:同一类内部是否形态一致、有没有明显不属于该类的图混入。如果发现某类里混了别的病害,要么手动剔除,要么在训练时当作噪声容忍——但类别少的时候建议剔除,3000 张的规模经不起太多脏数据。
2.3 划分训练验证测试集时别按随机切
随机切分在图像分类里有个隐蔽的坑:同一片叶子连拍的多张图可能被分到训练集和验证集,导致验证指标虚高。番茄叶片数据如果是连续拍摄的,相邻帧高度相似。稳妥做法是按「拍摄批次」或「叶片编号」分组切分,没有分组信息时至少用固定随机种子并检查重复图。
import hashlib from pathlib import Path from collections import defaultdict def file_hash(p, block=8192): h = hashlib.md5() with open(p, "rb") as f: while chunk := f.read(block): h.update(chunk) return h.hexdigest() root = Path("./tomato_leaf_defect") hash_map = defaultdict(list) for p in root.rglob("*"): if p.suffix.lower() in {".jpg", ".png", ".jpeg"}: hash_map[file_hash(p)].append(str(p)) dups = {k: v for k, v in hash_map.items() if len(v) > 1} print(f"重复图片组数: {len(dups)}") for k, v in list(dups.items())[:5]: print(v)逻辑说明:用 MD5 对文件内容做哈希,内容完全相同的图会落到同一组。参数说明:block=8192是分块读取大小,大图也不会吃满内存。如果重复组很多,说明数据集里有大量连拍或增强过的副本,切分时必须整组归到同一侧,否则验证集就是「背答案」。这一步做完再按 7:1.5:1.5 划分,训练集用于拟合,验证集调参,测试集只在最后跑一次。
3. 用迁移学习在 3000 张番茄叶片上训出可用模型:从基线到调参
3000 张图在图像分类里属于小样本,从零训 ResNet 基本会过拟合。常见做法是拿 ImageNet 预训练权重做迁移,冻结主干先训分类头,再解冻部分层微调。这一章给出可复现的训练脚本和关键参数,模型选型上,EfficientNet-B0 或 ResNet18 在边缘端部署友好,精度也够用。
3.1 构建 DataLoader:增强策略与类别权重
番茄叶片图像的增强不能照搬通用配方。颜色抖动要克制,因为病斑颜色是重要特征;随机裁剪可以,但别裁掉病斑区域;水平翻转安全,垂直翻转对叶片语义影响不大但也能用。类别不均衡时用加权采样。
import torch from torch.utils.data import DataLoader, WeightedRandomSampler from torchvision import datasets, transforms from collections import Counter train_tf = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale=(0.7, 1.0)), # 保留主体,避免裁掉病斑 transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.2), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.02), # 颜色抖动要轻 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = 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_ds = datasets.ImageFolder("./split/train", transform=train_tf) val_ds = datasets.ImageFolder("./split/val", transform=val_tf) targets = [s[1] for s in train_ds.samples] counts = Counter(targets) class_weights = {c: 1.0 / n for c, n in counts.items()} sample_weights = [class_weights[t] for t in targets] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) print("类别分布:", counts)逻辑说明:训练增强包含缩放裁剪、翻转、轻量颜色抖动,验证集只做确定性的 resize 和中心裁剪。参数说明:scale=(0.7, 1.0)控制裁剪面积下限,太低会切掉病斑;ColorJitter的hue=0.02很小,因为色相变化会破坏病斑颜色特征;WeightedRandomSampler让少样本类别被采到的概率提高,replacement=True表示有放回采样。batch_size=32在 8GB 显存上跑 224 输入比较稳,显存小就降到 16。
3.2 迁移学习训练循环:冻结、解冻与学习率设置
训练分两阶段:先冻结主干只训分类头,让随机初始化的头收敛;再解冻最后几个 block 做小学习率微调。这样比一上来全量微调稳定,也不容易把预训练特征打乱。
import torch.nn as nn import torch.optim as optim from torchvision import models device = torch.device("cuda" if torch.cuda.is_available() else "cpu") num_classes = len(train_ds.classes) model = models.efficientnet_b0(weights=models.EfficientNet_B0_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad = False # 第一阶段冻结全部主干 model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.AdamW(model.classifier.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=10) def run_epoch(loader, train=True): model.train() if train else model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.set_grad_enabled(train): for x, y in loader: x, y = x.to(device), y.to(device) out = model(x) loss = criterion(out, y) if train: optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() * x.size(0) correct += (out.argmax(1) == y).sum().item() total += x.size(0) return total_loss / total, correct / total for epoch in range(10): tr_loss, tr_acc = run_epoch(train_loader, True) va_loss, va_acc = run_epoch(val_loader, False) scheduler.step() print(f"Epoch {epoch+1}: train_loss={tr_loss:.4f} train_acc={tr_acc:.4f} val_loss={va_loss:.4f} val_acc={va_acc:.4f}") # 第二阶段:解冻最后两个 block 微调 for name, param in model.named_parameters(): if "features.7" in name or "features.8" in name: param.requires_grad = True optimizer = optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-4, weight_decay=1e-4) for epoch in range(10): tr_loss, tr_acc = run_epoch(train_loader, True) va_loss, va_acc = run_epoch(val_loader, False) print(f"Finetune Epoch {epoch+1}: train_acc={tr_acc:.4f} val_acc={va_acc:.4f}")逻辑说明:第一阶段只更新classifier,学习率 1e-3;第二阶段解冻 EfficientNet 的最后两个 stage,学习率降到 1e-4。参数说明:AdamW的weight_decay=1e-4抑制过拟合;CosineAnnealingLR的T_max=10对应第一阶段轮数;解冻层用features.7、features.8是 EfficientNet-B0 的深层 block,浅层特征通用性强不必动。如果验证准确率在第一阶段就冲到很高但训练准确率低,说明数据太简单或验证集泄漏,回头查切分。
3.3 评估不只看准确率:混淆矩阵与单类召回
番茄病害分类里,把「早疫病」误判成「健康叶」比反过来代价大得多,因为漏检会导致整株传染。所以评估必须看每类召回率,不能只看总体准确率。
from sklearn.metrics import classification_report, confusion_matrix import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for x, y in val_loader: x = x.to(device) preds = model(x).argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(y.numpy()) print(classification_report(all_labels, all_preds, target_names=val_ds.classes, digits=4)) print("混淆矩阵:") print(confusion_matrix(all_labels, all_preds))逻辑说明:classification_report输出每类的 precision、recall、f1。参数说明:target_names用val_ds.classes保证类别名对应;digits=4方便看小差异。重点看召回率最低的那一类,通常是样本最少或标注最乱的类。如果某类召回低于 0.7,优先补该类数据或单独调该类阈值,而不是盲目加轮数。
4. 番茄叶片缺陷分类的避坑清单:五条血泪经验
这一章记录我在植物病害分类项目里真实翻过的车,每条按现象、原因、解决写。番茄叶片数据集的坑和通用图像分类不完全一样,背景干扰和标注粒度是重灾区。
4.1 验证集准确率 99% 但上线就废
现象:本地验证准确率冲到 0.99,部署到手机端拍照识别,健康叶被大量判成病害。原因:数据集里的图多是近距离、均匀光照的样本,而实际拍摄有阴影、反光、远距离小目标,域差异巨大。另外切分时连拍图泄漏,验证集等于训练集的近邻。解决:按文件哈希去重后再切分;训练时加入模拟真实场景的增强,比如随机遮挡、亮度大幅变化、运动模糊;上线前用真实手机拍一批图做测试集,别信实验室指标。
4.2 颜色抖动开太大,病斑特征被抹掉
现象:训练损失下降正常,但模型对早疫病和晚疫病的区分能力很差,混淆矩阵里两类互相误判。原因:早疫病和晚疫病的差异部分体现在病斑颜色和边缘色泽上,ColorJitter的hue或saturation开大后,这些细微差异被随机化,模型学不到判别特征。解决:把hue控制在 0.02 以内,saturation不超过 0.2;如果类别间主要靠颜色区分,考虑在 HSV 空间做固定变换而不是随机抖动,或者干脆去掉颜色增强。
4.3 类别文件夹命名带空格和中文,ImageFolder 报错
现象:datasets.ImageFolder加载时报FileNotFoundError或类别名乱码。原因:ImageFolder 按文件夹名生成类别索引,中文或空格在某些系统编码下会出问题,且类别顺序按字典序,容易和预期不一致。解决:统一改成英文小写加下划线,比如early_blight、late_blight、leaf_mold、healthy;加载后打印train_ds.classes确认顺序,保存模型时把类别列表一起存,推理时按同一顺序解码。
4.4 训练集和验证集归一化参数不一致
现象:验证损失比训练损失高一大截,且验证准确率波动剧烈。原因:训练用了Normalize,验证忘了加,或者两边的 mean/std 写的不一样。番茄叶片图像如果自己统计了数据集均值,训练和验证必须用同一组。解决:把 transform 定义成函数统一管理,训练和验证共用同一个Normalize;如果要用数据集自身均值,先在训练集上统计一次,写死到配置里,验证和测试都复用。
4.5 显存不够就无脑降 batch size,忘了同步调学习率
现象:从 batch 32 降到 8 后,训练变得极不稳定,损失震荡不收敛。原因:学习率是按大 batch 设的,batch 变小后梯度噪声增大,等效学习率偏高。解决:batch 减半时学习率大致减半,或者用梯度累积模拟大 batch。比如batch_size=8配accumulate_steps=4,等效 batch 32,学习率保持 1e-3。另外开pin_memory=True和num_workers=4能缓解数据加载瓶颈,但别把 workers 开太大,IO 跟不上反而慢。
5. 把 3000 张番茄叶片模型推到边缘设备:量化、导出与现场验证
训完模型只是半程,番茄大棚里真正要用起来,得把模型塞进边缘设备或手机。3000 张数据训出的 EfficientNet-B0 参数量约 5M,FP32 权重 20MB 左右,量化到 INT8 能压到 5MB 上下,推理延迟在树莓派 4 上大约几百毫秒,手机端更快。这一章给一个从 PyTorch 到 ONNX 再到量化的具体路径,以及现场验证的笨办法。
5.1 导出 ONNX 并做动态量化
先导出 ONNX,再用 ONNX Runtime 做动态量化。动态量化对分类模型友好,不需要校准数据集,权重和激活都量化。
import torch import onnx import onnxruntime as ort from onnxruntime.quantization import quantize_dynamic, QuantType model.eval() dummy = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy, "tomato_effb0.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}, "logits": {0: "batch"}}, opset_version=12, ) quantize_dynamic( "tomato_effb0.onnx", "tomato_effb0_int8.onnx", weight_type=QuantType.QInt8, ) sess = ort.InferenceSession("tomato_effb0_int8.onnx") import numpy as np test_input = np.random.randn(1, 3, 224, 224).astype(np.float32) out = sess.run(None, {"input": test_input}) print("INT8 输出形状:", out[0].shape)逻辑说明:torch.onnx.export把模型转成 ONNX 图,dynamic_axes让 batch 维可变,方便部署时按需调整。参数说明:opset_version=12兼容性较好;quantize_dynamic的weight_type=QuantType.QInt8表示权重用 8 位整型;动态量化不需要额外校准数据,适合快速验证。导出后务必用同一张图对比 PyTorch 和 ONNX 的输出,最大绝对误差在 1e-3 量级算正常,超过 1e-2 要查算子兼容性。
5.2 现场验证用「三档置信度」而不是单阈值
实验室里 argmax 就完事,现场不能这么干。番茄叶片识别错一类的代价不同,我一般设三档:置信度高于 0.85 直接给结论,0.6 到 0.85 提示「疑似,建议复拍」,低于 0.6 直接返回「无法判断,请重新拍摄」。这样能大幅降低误报带来的信任损耗。
def predict_with_confidence(sess, img_tensor, classes, high=0.85, low=0.6): logits = sess.run(None, {"input": img_tensor})[0][0] exp = np.exp(logits - logits.max()) probs = exp / exp.sum() idx = int(probs.argmax()) conf = float(probs[idx]) if conf >= high: return classes[idx], conf, "确定" elif conf >= low: return classes[idx], conf, "疑似,建议复拍" else: return "unknown", conf, "无法判断"逻辑说明:手动做 softmax 避免依赖框架;三档阈值把「不确定」显式暴露出来。参数说明:high=0.85、low=0.6是经验值,病害漏检代价高就把low提到 0.7,宁可多让用户复拍。类别列表classes必须和训练时train_ds.classes顺序一致,建议存成 JSON 随模型一起发。
5.3 一个我常犯的错误:拿验证集当测试集反复调
最后说个习惯问题。我早期做植物分类时,习惯性用验证集调阈值、选模型、试增强,调了十几轮后验证集已经间接参与训练,指标不可信。后来强制自己:切分时留一份测试集,锁在单独目录,训练期间绝不碰,只在最终交付前跑一次。如果测试集结果比验证集掉超过 5 个点,说明调参过拟合了验证集,得回头简化模型或补数据。3000 张的番茄叶片数据集不算大,这个纪律尤其重要。希望帮到你。
本文还有配套的精品资源,点击获取