简介基于PyTorch的中文手写汉字识别项目面向具备一定深度学习基础的高校学生或研究人员可作为高级课程期末项目参考。项目采用HWDB手写汉字数据集完整覆盖CNN网络设计、图像归一化与灰度预处理、模型训练与准确率评估等流程。压缩包共6个文件含4个Python脚本process_gnt.py负责数据解析、hwdb.py加载数据集、model.py定义网络结构、train.py执行训练与测试、1个Markdown说明文档及1张示意图整体仅184KB结构清晰方便快速上手。已有66人学习下载。通过源码可掌握PyTorch动态图机制、卷积层/池化层/全连接层搭建、批量归一化与dropout防过拟合技巧以及中文汉字分类逻辑模型可迁移至手写输入法优化、自动阅卷、文档数字化等场景适合算法验证与二次开发。1. 为什么中文手写识别比英文识别难一个量级从HWDB数据集说起中文手写汉字识别在OCR领域一直是个特殊的存在。英文字母或数字类别少、字形简单而汉字常用字就有3755个GB2312一级字表加上二级字库超过6000类。更棘手的是手写变体极大同一个“永”字在不同人笔下结构比例完全不同。这种“类内方差大、类间差异小”的问题恰好是CNN最需要也最擅长处理的场景——但前提是数据管线和模型设计都跟得上。HWDB数据集通常指CASIA-HWDB是目前公开的中文手写汉字标准库包含约300万手写样本覆盖3755个类别是训练识别模型最扎实的起点。这篇内容按一个期末项目的完整路径走一遍从HWDB原始格式解析到PyTorch里的DataLoader再到CNN结构选型、训练调参和误报分析。整个过程不会用到预训练权重也从零开始落一遍代码适合把课程项目做到能演示、能答辩的状态。2. 数据管线HWDB数据集的解析与PyTorch DataLoader设计2.1 HWDB原始数据格式GBK编码、坐标点与GNT文件HWDB数据集分为HWDB1.0和HWDB1.1等子集文件名形如HWDB1.1trn_gnt以二进制格式存储字符、行和坐标信息。原始格式里每个样本包含一个汉字的内码GB2312/GBK以及对应的字符图像。常见做法是解析GNT文件后直接导出为灰度图片再按类别号组织目录。一个最简单的解析思路是读取每条样本的头信息跳过坐标点数据把bitmap部分单独抽出。注意HWDB的位图是压缩过的游程编码RLE需要先解压成长宽可变的像素矩阵。转换时统一缩放为64x64或128x128并做二值化或灰度归一化。下表整理了GNT文件中每个样本块的关键字段很多人第一次解析时都会在这里对不上位。字段长度字节说明样本总长4当前样本块的总字节数用于跳到下一块字符内码2GB2312/GBK编码需要转换到Unicode灰度级1固定为8或1用来确定像素值位数位图宽度2像素宽度注意是short型位图高度2像素高度坐标点数2后续坐标点个数每个点占4字节点阵数据变长经过游程编码需要解码这里的关键是样本总长字段。解析器读取前4字节后不能直接继续读下一条而是要先根据长度跳转否则会在样本边界上错位。我的做法是先随机挑选几个样本打印出每条样本的长度和位图尺寸核对与官方文档的描述是否一致再写完整解析器。2.2 将GNT转换为标准图像目录若要快速进入训练我一般会写一个convert_hwdb.py把每个字符按类别存成train/label/sample_id.png。类别编号可以直接用GB2312区位码也可以先构建一个label - index映射表输出为JSON方便后续在训练脚本里读取。python convert_hwdb.py --gnt_dir path/to/HWDB1.1trn_gnt --output_dir ./hwdb_images --size 64转换完成后目录大概长这样hwdb_images/ train/ 0/ sample_0.png 1/ sample_1.png ...参数说明--size控制缩放尺寸64x64在显存有限的笔记本上更友好如果想追求更高精度可以换成128或96但DataLoader的batch size需要同步降低。另外HWDB的测试集和训练集不是完全独立的人写样本转换时最好把不同人书写的数据分开否则验证结果会被高估。构建类别映射时要注意HWDB的标签字符是GBK编码Python环境下读取后需统一转为Unicode。写映射表时可以直接用字符本身作为key也可以转成数字索引。import json import os char_to_idx {} idx_to_char {} idx 0 train_root ./hwdb_images/train for char_dir in sorted(os.listdir(train_root)): if char_dir not in char_to_idx: char_to_idx[char_dir] idx idx_to_char[idx] char_dir idx 1 with open(hwdb_label_map.json, w, encodingutf-8) as f: json.dump({char_to_idx: char_to_idx, idx_to_char: idx_to_char}, f, ensure_asciiFalse)这段代码背后的思想是先把类别空间固定下来训练和推理都用同一个映射否则验证集和模型输出对不上号。sort()保证同一字符在多次运行时获得相同的索引也便于在多个脚本之间复用。2.3 用PyTorch Dataset与DataLoader吃下数据集有了图像目录数据读取就交给torch.utils.data.Dataset和DataLoader。在PyTorch基础框架里这两者是组织数据的最常用组合。import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import os class HWDBDataset(Dataset): def __init__(self, root_dir, transformNone): self.samples [] self.transform transform for label_name in sorted(os.listdir(root_dir)): label_dir os.path.join(root_dir, label_name) if not os.path.isdir(label_dir): continue for img_file in os.listdir(label_dir): self.samples.append((os.path.join(label_dir, img_file), int(label_name))) def __len__(self): return len(self.samples) def __getitem__(self, idx): img_path, label self.samples[idx] image Image.open(img_path).convert(L) if self.transform: image self.transform(image) return image, label train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomAffine(degrees5, translate(0.05, 0.05), scale(0.9, 1.1)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_loader DataLoader(HWDBDataset(./hwdb_images/train, transformtrain_transform), batch_size128, shuffleTrue, num_workers4)逻辑说明Dataset只负责按索引读图并做预处理随机仿射变换可以模拟手写笔迹的不规则旋转与平移对HWDB这种风格多样的数据有明显收益。num_workers设为4以上可以利用多核CPU加载图像避免GPU空闲。这里有一个坑HWDB原始图片是白色背景黑色笔画而ToTensor()会把像素除以255Normalize里的均值和方差需要按灰度图实际分布设定。直接把ImageNet的RGB均值和方差搬过来虽然不会报错但收敛速度会变慢。更合理的做法是先手动统计训练集灰度图的均值和标准差再填入Normalize。3. 模型设计适用于汉字识别的CNN结构3.1 为什么通用CNN在汉字上不够用我们常说的CNN卷积神经网络结构图通常包含卷积、激活、池化和全连接层。但汉字识别有它的特殊性字符拓扑信息非常密集比如“己、已、巳”三个字只差笔画的闭合位置而“未、末”则差在横划长度比例。通用图像分类模型往往在低层只捕捉纹理和边缘对这类细节不够敏感。因此设计模型时需要在第一层就保证足够的输入分辨率和卷积核感受野搭配。常见做法是把输入放大到96x96或128x128。如果显存不足可以在第一个卷积层用stride1而不是2先保留笔画位置信息再通过后续池化逐步压缩。另一个容易忽略的问题是汉字手写体的笔画宽窄差异很大固定的卷积核尺寸可能会对细笔画和粗笔画产生不同的响应。可以在数据增强中加入RandomErasing随机遮挡一小块区域强迫模型使用上下文信息而不是只依赖局部笔画。3.2 一个适合HWDB的基线CNN结构下面给出一个不需要预训练也能在HWDB上跑出可观准确率的CNN结构参数规模约在几百万级别单卡GTX 1080Ti上训练一轮大约10分钟普通笔记本也能接受。import torch.nn as nn class HWDB_CNN(nn.Module): def __init__(self, num_classes3755): super(HWDB_CNN, self).__init__() self.features nn.Sequential( # 第1层输入1通道输出32通道 nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 输出 32x32 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 16x16 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 8x8 nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 4x4 ) self.classifier nn.Sequential( nn.Dropout(p0.3), nn.Linear(256 * 4 * 4, 1024), nn.ReLU(inplaceTrue), nn.Dropout(p0.3), nn.Linear(1024, num_classes), ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) x self.classifier(x) return x参数说明卷积核大小kernel_size3配合padding1保证特征图分辨率不缩水MaxPool2d每层减半最终从64x64降到4x4。BatchNorm对提升训练稳定性作用明显尤其在batch size不大时它能让激活分布更规整。Dropout放在全连接层前面能在不改变卷积层参数的情况下降低过拟合。这里没有用空洞卷积或注意力模块因为手写汉字的笔画密度高空洞卷积容易漏掉细线。若想进一步提高准确率可以在最后的池化层改用全局平均池化减小全连接层参数规模显存占用也会降低。3.3 关于卷积、池化、步长、核与填充的工程理解卷积神经网络结构图中的“卷积、池化、步长、核、填充”是有顺序的工程决策。以本例为参考参数本模型取值影响卷积核3x3能捕捉笔画交叉点的局部模式两层3x3堆叠比一层5x5参数更少非线性更强步长1卷积层保护笔画连续细节下采样全部交给池化完成填充1让特征图尺寸不因卷积而缩小避免笔画靠近边界的信息丢失池化2x2步长2快速降低尺寸并增加平移鲁棒性但过多次池化会损失细线结构如果觉得4层卷积不够深可以加一层3x3且不改变通道数的卷积通常能提升1-2%的准确率。注意不要一味增深HWDB的训练样本虽然多但类别也极多模型超过一定复杂度后反而容易记住训练集中的特定笔迹测试集上的top-1准确率下降通常比迁移学习模型更明显。4. 训练策略与调参损失函数、优化器与学习率4.1 标签平滑与类别映射3755类在Softmax交叉熵下模型的logits输出维度很大训练时容易对正确类别过度自信进而带来过拟合。一个常见的工程技巧是使用标签平滑label smoothing。在PyTorch中nn.CrossEntropyLoss(label_smoothing0.1)即可实现。相比直接硬编码one-hot标签平滑后的标签相当于给模型一点“容错空间”对手写字迹的随机变形更友好。类别映射在训练脚本里也需要和转换阶段保持一致最好在训练和验证时都从同一个JSON文件加载。criterion nn.CrossEntropyLoss(label_smoothing0.1)参数说明label_smoothing取值0.1表示把真实标签的概率从1.0降低到0.9并把剩下的0.1均匀分给其他类别。取值过大会让模型欠拟合一般建议0.05-0.15。4.2 优化器选择与学习率策略对于这种大类别数任务我常用SGD配合动量而不是初始就用Adam。SGD虽然收敛慢但最终泛化能力更强Adam适合快速看模型是否跑通。如果硬件资源受限可以先跑5个epoch的Adam再换回SGD这种切换在实践中不算罕见。import torch.optim as optim optimizer optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30, eta_min1e-5)逻辑说明动量0.9可以加速沿梯度方向一致的参数更新weight_decay是权重衰减等效于一个轻量L2正则化。CosineAnnealingLR让学习率在30个epoch内从0.01余弦衰减到1e-5比固定学习率更容易跳出局部最优。如果机器没有GPU可以把lr降到0.001否则训练会非常不稳定。这里的T_max30要和总epoch数匹配。如果只想训练20轮就把T_max设为20。太小的T_max会在后期频繁重启学习率导致损失震荡。下表给出常用参数的推荐范围和调整原则参数推荐范围调整方向lr0.001 - 0.05loss发散时调小收敛太慢时调大batch_size64 - 256显存够就放大类别多时建议至少64momentum0.85 - 0.95默认0.9震荡时微调到0.85weight_decay1e-5 - 1e-3过拟合严重时加大label_smoothing0.05 - 0.15形近字错误多时可调大4.3 一个可以跑起来的训练循环骨架下面给出一个精简但完整的训练循环重点在记录每个epoch的训练损失和验证准确率。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0.0 correct 0 total 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() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() return total_loss / total, correct / total注意在训练循环中不要频繁调用model.eval()否则BatchNorm的统计量会失效。验证时则要关闭梯度计算避免占用显存和浪费时间。建议在每轮结束后画出loss曲线观察是否存在明显的震荡或发散。如果训练loss下降但验证准确率长时间不动可以先检查是不是验证集类别映射写错了。5. 验证与错误分析从混淆矩阵到难例挖掘训练结束后最先要看的是测试集top-1准确率。3755分类任务能到90%以上就算不错到95%需要细调。但只看准确率不够还需要分析模型到底错在哪。HWDB自带测试集建议把预测结果和真实标签、图像路径一起存成CSV。import csv def evaluate(model, loader, device, output_path): model.eval() rows [] with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) for i in range(images.size(0)): rows.append((labels[i].item(), predicted[i].item())) with open(output_path, w, newline) as f: writer csv.writer(f) writer.writerow([true_label, pred_label]) writer.writerows(rows)拿到预测结果后可以计算每个类别的召回率找出最容易出错的字。一般“形近字”占大头比如“土”和“士”、“日”和“曰”。这种错误不是单纯加深网络就能解决的。一个更有效的技巧是难例挖掘。把预测概率低于0.5或top1置信度低的样本收集起来单独组成一个小批量“重训集”用较小的学习率微调模型。在PyTorch里可以这样实现# 在训练循环中保留置信度低于阈值的样本 import torch.nn.functional as F all_images [] all_labels [] threshold 0.5 for images, labels in loader: outputs model(images) probs F.softmax(outputs, dim1) max_probs, _ probs.max(dim1) mask max_probs threshold if mask.sum() 0: all_images.append(images[mask]) all_labels.append(labels[mask])然后重新构造一个DataLoader专门用这部分难样本继续训练。这种方法比单纯随机采样更高效能显著提升模型在相似字对上的区分能力。如果建模时间充裕还有一个比较取巧的验证技巧用测试集中的同一汉字多次预测检查预测结果的稳定性。手写识别模型对尺度、平移应该相对不敏感如果某次笔画稍有位移后预测类别就改变说明卷积神经网络的平移鲁棒性还不够这时可以增加RandomAffine的平移幅度或者在池化层改用AdaptiveAvgPool2d(1)配合更大的feature map。建议把难例挖掘的阈值从0.5逐步下调到0.3每调整一次都重新统计各形近字对的混淆比例再决定下一步调参方向。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站