ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python与CNN的花卉绽放识别:从数据标注到模型训练全流程解析

基于Python与CNN的花卉绽放识别:从数据标注到模型训练全流程解析

识别花卉是否绽放,本质是“花有没有开”的二分类问题。很多准备做计算机毕业设计的人,第一眼看到“基于Python的CNN卷积神经网络深度学习识别花卉是否绽放”这个题目,都以为就是建两个文件夹,一类放绽放的花,一类放花苞,然后丢进深度学习框架里训练。听起来很轻松,实际把整个流程跑通之后才会发现,这个题目最麻烦的并不是卷积神经网络本身,而是大量“半开不开”的花要怎么标、怎么学。我按实际调试的路线,从问题定义、数据整理、网络设计、训练调参,一直讲到评估和答辩展示,尽量做到可以直接照着做、能复现,也能在原有基础上改出自己的东西。

1. 选题拆解:为什么“是否绽放”比“是哪种花”更难

在写模型之前,最值得先做的事是搞清楚这个题目到底在解决什么问题。如果不把问题定义清楚,后面所有数据标注和训练都是糊涂账。

1.1 给“绽放”一个可量化标准

花从花苞到盛开是一个连续发育过程,中间夹着大量半开、侧开、微开的状态。如果让不同的人标注,对“半开”的判断很容易产生分歧。比如花瓣已经裂开缝隙,但花心还完全包着,这算绽放还是没绽放?有人觉得“已经开了”,有人觉得“还没完全开”。如果不统一口径,数据集的标签就会互相矛盾,模型训练时一会儿学A一会儿学B,效果自然好不了。

我在实际项目中采用的口径是:花瓣大部分展开、花心清楚可见,才算“绽放”;花瓣仍包住花蕊,或者只有边缘裂开一条缝,都算“未绽放”。已经出现萎蔫、垂头、颜色发暗的残花,也要归到“未绽放”这一类。这个标准不算完美,但好处是足够清晰,标注者拿到规则后能快速上手,一张图几秒钟就能判断。实测下来,明确标注规范之后,不同标注者之间的分歧率能明显下降。这个细节很多人忽略,但它直接决定数据质量,而数据质量又决定CNN模型的天花板。

1.2 边界样本不是噪音,是训练信息

另一个容易踩的坑是:看到“半开不开”的图就扔掉,觉得这是脏数据。实际上,这些边界样本恰恰是整个题目最有价值的部分。如果模型只见过“完全绽放”和“完全没绽放”两种极端情况,那它在真实场景里遇到半开的花,大概率会懵。

正确的做法是把边界样本单独分一个子集,用来观察模型输出概率。比如某张半开花,模型预测绽放概率是0.42,离0.5比较近,说明它知道这朵花有点特殊但没有把握。如果你看到一批这样的照片概率都集中在0.4到0.6之间,说明模型没有真正学到花从闭合到展开的形态变化,很可能是在靠颜色、背景纹理硬猜。这时候不要急着调网络结构,先回去补数据和统一标注更有效。这和“哪种花”的分类不一样,花品种分类拼的是花形、叶形、颜色组合的差异,而“是否绽放”拼的是同一朵花在不同发育阶段的细微形态差异,后者对数据分布更敏感。

2. 数据集:标注策略和脏数据才是第一个拦路虎

数据是这类小项目的生命线。模型用什么结构、用什么优化器,都是后话,先把数据集弄得干净、分布合理,CNN才有东西可学。

2.1 图像收集路线:公开数据集与自拍结合

我建议不要只依赖某一种数据来源。公开花卉数据集有一个典型问题:它通常是为了“品种分类”而整理的,同一朵花会从多个角度拍好几张,但“是否绽放”这个标签不一定齐全。所以更稳妥的组合方式是:取一部分公开数据集里明确能区分开合状态的图片,再自己拍摄一批花苞、半开、盛开、凋谢的照片补进去。

自己拍的时候需要注意三件事。第一是同一棵花的拍摄时间尽量拉长,比如从花苞期到盛花期每隔两三天拍一组,这样能覆盖连续的中间状态。第二是背景要多样化,不要总在同一个花坛同一个角度拍,路边的、阳台上的、楼下的,颜色和光线都要换。第三是数量要平衡,绽放和未绽放两类图片尽量各在一千张以上。如果某一类只有两三百张,模型很容易偏向样本多的类别。图片格式统一成JPG,尺寸不用太大,训练前统一缩放即可。

2.2 标注规范和复核机制

文件目录要按类别组织,比如训练集里放blooming和not_blooming两个子目录,这样很多框架用ImageFolder就能直接读。真正影响质量的是标注规范本身。我写过一个简单的“绽放判定手册”,大概内容就是:花瓣是否展开、花心是否可见、有没有明显萎蔫;符合前两条就标绽放,否则标未绽放。手册配了十张示例图,标注时照着示例看,效率很高。

标注完成后,我通常还会抽一百张图让另一个人独立标一遍,然后跟原始标签对比。如果一致率低于95%,就把不一致的图挑出来重新讨论,看是标准不够清晰还是图片本身太模棱两可。这个过程虽然麻烦,但能让训练集和验证集之间的判断逻辑一致。很多人训练时发现验证集准确率忽高忽低,其实不是模型不稳定,而是标签本身在“打架”。

2.3 数据增强和按“个体”划分数据集

数据增强方面,我用的是随机水平翻转、小角度旋转、随机裁剪缩放、亮度对比度抖动、饱和度抖动。这里有个度的问题:花瓣颜色本身是很重要的特征,所以饱和度抖动不要调得太狠,否则模型会把“颜色鲜艳”和“绽放”错误地绑定。随机旋转角度控制在正负20度以内,太夸张的旋转在花卉摄影里不常见。

更关键的是划分方式。如果同一朵花的照片同时出现在训练集和验证集里,模型相当于提前“见过”了这个个体,验证准确率会虚高不少。正确做法是先按花的个体做分组,同一株花、同一组连拍的照片尽量放进同一个集合,再按大致7:2:1划分训练、验证、测试。比如一朵月季从花苞到盛开拍了五张,这五张要放在同一侧,而不是随机散落到各个集合。我见过网上不少教程对这一点完全没有交代,实际做下来,随机划分和按个体划分的验证集准确率可以差五到十个百分点,这个差距在毕设答辩里很容易被问住。

3. 模型设计:从零搭建CNN还是迁移学习

数据集准备好之后,模型这一环就很清晰了。我同时做了两个版本:一个是自己搭的轻量CNN,用来讲原理、跑通流程;另一个是迁移学习版本,用来刷指标和做对比实验。

3.1 自建轻量CNN的结构与解释

自建CNN的好处是每一层都知道它在干什么,答辩的时候被问参数含义不会慌。我用的是一个非常朴素的结构:三层卷积,每层后面接BatchNorm和ReLU,用最大池化降分辨率,最后接全局平均池化和一个输出单元。输入图片统一缩放到224x224。

import torch import torch.nn as nn class BloomNet(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 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.global_pool = nn.AdaptiveAvgPool2d((1, 1)) self.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(128, 1) ) def forward(self, x): x = self.features(x) x = self.global_pool(x) x = x.flatten(1) x = self.classifier(x) return x

这个网络参数很少,CPU也能训练,特别适合毕设演示。三次最大池化会把224x224的图逐步降到28x28,全局平均池化之后得到128维特征,再全连接到1个神经元。BatchNorm的作用是让每层输入分布稳定,加快收敛;Dropout则是防止分类头过拟合的一种常规手段。如果你后续想加内容,可以在最后一层卷积后再堆一层卷积,或者把全连接改成两个全连接,灵活性很高。

3.2 迁移学习:什么时候用、怎么冻结

自建CNN在上千张数据量下能跑到大约88%到92%的准确率,但再往上会感觉吃力。如果想让指标更好看,我建议做一版迁移学习对照实验。用预训练在大型图像数据集上的残差网络作为骨干,把最后分类头换成1个输出节点即可。这个思路简写如下:

import torch.nn as nn from torchvision import models backbone = models.resnet18(weights=models.ResNet18_Weights.DEFAULT) backbone.fc = nn.Linear(backbone.fc.in_features, 1)

数据量不大时,不要一上来就全量微调。我的建议是冻结骨干的前几层,只训练后面部分和分类头。比如把骨干的前5个模块参数requires_grad=False,后面的层保持可训练。这样模型既能利用预训练学到的边缘、纹理、形状特征,又不会因为小数据而过拟合。如果数据量到了一两千张以上,可以考虑全量微调,但要把学习率调小,通常是1e-4到1e-5量级。

同样是跑二分类,小样本场景下迁移学习通常比自建CNN高3到8个百分点,稳定性也会更好。但从理解原理的角度,我始终建议先搭一版自建CNN,把loss降下去、把整个流程跑通,再切换到迁移学习。这样答辩时你能讲清楚两套方案的差异,而不是只会调接口。

3.3 输出层:为什么最后一层只有一个神经元

很多初学者习惯性用两个神经元加Softmax做二分类。这个做法没有错,但这个任务我用的是单神经元加Sigmoid:输出的是一个0到1之间的绽放概率,大于0.5判为绽放,小于0.5判为未绽放。在PyTorch里,训练时配合BCEWithLogitsLoss,不需要自己手动计算Sigmoid,数值上更稳定。

具体来说,模型输出的原始值叫logit,不是概率。训练时直接用nn.BCEWithLogitsLoss()(logit, target)计算损失,推理时用torch.sigmoid(logit)转换成概率。标签用0和1表示,1代表绽放,0代表未绽放。这是二分类里很标准的做法,处理代价低,调阈值也方便。

4. 训练阶段:损失函数、优化器与调参实战

网络搭好后,训练这一步看着门槛不高,但有几个参数直接影响项目能不能顺利跑完。我把实际调过的关键点列出来,你可以直接参考。

4.1 损失函数、优化器与学习率设定

损失函数就用二分类的BCEWithLogitsLoss。优化器我一般先用Adam跑通流程,初始学习率设1e-3,batch size设32或64。如果用的是迁移学习骨干,初始学习率建议降到1e-4,因为预训练模型已经比较“成熟”,学习率太大会把之前学到的特征冲坏。

学习率调度上用CosineAnnealingLR比较省心,也可以简单的StepLR每10个epoch降一半。我实测下来,30到50个epoch就能收敛,没必要训练太久。训练前要固定随机种子,保证实验能复现,这对写毕业设计报告很重要,不然今天跑91%,明天跑89%,又解释不了为什么。

4.2 训练日志和过拟合判断

训练过程中不要光盯着准确率,要同时看训练损失和验证损失。下面是我一版模拟训练日志,比较典型:

EpochTrain LossVal Acc
10.61271.2%
30.47182.5%
50.35387.6%
80.24191.3%
100.19789.8%

第8个epoch时验证准确率最高,之后训练loss继续往下掉,验证准确率却开始下滑,这就是过拟合的典型信号。这时候不需要继续硬训练,直接保存第8轮的模型权重就可以。早停的阈值可以设在5到10个epoch,验证指标连续不提升就停止,省时间又能防止过拟合。

4.3 类别不平衡和损失加权

很多实际场景里,“绽放”和“未绽放”的样本并不均衡,花苞期的照片可能比盛开期少。如果不处理,模型会倾向于预测样本多的类别。最简单的处理方式是给损失函数加一个正样本权重,让模型少犯错时对少样本类别更敏感。

在PyTorch里可以用pos_weight实现,比如nn.BCEWithLogitsLoss(pos_weight=torch.tensor([1.5]))。这个值一般取“负样本数量/正样本数量”,代码里可以自动算出来。也可以用WeightedRandomSampler在采样时让少样本类别被抽中的概率更高。我个人的习惯是先加pos_weight,效果不明显再换采样器,因为采样器会让每个epoch看到的重复样本变多,训练时长增加不少。

5. 结果评估:准确率之外的混淆矩阵和典型误判

模型训练完,很多人习惯只看验证集准确率,但这一个指标在二分类里信息量太少。尤其是“绽放/未绽放”这个场景,两类错误的意义完全不同,必须拆开看。

5.1 精确率、召回率、F1与AUC

假设测试集里有328张实际绽放的图和390张实际未绽放的图,模型预测结果是:真正绽放且预测绽放的有312张,把未绽放误判为绽放的有58张,把绽放漏判为未绽放的有16张,真正未绽放且预测未绽放的有332张。按这些数据算:

  • 精确率:312 / (312 + 58) = 84.3%,表示模型预测为绽放的图里,确实绽放的比例。
  • 召回率:312 / (312 + 16) = 95.1%,表示实际绽放的图里,模型找回的比例。
  • F1分数:约89.4%,是精确率和召回率的调和平均。
  • 特异性:332 / (332 + 58) = 85.1%,表示未绽放图被正确识别的比例。
  • AUC大约0.93,说明整体排序能力不错。

从这个结果能看出来,模型更倾向于“宁可多报绽放、少漏报绽放”,所以精度被误报拉低。至于这种倾向好不好,取决于应用场景。如果是用来提示赏花时间,漏报会让人觉得不够灵敏;如果是用来筛选花田成熟度,误报又会带来额外工作量。所以不能只用一个准确率糊弄过去,最好把混淆矩阵打印出来,看看错在哪类。

5.2 典型误判案例:半开、背景、小目标与残花

我整理了一批典型的误判照片,大概能分成四类。

第一类是半开状态,花瓣已经露出明显颜色,但花心没有完全展开,模型有时会犹豫。这类样本大量出现在0.4到0.6的概率区间,处理方式是多收集、多标注,让模型见过更多“中间状态”。

第二类是背景干扰。如果背景里有红色塑料袋、同色的墙面、甚至远处另一朵颜色类似的虚化花朵,模型会把背景纹理误当成花瓣区域。这种问题可以通过热力图看出模型关注了错误区域,然后针对性增加带复杂背景的训练样本。

第三类是花朵在画面中占比太小。比如几米外拍的一小丛花,花瓣特征被压缩到几十个像素,人眼也要仔细辨别,模型更容易乱猜。这类图不要硬训练,可以先用一个目标检测框把花裁出来再分类,或者至少保证测试时花朵占比不要太悬殊。

第四类是残花与花苞形态接近。菊花、向日葵之类的花,开败之后舌状花开始下垂,绒毛部分露出来,从侧面看跟正在闭合的花苞状态很相似。这类样本需要单独拉出来看,如果数量太少,就考虑把它们单独作为一个“其他状态”类别,否则二分类模型学得很痛苦。

5.3 阈值调整和后处理

默认情况下,模型输出概率大于0.5就判为绽放。但根据上面的混淆矩阵和实际需求,阈值是可以移动的。如果希望放过更多边界开花,可以把阈值调到0.4;如果希望只输出比较确定的绽放,可以把阈值升到0.6。这个操作在推理阶段很简单,画一条PR曲线或ROC曲线就能辅助选阈值。

另外一个实用后处理技巧是:如果一张测试图里有多朵花,先裁剪成多个局部区域再分别预测,最后取平均概率,会比整张图直接判断稳定很多。因为整张图里花的状态不一致时,模型容易被数量多的一侧带跑。我在实际测试中,用这个方式把边界样本的稳定性提升了几个百分点,而且实现成本很低。

6. 毕设落地的代码框架与答辩演示

模型效果调到可接受范围后,剩下的就是工程整理和展示。很多同学有一个误区:只丢一个训练好的模型文件和一段训练脚本给指导老师看。其实毕业设计更看重的是项目结构清晰、实验有对比、展示有深度。

6.1 项目文件组织方式

我建议使用下面这种目录结构:

flower_bloom/ ├── data/ │ ├── train/ │ │ ├── blooming/ │ │ └── not_blooming/ │ ├── val/ │ └── test/ ├── src/ │ ├── dataset.py │ ├── model.py │ ├── train.py │ ├── evaluate.py │ └── infer.py └── checkpoints/

dataset.py负责读取图片、做数据增强、返回训练用的pytorch Dataset;model.py里面放自定义网络和迁移学习网络的构建逻辑;train.py负责训练、验证、保存最优模型;evaluate.py输出准确率、混淆矩阵、分类报告;infer.py接收单张图片,输出概率和最终判断。checkpoints目录用来放每个epoch的模型权重,也可以只保留最佳模型。这个结构看起来很常规,但好处是分工清楚,每一步都能被复现。

6.2 训练脚本与推理的关键环节

训练脚本里最需要注意的是“写对训练循环的四个状态”:训练模式、验证模式、保存时机、早停条件。简单写出来就是:

model.train() for images, labels in train_loader: logits = model(images) loss = criterion(logits, labels.unsqueeze(1).float()) optimizer.zero_grad() loss.backward() optimizer.step()

验证的时候要记得model.eval(),并且用with torch.no_grad()包裹,否则推理模式下的随机失活和BatchNorm统计会不稳定。保存模型时我建议同时保存state_dict和超参数信息,比如记录当时的epoch、val_acc、optimizer状态,这样之后恢复实验很方便。推理脚本则很简单:加载权重,对图片做和训练时相同的预处理,得到sigmoid概率,再用阈值判断。预处理不一致是常见的部署翻车原因,比如训练时把图片缩放到224x224,推理时却忘了做同样的变换,导致结果跟验证阶段差距很大。

6.3 答辩展示的三个加分项

真正到了答辩展示环节,我只推荐三个方向。

第一,讲数据难点和标注规范。把“什么算绽放”的口径、边界样本、半开状态的样例放出来,比单纯展示94%准确率更有说服力。因为答辩组老师通常更关心你有没有想清楚问题边界。

第二,展示训练曲线和混淆矩阵。一张loss下降的图、一张混淆矩阵图、几个典型误判案例,就能把项目从“跑通”提升到“系统分析”的层次。特别是误判案例,只要讲清楚错在哪里、可不可以解决,就已经展示了你的思考能力。

第三,做一个“未见过照片”的小测试。在提交前,当场用训练集之外的实拍照片测试模型,把输出概率展示出来。这个动作虽然简单,但比任何口头描述都直观。我见过不少准备充分的同学在这个环节加分明显,因为这说明项目不是背题,而是真正面对了未知数据分布。

答辩里还有一个很常见的问题:“为什么没用目标检测先把花定位出来?”你可以回答:本任务专注于二分类,目标检测是后续扩展方向,并且你已经观察到小目标容易误判,未来可以用检测加分类的两阶段方案提升稳定性。这种回答既没有否定当前工作,又展示了对项目局限的认知。

最后分享一个小技巧。无论验证集准确率多高,提交前请单独去路边、阳台、校园绿化带拍上一两百张和训练集无关的新照片,把这些当成“模型没见过的新世界”来测。我试过很多次,凡是这轮测试掉链子的模型,基本都是训练集背景太单一导致的,要么花太小,要么背景有大量同色色块。把这些照片整理成一个小型测试集,再让模型跑一遍,你能很清楚地看到自己这个项目距离“真实场景可用”还差多少。这个环节做好了,答辩时讲出来的深度完全不一样。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进