news 2026/9/28 1:44:57

Python实现CNN手写数字识别:PyQt5 GUI界面与完整源码解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python实现CNN手写数字识别:PyQt5 GUI界面与完整源码解析

简介:基于Python实现的CNN卷积神经网络手写数字识别实验源码,面向Python初学者及需要完成期末大作业、课程设计的在校学生。项目包含网络构建与训练、图形界面交互、数字识别推理三部分核心代码,涵盖数据加载、数据预处理、模型训练、测试评估的完整流程,均在关键位置配有详细注释,零基础新手也能读懂并快速理解卷积层、池化层、全连接层的工作原理。压缩包共23个文件,以3个Python源码为核心,另有10张PNG格式手写数字测试图片、训练完成的模型权重记录、工程配置说明与操作指引文档,整体仅3.53MB,轻量易部署。已有394人学习下载。配套目录结构清晰,解压后即可直接运行,适合作为课程设计、期末大作业的高分参考,也是CNN入门实战的优质范例。

1. 基于Python的CNN手写数字识别:带GUI的完整大作业源码,适合期末设计和课程设计

手写数字识别是卷积神经网络入门绕不开的经典实验,但绝大多数开源代码只给了训练脚本,跑完出一张准确率曲线图就结束了,交大作业时根本撑不起“系统”二字。这份基于Python实现的CNN手写数字识别实验源码,把整个闭环做完了——不仅包含CNN模型的定义、训练和权重保存,还带了一个基于PyQt5的图形界面,你可以在界面上用鼠标写数字,点击识别立刻出结果。适合正在做期末大作业或课程设计的学生,也适合想快速看到CNN实际效果、又不想从零搭环境的新手。该工程用MNIST数据集训练模型,权重已生成在weights.txt中,下载后部署好Python环境就能直接跑起来,不需要再花时间训练。

2. 先把项目拆开看:文件结构与推理链路

拿到压缩包先不要急着运行,把每个文件的职责摸清楚,后面排错才有方向。这个项目的结构是典型的“训练脚本 + 推理脚本 + GUI入口 + 资源文件”四段式布局,和工业界的小型演示项目风格一致。

2.1 五个核心文件分别负责什么

根目录下的文件不算多,但每一个都有明确分工。gui.py是程序入口,运行它就会弹出图形界面,也就是你在答辩演示时给老师看的主窗口;CNN-Model.py是整个项目的核心,里面定义了卷积神经网络的结构、训练过程和参数保存逻辑;recognition.py是推理模块,负责加载训练好的权重,对输入的图片做预处理并输出预测结果;weights.txt是训练结束后保存的模型参数;numbers_images文件夹里存放了0到9共10张示例图片,供测试识别效果用。

这种拆分的优点是职责清晰:训练和推理完全解耦。你在做课程设计答辩时,可以直接说“这一层是模型训练模块,这一层是识别服务模块,这一层是用户交互层”,比把全部代码塞进一个文件里要专业得多。还有icon.ico是窗口图标,README.md里写的是基本的运行说明。

2.2 数据流从训练到推理怎么走通

整个系统的数据流其实就两个阶段。训练阶段,CNN-Model.py从MNIST数据集中读取手写数字图片,图片都是28×28像素的灰度图,经过卷积、池化、全连接一系列操作后输出10个类别的概率分布,训练完成后把权重写入weights.txt。推理阶段,gui.py或recognition.py读取这张权重表,把用户画的图或导入的图片做同样的预处理,前向传播一次,取概率最大的那个数字作为结果。

我在拿到这个工程时,做了一件很关键的事——先检查weights.txt是否存在。很多初学者下载资源后直接运行gui.py,结果界面弹出来了,一识别就报错,多半就是权重文件缺失或者路径不对。这个文件是整个系统的“大脑”,没有它模型就是随机初始化状态。你在交付前一定要确认weights.txt和gui.py在同一个目录下,并且大小可观,通常一个训练好的MNIST模型权重用文本格式存下来会有几十KB甚至上百KB,如果文件只有几百字节,那大概率是保存的时候出了岔子。

2.3 环境依赖和版本要求

由于项目使用了PyQt5做界面、PyTorch或TensorFlow做深度学习(根据源码注释中的import方式判断具体框架),环境配置这一点需要特别注意。我建议在部署前先创建独立的虚拟环境,避免和系统Python环境里的包冲突。常见做法是用conda创建环境,然后按顺序安装依赖。

conda create -n cnn_digit python=3.8 conda activate cnn_digit pip install torch torchvision pip install PyQt5 pip install pillow numpy matplotlib

参数说明:Python版本选择3.8是兼容性最稳的,PyTorch在3.8上的轮子最全;PyQt5是GUI框架,torch和torchvision用于构建和训练CNN;pillow负责图片的读取和尺寸调整;numpy做矩阵运算;matplotlib一般用于训练时绘制准确率曲线。如果你的环境是Python 3.10以上,我建议先把torch装上测试一下能不能正常import,因为某些旧版本PyTorch对高版本Python支持不佳。

提示:不要直接pip install torch带上GPU版本,除非你确认机器上装了CUDA。CPU版本的torch就足够跑MNIST这个量级的任务了,训练也就几分钟的事。

3. 把MNIST练进CNN:模型定义、训练参数与权重落盘

CNN-Model.py是本工程的核心关节。很多同学抄代码只关注训练完准确率多少,忽略了整个训练流程的组织方式,这到答辩时会吃亏。面试或答辩老师不关心你的准确率是99%还是98%,关心的是“这个模型为什么这么设计”“卷积核大小为什么是5×5”“池化层起到什么作用”——这些问题都能在源码注释里找到答案。

3.1 网络结构逐层拆解

这个实验的CNN结构是经典LeNet风格,也是MNIST任务的标配:两个卷积层、两个池化层、两个全连接层。第一层卷积输入是1通道(灰度图),输出6个特征图,卷积核大小为5×5,步长为1;卷积后接2×2的最大池化。第二层卷积输入6通道输出16通道,同样是5×5卷积核和2×2池化。之后把二维特征图展平成一维向量,送入两个全连接层,最终输出10维向量,对应0-9十个数字。

class CNN(nn.Module): def __init__(self): super(CNN, self).__init__() self.conv1 = nn.Sequential( nn.Conv2d(in_channels=1, out_channels=6, kernel_size=5, stride=1, padding=0), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2) ) self.conv2 = nn.Sequential( nn.Conv2d(in_channels=6, out_channels=16, kernel_size=5, stride=1, padding=0), nn.ReLU(), nn.MaxPool2d(kernel_size=2, stride=2) ) self.fc1 = nn.Sequential( nn.Linear(in_features=16 * 4 * 4, out_features=120), nn.ReLU() ) self.fc2 = nn.Linear(in_features=120, out_features=10) def forward(self, x): x = self.conv1(x) x = self.conv2(x) x = x.view(x.size(0), -1) x = self.fc1(x) output = self.fc2(x) return output

这段代码是CNN模型的定义。in_channels=1代表输入是单通道灰度图,6和16是卷积核数量,或者叫输出特征图深度。第一层卷积后输出6张特征图,第二层输出16张。kernel_size=5就是5×5的卷积窗口,这个尺寸在MNIST特征提取时能在“捕捉局部纹理”和“控制参数量”之间取得平衡。MaxPool2d把2×2区域内的最大值保留下来,一方面降低特征图分辨率,另一方面让模型对微小位移更不敏感。

你注意fc1的输入尺寸是16×4×4,为什么是4×4?因为输入图片是28×28,经过第一次卷积(28-5+1=24)加池化(24/2=12),第二次卷积(12-5+1=8)加池化(8/2=4),最后特征图就是16通道的4×4。这个尺寸是手工算的,写代码时要仔细核对,一旦输入图片尺寸变了,这里的数字就要跟着改,这是新手最容易忽略的地方。

3.2 训练参数设置:batch、学习率与epoch的选择逻辑

训练部分的参数在源码中有明确的默认值。我挑关键的说:batch size一般设为64或128,学习率初始0.001(也就是1e-3),训练轮数在5到10轮之间。MNIST数据集本身是6万张训练图加1万张测试图,用CPU训练的话,每轮大约需要一两分钟,5轮足够了。学习率用0.001配合Adam优化器,这是现代深度学习实践里最稳的组合,几乎不需要手调。

def train_model(): transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True) train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) model = CNN() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) for epoch in range(5): running_loss = 0.0 for images, labels in train_loader: optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() print(f'Epoch {epoch+1}, Loss: {running_loss/len(train_loader):.4f}')

代码里transform.Normalize的两个参数是MNIST数据集的全局均值和标准差,这个数值是官方算好的,不能随便改。shuffle=True表示每个epoch打乱数据顺序,防止模型学到样本排序的“作弊特征”。criterion用CrossEntropyLoss,在多分类任务中它把softmax和交叉熵合在了一起,所以模型的最后一层不需要单独接softmax,直接输出原始logits即可。

损失值在训练过程中应该是逐epoch下降的,第一轮结束时一般在0.1到0.2左右,第五轮基本降到0.02以下。如果loss不降或直接nan,优先怀疑学习率过大或者数据归一化写错。训练完成后torch.save(model.state_dict(), 'weights.txt')——注意这里虽然是.txt后缀,但实际保存的是PyTorch的state_dict格式,加载时要对应使用torch.load。

3.3 为什么用MNIST而不是自己造数据集

这是大作业答辩时老师最爱问的问题。MNIST由Yann LeCun团队整理,包含6万张训练图和1万张测试图,每张是28×28的灰度图,已经被标准化到0-1之间的像素值。它有三个不可替代的优势:数据量适中,用CPU就能在几分钟内完成训练;类别均衡,每个数字大约有接近6000个样本,不存在类别不平衡问题;预处理简单,不需要额外的物体检测或分割步骤,图片本身就是居中且尺寸统一的。

提示:如果老师追问“MNIST是不是太简单了”,你可以回答“手写数字识别虽然是入门级任务,但卷积神经网络的基本范式——局部连接、权重共享、空间下采样——在这个任务上得到了最直观的体现。换到CIFAR-10或ImageNet,复杂度会成倍增加,不适合课程设计周期。”

4. 识别流程与GUI联动:从模型加载到界面显示

训练好的模型要变成一个能演示的“系统”,关键在推理模块和图形界面的衔接。这个工程把recognition.py作为GUI和模型之间的桥梁,界面从画布拿图片,传给识别模块,识别模块返回结果,界面再显示出来。

4.1 recognition.py:把28×28输入变成10个类别的概率

recognition.py中封装的推理函数是整个系统能否正确运行的关键。我在复现这类项目时见过最多的翻车现场就是图片预处理不一致——训练时用的是经过Normalize的张量,推理时如果忘记做同样的归一化,识别准确率会直线下降。下面是这个工程中典型的推理代码结构。

def recognize_digit(image_path): transform = transforms.Compose([ transforms.Resize((28, 28)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) img = Image.open(image_path).convert('L') img_tensor = transform(img).unsqueeze(0) model = CNN() model.load_state_dict(torch.load('weights.txt', map_location='cpu')) model.eval() with torch.no_grad(): output = model(img_tensor) probs = torch.softmax(output, dim=1) pred = torch.argmax(output, dim=1).item() return pred, probs

这段代码里的几个细节要着重说明。convert('L')是把彩色图片转成灰度模式,否则RGB三通道输入会和你训练时单通道模型维度不匹配。unsqueeze(0)是给张量加一个batch维度,因为模型forward的时候期望输入是四维的(batch, channel, height, width)。model.eval()是把自己归一化到推理模式,它会关闭dropout和batch normalization的训练行为,保证同样一张图每次预测结果都一致。torch.no_grad()告诉PyTorch不需要计算梯度,能省出一块显存和推理时间。

这个工程有意思的地方在于它同时提供了两种路径——GUI里鼠标手写的图像会通过内存传递到识别函数,而numbers_images文件夹里的png图片会通过文件路径加载。我在调试时发现GUI手写识别和图片文件识别走的是同一套预处理逻辑,所以如果你想替换自己的图片测试,直接丢进numbers_images目录并修改recognition.py里图片路径即可。

4.2 GUI事件循环和画布逻辑

gui.py是基于PyQt5搭建的。界面主要包含三块区域:左侧是画布,用于手写输入;中间是按钮“识别”和“清空”;右侧是结果显示区域,显示预测数字和每个类别的置信度。画布的核心逻辑是mousePressEvent和mouseMoveEvent的事件响应,按下鼠标时记录坐标、移动时画轨迹。

def mousePressEvent(self, event): if event.buttons() == Qt.LeftButton: self.last_point = event.pos() self.drawing = True def mouseMoveEvent(self, event): if self.drawing and event.buttons() == Qt.LeftButton: painter = QPainter(self.canvas_pixmap) painter.setPen(QPen(Qt.black, 12, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) painter.drawLine(self.last_point, event.pos()) self.last_point = event.pos() self.update() def clear_canvas(self): self.canvas_pixmap.fill(Qt.white) self.update()

这里有一个关键的参数:画笔宽度。代码里设的是12像素,这个值直接影响识别效果。铅笔太细,笔画连通性不足,数字的关键结构可能断裂;铅笔太粗,数字的空白区域被堵住,数字内部结构糅成一团。我在运行时试了8、10、12、16几个值,效果最好的是12到14之间。你如果觉得识别率不稳定,先检查画笔宽度而不是模型。

画布本身是一个QPixmap对象,每次绘制完成后用update()触发重绘事件。识别的时候从这张pixmap里提取图像数据,先缩放成28×28保存(注意保持纵横比),再做反色处理,因为MNIST数据是黑底白字的理想状态,而GUI默认是白底黑笔画。

4.3 GUI和推理的边界:什么时候用GPU,什么时候用CPU

我观察到很多同学一看到深度学习项目就覺得必须配GPU,实际上对于这个工程完全没必要。MNIST单张28×28的图片前向传播一次,在CPU上的耗时是毫秒级的,完全不影响交互体验。GPU只在训练阶段可能有帮助,但就算用CPU训练,5轮epoch也就是几分钟的事。所以部署时不用考虑CUDA配置,torch.load时指定map_location='cpu'反而能省掉跨设备加载的报错。

提示:如果识别按钮点击后卡顿很久才出结果,不要怀疑模型推理慢,先看gui.py里有没有在识别前做了不必要的图像保存或格式转换。有些版本会在识别时把画布内容保存成临时png文件再重新加载,这个磁盘IO会拖慢整个流程。内存直接传PIL Image对象速度快得多。

5. 避坑与常见问题:我在复现过程中踩过的五个坑

这个工程整体完成度比较高,但“能跑”和“跑得顺”是两件事。我把它从上到下跑通一遍,记录了五个最典型的踩坑现场,每个都是实际会发生的现象、原因和解决办法。

5.1 识别时提示“anchor size mismatch”或权重加载报错

现象:运行gui.py后界面正常弹出,在画板上写数字点识别,控制台报错,内容涉及size mismatch或state_dict加载失败。
原因:weights.txt文件里保存的模型结构参数和CNN-Model.py里定义的网络结构不一致。最常见的是你自己改过CNN里卷积核数量或全连接层维度,但没有重新训练生成新的权重文件。
解决:如果你没有改模型结构,检查weights.txt是否完整;如果你改了模型,用CNN-Model.py重新训练并覆盖weights.txt。千万别尝试手动修改权重文件里的数字,结构不一致时加载一定会失败。

5.2 GUI画板写的数字识别结果永远是同一个

现象:不管写什么数字,识别结果都固定在“0”或者“1”,清空重写也没用。
原因:这是图像预处理方向搞反了。MNIST训练集是黑底白字,而GUI画布是白底黑笔。如果你直接把白色背景、黑色笔迹的图喂给模型,输入分布和训练时完全相反,模型输出就倾向于某一个类别。
解决:在把画布转成张量喂给模型之前,执行一次像素反转:img = 255 - img。我在调试时加了一行打印语句检查像素均值,发现反转后数据分布明显正常了。源码里recognition.py可能没处理这一步,需要你根据实际情况自行补上。

5.3 torch.load加载weights.txt时警告“weights only”

现象:加载权重时控制台输出FutureWarning或WeightsOnlyWarning,但程序还是能跑。
原因:这是PyTorch 2.6以上版本的新变化,默认用weights_only=True加载文件,避免执行pickle中的恶意代码。
解决:如果你确定weights.txt来源可靠,把torch.load改成torch.load('weights.txt', map_location='cpu', weights_only=True)显式声明;如果需要加载的参数中包含非张量对象,weights_only=False。这个警告不影响正常使用,但答辩时被问到可以解释一句“这是新版PyTorch的安全机制”。

5.4 PyQt5显示界面后一片空白或画不出笔迹

现象:窗口能打开,按钮能点击,但鼠标在画布区域画不出任何线条。
原因:没有正确初始化画布QPixmap,或者painter对象没有绑定到正确的绘图设备。有些简化版代码在__init__里只创建了QLabel控件,但没有为它分配QPixmap画布,导致绘制操作落空。
解决:检查gui.py中是否有self.canvas_pixmap = QPixmap(width, height)且fill(Qt.white)的操作。如果没有,手动加上并调用setPixmap关联到画布控件。

5.5 手写数字准确率低,但测试numbers_images里的图片都很准

现象:用自带的0.jpg到9.jpg测试,识别全部正确;自己用鼠标写的字,准确率明显下降。
原因:这是训练集和测试域不匹配的问题。MNIST里的数字是居中的、笔画粗细均匀的;你在GUI画布上写的时候,位置可能偏左上或右下,笔画粗细跟MNIST差异很大,模型没见过这种“风格”的输入。
解决:训练时做数据增强,比如随机平移几个像素、轻度旋转5度以内;或者推理前把画布内容缩放并居中到图片中心——许多手写优化做法都是先把笔迹的质心平移到图片中心,再做缩放。这个工程里没有内置数据增强,我建议你在CNN-Model.py训练部分补上随机平移的代码,能有效提升GUI场景的鲁棒性。

6. 自己动手扩展:把准确率可视化、做答辩演示彩蛋

既然这是大作业资源,你肯定不满足于“能跑”,还得在答辩时让老师觉得你“懂”。我的建议是改两个地方,成本很低但演示效果提升明显。

第一,加一个准确率曲线图。训练完成后用matplotlib绘制两张图——训练损失随epoch下降的曲线,以及测试集准确率上升的曲线。在CNN-Model.py训练循环里记录每个epoch结束后的loss和accuracy,最后统一绘制。代码就几行,但在答辩PPT里贴一张曲线图,比任何文字说明都有说服力。

loss_history = [] acc_history = [] for epoch in range(epochs): # ... 训练代码 ... test_acc = evaluate(model, test_loader) loss_history.append(avg_loss) acc_history.append(test_acc) plt.subplot(1, 2, 1) plt.plot(range(epochs), loss_history, 'r-') plt.title('Training Loss') plt.subplot(1, 2, 2) plt.plot(range(epochs), acc_history, 'b-') plt.title('Test Accuracy') plt.tight_layout() plt.savefig('training_curve.png', dpi=150)

第二,做一个“识别置信度显示”的小功能。当前GUI只显示最终识别结果,你可以从recognition.py返回的probs中取前三个概率最高的数字,用柱状图展示。这样即使用户写的数字很潦草,模型可能给出的最高概率只有60%,你也能直观看到模型的“犹豫程度”,这比单纯显示一个识别结果要高级得多。答辩时你可以现场演示:写一个歪歪扭扭的7,模型可能有30%概率判成1,你顺势解释“这是卷积特征提取的局限性,两个数字的局部纹理相似度较高”——老师一听就知道你真明白模型的边界在哪里。

另外,我强烈建议你在最终交付前跑一遍完整的回归测试:依次打开每个numbers_images里的图片,确认全部识别对;再用鼠标写10个数字,记录识别对的数量;最后清空画布,确认没有残留笔迹。这三步都过了,你的项目就处于“可交付”状态。从那以后我每次拿到一个课程设计类资源,都会强制走一遍“环境部署-训练脚本复现-GUI功能遍历-异常输入测试”这四步,特别是异常输入测试——在画布上画一个叉或写一个字母,看程序会不会崩。这个习惯帮我避开了至少三次答辩现场翻车。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 1:44:42

LCC补偿电路设计:无线充电效率跃迁的核心技术

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 1:44:09

超声乳腺良性图像分割数据集:U-Net训练与避坑实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 1:43:23

Cocos Creator跑酷游戏源码拆解:动画状态机与性能优化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 1:43:21

瑞萨RZN2L QSPI Flash启动与参数存储优化实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 1:43:18

魔百和CM101S刷机全攻略:如何挑选稳定固件与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 1:42:43

高精度ADC硬件设计:参数选型、噪声预算与PCB布局实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华