简介:这份基于深度学习的连续多位手写数字识别系统,面向毕业设计、课程作业及计算机视觉初学者,结合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 都是现成接口 |
| GUI | PyQt5 | 教程资料多,打包生态成熟,控件够用 |
| 评估可视化 | 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 就能办到。经验值如下表:
| 增强方式 | 参数范围 | 作用 |
|---|---|---|
| 随机旋转 | degrees=10 | 模拟手写倾斜,超过 15 度会引入现实中不存在的变形 |
| 随机平移 | translate=(0.1, 0.1) | 让数字在画布里偏移,提高分割框不准时的容忍度 |
| 随机缩放 | scale=(0.9, 1.1) | 模拟粗细差异,和坐标归一化配合使用 |
| 随机擦除 | RandomErasing | 模拟笔迹中断,顺便当正则化 |
3.2 PyTorch 训练 LeNet-5 的最小可复现代码
网络定义直接照抄下面这段,输入是 1×28×28 的灰度图,输出是 10 类 logits:
import torch import torch.nn as nn class LeNet5(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(6, 16, kernel_size=5), 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(degrees=10, translate=(0.1, 0.1), scale=(0.9, 1.1)), transforms.ToTensor(), ]) train_data = datasets.MNIST("./data", train=True, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=128, shuffle=True)训练循环注意 learning rate,选 0.001 用 Adam 就能稳收敛。每跑一个 epoch,把训练 loss 和验证准确率分别存进两个字典,最后用 Matplotlib 输出成 png:
import matplotlib.pyplot as plt plt.figure(figsize=(8, 3)) plt.subplot(121) plt.plot(history["train_loss"], label="train_loss") plt.subplot(122) plt.plot(history["val_acc"], label="val_acc") plt.savefig("evaluation_curves.png", dpi=200)注意:训练集和验证集必须分开,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, axis=0) 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), dtype=np.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 |
| 识别按钮 | QPushButton | clicked |
| 打开图片 | QPushButton | clicked |
| 清空按钮 | QPushButton | clicked |
| 结果标签 | QLabel | setText |
| 置信度标签 | QLabel | setText |
5.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, dtype=np.uint8).reshape((h, w))用灰度图直接转换,绕开了 OpenCV 彩色图的 BGR 和 RGB 顺序问题。
5.3 识别线程与模型常驻:别把 UI 卡死
模型推理不能放在按钮的 clicked 槽里直接执行,因为 PyTorch 的 forward 即使只在 CPU 上跑也要几十毫秒,加上分割和图像转换,界面会明显卡顿。标准做法是开一个 QThread:
class 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, samples=200) 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 的脚本,确认端到端准确率没有退步再谈打包发布。
本文还有配套的精品资源,点击获取