简介:一套基于 Python 与卷积神经网络(CNN)的花卉图像识别完整项目,面向计算机视觉课程设计、毕业设计及期末大作业场景,尤其适合计算机相关专业学生和需要项目实战的初学者。项目以 CNN 为算法核心,覆盖数据预处理、特征提取、模型构建、训练调参、测试评估与 GUI 图形界面识别等完整流程,并提供命令行与可视化两种使用方式;训练好的模型与源码均已打包,确保可直接运行,帮助使用者快速理解图像分类任务从数据集到模型部署的各个环节。包体共 13 个文件,包含 6 个 Python 脚本(分别负责模型构建、训练、推理测试、数据记录与图形界面等)、1 份 Word 设计报告、1 份 PPT 答辩演示,以及环境配置文件、说明文档和模型压缩包,整体大小约 10.82MB,结构清晰,便于分类查阅。现有 148 人学习使用,适合按步骤复现并迁移到相似任务中。通过该资源,用户可获得接近满分的项目范例,包括完整可运行代码、已训练模型、设计报告与答辩演示材料,无需从零调参即可体验深度学习图像识别项目,也可在此基础上针对不同花卉数据集进行扩展改造,直接支撑课程答辩或文档撰写。
1. 计算机视觉大作业:从数据集到设计报告的一条龙方案
期末前两周接到这门课的大作业要求:基于 CNN 的花卉图像识别,要交源码、模型和设计报告。这类题目在“计算机视觉大作业”里出现频率极高——任务本身不难,但坑全藏在数据集处理、模型选型和报告组织里。你以为难点在“识别”,真正卡住人的往往是标签错位、训练不收敛、显存不够,以及报告写得像流水账。这篇文章讲的是我做过的一套完整路径:用什么数据集、怎么搭 CNN、参数怎么设、哪里最容易翻车,以及设计报告怎么写得让老师愿意给高分。适合正在做课程设计、需要交源码和报告的本硕学生,也适合想快速拿一个图像分类项目练手的入门开发者。
2. 数据集与预处理:花卉识别项目的起点,也是决定结果上限的地方
2.1 选数据集的标准:类别数、每类样本量和标注质量
花卉图像识别最常用的公开数据集有两个:一个是 Oxford 102 Flower,102 个类别、每类 40 到 258 张图不等,类别多、相似度高,适合做课程设计撑起报告篇幅;另一个是 TensorFlow 示例数据集 flower_photos,5 个类别(雏菊、蒲公英、玫瑰、向日葵、郁金香),每类约 700 张,适合快速验证流程。我的建议是:课程设计选 Oxford 102,时间紧就选 5 类的 flower_photos。
判断一个数据集适不适合你的作业,看三点:类别数是否和你报告的实验设计匹配、每类样本是否均衡、是否带官方划分。Oxford 102 不带官方 train/test 划分,需要自己拆,这反而是好事——你可以在报告里写“数据划分策略”这一节,老师很吃这一套。注意 102 类的相似度很高,比如某些菊科花卉之间只差花瓣形态,这会让准确率上不去,所以报告里模型选择部分要提前铺垫“类别间相似度高”这个理由。
2.2 目录组织:让 ImageFolder 直接吃你的数据
PyTorch 的 torchvision.datasets.ImageFolder 是这类任务的标准入口,但它对目录结构有硬性要求:每个类别一个子文件夹,文件夹名就是类别名。常见做法是先手动建好目录,再用脚本按类别复制图片:
# 处理 flower_photos 的经典目录结构 # 原始结构:flower_photos/daisy/*.jpg, flower_photos/dandelion/*.jpg # 目标结构:data/train/daisy/*.jpg, data/val/daisy/*.jpg, data/test/daisy/*.jpg mkdir -p data/train data/val data/test for class in daisy dandelion rose sunflower tulip; do mkdir -p data/train/$class data/val/$class data/test/$class done这段脚本的作用是先把目标目录骨架建出来,等会儿用 Python 脚本做随机划分。这里有个容易踩的坑:文件夹名必须用英文,用中文名会导致某些环境下的路径编码报错,尤其是 Windows 上跑 PyTorch 的同学,中文路径会让部分 PIL 版本读图失败。
2.3 划分脚本:随机种子是唯一能救你的东西
import os import random import shutil from collections import defaultdict random.seed(42) # 固定种子,保证报告里的数字可复现 src_root = "flower_photos" train_root = "data/train" val_root = "data/val" test_root = "data/test" train_ratio, val_ratio = 0.7, 0.15 # 剩下 0.15 给测试集 for class_name in os.listdir(src_root): class_path = os.path.join(src_root, class_name) if not os.path.isdir(class_path): continue imgs = [f for f in os.listdir(class_path) if f.lower().endswith((".jpg", ".jpeg", ".png"))] random.shuffle(imgs) n_train = int(len(imgs) * train_ratio) n_val = int(len(imgs) * val_ratio) for i, img in enumerate(imgs): if i < n_train: dst = os.path.join(train_root, class_name, img) elif i < n_train + n_val: dst = os.path.join(val_root, class_name, img) else: dst = os.path.join(test_root, class_name, img) shutil.copyfile(os.path.join(class_path, img), dst) print("train:", sum(len(os.listdir(os.path.join(train_root, c))) for c in os.listdir(train_root)))这段脚本的逻辑是按比例随机抽样,然后复制到对应目录。关键点是 random.seed(42) 必须放在 shuffle 之前,否则每次跑结果都不一样。划分比例我一般用 7:1.5:1.5,不要用 8:1:1,因为花卉数据集类别多、相似度高,验证集太小会让 early stopping 的判断不稳定。复制而不是移动原文件,这样原始数据还在,出问题随时能回来重分。
2.4 数据增强策略:翻转变换加归一化,别做过度裁剪
from torchvision import transforms # 训练集:增强 + 归一化 train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证/测试集:只做 resize 和归一化,不做任何随机增强 val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])transforms 里每个操作的顺序是固定的:先 Resize 统一尺寸,再做随机增强,再转 Tensor,最后归一化。RandomHorizontalFlip 和 RandomRotation 是保真增强,不会改变花的类别语义;裁剪类增强(比如 RandomResizedCrop)建议谨慎用,因为花卉图像可能出现花在边缘、裁剪后主体丢失的情况。归一化的 mean 和 std 是 ImageNet 的标准值,用预训练模型时不能改——后面讲模型迁移时会再强调一次。
3. CNN 模型选型与训练:为什么迁移学习是大作业的最优解
3.1 从零搭 CNN 的诱惑和代价
很多同学觉得“自己搭的 CNN 才是真本事”,于是写了五六层卷积加全连接的网络,然后训练两小时,准确率卡在 60% 上不去。问题不是你代码写错了,而是数据量不够——Oxford 102 每类平均才几十张图,从零训练一个深层 CNN 必然过拟合。这不是能力问题,是统计规律。
如果你确实想展示对 CNN 结构的理解,可以搭一个迷你 CNN 作为 baseline,和迁移学习做对比实验。这比只用一个模型更有报告价值:
import torch.nn as nn class MiniCNN(nn.Module): def __init__(self, num_classes=102): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), # 224x224 -> 224x224 nn.ReLU(inplace=True), nn.MaxPool2d(2), # -> 112x112 nn.Conv2d(32, 64, kernel_size=3, padding=1), # -> 112x112 nn.ReLU(inplace=True), nn.MaxPool2d(2), # -> 56x56 nn.Conv2d(64, 128, kernel_size=3, padding=1), # -> 56x56 nn.ReLU(inplace=True), nn.MaxPool2d(2), # -> 28x28 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 28 * 28, 256), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))这个迷你 CNN 有 5 层带参数的层,结构上覆盖了卷积、池化、ReLU、Dropout 这些基础组件。padding=1 让卷积不改变尺寸,MaxPool2d 每次减半,1282828 是最后特征图的展平长度——改输入尺寸时必须同步重算这个数,这是新手最容易搞错的地方。Dropout(0.5) 是防止过拟合的关键,但要注意推理时 Dropout 会被自动关闭,PyTorch 的 model.eval() 会处理这件事。
3.2 迁移学习:用预训练 ResNet18 替换分类头
真正的重头戏是迁移学习。用 torchvision 里预训练好的 ResNet18 或 ResNet34,把最后一层全连接换成你自己的分类器。对花卉识别这种细粒度分类任务,ResNet18 比 ResNet50 更合适——不是因为 50 不好,是因为数据集太小,50 的参数更多、更容易过拟合,训练时间也更长。
import torchvision.models as models # 加载预训练模型,pretrained=True 会下载 ImageNet 权重 model = models.resnet18(pretrained=True) # 冻结所有层参数,只训练最后一层 for param in model.parameters(): param.requires_grad = False # 替换最后一层全连接 num_ftrs = model.fc.in_features # ResNet18 是 512 model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(num_ftrs, 102) # 102 类花卉 )这段代码的做法是:先加载在 ImageNet 上预训练好的 ResNet18,冻结前面所有卷积层(requires_grad=False),再把最后一层换成 Dropout 加 Linear。冻结的原因很直接——前面层学到的是通用特征(边缘、纹理、形状),对花卉同样适用,没必要重新学;只有最后分类层需要适应你的 102 类。这个方案在小数据集上通常能到 85% 以上的测试准确率,而从头训同样数据大概只有 60% 多一点。
注意这里的 102 是类别数,如果你的数据集是 5 类就要改成 5,否则最后一层维度不匹配会直接报错。
3.3 训练循环的关键参数:学习率、batch size 和超参数怎么调
import torch import torch.optim as optim from torch.optim.lr_scheduler import StepLR 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=0.001) # 每 5 个 epoch 学习率乘以 0.5 scheduler = StepLR(optimizer, step_size=5, gamma=0.5) num_epochs = 30 best_acc = 0.0 for epoch in range(num_epochs): model.train() running_loss = 0.0 for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) scheduler.step() # 验证 model.eval() correct, total = 0, 0 with torch.no_grad(): for val_inputs, val_labels in val_loader: val_inputs, val_labels = val_inputs.to(device), val_labels.to(device) val_outputs = model(val_inputs) _, predicted = torch.max(val_outputs, 1) total += val_labels.size(0) correct += (predicted == val_labels).sum().item() val_acc = correct / total print(f"Epoch {epoch+1}: loss={running_loss/len(train_loader.dataset):.4f}, val_acc={val_acc:.4f}") if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") print("Best val acc:", best_acc)这个训练循环是标准骨架。几个关键参数的解释:
- optimizer 用 Adam,lr=0.001 是默认值也是最稳的起点。如果发现 loss 震荡厉害,把 lr 降到 0.0003;如果收敛太慢,先试 0.003。学习率是深度学习里最“玄学”的参数,但按 3 倍递减法找一般不会翻车。
- StepLR 每 5 个 epoch 把学习率乘 0.5,作用是后期收敛更细。你可以在报告里画 lr 变化曲线,这比在正文里写“我调了参”有说服力得多。
- batch size 初始设 32 或 64。显存不够就降到 16,但注意 batch size 变了学习率也要跟着变——后面避坑章节会专门讲。
- best_model.pth 只在验证集准确率创新高时保存,防止最后几个 epoch 过拟合把好模型覆盖掉。
- 冻结前面层意味着梯度只在 model.fc 里流动,反向传播仍然会过完整网络但前面层不更新,所以训练速度不会比全量微调快太多。
3.4 评估:top-1 准确率不够用,混淆矩阵才是重点
花卉识别大作业里最常见的评估错误是只看 top-1 准确率。但 102 类花卉相似度极高,模型真正答对但排名第二的情况很常见。很多课程的老师会追问“你的模型为什么把这种菊花认成那种菊花”,这时候你有混淆矩阵就能直接回应。
import matplotlib.pyplot as plt import numpy as np from sklearn.metrics import confusion_matrix all_preds, all_labels = [], [] model.eval() with torch.no_grad(): for inputs, labels in test_loader: inputs = inputs.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) # 打印每一类的准确率,找到最容易被混淆的类别 class_acc = cm.diagonal() / cm.sum(axis=1) worst_idx = np.argsort(class_acc)[:5] print("最易出错的 5 个类别索引:", worst_idx) print("对应准确率:", class_acc[worst_idx])这段代码会告诉你模型在哪几个类别上表现最差。通常会发现是外观相似的菊科或蔷薇科花卉。报告里放一张混淆矩阵热力图,再解释“这两类花瓣颜色分布近似导致特征混叠”,立刻就把一个普通作业拉升到“有实验分析”的档次。如果你用了 top-5 准确率,可以顺便算一下,一般会比 top-1 高出 8 到 12 个百分点,这也是值得写进报告的数据。
4. 花卉识别项目避坑指南:现象、原因和根治办法
4.1 训练 loss 是 nan 或者不下降
现象:第一个 epoch 打印的 loss 就是 nan,或者连续十个 epoch loss 卡在同一个值附近不动。
原因分两种:loss 为 nan 十有八九是输入数据没有归一化,或者学习率太大;loss 不降则往往是归一化参数和预训练模型不匹配。很多同学从网上抄了一个训练代码,但 mean 和 std 用的是别人数据集的值,自己的数据分布对不上。
解决:检查 transforms 里有没有 ToTensor 和 Normalize,顺序必须是先 ToTensor 再 Normalize。预训练 ResNet 的归一化必须用 ImageNet 的 mean/std,也就是 0.485/0.456/0.406 和 0.229/0.224/0.225。如果确认归一化没问题,把 lr 从 0.001 降到 0.0003 试一次。
4.2 验证集准确率远低于训练集
现象:训练集准确率到了 95%,验证集却只有 70%,而且随着 epoch 增加差距还在拉大。
原因:这就是典型的过拟合。你的模型把训练集的背景、拍摄角度等无关特征也学进去了。冻结核层只训分类头的方案能缓解,但如果你把整个网络都解冻了(requires_grad=True),过拟合马上回来。
解决:三种手段叠加使用。第一,确认数据增强只加在训练集上,验证和测试集不要加随机翻转旋转;第二,把 Dropout 加到 0.5 或 0.6;第三,提前停止——当验证集准确率连续 7 到 10 个 epoch 不再上升就保存模型退出,而不是跑满预设的 30 轮。早停的代码很简单:记录 best_acc,连续 N 轮没更新就 break。
4.3 文件夹里混入了损坏图片导致 DataLoader 报错
现象:训练到一半突然报错,说某个图片无法加载,或者 ImageFolder 初始化时直接抛 RuntimeError。
原因:数据集中存在损坏的图片文件,或者扩展名是 .jpg 但实际编码格式不是 JPEG。这在从网上下载的数据集里非常常见,尤其是用爬虫凑数据的情况。
解决:在划分数据集之前先扫一遍所有文件,用 PIL 验证能不能正常打开:
from PIL import Image import os bad_files = [] for root, dirs, files in os.walk("flower_photos"): for f in files: if f.lower().endswith((".jpg", ".jpeg", ".png")): path = os.path.join(root, f) try: img = Image.open(path) img.load() except Exception: bad_files.append(path) print("损坏文件数:", len(bad_files)) for p in bad_files[:10]: print(p)这段代码会列出所有打不开的图片。处理方式很简单:删除或者移动到 backup 文件夹。这个检查必须在划分数据集之前做,否则损坏文件被分到了训练集,模型又训练了半小时才发现,浪费时间。
4.4 显存溢出,调小 batch size 后效果变差
现象:batch size 从 32 降到 16 以后,模型训练到同样的 epoch 数,验证集准确率明显下降。
原因:batch size 变小意味着每个 batch 的梯度估计噪声变大。很多同学只降了 batch size 没降学习率,Adam 对 batch size 变化没那么敏感,但它依然是个暗坑——batch size 大的时候可以用大步长,batch size 小了步长太大反而震荡。
解决:如果显存不够,建议的调整顺序是:换更小的输入尺寸(224 降到 160 或 128,但会牺牲一点准确率);用梯度累积模拟更大的 batch;或者换 ResNet18 而不是 ResNet34。调整 batch size 后观察 loss 曲线,如果震荡明显,把 lr 同步往下降。如果用的是只训分类头的方案,batch size 对这个坑的影响会小很多,因为可训练参数少。
4.5 保存的模型文件加载后结果对不上
现象:训练时准确率 90%,重新加载 best_model.pth 做测试时准确率只有 40%。
原因:保存模型时没有同时保存类别映射。ImageFolder 的类别顺序是按文件夹名字典序排的,比如 daisy 在第 0 位、dandelion 在第 1 位。加载模型的机器上如果目录遍历顺序不同,预测结果和真实标签就对不上了。
解决:训练结束后把 class_to_idx 存成 json 文件:
import json from torchvision.datasets import ImageFolder dataset = ImageFolder("data/train") class_to_idx = dataset.class_to_idx with open("class_to_idx.json", "w") as f: json.dump(class_to_idx, f, indent=2)注意 class_to_idx 是基于训练集的目录。验证和测试集目录里的类别名必须和训练集完全一致,否则映射会错位。加载模型做推理时,先读这个 json 文件,再用同一个映射做预测,就能保证结果可复现。这是很多人做作业时忽略的细节,但在课程的验收演示环节非常容易出问题。
5. 从跑通到能交:设计报告的写法与答辩的四个高频追问
代码跑通只是完成了这个大作业的一半,另一半是把设计报告写到让老师相信你“真的理解了这个项目的每一行代码”。课程设计报告的常规结构是:问题定义、数据说明、模型设计、实验与结果、总结与展望。其中数据说明里放两张图——类别分布图和增强前后的对比图,就能撑起一页半的篇幅。
模型设计部分不要只贴一行 ResNet18 的调用代码。对比实验是拉开分差的关键:一个 MiniCNN baseline 加一个迁移学习主模型,两张训练曲线放在一起,标注出各自的最佳验证准确率,然后写一段“为什么迁移学习在小数据集上占优”的分析。这段分析里提 ImageNet 预训练特征对通用边缘纹理的抽象能力,再提 Fine-tuning 和特征冻结的差别——你理解了这个,老师就不会在答辩时卡你。
答辩时被问得最多的四个问题,提前准备好答案。第一,“为什么选 ResNet18 不选 ResNet50?”——数据集小、ResNet50 参数多更容易过拟合、显存和学生卡训练时间都有限制。第二,“数据增强为什么有效?”——增加了训练样本的表征多样性,降低了模型对翻转角度和拍摄位置的过拟合。第三,“log loss 曲线怎么解释?”——用你训练过程里保存的 loss 值画曲线,指出三个阶段:快速下降、平台期、微调期。第四,“你的模型哪些类容易混?”——把混淆矩阵里最差的 5 个类别拿出来解释,说你观察了这些类的花形相近、颜色分布统计相似。
收尾说一个我的习惯:做完这个项目后我养成了固定随机种子加记录所有超参数的习惯,包括每次实验的 lr、batch size、epoch、数据增强参数都记在一个 markdown 文件里。后面不管做课程作业还是实验室项目,回去翻这些记录都比翻聊天记录管用。报错时先查是不是数据的问题再查是不是模型的问题,这条排查顺序帮我省过不止一个下午。希望帮到你。
本文还有配套的精品资源,点击获取