用PyTorch训练花卉识别模型:64类数据集与37种CNN实战
2026/9/23 8:40:39 网站建设 项目流程

简介:面向深度学习图像分类入门与实战,这份资料整合了花卉识别专用数据集与多模型训练代码。数据集涵盖64种花卉,共32000张224×224彩色图像,其中25600张用于训练、6400张用于测试,图像均为手机实地采集,较网络爬虫图更贴近真实场景。配套训练代码基于卷积神经网络实现37种主流分类模型,覆盖ResNet、VGG、Inception、MobileNet、DenseNet、EfficientNet、SqueezeNet等系列,方便对比不同架构在花卉识别任务上的效果。整个压缩包约194.92MB,包含2000个文件,以1919张jpg图像为主,另有39个txt说明文件、20个Python训练脚本及22个pyc编译文件,可直接用于模型训练与推理。已有1645人学习/下载。对于需要一套干净、成体系的花卉图像分类基准数据的开发者,这份资源同时提供了数据与代码,省去自行采集标注与搭建模型的流程,适合快速上手图像分类项目。

1. 手机相机图库中的64类花卉数据集,比爬虫图更值得训练

做花卉识别,第一反应往往是去找 ImageNet 里那几个 flower 类别,但一旦把模型放到手机相册里拍的实景花朵上,背景、光照、拍摄角度全变了,精度掉得很快。这份资源把 64 种常见花卉整理成 32000 张 224×224 彩色图,训练集 25600 张、测试集 6400 张,图片来自手机实地采集而不是网络爬虫,文件名形如024-001-03397.jpg,前三位就是类别编号。配套的深度学习训练代码覆盖 ResNet、VGG、Inception、MobileNet、DenseNet、EfficientNet、SqueezeNet 七大系列的 37 种图像分类模型,对做深度学习和模型训练的人来说,既能当细粒度分类练手项目,也能快速验证预训练模型在真实拍摄条件下的泛化能力。

2. 数据集解包:64类32000张图像的结构、标签与训练堵点

原始下载包里的 jpg 是平铺存放的,目录里看不到类别文件夹。如果直接把所有 jpg 丢给torchvision.datasets.ImageFolder,它会认为每个文件单独一个样本但没有类别归属,所以第一件事不是急着写模型,而是重建目录结构,让训练代码能正确读取标签。

2.1 文件名三段式:类别编号、样本编号和原始编号

024-001-03397.jpg这种命名并不是随机字符串。按常见约定,第一段024是类别 ID,第二段001是样本编号,第三段03397是原始图片在手机相册里的编号。训练时真正用到的是第一段,后两段只用于追溯原始照片。如果你的数据集会按类划分,通常会把024对应的目录名写成024,类别中文名由另一个label.txt维护。

先用一段脚本把平铺文件移动到按类别分好的目录里:

from pathlib import Path import shutil src = Path("flower_224") # 原始 jpg 平铺目录 dst = Path("flower_by_class") # 按类别分好的目标目录 dst.mkdir(exist_ok=True) for img in src.glob("*.jpg"): cls_id = img.stem.split("-")[0] # 文件名第一段是类别 ID,例如 024 target_dir = dst / cls_id target_dir.mkdir(parents=True, exist_ok=True) shutil.copy(img, target_dir / img.name)

这里img.stem返回不含后缀的文件名,split("-")[0]取出第一段024。不要把split("-")[1]当作类别,那是样本编号,用了会直接多出 64 倍的错误目录。parents=True解决多级目录不存在的问题,exist_ok=True防止重复执行时抛FileExistsError。更稳妥的做法是同时生成label.txt,把024映射成“月季”这类可读名称,模型训练脚本只读数字 ID,展示结果时才查表。

2.2 训练集与测试集划分:三个检查点必须过一遍

资源给出的是训练集 25600 张、测试集 6400 张的 8:2 划分,但下载后首先要核对三个数字:类别数是不是 64,样本总数是不是 32000,每个类别是否都存在足够样本。如果包内已经分好train/val/,直接使用;如果还是按类平铺,建议手动切分。

核对类别数和样本数的脚本可以这样写:

from PIL import Image from pathlib import Path root = Path("flower_by_class") for cls_dir in sorted(root.iterdir()): imgs = list(cls_dir.glob("*.jpg")) print(f"{cls_dir.name}: {len(imgs)} 张") # 抽检尺寸,不用解完所有 32000 张 for p in imgs[:5]: w, h = Image.open(p).size assert (w, h) == (224, 224), f"{p.name}: ({w}, {h})"

这段代码只解每类前 5 张图,避免全量解码带来的 IO 开销。手机图片容易携带 EXIF 旋转信息,某些图像解码后宽高会和实际旋转后的尺寸对调,抽检可以提前发现这类问题。类别数少于 64 或某个类只有几十张时,需要重点关注,后面 5.4 节会专门说单类样本不足的处理。

确认无误后按类别切分训练集和验证集:

from sklearn.model_selection import train_test_split import shutil root = Path("flower_by_class") train_dir = Path("flower_dataset/train") val_dir = Path("flower_dataset/val") for cls_dir in root.iterdir(): imgs = list(cls_dir.glob("*.jpg")) train_imgs, val_imgs = train_test_split( imgs, test_size=0.2, random_state=42) for p in train_imgs: out = train_dir / cls_dir.name / p.name out.parent.mkdir(parents=True, exist_ok=True) shutil.copy(p, out) for p in val_imgs: out = val_dir / cls_dir.name / p.name out.parent.mkdir(parents=True, exist_ok=True) shutil.copy(p, out)

test_size=0.2对应资源声称的 25600/6400 比例,random_state=42保证可复现。这里不是全数据集按 8:2 一次性切分,而是每个类别内部独立切分,这样每个类别在验证集里都占 20%,不会出现某个类别在验证集中缺失的极端情况。

2.3 数据增强:手机采图的背景复杂度比爬虫图更难处理

手机拍摄的花卉往往有绿叶背景、曝光不均、轻微失焦。训练时如果只用随机裁剪和水平翻转,模型容易记住背景颜色而不是花瓣纹理。常见做法是把增强分成几何扰动和颜色扰动两部分:

from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0), ratio=(0.75, 1.333)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2, hue=0.05), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])

RandomResizedCropscale范围控制在 0.6 到 1.0,模拟手机取景时花占画面比例不固定的情况;ratio接近正方形,避免过度拉伸花瓣形状。RandomRotation(15)处理手机拍摄时经常出现的轻微歪斜。ColorJitterhue=0.05不要调大,花卉颜色是分类主特征,色调偏移太大会让模型学到错误的颜色分布。验证集只用Resize(256)CenterCrop(224),不做随机增强。

3. 一套代码跑37种CNN:统一训练器的模型接口设计

拿到数据集之后,训练代码的价值就体现出来了。这套代码的入口是一个模型名字符串,背后统一封装了七大系列 37 个分类模型,换模型不需要改数据加载和训练逻辑。我习惯把模型构建独立成一个model_builder.py,避免在训练脚本里堆 37 个 if-else。

3.1 用 getattr 动态构造模型,再替换分类头

TorchVision 的 models 接口把所有网络都挂在同一个命名空间下,resnet50vgg16_bnmobilenet_v3_large都是可调用对象。用getattr(tv_models, name)能像查字典一样取到对应的构造器,之后只需要按系列替换最后的分类层。

import torch.nn as nn import torchvision.models as tv_models def replace_head(model, name, num_classes): if name.startswith("squeezenet"): in_ch = model.classifier[1].in_channels model.classifier[1] = nn.Conv2d(in_ch, num_classes, kernel_size=1) return model if name.startswith("resnet") or name.startswith("inception"): model.fc = nn.Linear(model.fc.in_features, num_classes) elif name.startswith("densenet"): model.classifier = nn.Linear(model.classifier.in_features, num_classes) else: # vgg / mobilenet / efficientnet 都在 classifier[-1] layer = model.classifier[-1] model.classifier[-1] = nn.Linear(layer.in_features, num_classes) return model def build_model(name, num_classes=64, pretrained=True): weights = "DEFAULT" if pretrained else None model = getattr(tv_models, name)(weights=weights) return replace_head(model, name, num_classes)

重点说明几点。第一,weights="DEFAULT"是新版 TorchVision 的写法,等价于自动选择该模型在 ImageNet 上的最佳预训练权重,比旧版pretrained=True更显式。第二,SqueezeNet 分类头是Conv2d而不是全连接层,所以替换时要用nn.Conv2d(..., kernel_size=1),如果硬塞一个nn.Linear,forward 阶段会直接报维度错误。第三,replace_head里没有覆盖所有细节,比如 InceptionV3 的辅助分类器,实际使用时通常会把aux_logits=False或保留训练时单独计算 aux loss,下面单独说。

3.2 Inception 系列的特殊处理:输入尺寸和输出结构都不同

InceptionV3 官方输入是 299×299,不是 224×224。直接在 224 图上跑,前面的卷积层会因为空间尺寸不足抛出异常。如果要用inception_v3,数据增强里的RandomResizedCrop要改成 299,验证集也要对应CenterCrop(299)

另一个坑是训练时 InceptionV3 的 forward 返回的是InceptionOutputs对象,而不是普通 Tensor。训练循环里必须取.logits,否则CrossEntropyLoss会报TypeError。建议在训练脚本里用一段防御性代码:

from torchvision.models.inception import InceptionOutputs outputs = model(images) if isinstance(outputs, InceptionOutputs): outputs = outputs.logits

验证或推理时把model.aux_logits = False,可以直接让模型输出纯 logits,省去判断。这个细节是实际跑 Inception 时最常见的卡点,不是模型本身难调,而是接口形态导致。

3.3 七大系列模型选型:不只看参数量

下面是 37 个模型在分类任务里的一般选型倾向,按系列整理成表,方便决定先用哪个模型做 baseline:

模型系列代表模型输入尺寸参数量特点这个数据集上的倾向
ResNetresnet18 / resnet50224适中最稳定的 baseline,先跑它
VGGvgg16_bn / vgg19_bn224特征可视化方便,训练慢
Inceptioninception_v3299多尺度感受野,需要改输入尺寸
MobileNetmobilenet_v3_large224手机端部署首选
DenseNetdensenet121 / densenet201224小到中特征复用强,小数据比 ResNet 稳
EfficientNetefficientnet_b0 / b1224精度/算力性价比高
SqueezeNetsqueezenet1_0 / 1_1224极小嵌入式原型验证

花卉分类考验的是细粒度特征,DenseNet、EfficientNet 通常比 MobileNet 更容易收敛。但如果目标是部署到手机端,MobileNet 的推理速度优势不可替代。37 种模型自由选择不等价于每轮都换模型,建议先用 ResNet50 跑通全流程,再在同一系列内换深度,最后对比不同系列在同一超参下的表现。

3.4 超参基线:预训练模型不要用太大的学习率

默认超参我一般这样设:

optimizer = torch.optim.SGD( model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs) criterion = torch.nn.CrossEntropyLoss()

ResNet、VGG、DenseNet 用 SGD 加余弦退火就足够,初始学习率 0.001 对预训练模型是安全值。MobileNet、EfficientNet 这类 BN 层较多的网络,SGD 调起来会有抖动,换AdamW(lr=1e-4)更稳。如果是从头训练而不是加载预训练权重,学习率可以提到 0.01,但 32000 张图的数据量不足以支撑从头训练出好的细粒度特征,不建议这么干。

4. 训练与验证:命令、指标与断点续训

数据集和模型接口准备好之后,训练过程本身要能快速复现、及时看指标、失败后能恢复。这里按常见训练入口脚本整理一套完整流程。

4.1 训练入口与命令行参数

包里的训练代码如果命名为train.py,典型启动命令如下:

python train.py \ --data flower_dataset \ --model resnet50 \ --epochs 40 \ --batch-size 64 \ --lr 0.001 \ --pretrained \ --gpu 0

--data指向包含trainval的根目录,--model对应模型名,--pretrained表示加载 ImageNet 预训练权重,--gpu 0指定显卡编号。--batch-size 64在 8GB 显存上跑 ResNet50 已经接近上限;如果显存不够,优先降 batch size 到 32,而不是调小输入分辨率。输入分辨率降低会影响细粒度特征提取,属于最后才考虑的手段。

4.2 训练循环里最关键的几行代码

训练循环本身不复杂,但顺序和状态切换经常出错。核心部分可以写成这样:

import torch from torchvision.models.inception import InceptionOutputs def train_one_epoch(model, loader, optimizer, criterion, device, model_name): model.train() total_loss = 0.0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) if "inception" in model_name and isinstance(outputs, InceptionOutputs): outputs = outputs.logits loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(loader.dataset)

model.train()必须写在循环外,否则每批都切换模块状态。optimizer.zero_grad()必须在backward()之前调用,否则梯度会跨 batch 累加。loss.item()取出 Python 数值用于统计,乘以images.size(0)加权,避免最后一个 batch 样本数不足导致平均 loss 偏低。

验证函数要放在torch.no_grad()下,并且把模型切到 eval 状态:

@torch.no_grad() def validate(model, loader, criterion, device, model_name): model.eval() correct = 0 total = 0 val_loss = 0.0 for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) if "inception" in model_name and isinstance(outputs, InceptionOutputs): outputs = outputs.logits val_loss += criterion(outputs, labels).item() * images.size(0) preds = outputs.argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) return val_loss / total, correct / total

注意验证时不要写model.train(),否则 BN 层会继续更新 running_mean 和 running_var,验证指标会虚高,尤其是 MobileNet、EfficientNet 这类 BN 密集的模型。每个 epoch 结束后保存最佳模型:

if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), f"checkpoints/{model_name}_best.pth")

如果训练中断,建议保存完整训练状态,不只是模型权重:

torch.save({ "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), "epoch": epoch, }, f"checkpoints/{model_name}_last.pth")

恢复训练时依次load_state_dict,再继续循环。只保存模型权重的做法在崩溃后要重新调整学习率,很浪费。

4.3 测试集评估:分类报告和混淆矩阵写文件,别刷屏

训练结束后,需要把当前最好的模型加载回来,在测试集上输出 top-1 准确率、每类的精确率和召回率,以及混淆矩阵。

from sklearn.metrics import classification_report, confusion_matrix model.load_state_dict(torch.load("checkpoints/resnet50_best.pth")) model.eval() y_true, y_pred = [], [] for images, labels in test_loader: images = images.to(device) with torch.no_grad(): outputs = model(images) preds = outputs.argmax(dim=1) y_true.extend(labels.numpy()) y_pred.extend(preds.cpu().numpy()) report = classification_report(y_true, y_pred, digits=3) with open("resnet50_report.txt", "w") as f: f.write(report) cm = confusion_matrix(y_true, y_pred)

64 类的classification_report在终端里会刷屏,写文件更实用。混淆矩阵重点看对角线外的热点,比如某两类互相猜错,说明这两类在视觉上高度相似,后续要考虑合并类或采集更多样本。

5. 让花卉模型再涨两点:细粒度分类的五个实用技巧

5.1 分两段训练

先冻结骨干网络,只训练新加的分类头,用 1e-3 的学习率跑 5 个 epoch;然后解冻全部参数,学习率降到 1e-4 继续训练。冻结时要把分类头单独拿出来:

for param in model.parameters(): param.requires_grad = False for param in head.parameters(): param.requires_grad = True

对 ResNet 系列,head就是model.fc;对 MobileNet 系列则是model.classifier[-1]。这样做能让随机初始化的分类头先稳定下来,避免一开始反向传播的梯度太大把预训练特征冲坏。

5.2 用 Label Smoothing 代替普通交叉熵

64 类花卉里存在大量相似类别,硬标签会让模型对训练集的边界过于自信。改一行代码即可:

criterion = torch.nn.CrossEntropyLoss(label_smoothing=0.1)

label_smoothing=0.1表示真实类别的目标概率是 0.9,其余 0.1 分摊到其他 63 类。对细粒度分类任务,这通常能带来 0.5 到 1 个点的验证集提升。

5.3 遇到长尾类别,用 WeightedRandomSampler

如果检查时发现某些类别明显少于 400 张,训练时要避免多数类主导 loss。使用WeightedRandomSampler按类别样本数的倒数加权采样:

from torch.utils.data import WeightedRandomSampler class_counts = [len(list(cls_dir.glob("*.jpg"))) for cls_dir in train_dir.iterdir()] weights = [1.0 / c for c in class_counts] sample_weights = [weights[label] for label in all_labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)

replacement=True允许重复采样,这样每个 epoch 里少样本类也能被反复看到。

5.4 手动检查 top-5 命中但 top-1 错误的样本

花卉分类的很多错误并没有真的“分错”,只是 top-1 压错了顺序。训练结束后遍历测试集,把top-1错误但top-5正确对应的图片收集出来,看是光照问题、遮挡问题,还是两个类真的长得很像。若是后者,建议把两个类合并后再训一轮,数据集重新标注的成本远低于继续堆模型复杂度。

5.5 测试时增强,吃掉最后 0.5 个点

验证和测试时,可以做简单的水平翻转 TTA,把多次推理结果平均:

base_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean, std) ]) flip_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=1), transforms.ToTensor(), transforms.Normalize(mean, std) ]) @torch.no_grad() def tta_predict(model, img, device): model.eval() probs = [] for t in (base_transform, flip_transform): x = t(img).unsqueeze(0).to(device) probs.append(torch.softmax(model(x), dim=1)) final = torch.mean(torch.cat(probs), dim=0) return final.argmax().item()

TTA 的意义在于消除测试图片本身的方向偏置。手机拍摄的花卉图没有固定朝向,水平翻转后的预测结果如果和原图不一致,说明模型对方向的敏感性高于对花型特征的敏感性。注意 TTA 只适合推理阶段,不要在验证集上反复调参时使用,否则会把 TTA 带来的提升当成模型本身的提升。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询