
简介这份基于深度学习的连续多位手写数字识别系统面向毕业设计、课程作业及计算机视觉初学者结合PyTorch与PyQt5构建了直观的GUI交互界面。系统不仅能够检测并识别单个数字还支持输出连续多位数字结果用户可通过界面灵活调整各类阈值满足不同测试场景需求。资源包共包含1758个文件总大小约130.22MB主要涵盖jpg格式的手写数字图片样本、xml与txt格式的标注标签、yaml模型配置、py训练推理脚本、pt训练权重、ui界面文件以及png评估曲线图等文件类型覆盖从数据到部署的完整链路。此外项目还附带了人工标注的数据集、运行教程、启动脚本与Dockerfile方便快速搭建环境并复现效果。训练好的模型可开箱即用完整源码也便于学习者深入理解YOLOv5的检测原理与PyQt5的界面开发方法。目前已有173人学习下载质量与实用性得到了初步验证非常适合需要完整项目参考的开发者。1. 连续多位手写数字识别比 MNIST 难在哪里“基于深度学习opencv的连续多位手写数字识别系统”这个标题在毕业设计里出现频率极高几乎成了一条约定俗成的入门路线MNIST 证明 CNN 会用OpenCV 证明图像处理基本功GUI 把模型变成能演示的产品。这套组合看起来简单真做“连续多位”时才会发现单字识别能到 99%连写数字的端到端正确率可能跌到 50% 以下问题几乎都出在分割粘连、歪斜、粗细不均都会让“切一刀”的位置偏掉后面全错。这里把整个系统拆成数据集训练、OpenCV 分割、GUI 集成、端到端评估与交付验证四段讲完适合课程设计、毕业设计也适合第一次想把模型真正交付成桌面程序的人。2. 深度学习 OpenCV 手写数字识别系统的任务拆解与选型2.1 连续多位识别先拆任务分割、识别、后处理三件事连续多位手写数字识别比单字识别多的不是模型而是“定位”这一步。直观来看任务可以拆成三个彼此独立的模块分割、识别、后处理。分割负责在原始图像上找每个数字的区域识别负责对切出来的字符图片做分类后处理负责把分类结果按从左到右的顺序拼成完整字符串同时过滤明显异常的分割框。这个拆法决定了后续所有代码的组织方式。单字识别只需要“图片 → 网络 → 标签”一条链路多位识别必须先回答“图里有几个数字、每个数字在哪”否则卷积网络再强也不知道特征应该对齐到哪个区域。常见做法是用垂直投影切分先做二值化统计每一列黑色像素的数量连续为 0 的列就是字符空隙两个空隙之间就是一个候选字符区域。手写连笔、歪斜和噪点都会破坏投影曲线的零点所以形态学闭运算、最小宽度过滤和面积过滤是这个模块里的必备后手参数怎么调在第 4 章展开。2.2 CNN 与 OpenCV 的分工为什么不能只用其中一个只靠 OpenCV 模板匹配也能挑数字但同一个数字有几十种手写笔画形态形状匹配的准确率会跌到没法用只靠深度学习做端到端识别也可以比如把整串图直接丢给 CRNN 或 CTC Loss但数据量和训练成本远超普通课程设计。所以“OpenCV 切图 CNN 分类”是多数毕设和工程落地时的默认方案这也是标题把深度学习和 OpenCV 并列出现的原因二者各管一段互不抢活。CNN 这一段用 LeNet-5 就够了。这个结构是计算机视觉里最经典的入门网络两层卷积加池化、三层全连接在单通道 28×28 数字分类上稳定能到 99% 左右。任务对模型容量的要求就是这么低参数少、收敛快、不容易过拟合还方便导出。从 PyTorch 框架详解到 100 个 Python 实战项目几乎都把 LeNet 系列作为第一个完整的卷积网络例子。2.3 框架选型训练、图像处理、GUI 各选什么这个系统的技术栈建议直接照抄下面这张表组件推荐理由深度学习框架PyTorch动态图调试方便torchvision 自带 MNIST 和常用 transforms图像处理OpenCV-Python二值化、形态学、轮廓、resize 都是现成接口GUIPyQt5教程资料多打包生态成熟控件够用评估可视化Matplotlib可以嵌入 PyQt5 的 FigureCanvas也能直接存 png环境安装一条命令就能完成注意 torch 与 torchvision 的版本必须配对否则导入时会直接报错pip install opencv-python torch torchvision PyQt5 matplotlib scikit-learn提示现在 GUI 方向经常被讨论的是 GUI agent 这类自动化工具但课程设计里要的是稳定、可评审的桌面程序选 PyQt5 这种生态成熟的方案最稳。除非你要跨平台发布否则不用一上来就考虑 golang 或 Web GUI 方案。3. 数据集准备与模型训练用 PyTorch 训练 CNN 并保存评估曲线3.1 训练数据MNIST 打底自采样本补泛化标题里把“数据集”单列出来说明这个系统不打算只在 MNIST 上自嗨。MNIST 是最常用的数据源torchvision 里可以直接下载但它有一个实际短板测出来的是标准手写数字用鼠标或触控板写出来的字形差异可能很大。常见做法是用 MNIST 训练出第一个模型再按毕设场景补充自采样本。补数据时注意类别均衡10 个数字每个至少 200 张否则小样本类别会被大样本吃掉。自采量不大时最直接的办法是数据增强用 PyTorch 的 transforms 就能办到。经验值如下表增强方式参数范围作用随机旋转degrees10模拟手写倾斜超过 15 度会引入现实中不存在的变形随机平移translate(0.1, 0.1)让数字在画布里偏移提高分割框不准时的容忍度随机缩放scale(0.9, 1.1)模拟粗细差异和坐标归一化配合使用随机擦除RandomErasing模拟笔迹中断顺便当正则化3.2 PyTorch 训练 LeNet-5 的最小可复现代码网络定义直接照抄下面这段输入是 1×28×28 的灰度图输出是 10 类 logitsimport torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 6, kernel_size5, padding2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(6, 16, kernel_size5), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(16 * 5 * 5, 120), nn.ReLU(), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, 10), ) def forward(self, x): return self.classifier(self.features(x))训练主循环里只需要记录每个 epoch 的 loss 和验证集准确率方便后面画评估曲线from torch.utils.data import DataLoader from torchvision import datasets, transforms transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1), scale(0.9, 1.1)), transforms.ToTensor(), ]) train_data datasets.MNIST(./data, trainTrue, downloadTrue, transformtransform) train_loader DataLoader(train_data, batch_size128, shuffleTrue)训练循环注意 learning rate选 0.001 用 Adam 就能稳收敛。每跑一个 epoch把训练 loss 和验证准确率分别存进两个字典最后用 Matplotlib 输出成 pngimport matplotlib.pyplot as plt plt.figure(figsize(8, 3)) plt.subplot(121) plt.plot(history[train_loss], labeltrain_loss) plt.subplot(122) plt.plot(history[val_acc], labelval_acc) plt.savefig(evaluation_curves.png, dpi200)注意训练集和验证集必须分开MNIST 的 test 集专门用来画验证曲线不要再把 test 数据塞进 train_loader 做 shuffle否则曲线会“假好看”真实泛化能力反而没测出来。模型保存用torch.save(model.state_dict(), mnist_cnn.pth)推理时先model LeNet5()再model.load_state_dict()最后model.eval()。这个文件就是标题里的“模型”部分。3.3 评估曲线怎么读过拟合、欠拟合的分界线在哪训练完先看曲线开口方向。如果 train_loss 一直下降而 val_acc 横盘不动说明过拟合优先加数据增强或降低模型容量如果两个指标都在低位徘徊可能是学习率太小或网络没有收敛直接把 epochs 拉长没有意义。MNIST 这类简单任务正常情况是 10 个 epoch 以内 val_acc 就能过 99%如果迟迟上不去检查 Normalize 的均值和方差是不是写错或者把图像反色了。4. 用 OpenCV 投影法分割连续多位数字预处理到归一化的完整代码4.1 预处理管道灰度、OTSU 二值化与形态学闭合分割之前先要把输入图像统一成“黑底白字”的二值图。GUI 画布上传过来的可能是白底黑字摄像头拍下来的是彩色图直接做投影统计会被背景干扰import cv2 import numpy as np def preprocess(image): gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return binary这段有两个参数值得单独说明。THRESH_BINARY_INV把数字变成白色、背景变成黑色方便后面按binary 255统计THRESH_OTSU是自适应阈值光照不均时自动找分割点比写死 127 稳。闭运算的kernel是 3×3如果笔画太细经常断可以换 5×5代价是两个挨得近的数字更容易粘连。4.2 垂直投影切分数字的循环逻辑垂直投影的核心思想白色像素数量为 0 的列是字符间隙。用一个状态变量记录当前是否在数字内部就能把整个图像切成若干列区间h, w binary.shape proj np.sum(binary 255, axis0) in_digit False start -1 boxes [] for col in range(w): if proj[col] 0 and not in_digit: in_digit True start col elif proj[col] 0 and in_digit: in_digit False boxes.append((start, col)) if in_digit: boxes.append((start, w - 1))boxes里存的是每个数字横向的左右边界。这个循环本身很简单真正影响准确率的是后面的过滤。如果某一段的宽度小于平均宽度的 1/3大概率是噪点或笔画碎片直接丢弃如果宽度超过平均宽度的 1.5 倍说明两个数字可能粘在了一起需要特殊处理。高度方向也要过滤太矮的区间同样不进入识别管线。4.3 粘连、断裂、误切常见分割异常对照表投影法对付整齐印刷体很干净手写数字最常见的四个坑及兜底方案如下现象原因处理参数一个数字被切成两段笔迹中断投影出现假零点增大闭运算 kernel 到 5×5两个数字粘在一起连笔导致无零点列按宽度阈值识别后从最窄处再次切分把噪点当数字二值化后残留孤立点过滤宽度/高度小于平均值的轮廓分割框只框住半个数字笔画超出区域对每个 box 用cv2.boundingRect重新取外接矩形调试分割效果时把每个 box 用cv2.rectangle画回原图另存为 debug.jpg比盯着坐标数值直观得多。这一步也可以把每个切出来的小图单独保存逐张看是哪一类数字最容易切歪。4.4 归一化到 28×28直接 resize 是错误做法切出来的数字尺寸不一直接cv2.resize压缩到 28×28 会把宽高比破坏数字拉胖或压扁MNIST 训练出来的模型对这种变形非常敏感。正确做法是先等比缩放到 20×20再贴到 28×28 的画布中央def normalize_digit(binary, box): x1, x2, y1, y2 box digit binary[y1:y2, x1:x2] h, w digit.shape scale 20.0 / max(h, w) digit cv2.resize(digit, (int(w * scale), int(h * scale))) canvas np.zeros((28, 28), dtypenp.uint8) x_off (28 - digit.shape[1]) // 2 y_off (28 - digit.shape[0]) // 2 canvas[y_off:y_off digit.shape[0], x_off:x_off digit.shape[1]] digit return canvas传给模型前还要做一步x torch.FloatTensor(canvas).unsqueeze(0).unsqueeze(0) / 255.0把取值范围从 0~255 缩到 0~1。注意训练时如果加了 Normalize推理时也要加同样的 Normalize前后不一致是模型输出全是同一个类别的常见原因。5. 集成到 PyQt5 GUI画布涂写、图片打开与识别线程分离5.1 PyQt5 界面布局与信号槽设计GUI 是评判一个毕设完成度最直观的部分。界面不需要花哨把核心功能摆清楚就行一个画布用来手写数字一个“识别”按钮触发推理一个“清空”按钮重置画布一个“打开图片”按钮加载外部图片两个 QLabel 分别显示识别结果和置信度。布局用 QVBoxLayout 叠 QHBoxLayout 就能完成。控件类型关键信号画板QWidget 子类mousePressEvent / mouseMoveEvent识别按钮QPushButtonclicked打开图片QPushButtonclicked清空按钮QPushButtonclicked结果标签QLabelsetText置信度标签QLabelsetText5.2 画布涂写类鼠标事件和画笔参数画布的核心是重写paintEvent把鼠标轨迹画到 QImage 上再用update()触发重绘。画笔粗细建议 12~16太细写的字识别率很低太低端设备上还会出现断线class PaintBoard(QWidget): def __init__(self): super().__init__() self.image QImage(self.size(), QImage.Format_RGB32) self.image.fill(Qt.white) self.drawing False self.last_pos None def mousePressEvent(self, event): self.drawing True self.last_pos event.pos() def mouseMoveEvent(self, event): if self.drawing and self.last_pos: painter QPainter(self.image) painter.setPen(QPen(Qt.black, 14, Qt.SolidLine, Qt.RoundCap)) painter.drawLine(self.last_pos, event.pos()) self.last_pos event.pos() self.update() def paintEvent(self, event): painter QPainter(self) painter.drawImage(self.rect(), self.image, self.image.rect())识别时把 QImage 转成 OpenCV 能处理的 numpy 数组这里有个容易踩的坑bits()拿到的指针需要setsize才能转 bytes否则读出来的数据长度不对def qimage_to_cv(image: QImage) - np.ndarray: image image.convertToFormat(QImage.Format_Grayscale8) w, h image.width(), image.height() ptr image.bits() ptr.setsize(h * w) return np.frombuffer(ptr, dtypenp.uint8).reshape((h, w))用灰度图直接转换绕开了 OpenCV 彩色图的 BGR 和 RGB 顺序问题。5.3 识别线程与模型常驻别把 UI 卡死模型推理不能放在按钮的 clicked 槽里直接执行因为 PyTorch 的 forward 即使只在 CPU 上跑也要几十毫秒加上分割和图像转换界面会明显卡顿。标准做法是开一个 QThreadclass RecognizeThread(QThread): result_ready pyqtSignal(str, float) def __init__(self, pipeline, image): super().__init__() self.pipeline pipeline self.image image def run(self): result, confidence self.pipeline.recognize(self.image) self.result_ready.emit(result, confidence)识别管线在 MainWindow 构造函数里初始化一次把第 3 章的模型和第 4 章的分割函数串起来先preprocess拿二值图再split_digits拿候选框逐框normalize_digit后过模型最后把每个字符的预测标签拼成字符串置信度取所有数字概率的平均值。加载模型只做一次每次识别都重新 load_state_dict 才是界面卡顿的真凶。6. 端到端评估曲线与交付前验证整串正确率和打包检查6.1 画“位数 → 整串正确率”曲线训练阶段的评估曲线证明的是单字分类能力毕设评审更在意系统对连续多位数字的整体效果。生成 1 到 8 位的随机数字串渲染成图片走“预处理 → 分割 → 识别 → 拼接”全流程统计整串完全正确的比例for n in range(1, 9): acc evaluate_n_digits(n, samples200) plt.plot(n, acc, o-)这条曲线通常随位数增加明显下降因为每一位的分割误差都会累积位数越多越容易错。以 99% 的单字准确率估算5 位数整串正确率理论值约为 95%如果实测掉到 70% 以下说明问题多半出在分割而不是分类。曲线本身就是最好的排错工具。6.2 打包检查与运行教程的闭环PyInstaller 打包时模型权重文件不会自动包含需要手动指定数据目录pyinstaller -w -F main.py --add-data models;models注意 Windows 用分号Linux 和 macOS 用冒号路径写错直接报找不到模型。打包后程序里用sys._MEIPASS拼出临时解压路径代码里要有判断开发环境和打包环境的两套路径逻辑。最后把 requirements.txt 固定好版本pth 模型、评估曲线截图和打包好的可执行程序放进发布目录按“安装依赖 → 启动 GUI → 涂写识别 → 查看评估曲线”的顺序自测一遍。交付前任何改动都要先重跑 6.1 的脚本确认端到端准确率没有退步再谈打包发布。本文还有配套的精品资源点击获取