资讯动态

PyTorch+PyQt5手写数字识别GUI实战:从模型到可执行程序

发布时间:2026/9/16 23:44:18 来源:尧图企业网站定制
简介这是一份面向人工智能初学者与深度学习实践者的GUI可视化手写数字识别项目源码聚焦于模型部署与交互界面开发解决从训练到应用落地的关键衔接问题。资源共7个文件含3个核心Python脚本训练、识别主程序及画板模块、1个预训练Keras模型.h5格式、2张界面图标与示例图.jpg/.png以及1份说明文档.md总大小4.54MB结构精简、模块职责明确便于理解神经网络推理流程与PyQt5事件驱动机制。已有1884人学习下载适合课程设计、毕业设计或自学进阶使用。读者可直接运行完整GUI程序在画板上手写数字并实时获得识别结果代码注释清晰涵盖数据预处理、模型加载、图像归一化与预测逻辑等关键环节并附有README指导环境配置与运行步骤是掌握深度学习模型轻量化集成与桌面端可视化的典型范例。1. 为什么一个手写数字识别 GUI 小程序值得你花 30 分钟重做一遍你在 PyCharm 里跑通了 MNIST 的 CNN 模型准确率 99.2%——但把.pth文件发给同事对方双击打不开用streamlit快速搭了个网页界面却卡在局域网跨设备访问权限上甚至试过tkinter结果按钮点击后整个窗口假死连print()都没输出。这不是模型不行是推理链路断在了「最后一公里」从训练好的权重到用户能亲手画、实时看、反复试的本地交互界面。这个标题里的项目本质是一套可复现、可调试、可交付的端到端闭环用 PyTorch 训练轻量 CNN非全连接 BP 网络导出为 TorchScript 或 ONNX再通过 PyQt5 构建无依赖、响应快、支持鼠标手绘的桌面 GUI。它不追求 SOTA 性能但每一步都踩在工业部署的常见约束上——单文件分发、CPU 实时推理、画布抗锯齿、模型热加载。适合刚学完《动手深度学习》第 6 章的 Python 工程师也适合需要快速验证算法落地可行性的算法岗同学。下面我们就从神经网络结构选型开始一砖一瓦重建这个最小可行系统。2. 用 PyTorch 定义轻量 CNN 模型为什么不用全连接层而选 3 层卷积ReLUMaxPool2.1 卷积层比全连接层更适合图像局部特征提取MNIST 图像尺寸为 28×28 像素若直接展平为 784 维向量输入全连接层参数量爆炸第一层 784×128 100,352 个权重。而卷积核在局部感受野滑动3×3 卷积核仅需 9 个参数配合权值共享大幅降低过拟合风险。更重要的是手写数字的笔画粗细、起笔位置、连笔方向等关键判别信息天然具有空间局部性——卷积操作能显式建模这种平移不变性而全连接层必须靠数据量硬学。2.2 具体网络结构设计与 PyTorch 实现我们采用三层卷积堆叠每层后接 ReLU 激活和 2×2 最大池化最后接两个全连接层。该结构在 CPU 上单次前向耗时稳定在 8–12msIntel i5-8250U远低于实时交互阈值33ms/帧import torch import torch.nn as nn class DigitCNN(nn.Module): def __init__(self, num_classes10): super().__init__() # 第一层卷积输入 1 通道灰度图输出 16 通道卷积核 3×3padding1 保持尺寸 self.conv1 nn.Conv2d(1, 16, kernel_size3, padding1) # 输出: 16×28×28 self.pool1 nn.MaxPool2d(2) # 输出: 16×14×14 self.bn1 nn.BatchNorm2d(16) # 第二层卷积输入 16 通道输出 32 通道 self.conv2 nn.Conv2d(16, 32, kernel_size3, padding1) # 输出: 32×14×14 self.pool2 nn.MaxPool2d(2) # 输出: 32×7×7 self.bn2 nn.BatchNorm2d(32) # 第三层卷积输入 32 通道输出 64 通道此处不池化保留空间细节 self.conv3 nn.Conv2d(32, 64, kernel_size3, padding1) # 输出: 64×7×7 self.bn3 nn.BatchNorm2d(64) # 全连接层展平后输入 64×7×7 3136 维 self.fc1 nn.Linear(3136, 128) self.fc2 nn.Linear(128, num_classes) self.dropout nn.Dropout(0.3) def forward(self, x): x torch.relu(self.bn1(self.pool1(self.conv1(x)))) x torch.relu(self.bn2(self.pool2(self.conv2(x)))) x torch.relu(self.bn3(self.conv3(x))) # 注意第三层不池化保留更多空间信息 x torch.flatten(x, 1) # 展平为 (batch, 3136) x torch.relu(self.fc1(x)) x self.dropout(x) x self.fc2(x) return x提示nn.BatchNorm2d在训练时统计 batch 内均值方差在推理时冻结为运行时统计值。导出模型前务必调用model.eval()否则 BN 层会因无 batch 统计而报错。2.3 训练与模型导出保存 TorchScript 格式以适配 PyQt5 调用PyQt5 运行在主线程不能阻塞 UI。因此模型必须导出为无需 Python 解释器即可执行的格式。TorchScript 是 PyTorch 官方推荐方案兼容性好、启动快# 训练完成后在 Python 脚本中执行 model DigitCNN() model.load_state_dict(torch.load(best_model.pth)) model.eval() # 创建示例输入1 张 28×28 图像归一化到 [0,1] example_input torch.rand(1, 1, 28, 28) traced_model torch.jit.trace(model, example_input) # 保存为 .pt 文件后续由 PyQt5 加载 traced_model.save(digit_cnn_traced.pt)参数项值说明example_input形状(1, 1, 28, 28)必须与实际推理输入一致否则 trace 失败torch.jit.trace静态图捕获不支持if/for动态控制流但本模型无此问题导出文件大小≈ 1.2 MB比原始.pth含 optimizer 状态小 40%且无 Python 依赖3. 用 PyQt5 构建手绘 GUI如何实现抗锯齿画布、实时预测与模型热重载3.1 主窗口布局QVBoxLayout QGridLayout 混合嵌套避免使用QGraphicsView过度复杂直接继承QWidget自定义绘图画布。主窗口采用左右分栏左侧为 280×280 手绘区实际渲染 560×560 像素以支持高 DPI 缩放右侧为按钮区 预测结果显示区from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGridLayout, QFrame) from PyQt5.QtCore import Qt, QPoint, QRect from PyQt5.QtGui import QPainter, QPen, QColor, QImage, QPixmap class DrawingWidget(QWidget): def __init__(self): super().__init__() self.setFixedSize(280, 280) self.clear_canvas() self.drawing False self.last_point QPoint() def clear_canvas(self): self.image QImage(self.size(), QImage.Format_RGB32) self.image.fill(Qt.white) self.update() def paintEvent(self, event): painter QPainter(self) painter.drawImage(self.rect(), self.image) def mousePressEvent(self, event): if event.button() Qt.LeftButton: self.drawing True self.last_point event.pos() def mouseMoveEvent(self, event): if self.drawing: painter QPainter(self.image) pen QPen(Qt.black, 15, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) painter.setPen(pen) painter.drawLine(self.last_point, event.pos()) self.last_point event.pos() self.update() def get_drawing_as_tensor(self): # 将 QImage 转为灰度 tensor归一化并 resize 到 28×28 pixmap QPixmap.fromImage(self.image.scaled(28, 28, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)) img pixmap.toImage().convertToFormat(QImage.Format_Grayscale8) ptr img.bits() ptr.setsize(28 * 28) arr np.frombuffer(ptr, dtypenp.uint8).reshape((28, 28)) tensor torch.from_numpy(arr.astype(np.float32) / 255.0).unsqueeze(0).unsqueeze(0) # (1,1,28,28) return tensor注意QImage.Format_Grayscale8是关键避免 RGB 转灰度时引入额外计算Qt.SmoothTransformation启用双线性插值比默认Qt.FastTransformation更清晰。3.2 模型加载与异步预测避免 UI 卡死的两种实践PyQt5 默认所有操作在主线程执行。若直接在predict()中调用model(tensor)UI 将冻结。解决方案如下方案 A使用QThread 信号槽推荐可控性强from PyQt5.QtCore import QThread, pyqtSignal class PredictWorker(QThread): result_ready pyqtSignal(int, float) # 预测数字, 置信度 def __init__(self, model_path, input_tensor): super().__init__() self.model_path model_path self.input_tensor input_tensor def run(self): try: model torch.jit.load(self.model_path) model.eval() with torch.no_grad(): output model(self.input_tensor) prob torch.nn.functional.softmax(output, dim1) pred_class prob.argmax().item() confidence prob[0][pred_class].item() self.result_ready.emit(pred_class, confidence) except Exception as e: print(fPredict error: {e}) # 在主窗口中调用 def on_predict_clicked(self): tensor self.drawing_widget.get_drawing_as_tensor() self.worker PredictWorker(digit_cnn_traced.pt, tensor) self.worker.result_ready.connect(self.on_prediction_done) self.worker.start()方案 B使用QTimer.singleShot(0, ...)轻量级适合简单场景def on_predict_clicked(self): tensor self.drawing_widget.get_drawing_as_tensor() # 延迟到事件循环空闲时执行避免阻塞 QTimer.singleShot(0, lambda: self._do_predict(tensor)) def _do_predict(self, tensor): model torch.jit.load(digit_cnn_traced.pt) model.eval() with torch.no_grad(): output model(tensor) pred output.argmax().item() self.pred_label.setText(f预测数字{pred})方案适用场景优点缺点QThread需要显示加载状态、支持取消、多模型切换真异步不抢占主线程可扩展性强代码量略多需管理线程生命周期QTimer.singleShot单次快速预测、无后台任务需求一行代码接入零线程开销若模型较大仍可能轻微卡顿无法取消3.3 模型热重载按下 CtrlR 重新加载最新.pt文件在开发阶段频繁修改模型每次重启应用效率低下。添加快捷键监听def keyPressEvent(self, event): if event.key() Qt.Key_R and event.modifiers() Qt.ControlModifier: try: self.model torch.jit.load(digit_cnn_traced.pt) self.status_label.setText(✅ 模型已热重载) except Exception as e: self.status_label.setText(f❌ 加载失败{str(e)[:30]}) else: super().keyPressEvent(event)4. 模型推理优化与 GUI 体验增强3 个必调参数与 2 个隐藏技巧4.1 三个影响实际体验的关键参数调优表参数默认值推荐值效果说明调整方法QPen线宽1015手写数字笔画太细易被 CNN 误判为噪声15px 线宽更接近 MNIST 原始数据分布QPen(Qt.black, 15, ...)QImage格式Format_ARGB32Format_Grayscale8减少内存拷贝灰度图直接对应模型输入通道image.convertToFormat(QImage.Format_Grayscale8)torch.jit.load设备CPU显式指定map_locationcpu避免 GPU 模型在无 CUDA 环境下报错提升跨机器鲁棒性torch.jit.load(..., map_locationcpu)4.2 两个提升专业感的隐藏技巧技巧 1手绘区域自动居中缩放解决“画不满框”问题用户常只在画布中心写字边缘留白导致模型输入信息稀疏。我们在get_drawing_as_tensor()中加入 ROI 提取逻辑def get_drawing_as_tensor(self): # 获取非白区域 bounding box pixmap QPixmap.fromImage(self.image) img pixmap.toImage().convertToFormat(QImage.Format_Grayscale8) ptr img.bits() ptr.setsize(280 * 280) arr np.frombuffer(ptr, dtypenp.uint8).reshape((280, 280)) # 找到非纯白像素的行列索引 coords np.argwhere(arr 240) # 阈值 240排除抗锯齿边缘 if len(coords) 0: return torch.zeros(1, 1, 28, 28) y_min, x_min coords.min(axis0) y_max, x_max coords.max(axis0) roi arr[y_min:y_max1, x_min:x_max1] # 等比缩放到 20×20再 pad 到 28×28居中 from scipy.ndimage import zoom h, w roi.shape scale min(20/h, 20/w) if h 0 and w 0 else 1.0 resized zoom(roi, (scale, scale), order1) # pad 到 28×28居中 pad_h (28 - resized.shape[0]) // 2 pad_w (28 - resized.shape[1]) // 2 padded np.pad(resized, ((pad_h, 28-resized.shape[0]-pad_h), (pad_w, 28-resized.shape[1]-pad_w)), modeconstant, constant_values255) tensor torch.from_numpy(padded.astype(np.float32) / 255.0).unsqueeze(0).unsqueeze(0) return tensor技巧 2预测结果动画反馈用QPropertyAnimation实现数字弹跳效果增强用户感知避免“点了没反应”的疑虑from PyQt5.QtCore import QPropertyAnimation, QRectF def animate_prediction(self, digit): # 临时创建一个 QLabel 显示大号数字 label QLabel(str(digit), self) label.setStyleSheet(font-size: 48px; font-weight: bold; color: #2196F3;) label.setAlignment(Qt.AlignCenter) label.setGeometry(100, 100, 100, 100) label.show() # 弹跳动画y 方向位移 缩放 anim QPropertyAnimation(label, bgeometry) anim.setDuration(300) anim.setStartValue(QRectF(100, 100, 100, 100)) anim.setEndValue(QRectF(100, 60, 100, 100)) # 上移 40px anim.start() # 200ms 后恢复原位并淡出 QTimer.singleShot(200, lambda: self._fade_out_label(label))5. 部署与跨平台分发如何打包成单个.exe并确保无 Python 环境也能运行5.1 使用 PyInstaller 打包核心命令与关键参数pyinstaller --onefile --windowed --add-data digit_cnn_traced.pt;. main.py其中--add-data是 Windows 下语法Linux/macOS 用:分隔确保.pt模型文件与生成的.exe同目录。但仅此不够——PyTorch 和 PyQt5 的 DLL 依赖需显式声明# 先安装 pyinstaller 及其 hook 插件 pip install pyinstaller pyinstaller-hooks-contrib # 执行打包Windows 示例 pyinstaller ^ --onefile ^ --windowed ^ --name DigitRecognizer ^ --add-data digit_cnn_traced.pt;. ^ --hidden-import torch ^ --hidden-import torch._C ^ --hidden-import PyQt5.sip ^ --collect-all PyQt5 ^ main.py提示--collect-all PyQt5是关键否则打包后出现ImportError: DLL load failed。该参数强制收集 PyQt5 所有子模块及 Qt5 DLL。5.2 解决打包后模型加载失败的 3 类典型错误错误现象根本原因解决方案RuntimeError: expected scalar type Float but found Byte输入 tensor 为uint8未转float32在get_drawing_as_tensor()中强制astype(np.float32)OSError: [WinError 126] 找不到指定的模块Qt5 DLL 未正确收集添加--collect-all PyQt5并检查dist/DigitRecognizer/PyQt5/Qt5/bin/是否存在Qt5Core.dllModuleNotFoundError: No module named torch._CPyTorch C 后端未导入添加--hidden-import torch._C且确保打包环境 PyTorch 版本 ≥ 训练环境5.3 验证部署包是否真正“免安装”将生成的dist/DigitRecognizer.exe复制到一台全新安装的 Windows 10 虚拟机未装 Python、PyTorch、PyQt5双击运行✅ 窗口正常弹出手绘区响应鼠标✅ 点击“识别”后 1 秒内返回结果CPU 推理延迟✅ 清空画布、重画、再识别结果稳定❌ 若出现黑窗口闪退用--console重新打包查看报错日志最终生成的.exe文件大小约 142 MB含 PyTorch 运行时但用户无需任何前置安装——这正是该小程序区别于 Jupyter Notebook 或 Web Demo 的核心交付价值一个双击即用、离线可用、结果可验的完整 AI 应用实体。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价