简介:这是一份基于Pyqt5设计实现的简易版水果识别系统Python源码,面向初学GUI编程、图像处理与机器学习的高校学生和开发者,可作为课程设计或毕业设计参考。系统利用Pyqt5搭建图形界面,并结合灰度化、边缘检测等图像预处理手段,通过颜色直方图、形状描述符提取特征,再借助SVM或KNN分类器完成水果识别,整个流程从界面搭建到分类输出协调完整。压缩包共27个文件,以Python脚本、Pyqt5界面文件、PNG图片和JPG样例为主,同时包含编译生成的pyc缓存、文本说明及项目文档,整体大小约1.54MB,结构简洁,便于直接查看和运行调试。目前已吸引81人学习,内容不仅覆盖水果识别系统的基本实现链路,还便于反向理解Pyqt5组件使用、图像特征处理与分类器训练之间的配合方式,适合在此基础上做功能扩展或二次开发。
1. 基于Pyqt5的水果识别系统:先搞清楚它值不值得做
基于Pyqt5设计实现的简易版水果识别系统,这个名字看着像是个AI项目,实际拆开就两块:Pyqt5画界面,OpenCV做传统图像识别。很多第一次做课设的朋友会直接冲深度学习模型,结果发现这个“简易版”根本用不上神经网络,反而在界面布局和颜色阈值上花掉了大部分时间。这类工程在“免费python源码大全”里遍地都是,不是因为它高级,而是它恰好覆盖了GUI、图像处理、多线程三个最常见的考查点。我接下来把这套方案的完整落地路径讲一遍,界面怎么写、识别怎么调、以及真正会卡住你的那几处坑都在里面。
2. 用Pyqt5搭出识别界面:主窗口、按钮与QLabel的布局套路
Pyqt5是Python桌面端里最成熟的框架,在pyqt5教程里的出镜率远超Tkinter。Tkinter不是不能用,而是控件太少、样式太旧,做个识别结果的图文展示要自己拼很多东西;用Web方案又得起本地服务,课设答辩时反而多一个故障点。Pyqt5的信号槽机制和Python对象模型贴合得非常好,按钮点击、线程通知、界面刷新都可以用同一种事件方式串起来,这是它最适合做这类识别系统的根本原因。
2.1 界面拆成三个区域
一个识别系统的主窗口不需要花哨,但结构要清楚。我习惯把它拆成三个区域:图片显示区、识别结果区、操作区。图片显示区负责展示选中的原图,识别结果区显示类别和置信度,操作区放“选择图片”“开始识别”两个按钮。三个区域用垂直布局叠起来,图片区占大头。
| 区域 | 推荐控件 | 职责 |
|---|---|---|
| 图片显示区 | QLabel | 显示原图,缩放时保持宽高比 |
| 识别结果区 | QLabel | 展示识别类别、置信度文本 |
| 操作区 | QPushButton | 触发选图、触发识别 |
2.2 主窗口的最小可运行代码
下面是这套系统主窗口的骨架,代码可以直接跑。我故意没放识别逻辑,先把界面和选图流程跑通,后面再往上挂算法。
import sys from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QLabel, QPushButton, QVBoxLayout, QHBoxLayout, QFileDialog) from PyQt5.QtGui import QPixmap class FruitWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("简易水果识别系统") self.setMinimumSize(800, 600) self.image_label = QLabel("点击下方按钮选择图片") self.image_label.setAlignment(Qt.AlignCenter) self.image_label.setStyleSheet("border: 1px solid #aaa; background: #fafafa;") self.result_label = QLabel("识别结果:未开始") self.result_label.setAlignment(Qt.AlignCenter) self.open_btn = QPushButton("选择图片") self.recognize_btn = QPushButton("开始识别") self.recognize_btn.setEnabled(False) btn_layout = QHBoxLayout() btn_layout.addWidget(self.open_btn) btn_layout.addWidget(self.recognize_btn) main_layout = QVBoxLayout() main_layout.addWidget(self.image_label, stretch=3) main_layout.addWidget(self.result_label, stretch=1) main_layout.addLayout(btn_layout) container = QWidget() container.setLayout(main_layout) self.setCentralWidget(container) self.open_btn.clicked.connect(self.open_image) self.recognize_btn.clicked.connect(self.start_recognize) def open_image(self): path, _ = QFileDialog.getOpenFileName( self, "选择水果图片", "", "图片文件 (*.jpg *.jpeg *.png *.bmp)") if path: self.current_path = path self.image_label.setPixmap( QPixmap(path).scaled( self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) self.recognize_btn.setEnabled(True) def start_recognize(self): # 先占位,第4章换成真正调用识别线程 self.result_label.setText("识别中……") if __name__ == "__main__": app = QApplication(sys.argv) window = FruitWindow() window.show() sys.exit(app.exec_())这段代码的逻辑很直白:open_image用QFileDialog弹出选图框,拿到路径后生成QPixmap并缩放到QLabel里显示;recognize_btn初始是禁用状态,只有选了图才启用,防止用户还没选图就点识别。布局上外层是QVBoxLayout,两个按钮放进QHBoxLayout,再用addLayout嵌到垂直布局里,这是Pyqt5里最常用的嵌套布局方式。
这里有两个参数值得细说。stretch=3和stretch=1是垂直布局的拉伸系数,图片区会占到窗口高度的四分之三,结果区占四分之一,窗口被拉大时这个比例依然保持。Qt.KeepAspectRatio告诉scaled缩放时保持图片宽高比,否则图片会被拉伸变形;Qt.SmoothTransformation则让缩小的图片边缘更平滑,不会出现明显的锯齿感。
2.3 选图和图片显示的三个细节
显示图片这一步有不少人会翻车。第一个细节是QPixmap(path)对大图很吃力,一张手机拍的三千像素照片直接加载再缩放,窗口会卡半秒。常见做法是先读图片尺寸再手动缩小,但我这台课设机器上直接用scaled也没问题,真正卡的还在后面识别算法那段。第二个细节是setScaledContents(False)这个默认值不要改,改了之后图片会失去比例被硬撑满整个QLabel。第三个细节是窗口缩小到比图片还小时,scaled是按当前label尺寸来的,所以主窗口的初始尺寸和最小尺寸一定要设好,否则界面一开就是一张缩得很过分的图。
3. 让OpenCV认识水果:HSV颜色直方图与轮廓识别的关键参数
界面能跑起来之后,核心问题是识别。这个系统叫“简易版”,意味着你要在识别效果和实现成本之间找平衡。我一贯的选择是传统图像处理:颜色阈值分割加轮廓面积统计。它不需要数据集、不需要训练,几十行代码就能对固定背景下拍摄的水果做出不错的分类,这是深度模型短期替代不了的落地优势。
3.1 为什么不用深度模型
常见做法是先去做图像分类的迁移学习,拿ResNet或者MobileNet微调。但你要面对的是:准备几百张标注图片、装CUDA或找替代设备、训练调参,这些工作量加起来远超Pyqt5界面本身。而且深度模型有个老问题,光线一变、背景一变,准确率就掉得厉害,对新手来说排查起来像在碰运气。传统视觉方法的逻辑是透明的:调了哪个参数、分割出什么区域,每一步都能看到结果,非常适合作为课设和毕设的基底。
3.2 颜色分割与直方图匹配,简易方案这样选
水果识别最常用的传统做法有两类:颜色直方图匹配和颜色阈值分割。直方图匹配的思路是先把已知水果的HSV直方图存成模板,识别时计算待检测图和各模板的相似度。它适合背景复杂、颜色分布均匀的场景,但对光照极其敏感,同一个苹果换个角度拍,直方图特征可能偏离很多。阈值分割的思路是用cv2.inRange把某一颜色范围的像素分离出来,再用轮廓面积判断有没有以及有多大一片这个颜色的区域,更适合拍摄台、白背景这类可控场景。
| 方案 | 实现难度 | 光照鲁棒性 | 适用场景 |
|---|---|---|---|
| HSV颜色直方图匹配 | 中 | 低 | 背景复杂、物体颜色分散 |
| HSV阈值分割+轮廓统计 | 低 | 中 | 固定背景、颜色差异大 |
| 轻量CNN微调 | 高 | 高 | 数据充足、效果要求高 |
我这里推荐阈值分割路线,它的参数直观,调试起来能看到中间结果,这是良家方案里最容易复现的一条路。
3.3 阈值分割与轮廓统计的最小实现
识别核心实现如下,这是整套系统里最重要的代码块。
import cv2 import numpy as np # 颜色范围都是 (H, S, V),注意 OpenCV 里 H 的范围是 0~180 BANANA = {"lower": (20, 80, 80), "upper": (35, 255, 255)} APPLE = {"lower": (0, 80, 80), "upper": (10, 255, 255)} ORANGE = {"lower": (11, 80, 80), "upper": (22, 255, 255)} def read_image(path): # 直接用 cv2.imread 读中文路径会失败,这里先读字节再解码 data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def recognize_fruit(image): hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) results = {} for name, cfg in [("香蕉", BANANA), ("苹果", APPLE), ("橘子", ORANGE)]: mask = cv2.inRange(hsv, np.array(cfg["lower"]), np.array(cfg["upper"])) # 开运算去掉微小噪点 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8)) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) area = sum(cv2.contourArea(c) for c in contours) results[name] = area total = sum(results.values()) if total < 2000: return "未识别", 0.0 best = max(results, key=results.get) return best, results[best] / total这段代码的核心逻辑是把RGB图转到HSV空间,然后对每一种水果的颜色范围做一次cv2.inRange,得到一张二值掩码图——白色像素表示该颜色范围内的区域。随后用findContours找出所有独立连通域,contourArea把它们面积加起来。面积最大的颜色类别就是识别结果,该类别面积与总面积的比值作为置信度。total < 2000这个判断是防止画面里只有零星噪点就乱报一个水果,面积小于这个阈值的都视为未识别。
参数这里要先说清楚:cv2.inRange的下界和上界决定颜色范围,这是识别准不准的最关键参数。我给出的数值是在白炽灯、白背景条件下拍的,如果你的环境是日光灯或者自然光,H值基本能沿用,S和V需要往下调,后面第三部分讲怎么测。RETR_EXTERNAL只取最外层轮廓,避免水果上高光区域的内层轮廓重复计数;CHAIN_APPROX_SIMPLE压缩轮廓点,只保留端点,能减少一点点计算量,影响不大。
3.4 三个必调参数:H值、S/V下限、最小面积
颜色阈值有三个参数必须在你的实际环境里调一遍,否则识别效果会很抽象。第一个是H范围。H是色相,决定这个颜色偏红、偏黄还是偏绿。拍一张水果图,用下面这行代码把目标像素的HSV值打出来,多点几个不同位置取均值,就能确定H的上下界。注意OpenCV读入后H的范围是0到180,不是常见的0到360。
hsv = cv2.cvtColor(cv2.imread("test.jpg"), cv2.COLOR_BGR2HSV) print(hsv[200, 300]) # 查看 (300, 200) 像素的 HSV 值第二个是S和V的下限。S是饱和度,V是明度。实测里最常见的误判就是把背景中的阴影、反光区域也当成水果区域,导致面积虚高。把S和V下限从80提高到100可以减少这类误判,但光线不足时水果本身的饱和度也会下降,下限太高又容易漏检。一般场景我建议从60开始试,拍照光源比较均匀的台面可以提到100。第三个参数是置信度计算里的total < 2000阈值,它跟分辨率直接相关,一千万像素的照片和五百万像素的照片,同样的水果面积会差一倍。我用的是448x448左右的输入尺寸,如果你保持原图识别,要把阈值按比例放大。
4. 把识别算法接进Pyqt5界面:信号槽、多线程与图片显示的衔接
识别函数写好了,接下来是把它挂到按钮上。这一步很多新手会踩同一个坑:直接在start_recognize里调用recognize_fruit,然后界面就卡死了。原因是图像处理是耗时的同步操作,在UI线程里跑会阻塞Qt的事件循环,窗口动弹不得。正确做法是开一个后台线程干活,干完通过信号把结果传回主线程刷新界面。
4.1 直接调用识别函数必然假死
在start_recognize里直接写name, conf = recognize_fruit(self.current_path),运行后你会发现点击按钮时窗口先灰一段时间,然后结果才刷出来。如果你的图片稍微大一点或者算法里有多个findContours循环,这个灰屏会持续两秒以上,Windows会给窗口标题加上“未响应”。这不是Pyqt5的bug,而是事件循环被阻塞。界面刷新、鼠标事件、重绘都在同一个线程里排队,你的耗时任务不结束,它们就得等着。
4.2 用QThread把识别放到后台线程
Pyqt5的标准解法是QThread配合pyqtSignal。下面这个类是识别线程的最小实现。
from PyQt5.QtCore import QThread, pyqtSignal class RecognizeWorker(QThread): result_ready = pyqtSignal(str, float) def __init__(self, image_path, parent=None): super().__init__(parent) self.image_path = image_path def run(self): # run 方法在子线程中执行 img = read_image(self.image_path) name, conf = recognize_fruit(img) self.result_ready.emit(name, conf)逻辑上很简单:继承QThread,重写run方法,把耗时操作都放在里面。pyqtSignal(str, float)定义了一个带两个参数的信号,识别完成后用emit把类别和置信度发出去。这里有个细节,read_image用的np.fromfile方案在子线程里同样有效,不会有中文路径问题。还有一点,不要在这个线程里直接操作任何界面控件,QThread不能在子线程里改QLabel,只能靠信号通知主线程去改。
4.3 信号槽的接线与防重复点击
在主窗口里接信号,代码不算多,但有两个细节决定了稳定性。
def start_recognize(self): # 防止上一轮识别还没结束,用户又点了一次 if hasattr(self, "worker") and self.worker.isRunning(): return self.worker = RecognizeWorker(self.current_path) self.worker.result_ready.connect(self.show_result) self.worker.start() self.recognize_btn.setEnabled(False) self.result_label.setText("识别中……") def show_result(self, name, conf): self.result_label.setText(f"识别结果:{name}({conf:.1%})") self.recognize_btn.setEnabled(True)self.worker必须存成实例属性,这是新手最容易翻车的地方。如果你直接用worker = RecognizeWorker(...)局部变量,Python的引用计数会在函数返回时把worker对象销毁,线程可能跑着跑着直接崩溃,报错会指向QThread: Destroyed while thread is still running。isRunning()判断是防重复点击,否则用户连点两次会启动两个识别线程,结果回来时两个信号都会触发界面更新,顺序没法控制。禁用按钮是另一道保险,识别期间直接把按钮变灰。
4.4 BGR转RGB后显示到QLabel
识别完成后,有的需求会把识别结果用框标在原图上,这就涉及把OpenCV的图送到Pyqt5的QLabel。直接用小节的QPixmap显示路径对识别结果标注并不方便,推荐用QImage方式。
from PyQt5.QtGui import QImage, QPixmap def show_cv_image(self, img): rgb_image = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) height, width, channel = rgb_image.shape qimg = QImage(rgb_image.data, width, height, 3 * width, QImage.Format_RGB888) self.image_label.setPixmap(QPixmap.fromImage(qimg))这里有个常见的显示发绿发蓝问题,根源是OpenCV读图默认是BGR顺序,而QImage和QPixmap按RGB解释,不转格式直接显示,颜色通道会错位。QImage构造函数的第五个参数bytesPerLine必须填3 * width,表示每行像素的字节数,这个值不能省略,否则当宽度不是四的倍数时图像会出现错位条纹。转好之后调用QPixmap.fromImage(qimg)就能正常显示了。
5. 识别系统避坑:中文路径、界面假死与颜色误判的排查手记
这一部分把我在Pyqt5识别项目里遇到频率最高的几个坑集中写出来,每一条都是现象、原因、解决的结构,方便你踩到同一个问题时直接对号入座。这五条坑解决掉,这套系统基本就能稳定跑起来。
5.1 中文路径读不出图片
现象:cv2.imread("D:/水果/苹果.jpg")返回None,但同一路径用系统自带看图软件能正常打开。原因是OpenCV的底层C++实现不支持非ASCII路径,Windows下中文目录是重灾区。解决办法是用np.fromfile读字节流再交给cv2.imdecode,这就是我前面代码里read_image函数存在的原因,它能一劳永逸地解决中文路径问题。
5.2 界面卡死不响应
现象:点击“开始识别”后窗口标题出现“未响应”,过一两秒才恢复。原因是识别算法跑在主线程,阻塞了Qt事件循环。解决方案是第4章的QThread方案,把recognize_fruit挪到子线程执行。判断是哪种情况很简单,在start_recognize开头加一行print,如果“未响应”期间这行打印已经输出,说明主线程确实被卡在后面某段代码里了。
5.3 颜色误判:黄香蕉被认成青苹果
现象:检测一张黄色底色图片时,苹果和香蕉的置信度都很高,最终结果在两者之间横跳。原因是H值边界重叠,苹果范围设到了0到10,香蕉是20到35,但青苹果的H值在25到35之间,和香蕉直接撞车。解决方法是先用3.4小节的打印方法取多个像素值实测,把红色系的H上限和黄色系的H下限拉开,必要时缩窄范围换召回率。
5.4 QLabel不刷新,界面一直显示旧图
现象:选了新图片,QLabel仍是上一次的图。原因是QPixmap被局部变量持有,函数返回后对象被垃圾回收,显示区就丢了内容。另外setPixmap之后没有调用repaint在某些极低概率下也会不刷新,但绝大多数情况就是引用没有保存。把QPixmap赋给self.current_pixmap = ...即可稳定解决。
5.5 Pyqt5安装失败报错
现象:pip install pyqt5在国内网络下经常超时或者中断。原因是默认PyPI源较慢。解决办法是换镜像,同时把opencv-python也一起装掉,顺手解决后续依赖。命令如下,镜像地址是通用的PyPI中国镜像,不改全局配置也能用。
pip install pyqt5 opencv-python -i https://pypi.tuna.tsinghua.edu.cn/simple6. 给识别加一点免疫力:准确率验证与两个见效最快的改造
到这一步系统已经能跑通了,但距离能展示还有一段路。我最常被问的问题是“识别准不准”,这个答案不能靠感觉,要拿测试集算出来。建一个test文件夹,按类别放好图片,跑一遍批量识别,统计准确率。这段代码建议放到项目里作为evaluate.py,每次改动参数后跑一下,心里有数。
import os correct = 0 total = 0 for label in ["苹果", "香蕉", "橘子"]: folder = f"test/{label}" for fname in os.listdir(folder): img = read_image(os.path.join(folder, fname)) pred, conf = recognize_fruit(img) if pred == label: correct += 1 total += 1 print(f"准确率:{correct / total:.1%}")验证之外,有两个改造能让准确率明显往上走,代价都很小。第一个是光照补偿,在转HSV前对BGR图的V通道做CLAHE自适应直方图均衡,对暗光下的漏检改善很直观。第二个是多帧投票,连续采集三到五帧分别识别,取出现次数最多的结果作为最终输出,能压掉不少偶发误判。我自己的习惯是,写这类GUI识别项目时先把识别算法做成一个独立的recognizer.py先验证准确率,确认达标再开始画界面。这个顺序省掉了无数无效调试。希望帮到你。
本文还有配套的精品资源,点击获取