简介:面向图像分类入门与实战的花卉识别资料包,适合深度学习学习者与算法调试人员使用。内含16种花卉共32000张224×224彩色图片,每类约2000张,覆盖千屈菜、射干、旋覆花、曼陀罗、桔梗、秋英、粉黛乱子草等常见花卉;配套基于TensorFlow编写的训练源码,集成了23种主流图片分类模型,可直接选用ResNet、Inception等结构开展训练与对比实验。资源共110个文件,包含33个Python源码、对应pyc编译文件、TXT说明文档、bat启动脚本以及数据封装模块,压缩包总大小684.45MB。源码将卷积层、残差结构、图像预处理、模型搭建等模块拆分配置,便于二次开发;图片统一尺寸且覆盖不同生长形态与背景,可直接用于分类训练。此批数据集为第一批,后续批次花卉种类不同,如需更多类别可另下其他批次。目前已有1499人学习下载,适合需要完整花卉数据集与多模型训练框架的读者,可用于课程设计、算法预研或模型效果验证,作为图像分类项目的基线参考。
1. 花卉识别数据集:别把精度当回事,先看你的训练集和测试集分没分对
做花卉分类踩过最大的坑,不是模型选错,而是数据集本身“看起来很美”。这个花卉识别数据集(02)包含数千张花朵图片和配套训练源码,类别覆盖常见园林花卉,适合做花朵分类、识别算法的训练与验证。我一开始直接拿默认划分跑 ResNet50,训练集准确率冲到 96%,测试集却只有 71%,翻车翻得莫名其妙——后来发现是数据集里同一株花的照片被硬生生拆进了 train 和 val,模型等于提前见了答案。这篇就围绕这个数据集,讲清楚怎么组织数据、怎么选模型、怎么把训练跑稳、怎么避坑,以及最后怎么把模型导出成能落地的格式。新手可以按步骤走,熟手重点看参数边界和踩坑记录。
2. 把图片集变成训练集:目录结构、标签映射与 PyTorch 加载
2.1 先搞清楚数据集目录里装的是什么
拿到这个花卉图片集,第一步不是急着训练,而是把目录结构摸清楚。常见做法是dataset/下按类别分文件夹,或者统一放在images/里配一个labels.csv。这套花卉数据集的常规组织方式是前者——每个类别一个文件夹,文件夹名即类别名。这样做的好处是 torchvision 的ImageFolder可以直接吃,不需要手写 label 映射。
我一般会先用一段脚本把实际情况打出来,确认类别数、每类图片数、图片尺寸范围和格式。这一步能提前发现三个问题:类别文件夹命名是否规范、图片是否损坏、不同类别数量是否悬殊。批量下载或采集的数据集最容易翻车的地方就是图片格式五花八门,.jpg、.png、.bmp、.jpeg混着来,有些甚至是.webp后缀但实际编码是 JPEG。
import os from collections import Counter from PIL import Image dataset_root = "./flower_dataset" extensions = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} class_counter = Counter() bad_images = [] size_counter = Counter() for class_name in sorted(os.listdir(dataset_root)): class_path = os.path.join(dataset_root, class_name) if not os.path.isdir(class_path): continue for fname in os.listdir(class_path): ext = os.path.splitext(fname)[1].lower() if ext not in extensions: continue fpath = os.path.join(class_path, fname) class_counter[class_name] += 1 try: with Image.open(fpath) as img: size_counter[img.size] += 1 except Exception: bad_images.append(fpath) print("类别统计:", class_counter) print("图片尺寸分布:", size_counter.most_common(5)) print(f"损坏图片数量: {len(bad_images)}") for p in bad_images[:10]: print("BAD:", p)这段脚本做的三件事分别对应三个风险点:class_counter检查每类样本量,防止某个类别只有 20 张、另一个类别有 800 张这种极端不均衡;size_counter统计尺寸分布,如果图片既有 224x224 又有 600x800,后面做 Resize 和 CenterCrop 时就要统一策略;bad_images提前揪出打不开的文件,训练时遇到损坏文件报错中断,浪费的时间远多于提前扫描这几分钟。
2.2 数据集划分:必须按“个体”切,不能按“文件”切
这里说的坑很隐蔽,但直接影响模型真实水平。花卉数据集很多来自同一株植物连续拍摄的多张照片,或者同一个公园同一片花丛的多角度照片。如果你直接random_split(0.8, 0.2),那同一株花的多张照片大概率同时落在训练集和验证集里。模型在验证集上的表现会虚高,因为它“见过”这些花的其他角度照片,本质上是记住了这株花的纹理特征,而不是学到了“玫瑰”和“月季”的判别边界。
正确的做法是先按文件名的前缀或元数据把同一来源的图片聚成一组,再按组划分。但这个数据集不一定提供了明确的个体 ID,所以退而求其次的做法是:先做一次聚类或按目录子文件夹归组,再划分。
import os import random import shutil from collections import defaultdict dataset_root = "./flower_dataset" train_dir = "./split/train" val_dir = "./split/val" # 先按文件名前缀归组(假设同一株花的图片以相同前缀命名) groups = defaultdict(list) for class_name in os.listdir(dataset_root): class_path = os.path.join(dataset_root, class_name) if not os.path.isdir(class_path): continue for fname in os.listdir(class_path): prefix = fname.split("_")[0] # 例如 IMG_1234_01.jpg -> IMG_1234 groups[f"{class_name}/{prefix}"].append((class_name, fname)) group_list = list(groups.keys()) random.seed(42) random.shuffle(group_list) split_idx = int(len(group_list) * 0.8) train_groups = set(group_list[:split_idx]) val_groups = set(group_list[split_idx:]) for class_name in os.listdir(dataset_root): os.makedirs(os.path.join(train_dir, class_name), exist_ok=True) os.makedirs(os.path.join(val_dir, class_name), exist_ok=True) for class_name, fname_list in groups.items(): pass # 注意上面是归组,这里重新遍历逻辑略 # 实际上更稳妥的做法是按组名遍历 for group_name, files in groups.items(): class_name, _ = group_name.split("/", 1) dest_root = train_dir if group_name in train_groups else val_dir for cls, fname in files: src = os.path.join(dataset_root, cls, fname) dst = os.path.join(dest_root, cls, fname) shutil.copy(src, dst)这里有个关键理解:划分的单位是“组”而不是“张”。如果数据集图片命名没有前缀规律,那至少要做到按时间戳或拍摄批次划分。实在没有这类信息,也要在训练后用按类别抽样的方式单独检查验证集里有没有和训练集高度相似的图。用random.seed(42)固定随机种子,保证每次复现的划分一致,这是训练可复现性的底线。
2.3 用 ImageFolder 和 DataLoader 把训练管道跑通
划分完成后的标准动作是用torchvision.datasets.ImageFolder加载。这个类要求目录层级是root/class/image.jpg,前面划分脚本的输出正好满足这个结构。ImageFolder会自动按文件夹名的字母序生成类别索引,这意味着class_to_idx的映射是确定的,但也提示你要在训练前把这个映射打印出来存档,后面推理时要保证同样的映射顺序。
from torch.utils.data import DataLoader from torchvision import datasets, transforms train_transform = transforms.Compose([ transforms.Resize(256), transforms.RandomResizedCrop(224), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = 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]) ]) train_dataset = datasets.ImageFolder("./split/train", transform=train_transform) val_dataset = datasets.ImageFolder("./split/val", transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) print("类别映射:", train_dataset.class_to_idx) print("训练集样本数:", len(train_dataset)) print("验证集样本数:", len(val_dataset))训练集和验证集的 transform 策略是两套完全不同的逻辑:训练集要“折腾”数据,用RandomResizedCrop模拟不同距离、不同构图的拍摄效果,RandomRotation(15)模拟花朵朝向偏移,ColorJitter模拟不同光照条件;验证集要“稳定”数据,只做Resize + CenterCrop,保证每张图都以固定尺度和位置进入模型。这里Normalize用的均值和标准差是 ImageNet 的统计值,不是在当前数据集上重新算的。如果换用自己算的均值方差,迁移学习的预训练权重效果会打折,因为预训练模型已经习惯了输入数据的分布范围。
3. 模型选型与训练配置:ResNet50 还是 MobileNetV3
3.1 预训练权重是底线,不是可选项
花卉识别这种细粒度分类任务,类别之间差异小——“桃花”和“樱花”都是粉色小花,花瓣形状、花蕊颜色才是判别点。从头训练一个 CNN 在这个规模的数据集上很难收敛到理想效果,因为可学习的判别特征太多,而样本量不足以支撑模型学到鲁棒的边界。使用 ImageNet 预训练权重做迁移学习,相当于让模型从“认识 1000 类物体”的底座出发,只需要微调来适应花卉的细粒度差异。这个数据集的源码包通常默认支持 ResNet 系列,部分版本也带了 MobileNet 的配置入口。
选 ResNet50 的理由是它在 ImageNet 分类任务上的特征表达足够强,模型规模适中,在单卡 GPU 上训练 30~50 个 epoch 的时间可控。MobileNetV3 的优势是参数量小,适合之后部署到移动端或边缘设备。我的做法是先用 ResNet50 跑通流程、验证数据没问题,再切到 MobileNetV3 做正式训练。
import torch import torch.nn as nn import torch.optim as optim from torchvision import models num_classes = len(train_dataset.classes) model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) # 冻结除最后一层外的所有参数,先只训练分类头 for name, param in model.named_parameters(): if "fc" not in name: param.requires_grad = False device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.fc.parameters(), lr=1e-3)先冻结 backbone 只训练 fc 层,这一步是让新初始化的分类头先在当前数据集分布上站稳——因为新头的权重是随机初始化的,如果一开始就用大梯度去更新整个网络,预训练特征很快就被破坏,模型会退化成一个从头训练的 CNN。跑 5 个 epoch 左右、验证集准确率不再明显上升后,再解冻全部参数、把学习率调低一个数量级做全量微调。
3.2 学习率、batch size、epoch 的搭配逻辑
花卉分类训练最容易出现的问题不是不收敛,而是收敛得太快然后过拟合。预训练权重让模型起点很高,第一轮 epoch 就可能到 80% 以上,这时候容易误判为“模型很好”,继续训练则会发现验证集准确率停滞甚至下降。
学习率策略我一般用OneCycleLR。它的核心逻辑是先热身后降温——前 20% 的步数把学习率从小到大线性升到峰值,后面逐步降到接近 0。这种方式比固定学习率更能逃离局部极小点,在微调场景下尤其有效。峰值学习率可以参考lr_max = 1e-4这个量级,如果 backbone 被冻结则可以用 1e-3,全量微调时必须降到 1e-4 以下。
from torch.optim.lr_scheduler import OneCycleLR total_steps = len(train_loader) * 20 # 假设训练 20 个 epoch # 解冻 backbone,切换优化器 for param in model.parameters(): param.requires_grad = True optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = OneCycleLR( optimizer, max_lr=1e-4, total_steps=total_steps, pct_start=0.2, anneal_strategy="cos" ) for epoch in range(20): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() running_loss += loss.item() * images.size(0) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) train_acc = correct / total val_acc = evaluate(model, val_loader, device) print(f"Epoch {epoch+1}/20 | Loss: {running_loss/total:.4f} " f"| Train Acc: {train_acc:.4f} | Val Acc: {val_acc:.4f}")pct_start=0.2的含义是整个训练过程前 20% 的步数用于升温,之后 80% 用于余弦退火。batch size = 32 时梯度噪声适中,类别不均衡时还可以调CrossEntropyLoss(weight=class_weights),但会增加调参复杂度。我建议先不平权跑一轮,看混淆矩阵里哪些类别互相混淆,再决定是否加权。
4. 训练源码跑通:训练脚本、断点续训与评估指标
4.1 完整训练入口的参数怎么传
这个数据集的训练源码通常提供一个train.py入口,核心参数包括--data、--model、--epochs、--batch-size、--lr、--pretrained。第一次跑通不建议用默认参数直接开跑,先把--epochs 3 --batch-size 16这种小配置跑一轮,确认数据加载、前向传播、反向传播、验证评估全链路无异常,再上完整配置。
python train.py \ --data ./split \ --model resnet50 \ --epochs 30 \ --batch-size 32 \ --lr 1e-4 \ --pretrained \ --num-classes 10 \ --output-dir ./runs/exp1 \ --device cuda:0--data指向的是包含 train 和 val 两个子目录的根目录,不是指向 train 本身。--num-classes建议显式指定,虽然代码里可能能从目录结构自动推断,但显式传参可以避免“自动推断拿到的类别数和模型 fc 层输出不匹配”这类低级错误。--output-dir设置独立的输出目录,每个实验一组参数一个目录,后面对比实验时才不会互相覆盖。
4.2 训练日志和 checkpoint 保存是后悔药
训练到第 20 个 epoch 时发现验证集准确率开始下降,这时候最需要的是能回退到第 15 个 epoch 的 checkpoint。很多训练脚本只保存最后一个 epoch 的模型,这在迁移学习场景下不够。正确做法是保存两类文件:一是“当前最优”模型——用验证集准确率做判据,一旦刷新最高值就覆盖保存;二是周期性 checkpoint——每 5 个 epoch 存一份完整状态,包括模型权重、优化器状态、学习率调度器状态、当前 epoch 数。
best_val_acc = 0.0 save_path = "./runs/exp1" for epoch in range(30): train_loss, train_acc = train_one_epoch(...) val_acc = evaluate(...) if val_acc > best_val_acc: best_val_acc = val_acc torch.save({ "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "scheduler_state_dict": scheduler.state_dict(), "val_acc": val_acc, }, f"{save_path}/best_checkpoint.pth") print(f"Best model saved at epoch {epoch+1}, val_acc={val_acc:.4f}") if (epoch + 1) % 5 == 0: torch.save(model.state_dict(), f"{save_path}/epoch_{epoch+1}.pth")best_checkpoint.pth保存完整状态而不是只保存model.state_dict(),原因是断点续训时需要恢复优化器和调度器的状态。如果只有模型权重,恢复训练后学习率会从初始状态重新开始,导致训练曲线断裂。周期性保存则用于事后分析——如果最后一轮过拟合了,可以回到第 20 轮的权重继续评估,不用重新训练。磁盘占用方面,ResNet50 一个 checkpoint 约 100MB,30 个 epoch 存 6 个也就 600MB,这点成本换来的后悔药非常值。
4.3 别用准确率当唯一指标
花卉数据集的类别分布很难做到完全均衡,特别是采集自野外的图片集,某些罕见品种可能只有训练集里几十张。这种情况下验证集准确率会被高频类别主导,模型可能对低频类别完全失效,但整体准确率看着还不错。我每次训练结束后会强制打印每个类别的精确率、召回率和 F1,这个工具 sklearn 的classification_report可以直接给,不需要自己写循环。
from sklearn.metrics import classification_report import numpy as np all_preds = [] all_labels = [] model.eval() with torch.no_grad(): for images, labels in val_loader: images = images.to(device) outputs = model(images) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) class_names = list(train_dataset.class_to_idx.keys()) print(classification_report( all_labels, all_preds, target_names=class_names, digits=3 ))classification_report会按类别给出 precision、recall、f1-score 和 support(样本数)。看这个报告的习惯是:先看 support——哪些类别样本少于 50、哪些类别超过 200,这是判断结果可信度的前提;再看每个类别的 recall——recall 低的类别说明模型大量漏检,典型情况是“月季”被误判为“玫瑰”,因为两者外观相似度极高;最后看 macro avg 和 weighted avg 的差值,差得越大说明模型在低频类别上的表现越差。
5. 避坑与调参:花卉识别训练中的常见问题排查
5.1 验证集准确率虚高,换真实场景就崩
现象:训练时验证集达到 95%,拿到几张自己拍的花朵照片测试,识别结果完全不准。原因:训练集和验证集划分不够严格,同一株花、同一个拍摄场景的照片被同时分进两边;也可能数据集本身是从视频帧或连拍中提取的,相邻帧差异极小。解决:按文件名前缀或拍摄时间归组后重新划分,保证同一来源图片只出现在一边;如果数据集没有提供这些信息,只能手动人工抽检验证集图片,把明显来自同一场景的图像整组剔除。从那以后我拿到数据集的第一件事就是跑一遍相似度检查,用感知哈希算图片两两相似度,相似度高于阈值的只留一张,而不是直接开训。
5.2 训练 loss 下降到 0.1 以下,验证集准确率却不高
现象:训练 loss 一路降到 0.1 以下,训练准确率几乎 100%,但验证集准确率只有 70% 左右。原因:这是典型的过拟合信号。花卉数据集图片数量通常在几千张级别,ResNet50 参数量 2500 万以上,如果没有强数据增强和正则化,模型很容易把训练集的背景、光照等无关特征记下来。解决:强化数据增强,RandomResizedCrop的 scale 参数可以设到(0.5, 1.0),强制模型关注花朵主体;同时加入RandomHorizontalFlip和RandomRotation(15)之外的RandomErasing——随机遮挡图片的一部分,让模型不能依赖单一局部特征。Dropout 层加在 fc 层之前,概率取 0.3。
5.3 训练速度极慢,GPU 利用率只有 20%
现象:显卡显示利用率很低,训练一个 epoch 要 20 分钟,但实际计算量不大。原因:数据加载成为瓶颈。图片尺寸大、解码慢,num_workers设置太低或者磁盘本身是机械硬盘。解决:num_workers调成 CPU 核心数的一半或三分之二,pin_memory=True让 GPU 直接读取锁页内存。如果图片分辨率很大,在 transform 里先Resize(256)再RandomResizedCrop(224),这样可以减少解码后的内存占用和缩放计算量。还有一个玄学问题——Windows 系统上num_workers必须放在if __name__ == "__main__"保护的代码块里,否则会无限递归报错。
5.4 微调阶段损失函数震荡,不收敛
现象:解冻 backbone 后 loss 不但没下降,反而出现周期性震荡,验证集准确率上下跳动。原因:学习率过大,Adam 默认lr=1e-3在预训练模型上偏大,微调阶段权重更新幅度过猛;也可能是 batch size 太小导致梯度估计噪声太大。解决:微调阶段学习率调到1e-4到3e-4之间,batch size 小于 16 时先调大。另外确认 backbone 的 BatchNorm 层行为,PyTorch 的 model.train() 下 BN 会使用当前 batch 的统计量,如果 batch size 太小,BN 的均值方差估计不稳定,也会导致 loss 震荡。如果必须用小 batch,改用SyncBatchNorm或干脆冻结前几层的 BN。
5.5 推理时输入尺寸和 transform 不一致
现象:训练时验证集准确率正常,部署到 API 接口后准确率明显下降。原因:推理代码里没有做Normalize,或者 Resize/Crop 策略与训练时不一致。训练时的RandomResizedCrop相当于数据增强,如果推理时也用了这个,等于给模型喂了“随机裁剪”后的图片,输出自然不稳定。解决:推理管线和验证集完全一致——先Resize(256),再CenterCrop(224),最后Normalize,并且 Normalize 用的 mean 和 std 必须和训练时完全相同。我把 transform 封装成一个单独的函数,训练和推理都从这里导入,杜绝手写两遍导致不一致的问题。
6. 模型导出的工程化验证:从 PyTorch 到 ONNX 的完整流程
训练收尾后,模型最终要走出 Jupyter Notebook,进入真实的应用环境。PyTorch 的.pth文件只能在 Python 环境里跑,如果要部署到推理框架或嵌入到移动端,需要导出成 ONNX 或者 TorchScript。我这里以 ONNX 为例,因为 OnnxRuntime 的跨平台性最好,CPU 推理速度也比纯 PyTorch 快不少。
import torch import onnx import onnxruntime as ort import numpy as np from PIL import Image from torchvision import transforms model.eval() dummy_input = torch.randn(1, 3, 224, 224).to(device) torch.onnx.export( model, dummy_input, "flower_model.onnx", export_params=True, opset_version=13, do_constant_folding=True, input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch_size"}, "logits": {0: "batch_size"}} ) # 验证导出的 ONNX 模型输出与 PyTorch 一致 ort_session = ort.InferenceSession("flower_model.onnx") sample_img = Image.open("test_flower.jpg").convert("RGB") transform = 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]) ]) input_tensor = transform(sample_img).unsqueeze(0).numpy() onnx_output = ort_session.run(["logits"], {"input": input_tensor})[0] with torch.no_grad(): torch_output = model(torch.from_numpy(input_tensor)).cpu().numpy() diff = np.abs(onnx_output - torch_output).max() print(f"最大输出差异: {diff:.6f}") assert diff < 1e-3, "ONNX 输出与 PyTorch 差异过大"dynamic_axes设置 batch 维度为动态,这样导出后既支持单张推理,也支持 batch 推理,不需要为不同 batch size 导多份模型。opset_version=13兼容性较好,OnnxRuntime 和大多数推理框架都能跑。差异验证是关键一步——diff通常应该在1e-4量级,如果超过1e-2,说明模型里有某些算子导出质量不行,常见的坑是torch.nn.functional.interpolate的坐标变换在 ONNX 里的行为与 PyTorch 不完全一致。
导出完成后还要做一次全流程验证:随机从验证集抽 100 张图,全部用 OnnxRuntime 跑一遍推理,再和 PyTorch 的预测结果对比,统计预测不一致的数量。理论上 ONNX 和 PyTorch 推理结果应该完全一致,因为只是计算图的格式变了,权重没有改动。如果出现不一致,优先检查推理时的预处理——transform 是否和训练时一模一样,尤其是 Normalize 的 mean/std 是否照抄,这里抄错一个数字,模型输出就完全不同。
从那以后,我每次训练完强制自己走一遍“导出 ONNX → 跑差异对比 → 抽检 100 张图”的流程,把 PyTorch 模型和 ONNX 模型当成两个独立的实现来交叉验证。这步做完,模型才算真正从“训练能跑”变成“能用”。拿到这份花卉识别训练源码和数据集的同行,建议先花 20 分钟跑一遍第 2 章的检查脚本,再决定要不要调模型——数据这一关过了,后面训练踩的坑能少一半。希望帮到你。
本文还有配套的精品资源,点击获取