☰
Python手写BP神经网络:从零实现MNIST手写数字识别与迁移
2026/10/2 1:23:31 网站建设 项目流程

简介:这套Python神经网络手写数字识别源码包,面向机器学习初学者、Python开发者及高校相关课程学生,用于理解神经网络如何通过训练实现图像分类,重点涵盖BP神经网络、激活函数、前向传播与反向传播等核心机制。压缩包共7个文件,仅154KB,体积精简但链路完整,包含1个Python实现脚本、5张不同样本的识别效果图片和1份Markdown说明文档:脚本负责数据加载与网络训练,可基于MNIST数据集完成模型训练与预测;图片直观展示模型对多个手写数字的识别输出;说明文档则梳理项目结构、运行方式与关键代码思路。已有180人学习下载,非常适合对照代码理解神经网络从输入到输出的完整流程,也方便在此基础上做参数调优、模型改进,或作为课程实验与毕业设计的起步模板。

1. 手写数字识别为什么总拿神经网络当第一个项目:任务定义与数据真相

只要你在搜索引擎里敲过“Python 神经网络 手写数字识别”,大概率会落到 MNIST 数据集上。这个任务看起来简单,但恰好是入门神经网络最锋利的一把刀:输入是 28x28 的灰度图,输出是 0 到 9 的十类概率,数据量不大,单机 CPU 就能跑完,但前向传播、反向传播、损失函数、梯度下降这些核心机制一个都不少。很多人以为神经网络识别手写数字只是“调库调参”的事,实际上只要你亲手用 Python 实现一遍 BP 算法,后面再看 PyTorch、TensorFlow 里的抽象接口,会有一种“原来黑匣子里面是这么回事”的通透感。

这篇笔记适合两类人:一类是刚学完 Python 基础、想用神经网络做点实物的新手,另一类是已经在用框架训练模型、但没手写过反向传播的熟手。我不打算给你堆一个几百行的工程,而是从数学骨架开始,落到最小可运行代码,再到训练评估和排错,最后告诉你如何把 MNIST 模型迁移到你自己写的数字上。读完你不仅能跑通一个手写数字识别网络,还能知道每个参数为什么这么设、失败时该看哪一行输出。

2. 从像素到概率:前馈神经网络的数学骨架与选型理由

2.1 神经元、激活函数与 Softmax:识别网络最小组成单元

一个用于手写数字识别的前馈神经网络,输入是一张 28x28 尺寸的图片。把它拉平后,就是 784 个像素的灰度值向量,喂给第一层神经元。每个神经元做的事情其实只有两步:计算输入向量的加权和,再套一个非线性激活函数。所谓“神经网络”,就是把这些神经元分层串起来,层与层之间用权重矩阵连接,前一层的输出成为后一层的输入。

以两层网络为例:第一层叫隐藏层,输出维度可以设成 64 或 128;第二层是输出层,因为 MNIST 有 10 个类别,输出层的神经元个数固定是 10。最后一层通常会接 Softmax,把十个神经元的输出压缩成一组和为 1 的概率,第几个位置的概率最高,预测结果就是那个数字。这就是“从像素到概率”的最短路径。这里强调一下,隐藏层必须加非线性激活函数,常用的有 Sigmoid、tanh 和 ReLU。如果每一层都是线性加权,那么无论如何堆层数,整个网络在数学上等价于一个线性分类器,永远学不出“弯曲”的决策边界。

2.2 为什么 MNIST 用全连接而不是卷积:边界与理由

很多人在搜索“手写数字识别”时看到的大多是卷积神经网络 CNN 的教程,紧接着就开始犹豫:是不是该直接上 CNN?我的建议是,在实现第一个神经网络时,先老老实实用全连接网络,也就是前馈神经网络。原因有三。第一,MNIST 的数字是居中、拉伸过的,背景干净,类别差异主要体现在笔画朝向和形状上,这些特征用全连接网络低层组合也能捕获,测试集准确率可以轻松到 97% 左右,对于入门完全够用。第二,全连接网络的实现代码量大约是 CNN 的三分之一,你花一个小时手写 BP 就能跑通,而 CNN 的卷积、池化、感受野概念对新手容易变成黑匣子,还没搞清楚反向传播就先被维度翻转淹没。第三,后续迁移到自定义数字时,全连接网络对预处理要求较低,调试更直观。

但并不是说 CNN 不值得学。CNN 在复杂图像、有平移和缩放变化的数据上优势明显,这也是为什么搜索热词里“卷积神经网络”常年和手写数字识别绑定。我的建议是:第一遍用全连接把机制打通,第二遍再用 PyTorch 或 Keras 实现一个 LeNet-5 替换掉全连接层,对比两者在验证集上的差距,你的理解会比直接抄 CNN 代码深得多。全连接是地基,CNN 是精装修,别跳级。

2.3 损失函数与梯度下降:让误差反向传播的闭环

神经网络训练的本质是极小化损失函数。对分类任务来说,最常用的损失是交叉熵损失。输出概率分布和真实标签之间的交叉熵越小,说明预测越接近正确类别。这和均方误差(MSE)有什么区别?手写数字识别本质是一个概率分布拟合问题,交叉熵对概率分布的差异更敏感,梯度随误差变化也更合理;而 MSE 在 Softmax 输出下容易梯度消失,训练会慢得多。所以动手写代码时,第一反应应该是交叉熵,不是 MSE。

有了损失函数,剩下的问题就是如何更新权重。梯度下降的思想很简单:计算损失对每个权重的偏导数,然后朝着负梯度方向更新权重。误差反向传播(BP)算法给出了一个高效计算所有偏导数的方法,即先把输出层误差算出来,再通过链式法则逐层往前传。这个“传”的过程就是反向传播,也是“前馈”网络的动力来源。许多热词里的“神经网络 正向 反向 传播 残差计算”,指的就是前向计算输出、反向计算残差、用残差更新权重这三步循环。理解并手写这三步,比调十个框架都值钱。

3. 用 Python 在本地跑通最小神经网络:代码实现与参数设置

3.1 搭建环境与数据准备:numpy、pandas 与 mnist 数据加载

开始写代码前,我先给出环境建议:Python 3.8 以上即可,核心依赖只有 numpy 和 matplotlib。不需要 GPU,不需要 CUDA,甚至不需要 PyTorch 或 TensorFlow,单核 CPU 跑几十个 epoch 也就几十秒。数据加载我推荐直接从 sklearn.datasets 提供的 fetch_openml 接口读 MNIST,它内部完成下载、切分和格式转换,返回的就是 numpy 数组,很适合脱离框架手写网络时使用。

import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import fetch_openml from sklearn.model_selection import train_test_split # 第一次运行会自动下载,约 12MB,数据会缓存在本地 mnist = fetch_openml('mnist_784', version=1, as_frame=False, parser='auto') X = mnist.data.astype(np.float32) / 255.0 # 像素值归一化到 [0,1] y = mnist.target.astype(np.int64) # 标签转成 int print(f"样本数量: {X.shape[0]}, 特征维度: {X.shape[1]}") X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) print(f"训练集: {X_train.shape}, 测试集: {X_test.shape}")

这段代码做了三件事:加载数据、归一化、按分层抽样切分训练集和测试集。归一化到 [0,1] 非常重要,原图的灰度值是 0 到 255,如果不除以 255,输入到神经元后的加权和会很大,Sigmoid 或 tanh 激活函数容易饱和,梯度直接消失到接近 0。stratify=y 保证训练集和测试集中 0 到 9 的比例一致,这样不会因为某个数字恰好集中在一侧而低估准确率。

3.2 核心代码:两层网络的前向与反向传播

下面就是一个最简但完整的全连接前馈神经网络,隐藏层 64 个神经元,ReLU 激活,输出层 Softmax,交叉熵损失。我把前向、反向和梯度更新全部写在一个类里,便于你逐一跟踪矩阵维度。

class TwoLayerNet: def __init__(self, input_size=784, hidden_size=64, output_size=10, lr=0.1): # 用 He 初始化减少 ReLU 带来的神经元死亡 self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2.0 / input_size) self.b1 = np.zeros(hidden_size) self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2.0 / hidden_size) self.b2 = np.zeros(output_size) self.lr = lr def forward(self, x): self.z1 = np.dot(x, self.W1) + self.b1 self.a1 = np.maximum(0, self.z1) # ReLU self.z2 = np.dot(self.a1, self.W2) + self.b2 # Softmax,防止数值溢出:减去每行最大值 exp_z2 = np.exp(self.z2 - np.max(self.z2, axis=1, keepdims=True)) self.a2 = exp_z2 / np.sum(exp_z2, axis=1, keepdims=True) return self.a2 def backward(self, x, y_onehot, output): batch_size = x.shape[0] # 输出层梯度:Softmax + CrossEntropy 的简化形式 dz2 = output - y_onehot self.dW2 = np.dot(self.a1.T, dz2) / batch_size self.db2 = np.sum(dz2, axis=0) / batch_size # 隐藏层梯度 da1 = np.dot(dz2, self.W2.T) dz1 = da1 * (self.z1 > 0) # ReLU 导数 self.dW1 = np.dot(x.T, dz1) / batch_size self.db1 = np.sum(dz1, axis=0) / batch_size def update(self): self.W1 -= self.lr * self.dW1 self.b1 -= self.lr * self.db1 self.W2 -= self.lr * self.dW2 self.b2 -= self.lr * self.db2 def train_step(self, x_batch, y_onehot): output = self.forward(x_batch) self.backward(x_batch, y_onehot, output) self.update() # 返回当前 batch 的交叉熵损失与准确率 loss = -np.sum(y_onehot * np.log(output + 1e-8)) / x_batch.shape[0] pred = np.argmax(output, axis=1) true = np.argmax(y_onehot, axis=1) acc = np.mean(pred == true) return loss, acc

这段代码的关键点在于反向传播的简化写法。当输出层使用 Softmax 且损失是交叉熵时,output - y_onehot就是输出层梯度,很多人卡在这一步,不知道为什么要减成 one-hot。逻辑推导是:交叉熵对 Softmax 输入的偏导最终化简为“预测概率减去真实分布”,你只需要记住这个优雅的结论,不需要每次重新推链式法则。dz1 = da1 * (self.z1 > 0)是 ReLU 的导数,前向激活值大于 0 的位置梯度直接穿透,否则截断为 0。权重初始化用了 He 初始化,标准差取sqrt(2/输入维度),这是为 ReLU 准备的,如果你用 Sigmoid,初始化应换成 Xavier,否则第一层梯度容易过早消失。

3.3 训练循环与超参数:学习率、batch size、epochs 怎么设

有了上面的类,训练循环本身只有几行,但它决定了整个网络能不能收敛。我一般会先固定一个较小的测试配置,跑通代码后再调参。下面给出一个可直接运行的训练脚本,包含 mini-batch 切分和每个 epoch 结束后的验证输出。

def to_onehot(labels, num_classes=10): onehot = np.zeros((labels.shape[0], num_classes)) onehot[np.arange(labels.shape[0]), labels] = 1 return onehot model = TwoLayerNet(hidden_size=64, lr=0.05) y_train_onehot = to_onehot(y_train) batch_size = 64 epochs = 30 for epoch in range(epochs): indices = np.random.permutation(X_train.shape[0]) total_loss, total_acc = 0, 0 num_batches = 0 for start in range(0, X_train.shape[0], batch_size): batch_idx = indices[start:start + batch_size] x_batch = X_train[batch_idx] y_batch = y_train_onehot[batch_idx] loss, acc = model.train_step(x_batch, y_batch) total_loss += loss total_acc += acc num_batches += 1 avg_loss = total_loss / num_batches avg_acc = total_acc / num_batches if epoch % 5 == 0 or epoch == epochs - 1: # 每 5 个 epoch 在测试集上验证一次 test_output = model.forward(X_test) test_pred = np.argmax(test_output, axis=1) test_acc = np.mean(test_pred == y_test) print(f"Epoch {epoch:3d} | 训练损失 {avg_loss:.4f} | 训练准确率 {avg_acc:.4f} | 测试准确率 {test_acc:.4f}")

这个循环里我用了随机打乱训练顺序的 mini-batch 梯度下降,而不是一次性用全部数据做梯度下降。mini-batch 的 batch size 取 64,这是一个常见默认值。为什么不是 1 或 78400?batch size 太小,梯度噪声大,训练不稳定;太大,每个 epoch 更新次数少,收敛变慢,且内存占用高。数值上,我这里 lr 取 0.05,如果你把 lr 设为 1.0,第一轮损失就会变成 nan;设为 0.001,30 个 epoch 可能只到 90% 准确率。经验法则是先用 0.1 跑 5 个 epoch 看损失下降趋势,若损失震荡,除以 10;若下降太慢,乘以 3 到 5。epochs 的合理范围在 20 到 50,MNIST 用全连接网络超过 50 个 epoch 后验证准确率提升很小,反而有轻微过拟合风险。运行这段代码,最终测试准确率应该在 0.96 左右,这就是你手写 BP 应得的回报。

4. 训练与评估:从准确率曲线到混淆矩阵的可信度验证

4.1 训练集/验证集/测试集划分,防止自欺欺人

很多人做手写数字识别时,只用训练集和测试集,训练完直接看测试集准确率,然后宣布“成功了”。但有一个常见的翻车点:你可能会为了调参反复使用测试集,最终让模型“记住”了测试集的信息,导致实际推广能力被高估。更规范的做法是切出三份数据:训练集、验证集、测试集。训练集用来更新权重;验证集用来调超参数,比如学习率、隐藏层大小,观察是否过拟合;测试集只在最终确认模型时用一次,用完就不要再碰。

X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, random_state=42, stratify=y_temp) print(f"训练集: {X_train.shape}, 验证集: {X_val.shape}, 测试集: {X_test.shape}")

上面的代码先分 30% 做临时集,再对临时集一半做验证集、一半做测试集。这样最终测试集完全独立。我在真实项目中见过不少因为偷看测试集调参,最后上线准确率暴跌 3 个百分点的案例。手写数字识别虽然简单,但训练集和测试集分布略有差异,比如手写风格、扫描噪声,如果模型被测试集调过参,这种差异会被放大。记住一句血泪经验:测试集是考卷,不是练习册。

4.2 评估指标:准确率、损失曲线与错分样本可视化

准确率是最直观的指标,但只告诉一个数字太单薄。损失曲线能反映训练动态:横轴是 epoch 或 batch 数,纵轴是损失。如果你把训练损失和验证损失画在同一张图上,能一眼发现欠拟合、过拟合、学习率过高导致的震荡。下面给出绘制损失曲线和错分样本的代码。

history = {'train_loss': [], 'val_loss': [], 'train_acc': [], 'val_acc': []} # 在训练循环中,每个 epoch 结束后记录验证集指标 for epoch in range(30): # ... 省略训练步骤 ... val_output = model.forward(X_val) val_loss = -np.sum(to_onehot(y_val) * np.log(val_output + 1e-8)) / X_val.shape[0] val_acc = np.mean(np.argmax(val_output, axis=1) == y_val) history['train_loss'].append(avg_loss) history['val_loss'].append(val_loss) history['train_acc'].append(avg_acc) history['val_acc'].append(val_acc) plt.figure(figsize=(10, 4)) plt.subplot(1, 2, 1) plt.plot(history['train_loss'], label='train_loss') plt.plot(history['val_loss'], label='val_loss') plt.xlabel('epoch') plt.ylabel('loss') plt.legend() plt.subplot(1, 2, 2) plt.plot(history['train_acc'], label='train_acc') plt.plot(history['val_acc'], label='val_acc') plt.xlabel('epoch') plt.ylabel('accuracy') plt.legend() plt.tight_layout() plt.show()

如果训练损失持续下降、验证损失却在某个 epoch 后开始上升,说明训练集过拟合,此时应该考虑正则化、增大数据量或降低模型容量。只有验证损失和训练损失同时下降,才代表模型真正在学习规律。错分样本可视化同样值得做:随机找几个被预测错的样本,打印真实标签和预测标签,把图片显示出来。你会发现不少错误是“7”被误判成“1”、笔画过轻或图像旋转太严重。别小看这一步,它决定了你要不要上数据增强,还是改成 CNN。

4.3 保存与复用模型:把训练好的参数持久化

训练完成后,如果每次运行都要重新训练,既浪费 CPU 又无法部署。把权重和偏置保存到磁盘是最基本的复用方式。因为我们的模型纯 numpy 实现,用 np.savez 保存即可。

np.savez('mnist_model.npz', W1=model.W1, b1=model.b1, W2=model.W2, b2=model.b2) # 加载模型用于推理 data = np.load('mnist_model.npz') W1, b1, W2, b2 = data['W1'], data['b1'], data['W2'], data['b2'] model.W1, model.b1, model.W2, model.b2 = W1, b1, W2, b2 # 单张图片预测函数 def predict_digit(image): # image 是已归一化、拉平的一维数组,长度为 784 output = model.forward(image.reshape(1, -1)) return np.argmax(output, axis=1)[0]

保存时建议同时保存训练用的预处理参数,比如归一化均值。MNIST 原始像素除以 255 后范围是 0 到 1,但有些自定义数据集会额外做均值减法,如果不保存均值,部署时预处理不一致,预测就会完全乱套。我习惯把预处理参数也写进 npz 文件,并在文件命名里加一个时间戳或隐藏层大小标记,避免模型文件覆盖后找不到对应结构。加载模型后最好用一个全新的测试集样本验证一次,确认与热加载模型的准确率一致,再拿去给别人用。

5. 手写数字识别的避坑与常见问题排查:现象、原因与解决

5.1 损失不下降:学习率过大、激活函数饱和、权重初始化不当

现象:训练前几个 epoch,损失一直停留在 2.3 附近,几乎不动;或者损失开始下降,到某个 epoch 后直接变成 nan。

原因:2.3 恰好是 10 分类交叉熵在均匀分布下的期望损失,说明网络输出一直是近似等概率的,权重根本没学到东西。最常见的原因是学习率太大或权重初始化不当。如果 W1 初始化的标准差过大,比如直接randn * 1.0,大批神经元进入 ReLU 的负数区,梯度为 0,神经元“死亡”;如果学习率太大,参数更新后可能发散,产生 nan。

解决:先用np.random.randn(...) * 0.01初始化全部权重,把学习率调到 0.01,跑 5 个 epoch,确认损失从 2.3 降到 1.8 以下。然后换成 He 初始化sqrt(2/n),再把学习率逐步调到 0.1,观察收敛速度。如果损失仍然不降,检查输入是否归一化到 [0,1],以及标签 one-hot 转换是否正确——我见过有人把 y 直接喂进训练,维度不匹配但代码用了 reshape 硬顶过去,结果梯度完全乱掉。

5.2 训练集准确率高但测试集低:过拟合与正则化选择

现象:训练 30 个 epoch 后,训练准确率 0.99,测试准确率只有 0.93,且验证损失在第 15 个 epoch 后开始上升。

原因:全连接网络参数总量很大,我们的输入是 784 维、隐藏层 64 维、输出 10 维,参数量已经超过 5 万,而 MNIST 只有 6 万训练样本。网络容量足以“记住”训练集中噪声和特定笔画的细节,却无法泛化到新样本。

解决:最直接的办法是减少隐藏层大小,从 64 降到 32,观察验证准确率是否反而上升。如果仍然过拟合,再考虑 L2 正则化,在损失函数上加一项权重平方和,但我建议先做数据增强——随机平移、旋转 10 度以内,模拟真实手写变化。对于纯 numpy 的实现,数据增强手动写也不复杂,每张图随机裁剪或添加高斯噪声,一个小技巧是先用scipy.ndimage的仿射变换。如果换成 PyTorch,可以直接用 torchvision 的 transforms,但这些库不是本方案必需的。

5.3 预测结果出现大量“6”和“9”混淆:数据增强与标签噪声

现象:模型整体准确率正常,但混淆矩阵中“6”与“9”互相误判率明显高于其他数字对,肉眼看起来几乎一样的数字被分错。

原因:MNIST 的“6”和“9”在缩放、旋转后确实很像,全连接网络对局部方向的鲁棒性弱。再一个原因是训练数据中这两个数字的辨异性特征没有在像素空间被充分展开。严格来说,这不是 bug,而是模型容量和先验结构不足。

解决:一个有效技巧是随机旋转 10 度并轻微缩放,我在自己的实现中做了在线增强,测试准确率提升约 0.3%。另一个思路是增加隐藏层神经元到 128 或增加一层隐藏层,但这会放大过拟合,必须配合 dropout。对于“6”和“9”的混淆,你还可以做一个后处理:在 Softmax 概率中,若 max 概率和次大概率之差小于 0.2,拒绝预测并标记为“不确定”,这在工程上比硬着头皮乱猜更有价值。

5.4 代码运行极慢:矩阵维度错误与 Python 循环陷阱

现象:训练一个 epoch 要几十秒,甚至几分钟,随着 epoch 增加越来越慢,最后和别人的实现速度相差几个数量级。

原因:新手最容易踩的坑是把矩阵运算写成 Python for 循环,比如每个样本逐条计算权重和,而不是用np.dot。还有人在反向传播中反复创建 numpy 数组,导致内存分配开销巨大。另一个维度错误是:输入 x 形状是 (批量, 784),权重 W1 是 (784, 64),如果误写成np.dot(W1, x),就会报维度不匹配或是因广播而偷偷运行但结果错乱。

解决:先用一个小批量比如 64 个样本,打印每个中间矩阵的 shape,维护一个断言:前向z1 = x @ W1 + b1中,x 列数必须等于 W1 行数。运行时优先使用np.dot或@,避免循环。其次检查是否在更新参数时用了self.W1 -= lr * dW1,如果是多条语句分开更新,numpy 的广播机制可能没问题,但如果你写成self.W1 = self.W1 - lr * dW1在函数内且没有返回值,就不会真正更新,这会导致损失永远是 2.3。还一个隐藏性能杀手是每次迭代都调用to_onehot转换标签,应该在训练开始前做一次,避免重复创建大数组。

6. 进阶技巧:把识别网络从 MNIST 迁移到自定义手写数字

6.1 图像预处理:二值化、去边框、缩放与居中

MNIST 数据集中的图片已经做了尺寸归一化和居中,但你自己收集或拍摄的手写数字往往是白底黑字、分辨率不一、位置偏斜、带有背景噪声。直接用 MNIST 训练好的模型去预测这种图片,准确率会掉得很惨。因此预处理的第一步是将任意图片统一成 MNIST 风格:灰度化、二值化、去除孤立噪声点、找到数字的边界框、按长边缩放到 20x20、再放到 28x28 画布的中心。

import cv2 import numpy as np def preprocess_custom_image(image_path): # 读取为灰度图,再反色使数字为白、背景为黑,与 MNIST 保持一致 img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img = cv2.bitwise_not(img) # 二值化,thresh 可调,常用 Otsu 自动确定阈值 _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 找到数字的轮廓并裁剪到边界框 coords = np.column_stack(np.where(img > 0)) y_min, x_min = coords.min(axis=0) y_max, x_max = coords.max(axis=0) cropped = img[y_min:y_max + 1, x_min:x_max + 1] # 保留长宽比缩放,避免数字变形 h, w = cropped.shape scale = 20.0 / max(h, w) new_h, new_w = int(h * scale + 0.5), int(w * scale + 0.5) resized = cv2.resize(cropped, (new_w, new_h), interpolation=cv2.INTER_AREA) # 放入 28x28 纯黑画布中心 canvas = np.zeros((28, 28), dtype=np.uint8) y_offset = (28 - new_h) // 2 x_offset = (28 - new_w) // 2 canvas[y_offset:y_offset + new_h, x_offset:x_offset + new_w] = resized # 归一化到 [0,1],对应模型的输入格式 return canvas.astype(np.float32) / 255.0

这里反色操作很容易忽略。MNIST 原图的背景是黑色,数字是白色,而普通扫描件往往是白纸黑字。如果图省事,直接丢给模型,模型会把背景当成主成分,预测结果几乎会变成随机的。缩放时用cv2.INTER_AREA而不是默认的线性插值,因为插值方式会对细小笔画产生锯齿,影响预测。居中时如果new_h是奇数,(28 - new_h) // 2会略微偏左,可以先判断奇偶再调整,但这个偏差对识别影响很小,可以忽略。

6.2 用 OpenCV 或 PIL 从一张图里提取多个数字

实际手指写出来的往往是一整行数字,而不是一张图一个数字。这时候需要把每个数字从图片中切开。最简单的做法是连通域分析:二值化后找到所有轮廓,过滤掉尺寸过小或过大的轮廓,然后按 x 坐标排序。下面给出基于 OpenCV 的切割函数。

def split_digits_from_image(image_path): img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 找轮廓,RETR_EXTERNAL 只取外层边界 contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) digit_boxes = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) # 过滤噪声:面积大于 20,高宽比在 0.2 到 3 之间,避免把噪声或连笔笔画当数字 area = w * h if area > 20 and 0.2 < h / w < 3: digit_boxes.append((x, y, w, h)) digit_boxes.sort(key=lambda box: box[0]) # 按 x 坐标排序 digits = [] for x, y, w, h in digit_boxes: # 向外扩 2 像素,避免切到笔画边缘 pad = 2 x1, y1 = max(0, x - pad), max(0, y - pad) x2, y2 = min(img.shape[1], x + w + pad), min(img.shape[0], y + h + pad) digit_img = binary[y1:y2, x1:x2] # 再调用上一节的 preprocess_custom_image 对裁剪图做归一化 digits.append(preprocess_custom_image_from_array(digit_img)) return digits

这段代码里的THRESH_BINARY_INV把白色背景变成黑色,数字变成白色,和 MNIST 一致。过滤条件area > 20能去掉摄像头噪点,但如果数字很小,可以调低到 10。h/w的上下限主要是防止把一条长竖线当成“1”的完整轮廓后又把其他字符粘连进去。如果你的图片中存在两个数字连笔,轮廓把它们连成一个整体,此时需要更复杂的投影切割法,但我建议先在数据采集时尽量让数字分开,比算法更可靠。

6.3 重训练策略:用 MNIST 预训练权重微调还是从零开始

迁移到自定义数字时,你有两条路:一是直接利用 MNIST 训练好的权重做推理,二是把自定义数据集和 MNIST 混起来重新训练。我实际测试下来的经验是,如果你的自定义样本只有几十张,直接推理并不可靠;最优方案是收集至少 200 到 500 张每类样本,用 MNIST 预训练的权重作为初始值,冻结前几层,只微调最后一层。但是我们的全连接网络只有两层,层数太少,冻结第一层后剩余层参数很少,意义并不大,不如直接用自定义数据从头训练。

这里给你一个更实用的建议:把 MNIST 的 6 万张图作为预训练数据,然后把自定义的几百张图按 7:3 划分训练集和验证集,将两者混合后按 9:1 的样本比例重新训练全连接网络。因为自定义样本占比少,学习率要比训练 MNIST 时更低,比如 0.01,且增加正则化,避免小样本过拟合。如果你对精度有更高要求,建议此时切换到 PyTorch,用预训练的 ResNet18 或 LeNet 做微调,热词榜里的“pytorch 手写数字识别”就是在这一步发力。但核心概念你已经用手写网络打通了,换框架只是换语法。

训练结束后,建议做一次极端测试:把你自己手写的数字拍照,用上面的预处理流程走一遍,观察错误样本。你会发现,自己写的“0”和“6”因为起笔位置不同,很可能会被误判,这是全连接网络的极限。我会在数据增强里加入随机旋转来缓解,但更根治的办法是改用 CNN 并加大训练集。希望帮到你,这条从手写 BP 到自定义数字识别的路,走通一次,后面学任何神经网络都会轻松不少。

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

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

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

立即咨询