简介:一套基于Python实现的手写数字识别系统源码与配套数据集,主要面向需要完成课程设计或期末大作业的Python学习者,也适合深度学习、计算机视觉初学者将其作为第一个综合实践项目。项目已通过导师指导并获得97分高分评价,代码完整且可运行,下载后无需修改即可直接复现结果,主要流程包括数据读取、预处理、模型训练与数字识别测试,能够作为课程项目模板或答辩演示材料直接使用。资源包共3个文件,分别是Python主脚本、rar压缩格式的数据集和Markdown说明文档,整体约11.06MB;三种文件承担不同角色:脚本实现核心识别逻辑,数据包提供手写数字样本,说明文档介绍项目结构与运行方式,层次清晰、便于按需查看。目前已有260人学习下载,项目完整性与实用性得到多数下载者的认可。借助这份压缩包,读者可以快速跑通一个可演示的数字识别Demo,并根据文档理解特征处理与参数调整的基本思路,还能在此基础上替换数据集、优化网络结构,完成扩展到更高阶的模型实验。无论是用于课程考核、毕业设计前练手,还是丰富个人项目经历,都能提供扎实的起点,同时省去大量环境搭建与排错时间。
1. 手写数字识别系统:一份能跑通的期末作业源码里到底有什么
“基于Python实现的手写数字识别系统源码+数据集”,这份期末大作业包我刚拆完就跑通了。它不是那种需要自己补半天环境、还得猜数据格式的半成品,而是把数据集(data.rar)、训练主程序(test2.py)、说明文档(README.md)打包在一起,按 README 的步骤走就能完成从数据加载到模型评估的完整闭环,README 里标注了导师评分 97 分,项目结构确实对得起这个分数。手写数字识别(MNIST 是最常见的入门数据集)这个任务看起来小,但涵盖了图像分类的全部关键环节:数据读取、归一化、网络构建、训练、评估、误差分析。你把这套流程走通,后面换成其他图片分类任务都是同一套骨架。这份资源适合两类人:课程设计或期末大作业急需交付的学生,以及想通过 MNIST 入门深度学习图像识别的开发者。
2. 资源包结构与技术选型:为什么是MNIST+CNN这套组合
2.1 解压后你会看到什么:数据、脚本和文档的三层分工
拿到 zip 包之后,我的习惯是先看目录结构,而不是急着跑代码。这份资源的核心内容就三块:data.rar存放训练数据,test2.py是训练与评估主程序,README.md是使用说明。按照 README 的描述,先解压数据,再运行主程序,顺序别搞反,否则脚本找不到数据文件会直接抛异常。
| 文件 | 类型 | 职责 | 使用时机 |
|---|---|---|---|
| data.rar | 压缩数据集 | 存放手写数字训练集/测试集 | 第 1 步解压到./data/ |
| test2.py | Python 脚本 | 数据加载、模型构建、训练、评估 | 解压完成后执行 |
| README.md | 说明文档 | 环境要求、运行步骤、依赖清单 | 动手之前先读一遍 |
我一般会先看一眼 data.rar 里的内容再决定后续怎么处理,避免解压后发现目录层级和代码对不上。Linux 环境用unrar,Windows 上直接用 WinRAR 或者 7-Zip 解压到data文件夹即可。
unrar e data.rar ./data/ ls -lh ./data/unrar e会把压缩包内所有文件解压到指定目录并去掉原有目录层级。执行完ls后重点看两件事:文件数量是否和 README 描述一致,以及每个文件的行数或图片数量。手写数字识别最经典的 MNIST 数据集是 6 万张训练图加 1 万张测试图,课程设计包为了便于查看,通常会转成 CSV 格式,每一行是 784 个灰度像素值加上一个标签列。如果解压出来是图片目录结构(比如train/0/、train/1/),那 test2.py 里对应的数据读取方式就会换成flow_from_directory,这点在读取之前最好确认一下。
2.2 为什么选 CNN 而不是 SVM 或逻辑回归
理论上手写数字识别用传统机器学习也能做,比如 HOG 特征加 SVM 可以把准确率做到 95% 左右,逻辑回归大概 92%。但问题在于,这类方案需要先做特征工程,对书写风格变化、笔画粗细非常敏感,换一套测试数据效果就可能明显下滑。对于课程设计来说,这不算一个“稳”的方案。
CNN 解决的是特征提取的自动化问题。卷积核在 28x28 的灰度图上滑动,自动学习边缘、笔画端点、弧线这些局部特征,再逐层组合成数字的高层语义。对一个两层卷积的浅层网络来说,MNIST 准确率可以轻松到 99% 以上,而且结构足够简单,答辩时每一层的输入输出形状、参数量都能讲得明明白白。
test2.py 里搭建的就是这类经典结构:
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout model = Sequential([ Conv2D(32, (3, 3), activation="relu", input_shape=(28, 28, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activation="relu"), MaxPooling2D((2, 2)), Flatten(), Dense(128, activation="relu"), Dropout(0.3), Dense(10, activation="softmax"), ])这个结构的参数量分布很值得算一遍。第一层卷积的权重是 33132 再加 32 个偏置,共 320 个参数;经过 2x2 最大池化后,特征图从 28x28 降到 14x14。第二个卷积层输入是 32 通道,参数变成 333264 再加 64 个偏置,共 18496 个。再池化一次降到 7x7,Flatten 之后是 7764=3136 个特征,映射到 128 个神经元,最后输出层是 128*10+10=1290 个参数。整个模型加起来约 31 万个参数,CPU 训练完全跑得动。
| 层 | 输出形状 | 参数量 | 作用 |
|---|---|---|---|
| Conv2D(32, 3x3) | 28x28x32 | 320 | 提取边缘与笔画特征 |
| MaxPooling2D | 14x14x32 | 0 | 降维,保留主要特征 |
| Conv2D(64, 3x3) | 14x14x64 | 18496 | 提取更高层特征 |
| MaxPooling2D | 7x7x64 | 0 | 二次降维 |
| Flatten | 3136 | 0 | 展平接入全连接 |
| Dense(128) | 128 | 401664 | 特征组合 |
| Dropout(0.3) | 128 | 0 | 随机丢弃防过拟合 |
| Dense(10) | 10 | 1290 | 输出 0-9 概率 |
激活函数的选择也有讲究。卷积层和全连接层用 relu 而不是 sigmoid,是因为 relu 在正区间梯度恒为 1,能有效缓解深层网络梯度消失的问题。输出层用 softmax,把 10 个神经元的输出变成概率分布,概率最大的索引就是识别结果。
数据集为什么单独压成 rar 而不是直接放原目录?通常有两个原因:一是平台上传有文件数限制,压缩后方便传输;二是老师要求提交原始数据文件,保证可复现。这其实是个好习惯,下载解压后内容和 README 对得上,才说明资源完整。
3. 从依赖安装到模型训练:把test2.py完整跑通
3.1 环境准备与依赖安装
跑通这个项目的关键不是模型代码,而是 Python 和 TensorFlow 的版本匹配。这个坑几乎每个人都会踩一次:Python 3.11 环境里装 TensorFlow 2.5,结果import tensorflow直接 DLL load failed。我建议用 Python 3.8 到 3.10 之间的版本,搭配 TensorFlow 2.10,这是兼容性最稳的组合。
python --version pip install numpy==1.24.3 matplotlib opencv-python pip install tensorflow-cpu==2.10.0tensorflow-cpu是 CPU 版本,不需要额外装 CUDA 和 cuDNN。MNIST 的数据量不大,CPU 上训练 10 个 epoch 也就几分钟,完全没必要为了这个项目去配 GPU 环境。numpy锁版本是为了避免部分老代码用到np.float这类在新版本中移除的别名时报错。opencv-python就是常用的cv2库,后面做单张图片预测和实时识别都会用到。
如果安装速度慢,可以换国内镜像源:
pip install tensorflow-cpu==2.10.0 -i https://pypi.tuna.tsinghua.edu.cn/simple装完依赖之后,建议先跑一行import tensorflow as tf; print(tf.__version__)验证安装成功,再往下走。这一步花十秒钟,能省掉后面排查半天环境问题的时间。
3.2 数据加载与预处理:还原 28x28 的图片结构
手写数字识别的数据预处理,最核心的一点是把一维的 784 个像素还原成 28x28 的二维图片,并且做归一化。test2.py 里默认读取./data/train.csv,这是课程设计最常见的组织方式,直接把 MNIST 转成带表头的 CSV。
import numpy as np # 从 CSV 读取数据,第一行是表头 train_data = np.loadtxt("./data/train.csv", delimiter=",", skiprows=1) # 第一列是标签,其余 784 列是 28x28 的灰度像素 X = train_data[:, 1:].reshape(-1, 28, 28, 1).astype("float32") / 255.0 y = train_data[:, 0].astype("int")skiprows=1跳过了 CSV 里的表头行。train_data[:, 1:]取出从第二列开始的像素数据,reshape(-1, 28, 28, 1)把每一行的 784 个值还原成 28 行 28 列的单通道图片,最后一个维度 1 表示灰度通道。如果这里忘了加通道维度,后面喂给卷积层时会直接报维度不匹配的错误。除以 255 是归一化操作,原始像素范围是 0 到 255,网络训练时更希望输入落在 0 到 1 之间,这样梯度更新更稳定,收敛速度也更快。我经常看到有人在归一化这一步偷懒,不除或者除错,结果准确率卡在 80% 上不去。
如果解压出来的数据不是 CSV,而是按类别存放的图片目录,那就改用flow_from_directory方式加载:
from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen = ImageDataGenerator(rescale=1.0 / 255.0, validation_split=0.2) train_gen = datagen.flow_from_directory( "./data/train", target_size=(28, 28), color_mode="grayscale", batch_size=128, class_mode="categorical", subset="training", )这种方式要求目录结构是train/0/xxx.png、train/1/xxx.png这样按数字类别分文件夹组织。两者路径不同,但核心逻辑一样:把任意来源的手写数字图片统一成 28x28 的单通道灰度图,再做归一化。下面先按 CSV 流程往下走,因为这是 test2.py 验证过的主链路。
3.3 模型构建与训练:输入和标签的形状必须对齐
模型构建部分核心是搞清楚两个维度:图片输入形状和标签编码方式。图片是 28x28x1,标签如果是整数形式 0 到 9,就要转成 one-hot 编码,也就是每个标签变成一个长度为 10 的向量,比如数字 3 变成[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]。
from tensorflow.keras.utils import to_categorical # 将整数标签转成 one-hot 编码 y_cat = to_categorical(y, 10) model.compile( optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"], ) history = model.fit( X, y_cat, batch_size=128, epochs=10, validation_split=0.2, verbose=1, )build逻辑在这里,模型结构在 2.2 节已经定义了,compile阶段指定优化器、损失函数和评估指标。adam是自适应学习率优化器,对新手最友好,不需要手动调节学习率。categorical_crossentropy配合 one-hot 标签使用,如果你不想做 one-hot 编码,可以把损失函数换成sparse_categorical_crossentropy,效果等价。
validation_split=0.2会在训练数据里切出 20% 作为验证集,用于观察模型在没见过数据上的表现,这是判断过拟合的唯一途径。batch_size=128表示每轮训练取 128 张图片计算一次梯度,batch 太大内存压力大,太小训练不稳定。epochs=10对 MNIST 这个任务来说已经足够,训练过程中可以直观看到损失下降趋势。
训练完成后保存模型,这一步非常关键,否则每次评估都要重新训练:
model.save("mnist_model.h5")保存为 h5 文件后,后续可以直接加载模型做预测和评估,不需要再跑训练流程。下面是 10 个 epoch 过程中典型的指标变化:
| epoch | loss | accuracy | val_loss | val_accuracy |
|---|---|---|---|---|
| 1 | 0.352 | 0.902 | 0.089 | 0.973 |
| 3 | 0.108 | 0.967 | 0.062 | 0.982 |
| 5 | 0.071 | 0.979 | 0.046 | 0.986 |
| 8 | 0.041 | 0.987 | 0.036 | 0.990 |
| 10 | 0.028 | 0.991 | 0.030 | 0.992 |
从表格可以看出,训练损失持续下降,验证准确率同步上升,到第 10 轮时训练集准确率 99.1%,验证集 99.2%,两者差距很小,说明模型没有明显过拟合。如果 val_loss 在第 5 轮之后开始反弹而训练 loss 还在降,那就要考虑减小 epochs 或者加大 Dropout 比例了。
4. 评估指标与可视化:准确率之外还要看什么
只报告一个准确率数字在答辩时很难撑住场面,老师通常会追问:模型在哪些数字上容易犯错?有没有证据证明模型真的学到了特征,还是只是记住了训练集?这一章把评估环节做扎实,报告会更有说服力。
4.1 损失曲线:直观判断过拟合
训练过程返回的history对象里保存了每个 epoch 的损失和准确率,把它画出来是最直观的评估方式。
import matplotlib.pyplot as plt plt.plot(history.history["loss"], label="train_loss") plt.plot(history.history["val_loss"], label="val_loss") plt.xlabel("epoch") plt.ylabel("loss") plt.legend() plt.title("Loss Curve") plt.savefig("loss_curve.png")判断方法很简单:两条曲线都在下降并逐渐平缓,说明训练正常;训练损失持续下降但验证损失在第 6 轮开始反弹,就是过拟合的典型信号,此时应减少 epochs、增加 Dropout 比例,或者引入数据增强。把这张曲线图贴进课程设计报告,比单纯写“模型准确率 99%”更有说服力,因为它展示了调优过程。
4.2 混淆矩阵:找出最容易认错的数字
准确率是一个整体指标,混淆矩阵才能告诉你错误具体发生在哪里。
from sklearn.metrics import confusion_matrix, classification_report # 加载测试集并预测 test_data = np.loadtxt("./data/test.csv", delimiter=",", skiprows=1) X_test = test_data[:, 1:].reshape(-1, 28, 28, 1).astype("float32") / 255.0 y_test = test_data[:, 0].astype("int") pred = np.argmax(model.predict(X_test), axis=1) cm = confusion_matrix(y_test, pred) print(classification_report(y_test, pred, digits=3))confusion_matrix生成一个 10x10 的矩阵,cm[i][j]表示真实数字 i 被预测成了 j 的次数。对角线上的值是正确预测,越大越好。classification_report会输出每个类别的精确率、召回率、F1 值。
从多次经验来看,最容易混淆的组合是 3 和 5、4 和 9、7 和 2,原因在于手写风格潦草时局部笔画特征相似,加上 28x28 分辨率较低,部分细节在缩放过程中丢失。如果对角线之外有数值特别大的项,说明模型对某对数字的区分能力不足,这时候优先检查数据集中是否存在大量书写不清晰的样本,而不是急着改模型结构。毕业论文往错误分析部分贴一张混淆矩阵热力图,配上这类分析,导师会认为你做了完整的误差分析闭环。
4.3 单张图片预测:模型导出后的标准化验证
训练完成的模型要验证它真的能用,最好的方式是拿一张新写的手写数字图片走完整预测流程。
import cv2 import numpy as np img = cv2.imread("handwritten_5.png", cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (28, 28)) # 如果图片是黑字白底,需要翻转成白字黑底,与训练数据一致 img = 255 - img img = img.reshape(1, 28, 28, 1).astype("float32") / 255.0 pred = model.predict(img) print("预测结果:", np.argmax(pred), "置信度:", np.max(pred))cv2.imread以灰度模式读取图片,resize把任意尺寸统一到 28x28。翻转操作很关键:MNIST 训练数据是白字黑底,而我们用手机拍的照片通常是黑字白底,如果不翻转,模型会把背景当成笔画来识别,表现是准确率骤降。翻转之后再做归一化,然后增加 batch 维度变成(1, 28, 28, 1),因为模型训练时接受的输入是四维张量,单张图片必须补上第一个维度。最后np.argmax从 10 个概率值里取最大的索引作为识别结果。这一步走通了,才算真正完成了从数据解压到模型部署的完整验证。
5. 常见问题与避坑:运行报错和精度上不去的六个场景
5.1 import tensorflow 直接报错:DLL load failed 或 ModuleNotFoundError
现象:执行import tensorflow as tf时,在 Python 3.11 或 3.12 环境里直接报ModuleNotFoundError,或者报 DLL 加载失败。
原因:Python 版本与 TensorFlow 版本不匹配。TensorFlow 2.10 以及更早的版本在 Python 3.10 之后兼容性很差,新版 Python 只支持新版本 TensorFlow,而课程设计包里的代码是按老版本 API 写的。
解决:不要跟系统 Python 硬杠,建一个虚拟环境再装。
conda create -n digit python=3.9 conda activate digit pip install tensorflow-cpu==2.10.0这里 Python 3.9 配 TensorFlow 2.10 是经过验证的稳定组合。
5.2 数据解压后形状对不上:找不到 784 个像素
现象:执行np.loadtxt后报错,提示字符串无法转成浮点数,或者train_data.shape不是预期的二维数组。
原因:CSV 带表头时没有加skiprows=1,字符串列混进了数值矩阵;也可能是data.rar里放的根本不是 CSV,而是图片目录,脚本按错误格式解析了。
解决:先检查数据形态,不要跳过这一步直接跑训练。
train_data = np.loadtxt("./data/train.csv", delimiter=",") print(train_data.shape) print(train_data[0])输出第一行看一眼是数字还是字母,立刻就能定位问题。解压出来是图片目录的话,换flow_from_directory方式加载,路径指向./data/train即可。
5.3 训练 Loss 变成 NaN
现象:训练到第几个 epoch 时 loss 突然变成 nan,准确率卡在一个值不再变化。
原因:最常见的是输入数据里有 NaN 值,或者没有做归一化,像素值范围过大导致梯度爆炸。
解决:先检查数据。
print(np.isnan(train_data).sum())如果结果为 0,确认归一化操作已执行。还可以把优化器改成显式指定学习率:
from tensorflow.keras.optimizers import Adam model.compile( optimizer=Adam(learning_rate=1e-3), loss="categorical_crossentropy", metrics=["accuracy"], )1e-3 的学习率对 MNIST 是安全范围。如果还出现 NaN,用np.clip把输入限制在 0 到 1 之间之后再喂给模型。
5.4 准确率卡在 80% 左右上不去
现象:模型训练完成,准确率在 80% 附近波动,上不了 90%。
原因:网络结构太浅,或者输入没有归一化,也可能是 epochs 只有 3 到 5 轮,模型还没收敛。更隐蔽的原因是把 28x28 的图片 reshape 成 784 维后直接输入全连接层,丢掉了空间结构信息。
解决:确认归一化已执行,把网络改成卷积结构。如果已经是卷积结构但准确率低,把第一层卷积核从 16 提到 32,epochs 从 5 提到 10。如果数据集是自采的小规模样本,80% 可能就是数据量的上限,此时应该做数据增强而不是盲目加深网络。
5.5 预测单张图片时维度报错
现象:ValueError: Input 0 of layer sequential is incompatible with the layer: expected axis -1 of input shape to have value 1 but received input with shape (28, 28)。
原因:模型训练时输入是(batch, 28, 28, 1),单张图片如果不加 batch 维度,形状不匹配直接报错。
解决:补一个维度即可。
img = img.reshape(1, 28, 28, 1).astype("float32") / 255.0如果你不想用reshape,也可以用img[None, ...],效果一样。
5.6 显示手写图片时颜色反了或者全黑
现象:用plt.imshow(img)显示手写图片时,背景是黑色、数字是白色,跟平时看到的黑字白底不一样。更严重的是,模型预测手机里拍的数字时准确率骤降。
原因:MNIST 训练数据是白字黑底,很多课程设计包在保存图片时也是白字黑底。而我们日常见到的扫描件、手机拍的照片是黑字白底,两者颜色正好相反。如果直接把黑字白底的图片喂给模型,所有像素值的关系都反了,模型看到的完全不是训练时的分布。
解决:显示时加cmap="gray",预测时按实际图片做颜色翻转。
plt.imshow(img, cmap="gray") # 黑字白底时翻转 img = 255 - img这个坑很隐蔽,我见过有人把手机拍的数字全部预测成 1,查了好久才发现是颜色翻转问题。现在看到新图片,第一反应就是判断它和训练数据的背景关系。
6. 把识别系统接到摄像头:实时手写数字识别的改造思路
模型训完别急着收工,可以做一个鼠标画板实时识别的小工具,把 test2.py 的能力延伸到一个可交互的场景。核心思路是:用 OpenCV 建一个 280x280 的画布,鼠标在白底上画数字,按下任意键后对画布做预处理,扔给模型预测。
import cv2 import numpy as np from tensorflow.keras.models import load_model model = load_model("mnist_model.h5") canvas = np.zeros((280, 280), dtype=np.uint8) drawing = False ix, iy = -1, -1 def draw(event, x, y, flags, param): global drawing, ix, iy if event == cv2.EVENT_LBUTTONDOWN: drawing = True ix, iy = x, y elif event == cv2.EVENT_MOUSEMOVE and drawing: cv2.line(canvas, (ix, iy), (x, y), 255, 12) ix, iy = x, y elif event == cv2.EVENT_LBUTTONUP: drawing = False cv2.namedWindow("draw") cv2.setMouseCallback("draw", draw) while True: cv2.imshow("draw", canvas) key = cv2.waitKey(1) & 0xFF if key == ord("q"): break img = cv2.resize(canvas, (28, 28)) img = img.reshape(1, 28, 28, 1).astype("float32") / 255.0 pred = model.predict(img) print("识别结果:", np.argmax(pred), "置信度:", np.max(pred))dtype=np.uint8保证画布是 8 位灰度图,cv2.line用 255 画白线,正好是黑底白字,和 MNIST 训练数据一致,不用额外做翻转。setMouseCallback把鼠标事件绑定到画布窗口,按 q 退出循环。退出后把 280x280 画布缩放到 28x28,归一化后直接预测。
实时识别和离线预测的预处理链路完全一致:灰度化、缩放、归一化、加 batch 维度。模型本身不需要做任何改动,改的只是图片来源。如果要把这套流程接到摄像头画面,替代方案是把摄像头 ROI 区域截下来,预处理之后再预测,逻辑完全复用。从那以后,我每次拿这类项目做图像分类改造,都会强制走一遍“形状检查、颜色翻转、归一化、加 batch 维”这条链路,确认输入和训练时完全一致再动模型。这个习惯帮我省了很多次调参的冤枉时间,希望帮到你。
本文还有配套的精品资源,点击获取