HWDB手写汉字识别实战:GNT解析到PyTorch CNN训练全流程 简介这是一套基于PyTorch的中文手写汉字识别卷积神经网络项目面向具备深度学习基础的高校学生和开发者适合作为高级课程期末项目或进阶实战参考主要解决手写汉字自动分类与识别问题。项目使用HWDB真实手写汉字数据集进行训练样本涵盖多种书写风格与质量代码设计了卷积层、激活函数、池化层和全连接层组合的卷积网络并引入批量归一化、dropout等策略提升泛化能力完整覆盖图像归一化、灰度转换、模型训练及准确率评估等环节。压缩包共6个文件包含4个Python脚本分别负责数据处理、模型构建、训练主流程等、1份说明文档和1张示例图片压缩包仅184KB轻量紧凑、结构清晰可直接运行参考或在此基础上改进迭代。目前已有66人学习下载代码注释清晰、模块划分明确便于读者学习CNN在中文手写识别中的应用思路也可迁移到手写输入法、自动阅卷等实际场景。1. 为什么HWDB中文手写识别要先过GNT这一关中文手写汉字识别是卷积神经网络的典型应用场景然而HWDB 1.1数据集并不像绝大多数图像分类任务那样直接给你一堆可读的PNG文件夹。HWDB的原始发放格式是GNT一种连续二进制容器所有样本的灰度点阵按“记录头 像素数据”顺序堆在一个文件里没有目录结构也没有现成的标签树。很多人把HWDB下下来以后第一反应是“这图片怎么读”其实卡的并非CNN而是解码层。本项目把这条链路拆成四段process_gnt.py负责把GNT解析成灰度图hwdb.py构建PyTorch的数据管道model.py定义卷积神经网络train.py完成训练和验证。对于正在做PyTorch期末项目、或想把HWDB作为预训练语料的同学这套流程能帮你绕开最消耗时间的二进制解析和标签对齐问题。2. HWDB的GNT解析二进制容器转灰度样本2.1 GNT文件里到底存了什么GNT文件的每一条记录对应一个手写汉字样本。常见的字段安排是4字节记录总长度、2字节字符编码、2字节位图宽度、2字节位图高度后面紧跟width * height字节的灰度像素数据。字段顺序可能在不同子版本里略有出入但HWDB 1.0和1.1基本保持这种布局。记录长度字段用于快速跳过或对齐尤其当某个文件末尾存在损坏数据时可以用它来校验实际读取的字节是否和头部声明一致。字符编码并不是简单的Unicode码点而是GBK编码的两个字节。这也是新手最容易踩坑的地方如果直接用chr(code)转字符串得到的是乱码因为GBK字节必须两两一组按汉字编码表解码。正确做法是保留原始文件里的两字节顺序再交给gbk编码解码器。下表总结了GNT记录的核心字段字段大小说明记录总长度4字节uint32包含长度字段自身在内的整条记录字节数字符编码2字节GBK编码需要按原始字节顺序解码位图宽度2字节uint16灰度点阵的宽度位图高度2字节uint16灰度点阵的高度像素数据宽 × 高 字节每个字节是0255的灰度值2.2 process_gnt.py 的核心读取逻辑下面是在process_gnt.py里常见的一段实现。我一般会先一次性读出整条记录再做切片解析避免边读边跳带来的多次文件IO。# process_gnt.py import struct import numpy as np def parse_gnt_bytes(data: bytes): 从一条GNT记录中解析出字符编码和灰度点阵。 tag_code, width, height struct.unpack_from(HHH, data, 0) bitmap_offset 6 # 前面有 tag_code width height 共 6 字节 bitmap np.frombuffer( data, dtypenp.uint8, countwidth * height, offsetbitmap_offset ).reshape(height, width) return tag_code, bitmap def parse_gnt_file(gnt_path: str): 遍历GNT文件中的所有样本记录。 samples [] with open(gnt_path, rb) as f: while True: length_bytes f.read(4) if len(length_bytes) 4: break (length,) struct.unpack_from(I, length_bytes) record f.read(length - 4) if len(record) 6: break tag_code, bitmap parse_gnt_bytes(record) samples.append((tag_code, bitmap)) return samples这段代码先读取4字节记录长度再读取length - 4字节的记录体。parse_gnt_bytes从记录体偏移0开始解析出GBK编码和位图尺寸。这里的关键点是struct.unpack_from(HHH, data, 0)读出的tag_code是数值还需要用它反推出原始字节def gbk_code_to_char(tag_code: int) - str: raw_bytes struct.pack(H, tag_code) # 还原文件中顺序 return raw_bytes.decode(gbk)struct.pack(H, tag_code)会把小端数值重新打包成文件里的原始字节顺序比如文件里写的是0xBAA3解出来数值后再pack回去才能用gbk正确读出汉字。如果省略这一步直接在decode(gbk)里传入整数会立刻报编码异常。2.3 批量解包成图片并保存标签目录每次训练都去顺序扫描GNT文件效率太差因为单个样本没有索引DataLoader每次随机取样本都需要从头扫文件。更合理的方式是先离线解包成PNG图片并用标签的Unicode码点作为目录名。# process_gnt.py 中的导出函数 from pathlib import Path from PIL import Image def export_to_images(gnt_root: str, output_root: str): gnt_root, output_root Path(gnt_root), Path(output_root) for gnt_file in gnt_root.glob(*.gnt): for tag_code, bitmap in parse_gnt_file(str(gnt_file)): label gbk_code_to_char(tag_code) label_dir output_root / f{ord(label):05d} label_dir.mkdir(parentsTrue, exist_okTrue) save_name f{gnt_file.stem}_{len(list(label_dir.iterdir()))}.png Image.fromarray(bitmap, modeL).save(label_dir / save_name)用ord(label)生成5位数字目录可以绕过Windows和Linux对中文字符串路径的编码差异也能直接兼容torchvision.datasets.ImageFolder。真实项目里样本很多时建议把class_to_idx序列化保存一份后续做推理时要通过它把索引映射回汉字。3. hwdb.py用Dataset和DataLoader搭建训练管道3.1 为什么不在训练时实时解析GNT虽然process_gnt.py已经能把GNT解析出来但训练过程每个step要访问大量样本实时解析会导致大量重复文件读取。尤其HWDB单个字符的位图通常只有几百字节但GNT文件连续存储的特性使得随机读取成本很高。如果每个epoch都重新解析一遍训练速度可能会被IO拖慢一倍不止。正确的思路是“一次解析、反复使用”把解包得到的PNG作为训练集的输入。hwdb.py的核心是继承torch.utils.data.Dataset把图片路径和标签索引对应起来。它的职责不是做图像解码而是把路径管理、缓存、数据增强这些逻辑集中到一个类里。3.2 HWDBDataset的实现下面是一个典型的HWDBDataset实现兼容上一章导出的数字目录结构。# hwdb.py import os from torch.utils.data import Dataset from PIL import Image class HWDBDataset(Dataset): def __init__(self, root, transformNone): self.samples [] self.transform transform class_names sorted(os.listdir(root)) self.class_to_idx {name: i for i, name in enumerate(class_names)} for class_name in class_names: class_dir os.path.join(root, class_name) for file_name in sorted(os.listdir(class_dir)): self.samples.append(( os.path.join(class_dir, file_name), self.class_to_idx[class_name] )) def __len__(self): return len(self.samples) def __getitem__(self, index): path, label self.samples[index] image Image.open(path).convert(L) if self.transform: image self.transform(image) return image, labelclass_names是上一章生成的5位数字目录所以sort可以保证稳定顺序。class_to_idx被单独保存一份可以让后期推理时通过idx2char反推出汉字。convert(L)确保图片是灰度单通道避免PNG中可能出现的带通道数据干扰卷积层输入。每次只返回一张图和一个整数标签真正的高效读取交给DataLoader去做。3.3 图像缩放与归一化参数HWDB原始位图分辨率并不统一有的字符只有几十像素有的超过一百像素。卷积神经网络的输入必须固定尺寸这里常用64×64或96×96。64×64在显存占用和识别精度之间比较均衡作为期末项目足够。数据预处理采用torchvision.transforms.Compose组合多项操作。# hwdb.py 中的 transform 配置 from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomAffine( degrees10, translate(0.1, 0.1), scale(0.9, 1.1), fill0 ), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ]) val_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)), ])很多人会直接复制Normalize((0.5,), (0.5,))但HWDB的灰度分布不一定是均值为0.5的高斯分布。我一般会先跑一段统计脚本随机采样几千张图片计算灰度均值mean和标准差std再把实际数值填进Normalize。如果直接使用默认值得到的不是标准正态分布初期loss可能下降得不够快。另外中文汉字不适合做大幅旋转。RandomAffine的degrees10表示随机旋转范围是[-10, 10]度这个量级对汉字结构影响有限。过大的旋转会造出“彳”和“亻”这类形近字混淆因此不要盲目把角度调大。3.4 DataLoader参数怎么配from torch.utils.data import DataLoader train_loader DataLoader( train_dataset, batch_size128, shuffleTrue, num_workers4, pin_memoryTrue, drop_lastTrue ) val_loader DataLoader( val_dataset, batch_size128, shuffleFalse, num_workers4, pin_memoryTrue )num_workers4可以让4个子进程并行读取和预处理图片训练时不会因为等待数据而空转。pin_memoryTrue在GPU训练时能加速CPU到GPU的传输。drop_lastTrue会丢弃最后一个不足batch_size的批次避免BatchNorm在最后一个batch统计量失衡。如果你拿到的是HWDB1.1的官方划分train和test分别在不同文件夹只需要为两个文件夹各建一个HWDBDataset。如果没有官方划分就按类别数量按8:2随机切分但必须保证切分后每个类别在训练集里都有样本否则某些类别标签会在验证集里出现而训练集里完全没有。4. model.py train.pyCNN设计、损失与训练循环4.1 卷积神经网络结构怎么选中文手写汉字识别不是简单的MNIST任务类别数高达3755个需要比LeNet更大的表达能力但又不能因为模型过大而过拟合。常见的做法是三层卷积加池化配合BatchNorm和Dropout。输入数组尽量固定为64×64单通道每个卷积块由“卷积BNReLUMaxPool”组成。如果是在做课程项目建议先用Adam快速验证模型能否收敛再把优化器换成SGD看精度上限。下面是model.py里一个可运行的CNN结构。# model.py import torch.nn as nn class HWDBCNN(nn.Module): def __init__(self, num_classes3755): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d((4, 4)), ) self.classifier nn.Sequential( nn.Dropout(0.3), nn.Linear(128 * 4 * 4, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(512, num_classes), ) def forward(self, x): x self.features(x) x torch.flatten(x, 1) return self.classifier(x)AdaptiveAvgPool2d((4, 4))是一个很实用的设计它不关心输入特征图具体是8×8还是16×16始终固定输出4×4特征图。这样即使把输入尺寸从64改为96也不需要重算全连接层的输入维度。三个卷积块分别把空间尺寸从64降到32、16最后到4感受野足够覆盖一个完整汉字的结构。Dropout放在全连接层前能在样本量有限时降低过拟合风险。4.2 损失函数与优化器参数对比字符识别本质上是最细粒度的分类任务CrossEntropyLoss直接支持未归一化的logits内部会先做Softmax再计算损失不需要在模型尾部额外加Softmax激活。优化器方面Adam对于PyTorch入门用户更友好SGDMomentum则需要花更多时间在学习率上。优化器常用学习率收敛速度最终精度趋势Adam0.001 或 0.0003快容易停在稍差极小值附近SGD Momentum0.01 配合CosineAnnealing前期慢后期能冲到更高精度对于HWDB 3755类这种大规模分类任务我一般先把Adam的lr设成1e-3跑通流程验证数据管道和模型没有bug后再换SGD。SGD的收敛结果通常更稳但在训练后期需要配合学习率衰减。# train.py 中的优化器配置 import torch.optim as optim criterion nn.CrossEntropyLoss() optimizer optim.SGD( model.parameters(), lr0.01, momentum0.9, weight_decay1e-4 ) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20)momentum0.9让梯度带有一定的惯性能跨过局部小陷阱。weight_decay1e-4是常用的L2正则可以稍微抑制大权重。CosineAnnealingLR会在20个epoch内把学习率从初始值按余弦曲线降到接近0比手写StepLR更省心。4.3 train.py 的训练与验证循环训练循环里最容易出错的是没有切换model.train()和model.eval()。BatchNorm和Dropout在两种模式下的行为完全不同忘记切换会让验证集指标失真。下面这段代码把训练和验证放在同一个epoch循环里。# train.py device torch.device(cuda if torch.cuda.is_available() else cpu) model HWDBCNN(num_classes3755).to(device) for epoch in range(20): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() model.eval() correct_1 0 correct_5 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) top1 outputs.argmax(dim1) top5 outputs.topk(5, dim1).indices correct_1 (top1 labels).sum().item() correct_5 (top5 labels.view(-1, 1)).sum().item() total labels.size(0) print(fepoch {epoch1}/{20} | loss {running_loss/len(train_loader):.4f} f| top1 {correct_1/total:.4f} | top5 {correct_5/total:.4f}) scheduler.step()top5 labels.view(-1, 1)是Top-5准确率计算的关键。labels.view(-1, 1)把每个标签变成一列向量PyTorch会做广播比较top5的shape是[batch_size, 5]比较后每个样本位置会统计5个候选里有多少个正确最后再sum()除以样本总数。利用这个技巧可以同时输出Top-1和Top-5两个维度的指标而不需要额外循环。如果显存不够把batch_size从128降到32时最好同步把学习率缩小到原来的四分之一否则梯度估计的噪声变大原来合适的lr可能会让loss震荡。这种情况下建议优先用Adam因为它对batch size变化不如SGD敏感。4.4 怎么判断过拟合和欠拟合训练初期如果训练loss和验证top1都在上升说明数据管道或模型有bug先不要调参。如果训练top1快速逼近100%而验证top1停留在70%以下则是过拟合信号优先做三件事增加RandomAffine的扰动强度、提高Dropout到0.5、或者把网络最后一层全连接改成Dropout前加一层BatchNorm1d。反过来如果训练和验证top1都很低则是欠拟合先把模型里的AdaptiveAvgPool2d输出从4×4改成6×6让全连接层拿到更多空间信息。很多HWDB项目的精度瓶颈不是网络深度而是数据集本身的标注噪声和手写字体风格跨度过大。HWDB1.1同一汉字不同作者的写法差异非常显著所以验证时一定保留Top-5指标否则你会误以为模型完全没有学会。5. 验证指标与ONNX导出从PyTorch到快速推理5.1 Top-5比Top-1更能反映模型真实水平中文汉字有大量形近字例如“日”和“曰”、“己”和“已”人眼都容易混淆模型把正确答案放进Top-5其实已经很实用。如果一个手写输入法的候选中能出现正确字用户就能接受。训练完成后可以顺手统计混淆矩阵找到最容易出错的类别对。from sklearn.metrics import confusion_matrix cm confusion_matrix(all_labels, all_preds) np.fill_diagonal(cm, 0) bad_score cm.sum(axis0) worst_classes bad_score.argsort()[-10:][::-1]输出前十个被错误预测最多的类别再结合对应汉字反向映射能快速发现是数据清洗问题还是网络结构问题。注意这里传入的all_labels和all_preds应该是整理好的类别索引列表而不是汉字本身。5.2 导出ONNX并用onnxruntime推理模型训练完成后通常要把模型部署到C环境或者让Python脚本不再依赖PyTorch。ONNX导出只需要一个随机输入确定尺寸。# export_onnx.py import torch model.eval() dummy torch.randn(1, 1, 64, 64).to(device) torch.onnx.export( model, dummy, hwdb_cnn.onnx, input_names[input], output_names[output], dynamic_axes{ input: {0: batch}, output: {0: batch} } )dynamic_axes让batch维度可以是任意值这样一次导出的模型既能处理单张图片也能处理批量推理。ONNX Runtime的加载方式和PyTorch完全不同但推理接口非常简单。import onnxruntime as ort import numpy as np sess ort.InferenceSession( hwdb_cnn.onnx, providers[CUDAExecutionProvider, CPUExecutionProvider] ) input_name sess.get_inputs()[0].nameproviders列表指定了执行优先顺序没有GPU的机器会自动使用CPU有GPU时则优先走CUDA。5.3 避开预处理不一致的坑ONNX推理端最容易出问题的地方不是模型而是图像预处理。PyTorch训练时用的是Resize((64, 64)) ToTensor Normalize((0.5,), (0.5,))推理端也必须完全复现这一套否则模型输入分布一变准确率会直线下降。def infer_single(sess, pil_image, idx2char): img pil_image.resize((64, 64)).convert(L) arr np.asarray(img, dtypenp.float32) arr (arr / 255.0 - 0.5) / 0.5 tensor arr.reshape(1, 1, 64, 64) outputs sess.run(None, {input_name: tensor})[0] top5 outputs[0].argsort()[-5:][::-1] return [idx2char[i] for i in top5]这里把图像像素从0255缩放到-11等同于训练时的ToTensor Normalize((0.5,), (0.5,))。idx2char需要提前从process_gnt.py导出的类别目录构建按数字目录名排序后每个位置对应的汉字就是chr(int(dir_name))。这样返回的列表是五个最可能的汉字按置信度从高到低排列适合接入候选框等前端交互。本文还有配套的精品资源点击获取