简介:这份资源面向计算机视觉初学者与图像分类实践者,提供一套已完成划分的森林树叶图像分类数据集,可直接用于模型训练与算法验证。数据共176个类别,按文件夹组织,data目录下分train与test两个子集,训练集14,755张、测试集3,598张,配合ImageFolder即可加载,无需额外预处理,也可作为YOLOv5分类任务的数据源。压缩包内共2000个文件,以1998张jpg图像为主,另含1个py可视化脚本与1个json字典文件,整体约133.63MB;json记录176种树叶的类别映射,py脚本随机抽取4张图片即可展示并保存到当前目录,无需修改即可运行。目前已有744人学习下载,适合希望快速上手图像分类、验证网络结构或开展迁移学习实验的读者,目录结构清晰,便于按类别检索与复现。
1. 176类森林树叶图像分类数据集:从拿到手到跑出第一个baseline
森林树叶识别这件事,听起来像是植物学家的活儿,但真正动手做的时候你会发现,它本质上是一个细粒度图像分类问题——176个类别,类间差异可能只是叶缘锯齿的深浅、叶脉走向的弧度,甚至同一棵树不同朝向的叶片在光照下呈现出的纹理都不一样。我拿到这个数据集的第一反应是:终于不用自己扛着相机去林子里拍叶子了。已做数据集划分意味着train/val/test已经分好,省去了最容易被做错的一步——按类别分层抽样。这个数据集适合谁?想做细粒度分类发论文的研究生、要落地林业巡检或植物识别的工程师、以及拿它当图像分类教学案例的高校老师。176类不算多,但足以让你把ResNet、EfficientNet、ViT这些主干网络跑一遍对比,也足够暴露过拟合、类别不均衡、预处理不一致这些经典坑。接下来我会按“先看清数据长什么样、再选模型、再训练调参、最后避坑”的顺序,把整个流程拆开讲。
2. 先摸清176类森林树叶数据集的底细:目录结构、类别分布与预处理
2.1 拿到数据集先别急着写DataLoader
很多人拿到一个图像分类数据集,第一件事就是torchvision.datasets.ImageFolder一把梭,然后直接开训。这种做法在类别均衡、图像质量统一的数据集上没问题,但森林树叶数据集往往有几个隐藏特征:类别样本数可能从几十到几百不等、图像尺寸不统一、部分类别存在明显的光照或背景差异。你如果不在训练前把这些摸清楚,后面loss不降或者val_acc震荡的时候,根本不知道是模型问题还是数据问题。
我一般会先跑一段统计脚本,把每个类别的样本数、图像尺寸分布、通道模式(RGB还是带alpha)全部打出来。下面这段代码可以直接抄:
import os from pathlib import Path from PIL import Image from collections import defaultdict DATA_ROOT = Path("./forest_leaves_176") # 替换成你的实际路径 SPLITS = ["train", "val", "test"] for split in SPLITS: split_dir = DATA_ROOT / split if not split_dir.exists(): print(f"[跳过] {split} 目录不存在") continue class_counts = defaultdict(int) size_stats = defaultdict(int) mode_stats = defaultdict(int) for cls_dir in sorted(split_dir.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 class_counts[cls_dir.name] += 1 try: with Image.open(img_path) as im: size_stats[im.size] += 1 mode_stats[im.mode] += 1 except Exception as e: print(f"[损坏] {img_path}: {e}") counts = list(class_counts.values()) print(f"\n===== {split} =====") print(f"类别数: {len(class_counts)}") print(f"总图片数: {sum(counts)}") print(f"每类最少: {min(counts)}, 最多: {max(counts)}, 均值: {sum(counts)/len(counts):.1f}") print(f"尺寸分布(top5): {sorted(size_stats.items(), key=lambda x: -x[1])[:5]}") print(f"通道模式: {dict(mode_stats)}")这段脚本做三件事:统计每个split的类别数和样本数、统计图像尺寸分布、统计通道模式。逻辑很直白,但输出信息量很大。如果train和val的类别数不一致,说明划分有问题;如果尺寸分布极其分散,说明预处理阶段必须统一resize;如果出现RGBA或L模式,说明要强制转RGB,否则后续归一化会报错。参数方面,DATA_ROOT指向数据集根目录,SPLITS按实际划分命名调整,常见的是train/val/test,也有train/valid/test的写法。
2.2 类别不均衡的判断与处理策略
176类森林树叶数据集,大概率存在长尾分布——常见树种样本多,稀有树种样本少。你跑完上面的统计脚本后,如果发现最多类别和最少类别的样本数差距超过5倍,就要考虑处理策略了。常见做法有三种:一是加权采样,用WeightedRandomSampler给少数类更高采样概率;二是损失加权,在CrossEntropyLoss里传weight参数;三是数据增强倾斜,对少数类做更强的增强。
我一般会先用加权采样的方式跑一版baseline,因为它不改变损失函数的形式,调起来直观。代码大概长这样:
import torch from torch.utils.data import WeightedRandomSampler from collections import Counter # 假设train_dataset是ImageFolder实例 targets = [s[1] for s in train_dataset.samples] class_counts = Counter(targets) num_classes = len(class_counts) # 每个类别的权重取倒数,样本越少权重越高 class_weights = {cls: 1.0 / count for cls, count in class_counts.items()} sample_weights = [class_weights[t] for t in targets] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True ) train_loader = torch.utils.data.DataLoader( train_dataset, batch_size=32, sampler=sampler, # 注意:用了sampler就不能再设shuffle=True num_workers=4, pin_memory=True )这里的关键参数是num_samples,一般设成和训练集总样本数一致,保证每个epoch看到的图片数量和正常训练差不多。replacement=True表示有放回采样,少数类会被反复抽到。注意用了sampler之后shuffle必须去掉,否则PyTorch会报错。这个方案的好处是实现简单,坏处是少数类被重复采样后可能过拟合,所以后续要配合早停或者更强的数据增强。
2.3 预处理流水线:训练和验证必须分开写
图像分类里最容易翻车的地方之一,就是训练和验证用了同一套预处理。训练阶段需要随机裁剪、随机翻转、颜色抖动这些增强手段,验证阶段只需要resize和归一化。如果你把增强也用到验证集上,val_acc会剧烈震荡,你根本判断不了模型到底有没有收敛。
我一般会写两个transform:
from torchvision import transforms # 训练增强:随机性越强,泛化越好,但训练时间也越长 train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.3), # 树叶方向不固定,垂直翻转也合理 transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.05), transforms.RandomRotation(degrees=30), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证/测试:只做确定性的resize和归一化 val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])RandomResizedCrop的scale参数我设成(0.6, 1.0),意思是随机裁取原图60%到100%的区域再resize到224。这个范围对树叶数据集比较合适,因为叶片在图像中的占比本身就有大有小。RandomVerticalFlip的概率我给了0.3而不是0.5,因为虽然树叶方向不固定,但垂直翻转后的叶脉纹理和自然状态还是有差异,给太高可能引入不自然的样本。归一化的mean和std用的是ImageNet的统计值,如果你从头训练而不是用预训练权重,可以改成自己数据集的统计值,但用预训练权重时必须保持一致。
3. 选主干网络与训练策略:从ResNet到ViT,176类怎么选
3.1 先用ResNet-50跑通再考虑换模型
我见过太多人一上来就上ViT或者Swin Transformer,结果训练半天不收敛,最后连baseline都没跑出来。176类森林树叶数据集,图像数量如果在一万到几万之间,ResNet-50配合预训练权重是最稳的起点。原因很简单:ResNet-50在ImageNet上预训练后,浅层已经学到了通用的边缘和纹理特征,树叶分类恰好高度依赖纹理和形状,迁移效果通常很好。
用timm库加载预训练模型最方便:
import timm import torch.nn as nn model = timm.create_model( "resnet50", pretrained=True, num_classes=176 # 直接替换分类头 ) # 如果显存不够,可以冻结前几层 # for name, param in model.named_parameters(): # if "layer1" in name or "layer2" in name: # param.requires_grad = False device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device)timm.create_model的num_classes参数会自动替换最后的全连接层,不需要你手动改model.fc。如果你显存紧张,可以冻结layer1和layer2,只训练后面的层,但这样做的前提是你的数据集和ImageNet差异不能太大。树叶数据集和ImageNet的自然图像分布还算接近,冻结浅层通常没问题。
3.2 学习率、优化器和调度器的参数怎么设
训练策略这块,我一般用AdamW配合余弦退火,学习率从3e-4开始。为什么不用SGD?因为SGD对学习率太敏感,176类细粒度分类的loss曲面比较复杂,AdamW的自适应学习率更容易在初期快速下降。但AdamW的权重衰减要设对,我一般用0.05,比默认的0.01大一些,因为细粒度分类容易过拟合。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR optimizer = optim.AdamW( model.parameters(), lr=3e-4, weight_decay=0.05 ) # 余弦退火:从3e-4降到1e-6,共跑50个epoch scheduler = CosineAnnealingLR( optimizer, T_max=50, eta_min=1e-6 ) criterion = nn.CrossEntropyLoss(label_smoothing=0.1)label_smoothing=0.1是我强烈建议加的。176类分类里,有些类别的边界本身就很模糊,硬标签会让模型过度自信,label smoothing相当于给目标分布加了一点噪声,能提升泛化。T_max设成总epoch数,eta_min是最小学习率,一般设成初始学习率的1/100到1/1000。
3.3 什么时候该换ViT或EfficientNet
ResNet-50跑通之后,如果你发现val_acc卡在某个值上不去了,可以考虑换模型。EfficientNet-B3在参数量和精度之间平衡得比较好,适合显存有限的场景。ViT-B/16需要更多的数据才能发挥优势,如果你的森林树叶数据集总图片数超过5万,可以试试ViT;如果只有一两万张,ViT很容易过拟合,不如用ResNet-50加更强的数据增强。
换模型的时候注意一点:不同模型的输入尺寸可能不同。ResNet-50默认224,EfficientNet-B3是300,ViT-B/16是224。你如果换了模型但没改transform里的resize尺寸,精度会掉得莫名其妙。我一般会把输入尺寸和模型名绑定成一个配置字典,避免这种低级错误。
4. 训练循环与验证:把每个epoch的指标都记下来
4.1 训练循环里必须记录的东西
训练循环不是只跑loss.backward()就完事了。我一般会在每个epoch结束后记录:训练loss、训练acc、验证loss、验证acc、当前学习率、以及每个类别的验证acc。最后一项特别重要——176类里如果有几个类别acc一直是0,说明模型根本没学到这些类的特征,可能是样本太少或者标注有问题。
import torch from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 0 for imgs, labels in tqdm(loader, desc="训练"): 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) preds = outputs.argmax(dim=1) correct += (preds == 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 class_correct = defaultdict(int) class_total = defaultdict(int) for imgs, labels in tqdm(loader, desc="验证"): imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) loss = criterion(outputs, labels) total_loss += loss.item() * imgs.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += imgs.size(0) for p, l in zip(preds.cpu().tolist(), labels.cpu().tolist()): class_total[l] += 1 if p == l: class_correct[l] += 1 per_class_acc = { cls: class_correct[cls] / class_total[cls] for cls in class_total } return total_loss / total, correct / total, per_class_accevaluate函数里用@torch.no_grad()装饰器关闭梯度计算,节省显存。per_class_acc字典记录了每个类别的准确率,训练结束后可以排序看看哪些类别最差。如果某些类别acc长期低于0.3,要么是样本太少,要么是这些类之间的视觉差异确实太小,需要考虑用更强的特征提取器或者引入注意力机制。
4.2 早停与模型保存策略
我一般会保存验证acc最高的那个epoch的权重,而不是最后一个epoch的。因为176类分类训练到后期,验证acc可能会波动,最后一个epoch不一定是最好的。早停的patience设10到15个epoch,如果连续这么多个epoch验证acc没有提升就停。
best_acc = 0.0 patience = 12 wait = 0 for epoch in range(1, 51): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc, per_class_acc = evaluate(model, val_loader, criterion, device) scheduler.step() print(f"Epoch {epoch}: train_loss={train_loss:.4f} train_acc={train_acc:.4f} " f"val_loss={val_loss:.4f} val_acc={val_acc:.4f} lr={scheduler.get_last_lr()[0]:.6f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") wait = 0 print(f" -> 保存最优模型,val_acc={best_acc:.4f}") else: wait += 1 if wait >= patience: print(f"早停触发,最优val_acc={best_acc:.4f}") break这段代码里scheduler.step()放在验证之后调用,因为CosineAnnealingLR是按epoch更新的。torch.save只保存state_dict而不是整个模型,这样加载的时候更灵活,不会因为代码结构变化而失败。
5. 避坑与排查:176类森林树叶分类里最容易翻车的5件事
5.1 现象:训练loss正常下降,但验证acc一直在0.5%左右
原因:验证集的类别索引和训练集对不上。ImageFolder是按文件夹名称的字母顺序分配类别索引的,如果train和val的文件夹名称不完全一致,或者某个类别在val里缺失,索引就会错位。
解决:用同一份class_to_idx映射。最稳妥的做法是把train和val合并成一个ImageFolder再手动划分,或者显式检查两个数据集的class_to_idx是否一致:
assert train_dataset.class_to_idx == val_dataset.class_to_idx, "类别映射不一致!"5.2 现象:训练到第10个epoch左右,验证loss突然飙升
原因:学习率太大,模型在局部最优附近震荡后跳出去了。AdamW的初始学习率3e-4对某些数据集可能偏大。
解决:把初始学习率降到1e-4,或者加warmup。warmup的做法是前5个epoch线性增加学习率,之后再按余弦退火下降。timm库自带CosineLRScheduler支持warmup,可以直接用。
5.3 现象:某些类别的准确率始终为0
原因:这些类别的样本数太少,加权采样后虽然被抽到的次数多了,但每次都是同样的几张图,模型记住了但没泛化。
解决:对这些类别做离线增强,把样本数扩增到至少每类100张。增强手段包括旋转、裁剪、颜色抖动、加噪声。如果扩增后还是不行,考虑把这些类别合并到相近类别,或者直接标记为“难类”单独分析。
5.4 现象:GPU显存够但训练速度很慢
原因:num_workers设成了0,数据加载在主进程里串行执行,GPU一直在等数据。
解决:把num_workers设成4或8,同时开pin_memory=True。如果还是慢,检查是不是用了太复杂的增强(比如RandAugment),可以先用简单的翻转裁剪跑通再逐步加增强。
5.5 现象:测试集准确率比验证集低很多
原因:验证集被“偷看”了。如果你根据验证集的表现反复调参、换模型、改增强,验证集实际上变成了训练集的一部分,测试集才是真正的泛化评估。
解决:在最终评估之前,不要碰测试集。调参全部在验证集上做,最后只跑一次测试集。如果测试集和验证集差距超过5个百分点,说明验证集太小或者分布和测试集不一致,需要重新划分。
6. 进阶技巧:用混淆矩阵和Grad-CAM定位176类里的“重灾区”
跑完baseline之后,你手里有了一个val_acc大概在70%到85%之间的模型(具体取决于数据集难度和训练策略)。这时候不要急着换更大的模型,先用混淆矩阵看看哪些类别之间互相混淆。176类的混淆矩阵很大,直接画出来看不清,我一般会先找出混淆最严重的20对类别,再针对性地分析。
import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix import seaborn as sns # 收集所有验证集的预测结果 all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for imgs, labels in val_loader: imgs = imgs.to(device) outputs = model(imgs) preds = outputs.argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) # 找出混淆最严重的类别对 confusion_pairs = [] for i in range(len(cm)): for j in range(len(cm)): if i != j and cm[i][j] > 0: confusion_pairs.append((cm[i][j], i, j)) confusion_pairs.sort(reverse=True) print("混淆最严重的前10对类别:") for count, i, j in confusion_pairs[:10]: print(f" 类别{i} -> 类别{j}: {count}次")这段代码输出的是“真实类别i被预测成类别j”的次数。拿到这些类别对之后,你可以把对应的图片调出来肉眼看看,通常会发现两种可能:要么这两个类别的叶片在视觉上确实极其相似,要么标注本身就有问题。如果是前者,可以考虑用更细粒度的特征(比如叶脉纹理)或者引入度量学习;如果是后者,需要清洗标注。
另一个我常用的工具是Grad-CAM,它能告诉你模型在分类时“看”了图片的哪个区域。如果模型关注的是背景而不是叶片本身,说明数据集的背景泄漏了类别信息——比如某些类别的图片都是在同一种背景下拍的。这种情况在森林树叶数据集里不常见,但一旦出现,模型在真实场景下的泛化能力会很差。
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 取ResNet-50的最后一个卷积层 target_layers = [model.layer4[-1]] cam = GradCAM(model=model, target_layers=target_layers) # 取一张验证集图片 input_tensor = val_dataset[0][0].unsqueeze(0).to(device) grayscale_cam = cam(input_tensor=input_tensor, targets=None) visualization = show_cam_on_image( input_tensor.squeeze().cpu().permute(1, 2, 0).numpy(), grayscale_cam[0], use_rgb=True )target_layers指定你要可视化的层,ResNet-50一般用layer4[-1],也就是最后一个残差块的最后一层。targets=None表示用模型预测的类别作为目标,你也可以传入真实标签看看模型在正确类别上的关注区域。
最后说一个我自己的习惯:每次跑完一个数据集,我都会把最优模型的per_class_acc排序,把最差的10个类别截图保存到一个文件夹里。过一段时间回头看,你会发现这些“难类”往往有共同的视觉特征——要么是叶片形状特别接近,要么是拍摄角度单一。针对这些类做定向增强或者收集更多数据,比盲目换模型有效得多。希望帮到你。
本文还有配套的精品资源,点击获取