☰
手写体字符识别实战:从MNIST到CNN的机器视觉期末作业指南
2026/10/8 19:07:47 网站建设 项目流程

简介:这份资源面向高校学生与机器视觉初学者,提供一套可直接用于期末大作业或课程设计的手写体字符识别完整方案,帮助解决从模型搭建到测试评估的全流程问题。压缩包共8个文件,约31.64MB,以5个Python源码文件为核心,涵盖数据处理、MLP与MiniVGG模型定义、训练与测试脚本,另附requirements依赖清单、README说明文档及MNIST数据集压缩包,结构清晰、便于按模块阅读。代码注释详尽,新手也能理解网络结构与训练逻辑。目前已有344人学习下载,具备一定参考热度。读者可据此掌握手写数字识别的数据加载、模型构建、训练调参与测试评估思路,并直接部署运行,省去从零搭建的时间,适合作为高分课程设计或入门机器视觉的实践范本。

1. 手写体字符识别到底在识别什么:从一张 28×28 的灰度图说起

很多人第一次做机器视觉期末作业,看到「手写体字符识别」这几个字,脑子里想的是 OCR 那种从一张随手拍的照片里把一整段文字抠出来的场景。真上手才发现,作业要的其实是更基础的一步:给一张已经裁好、摆正、灰度化的单字符小图,判断它是 0 到 9 里的哪一个,或者再加几个字母。这件事听起来简单,但它把机器视觉里最核心的几件事全串起来了——图像怎么表示、特征怎么提、模型怎么训、结果怎么评。你把这套流程跑通一遍,后面做零件缺陷检测、电子称数值识别、车牌识别,底层逻辑是同一套。

这篇笔记面向的是要交机器视觉期末作业、或者想拿一个完整小项目入门 Python 机器视觉的人。我会按「数据集怎么准备 → 模型怎么搭 → 怎么训练和调参 → 怎么评估和排错 → 怎么把作业做出区分度」这条线讲,代码能直接抄,参数会告诉你为什么这么设。手写体字符识别这个题目最大的好处是数据量可控、训练快、结果直观,一张普通笔记本 CPU 就能跑完,不需要显卡,这对期末周来说很关键。

2. 数据集怎么选怎么读:MNIST 之外的三个现实问题

2.1 为什么默认从 MNIST 起步,但它不是终点

MNIST 几乎是手写体字符识别的默认起点,6 万张训练图加 1 万张测试图,每张 28×28 灰度,标签是 0 到 9。它的好处是干净、格式统一、加载方便,很多框架直接内置了下载接口。但你要清楚它的局限:这些数字是经过居中、归一化处理的,笔画粗细和位置都比较规范。一旦你拿自己手写的数字去测,准确率经常掉一截,这就是所谓的分布差异。

所以我的建议是,作业主体用 MNIST 跑通,但一定要留一步「用自己的手写图测试」,这一步能体现你对泛化问题的理解,也是拉开分数的地方。常见做法是用画图工具或者手机拍一张写数字的纸,裁成单字符,再缩放到 28×28。

2.2 用 Python 把数据集读进来的最小代码

下面这段代码用 torchvision 加载 MNIST,同时做了归一化。归一化这步别省,它把像素值从 0 到 255 压到均值 0.1307、标准差 0.3081 附近,能让训练稳定很多。

import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 定义预处理:转张量 + 标准化 transform = transforms.Compose([ transforms.ToTensor(), # 把 PIL 图转成 [0,1] 的张量 transforms.Normalize((0.1307,), (0.3081,)) # MNIST 全局均值和标准差 ]) # 下载并加载训练集与测试集 train_set = datasets.MNIST(root='./data', train=True, download=True, transform=transform) test_set = datasets.MNIST(root='./data', train=False, download=True, transform=transform) train_loader = DataLoader(train_set, batch_size=64, shuffle=True) test_loader = DataLoader(test_set, batch_size=1000, shuffle=False) print(len(train_set), len(test_set))

逻辑说明:ToTensor会把像素从整数变成 0 到 1 的浮点,Normalize再按通道做减均值除标准差。batch_size训练时设 64 是折中,太小训练慢,太大显存或内存吃紧;测试时设 1000 是为了少跑几轮循环。shuffle=True只在训练集开,测试集不要打乱,方便你按顺序对照结果。

2.3 如果老师要求换数据集,怎么接

有些作业会要求不能用 MNIST,那你可以考虑 EMNIST,它把字母也加进来了,格式和 MNIST 一样,改个名字就能加载。或者用 sklearn 自带的 digits 数据集,只有 1797 张 8×8 的图,量小但足够演示流程。换数据集时要注意两点:一是图像尺寸变了,网络输入层要跟着改;二是类别数变了,输出层维度要改。这两处不改,训练直接报维度错误。

提示:数据集下载慢是常见问题,可以提前把文件放到./data目录下,代码检测到已存在就不会重复下载。

3. 模型怎么搭:从全连接到卷积,参数到底怎么定

3.1 先跑一个全连接基线,知道下限在哪

别一上来就堆卷积,先用两层全连接跑一个基线,准确率大概能到 92% 左右。这个数字很重要,它是你的下限,后面换卷积如果没超过它,说明哪里写错了。

import torch.nn as nn import torch.nn.functional as F class MLP(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(28*28, 256) # 输入展平成 784 维 self.fc2 = nn.Linear(256, 10) # 输出 10 类 def forward(self, x): x = x.view(-1, 28*28) # 展平,-1 表示自动推断 batch x = F.relu(self.fc1(x)) x = self.fc2(x) return x

逻辑说明:view(-1, 784)把[batch, 1, 28, 28]变成[batch, 784]。fc1输出 256 是经验值,太小欠拟合,太大容易过拟合且慢。最后不加 softmax,因为交叉熵损失函数内部会做。

3.2 换成卷积网络,三个关键参数怎么设

卷积网络对手写字符更合适,因为它能利用局部空间关系。下面这个结构是我常用的,两层卷积加两层全连接,参数量不大,训练几分钟就能收敛。

class CNN(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, kernel_size=3, padding=1) # 1 通道进,32 通道出 self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) # 32 进,64 出 self.pool = nn.MaxPool2d(2) # 2x2 池化,尺寸减半 self.fc1 = nn.Linear(64*7*7, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.pool(F.relu(self.conv1(x))) # 28x28 -> 14x14 x = self.pool(F.relu(self.conv2(x))) # 14x14 -> 7x7 x = x.view(-1, 64*7*7) x = F.relu(self.fc1(x)) x = self.fc2(x) return x

参数说明:kernel_size=3配padding=1能保持特征图尺寸不变,这是最常用的组合。conv1输出 32 通道、conv2输出 64 通道,是逐层翻倍的思路,让网络先学边缘再学组合特征。池化用 2×2 最大池化,把 28 压到 14 再压到 7。fc1输入维度 64×7×7 必须和前面特征图尺寸对上,这是新手最容易写错的地方,尺寸不对就报矩阵乘法错误。

3.3 训练循环里必须盯住的三个量

训练代码本身不长,但有几个量要盯:损失是不是在降、训练准确率和测试准确率的差距、以及学习率有没有设对。

import torch.optim as optim device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CNN().to(device) optimizer = optim.Adam(model.parameters(), lr=1e-3) # Adam 对新手友好 criterion = nn.CrossEntropyLoss() for epoch in range(5): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() # 梯度清零,别忘 out = model(imgs) loss = criterion(out, labels) loss.backward() optimizer.step() print(f'epoch {epoch}, loss {loss.item():.4f}')

逻辑说明:zero_grad必须在反向传播前调用,否则梯度会累加。学习率1e-3是 Adam 的常用起点,太大震荡,太小收敛慢。跑 5 个 epoch 通常测试准确率能到 99% 左右。如果 loss 不降,先检查标签有没有对齐、学习率是不是太大。

4. 训练完怎么评估:别只看一个准确率数字

4.1 混淆矩阵能告诉你错在哪

准确率是个笼统指标,混淆矩阵才能看出模型把哪个数字认成了哪个。比如 4 和 9、3 和 8 经常混,这跟笔画结构有关。

from sklearn.metrics import confusion_matrix import numpy as np model.eval() all_preds, all_labels = [], [] with torch.no_grad(): for imgs, labels in test_loader: imgs = imgs.to(device) preds = model(imgs).argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm = confusion_matrix(all_labels, all_preds) print(cm)

逻辑说明:model.eval()会关掉 dropout 和 batchnorm 的训练行为,评估时必须开。torch.no_grad()省内存也加速。argmax(dim=1)取每行最大值的下标作为预测类别。拿到混淆矩阵后,看对角线外的数字,哪两个类之间错得多,就针对性地补这类样本。

4.2 用自己的手写图测试,暴露真实问题

这一步是作业加分项。用画图工具写一个数字,存成 png,然后走一遍预处理再喂给模型。

from PIL import Image img = Image.open('my_digit.png').convert('L') # 转灰度 img = img.resize((28, 28)) # 缩放到训练尺寸 img = transforms.ToTensor()(img) img = transforms.Normalize((0.1307,), (0.3081,))(img) img = img.unsqueeze(0).to(device) # 加 batch 维度 model.eval() with torch.no_grad(): pred = model(img).argmax(dim=1).item() print('预测结果:', pred)

逻辑说明:convert('L')保证单通道,resize保证尺寸一致,unsqueeze(0)补上 batch 维度。这里最常见的翻车是图片背景是白色、数字是黑色,而 MNIST 是黑底白字,导致预测全错。解决办法是做一次颜色反转,或者训练时就把数据增强加上反转。

注意:自己拍的图往往有噪声和倾斜,直接缩放效果一般,可以先用阈值二值化再裁剪到字符边界框。

5. 避坑与排查:五个我踩过的坑

坑一:训练准确率很高,测试准确率很低。现象是训练集 99.9%,测试集只有 90%。原因是过拟合,模型把训练样本背下来了。解决办法是加 dropout 层、做数据增强(随机平移、旋转),或者减小模型参数量。

坑二:loss 一直是 2.3 左右不降。现象是训练几轮损失几乎不动。原因是标签和输出没对齐,或者学习率太小。解决办法是检查CrossEntropyLoss的输入是不是原始 logits(不要自己先 softmax),学习率调到 1e-3 试试。

坑三:换数据集后报维度错误。现象是mat1 and mat2 shapes cannot be multiplied。原因是全连接层输入维度和特征图尺寸对不上。解决办法是打印卷积输出后的 shape,把fc1的输入改成对应值。

坑四:预测结果全是同一个数字。现象是无论输入什么都输出 0。原因是数据没归一化,或者初始化有问题。解决办法是确认Normalize的均值和标准差和训练时一致,检查权重初始化。

坑五:用自己的图测试全错。现象是 MNIST 测试集 99%,自己写的数字全认错。原因是背景色和训练数据相反,或者字符没居中。解决办法是做颜色反转,并把字符裁剪到边界框再缩放。

6. 把作业做出区分度:数据增强和可视化两个技巧

想让期末作业不只是「跑通」,可以加两件事。第一件是数据增强,用 torchvision 的RandomAffine做小角度旋转和平移,模拟手写的自然变化,通常能把自测准确率提几个点。

train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), # 旋转±10度,平移10% transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])

逻辑说明:degrees=10控制旋转范围,translate控制平移比例,这两个值别设太大,否则数字变形过度反而学不好。增强只加在训练集,测试集保持原样。

第二件是把卷积层的特征图可视化出来,看看网络到底学到了什么。取第一层卷积的输出,选几个通道画成热力图,你会看到有的通道对横线敏感,有的对竖线敏感。这个图放进作业报告里,比单纯贴准确率有说服力得多。

# 取一个 batch 的第一张图,看 conv1 输出 model.eval() with torch.no_grad(): sample = next(iter(test_loader))[0][:1].to(device) feat = F.relu(model.conv1(sample)) # [1, 32, 28, 28] feat = feat.squeeze(0).cpu() # [32, 28, 28] import matplotlib.pyplot as plt fig, axes = plt.subplots(4, 8, figsize=(12, 6)) for i, ax in enumerate(axes.flat): ax.imshow(feat[i], cmap='gray') ax.axis('off') plt.show()

逻辑说明:conv1输出 32 个通道,这里画前 32 个。squeeze(0)去掉 batch 维度。可视化能帮你判断网络是不是真的在学笔画特征,如果特征图一片模糊,可能是学习率或初始化有问题。

我自己做这类作业的习惯是,先把基线跑通拿到一个保底分数,再逐步加增强、加可视化、加自测环节,每一步都记录准确率变化。这样报告里既有过程又有对比,老师一眼能看出你是真动手了。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询