
简介一套基于PyTorch卷积神经网络的中文手写汉字识别系统实现面向高校计算机视觉课程设计、期末大作业及深度学习入门者专门解决中文手写汉字自动识别与分类问题覆盖数据处理、模型构建、训练评估与部署的完整链路。资源包共10个文件整体压缩包仅366KB以4个Python脚本为主体分别承担GNT样本预处理、网络模型定义、训练与评估流程另附README说明文档、样本示意图及若干备份文件zip格式便于解压运行结构清晰。已有47人学习下载。系统采用多层卷积结构提取汉字笔画与局部特征结合全连接层完成分类并通过数据增强提升泛化能力包内预训练模型、数据处理模块与评估脚本一应俱全支持多种中文手写汉字样本的准确识别无需额外配置即可部署。既可作为课程设计或期末大作业的完整交付也可作为手写汉字识别研究与实践的可靠基线。1. 手写汉字识别系统为什么说它是深度学习落地的好样本中文手写汉字识别看起来是个老话题但真正把它做成一个能部署出去给用户用的系统仍要跨过不少坎。难点不在模型够不够深而在三个具体问题类别多常用汉字就有3755个、形近字多“未/末”“日/目”笔迹稍一抖动就分不清、笔迹差异大同一个字不同人写出来的结构比例差距很大。用PyTorch做卷积神经网络识别系统最大的好处是技术栈统一数据加载、模型训练、推理部署都在PyTorch生态内闭环不需要在多个框架之间来回倒腾。下面按“数据准备→模型搭建→训练调参→系统封装→踩坑排查→效果进阶”的路径走一遍适合有Python基础、想把深度学习项目真正落地的工程师——不是跑通一个notebook而是交付一个能处理真实手写输入的识别服务。2. 数据集与预处理先把字库变干净再谈卷积神经网络2.1 中文手写数据集的选择公开字库、自建样本怎么搭这个项目的第一步是拿到“干净”的字库。常见做法是使用CASIA-HWDB系列公开数据集HWDB1.0和HWDB1.1加起来覆盖三千多个常用汉字类别样本来自上百位书写者用笔在纸上书写后扫描的图片自带真实手写噪声——笔画抖动、粗细不均、轻微歪斜正是模型训练需要的难度来源。公开字库的优势是省去采集标注时间缺点是样本书写风格偏向规整遇到草书连笔会明显掉点所以生产环境通常还要补充目标场景的自建样本。自建样本的采集方式取决于业务入口。如果是网页端手写板或触屏设备拿到的是笔画轨迹点需要先把轨迹渲染成图再入库如果是扫描件要经过版面切割把单字从文档里抠出来。这个环节最容易被低估一套能稳定切出单字的工具往往比训练模型本身更花时间。我一般会按“书写者留出法”组织数据——每个书写者的全部样本只允许出现在训练集或验证集一侧否则验证损失会严重失真后面避坑章节会细说。2.2 预处理流程灰度化、背景清理、尺寸归一化的顺序不能乱手写汉字图片和普通自然图像差别很大信息集中在笔画上背景基本是噪声。预处理的首要原则是保住笔画边缘不能像做ImageNet分类那样随意裁剪缩放。常规顺序是先灰度化去掉色彩干扰再做背景清理最后统一尺寸。灰度化直接转单通道不要为了“看起来像自然图像”保留三通道单通道输入能让模型参数更聚焦在笔画结构上。背景清理这里有个微妙点全局二值化会把浅笔迹直接切断尤其是细笔画起笔处只剩一半这对模型是致命伤害。正确做法是用自适应阈值或简单对比度拉伸把纸张阴影去掉就好不必强求纯黑白。尺寸归一化一般取56x56或64x64。为什么不用MNIST那样的28x28三千多个汉字类别之间的区分信息集中在笔画的局部细节里28x28下“己/已/巳”这类字基本糊成一片。缩放插值建议用BILINEAR但BILINEAR会轻微磨掉笔画我会在缩放后补一个轻微的锐化或对比度增强来补偿。数据格式上PyTorch的常规约定是图片通道在前即Tensor形状为[1, height, width]。白底黑字的手写图片在ToTensor之后背景接近1.0、笔画接近0.0Normalize用mean0.5、std0.5会把它映射到[-1, 1]区间。这个区间分布对ResNet这类BN网络是友好的不需要再做额外的归一化。2.3 数据加载Dataset写好预处理DataLoader管好喂数节奏PyTorch里数据加载的职责边界要分清Dataset负责读图、走transform、返回张量DataLoader负责打乱、分批、并行预取。很多新手把transform放在DataLoader外面或者Dataset返回了没归一化的PIL图训练时才暴露类型不匹配的问题。自定义Dataset的骨架如下import torch from torch.utils.data import Dataset from torchvision import transforms from PIL import Image import os class HanziDataset(Dataset): 从 图片路径 类别索引 文本中加载手写汉字图。 def __init__(self, img_dir, label_file, transformNone): self.img_dir img_dir self.transform transform self.samples [] with open(label_file, r, encodingutf-8) as f: for line in f: img_name, label line.strip().split() self.samples.append((img_name, int(label))) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_name, label self.samples[idx] image Image.open(os.path.join(self.img_dir, img_name)).convert(L) if self.transform: image self.transform(image) return image, torch.tensor(label, dtypetorch.long)这个类有三个关键点。convert(L)把输入强制统一成灰度避免个别图片是RGBA模式导致张量形状不一致label转成torch.long因为CrossEntropyLoss要求类别索引是整型用浮点会在训练中段才报错samples列表完全加载进内存图片本身不预加载靠DataLoader的多进程去磁盘读这样内存占用是可控的。如果图片量是几十万张路径字符串的内存开销大约几十MB不是问题。再来看训练集和验证集的transform差异train_transform transforms.Compose([ transforms.Resize((64, 64), interpolationtransforms.InterpolationMode.BILINEAR), transforms.RandomRotation(5, fill255), transforms.RandomAffine(0, translate(0.05, 0.05)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) val_transform transforms.Compose([ transforms.Resize((64, 64), interpolationtransforms.InterpolationMode.BILINEAR), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ])训练集和验证集的transform必须分开写验证集不能加RandomRotation这类随机增强否则同一张图每次验证结果都不同模型对比就失去意义。RandomRotation的fill255对白底黑字图片很重要不填fill的话旋转产生的空白区域默认填黑色等于给每个字边缘凭空加了一圈黑边干扰笔画特征。旋转角度限制在5度以内超过这个范围会把字的结构都转歪模拟的不是手写偏移而是旋转攻击。DataLoader侧的几个参数值得单独说train_loader torch.utils.data.DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue )num_workers根据机器CPU核数调整Windows上设成0更稳多进程在Windows下偶尔会卡死Linux服务器可以开4到8。drop_lastTrue是为了避免最后一个batch样本太少导致BN层的统计量抖动。pin_memoryTrue配合GPU训练能节省一次显存拷贝但会占一点内存内存紧张时关掉。shuffleTrue只在训练集设验证集必须设False否则评估集的顺序每轮都变不利于对比不同epoch的表现。设计好transform以后不要直接开训先把一个batch的图片打印出来用肉眼看一遍。这是最便宜的排查手段如果旋转后的字看起来不像人写的或者背景里出现明显的黑边说明transform参数有问题。现实中很多“模型怎么训都不收敛”的问题根源是图片预处理已经破坏了笔画结构模型学到的特征根本不反映手写字的真实模式。3. 用PyTorch搭建CNN从结构选型到训练配置3.1 网络结构选型为什么选ResNet而不是自己堆卷积层卷积神经网络CNN的结构选择要看任务规模。手写汉字识别是一个三千多类的细粒度分类问题特征之间差异极小浅层网络比如两三个卷积层加全连接很难把“提特征”和“做分类”两个任务同时做好。常见做法是直接使用ResNet-18或ResNet-34作为主干。ResNet的残差结构让梯度能直接跨层回传训练深层网络时不容易出现前面层梯度消失同时ResNet-18的参数量和算力要求对单卡训练很友好即使没有高端GPU也能在一夜内完成训练。有读者会问为什么不直接用VGG。VGG的卷积层堆叠方式也能做但参数冗余大训练收敛慢在三千多类的任务上性价比明显不如ResNet。也不用一上来就追EfficientNet这类结构先把基线跑通、把数据链路验证好再考虑结构升级。3.2 模型定义改造ResNet-18适配单通道输入和汉字类别数torchvision里直接提供resnet18但要改两处才能用于汉字识别。第一默认输入是三通道RGB要改成单通道灰度第二默认输出是1000类ImageNet类别要改成自己的汉字类别数。import torch.nn as nn from torchvision import models def build_model(num_classes3755): 构建用于中文手写汉字识别的ResNet-18。 model models.resnet18(weightsNone) # 输入从3通道改为1通道保持卷积核尺寸不变 model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) # 全连接输出改为汉字类别数 model.fc nn.Linear(model.fc.in_features, num_classes) return modelweightsNone表示不加载ImageNet预训练权重从零训练。这里有一个实际选型问题要不要加载预训练如果数据量达到几十万张从零训练完全够用如果数据量只有几万张加载预训练权重能让前期收敛更快——但conv1从3通道变成1通道后预训练的第一层权重不能直接复用常见做法是取通道维度的均值初始化或者干脆把整个conv1随机初始化。实务中我倾向于在数据量充足时直接从零训练省得在预训练权重的转移上做无谓的调试。补充一个结构细节resnet18的最后一个全连接层输入维度是512改输出为3755后分类头的参数量约190万整个模型约1200万参数量FP32推理的显存需求在2GB以内。这个体量对部署来说很友好CPU上也能跑只是速度不如GPU。3.3 训练配置损失函数、优化器、学习率调度与监控训练配置直接决定模型能否收敛到可用水平。损失函数用CrossEntropyLoss这里建议加上label_smoothing0.1。三千多个类别中部分生僻字样本很少模型容易在训练集上对这些少数类过度自信标签平滑给真实标签的概率留出余量能显著降低验证集上的过拟合程度。优化器选SGD还是Adam是每个项目都要面对的争论。在这个任务上我的经验是SGD配合momentum0.9、weight_decay5e-4配合余弦退火学习率最终准确率上限更高Adam的优势是前期收敛快、对学习率不敏感适合先跑通流程但后期在细粒度分类上容易停在次优解。如果你的目标是“尽快看到一条可用的训练曲线”Adam起步没问题如果打算一次跑到最好效果SGD更稳。criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer torch.optim.SGD( model.parameters(), lr0.1, momentum0.9, weight_decay5e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30 )学习率0.1是ResNet系列配合SGD的常见起点配合batch_size128时这个组合在多数CNN任务上不会翻车。如果batch_size改成256学习率可以相应提高到0.2因为大batch的梯度噪声更小。CosineAnnealingLR的T_max设为30表示30个epoch内学习率从0.1余弦退火到接近0。这种退火方式比固定学习率好很多前期以较大步长快速收敛后期逐步精细调整不用手动去找学习率衰减的时机。训练循环写出来也就几行但细节都在设备搬运和状态切换上device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model().to(device) for epoch in range(30): model.train() # 进入训练模式BN和Dropout生效 total_loss 0.0 for batch_idx, (images, labels) in enumerate(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() if batch_idx % 100 0: print(fEpoch {epoch1} [{batch_idx}/{len(train_loader)}] loss: {loss.item():.4f}) scheduler.step() avg_loss total_loss / len(train_loader) print(fEpoch {epoch1} finished, avg_loss: {avg_loss:.4f})这个循环里有几个容易忽略的状态model.train()必须每个epoch都调用不能只在训练前调一次因为后面的评估会切model.eval()切回来必须显式恢复训练模式optimizer.zero_grad()必须在backward之前调用否则梯度会跨batch累加scheduler.step()放在epoch结束后调用和CosineAnnealingLR的预期一致。如果想加warmup可以在前5个epoch手动把学习率从0.01线性升到0.1大batch训练时warmup的效果尤其明显。混合精度训练在数据量大的时候可以显著提速。PyTorch的torch.cuda.amp提供autocast和GradScaler前向和反向用fp16计算、参数更新留在fp32显存占用能砍一半。注意必须配GradScalerfp16的梯度数值范围很窄不缩放直接更新的话loss在某个batch突然变成nan的概率很高。3.4 评估和Top-5准确率的含义训练完要马上看模型在验证集上的表现评估代码和训练代码的一个关键区别是不开梯度、不调BNdef evaluate(model, val_loader): model.eval() correct1 correct5 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.topk(5, dim1) correct1 (preds[:, 0] labels).sum().item() correct5 (preds labels.view(-1, 1)).any(dim1).sum().item() total labels.size(0) print(fTop-1: {correct1 / total:.4f}, Top-5: {correct5 / total:.4f})为什么要关注Top-5而不是只看Top-1因为手写汉字的形近字太多Top-5命中率是实际用户体验的重要参考——很多识别系统会把Top-5候选展示给用户选而不是直接给出唯一答案。在3755类的设定下如果Top-1能到90%左右、Top-5到97%以上这套系统已经可以在许多非关键业务场景中上线使用。4. 系统封装从训练好的模型到能处理真实输入的识别服务4.1 模型保存state_dict、映射表一个都不能少训练一开始就要想好“怎么把模型交出去”。很多人只保存model.state_dict等到部署时才发现没有类别映射表模型输出的0到3754索引根本不知道对应哪个汉字。正确做法是在训练完成后连同优化器的状态用于断点续训和汉字索引映射表一并保存。import json checkpoint { model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), epoch: epoch 1, num_classes: num_classes, label_map: label_map, # {索引: 汉字} } torch.save(checkpoint, hanzi_recognition_checkpoint.pth) with open(label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, ensure_asciiFalse, indent2)label_map是训练时从原始字符建立的索引到汉字映射字典单独存一份JSON推理端读取时就不会因为代码重构导致索引错位。checkpoint文件会包含优化器状态体积比纯模型权重大约一倍部署时如果不需要续训只加载model_state_dict即可。4.2 推理接口输入一张图返回Top-K候选和置信度推理端的核心是把预处理、模型调用、索引映射三段串成一个流水线。注意预处理要和训练时的验证transform保持一致否则训练和推理的效果会系统性偏差这是模型上线后表现反常的最常见原因。def preprocess_image(image_path): image Image.open(image_path).convert(L) return val_transform(image).unsqueeze(0) # 加batch维度 def predict(model, image_tensor, label_map, top_k5): model.eval() with torch.no_grad(): logits model(image_tensor) probs torch.softmax(logits, dim1) topk_probs, topk_indices torch.topk(probs, top_k) results [] for idx, prob in zip(topk_indices[0], topk_probs[0]): results.append((label_map[idx.item()], prob.item())) return results device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model(num_classeslen(label_map)) model.load_state_dict(torch.load(hanzi_recognition_checkpoint.pth, map_locationdevice)[model_state_dict]) model.to(device) image_tensor preprocess_image(test_handwritten.png).to(device) for char, prob in predict(model, image_tensor, label_map): print(f{char}: {prob:.4f})推理有两个容易被忽略的细节。第一加载checkpoint时用map_locationdevice把权重映射到当前设备避免从GPU机器上传过来的权重在CPU机器上因为找不到cuda设备而报错。第二softmax必须在dim1上计算因为输出形状是[1, num_classes]如果写dim0会算出每个类别内跨样本的概率结果完全错乱。4.3 性能与交互取舍单张推理、批处理与候选展示线上识别服务的性能瓶颈通常不在模型计算而在图片解码和目标定位。单张64x64的图片在GPU上推理是毫秒级在CPU上取决于机器型号几十到几百毫秒都可能。如果服务QPS要求高优先把输入尺寸固定在64x64——不要传原图否则卷积计算量会随分辨率平方增长。批量推理时尽量把多张图拼成一个batchGPU的利用率比单张循环高很多。另一个值得投入的是候选展示策略。面向用户的手写识别输入法或答题卡应用返回Top-5候选尤其重要。用户写了一个潦草的“康”模型把它排在第一的是“唐”但“康”出现在Top-5里用户点一下就能纠正体验远好于只给一个答案。如果后续需要更高吞吐量常见做法是把模型导出为ONNX或TorchScript用ONNX Runtime做推理加速但不建议在一开始就上这层先把系统流程验证清楚更重要。还要处理边界输入。空白图、噪声图、歪到几乎只有一条线的图模型也会给出一个置信度最高的答案但这不能算有效识别。实际系统中应该对softmax概率做阈值判断最高置信度低于某个值比如0.5时返回“无法识别”而不是硬给一个汉字。这个阈值要拿真实负样本调不是拍脑袋定。5. 中文手写识别训练避坑五个容易翻车的地方这条路我走过不少回有几个坑几乎每个新项目都会踩一遍按后果严重程度从高到低说。每一条都是实际遇到过、排查过、最后形成固定检查动作的问题。5.1 验证集虚高没有按书写者划分数据集现象训练集准确率95%验证集准确率也在93%以上以为模型已经收敛但部署后拿真实用户手写输入一测准确率直接掉到80%以下。原因划分数据集时按图片文件随机切分同一个书写者的多张手写字被同时分进训练集和验证集。模型在训练时见过这个人的笔迹风格验证时等于开卷考试验证集成绩自然虚高对独立手写样本的泛化能力没有参考价值。手写体的风格和个人强相关这比自然图像里的同类问题更隐蔽——自然图像两张相似的猫不代表是同一只猫但手写体两张相近的字很可能就是同一个人写的。解决按书写者级别切分。每个书写者的全部样本只允许出现在训练集或验证集一侧。如果公开数据集的目录结构明确按书写者组织直接以书写者ID为划分单位自建数据要在采集时记录“哪批字是谁写的”从源头解决。这条规则应该写进数据准备流程的检查清单每轮训练前都核对一遍。5.2 标签映射错位训练时数字索引部署时找不到汉字现象训练时loss正常下降准确率也不错但推理时模型输出的汉字和图片内容对不上输出的是固定几个字且和图片无关。原因训练前建立字符到索引映射时用了默认的排序顺序或者不同的遍历顺序比如读取文件时没有统一排序导致同一个汉字在不同运行中对应不同的索引。训练后的模型权重记录的是索引而部署脚本用的可能是另一套映射。这个问题的隐蔽之处在于训练过程是自洽的loss会正常下降验证集的索引也是同一套所以指标不会有任何异常。解决把字符到索引映射表固化成JSON文件训练和推理都从同一个文件加载。映射表一旦确定就不允许改新加类别只能追加新索引不能重排已有索引。我在项目里会在每次训练开始时打印一行“当前类别数: 3755映射表路径: label_map.json”确认用的是同一个文件。5.3 输入尺寸过小28x28让形近字糊成一片现象训练集准确率能过拟合到98%但验证集准确率卡在70%上下不去而且“己/已/巳”“未/末”这类形近字几乎每次都错。原因沿用了MNIST时代的28x28输入尺寸。MNIST只有10个数字类别类别间差异巨大28x28够用但汉字有三千多个类别区分信息集中在笔画起止和相对位置上28x28分辨率下笔画细节被采样抹掉。形近字之间的差别可能只有一笔的长短或位置分辨率不足时这些信息在输入层就已经丢失。解决把输入尺寸提到56x56或64x64同时确认Resize时用BILINEAR保留笔画灰度过渡。分辨率提升后模型参数量不变但计算量增加64x64的ResNet-18在单卡上仍然可以训练这个代价值得付。如果显存吃紧就用56x56效果差距很小。5.4 PyTorch环境装完但GPU不可用CUDA版本对不上现象import torch正常torch.cuda.is_available()返回False或者打印出“no kernel image is available for execution on the device”。原因pip install torch默认安装的是CPU版本或者PyTorch编译时的CUDA版本和显卡驱动不兼容。装PyTorch之前没有确认本机显卡驱动和CUDA版本的匹配关系是这一系列问题的主要来源。如果机器上有多套Python环境还可能存在不同环境里装了不同版本PyTorch的情况。解决先运行nvidia-smi查看驱动对应的CUDA版本再到PyTorch官网选对应CUDA版本的安装命令不要直接用pip install torch。在Windows Subsystem for Linux环境下还要确认宿主机的GPU驱动能被WSL访问nvidia-smi在WSL里能正常显示显卡信息才说明环境通畅。5.5 训练到一半loss变NaN现象前几个epoch的loss正常下降某个batch开始loss输出nan之后所有数值都是nan模型彻底报废。原因最常见的是学习率过大导致梯度爆炸其次是输入图片里存在异常像素比如全黑图、损坏的图片文件产生非有限梯度还有一类是混合精度训练时fp16的数值溢出在小batch且无梯度裁剪的训练里更容易出现。汉字数据集的样本如果是从扫描件切出来的偶尔会混入全白或全黑的坏块这些坏块在数据检查环节很容易漏掉。解决先检查数据里有没有损坏图片把无法正常解码的文件从数据集中剔除再确认学习率SGD在0.1、batch_size128的组合下如果还在训练中段爆nan大概率是数据问题。混合精度训练建议配合torch.cuda.amp的GradScaler使用它能自动检测并避免fp16梯度下溢。6. 从85%到95%测试时增强和混淆矩阵驱动的数据补充如果基线Top-1在85%到90%之间想再往上推我的经验是两个方向最见效测试时增强TTA和基于混淆矩阵的针对性补数据。TTA的实现思路是推理时对同一张图做几次轻微变换把预测概率平均后再输出。颜色、位置、角度的扰动模拟了用户书写的正常波动模型不再被单一样本的偶然抖动带偏。实际效果在形近字容易混淆的场景下提升明显Top-1能涨1到2个百分点tta_transforms [ lambda x: x, lambda x: transforms.functional.rotate(x, 3, fill255), lambda x: transforms.functional.rotate(x, -3, fill255), lambda x: transforms.functional.affine(x, 0, translate(0.04, 0), scale1.02, shear0), lambda x: transforms.functional.affine(x, 0, translate(0, 0.04), scale1.02, shear0), ] def predict_with_tta(model, image_tensor, label_map): probs [] with torch.no_grad(): for t in tta_transforms: logits model(t(image_tensor).unsqueeze(0)) probs.append(torch.softmax(logits, dim1)) avg_probs torch.stack(probs).mean(dim0) topk_probs, topk_indices torch.topk(avg_probs, 5) return [(label_map[idx.item()], prob.item()) for idx, prob in zip(topk_indices[0], topk_probs[0])]注意TTA里的旋转和仿射参数要和训练时的增强幅度保持一致不要超过模型见过的扰动范围否则反而会降低准确率。TTA的代价是推理耗时乘以变换次数线上如果QPS高就只在关键场景启用或者用在离线批量识别上。第二个方向是混淆矩阵。把验证集里被预测错的样本按“错误目标类别”分组你会发现错误不是均匀分布的而是集中在几十对形近字上。针对这些对子不要盲目加数据而是去数据源里挖对应字的更多样本。公开数据集里高频错字本身样本就更多如果仍然错就要去看具体样本是标注错了还是图片本身模糊到人眼都难以辨认。如果错误样本里有一部分是标注错误修掉它们比重采数据更有效。做识别系统这几年我最深的体会是数据清理和映射管理这类“脏活”决定了项目下限网络结构调整只决定上限。我的习惯是每次训练前先跑一个检查脚本确认训练集和验证集的书写者无重叠、映射表和模型输出维度一致、验证集变换和训练集一致这一套流程能省掉后面至少三分之一的返工时间。走完这一轮你的中文手写汉字识别系统就能稳稳落地。希望帮到你。本文还有配套的精品资源点击获取