最近有好几个同学问我同一个问题:毕业设计想做点有实际意义、又不至于太难收场的图像分类题目,什么方向比较稳?我一般会先反问一句,你自己平时有没有特别熟悉、特别容易获取数据的场景?如果对方犹豫,我就推荐一个老牌但永不过时的题目——CNN卷积神经网络识别花卉是否枯萎。这个题目听起来不像人脸识别那么高大上,但做起来特别顺手:数据可以自己拍、网上也能找到公开集,模型用Python写百来行就能跑起来,而且农业病害、植物养护、智能园艺这些场景都有真实需求,写论文也好找落地点。
这篇文章就把我实际做这个项目时踩过的坑、总结出来的套路,从头到尾拆给你看。如果你正准备把“CNN识别花卉枯萎”当毕设题目,或者想快速入门图像分类实战,可以直接按这套流程走,基本能避开我当年八成以上的麻烦。
1. 项目选题与整体思路拆解
1.1 为什么选花卉枯萎识别这个方向
毕设选题有个隐藏逻辑:题目本身不一定要多新颖,但一定要让评委觉得“有用”。纯的猫狗分类已经烂大街,人脸识别又涉及隐私问题容易踩到数据合规的坑,而花卉枯萎识别正好卡在一个既接地气又有点技术含量的位置上。
从实际场景看,花卉枯萎检测能落在三个很实在的落地上:一是家庭园艺场景,养花的人没法24小时盯着花,用手机拍一张叶子照片就能判断该不该浇水、是不是生病;二是温室大棚的自动化监控,结合摄像头定时巡检,发现枯萎迹象就触发提醒;三是鲜花电商和物流仓储,在打包发货前自动筛查品质。这几类应用写进论文的“研究意义”和“应用前景”里,比写“深度学习发展很快”这种空话要扎实得多。
从技术难度看,这个题目是个标准的二分类问题(枯萎/正常),但又不是简单到没得做。因为花卉的品种差异、光照条件、拍摄角度、背景复杂程度都会给模型带来挑战,所以你有充分的调参和优化空间,不至于三分钟跑完没东西可写。
1.2 CNN为什么适合这类图像分类任务
要回答这个问题,先想一下传统方法做枯萎识别是怎么做的:人工设计颜色特征、纹理特征、形状特征,比如判断叶子的绿色占比是不是下降、边缘有没有卷曲、茎干是不是发软。这些特征在特定条件下有效,一旦光照一变、品种一换,规则就崩了。
CNN的思路是把“特征设计”这件事直接交给网络自己去学。卷积核本质上是可学习的滤波器,第一层可能学到边缘、颜色块这种低级特征,第二层组合出纹理、斑点,更深的层就能感知到“整片叶子区域性枯黄”“叶脉纹路断裂”这种高级语义。我在项目中用的网络不到十层,但已经足够捕捉枯萎的核心视觉特征。
还有一个关键点是参数共享和局部连接。一张224x224的图有150528个像素,如果用全连接网络直接处理,光第一层就需要天文数字般的权重,而CNN通过滑动窗口共享卷积核,把参数数量压缩了几个数量级。这也是为什么用PyTorch单张消费级显卡就能训练这个小模型的根本原因。
1.3 整体技术路线规划
我最后确定的技术路线是下面这样,你完全可以照搬:
- 数据集准备:自己采集一部分,加上公开数据集补充,最终做成“正常”和“枯萎”两个文件夹。
- 预处理:统一尺寸、归一化、数据增强(翻转、旋转、色彩抖动等)。
- 模型搭建:用PyTorch从零写一个简单的CNN,或者为了对比实验再迁移学习一个预训练网络。
- 训练验证:交叉熵损失函数 + Adam优化器,训练50个epoch以内,全程记录准确率和损失。
- 测试评估:在独立测试集上算准确率、精确率、召回率,并画出混淆矩阵。
- 结果分析:可视化部分特征图,证明网络确实关注到了枯萎区域。
这个路线的好处是每步都有明确产出,拿到数据后最多两周能跑通全流程,剩下的时间可以用来调参、做消融实验、写论文。如果你基础薄弱,也能按这个流程循序渐进地推进,不会卡在某一步一下午没进展。
2. 数据集准备与预处理实战
2.1 数据集从哪里来,如何构建
很多同学看到“数据集”三个字就发怵,其实这个项目的数据获取比想象中容易。我建议优先级从高到低按这几种方式组合:
- 自己拍摄采集:找几盆绿植,比如绿萝、吊兰、多肉,先拍正常状态,然后停止浇水几天,每天固定机位拍一次,直到出现明显萎蔫。一盆花在不同状态下的照片就能贡献几十张样本。
- 植物病害公开数据集:有些公开数据集本身包含枯萎叶片,或者包含健康叶片和多种病害叶片,你把其中“健康”和“枯萎/病害”两类抽出来就能用。
- 网络图片爬取:用搜索引擎按关键词下载,但要人工筛选删除重复图、无关图、有水印的图。这个工作量不小,但反正毕设期间时间多,可以考虑。
我最终收集了800张图片,正常和枯萎各400张。这个数量对二分类从零训练的简单CNN来说不算宽裕,但配合数据增强完全够用。如果你是做迁移学习,甚至500张都能有不错的准确率。
2.2 图像标注与目录组织
标注这块不需要做成VOC或COCO那种带边框标签,因为咱们是图像级分类,不是目标检测。目录命名就是最简单的标签。
建议按下面的结构组织:
flower_data/ ├── train/ │ ├── normal/ # 正常花卉 │ └── wilted/ # 枯萎花卉 └── val/ ├── normal/ └── wilted/我习惯把整体数据的80%给训练集,20%给验证集。测试集可以另外从原始数据里划分,也可以在验证集上做最终评估。尽量保证同一株花在共处一个采集序列中的照片不要同时出现在训练和验证里,否则会有信息泄漏,指标会虚高。这个细节我在实际中吃过亏,后面会再说。
2.3 数据预处理与增强策略
预处理主要做两件事:统一尺寸和归一化。我用的输入尺寸是224x224,原因是大部分预训练模型都要求这个大小,以后如果想换迁移学习,代码不用大改。归一化用的是ImageNet的均值和标准差,这也是迁移学习的惯例。如果你从零训练,其实用简单的[0,1]归一化也可以,差别不大。
数据增强是重点。800张原图直接训练很容易过拟合,我用了这些增强手段:
- 随机水平翻转(概率0.5)
- 随机旋转(范围正负15度)
- 随机色彩抖动(亮度、对比度、饱和度变化量为0.2)
- 随机仿射变换(平移和缩放)
增强之后每个epoch模型看到的图都不一样,相当于变相扩充了数据集。但要注意旋转角度别调太大,因为花卉的枯萎特征和朝向有一定关系,旋转过头可能把“下垂的叶子”变成“正常的叶子”,引入错误信号。
2.4 数据加载与划分
PyTorch里用torchvision.datasets.ImageFolder配合DataLoader就可以非常方便地加载这种目录结构的数据。关键代码大概是:
from torchvision import datasets, transforms from torch.utils.data import DataLoader transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), 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]) ]) train_transform = transform 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]) ]) train_dataset = datasets.ImageFolder('flower_data/train', transform=train_transform) val_dataset = datasets.ImageFolder('flower_data/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=0) # Windows下建议固定为0 val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=0)这里有个小坑:ImageFolder默认将子目录按字母顺序排序映射为类别索引,所以normal是0,wilted是1。你后面算混淆矩阵、打印分类报告时要搞清楚顺序,别把标签搞反。我建议代码里显式打印一下train_dataset.classes,确认对应关系。
另外,Windows系统下DataLoader的num_workers如果设置大于0,经常会在某些Python版本上卡死或报错,稳妥起见我直接把num_workers=0,虽然慢一点,但不会莫名其妙出问题。训练数据量不大,完全可接受。
3. 卷积神经网络模型的设计与搭建
3.1 网络结构设计:从输入到输出的每一层
从零写一个CNN并不难,但结构要够用。我设计的网络是四层卷积加两层全连接,整个结构可以这样理解:前面几层卷积负责“看”,后面的全连接负责“判断”。
具体结构如下:
- 输入层:3 x 224 x 224
- 第一个卷积块:卷积层(3->16, kernel=3, padding=1),BN,ReLU,最大池化(2x2)。这里通道数从3变成16,空间尺寸减半到112x112。
- 第二个卷积块:卷积层(16->32, kernel=3, padding=1),BN,ReLU,最大池化。输出尺寸变为56x56。
- 第三个卷积块:卷积层(32->64, kernel=3, padding=1),BN,ReLU,最大池化。输出28x28。
- 第四个卷积块:卷积层(64->128, kernel=3, padding=1),BN,ReLU,最大池化。输出14x14。
- 展平后特征数是128 x 14 x 14 = 25088。
- 全连接层1:25088 -> 512,之后ReLU和Dropout(0.5)。
- 全连接层2:512 -> 2,输出两个类别的logits。
为什么要加BatchNorm?因为BN能对每层输入做归一化,让梯度传播更稳定,可以用相对较大的学习率,也能加快收敛。我在不加BN的时候训练经常出现损失震荡,加了之后稳定很多。
Dropout是专门为全连接层准备的防过拟合武器。全连接层的参数数量占了网络总参数的大头,很容易把训练集的细节“背下来”,Dropout通过随机失活一部分神经元,强迫网络学习到更鲁棒的特征。
3.2 使用PyTorch实现CNN模型
把上述结构用代码写出来,定义成一个nn.Module子类:
import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=2): super(SimpleCNN, self).__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Linear(128 * 14 * 14, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, num_classes) ) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) x = self.classifier(x) return x这段代码用nn.Sequential把层按顺序堆起来,整体很清晰。训练时计算损失需要用nn.CrossEntropyLoss,它会自动把最后的logits先做softmax再算交叉熵,所以前向输出不需要手动加softmax。这一点新手很容易出错:如果自己先对输出做了softmax,又交给CrossEntropyLoss,就会出现重复计算,导致训练曲线一开始是正常后面突然崩掉的情况。
3.3 模型参数与计算量分析
这个网络的参数量大概是多少?可以算一下:
第一个卷积层:3通道变16通道,卷积核大小3x3,参数为31633=432,加上BN层的均值和方差参数约32个,合计不超过500。第二个卷积层:163233=4608。第三个卷积层:326433=18432。第四个卷积层:6412833=73728。卷积层部分累计约97000个参数。
全连接层的参数才是大头:第一层是25088512,约12845056个参数,加上偏置就是1284万;第二层5122=1024。所以整个模型总参数大概1300万,占了内存的绝大部分。这也是为什么全连接层需要Dropout来防过拟合。
计算量方面,因为卷积层有池化不断缩小特征图,总FLOPs并不夸张,消费级显卡上训练一张图大概几十毫秒,完全没问题。不过如果你的机器没有GPU,纯CPU跑800张图、50个epoch,可能要几个小时起,建议先用小数据集跑通流程,再全量训练。
4. 训练配置、损失函数与优化器选择
4.1 损失函数:交叉熵背后的原理
像这种二分类任务,最常用的损失函数就是交叉熵。你不需要把公式背得滚瓜烂熟,但要理解它干了什么:对于每个训练样本,模型输出一个2维向量,经过softmax变成[0.3, 0.7]这样的概率分布,交叉熵只取真实类别对应的那个概率的对数再取负。如果真实类别概率是0.7,损失是-log(0.7)≈0.357;如果模型错得离谱,真实类别概率只有0.1,损失是-log(0.1)=2.3026。损失越大,模型越差。
PyTorch里直接用:
criterion = nn.CrossEntropyLoss()默认情况下它会把每个batch里的样本损失取平均。要注意一点,如果你的数据集类别不平衡(比如正常800张,枯萎只有100张),可以用带权重的交叉熵,给少数类更大的权重。我前期为了省事没这么做,结果模型把正常样本全猜对了,枯萎样本全军覆没,后来才补上类别权重。
4.2 优化器与学习率设置
优化器我选的是Adam,因为它的自适应学习率能在大部分情况下省去手动调学习率的很多环节。但Adam不是万能药,初始学习率设置不当同样会出问题。我做过几组测试,可以给你参考:
| 学习率 | 现象 |
|---|---|
| 0.01 | 训练震荡,验证集准确率在80%左右跳动,无法稳定 |
| 0.001 | 收敛平稳,约20个epoch达到90%以上准确率 |
| 0.0001 | 收敛过慢,30个epoch时才到85% |
所以我的经验是:先用lr=0.001跑,看前3个epoch的表现,如果损失下降太慢再适当调大,如果损失爆炸就调小。Adam的默认参数(beta1=0.9, beta2=0.999)一般不用动。
代码写法:
import torch.optim as optim optimizer = optim.Adam(model.parameters(), lr=0.001) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)这里加了一个学习率调度器,每10个epoch把学习率乘以0.5,让训练后期走得更稳。你也可以用CosineAnnealingLR,效果类似且更平滑,我这里图省事就用StepLR。
4.3 训练循环与验证逻辑
训练循环是所有代码里最容易写错的部分,关键是模型模式的切换、梯度清零、反向传播、参数更新这几步不要漏。我一般这样写:
def train_one_epoch(model, train_loader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 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() total_loss += loss.item() * inputs.size(0) _, predicted = torch.max(outputs, 1) correct += (predicted == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total验证函数要加with torch.no_grad(),并且一定要调用model.eval()。eval()会关闭Dropout和BatchNorm的统计更新,让模型用固定的均值和方差做推理。我见过有同学忘了切换eval(),导致验证时结果忽高忽低,就是因为Dropout还在“随机丢神经元”。
主循环同样很简单:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN(num_classes=2).to(device) num_epochs = 50 for epoch in range(num_epochs): train_loss, train_acc = train_one_epoch( model, train_loader, criterion, optimizer, device) val_loss, val_acc = validate(model, val_loader, criterion, device) scheduler.step() print(f'Epoch {epoch+1}/{num_epochs}, ' f'Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.4f}, ' f'Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.4f}')到第20个epoch左右,我的模型在验证集上已经能达到92%以上的准确率。这个数字对毕设来说已经相当能打了。如果你追求更高精度,现阶段投入产出比最高的做法不是继续调整从零训练的CNN,而是转向迁移学习。
5. 训练过程的可视化与调参经验
5.1 损失曲线和准确率曲线怎么读
训练不能光看命令行里输出的几个数字就完事,把曲线画出来才能看清模型的真实走向。我用matplotlib存了两类曲线:训练集和验证集的损失曲线、准确率曲线。
正常的曲线应该是这样的:训练损失持续下降,验证损失也同步下降;验证准确率稳步上升。如果你的验证损失在第20个epoch后开始反弹上升,而训练损失还在降,那就说明模型在过拟合——它开始把训练集的特征背下来,但对验证集越来越不友好。
画曲线时注意横坐标是epoch编号,纵坐标是每轮的平均值。建议每个epoch都记录下来,存成csv文件,后面写论文用图也方便。我当时把记录函数封装了一下,训练完自动生成图片,省了不少功夫。
5.2 过拟合的判断与应对
在800张原图上训练一个1300万参数的模型,其实很容易过拟合。我第二轮实验就见到了教科书式的过拟合:训练准确率达到99%,验证准确率却卡在83%。
应对办法按优先级来:
- 增加数据增强强度:把ColorJitter的系数从0.2提到0.3,旋转角度从15度提到20度。增强后验证准确率确实提升了。
- 加大Dropout比率:从0.5加到0.6,对全连接层过拟合有明显的抑制作用。
- 简化模型:减少一个卷积层或者把全连接层维度从512降到256。这招看似“退步”,实际上在数据量少时会改善泛化。
- 提前停止:验证准确率连续5个epoch不上升就保存当前最好模型并停止训练。我最后的做法是保存验证准确率最高的模型权重,而不是最后一轮的权重。
一定要养成“测试集只在最终评估时使用一次”的习惯。如果把测试集也拿来反复调参,测试集就变成了第二个验证集,最终报告的指标会有水分。这一点答辩时被老师追问会很麻烦。
5.3 学习率调整策略
学习率是整个训练过程中最重要的超参。Adam默认0.001很好用,但训练后期如果学习率固定,容易在小范围震荡,到达不了更优的极值。
我用StepLR,每10个epoch乘0.5,这样在第40个epoch时学习率已经降到了0.000125,收敛自然变慢但更精细。如果你的训练集很小,甚至不需要调度器,20个epoch就能收敛。关键是要观察曲线:如果第40个epoch时验证损失还在下降,说明还没到停止时机,可以把epoch上限扩大到70或80。
还有一个实用技巧:先跑一个3~5轮的小实验,用batch_size=64、学习率0.01,如果前几个batch的损失能从2.0附近明显下降,说明代码逻辑没问题,可以放心跑全量。如果一开始损失就卡在1.5左右不动,大概率是标签错了、学习率不对,或者是模型结构写错了,先debug再训练,别浪费时间跑50个epoch。
6. 常见问题与排查技巧实录
6.1 数据集太小,模型不收敛怎么办
初学者最容易遇到的问题就是损失不降。我排查的顺序是:
- 检查归一化。图像数据是否转成了Tensor?是否减均值除标准差?如果忘了归一化,输入值范围太大,梯度不稳定,损失会震荡或直接NaN。
- 检查标签。用
ImageFolder时,normal在0,wilted在1,如果训练集和验证集目录名一样,映射一致。一旦换目录或者用自定义Dataset,很容易把标签倒置。 - 检查学习率。0.001不行就改0.0001,0.01爆炸就改0.001。
如果数据集真的很小,比如只有200张,建议直接用迁移学习。加载预训练的ResNet18,替换最后一层全连接为2输出,冻结前面的层只训练分类头。这样即使数据很少也能有80%以上的准确率。PyTorch实现很简单:
import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) num_ftrs = model.fc.in_features model.fc = nn.Linear(num_ftrs, 2)注意输入尺寸仍是224x224,预处理需按ImageNet的标准化参数。这部分代码跑起来比自己写的四层CNN收敛快得多,准确率还能往上走5个百分点。
6.2 准确率高但泛化差
我遇到过一种情况:训练集和验证集都从同一批照片中随机划分,验证准确率95%,但用手机随手拍一张测试,表现就崩了。原因就是数据分布不一致,训练集和验证集太“像”了,模型其实没有学到真正的枯萎特征,只学到了背景、花盆颜色这些无关线索。
解决办法有几个:
- 把不同时间段、不同角度拍摄的照片划分到不同集合,保证验证集里的图片与训练集的拍摄条件有差异。
- 做交叉验证,每次留出一部分的拍摄批次做验证,而不是随机抽单张图。
- 测试集的采集最好换个环境、换一盆花、换手机,模拟真实使用场景。
这个问题的本质是数据划分信息泄漏。很多毕设论文里的准确率虚高,就是因为划分过于随意,答辩时被问“你的测试集怎么来的”就露馅了。这属于学术规范问题,一定要当心。
6.3 显存不足或训练速度慢
如果没有GPU,800张图训50个epoch确实有点煎熬。几个实用做法:
- 把batch_size从32改成16,显存占用减半,但训练次数会增多。
- 缩小输入尺寸到128x128,参数和计算量大幅降低,准确率损失不大。
- 使用AMP混合精度训练,PyTorch自带
torch.cuda.amp,能显著加速并减少显存占用。不过如果电脑太老,可能出现精度问题,谨慎使用。 - 如果你用的是CPU,建议把
torch.set_num_threads(4)调整一下,别让线程数爆炸。
还有一个小经验:别在训练过程中在循环里打印大量日志,控制台输出会拖慢速度。每5个epoch打印一次就够了,想记录细节就写到文本文件里。
6.4 类别不平衡问题
如果你的枯萎样本明显少于正常样本,模型会偷懒直接全部预测为正常,因为这样损失依然很低。解决办法:
- 数据过采样:在
DataLoader中给少样本类设置更大的采样概率。 - 使用加权交叉熵:
nn.CrossEntropyLoss(weight=torch.tensor([1.0, 2.0])),其中第二个类权重更高。 - 数据增强:对少数类做更强的增强,相当于人工扩增。
我个人是在确认数据集比例后直接把权重设成[1.0, 1.5],比起过采样更省事,效果也不错。
6.5 还有几个容易踩的坑
torch.max返回的_记得丢弃,别在验证逻辑里用错变量。- 保存模型时别只保存
model.state_dict(),最好连优化器状态、epoch、验证准确率一起打包成字典,方便断点续训。 - 使用
torchvision.datasets.ImageFolder时,目录名不要用中文,有些库对中文路径支持不好。 - 所有随机操作都要固定随机种子:
import random import numpy as np import torch def set_seed(seed=42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)这样保证实验结果可重复,写论文时更好的交代实验条件。
结尾的一点个人经验
这个项目我前后做了将近三周,最深的体会是:深度学习毕设的难点不在于模型有多高级,而在于把数据管好、把实验流程规范好。我用自己写的四层CNN拿到了92%的验证准确率,换成ResNet18迁移学习后到了96%。如果当初一上来就冲高精度用最好的预训练模型,反而会失去对CNN内部机制的理解,答辩被问到底层原理时容易卡壳。
所以我建议你先用简单网络把整个pipeline跑通,然后再考虑加预训练模型做对比实验。你自己从零写的网络哪怕准确率低一点,也是论文里最诚实的一块内容。最后再分享一个小技巧:一定要把训练过程中的最佳模型单独存一份,不要最后只保留最后一轮的参数。很多时候最佳验证准确率出现在第30个epoch,而最后一轮已经过拟合开始退步了。用最佳模型去测试,结果会好看不少。
希望这篇记录对正在做类似毕设的同学有点帮助。动手跑一次,比看十篇文章更有用。