手写汉字识别系统落地:基于PyTorch的CNN分类实战
简介一套基于PyTorch卷积神经网络的中文手写汉字识别实现面向高校计算机视觉课程设计与期末大作业场景适合需要完整可运行代码和参考文档的学习者。系统采用多层卷积提取特征并结合全连接层完成分类通过数据增强提升模型泛化能力支持多种汉字样本的识别。资源包共10个文件压缩后约366KB以4个Python源码脚本为主分别对应数据处理、模型定义、训练与评估等环节另含README说明文档、示例图片及备份文件目录结构清晰便于直接部署与二次开发。包内预置训练好的模型与评估脚本可快速验证识别效果为手写汉字识别研究提供可复用的实践案例也便于对照课程要求逐模块理解实现细节。目前已有47人学习下载适合作为课程项目参照或期末作业的完整方案。1. 中文手写汉字识别为什么先做分类再做序列系统才落地基于PyTorch卷积神经网络的中文手写汉字识别系统实现听起来像个大工程拆开看其实是一条图像分类流水线给一张切好的汉字图片模型输出是哪个字。这里有个反直觉的经验别一上来就做整行文本的端到端识别先做单字分类系统易落地得多——切字、单字识别、后处理纠错每一步都能独立验证和调整。这篇笔记讲清楚从数据、CNN模型、训练调参到部署验证的完整路径以及那些让很多实现翻车的边界问题。适合正在做中文OCR、做课程设计或想把手写识别落到生产代码里的你。2. 先备料中文手写数据集与预处理流程2.1 数据集选择公开数据集和自己造字的取舍中文手写识别第一道坎不是模型是数据。常见做法是找公开的离线手写汉字数据集按类别整理好的单字图片训练时直接读文件夹。但这类数据集有两个问题一是覆盖范围有限一级汉字之外的生僻字往往没有二是申请和使用受版权约束毕设或内部验证没问题商用要先读清楚授权条款。另一条路是自己造字。用字体文件把目标汉字渲染成图片再加随机旋转、平移、抖动模拟手写痕迹。自己造字的好处是类别完全可控缺什么字就补什么字坏处是风格偏印刷体和真实手写笔迹有明显gap。纯靠造字训练出来的模型一到真实扫描件上准确率掉得很快。我一般会用公开数据集先把流程跑通确认模型结构和训练参数没问题再针对业务里缺的生僻字造一批数据做微调。系统演示阶段先用手头能拿到的公开数据不需要在这个环节卡太久。2.2 从图像到张量归一化、尺寸、灰度与数据增强汉字和拉丁字母不一样笔画密度极高。图像尺寸我习惯定成64x6432x32在“懿”“鬱”这类字上会把笔画糊成一团64x64能保住偏旁结构又不至于让网络参数爆炸。图片统一转成灰度单通道用PIL的convert(L)即可。像素值归一化用mean0.5, std0.5把输入映射到[-1,1]比直接喂[0,1]更容易收敛。数据增强对手写汉字要克制。随机旋转10度以内是安全的超过30度“人”会变成“入”“未”会变成“末”。随机擦除RandomErasing能模拟笔迹断墨对扫描件很有效但擦除比例要控制在0.1到0.25否则把关键笔画擦没模型学到的是残缺字。如果训练集样本本来就少增强强度反而要调低先把原图学好再考虑抗干扰。每类样本数量足够多的时候旋转、平移、缩放、擦除可以一起上。2.3 数据加载器实现Dataset类与训练/验证切分PyTorch的数据加载推荐继承Dataset写自己的类。下面这个实现假设数据目录按“标签名/图片文件”组织标签就是文件夹名加载时自动构建从汉字到整数的映射。import torch from torch.utils.data import Dataset from PIL import Image import os class HandwrittenChineseDataset(Dataset): def __init__(self, root_dir, transformNone): self.samples [] self.label_to_idx {} idx 0 for cls_name in sorted(os.listdir(root_dir)): cls_dir os.path.join(root_dir, cls_name) if not os.path.isdir(cls_dir): continue if cls_name not in self.label_to_idx: self.label_to_idx[cls_name] idx idx 1 for fname in os.listdir(cls_dir): if fname.endswith((.png, .jpg, .bmp)): self.samples.append((os.path.join(cls_dir, fname), self.label_to_idx[cls_name])) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] image Image.open(path).convert(L) if self.transform: image self.transform(image) return image, label逻辑说明__init__里遍历根目录下每个子目录子目录名当作类别名按字典序生成整数标签。这样同一个数据集的标签映射顺序固定训练和验证不会错位。__getitem__每次只处理一张图转灰度后交给transform返回样本和标签。注意不要在__getitem__里做重活比如读一个很大的缓存列表否则DataLoader多进程时会复制大量内存。配套的transform和DataLoader这样搭from torch.utils.data import DataLoader from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomRotation(degrees10), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) train_set HandwrittenChineseDataset(data/train, transformtrain_transform) val_set HandwrittenChineseDataset(data/val, transformval_transform) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_set, batch_size64, shuffleFalse, num_workers4)参数说明Resize把所有图统一到64x64RandomRotation只在训练时用验证集不做随机扰动否则准确率指标会被“干扰”得不真实。ToTensor把PIL图从HWC转成CHW并把像素缩到[0,1]。Normalize用mean0.5和std0.5把像素转换到[-1,1]。num_workers在Linux下可以设4或更多Windows下建议设为0否则多进程读取容易卡死这个坑后面会专门说。训练集必须shuffleTrue验证集用shuffleFalse保持顺序稳定。训练集和验证集切分不是简单地随机打乱文件列表而应该按类别分层抽样先把每个汉字的所有图片放在一组再按比例抽一部分进验证集。如果直接随机切分同一个字的相似样本可能同时出现在训练集和验证集验证准确率会虚高模型一上真实手写就露馅。3. 搭模型用CNN卷积神经网络把汉字特征逐层抽出来3.1 为什么选卷积神经网络而不是全连接汉字图像的信息分布很微妙横竖撇捺是局部笔画偏旁由相邻笔画组合整字结构则由偏旁相对位置决定。卷积神经网络的结构正好匹配这种层次性——卷积核在局部窗口内提取笔画特征多个卷积核叠加得到偏旁特征再往上就是整字结构。这就是常说的卷积神经网络原理局部感受野加权值共享用很少的参数学到空间上的平移不变特征。全连接层的问题在于每个输出节点都要看到所有像素64x64的输入接1024个隐层节点参数量是4096乘以1024超过四百万在中文手写数据量下几乎必然过拟合。卷积层一个3x3卷积核只有9个参数经过权值共享后在整张图上滑动配合池化层降低分辨率把参数总量控制在一个可训练的范围内。真实手写不会规规矩矩落在格子里字符经常偏移、歪斜、大小不统一池化层的下采样正好提供了对这种位移的容忍。3.2 一个能跑的CNN结构卷积-池化-全连接的设计要点我习惯用一个基准结构三个“双卷积加池化”模块。第一层32个卷积核学基本笔画第二层64个学偏旁第三层128个学整字组合。所有卷积核都用3x3padding1保证特征图尺寸不缩水池化用2x2最大池化每经过一次池化特征图边长减半。每个卷积后接BatchNorm和ReLUBatchNorm放在激活前能避免梯度在深层网络里波动过大。全连接部分不用太宽256个神经元足够后面跟Dropout比例为0.5。因为分类类别可能到3000多最后一层Linear本身就是参数量很大的层全连接中间层如果再宽模型就胖得危险。如果发现训练集都拟合不动可以先把通道数从32提高到48或64优先增加卷积层的容量而不是加全连接宽度。3.3 PyTorch实现与输入输出维度核对下面是一个可以直接跑通的CNN分类模型输入是(batch, 1, 64, 64)输出是每个类别的logits。import torch.nn as nn class CNNClassifier(nn.Module): def __init__(self, num_classes): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 8 * 8, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x逻辑说明输入1x64x64第一个卷积用padding1保持输出还是64x64经过两次卷积后池化到32x32第二个模块变成16x16第三个模块变成8x8。此时特征图是128通道、8x8分辨率展平后正好是128乘以8乘以8。全连接第一层把8192维压到256维中间加Dropout防止过拟合最后映射到类别数。inplaceTrue能在ReLU时省一点显存但不会改变计算结果。维度是最容易翻车的地方所以在训练前先做一次前向检查model CNNClassifier(num_classes3755) x torch.randn(8, 1, 64, 64) print(model(x).shape)这段代码用一个随机batch跑一次forward打印输出形状应该是torch.Size([8, 3755])。如果尺寸不对会立刻报错而不是等到训练循环里才炸。我每次新建模型都会跑这一句比什么检查都好使。如果输入图换成48x48三个池化后边长变成6全连接层就要改成128x6x6这就是后面避坑章要说的维度匹配问题。4. 训练与调参让损失降下来、准确率过95%的实践参数4.1 损失函数与优化器选型交叉熵、Adam还是SGD多分类任务首选nn.CrossEntropyLoss它把Softmax和负对数似然合并在一起在PyTorch里直接接收logits不需要手动做softmax。中文手写类别多初始交叉熵损失大概是ln(类别数)3755类就是8.23左右。如果训练了半天损失还卡在8.2附近说明模型输出均匀分布根本没学进去这时候优先查数据标签和模型forward而不是调学习率。优化器我用两段式。第一段用Adam学习率1e-3跑10到20个epoch试错看损失能不能从8出头降到1左右。Adam对学习率不敏感适合前期快速找到可行路径。第二段换用带动量的SGD或AdamW配合较低学习率把模型打磨得更好。说法是Adam收敛快但最终泛化一般SGD动量在后期更可靠这不是绝对的但实际体验下来手写汉字任务上SGD的验证准确率往往比Adam略高一点。4.2 学习率调度与批量大小从过拟合到泛化的调整路径一组不会出大错的初始参数batch_size64lr1e-3weight_decay1e-4StepLR每10个epoch把学习率乘以0.1。30个epoch跑下来第10轮前模型在快速下降第10轮后进入精细调整第20轮后再降一次验证集准确率通常能再涨一两个点。batch_size不是越大越好。调大batch可以加快单轮速度但会降低梯度噪声让模型更容易收敛到尖锐的极小值泛化变差。显存不够就先从128降到64如果还溢出就降到32同时观察训练是否变慢。BatchNorm在batch变小时统计量会抖动训练loss可能出现周期性波动这时候可以把BN的momentum从0.1调到0.5让滑动平均更平滑。每次只动一个变量不要同时改学习率、batch和权重衰减否则翻车了都不知道是哪一步引起的。4.3 训练循环完整代码与常见信号解读完整的训练和验证循环如下import torch import torch.optim as optim import torch.nn as nn from torch.optim.lr_scheduler import StepLR device torch.device(cuda if torch.cuda.is_available() else cpu) model CNNClassifier(num_classeslen(train_set.label_to_idx)).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler StepLR(optimizer, step_size10, gamma0.1) for epoch in range(30): model.train() running_loss 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() running_loss loss.item() * images.size(0) scheduler.step() train_loss running_loss / len(train_set) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) preds outputs.argmax(dim1) correct (preds labels).sum().item() total labels.size(0) val_acc correct / total print(fepoch {epoch1:02d} | loss {train_loss:.4f} | val_acc {val_acc:.4f})逻辑说明每个batch先optimizer.zero_grad()清空上一次的梯度否则梯度会累加loss.backward()计算当前batch梯度optimizer.step()更新参数。scheduler.step()在每个epoch结束后调整学习率放在验证之前保证当前epoch用的是新学习率。验证时必须model.eval()它会关闭Dropout并让BatchNorm使用全局统计量同时用torch.no_grad()禁掉自动求导否则验证过程会多占显存还可能因为某些算子导致数值不稳定。参数说明running_loss按样本数加权累加最后除以len(train_set)得到平均损失。weight_decay1e-4就是L2正则对全连接层作用最明显能压住过拟合。打印的val_acc如果连续5个epoch没有上涨就手动降低学习率或提前停止如果loss出现NaN先把lr降到1e-4还不行就查图片里有没有损坏文件。5. 避坑专章这五个问题让多数实现翻车排查与修复5.1 类别不平衡与生僻字公开数据集里没有“喆”现象模型在常用字上准确率很高遇到生僻字比如“喆”“犇”“燚”总是被识别成结构相似的常用字像“吉”“牛”“炎”。原因公开数据集通常只覆盖GB2312一级汉字合计3755个生僻字样本少甚至没有。模型训练时见过“吉”一千次见过“喆”零次输出概率自然压向“吉”。这就是类别不平衡和“没见过”的叠加。解决如果业务模型必须覆盖生僻字自己渲染一批目标字再微调。渲染时不要用同一个字体混用几种楷体、宋体、黑体加随机扰动和灰度变化让模型学到的是字形结构而不是某个字体的像素模式。另外可以用重采样让每类样本数量均衡torch.utils.data.WeightedRandomSampler给样本数少的类别分配更高采样概率。原理是每轮epoch中稀有类能被多抽几次就不会被完全淹没。5.2 模型过拟合验证集准确率停滞训练集99%现象训练集准确率很快冲到99%验证集在90%上下停滞甚至随epoch增加而下降。训练损失持续走低验证损失不再下降。原因一是模型容量过大把训练集特有噪声也记住了二是数据增强不够模型能看到的有效变化太少三是验证集切分泄漏同一个字的不同写法被同时分进训练集和验证集导致评估指标虚高真实上手时才发现泛化不行。解决先检查切分逻辑按类别分层抽样确保每个字在训练集和验证集中都有代表但不重复。再看增强把RandomRotation从5度加到10度加RandomAffine的平移和缩放以及RandomErasing模拟涂改。对应地调整Dropout到0.5、weight_decay到1e-3这些加起来基本能压住过拟合。如果还不行就把卷积通道数减半模型本身变小是最后一张牌。5.3 内存暴涨与DataLoader卡死num_workers设置现象Linux下训练到第几轮内存占用逐轮上涨最后被OOM杀死Windows下程序跑完一个epoch后没有报错也不进入下个epoch像卡死一样。原因num_workers开太高每个worker都会加载一份数据缓存叠加起来内存暴涨。Windows上DataLoader使用spawn方式启动子进程如果脚本的主入口没有用if __name__ __main__:保护子进程会递归加载整个训练脚本造成无限启动和卡死。解决Windows下设num_workers0数据加载在主进程同步执行慢一点但可靠Linux下也不要盲目的设成CPU核数4到8就够。pin_memory在GPU训练时可以打开能减少数据从CPU到GPU的拷贝时间但CPU训练时打开只会增加内存占用。还有一个隐性坑自己写的Dataset里不要持有大型共享对象比如把所有图片一次性读进内存否则每个worker复制一份直接爆内存。5.4 张量维度不匹配全连接层输入算错现象运行到forward时报错RuntimeError: mat1 and mat2 shapes cannot be multiplied提示维度对不上。最常见的是改了输入图片尺寸或删了一个池化层但全连接层第一层的输入还写在128 * 8 * 8。原因全连接层的输入维度必须和卷积最后一层输出的展平维度一致。64x64输入经过三个MaxPool变成8x8但如果你把图换成48x48变成6x6或者池化层用了kernel_size3, stride2维度就不是整数全连接层维度立刻炸。解决不要在纸面上算把模型实例化后跑一次randn的前向检查报错信息会直接给出当前形状和期望形状。更省心的写法是用nn.AdaptiveAvgPool2d((1, 1))代替固定的展平方式它能接受任意输入尺寸输出固定为通道数 x 1 x 1再接一个torch.flatten(x, 1)全连接层输入就只依赖通道数不再依赖图尺寸了。但要注意全局平均池化会丢弃位置信息如果用在笔画细节很多的任务上可能降低精度所以我只在频繁改动输入尺寸的实验里用最终部署仍会固定64x64。5.5 PyTorch环境搭建坑CUDA版本与anaconda配置现象torch.cuda.is_available()返回False或者模型训练时速度极慢一看是在用CPU跑GPU版代码。还有的安装过程中装成了CPU版本torch.version.cuda是None。原因PyTorch的安装包按CUDA版本区分默认的pip install torch在很多机器上装的是CPU版。另外CUDA driver和PyTorch要求的CUDA runtime版本不匹配也会导致显卡不可用。驱动支持较高CUDA版本但PyTorch轮子里带的是旧CUDA这时还会遇到驱动丢失之类的错觉。解决不要靠“装最新版”解决问题。先用nvidia-smi看右上角Driver版本以及它支持的最高CUDA版本再决定装哪个。用Anaconda建独立环境是常见做法conda create -n torch python3.8 conda activate torch # 根据本机驱动匹配的CUDA版本按官网给出的命令安装PyTorch逻辑说明Python版本不用追新3.8对PyTorch生态兼容性很好。创建独立环境避免把系统或原本的开发环境弄坏。安装命令不要手动拼pip install torch一定要按官网给出的CUDA索引地址安装例如CUDA 11.8对应的命令是带--index-url的没写CUDA版本的就是CPU版。如果机器没有NVIDIA显卡先装CPU版把代码调通后续换GPU机器时再重装对应版本模型代码不用改只需要device torch.device(cuda)。6. 系统落地与验证从模型到能用的识别服务模型训练完准确率再高也不能只在验证集上自我陶醉。最后一步是把权重固化下来写一个能接收单张图片、输出预测汉字的推理函数。def predict_image(img_path, model, label_list, device): model.eval() img Image.open(img_path).convert(L) img val_transform(img).unsqueeze(0).to(device) with torch.no_grad(): logits model(img) probs torch.softmax(logits, dim1) top5_idx probs.topk(5).indices[0] return [label_list[i] for i in top5_idx.tolist()], probs.max().item()这里val_transform复用上面的验证集transform只做Resize、ToTensor、Normalize不加随机增强。unsqueeze(0)给图片加一个batch维度。torch.topk取概率最高的前5个结果因为在真实手写体上模型把第2热门的“未”和第1热门的“末”都排得很接近只展示一个答案很容易误导用户。我训练时有一个固定习惯每次跑完一个epoch会把当前学习率、验证集最高准确率对应的权重单独保存。torch.save(model.state_dict(), best_model.pth)加载时先用相同的模型结构实例化再load_state_dict。这能少走很多弯路因为训练中断或过拟合之后你永远有一个“后悔药”可以回退。如果要把模型部署到CPU环境PyTorch原生推理有点笨重我一般会转成ONNX格式再用ONNX Runtime跑。model.eval() dummy torch.randn(1, 1, 64, 64) torch.onnx.export(model, dummy, handwriting.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}})注意导出前必须model.eval()否则BatchNorm和Dropout会以训练状态参与计算导致导出的图在推理时行为异常。dynamic_axes允许部署时动态batch但输入输出图尺寸还是固定在64x64。导出后用自己的推理函数跑一遍相同图片比较ONNX输出和PyTorch输出差异在1e-5以内才算导出成功。这套流程跑通后服务端只需要一个图像输入接口识别模块就是一个很小的依赖。希望这个从数据到部署的路径能帮你把手写汉字识别系统真正落实到生产代码里。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →