☰
基于CNN的11种花卉识别:从模型训练到Web与PyQt5双端部署
2026/10/19 8:17:57 网站建设 项目流程

我做毕设那会儿,选的是基于CNN卷积神经网络的11种花卉识别题目。除了模型训练本身,还要同时交付Web端和PyQt桌面端两个小程序。一开始我以为难度全在模型上,做完才发现,真正磨人的是数据整理、模型导出、两个前端调用同一个推理模块这一整条链路。这篇就是完整的过程复盘,从选题逻辑、数据预处理、CNN训练,到Flask后端和PyQt5界面开发的细节都会写到。如果你也在做类似的图像分类毕设,或者刚入门想找一个能从前端打到后端的完整案例,这篇应该能直接当参考。

1. 项目起步:题目拆解与技术栈选型

1.1 为什么定“11种花卉”而不是更多或更少

选这个题目之前,我大概翻了同类毕设的常见做法。有的是猫狗识别,有的是树叶分类,但花卉识别在效果展示上有个天然优势——结果直观,用户上传一张照片立刻出答案,答辩时演示成本很低。我最终把类别数定在11种,主要考虑了三个因素。

第一,数据来源要可控。公开的花卉分类数据集大多在10类上下,直接下载能省掉大量采集时间。如果自己补拍,10类左右的花也比较容易凑齐。第二,11类对模型来说是一个“多分类入门难度”,既不会像二分类那样显得简单,也不至于像100类那样需要调很久。第三,用于演示时,类别多了用户记不住,类别少了又没有“多分类”的说服力。

如果你也想复刻这个思路,类别数量建议控制在10到15种之间。太少体现不了CNN多分类的能力,太多会让数据标注和模型训练周期变长,对毕业论文的时间节奏不友好。

1.2 CNN对比传统机器学习方案,优势在哪

做之前我其实犹豫过要不要用SVM加手工特征。当时我在某课程实验里跑过HOG特征加SVM的分类,效果也还行,但换一个数据集就要重新设计特征提取器,泛化能力一般。而CNN卷积神经网络走的是端到端学习,卷积层自动提取边缘、纹理、花瓣形态等特征,不需要人工设计特征算子。

我后来做了一个简单的对比实验作为论文里的对照组:传统SVM加HOG在11类花卉测试集上准确率大约86%,同一个数据集上,简单CNN能到94%左右。这里面的差异来源很直接:花卉图像的类别差异往往体现在局部纹理和颜色分布上,CNN的多层卷积对这种局部特征的组合表达更充分。毕设论文里加这样一个对比,也能让“为什么用深度学习”这个论述站得住脚。

1.3 Web端和PyQt端的角色分工与技术选型

我一开始想过只做Web端,但题目明确要求“web和pyqt”两个形态,所以就得考虑一套推理代码怎么复用。

整体技术栈最终定成了这样:

模块技术选型理由
模型训练TensorFlow + KerasAPI简单,文档多,适合快速迭代
图像处理OpenCV + PillowOpenCV做增强和读取,Pillow负责格式统一
Web端Flask + HTML/CSS/JavaScriptFlask轻量,和TensorFlow同属Python生态
桌面端PyQt5 + Qt Designer组件成熟,可以直接拖拽界面
模型导出SavedModel + H5兼顾推理灵活性和迁移便利性

Web后端没有选Django,因为项目不需要数据库和后台管理,Django太重了。桌面端没有选Tkinter,因为Tkinter做出来的界面比较简陋,PyQt5在视觉和控件丰富度上都更合适。两个前端共用同一个模型文件和同一个预处理函数,核心推理逻辑只写一份,这也是整个项目里我认为最值钱的设计。

2. 数据集准备:最容易翻车的环节

2.1 数据来源与类别平衡

数据集是整个项目的地基,但也是我第一次翻车的地方。

当时从某公开数据集下载了一部分花卉图片,另外自己补拍了几类花。下载完我做了个统计,发现类别严重不平衡:数量最多的类别有800多张,最少只有200多张。这种情况下直接开训,CNN会把样本多的类别学得很好,样本少的类别准确率惨不忍睹。

我当时的处理分两步:第一步,把大类别随机抽到与中等类别接近的数量,避免模型倾向;第二步,对小类别做数据增强。你没看错,先降采样再增强,而不是一股脑全用,因为大类图片太多时训练周期也长,均衡后的数据集在效果上往往更好。

2.2 图像预处理:尺寸、归一化、通道顺序

预处理这一步看起来简单,实际上坑很深。

我统一把图片resize到128x128,像素值除以255归一化到[0,1]区间。训练集、验证集、测试集按6:2:2划分,划分前先shuffle,确保同类图片不是连续排在一起的。

这里有个容易踩的坑:OpenCV读进来的图片是BGR通道顺序,而TensorFlow的decode操作默认按RGB读取。如果你用OpenCV读图,又没有做cv2.cvtColor(img, cv2.COLOR_BGR2RGB)转换,训练出来的模型在验证集上可能表现还行,但在实际预测时颜色分布就不对,准确率会掉一大截。这个问题很隐蔽,因为模型不会报错,只会“默默地变笨”。

2.3 数据增强的“度”怎么把握

数据增强是解决小样本问题的常规手段,但不能乱用。常用的增强操作包括水平翻转、小角度旋转、亮度抖动、缩放裁剪等。

我刚开始把旋转角度设到了30°,跑出来的模型训练集准确率很高,验证集却一般,后来发现是旋转太大导致花蕊形状被破坏,反而给模型引入了错误样本。最后我调整成:水平翻转(概率0.5)、旋转10度以内、亮度调整0.8到1.2之间。这个幅度既增加了样本多样性,又不至于让花卉形态失真。

数据增强一定要在训练流程里做,而不是提前把所有增强后的图片存下来。用Keras的ImageDataGenerator或tf.data在训练时实时生成增强数据,可以省硬盘空间,还能让每个epoch看到不同的变体,相当于隐式地增加了训练轮次的效果。

3. 模型训练:从baseline到稳定收敛

3.1 卷积神经网络结构怎么设计

很多同学一上来就上预训练模型,但我觉得毕设题目里“基于CNN卷积神经网络”这几个字,意味着你自己搭建的模型结构必须能讲清楚。我最终用的是经典卷积池化堆叠结构:

import tensorflow as tf from tensorflow.keras import layers, models def build_flower_cnn(input_shape=(128, 128, 3), num_classes=11): model = models.Sequential([ layers.Conv2D(32, (3, 3), activation='relu', input_shape=input_shape), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), layers.Conv2D(128, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(256, activation='relu'), layers.Dropout(0.5), layers.Dense(num_classes, activation='softmax') ]) return model

这个结构没有多复杂,但每一层都能在论文里解释清楚:卷积层负责提取局部纹理,池化层负责降维并保留主要特征,全连接层做特征组合,Dropout抑制过拟合,最后一个softmax输出11个类别的概率分布。

我同时用迁移学习做了一组对照实验,用的是MobileNetV2的预训练权重做特征提取。对比下来,MobileNetV2的准确率能到97%,自己搭的简单CNN也能到94%。我个人建议:主体模型用自己的结构,把迁移学习作为对比实验写进论文,这样既证明你理解了CNN原理,又展示了探索能力。

3.2 训练配置与关键参数

训练参数我用了很常规的一套,适合绝大多数入门级图像分类:

model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='categorical_crossentropy', metrics=['accuracy'] ) early_stop = tf.keras.callbacks.EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) history = model.fit( train_generator, validation_data=val_generator, epochs=50, callbacks=[early_stop] )

表里的这些参数对应关系值得细看:

参数取值经验解释
learning_rate0.001Adam优化器的常用起始值,太小收敛慢,太大loss会震荡
batch_size32中等数据量下性价比高,显存不够就降到16
epochs50配合早停使用,实际大概在25轮左右就收敛了
EarlyStoppingpatience=5验证集loss连续5轮不降就停,避免无效训练

有同学问为什么不用更大的学习率。我试过0.01,训练到第三轮loss就变成了NaN。后来排查发现,Adam在0.01的学习率下对这个数据规模的模型来说步子太大了,权重直接爆炸。如果你遇到loss为NaN,优先把学习率降到0.0001试试,同时检查数据里有没有像素值异常。

3.3 训练阶段常见的三个坑

第一个坑是训练准确率极高但验证准确率低。这基本就是过拟合。我最初不加Dropout时,训练集能达到99%,验证集只有85%。加了Dropout(0.5)之后,验证集立刻回到92%以上。

第二个坑是验证集准确率忽高忽低。后来发现是batch_size太小(8),梯度下降的方向不够稳定。把batch_size提高到32后,曲线平滑了很多。

第三个坑是模型保存格式。Keras默认支持.h5和SavedModel两种格式,训练结束后我两种都导出过。.h5是一个单文件,部署时拷一个文件就行;SavedModel是一个目录,包含变量文件和结构定义,适合TensorFlow Serving。对Web端和PyQt端来说,用.h5最省事。但要注意,两个前端加载模型时必须保持训练时的预处理逻辑一致,否则再好的权重也会“水土不服”。

4. Web端识别小程序落地

4.1 Flask后端接口设计

Web端我拆成了前端页面和后端接口两部分。后端用Flask写了两个路由:一个GET /返回上传页面,一个POST /predict接收图片并返回识别结果。

模型文件在服务启动时加载一次,千万不能在每次请求里重复调用load_model。我之前图省事把加载写在了predict函数内部,本地调试时没问题,并发一上来内存就吃紧。正确的做法是在模块级别加载:

import os import numpy as np from PIL import Image from flask import Flask, request, jsonify from tensorflow.keras.models import load_model app = Flask(__name__) model = load_model('flower_model.h5') # 类别列表要和训练时的顺序完全一致 CLASS_NAMES = ['类别0', '类别1', '类别2', '类别3', '类别4', '类别5', '类别6', '类别7', '类别8', '类别9', '类别10'] def preprocess_image(image_bytes): img = Image.open(image_bytes).convert('RGB') img = img.resize((128, 128)) arr = np.array(img) / 255.0 arr = np.expand_dims(arr, axis=0) return arr @app.route('/predict', methods=['POST']) def predict(): file = request.files.get('image') if file is None: return jsonify({'error': 'no image uploaded'}), 400 try: array = preprocess_image(file.stream) probs = model.predict(array)[0] idx = int(np.argmax(probs)) confidence = float(probs[idx]) return jsonify({ 'class': CLASS_NAMES[idx], 'confidence': round(confidence, 4) }) except Exception as e: return jsonify({'error': str(e)}), 500 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)

这里有几个细节值得注意。用PIL而不是OpenCV来处理前端传来的图片,是因为PIL对常见图片格式的兼容性更好,而且convert('RGB')可以强制统一通道格式。前端的格式只有jpg、png还好,万一有人传了bmp或者webp,OpenCV处理起来就比较麻烦。我还给返回值加了个confidence字段,前端可以直接展示置信度,增加演示效果。

4.2 前端页面:上传、预览、异步请求

前端我写了个单页面,包含上传控件、图片预览区域、识别结果展示区域。核心逻辑是监听change事件读取本地图片,用URL.createObjectURL生成预览图,然后用fetch把图片POST到/predict。

<div> <h3>花卉识别</h3> <input type="file" id="imageInput" accept="image/*" /> <br /> <img id="preview" alt="预览图" style="max-width: 300px; display: none;" /> <div id="result"></div> </div> <script> document.getElementById('imageInput').addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const preview = document.getElementById('preview'); preview.src = URL.createObjectURL(file); preview.style.display = 'block'; const formData = new FormData(); formData.append('image', file); fetch('/predict', { method: 'POST', body: formData }) .then(res => res.json()) .then(data => { if (data.error) { document.getElementById('result').textContent = '识别失败:' + data.error; } else { document.getElementById('result').textContent = '预测结果:' + data.class + ',置信度:' + (data.confidence * 100).toFixed(2) + '%'; } }) .catch(err => { document.getElementById('result').textContent = '请求出错:' + err.message; }); }); </script>

这段代码写起来不难,但有一个细节:如果前端和后端不在同一个域名下,比如你用不同端口启动前端静态服务器,fetch会触发跨域限制。Flask里解决跨域最简单的方式是用flask-cors扩展,在predict接口上加个@cross_origin()装饰器就行。如果你只是想在本机演示,让Flask同时托管HTML文件和接口,就不会有跨域问题。

4.3 Web部署调试中的实际问题

我在这个环节踩了两个坑。

第一个坑是上传图片后接口返回“图片无法识别”。排查后发现问题出在前端某些手机照片是EXIF旋转过的,PIL读取后可能方向不对,虽然我代码里没有专门处理EXIF,但用convert('RGB')后统一尺寸,大部分情况下就能绕过这个问题。如果遇到竖拍照片方向异常,可以在预处理里用PIL的ImageOps.exif_transpose做一次翻转修正。

第二个坑是Flask启动后浏览器访问一直转圈。排查了一圈,发现是电脑上另一个程序占了5000端口。后来我习惯在app.run之前打印一行提示:

print('Server running at http://127.0.0.1:5000')

避免启动成功却不知道访问地址的尴尬。

5. PyQt桌面端实现

5.1 界面布局与交互控件

PyQt桌面端的界面我用了Qt Designer拖拽生成。主要控件就四个:一个QLabel用于显示选中的图片,一个“选择图片”按钮,一个“开始识别”按钮,一个QTextBrowser用于显示识别结果。

界面布局大概是这样:

+-------------------------------------------+ | [QLabel 图片显示区域] | | | +-------------------------------------------+ | [选择图片按钮] [开始识别按钮] | +-------------------------------------------+ | [QTextBrowser 识别结果] | +-------------------------------------------+

如果用代码直接写界面也可以,但Qt Designer生成.ui文件再转成.py,调整布局直观很多,而且毕业设计里截一张界面设计图也方便写论文。

5.2 推理逻辑和主线程分离

PyQt端最容易忽略的问题就是界面卡死。如果直接在按钮的点击事件里调用model.predict,高分辨率图片预处理加推理可能要一两秒,这一两秒内窗口会变成“无响应”状态,用户会以为程序崩溃了。

解决办法是引入QThread。把推理过程放到子线程里,主线程只负责更新界面。我封了一个简单的工作线程类:

from PyQt5.QtCore import QThread, pyqtSignal import numpy as np from PIL import Image import os class PredictThread(QThread): result_ready = pyqtSignal(str) def __init__(self, model, image_path, class_names): super().__init__() self.model = model self.image_path = image_path self.class_names = class_names def run(self): try: img = Image.open(self.image_path).convert('RGB') img = img.resize((128, 128)) arr = np.array(img) / 255.0 arr = np.expand_dims(arr, axis=0) probs = self.model.predict(arr)[0] idx = int(np.argmax(probs)) conf = float(probs[idx]) msg = f'预测结果:{self.class_names[idx]},置信度:{conf * 100:.2f}%' except Exception as e: msg = f'识别失败:{e}' self.result_ready.emit(msg)

主窗口里连接信号:

self.predict_thread = PredictThread(model, image_path, class_names) self.predict_thread.result_ready.connect(self.update_result) self.predict_thread.start()

注意:QThread对象要保存为实例属性,不能是局部变量,否则会被Python垃圾回收,线程跑一半就没了。这个坑我调试了一个多小时,报错信息里完全看不出来是回收导致的。

5.3 PyQt端打包成exe的注意事项

PyQt项目做完后一般会想打包成exe,方便答辩时在任意电脑上演示。我用的是PyInstaller,命令很简单:

pyinstaller -F -w main_window.py

-F表示打包成单个exe文件,-w表示不显示命令行窗口。

这里有两个坑。第一,模型文件flower_model.h5不在代码目录时会加载失败。最简单的解决方法是把模型放在exe所在目录的固定子文件夹里,代码里用相对路径拼接:

base_dir = os.path.dirname(os.path.abspath(__file__)) model_path = os.path.join(base_dir, 'models', 'flower_model.h5')

第二,PyQt5打包后在别人电脑上可能出现“缺DLL”的报错。一般装上对应版本的windows-curses或者把PyInstaller升级到新版能解决,如果还不行,就去PyInstaller生成的build目录看日志,它通常会标明缺失的是哪个动态库。

还有一个提升答辩效果的小技巧:把运行窗口标题改成自己的毕设题目标题,界面左上角再放一个小的结果置信度条。这些细节虽然不增加功能,但能让评委觉得完成度很高。

6. 实测结果与踩坑复盘

6.1 各端实测准确率

模型训练完成后的验证集准确率在94%左右,测试集准确率93.6%。Flask接口和PyQt端共用同一份测试集图片做推理,两个端的结果完全一致,说明两端共用推理逻辑的设计是成功的。

实际测试过程中,我挑了几张网络上找的花卉照片,识别的置信度普遍在85%到98%之间。这说明模型在“同类图片”上泛化能力可以,但我也发现了两个明显的短板:白玫瑰和百合经常被搞混,因为两者花瓣颜色接近、形态也类似;黄色郁金香容易被识别为向日葵,颜色特征在模型判断中占了太大比重。

6.2 从毕设答辩角度复盘整个项目

整个项目做完后,我把技术要点梳理成了一条主线:数据预处理、CNN结构设计、训练调参、模型导出、Web部署、桌面端打包。这条线几乎覆盖了深度学习项目从0到1的完整链路,答辩时讲起来逻辑非常顺。

我答辩时重点展示了三个东西:一是混淆矩阵,直接说明模型在哪些花卉类别上容易出错,以及我为什么做数据增强;二是两个端的实时识别演示,现场传了一张没见过的图片,识别正确时效果很直观;三是训练曲线,训练集和验证集的loss下降过程能体现早停机制的作用。

如果你也想复刻这个项目,可以从扩展方向入手再加深一点深度。比如加上摄像头实时识别,用OpenCV打开摄像头,每一帧都调用模型预测;或者把模型转换成TFLite格式,部署到手机上。这些扩展不算难,但能让项目从“毕设水平”向“项目经验”再靠一步。

6.3 给后来者的几点经验

最核心的体会是:别把训练模型当成全部,部署环节的坑远比你想象的多。我在Web端和PyQt端上花的时间几乎和训练一样多,而且大多是踩完坑才意识到。

模型保存和加载格式要保持一致,最好给自己写一个统一的预处理函数,两个端都调用同一个函数,确保训练时的图像尺寸、归一化方式、通道顺序完全一致。任何一端预处理逻辑和训练时不一致,识别效果都会打折扣。

数据集质量永远是第一位。我的模型在验证集准确率卡在93%左右上不去时,补了几张被错误归类的图片到训练集,重训后准确率就提升到了94%。图片质量、类别平衡、标注准确,这些基础工作比调参更重要。

如果你时间紧,先把Web端跑通,再加PyQt端。因为Web端调试迭代快,而PyQt端一旦涉及界面和线程问题,排查成本相对更高。两个端共用推理模块的设计,刚好能让你在完成Web端后,PyQt端只写界面代码就够了。

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

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

立即咨询