简介:这份资源是面向计算机视觉初学者与图像分类实践者的森林树叶图像分类数据集,共涵盖176个树叶类别,已完成训练集与测试集划分,可直接用于深度学习模型训练与算法验证。压缩包内共2000个文件,以1998张jpg图像为主,另附1个json字典文件记录176种树木树叶的英文标签映射,以及1个可视化py脚本,整体大小约133.63MB。目录下分为train与test两个子目录,训练集14755张、测试集3598张,按类别文件夹存放,使用ImageFolder即可直接加载,无需额外预处理,也可作为YOLOv5分类数据集使用。可视化脚本随机传入4张图片即可展示并保存到当前目录,无需修改即可运行。目前已有744人学习下载,适合希望快速上手图像分类任务、验证模型效果或开展迁移学习实验的读者参考使用。
1. 176 类森林树叶图像分类数据集:从拿到手到跑通第一个 baseline
上周帮一个做生态监测的朋友看模型,他拿 30 万张手机拍的树叶照片训 ResNet,验证集准确率卡在 61% 死活上不去。我让他把类别分布导出来一看——176 个类里最多的类有 4000 多张,最少的只有 12 张,而且训练集和验证集是随机切的,同一片叶子正反面被切进了两边。这不是模型的问题,是数据集划分的问题。今天要拆的这份森林树叶图像分类数据集,恰好把这件事做对了:176 个细粒度类别,已经完成训练/验证/测试划分,目录结构直接对齐ImageFolder和tf.keras.utils.image_dataset_from_directory的读取约定。它解决的不是"有没有数据"的问题,而是"数据能不能直接进训练循环、划分是否可信"的问题。适合三类人:想练细粒度分类但懒得自己爬数据的学生、需要快速验证 backbone 迁移效果的算法工程师、以及做植物识别产品原型的小团队。下面按"这份资源长什么样 → 怎么接进训练管线 → 划分和增强上容易翻车的地方 → 怎么把 176 类压到能跑动的程度"这条线走一遍。
2. 数据集结构与读取方式:先搞清楚目录约定再写 DataLoader
拿到一个分类数据集,第一件事不是写模型,是tree一下看目录。这份数据的组织方式决定了你后面用ImageFolder还是自己写Dataset,也决定了类别索引到标签名的映射怎么存。
2.1 目录层级与 ImageFolder 的隐式约定
常见做法是root/类别名/图片文件这种两层结构,torchvision.datasets.ImageFolder会按文件夹名的字典序自动生成class_to_idx。这里有个血泪经验:字典序不是自然序,class_10会排在class_2前面。如果你后面要把预测结果映射回中文树名,必须把dataset.class_to_idx存下来,别自己另写一套映射。
import os from torchvision import datasets, transforms DATA_ROOT = "./forest_leaves_176" # 先确认划分目录是否存在 for split in ["train", "val", "test"]: p = os.path.join(DATA_ROOT, split) print(split, "exists:", os.path.isdir(p)) # ImageFolder 读取,类别索引按文件夹名字典序生成 train_ds = datasets.ImageFolder( os.path.join(DATA_ROOT, "train"), transform=transforms.ToTensor() ) print("类别数:", len(train_ds.classes)) print("前 5 个类别:", train_ds.classes[:5]) print("样本数:", len(train_ds)) # 关键:把 class_to_idx 落盘,推理时对齐用 import json with open("class_to_idx.json", "w", encoding="utf-8") as f: json.dump(train_ds.class_to_idx, f, ensure_ascii=False, indent=2)这段代码做了三件事:验证三个划分目录都在、用ImageFolder建立索引、把class_to_idx序列化。参数上唯一要注意的是transform——这里先用ToTensor()占位,真正的增强管线在 3.2 节展开。len(train_ds.classes)应该输出 176,如果不是,说明目录里混进了非类别文件夹(比如.ipynb_checkpoints或__MACOSX),这是解压后最常见的污染源。
2.2 用 image_dataset_from_directory 走 TensorFlow 路线
如果团队用 Keras,读取逻辑一样,但接口换成了image_dataset_from_directory。它默认按文件名排序,同样存在字典序问题,而且label_mode的选择会直接影响损失函数。
import tensorflow as tf IMG_SIZE = (224, 224) BATCH = 32 train_ds = tf.keras.utils.image_dataset_from_directory( "./forest_leaves_176/train", image_size=IMG_SIZE, batch_size=BATCH, label_mode="int", # 配合 sparse_categorical_crossentropy shuffle=True, seed=42 ) val_ds = tf.keras.utils.image_dataset_from_directory( "./forest_leaves_176/val", image_size=IMG_SIZE, batch_size=BATCH, label_mode="int", shuffle=False # 验证集不要打乱,方便对齐混淆矩阵 ) print("类别名:", train_ds.class_names[:5])label_mode="int"输出整数标签,配sparse_categorical_crossentropy;如果改成"categorical"就是 one-hot,配categorical_crossentropy。这两个配错不会报错,但 loss 会一直不降,属于典型的"玄学不收敛"来源。shuffle=False用在验证集上是习惯,目的是让预测顺序和文件顺序一致,画混淆矩阵时不用再查索引。
2.3 先做一次类别分布体检
176 类几乎不可能完全均衡。在写训练脚本之前,花两分钟统计每个类的样本数,能省掉后面几小时的调参。
from collections import Counter import matplotlib.pyplot as plt targets = [y for _, y in train_ds.samples] # torchvision 的 .samples 是 (path, label) cnt = Counter(targets) counts = [cnt[i] for i in range(len(train_ds.classes))] print("最多:", max(counts), "最少:", min(counts)) print("均值: %.1f" % (sum(counts)/len(counts))) plt.hist(counts, bins=30) plt.title("train per-class sample count") plt.xlabel("samples"); plt.ylabel("num classes") plt.savefig("class_dist.png", dpi=120)如果max/min超过 20 倍,后面 4.1 节的采样策略就必须上,否则模型会明显偏向头部类。这一步不产出任何模型,但它是判断"要不要做重采样"的唯一依据。
3. 训练管线搭建:从增强策略到第一个能跑的 baseline
数据读进来只是开始,真正决定 baseline 能不能在 176 类上跑到可用水平的,是输入分辨率、增强强度和 backbone 选择这三件事的配合。
3.1 输入分辨率与 backbone 的匹配
树叶分类是细粒度任务,叶脉纹理、边缘锯齿这些判别特征在高分辨率下才明显。但 176 类意味着显存压力不小。常见做法是训练用 224×224,配合RandomResizedCrop让模型见到不同尺度;如果显存够(比如 24G 卡),可以上 320 或 384,细粒度任务上通常有 2~4 个点的提升。
| backbone | 输入尺寸 | 参数量 | 176 类预期 top-1 | 显存(bs=32) |
|---|---|---|---|---|
| ResNet-50 | 224 | 25M | 78~83% | ~6G |
| EfficientNet-B0 | 224 | 5.3M | 76~81% | ~4G |
| ConvNeXt-Tiny | 224 | 28M | 82~86% | ~8G |
| ViT-B/16 (预训练) | 224 | 86M | 84~88% | ~14G |
表里的预期区间是基于同类细粒度数据集的常见经验值,不是这份数据的实测承诺——具体数字取决于划分质量和类间相似度。选型逻辑很简单:显存紧就 EfficientNet-B0,追求精度且能等就 ConvNeXt-Tiny,想试 transformer 就 ViT-B/16 但必须用预训练权重,从零训基本没戏。
3.2 增强管线:哪些增强对树叶有效,哪些是负作用
树叶图像的类内差异主要来自拍摄角度、光照和背景,类间差异来自形状和纹理。所以几何增强要克制,颜色增强可以放开。
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), # 尺度抖动,别低于 0.5 transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), # 小角度,树叶有方向性 transforms.ColorJitter(0.3, 0.3, 0.3, 0.05), # 亮度/对比度/饱和度放开 transforms.ToTensor(), transforms.Normalize([0.485,0.456,0.406], [0.229,0.224,0.225]) ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485,0.456,0.406], [0.229,0.224,0.225]) ])RandomRotation(15)而不是 90,是因为叶子有明确的生长方向,大角度旋转会制造不存在的形态。ColorJitter的第四个参数是 hue,只给 0.05,色相大幅偏移会让不同树种的颜色特征混淆。RandomResizedCrop的scale下限设 0.6,再低会把叶脉切没,反而伤害细粒度特征。验证集只用 resize + centercrop,不做任何随机增强,这是评估可信度的底线。
3.3 一个能直接跑的 baseline 训练脚本
把前面的读取和增强串起来,配一个带 warmup 的余弦退火,就是一份可复现的起点。
import torch, torch.nn as nn, torch.optim as optim from torch.utils.data import DataLoader from torchvision import datasets, models, transforms from torch.optim.lr_scheduler import CosineAnnealingLR device = "cuda" if torch.cuda.is_available() else "cpu" train_ds = datasets.ImageFolder("./forest_leaves_176/train", transform=train_tf) val_ds = datasets.ImageFolder("./forest_leaves_176/val", transform=val_tf) train_ld = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=8, pin_memory=True) val_ld = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=8, pin_memory=True) model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) model.fc = nn.Linear(model.fc.in_features, 176) # 换成 176 类 model = model.to(device) criterion = nn.CrossEntropyLoss(label_smoothing=0.1) # 细粒度任务加 label smoothing optimizer = optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=30) for epoch in range(30): model.train() for x, y in train_ld: x, y = x.to(device), y.to(device) optimizer.zero_grad() loss = criterion(model(x), y) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval(); correct = total = 0 with torch.no_grad(): for x, y in val_ld: x, y = x.to(device), y.to(device) pred = model(x).argmax(1) correct += (pred == y).sum().item(); total += y.size(0) print(f"epoch {epoch}: val_acc={correct/total:.4f}")几个参数值得说:label_smoothing=0.1在类别多、标注可能有噪声时能稳住训练;AdamW的lr=3e-4是微调预训练模型的常用起点,从零训要降到 1e-3 以下;num_workers=8按机器核数调,设太大会在 epoch 切换时卡顿。跑完 30 epoch,ResNet-50 在这类数据上通常能到 80% 上下,如果只有 60% 多,先回去看第 4 章的划分和采样问题。
4. 避坑与排查:176 类细粒度分类最容易翻车的五个地方
这一章是我自己踩过和帮人排查过的真实问题,每条按"现象 → 原因 → 解决"写,遇到对应症状直接对号入座。
4.1 验证集准确率远高于测试集
现象:val_acc 85%,test_acc 只有 70%,差距超过 10 个点。原因:验证集和测试集分布不一致,或者验证集被无意中参与过模型选择(比如反复用 val 调超参),导致 val 虚高。另一个常见原因是划分时按图片随机切,但同一片叶子的多张照片被切到了不同集合。解决:先确认这份数据的划分是不是按"个体/拍摄批次"切的,而不是纯随机。如果是随机切,测试集的可信度要打问号。自己复现时,用GroupShuffleSplit按拍摄对象分组切分,别用train_test_split。
4.2 训练 loss 正常下降但准确率不动
现象:loss 从 5.2 降到 1.8,但 train_acc 一直在 1% 左右。原因:标签和输出维度对不上,或者label_mode与损失函数配错。比如 Keras 里用了label_mode="int"却配categorical_crossentropy,或者 PyTorch 里model.fc没改成 176 类。解决:打印一个 batch 的y.max()和model(x).shape[-1],前者应该小于 176,后者应该等于 176。这两个数一对,问题立刻现形。
4.3 显存溢出发生在第二个 epoch
现象:第一个 epoch 跑得好好的,第二个 epoch 开始报 CUDA out of memory。原因:验证阶段没加torch.no_grad(),计算图一直累积;或者num_workers太大,每个 worker 都缓存了一份数据。解决:验证循环强制套with torch.no_grad():,num_workers从 4 开始试,别一上来就 16。另外pin_memory=True只在 GPU 训练时有意义,CPU 训练要关掉。
4.4 类别极度不均衡导致尾部类全错
现象:整体准确率 80%,但混淆矩阵里后 50 个类几乎全预测成头部类。原因:176 类里长尾分布严重,交叉熵被头部类主导。解决:三种做法按代价排序——给CrossEntropyLoss传weight(按类频率倒数)、用WeightedRandomSampler过采样尾部类、或者对尾部类做针对性增强。我一般先上 sampler,因为它不改损失函数,调起来直观。
4.5 推理时类别索引对不上
现象:训练时准确率很高,部署后预测结果全是乱的。原因:推理脚本重新用ImageFolder建了索引,但推理目录里只有部分类别,class_to_idx和训练时不一致。解决:训练时把class_to_idx.json存下来,推理时直接加载这个文件,不要重新扫描目录。这是 2.1 节强调落盘的原因。
5. 把 176 类压到能跑动:分层采样与混淆矩阵驱动的迭代技巧
176 类在单卡上跑完整训练不是不行,但迭代一次要等太久,调参效率低。我的习惯是先做一个"可快速迭代的子集",用混淆矩阵找出最难分的类对,再决定要不要上更重的模型。具体做法是:先用全部数据训一个轻量 backbone(EfficientNet-B0,224 输入)拿到 baseline 混淆矩阵,然后按混淆矩阵把类别聚成若干"超类",在超类层面先调通增强和采样策略,最后再回到 176 类精调。
import numpy as np from sklearn.metrics import confusion_matrix # 假设已经收集了验证集的 preds 和 labels cm = confusion_matrix(labels, preds, labels=list(range(176))) np.fill_diagonal(cm, 0) # 抹掉对角,只看错分 top_pairs = np.dstack(np.unravel_index(np.argsort(cm.ravel())[::-1][:20], cm.shape))[0] for a, b in top_pairs: print(f"{train_ds.classes[a]} <-> {train_ds.classes[b]}: {cm[a,b]}")这段代码输出错分最多的 20 个类对。拿到之后,常见做法是只对这几对做针对性处理:要么补数据,要么在损失里给这几对加 margin。别一上来就换大模型,细粒度任务里数据侧的收益往往比模型侧大。
另一个实用技巧是分层学习率:backbone 用 1e-5,分类头用 1e-3。176 类的分类头参数量不小,从头学需要更大学习率,而预训练 backbone 只需要微调。
head_params = list(model.fc.parameters()) backbone_params = [p for n, p in model.named_parameters() if not n.startswith("fc.")] optimizer = optim.AdamW([ {"params": backbone_params, "lr": 1e-5}, {"params": head_params, "lr": 1e-3}, ], weight_decay=1e-4)这样配完,通常比统一学习率快 5~8 个 epoch 收敛。验证方法很简单:跑 5 个 epoch,看 val_acc 有没有在前 3 个 epoch 就超过统一学习率的同期水平。从那以后我每次接一个新的多类数据集,都强制先跑一遍类别分布统计和混淆矩阵,再动模型——这两步花不了十分钟,但能省掉后面反复试错的几个小时。希望帮到你。
本文还有配套的精品资源,点击获取