1. 项目全貌:识别链路不是“训练完”就结束的
PyTorch的入门项目里,LeNet5搭配MNIST手写数字识别属于绕不开的那一档。可大多数教程到训练完、打印个准确率就戛然而止,模型和真实使用之间还隔着一块画板。我这套小项目把三件事串在了一起:用PyTorch复现LeNet5,在MNIST上训练,再用Tkinter做一个手写画板,写完立刻识别。标题里说的“带界面和手写画板”,指的就是最后这个东西。
这个项目真正想解决的问题,不是“怎么把准确率刷到99%”,而是“用户随手写一个数字,程序怎么在毫秒级给出可读的答案”。LeNet5在这里只是链条中的一环,数据预处理、模型加载、GUI交互、推理展示才是真正容易被忽略的部分。我把项目按模块拆开,最终目录大概是:
MNIST_GUI/ lenet5.py # 网络结构定义 train_lenet.py # 训练脚本,产出权重 gui_draw.py # Tkinter画板 + 推理界面 lenet5_mnist.pth # 训练好的模型权重1.1 从画板到预测结果的数据流
整个项目的数据流看起来很简单,但每一步都有讲究:
- 鼠标在画板上移动,Tkinter Canvas负责显示,程序同步把轨迹写进一张PIL灰度图。
- 点击“识别”按钮后,把这张灰度图反色、二值化,按数字实际位置裁剪并居中。
- 用和训练时完全相同的 Transform 做处理:ToTensor 转张量,Normalize 做标准化。
- 模型前向推理,得到10个类别的logits,softmax转成概率,取最大值作为预测结果。
- 界面上显示“预测数字”和“置信度”。
这条链路里,最容易被做砸的是第2步。MNIST训练集的数字是黑底白字、居中的,你在画板上画出来的笔迹是白底黑字、位置随意的,如果不做反色和居中,模型很容易翻车。后面我会单独讲。
1.2 这次我建议谁照着复现
如果你是刚学完PyTorch基础、想找一个能把“训练”和“实际使用”打通的项目,这套东西非常合适。LeNet5没那么多花哨机制,网络结构一眼能看完,代码量也压得住。如果你已经在跑通MNIST训练但不知道怎么把它变成能交互的界面,这篇正好补齐后半段。
需要说明的是,这个项目不是为生产级OCR准备的,它适合学习和Demo演示。想识别复杂手写体、多行文本、任意背景,那要换数据集、换网络、换整套预处理思路。但作为理解深度学习落地的起点,它比单纯跑一个训练脚本有价值得多。
2. LeNet5的结构为什么放到今天依然够用
LeNet5是早期卷积神经网络里流传最广的一个,结构小,参数量大约6万,在MNIST这种灰度小图上表现非常稳定。MNIST每张图只有28x28,单通道,数字位于中心区域,背景干净,类别固定为10类。这类数据根本不需要几十层的深度网络,LeNet5这种“卷积提取特征、池化降维、全连接分类”的老套路,已经能吃到差不多99%的准确率。
2.1 每一层输出的尺寸变化
我的实现遵循LeNet5的基本骨架,但做了一处调整:第一个卷积层加了padding=2。原因是原始LeNet5设计时输入是32x32,而MNIST是28x28,加padding能把第一个卷积的输出尺寸维持在28x28,后面池化之后的空间尺寸和原始结构一致。
| 层名 | 操作 | 输入 -> 输出 |
|---|---|---|
| C1 | 5x5 Conv, padding=2 | 1x28x28 -> 6x28x28 |
| S2 | 2x2 AvgPool | 6x28x28 -> 6x14x14 |
| C3 | 5x5 Conv | 6x14x14 -> 16x10x10 |
| S4 | 2x2 AvgPool | 16x10x10 -> 16x5x5 |
| FC1 | Flatten + Linear | 400 -> 120 |
| FC2 | Linear | 120 -> 84 |
| FC3 | Linear | 84 -> 10 |
FC1之前的16*5*5来自S4的输出:16个通道,每个通道是5x5。把特征图展平成400维向量,再进入全连接层。这个展平操作在PyTorch里就是x = x.view(x.size(0), -1),很多新手在这里丢掉batch维,后面接nn.Linear时会直接报错。
2.2 激活函数和池化的选择
原始LeNet5论文里用的激活函数是tanh,池化是平均池化。我在实际复现时换成了ReLU,理由是收敛更快、代码更常见,在MNIST这个难度下最终准确率几乎没有差别。池化我保留了AvgPool2d(2),想尽量贴近原版;如果你换成MaxPool2d(2)问题也不大,MNIST上两种池化的差距完全可以忽略。
有一点值得记一下:CrossEntropyLoss里已经内置了softmax,所以网络最后一层直接输出10个实数logits就行,不要再手动加softmax,否则训练时梯度会被算重。
2.3 为什么是“小网络”而不是“深网络”
看到LeNet5只有5层左右,你可能会怀疑它是不是太老了。实际上MNIST的问题空间很有限:数字笔画简单、尺度固定、没有复杂背景,LeNet5的特征提取能力已经足够。换成ResNet跑MNIST当然也能跑,但训练时间更长、代码更复杂、推理时更容易在画板应用里引入莫名其妙的依赖问题。
对一个GUI手写识别项目来说,模型够小反而是优势:加载快、推理快、出错好排查。LeNet5在CPU上推理一张28x28图,耗时通常不到10毫秒,画板交互完全感觉不到延迟。
3. 训练部分:从数据集下载到保存权重,一条龙跑通
训练部分我用纯PyTorch实现,不引入额外框架。整个训练流程就四个文件里的一小段,核心代码可以全部放在train_lenet.py中。
3.1 环境依赖
这个项目的依赖非常克制:
- Python 3.10 或 3.11
- PyTorch 2.x
- torchvision
- Pillow
- Tkinter(Python自带)
安装命令:
pip install torch torchvision pillow有GPU就装CUDA版,没有GPU也没关系,MNIST这种小图用CPU训练5轮也就几分钟。我实际测试过,CPU单机训练,每个epoch大约一分钟上下,整个训练过程不会让人等到失去耐心。
3.2 数据加载:归一化里的0.1307是什么意思
MNIST是torchvision里自带的数据集,直接下载就能用。但下载之后不能直接丢给模型,必须做归一化。
import torch import torch.nn as nn import torchvision.transforms as transforms from torch.utils.data import DataLoader from torchvision.datasets import MNIST transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_set = MNIST(root="./data", train=True, download=True, transform=transform) test_set = 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=256, shuffle=False)0.1307是MNIST训练集像素的均值,0.3081是标准差。ToTensor会先把0到255的像素值缩放到0到1,然后Normalize再按照(x - mean) / std做标准化。这套数值是MNIST公认的标准,不需要自己重新算。
如果你在的公司或者学校网络环境不方便下载,可以先手动把MNIST文件放到./data目录下,然后把download设为False。
3.3 网络定义
LeNet5的定义非常短,适合直接手敲一遍:
class LeNet5(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 6, kernel_size=5, padding=2) self.pool = nn.AvgPool2d(2) self.conv2 = nn.Conv2d(6, 16, kernel_size=5) self.fc1 = nn.Linear(16 * 5 * 5, 120) self.fc2 = nn.Linear(120, 84) self.fc3 = nn.Linear(84, 10) def forward(self, x): x = self.pool(torch.relu(self.conv1(x))) x = self.pool(torch.relu(self.conv2(x))) x = x.view(x.size(0), -1) x = torch.relu(self.fc1(x)) x = torch.relu(self.fc2(x)) return self.fc3(x)注意第一个卷积层的padding=2。如果去掉它,输入28x28经过5x5卷积后变成24x24,再经过两次池化变成6x6,最后的全连接输入维度就要改成16*6*6,否则会和nn.Linear(16 * 5 * 5, 120)对不上。
3.4 训练循环与模型保存
训练代码里我最看重的两件事:一个是准确率的统计,另一个是模型保存时只保存state_dict。
device = "cuda" if torch.cuda.is_available() else "cpu" model = LeNet5().to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(5): model.train() total_loss, correct, total = 0, 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() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(dim=1) == labels).sum().item() total += images.size(0) train_acc = correct / total print(f"Epoch {epoch + 1}/5, loss: {total_loss / total:.4f}, train_acc: {train_acc:.4f}") model.eval() test_correct, test_total = 0, 0 with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) test_correct += (outputs.argmax(dim=1) == labels).sum().item() test_total += labels.size(0) print(f"Test accuracy: {test_correct / test_total:.4f}") torch.save(model.state_dict(), "lenet5_mnist.pth")我在这类小项目里通常用Adam(lr=1e-3),因为调参省事,5个epoch就能到99%上下的测试准确率。如果你想更贴近经典训练方式,可以把优化器换成SGD(lr=0.01, momentum=0.9),然后把epoch加到10,效果同样稳定。
保存模型时只保存state_dict而不是整个model,是因为state_dict只包含权重和偏置,文件小、跨环境兼容性好,加载时只要网络结构一致就能还原出完整模型。
3.5 训练结果参考
在 PyTorch 2.x、CPU 训练的条件下,我的实测结果大概是:
- 第1个epoch结束,训练准确率约 98%,测试准确率约 98.5%
- 第3个epoch结束,训练准确率约 99.5%,测试准确率约 99.1%
- 第5个epoch结束,测试准确率稳定在 99.2% 左右
MNIST对LeNet5来说确实没什么难度,所以训练阶段真正要盯的不是“准不准”,而是“训练好之后拿到画板场景还准不准”。
4. 画板界面:用Tkinter做一块能“边画边猜”的手写板
训练解决的是“模型能认图”,画板解决的是“用户怎么把图交给模型”。这一步做得好不好,直接决定DEMO看起来是专业还是粗糙。
4.1 为什么选Tkinter而不是PyQt或OpenCV
这个项目只需要三样东西:一块能画线的画布、一个识别按钮、一个显示结果的文本。Tkinter是Python自带的GUI库,零额外安装,代码量也小。PyQt功能更强、界面更漂亮,但对这个体量的项目来说引入成本太高。OpenCV主要优势是图像处理,GUI反而是它的短板,没必要为了用它而用它。
关于画板实现,我推荐一个思路:不要试图从Canvas控件里“截图”,而是把鼠标轨迹同时画到Canvas和一张PIL图片上。Canvas只负责显示,PIL图片才是真正送进模型的数据。这样避开了Canvas截图在不同操作系统上的兼容性问题。
4.2 画板的绘图逻辑
我用了一个 280x280 的Canvas来显示,但底层的PIL图片只有 28x28,缩放比例是10。鼠标在Canvas上移动一个格子,就在PIL上对应一个像素的位置画线。展示上足够清晰,数据上又直接对齐MNIST尺寸。
import tkinter as tk import torch import torchvision.transforms as transforms from PIL import Image, ImageDraw, ImageOps from lenet5 import LeNet5 transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) class DrawBoard: def __init__(self, root, model): self.root = root self.model = model self.scale = 10 self.pixel_size = 28 self.view_size = self.pixel_size * self.scale self.canvas = tk.Canvas(root, width=self.view_size, height=self.view_size, bg="white") self.canvas.pack() self.canvas.bind("<Button-1>", self.start_draw) self.canvas.bind("<B1-Motion>", self.draw) self.canvas.bind("<ButtonRelease-1>", self.end_draw) self.image = Image.new("L", (self.pixel_size, self.pixel_size), 255) self.drawer = ImageDraw.Draw(self.image) self.last_point = None btn_frame = tk.Frame(root) btn_frame.pack(pady=8) tk.Button(btn_frame, text="识别", command=self.recognize).pack(side=tk.LEFT, padx=5) tk.Button(btn_frame, text="清空", command=self.clear).pack(side=tk.LEFT, padx=5) self.result_label = tk.Label(root, text="", font=("Arial", 14)) self.result_label.pack() def start_draw(self, event): x = event.x // self.scale y = event.y // self.scale self.last_point = (x, y) self.drawer.point((x, y), fill=0) self.canvas.create_oval(event.x - 5, event.y - 5, event.x + 5, event.y + 5, fill="black", outline="black") def draw(self, event): x = event.x // self.scale y = event.y // self.scale if self.last_point: px, py = self.last_point self.drawer.line((px, py, x, y), fill=0, width=2) self.canvas.create_line(px * self.scale, py * self.scale, x * self.scale, y * self.scale, width=self.scale, capstyle=tk.ROUND, smooth=True) self.last_point = (x, y) def end_draw(self, event): self.last_point = None def clear(self): self.canvas.delete("all") self.image = Image.new("L", (self.pixel_size, self.pixel_size), 255) self.drawer = ImageDraw.Draw(self.image) self.result_label.config(text="")这里的关键细节是capstyle=tk.ROUND和smooth=True。如果不设置圆头线帽,快速拖动鼠标时笔画会出现明显的折线生硬感;设置之后笔画圆滑,更接近真实书写。
4.3 从画布数据到模型输入:反色、居中、标准化
点击识别按钮后,不能直接把self.image丢给模型。白底黑字的图必须做一次反色,变成黑底白字,否则和MNIST训练集正好相反。
def center_digit(img): img = img.point(lambda p: 255 if p > 100 else 0) bbox = img.getbbox() if bbox is None: return img digit = img.crop(bbox) digit = digit.resize((20, 20), Image.LANCZOS) canvas = Image.new("L", (28, 28), 0) canvas.paste(digit, (4, 4)) return canvas class DrawBoard: # ... 前面代码省略 ... def recognize(self): img = ImageOps.invert(self.image) bbox = img.getbbox() if bbox is None: self.result_label.config(text="没有检测到笔迹") return img = center_digit(img) tensor = transform(img).unsqueeze(0) with torch.no_grad(): outputs = self.model(tensor) prob = torch.softmax(outputs, dim=1)[0] pred = int(prob.argmax()) confidence = float(prob[pred]) self.result_label.config(text=f"预测结果:{pred},置信度:{confidence:.2%}")center_digit做的事情是:先把输入二值化,用getbbox()找到数字外接矩形,裁剪出来,缩放到20x20,再贴到28x28画布的中央。这一步模仿了MNIST数据集中数字居中的分布特征。如果不做居中,写在画板角落的数字很可能被模型认错,因为模型训练时几乎没见过“角落里单独出现一个数字”的布局。
transform(img).unsqueeze(0)里,transform(img)得到(1, 28, 28)的张量,unsqueeze(0)加上batch维变成(1, 1, 28, 28),这样才能直接送进PyTorch模型。
4.4 加载模型并启动界面
GUI启动代码里最重要的一点是设备对齐:
if __name__ == "__main__": model = LeNet5() model.load_state_dict(torch.load("lenet5_mnist.pth", map_location="cpu")) model.eval() root = tk.Tk() DrawBoard(root, model) root.mainloop()如果你训练时用了GPU,state_dict里的tensor可能在GPU上,加载到CPU机器时必须用map_location="cpu"。model.eval()一定不能省,它会把Dropout和BatchNorm切到推理模式。LeNet5里没这些层,但养成交互前固定eval()的习惯很重要。
5. 实际跑起来最容易翻车的四个地方
这个项目代码量不大,真正的坑都集中在预处理和状态切换上。我把自己踩过的、以及帮别人调过的几类问题列出来,按严重程度排个序。
5.1 黑白没反,模型从头错到尾
我第一次把画板和模型接起来时,跑到界面上写了个“5”,模型自信地告诉我那是“3”。当时还以为是网络训练得不够好,折腾了半天才发现是颜色反了。
MNIST是黑底白字,而我的画板是白底黑字。反色前,背景像素值为0,笔画像素值为255;反色后才是背景0、笔画255。不做ImageOps.invert,模型看到的语义完全颠倒,再好的训练结果也白搭。这个问题最坑的地方在于:损失不会提示你,程序不会报错,只有识别结果看起来“有点准但经常错”。
5.2 缩放用错插值方式,边缘全是锯齿
画板的笔迹最终要缩放成28x28。有人图省事直接用Image.NEAREST,结果就是笔画边缘布满锯齿,数字稍微潦草一点就没办法认。
我建议用Image.LANCZOS,它是带抗锯齿的高质量缩放。MNIST这种内容简单的小图虽然不一定因为几个锯齿就崩溃,但GUI交互场景追求的是稳定,没必要省这一点计算量。
5.3 手写位置没有居中,角上数字认错率飙升
这是画板项目和纯训练项目最大的不同。标准MNIST数据里数字都在图像中心,模型看到的训练样本位置分布非常集中。用户写画板时不会刻意控制位置,如果用户习惯在左上角写字,模型就会看到大量“左上角数字”这种分布外样本。
所以我用getbbox()把数字外接矩形找出来,裁掉四周空白,再放到28x28中央。这个操作实现起来只要几行,但对识别率的影响非常明显。如果写了很大的数字,缩放后笔画占满全图,识别的鲁棒性也会下降。
5.4 训练和推理时的数据预处理不一致
另一个隐藏问题是训练时用了ToTensor + Normalize((0.1307,), (0.3081,)),推理时却只做了tensor = torch.tensor(np.array(img) / 255.0),漏掉了标准化。模型训练时输入分布是“零均值、单位方差附近”,推理时输入分布变成“0到1之间”,相当于把模型放到了一个没见过的输入范围里。
解决办法很粗暴:推理时直接复用同一个transform = transforms.Compose([...])。不管是画板还是离线脚本,一张图只要走同一套预处理管线,就不会出现训练好推理差的问题。
这四类问题的高发原因其实都一样:训练时用的数据分布和使用时构造的数据分布不一致。OCR类项目尤其吃这个亏,建议你在做任何相关Demo时都先画一遍完整的数据流,再用这个清单自查。
6. 进阶扩展:让这个项目从玩具变成半成品工具
基础功能跑通之后,我顺手做了几个很轻的扩展,每一个都不需要改网络结构,却能让项目看起来更像一个“工具”。
6.1 加一个“不确定拒绝”逻辑
单张28x28图上,置信度是一个很好的可信度指标。如果softmax最大概率不到0.85,说明模型对这张图没有把握,这时候直接显示一个数字反而误导用户。
if confidence < 0.85: self.result_label.config(text=f"不太确定,可能是{pred},置信度只有{confidence:.2%}") else: self.result_label.config(text=f"预测结果:{pred},置信度:{confidence:.2%}")这个小改动看起来简单,实际价值很大。它把“模型必须给答案”变成了“模型可以选择不回答”,更接近真实产品的交互习惯。
6.2 把模型导出成ONNX,脱离PyTorch也能推理
如果你想把模型嵌入到其他环境,或者不在乎PyTorch这个依赖,可以用torch.onnx.export导出成ONNX格式:
dummy = torch.randn(1, 1, 28, 28) torch.onnx.export( model, dummy, "lenet5.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}} )ONNX推理速度更快,部署时可以在只需要CPU的环境里跑,不需要完整安装PyTorch。这对一个“画板识别”类工具来说,是降低分发成本最直接的办法。
6.3 训练时加一点数据增强,提升画板场景的鲁棒性
用户手写和标准MNIST的分布差异是客观存在的。训练时加一点随机旋转和平移,可以让模型稍微适应“不那么正”的数字:
train_transform = transforms.Compose([ transforms.RandomAffine(degrees=10, translate=(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])degrees=10表示最多旋转10度,translate=0.1表示在水平和垂直方向各最多平移10%。这个幅度下,模型仍然能保证训练稳定,但对角度偏移的容忍度会高不少。
最后再给一个我个人的小建议:如果只是照着把代码跑通,收获其实很有限。你可以把预处理里的每一行都单独改掉试试,把invert去掉、把LANCZOS换成NEAREST、把center_digit关掉,然后观察识别率是怎么一步步崩掉的。这个项目最大的价值不是LeNet5本身,而是让你彻底理解“训练时的数据分布”和“使用时的数据分布”必须一致。想通这一点,后面做任何OCR类项目都会少踩很多坑。