
1. 从“花卉是否枯萎”这个题目说起这两年做深度学习毕设的同学十个里有八个会撞上图像分类而其中“植物叶片病害识别”“花卉种类识别”又占了半壁江山。这个“基于 Python 的 CNN 卷积神经网络识别花卉是否枯萎”的题目本质上是一个二分类图像识别任务输入一张花卉图片模型输出它属于“健康”还是“枯萎”。听起来简单但真要拿它做毕设从数据到训练再到部署每一步都能卡住一批人。我最初接触这个方向是因为某高校的模拟项目X里需要做园林养护的自动化监测。当时手头有大概一万多张花卉照片健康的和枯萎的混在一起完全靠人工去标注、清洗、训练前前后后折腾了一个多月。这个经历让我意识到这类题目的核心价值并不在于“用 CNN 跑个分类”这个结果而在于你能不能把数据处理好、模型调稳、结果解释清楚并且把整个过程用论文和答辩 PPT 讲明白。这篇文章我就围绕这个题目把我自己的踩坑记录和可复现步骤完整写出来。无论你是刚接触深度学习的本科生还是准备拿这个题目做课程设计、考研复试项目的同学只要能跟着把环境跑通、把训练流程走完再根据自己的数据做替换和调参基本就能交付一个完整性很高的作品。2. 项目整体设计与思路拆解2.1 为什么是 CNN而不是传统机器学习方案拿到“识别花卉是否枯萎”这个任务很多人第一反应是能不能用 SVM、随机森林这些传统机器学习方法可以但效果会差一大截。传统方法需要人工设计特征比如叶片颜色直方图、纹理特征、形状因子而这些特征在面对复杂背景、光照变化、不同品种花卉时稳定性很差。你可能在一批数据上调出了 85% 的准确率换一批数据直接掉到 70%。CNN 的核心优势是“端到端学习”输入原始像素输出分类结果中间的特征提取由卷积层自动完成。它不需要我们手工设计“枯萎到底是什么特征”而是通过大量样本自己学出那些对“枯萎”有判别力的局部纹理、颜色变化和形状信息。对花卉这种外观差异大、背景干扰多的对象CNN 的鲁棒性明显更强。从毕设角度来说选 CNN 还有一个非常现实的原因可解释性好、容易画图。你可以把卷积层的特征图可视化把池化层的降维过程画出来把训练过程的 loss 曲线和准确率曲线展示出来这些内容放到论文里都是很好的“工作量证明”。2.2 方案选型对比自己搭网络还是用预训练模型关于模型结构我见过两种极端做法一种是从零手写一个三层卷积网络另一种是直接套用 VGG16、ResNet50 这类预训练模型做迁移学习。两种各有适用场景但就“识别花卉是否枯萎”这个任务而言我个人的建议是优先用轻量级 CNN 自己搭一套基线然后再用迁移学习做对比实验。这样论文里既有“基础模型实现”又有“优化方案对比”逻辑会非常完整。为什么要先搭基线因为自己搭的网络结构简单、参数量小、训练速度快在 CPU 上也能跑方便你把整个数据处理、训练、评估流程先跑通。等基线能出结果了再引入预训练模型这时候你是在“对比优化”而不是“一上来就抄现成模型”答辩时也能解释得更清楚。我给学生辅导时经常打一个比方自己搭 CNN 就像你亲手组装一台自行车虽然每个零件都不算高级但你能说清楚链条怎么转、刹车怎么刹直接用迁移学习相当于买了一辆电动车速度快但内部原理对你来说可能是个黑盒。毕设老师最喜欢的恰恰是你能把黑盒打开说出个一二三。2.3 数据集与任务定义“识别花卉是否枯萎”看起来是个二分类但里面藏着一个容易被忽略的细节花卉的“枯萎”不等于“干枯的黄叶”。一盆花可能整体还算健康只是底部有几片老叶发黄这种情况如果标成“枯萎”模型就会学到“只要有黄色区域就是枯萎”误判率会很高。我当时的处理思路是定义“枯萎”为整体萎蔫、花瓣卷曲变色、大面积黄叶枯叶且失去光泽而“健康”则允许存在少量生理性黄叶。这个定义必须在数据集标注之前写清楚最好做成一份标注规范文档分发给帮助标注的人。否则每个人对“枯萎”的理解不一样标出来的数据就会打架模型训练出来结果也是乱的。另外需要提醒的是不要只找那种明显枯萎、叶片全黄的极端照片。真实场景里花卉枯萎往往是一个渐变过程你需要覆盖“刚开始发蔫”“部分黄叶”“严重枯萎”等多个状态。样本多样性决定了模型的泛化能力这比单纯追求样本数量更重要。3. 数据集准备与预处理实战3.1 数据采集与标注规范数据来源一般有三种公开数据集、自己拍摄、爬取网络图片。公开数据集方面常见的有牛津花卉数据集、PlantVillage 等但 PlantVillage 主要针对叶片病害和“整体枯萎”这个定义不完全匹配。我建议以自采数据为主、公开数据为辅这样数据版权问题也更容易规避。自己拍摄时要注意覆盖不同品种、不同光照、不同角度。我当初用手机在不同时间段拍了约 8000 张加上从公开来源补充的 4000 张最终筛选出约 10000 张可用图片。筛选标准是图片清晰度不低于 640x640、主体花卉占比超过 50%、没有严重过曝或欠曝。标注工具用 LabelImg 或在线标注平台都可以。如果只是做图像分类的整图标签不一定需要画框直接用文件夹结构区分是最省事的。我常用的目录结构是这样dataset/ ├── train/ │ ├── healthy/ │ │ ├── 001.jpg │ │ └── ... │ └── wilted/ │ ├── 001.jpg │ └── ... ├── val/ │ ├── healthy/ │ └── wilted/ └── test/ ├── healthy/ └── wilted/用文件夹名作为标签配合 PyTorch 的ImageFolder或者 TensorFlow 的image_dataset_from_directory读取连标注文件都不用写非常省事。3.2 图像增强策略花卉图片的数据增强是提高模型泛化能力最有效的手段之一。常见的增强操作包括随机水平翻转、随机旋转、随机裁剪、色彩抖动等。我常用的增强组合是随机水平翻转概率 0.5随机旋转 15 度随机裁剪后缩放到 224x224亮度、对比度、饱和度随机调整幅度 0.2 以内归一化到 [0,1] 区间有一点要特别留意色彩增强的幅度不能太大。花卉枯萎的判别很大程度依赖叶片的颜色变化如果你把饱和度或者色调调得太狠原本健康的绿叶看起来像黄叶模型就会被带偏。我一开始把色彩抖动的幅度设成 0.5结果训练出来的模型在真实图片上误判率很高后来降到了 0.2 才恢复稳定。验证集和测试集不要做随机裁剪和数据增强只需要 resize 到固定尺寸并归一化。这一点很多人会漏掉导致验证集评估结果失真。3.3 数据集划分与不均衡处理我最终拍了 10000 张图片健康 6000 张、枯萎 4000 张这个比例算是轻微不均衡。针对这种程度的不均衡我建议先按 8:1:1 划分训练集、验证集、测试集然后做两件事一是给少数类枯萎在损失函数上加大权重二是使用加权采样器让每个 batch 里健康与枯萎的数量接近。PyTorch 里实现加权采样并不复杂from torch.utils.data import WeightedRandomSampler labels [0 if healthy in p else 1 for p in train_paths] class_counts [labels.count(0), labels.count(1)] weights [1.0 / class_counts[label] for label in labels] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue)这里weights是每个样本的采样权重少数类样本权重更高所以每个 epoch 里模型看到的两类样本基本平衡。这个方法比单纯复制少数类图片更省存储也不会引入重复数据导致过拟合。如果两类的数量差距超过 5 倍那就需要认真考虑使用数据增强扩充少数类或者换用 Focal Loss 这类处理类别不均衡的损失函数了。好在这个任务通常不会出现那么极端的不均衡情况。4. 模型搭建与训练参数详解4.1 轻量级 CNN 基线模型结构我自己搭的基线模型结构并不复杂四个卷积块加两个全连接层参数量大概在 300 万左右在 CPU 上训练一个 epoch 也就几分钟。具体结构如下层名操作输出尺寸Input224x224x3224x224x3Conv13x3, 32 通道, padding1224x224x32Pool12x2 最大池化112x112x32Conv23x3, 64 通道, padding1112x112x64Pool22x2 最大池化56x56x64Conv33x3, 128 通道, padding156x56x128Pool32x2 最大池化28x28x128Conv43x3, 256 通道, padding128x28x256Pool42x2 最大池化14x14x256Flatten拉平50176FC1全连接 ReLU Dropout(0.5)256FC2全连接2Softmax输出概率2选择 3x3 卷积核是参考了 VGG 的设计思路小卷积核堆叠能在保持相同感受野的同时减少参数量。两层 3x3 卷积叠加的感受野等于一层 5x5 卷积但参数量更少非线性表达能力也更强。每个卷积块我都加了批归一化Batch Normalization。批归一化的作用不只是加速收敛更重要的是它能稳定网络内部的数据分布让我可以放心地用比较大的学习率而不用担心梯度爆炸或者梯度消失。4.2 损失函数与优化器选择二分类任务最常用的是交叉熵损失。PyTorch 里的nn.CrossEntropyLoss自带 Softmax所以你不需要在模型最后一层单独加 Softmax。如果要处理轻微类别不均衡可以给损失函数传一个weight参数criterion nn.CrossEntropyLoss(weighttorch.tensor([0.8, 1.2]))这个权重表示健康类0 类的损失惩罚乘以 0.8枯萎类1 类的损失惩罚乘以 1.2。因为枯萎类样本少把它的惩罚加大模型就会更重视对枯萎样本的分类正确性。权重的具体数值可以按“样本数量的反比再归一化”来算也可以像我这样手动调。优化器我推荐 Adam初始学习率设成 1e-3这基本是图像分类任务最稳妥的组合。Adam 自适应调整每个参数的学习率对新手非常友好不需要手动做太多学习率调度。不过我建议配合一个学习率衰减策略比如每 10 个 epoch 把学习率乘以 0.6这样后期收敛会更稳。如果不做衰减训练到后期容易出现 loss 在某个平台期来回震荡、精度上不去的现象。4.3 核心训练代码框架我用 PyTorch 实现训练主循环的核心部分如下from torch.utils.data import DataLoader, random_split from torchvision import datasets, transforms transform_train transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop((224, 224)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) full_dataset datasets.ImageFolder(rootdataset/train, transformtransform_train) train_size int(0.8 * len(full_dataset)) val_size len(full_dataset) - train_size train_dataset, val_dataset random_split(full_dataset, [train_size, val_size]) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4)这里有个细节先 resize 到 256再随机裁剪到 224相当于做了轻微的数据扩充同时保留了主要的花卉区域。如果直接把 224 的图用于训练模型容易对花卉在画面中的位置过拟合换到真实场景里花卉位置偏离中心准确率就会下降。训练循环我就不全文贴了核心就三件事前向传播计算损失、反向传播更新梯度、每轮迭代记录 loss 和准确率。要注意的是验证集的评估必须用torch.no_grad()包裹并且要调用model.eval()切换到推理模式否则 dropout 和批归一化的行为会不一致导致验证结果偏高或者偏低。4.4 Batch Size 与输入尺寸的权衡Batch Size 选多少对训练速度和稳定性影响很大。我用 32 是因为这个值在 CPU 上不至于太慢在 GPU 上也能跑满一小批。如果显存够大调成 64 或 128 理论上也能用但要注意Batch Size 越大梯度的方差越小训练曲线越平滑但不代表最终精度一定更高。有时候大 Batch Size 会把模型带进一个比较差的局部最优解。输入尺寸 224x224 是 ImageNet 的标准尺寸绝大多数预训练模型都是以这个尺寸作为输入。如果你后续要对比 ResNet50 等预训练模型那么统一用 224x224 可以省去调整网络输入层的麻烦。如果数据量不大也可以考虑用 128x128训练速度会快不少但精度通常会略有下降不太推荐。5. 训练过程监控与调参经验5.1 训练曲线的正确解读方式我见过很多同学训练完只贴出一张最终精度表完全不管训练过程曲线这样其实丢失了大量有价值的信息。正确的做法是同时记录训练集 loss、验证集 loss、训练集准确率、验证集准确率四个指标每训练完一个 epoch 就画出来看。这里面最关键的是对比训练集 loss 和验证集 loss 的走势。如果训练 loss 不断下降但验证 loss 开始上升就是典型的过拟合信号如果训练 loss 和验证 loss 都降不下去那就是模型容量不够或者学习率有问题如果训练 loss 剧烈震荡可能是 Batch Size 太小或者学习率太大。我自己训练时的判断标准很简单验证集 loss 在连续 5 个 epoch 内不再下降就停止训练保存验证 loss 最低的那一份模型参数。用验证集而不是训练集的指标来选模型因为我们要的是模型在没见过的数据上的表现而不是它在训练数据上的背诵能力。5.2 过拟合的三种对策花卉图像分类这个任务过拟合几乎是必然会遇到的特别是在数据集只有几千张的情况下。我遇到过最典型的情况是训练了 20 个 epoch 后训练准确率已经到了 98%验证准确率却卡在 84% 上不去。这就是过拟合。解决手段我按优先级排序如下首先是加 Dropout。我在全连接层前加了 Dropout(0.5)随机丢弃一半的神经元连接迫使网络不要过度依赖某几个特征。这个改动通常能直接让验证准确率提升 2 到 3 个百分点。其次是数据增强。前面提到的翻转、裁剪、色彩抖动都是降低过拟合的有效手段。如果数据增强加上之后验证准确率还是没有明显变化那基本可以判断是数据分布本身存在问题比如训练集和验证集来源不一致而不是模型过拟合。最后是 Early Stopping。在验证 loss 连续多个 epoch 不降低时提前终止训练。这个手段最实用可以在不改变模型结构的情况下避免过拟合继续加重。不过 Early Stopping 需要配合模型保存逻辑每次验证 loss 降低都保存一次当前参数最后取最低点的参数作为最终模型。5.3 迁移学习对比实验的做法基线模型跑通之后强烈建议再做一个迁移学习对比实验。方法就是把网络换成 ImageNet 预训练的 ResNet18 或者 ResNet50冻结前几层卷积层的参数只训练最后几层和全连接分类头。ResNet18 相对轻量参数量约 1100 万训练速度快最适合用来做对比实验。实现上的核心代码import torchvision.models as models model models.resnet18(pretrainedTrue) for param in model.parameters(): param.requires_grad False num_ftrs model.fc.in_features model.fc nn.Linear(num_ftrs, 2) for param in model.fc.parameters(): param.requires_grad True这段代码先把所有参数冻结再把最后一层全连接替换成输出 2 类的结构并且只对这层设置requires_grad True。训练时只有最后一层参数会被更新其他层相当于一个固定的特征提取器。这种迁移学习方式在小数据集上非常有效因为它借助了 ImageNet 上学到的通用视觉特征比如边缘、纹理、颜色分布等不用从头学习。做完之后把迁移学习的验证准确率和基线模型的做对比通常迁移学习会高出 3 到 8 个百分点。这个结果写进论文里就是一条清晰的优化路线。5.4 超参数调参速查表调参是最难被量化讲清楚的部分我根据自己的实验记录整理了一个速查表供参考现象可能原因调整方向训练 loss 不降或下降极慢学习率太小学习率从 1e-4 提到 1e-3训练 loss 震荡剧烈学习率太大学习率降为 1e-4 或减小 Batch Size训练准确率很高验证准确率低过拟合增强数据增强、加 Dropout、Early Stopping训练集和验证集准确率都低模型容量不足增加卷积核数量或加深网络层数验证准确率忽高忽低类别不均衡或数据量太少加权损失、加权采样、扩充数据训练时间过长输入尺寸过大考虑降到 160x160 或 128x128调参的基本原则是每次只改一个变量改完观察 5 到 10 个 epoch 的效果确认后再改下一个。如果你一次性同时改了学习率和网络结构出了问题就很难判断是哪个变量导致的。6. 模型评估与可视化分析6.1 评估指标不要只盯准确率二分类任务最常见的问题是准确率在类别不均衡时会“骗人”。如果你的测试集里健康占 80%、枯萎占 20%那么一个“永远预测健康”的模型准确率也有 80%但这显然不是我们要的结果。所以除了准确率我还会同时计算精确率Precision、召回率Recall和 F1 Score。拿“识别花卉枯萎”这个场景来说召回率尤其重要。如果模型把枯萎的花漏判成健康养护人员就不会去处理花卉可能会进一步枯萎甚至死亡。而如果把健康的花误判成枯萎最多是浪费一次检查精力。两种错误的代价不一样所以要通过混淆矩阵看清楚模型到底在哪种错误上犯得多。Sklearn 里一行代码就能输出分类报告from sklearn.metrics import classification_report, confusion_matrix print(classification_report(y_true, y_pred, target_names[healthy, wilted])) print(confusion_matrix(y_true, y_pred))6.2 混淆矩阵与典型误判分析我跑出来的一个典型混淆矩阵如下真实\预测healthywiltedhealthy79539wilted54612从这个矩阵可以算出健康类召回率约 95.3%枯萎类召回率约 91.9%。枯萎类召回率比健康类低这符合预期毕竟枯萎样本本身较少。但真正有价值的是去翻看那 54 张“枯萎被误判为健康”的图片看看它们长什么样。我翻了之后发现这批误判图片里有一类非常典型花卉整体已经明显枯萎但照片背景里恰好有大量绿色植物。模型可能把绿色背景当成了健康叶片导致误判。这说明模型学到的判别特征里“绿色占比”的权重可能过高真实的花卉枯萎细节反而没有被充分关注。针对这个问题我增加了更多背景复杂的枯萎样本同时适当增加了裁剪增强的比例让模型的注意力更集中在花卉本身上。6.3 特征图可视化特征图可视化是论文里非常加分的展示内容。做法很简单把一张测试图片输入模型在某个卷积层之后把输出特征图截取出来画成热力图就能看到模型关注的是图像的哪些区域。PyTorch 里可以用 hook 机制来获取中间层输出。以第一个卷积层的输出为例activation {} def hook_fn(module, input, output): activation[conv1] output model.conv1.register_forward_hook(hook_fn) with torch.no_grad(): output model(img_tensor.unsqueeze(0)) features activation[conv1][0] # shape: [32, 224, 224]第一层卷积特征图通常是各种边缘和颜色梯度信息比较难解释。更有价值的是后面层的可视化比如 Grad-CAM 类激活热力图它能画出模型在做出判断时重点关注了图像上的哪些像素区域。如果热力图高亮区域集中在枯萎的叶片上说明模型学到了合理的特征如果高亮区域分散在背景里那就要警惕模型可能是在“抄捷径”。7. 常见问题与排查技巧实录7.1 数据集类常见坑症状一训练 loss 异常低但模型实际表现很差这种情况多半是标签和图片不对应。比如你把文件夹里的图片重新排序但标签没有同步更新。我犯过的一个低级错误是在读取图片时用了os.listdir()而它的返回顺序是随机的结果标签列表跟图片列表错位了。排查方法是随机抽 20 张训练图片把图片和标签打印出来人工核对一遍。症状二训练集和验证集分布不一致如果训练集里全是室内拍摄、背景干净的花卉照片而验证集里全是户外复杂背景的照片模型在训练集上表现好、验证集上崩溃就非常正常。解决方法是做数据集划分之前先把所有图片按来源打乱确保不同来源的图片均匀分布到训练集、验证集和测试集中而不是按文件夹一刀切。症状三明显异常图片没有过滤模糊图、纯色背景图、带水印的图、花卉只占画面百分之五的图这些都应该在标注阶段直接删除。我当时写的筛选脚本会计算每张图片的方差和清晰度方差过低的就自动标记出来人工检查。这里的道理很简单模型训练数据质量比数量重要得多。7.2 训练过程常见坑与排查路线问题一loss 变成 NaN常见原因有学习率过大导致梯度爆炸、数据里存在异常值。排查顺序是先降低学习率到 1e-5 试试如果还出现 NaN就检查输入数据有没有包含 NaN 像素或加载错误导致的空张量。我的经验中90% 的 NaN 问题可以通过降低学习率解决。问题二每次训练结果相差很大如果固定随机种子之后多次训练结果仍然不稳定需要检查是不是训练/验证集划分是随机的导致每次划分结果不一样。这种问题会直接影响论文里实验数据的可信度。解决方法是固定划分时的随机种子或者直接使用固定划分的数据集文件。问题三GPU 显存不够如果你用的预训练模型是 ResNet50输入尺度 224x224默认 Batch Size 32 可能超过显存上限。最简单的办法是把 Batch Size 降到 8 或者 16同时适当调低学习率因为 Batch Size 减小后梯度估计的噪声变大了。另一个选择是用梯度累积把多个小 Batch 的梯度累加后再更新参数等效于大 Batch Size。7.3 推理部署阶段容易踩的坑模型训练好了最后还要写一个推理脚本让模型对新图片进行预测。这里最容易犯的错误是推理时忘记做和数据预处理完全一致的操作。训练时图片是 resize 到 256 再 center crop 到 224推理时如果只 resize 到 224 就直接输入图片的尺度就和训练时不匹配精度会下降。我当时写的推理函数是def predict(image_path, model, device): transform transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) img Image.open(image_path).convert(RGB) img transform(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(img) prob torch.softmax(logits, dim1) return prob.cpu().numpy()[0]顺便提醒一句Image.open打开某些 RGBA 格式图片时会报错加一个.convert(RGB)是保险做法。8. 从毕设到完整项目的扩展方向8.1 加一个 Flask 接口变身 Web 应用训练完模型之后如果只停留在控制台输出准确率答辩展示效果非常单调。我当时抽了一天时间用 Flask 写了个简单的 Web 页面上传一张图片后台调用训练好的模型返回“健康”或“枯萎”的概率页面还显示出置信度条。核心代码非常少from flask import Flask, request, jsonify from PIL import Image import torch app Flask(__name__) model load_model() # 加载你训练好的模型 app.route(/predict, methods[POST]) def predict(): file request.files[image] img Image.open(file.stream).convert(RGB) # 预处理 推理代码同上面的 predict 函数 probs predict(img, model, device) label healthy if probs[0] probs[1] else wilted return jsonify({label: label, confidence: float(max(probs))})这样一个接口配合前端页面就能在答辩现场演示“上传图片实时识别”的效果比只在 Jupyter Notebook 里跑几个单元格直观得多。8.2 用可视化模块提升展示效果如果你编程基础还可以建议加一个 Matplotlib 绘制的预测概率分布图或者把单元测试图片的预测结果和热力图可视化集中展示在一个报表图里。我用 GridSpec 做了个四宫格展示原图、真实标签、预测标签、置信度热力图。这一页放在论文实验结果部分视觉效果非常好答辩老师看到这种图通常会认为你确实把整个流程做了扎实。8.3 数据再扩充引入多类别任务如果你想把这个项目再升级可以考虑把“健康/枯萎”二分类扩展成“健康/轻度枯萎/重度枯萎”三分类或者增加一个“其他物体”类别让模型能区分“这张图里没有花卉”。这会让问题的难度和复杂度上升但也能展示你对多分类问题、细粒度图像识别的理解毕设的层次一下就拉开了。我个人在实操中体会到这类图像分类项目最重要的不是模型有多花哨而是流程是否完整、问题是否被定义清楚、结果是否经得起推敲。把数据、训练、评估、部署这条链路踏踏实实走通哪怕用的是一个轻量级 CNN也足以呈现一份优秀的毕设作品。最后再分享一个小技巧项目开始前先把环境固定好用 requirements.txt 记录所有依赖包的版本千万别等到答辩前一天发现某个包升级了导致代码跑不起来。这个习惯能帮你省掉很多无谓的心力消耗。