资讯动态

手写数字识别系统实战:从MNIST训练到Python部署全流程

发布时间:2026/10/2 14:25:22 来源:尧图企业网站定制
简介面向课程设计与图像识别入门学习者的 Python 手写数字识别课程资源覆盖从数字图像输入、数据预处理到多元线性回归多分类建模、模型测试的完整流程。压缩包共 16 个文件、约 251KB包括可直接运行的训练与识别 Python 脚本、设计报告 Word 文档、训练权重及标签 CSV 数据、9 张 28×28 测试位图以及 README 与 LICENSE 说明按“源码-数据-报告”组织便于对照学习。实现思路是先用 Windows 画图绘制黑底白字的 28×28 数字图像作为输入再借助多元线性回归模型对 09 十个类别完成识别训练与识别环节分离脚本可直接练习或二次修改。已有 4327 人浏览学习适合计算机相关专业课程设计、期末项目及手写识别入门实践参考。1. 手写数字识别系统从MNIST跑分到能落地的Python工程基于Python实现的手写数字识别系统这个压缩包标题拆开看其实是一条机器学习入门最完整的链路用MNIST手写数字数据集训练一个分类器再把它包成能交互使用的程序。很多人练手止步于训练完看一眼准确率而这类项目真正考验的是后半段——怎么把模型从notebook里搬出来变成一个对着手写图片能稳定出结果的东西。这套东西能解决的实际问题很具体表单上的数字自动录入、试卷分数识别、票据编号提取。OCR对这些场景往往过重规则匹配又太脆训练一个轻量分类器反而是性价比最高的方案。项目本身不大但对数据预处理、模型选型、序列化和GUI交互的完整度要求很高任何一个环节断了整个系统就卡壳。适合三类人正在找课程设计或毕业设计题目的学生刚学完Python语法想碰第一个机器学习项目的人以及被识别一批手写数字图片这种需求找上门的工程师。后面所有内容都围绕一个目标让系统从测试集99%走到随便拿一张真实手写图都稳定出结果。这就是手写数字识别系统项目真正值钱的地方。2. 从数据到模型MNIST数据管线、SVM基线与小型CNN两条建模路线2.1 MNIST数据加载与预处理28×28灰度图到张量的一步之遥MNIST是手写数字识别绕不开的数据集6万张训练图、1万张测试图每张都是28×28的灰度图像素值0到255。任务固定为十分类看起来很简单但坑恰恰藏在看起来简单里——训练和预测两条路的预处理一旦不一致模型精度再高也白搭。我见过太多人训练时用torchvision的transform预测时用OpenCV裸读的图片直接resize最后喂进去的分布完全对不上结果就是训练99%、预测全错。加载MNIST最省事的通道是torchvision.datasets它会自动下载并缓存配合DataLoader按batch迭代代码量很小。import torch from torchvision import datasets, transforms # Compose 按顺序执行:先转 Tensor,再做标准化 transform transforms.Compose([ transforms.ToTensor(), # PIL图片 - Tensor(C,H,W),像素缩到[0,1] transforms.Normalize((0.1307,), (0.3081,)) # MNIST全量均值/标准差, 直接沿用 ]) train_data datasets.MNIST( root./data, # 首次运行会自动下载, 之后读本地缓存 trainTrue, # True取训练集, False取测试集 downloadTrue, transformtransform ) test_data datasets.MNIST(root./data, trainFalse, downloadTrue, transformtransform) train_loader torch.utils.data.DataLoader( train_data, batch_size64, shuffleTrue, num_workers2 ) test_loader torch.utils.data.DataLoader( test_data, batch_size256, shuffleFalse, num_workers2 )代码逻辑分成两段transform定义单张图怎么变成模型输入DataLoader定义一批数据怎么迭代。这里几个参数值得记一下。ToTensor()把PIL图像转成(C, H, W)形状的Tensor并把像素从0~255缩放到0.0~1.0通道被放到最前面这是后面所有维度操作的基础。Normalize的两个数0.1307和0.3081是MNIST数据集的固有统计量直接拿现成的就行不用自己算。如果不做标准化CNN照样能收敛但收敛速度和稳定性会差一些尤其是网络加深以后。batch_size64是训练集常用的起步值shuffleTrue保证每个epoch数据顺序不同避免模型学到无用的批次顺序num_workers2让数据加载走子进程不至于卡住GPU主线程。如果你不想引入torchvision还有一条更轻的路tensorflow.keras.datasets.mnist.load_data()直接返回两个numpy元组适合快速验证SVM和随机森林这类传统模型。两条路的数据内容完全一致差别只在返回的数据结构和预处理职责。我一般建议后面要上CNN就用torchvision只跑传统机器学习就用keras的load_data省掉transform的配置成本。2.2 用sklearn SVM快速出基线先跑通链路再谈精度跑基线不是为了炫技而是验证数据链路通不通、评估脚本对不对。SVM在MNIST上表现够用且训练量远小于CNN是完美第一棒线性核大约92%准确率RBF核能做到98%以上。先用传统模型把数据加载、评估、可视化这条链路跑通后面换CNN时只需要替换模型主体排查范围会小很多。import numpy as np from sklearn import svm from sklearn.metrics import classification_report from tensorflow.keras.datasets import mnist # 借用keras下载通道, 返回numpy数组 (x_train, y_train), (x_test, y_test) mnist.load_data() # 28x28拉平成784维, 像素从[0,255]缩放到[0,1] X_train x_train.reshape(-1, 784) / 255.0 X_test x_test.reshape(-1, 784) / 255.0 # RBF核SVM; C控误分类惩罚, gammascale由sklearn按特征数自动推算 model svm.SVC(C5.0, kernelrbf, gammascale, verboseFalse) model.fit(X_train[:10000], y_train[:10000]) # 先拿1万条确认链路, 再全量训练 y_pred model.predict(X_test) print(classification_report(y_test, y_pred, digits4))参数层面有三个关键点。C是误分类惩罚系数C大模型越倾向把所有样本分对容易过拟合C小容错高容易欠拟合MNIST上C5是稳妥的中间值。gammascale表示让sklearn根据特征数量自动推算约等于1/(n_features * X.var())这个默认在MNIST上表现很好手动把gamma调大SVM会退化成逐样本记忆相似度测试准确率突然跳水。最后一个细节是我故意只fit前10000条——RBF核的SVM训练复杂度接近O(n²)到O(n³)全量六万条在普通笔记本上要跑十几分钟先用子集确认没错再全量训练是省时间的血泪经验。跑完看classification_report里的每类precision、recall、f1。如果某个数字的recall特别低比如1总被认成7常见原因是手写风格分布不均或预处理把细笔画磨掉了。这时先别急着上复杂模型回去检查预处理管线通常比换模型更有效。2.3 用PyTorch搭小型CNN卷积、池化、全连接的结构与训练循环传统模型给了98%的基线再往上走就要靠CNN了。CNN在MNIST上的优势在于卷积核天然适合捕捉笔画这种局部特征而且参数量远小于全连接网络直接作用于784像素的情况。我用的是一个精简的LeNet风格网络结构干净新手能看懂每个模块的作用。import torch.nn as nn class DigitCNN(nn.Module): def __init__(self): super().__init__() # 输入1通道(灰度), 输出32个特征图; padding1保证28x28尺寸不变 self.conv1 nn.Conv2d(1, 32, kernel_size3, padding1) self.conv2 nn.Conv2d(32, 64, kernel_size3, padding1) self.pool nn.MaxPool2d(2, 2) # 两次2x2池化后, 28x28变成7x7, 特征图64个 self.fc1 nn.Linear(64 * 7 * 7, 128) self.fc2 nn.Linear(128, 10) self.dropout nn.Dropout(0.25) def forward(self, x): x self.pool(torch.relu(self.conv1(x))) # 28x28 - 14x14 x self.pool(torch.relu(self.conv2(x))) # 14x14 - 7x7 x x.view(x.size(0), -1) # 拉平成 [batch, 64*7*7] x torch.relu(self.fc1(x)) x self.dropout(x) return self.fc2(x) # 最后一层不加激活, 交给CrossEntropyLossforward里的维度变化是理解CNN的钥匙输入(batch, 1, 28, 28)经过conv1relupool变成(batch, 32, 14, 14)经过conv2relupool变成(batch, 64, 7, 7)view拉平成(batch, 3136)后进入全连接。全连接层输出10维向量对应0~9每个类别的得分CrossEntropyLoss内部会先做softmax再算交叉熵所以最后一层不加激活。Dropout加在全连接层前训练时随机屏蔽25%的神经元防止过拟合。训练循环也需要单独写清楚因为它涉及模型模式切换这是新手最容易忽略的点。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() # 开启训练模式, 启用Dropout total_loss 0.0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) def evaluate(model, loader, device): model.eval() # 关闭Dropout, BatchNorm用累计统计量 correct 0 with torch.no_grad(): # 推理阶段不建计算图, 省显存 for images, labels in loader: images, labels images.to(device), labels.to(device) pred model(images).argmax(dim1) correct (pred labels).sum().item() return correct / len(loader.dataset) criterion nn.CrossEntropyLoss() optimizer torch.optim.Adam(model.parameters(), lr1e-3)model.train()和model.eval()的区别常被忽略train模式启用Dropout和BatchNorm的批次统计eval模式关闭Dropout并使用累计统计量。忘了切换就会出现训练时好好的一评估就翻车的怪现象。with torch.no_grad()让推理不保存梯度计算图显存占用和速度都有明显改善。这个训练循环是通用模板换数据集、换网络结构基本都能复用只改输入维度和类别数就行。3. 把模型装进系统训练脚本固化、模型持久化与Tkinter手写画板3.1 把训练流程写成可复跑脚本损失曲线与验证精度怎么看notebook里调通的代码要变成能交付的系统第一步是把训练流程固化成脚本。这样做的意义在于固定随机种子让结果可复现训练曲线落盘让之后能复盘全流程参数集中配置让换数据集时不用改代码逻辑。# train.py 核心结构 import random import numpy as np import torch import matplotlib.pyplot as plt def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) set_seed(42) # 固定种子, 保证多次训练结果一致 model DigitCNN().to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) train_losses, val_accs [], [] for epoch in range(12): loss train_one_epoch(model, train_loader, optimizer, criterion, device) acc evaluate(model, test_loader, device) train_losses.append(loss) val_accs.append(acc) print(fepoch {epoch1:02d}: loss{loss:.4f}, acc{acc:.4f}) # 曲线落盘, 训练完随时回看 plt.figure(figsize(8, 4)) plt.subplot(1, 2, 1) plt.plot(train_losses, markero) plt.title(train loss) plt.subplot(1, 2, 2) plt.plot(val_accs, markero, colorgreen) plt.title(val acc) plt.tight_layout() plt.savefig(training_curve.png, dpi150)曲线的看法就三句话。loss平稳下降说明学习率合适loss震荡说明学习率偏大或批次太小数值来回跳但整体不降八成是这两个参数的事train loss持续降但验证准确率不再涨就是过拟合信号优先加Dropout或数据增强而不是继续加epoch。固定随机种子这事看着玄学但你没法复现前一版结果的时候它就是唯一的后悔药。3.2 模型保存与加载joblib与state_dict的使用边界模型训练好后序列化是系统能否独立运行的分水岭。不同框架有各自的推荐做法混着用会踩坑。# sklearn模型用joblib, 能完整保存整个estimator import joblib joblib.dump(model, mnist_svm.pkl) loaded_svm joblib.load(mnist_svm.pkl) pred loaded_svm.predict(X_test[:5]) # PyTorch模型推荐只存state_dict(参数), 不存整个对象 torch.save(model.state_dict(), mnist_cnn.pth) # 加载时先建同结构网络, 再把参数灌进去 loaded_cnn DigitCNN() loaded_cnn.load_state_dict(torch.load(mnist_cnn.pth, map_locationcpu)) loaded_cnn.eval()两条路的使用边界要分清。joblib保存的是整个sklearn估计器包括参数、类和版本信息加载时要求Python和sklearn版本尽量一致跨大版本经常报错。PyTorch的torch.save(model.state_dict())只保存参数字典加载时必须先手动构建同结构的网络好处是跨版本兼容性更好坏处是你得保证网络结构代码没变过。还有一种常见错误是直接torch.save(model)把整个对象序列化这种做法在类定义路径变化或跨机器时会直接崩别图省事。我的习惯是模型文件旁边放一个requirements.txt把numpy、scikit-learn、torch、torchvision的版本号固定住。这样半年后换机器能少一大半加载失败的排查时间。3.3 Tkinter画板交互从Canvas笔迹到28×28张量的实时识别链路一个手写数字识别系统总得有人机交互入口。Tkinter是Python标准库自带的可视化工具不需要额外安装做一个鼠标写数字、点击识别的画板足够用。核心是把Canvas上的笔迹转成28×28的MNIST风格张量这一步的处理方式和训练数据越一致识别越准。import tkinter as tk from PIL import Image, ImageDraw import numpy as np import torch class DigitApp: def __init__(self, model, device): self.model, self.device model, device self.win tk.Tk() self.win.title(手写数字识别) # 画布280x280, 正好是28x28的10倍, 缩放取整方便 self.canvas tk.Canvas(self.win, width280, height280, bgwhite) self.canvas.pack() self.canvas.bind(B1-Motion, self.paint) self.canvas.bind(ButtonRelease-1, self.reset_line) btn tk.Frame(self.win); btn.pack(pady10) tk.Button(btn, text识别, commandself.predict).pack(sideleft, padx5) tk.Button(btn, text清空, commandself.clear).pack(sideleft, padx5) self.label tk.Label(self.win, text用鼠标写一个数字, font(Arial, 14)) self.label.pack(pady10) # L模式8bit灰度, 白底; draw对象负责记录笔迹 self.image Image.new(L, (280, 280), 255) self.draw ImageDraw.Draw(self.image) self.prev None self.win.mainloop() def paint(self, event): if self.prev is not None: x1, y1 self.prev self.canvas.create_line(x1, y1, event.x, event.y, width12, capstyleround) self.draw.line([x1, y1, event.x, event.y], fill0, width12) self.prev (event.x, event.y) def reset_line(self, event): self.prev None # 每次抬笔重置锚点, 否则会画出连接线 def clear(self): self.canvas.delete(all) self.image Image.new(L, (280, 280), 255) self.draw ImageDraw.Draw(self.image) self.label.config(text用鼠标写一个数字)这里有个细节容易翻车如果不绑定ButtonRelease-1去重置prev下一次落笔时paint里的prev还停留在上一次抬笔的坐标系统会在两次书写之间画一条不存在的斜线。line width用12是为了模拟真实笔迹的粗细太细的线条经过缩放后很容易在28×28上断裂数字1这类细笔画会直接消失。def predict(self): # 缩放到28x28, BILINEAR抗锯齿比NEAREST平滑 img self.image.resize((28, 28), Image.BILINEAR) arr np.array(img).astype(np.float32) # 白底黑字 - 黑底白字, 和MNIST方向一致; 再缩放到[0,1] arr (255.0 - arr) / 255.0 tensor torch.from_numpy(arr).unsqueeze(0).unsqueeze(0).to(self.device) with torch.no_grad(): prob torch.softmax(self.model(tensor), dim1) digit torch.argmax(prob, dim1).item() conf prob[0, digit].item() self.label.config(textf识别结果: {digit} 置信度 {conf:.2f})predict这一段是整条识别链路的缩影缩放、反色、归一化、扩维、推理、取softmax置信度。扩维的unsqueeze做了两次第一次从(28,28)变成(1,28,28)表示单通道第二次变成(1,1,28,28)表示batch为1。反色那一步尤其关键因为MNIST是黑底白字而Tkinter画布是白底黑字不做(255.0 - arr)这一步模型的输入语义就反了。4. 精度从98%到99%数据增强、超参数调优与多模型投票的收益边界4.1 数据增强随机平移旋转让模型学到什么基线CNN能到98.9%左右再往上推靠的是让模型见更多变形样本。MNIST虽然规范但真实手写不可避免有偏移和倾斜数据增强就是在训练时随机制造这些变化让模型学会忽略它们。注意增强只加在训练集测试集必须保持确定性处理否则评估结果就没法横向比较。from torchvision import transforms # 训练集增强: 旋转±10度, 平移±10%, 这会显著提升泛化 train_transform transforms.Compose([ transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) # 测试集只做确定性变换, 不做随机增强 test_transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])RandomAffine的degrees10表示随机旋转范围正负10度translate(0.1, 0.1)表示水平和垂直方向各随机平移最多10%。这两个参数对MNIST效果明显旋转太大会让6和9变得难以区分平移太大会把笔画推到边缘所以10度、10%是经过大量实验的稳妥区间。加入增强后同样的网络结构准确率能从98.9%提到99.2%以上并且对画板输入这种有轻微偏移的场景更鲁棒。判断增强是否过头的办法是看验证集准确率如果训练loss降但是验证acc不再涨甚至掉说明增强强度已经让模型学不动了。4.2 关键超参数怎么定学习率、batch_size、卷积核与epochs的搭配表超参数调优是手写数字识别系统里最容易被玄学化的部分。实际上MNIST小、训练快完全可以用每次改一个变量跑五轮看趋势的方式系统排查。下面这张表是我常用的起始建议适合小型CNN。超参数建议值调大/调小的影响学习率1e-3过大loss震荡不收敛过小收敛极慢10轮看不出动静batch_size64调大训练稳但显存占用高调小噪声大梯度抖动明显卷积核数量32/64增加通道数提升拟合能力但参数量和过拟合风险同步上升epochs10~15MNIST上超过15轮收益很小重点看验证曲线是否走平Dropout0.25调大抑制过拟合但可能欠拟合0.5以上对MNIST通常过重优化器Adam默认配置即可SGD需要手动调momentum和lr新手不建议先换学习率是最先该查的参数。用Adam配1e-3在MNIST上几乎不会出问题但如果换到其他数据集第一件事永远是画loss曲线确认收敛方向而不是直接调网络结构。batch_size的选取有个实用原则能放进显存就尽量用64不必为了追求理论上的梯度稳定性去用256以上。卷积核数量方面MNIST这种简单任务32/64足够翻倍到128收益很小训练时间却要翻几倍性价比很低。epochs的确定不看固定值看曲线验证准确率连续三轮不涨就停掉这叫早停。MNIST上手写数字类别差异大模型通常第8到第12轮就收敛了再往后训练只会过拟合到训练集的书写风格上。4.3 集成与迁移的边界MNIST场景下什么时候别用大模型到了99%这条线还想往99.5%以上够常见思路有两个多模型投票和迁移学习。多模型投票的做法是训练两个结构不同或初始化不同的模型比如一个CNN加一个SVM预测时各自给出类别分布取平均后argmax。逻辑很直接不同模型的错误模式不完全重叠投票能互相纠错。# 软投票: 多个模型各自输出概率, 取平均后argmax def ensemble_predict(models, tensor, device): probs [] for model in models: model.eval() with torch.no_grad(): prob torch.softmax(model(tensor), dim1) probs.append(prob) avg torch.stack(probs).mean(dim0) return avg.argmax(dim1).item(), avg.max(dim1).values.item()但MNIST有个特殊性数据太干净模型已经接近饱和集成提升通常只有0.2到0.3个百分点却要把推理时间翻好几倍。如果系统要打包给别人用这个成本要仔细掂量。迁移学习同理从ImageNet预训练的ResNet迁移到MNIST属于大材小用28×28灰度图和ImageNet的分布差异太大预训练权重不仅帮不上忙还可能拖慢收敛。我的结论是MNIST场景下99.2%这个水平对绝大多数业务都够用再往上属于为跑分而跑分不如把精力花在真实图片的预处理鲁棒性上。5. 手写数字识别系统避坑指南五个让新手翻车的真实场景5.1 训练准确率99%真实手写图全废现象模型在MNIST测试集上99%用手机拍的手写数字图片预测完全不对输出乱七八糟。原因预处理不一致。测试图片是几百像素的彩色图没灰度化、没缩放、没归一化直接resize成28×28就喂给模型像素分布和MNIST完全不在一个空间里。解决把训练和预测共用的预处理抽成一个函数任何人任何入口都只能走这一个函数。灰度化→反色→缩放到28×28→归一化这四步一个都不能少。我在工程里的习惯是把这个函数单独放一个preprocess.py训练脚本引用它做数据准备GUI和批量测试脚本也引用它保证全项目只有一份预处理逻辑。5.2 白底黑字被当成黑底白字现象在画板上写一个5识别结果却是3或9置信度还不低。原因MNIST是黑底白字而Tkinter画布、扫描件通常是白底黑字。像素语义完全反了模型看到的数字实际是背景背景才是数字。这个错误很隐蔽因为模型还是有把握地答错。解决预测前做反色处理把白底黑字变成黑底白字再归一化。代码就是一行的差别arr (255.0 - arr) / 255.0。排查方法很简单把预处理后的数组保存成图片看一眼如果数字是黑的、背景是白的说明反色那一步漏了。5.3 维度报错与类型报错HWC与NCHW、float64与float32现象用OpenCV或PIL读图喂给PyTorch模型报错Expected 4D input或者Expected dtype float32 but got float64。原因两个底层习惯差异。其一OpenCV读出来是H×W×C高、宽、通道PyTorch要求N×C×H×W批次、通道、高、宽维度顺序相反其二numpy默认float64而PyTorch模型参数是float32类型不匹配直接报错。解决读图后先transpose再扩维同时显式转换类型。arr img.transpose(2, 0, 1)[None].astype(np.float32)这段代码把HWC变成NCHW把float64转成float32。PIL读灰度图没有通道维度需要先用np.array(img)拿到数值再按28×28单通道处理。这个坑在混合使用不同图像库的时候尤其容易踩统一入口函数是根治办法。5.4 模型文件换机器加载失败现象在自己电脑上保存的pkl或pth模型拷贝到服务器上加载直接报ModuleNotFoundError或KeyError部分场景能加载但预测结果和原来不一样。原因joblib保存的sklearn模型依赖Python和sklearn的版本路径跨版本时类定义可能迁移PyTorch如果连整个model对象一起保存网络结构的类定义换机器后找不到。解决分别对待。sklearn模型用joblib.load前先确认两台机器的sklearn版本一致最好在requirements.txt里锁死PyTorch模型只保存state_dict然后重建网络再灌入这种做法不依赖类对象序列化兼容性最好。如果还要跨框架调用可以考虑导出成ONNX格式用onnxruntime做推理这条路能彻底绕开Python版本差异问题。当然最稳妥的办法是训练完直接打印一份数据预处理参数模型结构权重的完整清单跟模型文件放在一起。5.5 画板坐标漂移Canvas事件坐标与图像坐标对不齐现象在画板左上角写数字识别框却响应在别的区域或者写8时出现一条不该有的连线把数字底部连成一团。原因两个细节叠加。Canvas的坐标原点是左上角PIL Image的坐标原点也是左上角两者理论上一致但很多人会把canvas的高度、width传错或者resize时用了NEAREST采样导致笔迹断裂另一个常见问题是prev锚点没有在ButtonRelease时重置两次书写之间自动画了一条线。解决第一Canvas尺寸和Image尺寸保持一致280×280就是28×28的十倍等比缩放不用额外换算第二缩放用Image.BILINEAR别用NEAREST细笔画的锯齿在28×28下会直接消失第三绑定了ButtonRelease-1事件后把prev重置为None确保每次落笔是一段新笔迹。如果已经出现了坐标偏移先打印event.x和event.y对照Canvas尺寸排查八成是尺寸传反了。6. 用真实手写图片做端到端验证批处理脚本与打包交付系统做到这步MNIST测试集上的数字已经说明不了问题真正的验收标准是拿一批你没见过的真实手写图片批量跑一遍。常见做法是写一个批处理脚本用glob扫描目录下的图片逐一走同一套预处理流程输出每张图的预测和置信度。这里的关键细节是真实图片往往有大量留白和裁切不规范直接缩放会把笔画挤扁所以要先做边界框居中。import glob import numpy as np from PIL import Image import torch def mnistify(path, target28, pad4): 任意手写图片 - MNIST风格28x28张量 img Image.open(path).convert(L) arr np.array(img) mask arr 200 # 笔迹通常是深色 rows np.any(mask, axis1) cols np.any(mask, axis0) if not rows.any(): return np.zeros((target, target), dtypenp.float32) # 纯空白 rmin, rmax np.where(rows)[0][[0, -1]] cmin, cmax np.where(cols)[0][[0, -1]] # 裁边后转成正方形, 避免横竖比例变形 h, w rmax - rmin 1, cmax - cmin 1 side max(h, w) square np.full((side, side), 255, dtypenp.uint8) # 白底 top, left (side - h) // 2, (side - w) // 2 square[top:toph, left:leftw] arr[rmin:rmax1, cmin:cmax1] # 缩放 反色 归一化 放进带白边的画布 img2 Image.fromarray(square).resize((target-2*pad, target-2*pad), Image.BILINEAR) out np.full((target, target), 0, dtypenp.float32) # 黑底 off pad out[off:offtarget-2*pad, off:offtarget-2*pad] (255.0 - np.array(img2)) / 255.0 return out for p in sorted(glob.glob(test_imgs/*.png)): x mnistify(p) tensor torch.from_numpy(x).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): prob torch.softmax(model(tensor), dim1) digit prob.argmax(1).item() conf prob[0, digit].item() print(f{p}: 数字{digit}, 置信度{conf:.2f})mnistify这个函数里裁边和转正方形是重点。真实图片不会像MNIST那样天然居中如果直接resize数字会被横向或纵向拉伸变形识别率掉一截。先找到笔迹的边界框裁出来再补成正方形能保证数字始终在画面中央且比例不变。这套批处理脚本同时是回归测试工具——每次改完预处理或模型跑一遍全量真实图片对比之前的输出就能发现哪次改动引入了回归。交付方式也要提前想好。如果对方没有Python环境常见做法是用PyInstaller打包成可执行文件。打包命令一行就能跑通pyinstaller --onefile --add-data mnist_cnn.pth;. app.py。这里有个隐藏坑打包成exe后程序的工作目录可能不在exe所在目录直接按相对路径找模型文件会报错需要用sys._MEIPASS定位解包目录。import sys, os if getattr(sys, frozen, False): base sys._MEIPASS # PyInstaller 运行时解包目录 else: base os.path.dirname(__file__) model_path os.path.join(base, mnist_cnn.pth)把模型路径改成这种写法打包后不管是双击exe还是命令行调用都能正确找到模型文件。部署时顺手在exe同目录放一份测试图片让对方打开就能试比任何说明文档都管用。我现在接这类识别需求第一件事永远不是调模型而是先写一个把任意图片变成MNIST风格的预处理函数然后对中间结果逐个存图检查。这个习惯帮我挡掉了大半翻车场景——大部分识别失败的根因不在模型而在数据进模型之前那一路上。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑