ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python实现手写体字符识别:从MNIST到CNN的完整课设指南

基于Python实现手写体字符识别:从MNIST到CNN的完整课设指南

简介:这份资源是面向高校学生与初学者的机器视觉期末大作业完整方案,围绕Python实现手写体字符识别展开,适合课程设计、期末项目或入门深度学习实践使用。压缩包共8个文件,约31.64MB,以5个py源码文件为核心,涵盖数据加载、MLP与MiniVGG两类模型定义、训练与测试脚本,另附数据集压缩包、依赖清单、说明文档与README,结构清晰、注释详尽,新手也能快速理解整体流程。项目基于MNIST手写数字数据集,完整呈现从数据预处理、模型搭建到训练评估的闭环,可直接部署运行并作为课程设计参考。目前已有344人学习下载,具备一定参考热度。对于需要提交高质量期末作业或希望掌握图像分类基础流程的读者,这份资源提供了可复用的代码框架、模型对比思路与文档说明,便于在此基础上修改扩展、撰写实验报告,具有较高的实际应用与学习价值。

1. 手写体字符识别这门课设,真正要交付的是什么

很多人拿到「机器视觉期末作业-基于python实现手写体字符识别」这个题目,第一反应是去搜一份能跑的源代码,跑通、截个图、交差。但真正做过这门课的人都知道,老师看的从来不是你能不能跑通一个 MNIST 示例——网上现成的代码一抓一大把,跑通没有任何区分度。这门作业真正要交付的,是一套能自证「我理解机器视觉分类任务全流程」的东西:数据怎么来、怎么预处理、模型为什么这么选、训练过程怎么监控、指标怎么解读、界面怎么把结果呈现出来。

手写体字符识别是机器视觉里最经典的入门分类任务,也是 python 生态里资料最全的方向。它麻雀虽小,五脏俱全:数据集加载、图像归一化、卷积网络搭建、训练调参、评估可视化、GUI 推理,一条链路走完,机器视觉的核心概念基本都摸了一遍。适合正在做课设的本科生、想补一个完整项目进简历的转行者,以及需要一份能讲清楚「我到底做了什么」的文档的开发者。这篇笔记就按我实际做这类项目的顺序,把每个环节的选择理由、可抄的代码、参数怎么定、哪里容易翻车,一次讲透。

2. 数据集与预处理:从原始图片到模型能吃的张量

2.1 为什么手写体识别首选 MNIST,以及它的边界在哪

MNIST 是手写数字识别的标准数据集,6 万张训练图加 1 万张测试图,每张 28×28 灰度图,共 10 个类别(0 到 9)。它之所以成为机器视觉入门的事实标准,原因很实际:图片小、类别少、标注干净、加载方便,在普通笔记本上几分钟就能跑完一轮训练,不需要 GPU 也能出结果。对于课设这种时间紧、要出完整成果的场景,选它是性价比最高的。

但必须清楚它的边界。MNIST 的数字大多居中、笔画清晰、背景纯黑,模型在这个分布上轻松做到 99% 以上准确率,可一旦换成你自己手写的、位置偏移的、笔画粗细差异大的图片,准确率会明显掉。这不是模型不行,是训练分布和推理分布不一致。所以课设里如果只报一个 MNIST 测试集准确率,说服力有限;更聪明的做法是额外准备一批自己手写的图片做验证,哪怕只有几十张,也能体现你意识到了泛化问题。

常见做法是用torchvision.datasets.MNIST或keras.datasets.mnist直接加载,前者配合 PyTorch,后者配合 TensorFlow/Keras。我一般选 PyTorch,因为后续要改网络结构、加自定义预处理时更灵活,调试也更直观。

2.2 用 torchvision 加载 MNIST 并做标准归一化

import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 训练集变换:转张量 + 标准化 train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), # 轻微旋转平移,增强泛化 transforms.ToTensor(), # 像素值从 0-255 缩到 0-1 transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值与标准差 ]) # 测试集不做增强,只做同样的张量和归一化,保证评估一致 test_transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set = datasets.MNIST(root='./data', train=True, download=True, transform=train_transform) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=test_transform) train_loader = DataLoader(train_set, batch_size=64, shuffle=True, num_workers=2) test_loader = DataLoader(test_set, batch_size=256, shuffle=False, num_workers=2)

这段代码的逻辑分三层。第一层是数据增强,RandomAffine对训练图做小幅度旋转和平移,模拟手写时位置和角度的自然抖动,这是提升泛化最廉价的手段。注意只对训练集做,测试集绝不能加,否则评估结果不可信。第二层是ToTensor,把 PIL 图片转成 PyTorch 张量并把像素归一到 0 到 1。第三层是Normalize,用 MNIST 的全局均值 0.1307 和标准差 0.3081 做标准化,让输入分布接近零均值,训练更稳。

参数上,batch_size=64是入门常用值,显存小、梯度噪声适中;如果机器内存紧张可以降到 32,收敛会稍慢但更稳。num_workers在 Windows 上有时会报多进程错误,遇到就设成 0,用主进程加载,慢一点但不会崩。shuffle=True只给训练集,打乱顺序避免模型学到样本排列的虚假规律。

提示:第一次运行会下载约 10MB 的数据到./data,网络不通时可以先手动下载好放进对应目录,避免卡在下载环节。

2.3 自己手写图片的预处理要对齐训练分布

课设答辩时老师常会让你现场写一个数字让系统识别,这一步翻车率极高,原因几乎都是预处理没对齐。MNIST 的图是黑底白字、数字居中、28×28。你用手机拍或画图工具写的图,往往是白底黑字、尺寸任意、数字偏角落。直接喂给模型,分布完全对不上。

正确做法是写一个预处理函数,把任意输入图转成和 MNIST 一致的格式:灰度化、二值化、反色(让字变白底变黑)、裁剪到数字外接框、缩放到 20×20 再居中放进 28×28 画布。这套流程和当年构建 MNIST 的方式一致,对齐后现场识别的准确率会从惨不忍睹变成基本可用。这一步是很多人忽略的血泪经验,代码不长但价值极高。

3. 模型搭建与训练:一个能打的 CNN 到底怎么配

3.1 为什么课设用小型 CNN 而不是直接上 ResNet

选模型的核心原则是匹配任务复杂度。MNIST 是 28×28 的灰度图、10 分类,任务本身很简单,一个两三层卷积的小网络就能到 99% 以上。直接搬 ResNet18 甚至更深的网络,参数量暴涨、训练变慢,准确率提升微乎其微,还容易过拟合,答辩时被问「为什么用这么深的网络」反而答不上来。

我一般用两个卷积块加全连接的结构:每个卷积块是「卷积 + ReLU + 最大池化」,最后接两层全连接输出 10 类。这个规模在 CPU 上几分钟能训完,在 GPU 上几十秒一轮,参数量几十万,既够用又好解释。如果想让作业更有亮点,可以在第二个卷积块后加 Dropout 或 BatchNorm,然后说明它们各自解决了什么问题,这比无脑堆深度更能体现理解。

3.2 完整可训练的 CNN 定义与训练循环

import torch.nn as nn import torch.nn.functional as F class HandwritingCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() # 第一个卷积块:1 通道输入 -> 32 通道特征 self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) # 第二个卷积块:32 -> 64 通道 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.pool = nn.MaxPool2d(2, 2) # 每次池化尺寸减半 self.dropout = nn.Dropout(0.25) # 抑制过拟合 self.fc1 = nn.Linear(64 * 7 * 7, 128) # 两次池化后 28->14->7 self.fc2 = nn.Linear(128, num_classes) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) # -> [B,32,14,14] x = self.pool(F.relu(self.bn2(self.conv2(x)))) # -> [B,64,7,7] x = x.view(x.size(0), -1) # 展平 x = self.dropout(F.relu(self.fc1(x))) return self.fc2(x)

结构说明:padding=1配合kernel_size=3保证卷积后尺寸不变,尺寸缩减只发生在池化层,这样每层输出尺寸好推算,调试时不容易出错。BatchNorm2d放在卷积和激活之间,加速收敛、稳定训练。Dropout(0.25)放在全连接前,随机丢弃四分之一神经元,是防止过拟合的常规手段。展平后的维度 64×7×7 来自两次池化把 28 降到 7,这个数字必须和前面的结构严格对应,改网络时最容易在这里翻车。

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = HandwritingCNN().to(device) criterion = nn.CrossEntropyLoss() # 多分类标准损失 optimizer = optim.Adam(model.parameters(), lr=1e-3) # Adam 自适应学习率 for epoch in range(10): model.train() running_loss = 0.0 for images, labels in train_loader: images, labels = images.to(device), labels.to(device) 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}')

训练循环的关键点:每轮开始调model.train()开启训练模式(影响 Dropout 和 BatchNorm 行为),optimizer.zero_grad()必须在反向传播前清梯度,否则梯度会累加。损失用交叉熵,它内部已经包含 softmax,所以网络最后一层直接输出 logits,不要再手动加 softmax,这是新手最常见的重复计算错误。

参数上,学习率1e-3是 Adam 的常用起点,训练 loss 震荡不降就降到1e-4。轮数 10 轮通常足够收敛到 99%,如果 loss 还在明显下降可以加到 15 轮。优化器选 Adam 而非 SGD,是因为课设场景下 Adam 对学习率不敏感、收敛快,省去调参时间;如果作业要求体现调参能力,可以对比 SGD 加动量,说明两者差异。

3.3 评估指标不能只看准确率

训练完必须做评估,而且不能只报一个准确率数字。至少要看三样:整体测试准确率、混淆矩阵、每类准确率。手写数字里 4 和 9、3 和 8、5 和 6 容易混,混淆矩阵能直观看出模型在哪几类上犯错,答辩时这是加分项。

from sklearn.metrics import confusion_matrix, classification_report import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): # 评估不需要梯度,省显存 for images, labels in test_loader: images = images.to(device) outputs = model(images) preds = outputs.argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, digits=4)) cm = confusion_matrix(all_labels, all_preds) print(cm)

model.eval()关闭 Dropout 并固定 BatchNorm 的统计量,保证评估可复现。torch.no_grad()关闭梯度计算,速度和显存都更优。classification_report直接给出每类的精确率、召回率、F1,比单一准确率信息量大得多。混淆矩阵里对角线是正确分类,非对角线就是易混对,可以据此决定要不要针对性增强某几类的训练样本。

4. 推理界面与文档:把「能跑」变成「能演示」

4.1 用 Tkinter 做一个能画能识别的演示界面

课设答辩最忌讳只给一个命令行脚本。老师要看的是交互,你现场画一个数字,系统实时给出识别结果,这个演示效果远胜一堆准确率数字。Tkinter 是 python 自带库,不用额外装,做这种小界面足够。

import tkinter as tk from PIL import Image, ImageDraw, ImageOps import torch class DrawApp: def __init__(self, model, device): self.model = model self.device = device self.root = tk.Tk() self.root.title("手写数字识别") self.canvas = tk.Canvas(self.root, width=280, height=280, bg='black') self.canvas.pack() self.canvas.bind('<B1-Motion>', self.draw) self.image = Image.new('L', (280, 280), 0) # 黑底 self.draw_obj = ImageDraw.Draw(self.image) tk.Button(self.root, text='识别', command=self.predict).pack() tk.Button(self.root, text='清空', command=self.clear).pack() self.label = tk.Label(self.root, text='结果:', font=('Arial', 20)) self.label.pack() def draw(self, event): x, y = event.x, event.y self.canvas.create_oval(x-8, y-8, x+8, y+8, fill='white', outline='white') self.draw_obj.ellipse([x-8, y-8, x+8, y+8], fill=255) def clear(self): self.canvas.delete('all') self.image = Image.new('L', (280, 280), 0) self.draw_obj = ImageDraw.Draw(self.image) self.label.config(text='结果:') def predict(self): img = self.image.resize((28, 28)) # 缩放到模型输入尺寸 img = ImageOps.invert(img) # 反色,对齐 MNIST 黑底白字 tensor = torch.tensor(np.array(img), dtype=torch.float32) / 255.0 tensor = (tensor - 0.1307) / 0.3081 # 同样的归一化 tensor = tensor.unsqueeze(0).unsqueeze(0).to(self.device) self.model.eval() with torch.no_grad(): out = self.model(tensor) pred = out.argmax(dim=1).item() self.label.config(text=f'结果:{pred}')

界面逻辑:画布黑底白笔,和 MNIST 一致,省去反色步骤;如果画布是白底黑笔,就必须在预测前反色。预测时把 280×280 缩到 28×28,做和训练完全相同的归一化,再补上 batch 和 channel 两个维度。这里最容易翻车的是归一化参数不一致——训练用了 Normalize,推理忘了,结果全错。所以预处理代码最好抽成一个函数,训练和推理共用,从根上避免不一致。

4.2 文档说明该写什么才算完整

课设的文档不是代码注释的堆砌,要能让一个没接触过的人照着复现。我一般按这个结构写:项目背景与目标、环境依赖(python 版本、torch、torchvision、Pillow、sklearn 及版本)、数据集说明与获取方式、目录结构、训练步骤与命令、评估结果(准确率、混淆矩阵图)、界面使用说明、遇到的问题与解决。其中环境依赖一定要写具体版本,因为 torch 不同版本 API 有差异,不写版本别人复现就报错。评估结果要贴真实数字和图,不要只写「效果良好」这种空话。

注意:文档里不要只写「运行 main.py 即可」,要写清楚先装依赖、再训练、再启动界面,每一步的预期输出是什么,出错时看哪里。这才是可复现的文档。

5. 避坑与排查:那些让课设翻车的细节

5.1 现象:训练 loss 一直不降,准确率卡在 10% 左右

原因通常是标签和输出维度对不上,或者损失函数用错。10% 正好是 10 分类瞎猜的概率,说明模型没学到任何东西。常见具体原因:最后一层输出维度写成了别的数字、交叉熵前又手动加了 softmax 导致数值问题、或者标签没转成 long 类型。

解决:先打印一个 batch 的outputs.shape和labels.shape,确认是[B,10]和[B];确认损失是nn.CrossEntropyLoss()且网络输出是原始 logits;检查labels的 dtype 是torch.long。这三步基本能定位。

5.2 现象:训练准确率很高,测试或现场手写识别很差

原因是过拟合或分布不一致。训练集准确率 99.9% 但测试集只有 95%,是过拟合;测试集也高但现场手写识别错,是预处理没对齐。

解决:过拟合就加 Dropout、加数据增强、减少参数量或早停。分布不一致就严格对齐预处理——灰度、反色、裁剪居中、缩放、同样的归一化参数,一步都不能少。现场识别差几乎都是这一步没做。

5.3 现象:Windows 上 DataLoader 报多进程相关错误

原因是num_workers大于 0 时 Windows 的多进程机制和某些环境冲突,典型报错和freeze_support或管道相关。

解决:把num_workers设为 0,用主进程加载。速度慢一些但稳定。或者在if __name__ == '__main__':保护下启动训练代码,避免子进程重复导入主模块。

5.4 现象:模型保存后再加载,预测结果全乱

原因是保存和加载的方式不匹配,或者加载时没重建网络结构。只保存state_dict却想直接torch.load出完整模型,或者加载后忘了model.eval()。

解决:统一用torch.save(model.state_dict(), 'model.pth')保存,加载时先实例化同样的网络结构,再model.load_state_dict(torch.load('model.pth')),最后model.eval()。结构和保存时必须完全一致,改过网络就得重新训练保存。

5.5 现象:界面画出来的数字识别总偏向某一类

原因是画布尺寸、笔画粗细和 MNIST 差异太大,或者缩放方式不对。280 缩到 28 是 10 倍下采样,粗笔画会糊成一团。

解决:画布别太大,或者缩放前先做裁剪,把数字外接框裁出来再缩放居中,模拟 MNIST 的构建方式。笔画粗细调到和 MNIST 接近,太粗太细都会掉点。这一步调好了,现场演示的稳定性会明显提升。

6. 让课设从及格变满分的两个进阶技巧

第一个技巧是加入置信度输出和拒识机制。现在的模型对任何输入都会强行给出 0 到 9 中的一个答案,哪怕你画的根本不是数字。在界面里加一个判断:当最大 softmax 概率低于某个阈值(比如 0.6)时,显示「无法识别」而不是硬猜。这个改动很小,但体现了你对模型不确定性的理解,答辩时是个亮点。

probs = torch.softmax(out, dim=1) confidence, pred = probs.max(dim=1) if confidence.item() < 0.6: result = "无法识别" else: result = str(pred.item())

阈值 0.6 不是固定的,要看你的模型在测试集上的置信度分布来定。做法是统计测试集正确分类样本的置信度,取一个能覆盖大多数正确样本、又能挡掉明显乱画的值。这个思路比单纯报准确率更能说明你懂模型输出。

第二个技巧是做一次跨数据集或自采数据的验证。MNIST 测试集准确率 99% 不稀奇,但如果你能展示「在自采的 50 张手写图上准确率 92%」,并分析错在哪、为什么错,这个工作的完整度就上了一个台阶。具体做法是收集身边同学手写的数字,拍照后用前面说的预处理流程对齐,跑一遍评估,把错例挑出来分析。这一步花不了多少时间,但能让老师看到你不满足于跑通示例,而是真的在验证泛化能力。

我自己做这类课设最大的教训是:一开始总想着把模型堆复杂、把准确率刷高,后来发现老师根本不看那零点几个百分点,看的是你能不能把数据、模型、评估、演示这条链路讲清楚,能不能说清每个选择背后的理由。把预处理对齐、把文档写细、把演示做稳,比多堆两层卷积有用得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进