HWDB手写汉字识别实战:GNT解析到PyTorch CNN训练全流程
2026/9/11 14:33:57 网站建设 项目流程

简介:这是一套基于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)灰度点阵的高度
像素数据宽 × 高 字节每个字节是0~255的灰度值

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, dtype=np.uint8, count=width * height, offset=bitmap_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(parents=True, exist_ok=True) save_name = f'{gnt_file.stem}_{len(list(label_dir.iterdir()))}.png' Image.fromarray(bitmap, mode='L').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, transform=None): 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, label

class_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( degrees=10, translate=(0.1, 0.1), scale=(0.9, 1.1), fill=0 ), 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可能下降得不够快。

另外,中文汉字不适合做大幅旋转。RandomAffinedegrees=10表示随机旋转范围是[-10, 10]度,这个量级对汉字结构影响有限。过大的旋转会造出“彳”和“亻”这类形近字混淆,因此不要盲目把角度调大。

3.4 DataLoader参数怎么配

from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True, drop_last=True ) val_loader = DataLoader( val_dataset, batch_size=128, shuffle=False, num_workers=4, pin_memory=True )

num_workers=4可以让4个子进程并行读取和预处理图片,训练时不会因为等待数据而空转。pin_memory=True在GPU训练时能加速CPU到GPU的传输。drop_last=True会丢弃最后一个不足batch_size的批次,避免BatchNorm在最后一个batch统计量失衡。

如果你拿到的是HWDB1.1的官方划分,train和test分别在不同文件夹,只需要为两个文件夹各建一个HWDBDataset。如果没有官方划分,就按类别数量按8:2随机切分,但必须保证切分后每个类别在训练集里都有样本,否则某些类别标签会在验证集里出现而训练集里完全没有。

4. model.py + train.py:CNN设计、损失与训练循环

4.1 卷积神经网络结构怎么选

中文手写汉字识别不是简单的MNIST任务,类别数高达3755个,需要比LeNet更大的表达能力,但又不能因为模型过大而过拟合。常见的做法是三层卷积加池化,配合BatchNorm和Dropout。输入数组尽量固定为64×64单通道,每个卷积块由“卷积+BN+ReLU+MaxPool”组成。

如果是在做课程项目,建议先用Adam快速验证模型能否收敛,再把优化器换成SGD看精度上限。下面是model.py里一个可运行的CNN结构。

# model.py import torch.nn as nn class HWDBCNN(nn.Module): def __init__(self, num_classes=3755): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d((4, 4)), ) self.classifier = nn.Sequential( nn.Dropout(0.3), nn.Linear(128 * 4 * 4, 512), nn.ReLU(inplace=True), 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入门用户更友好,SGD+Momentum则需要花更多时间在学习率上。

优化器常用学习率收敛速度最终精度趋势
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(), lr=0.01, momentum=0.9, weight_decay=1e-4 ) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)

momentum=0.9让梯度带有一定的惯性,能跨过局部小陷阱。weight_decay=1e-4是常用的L2正则,可以稍微抑制大权重。CosineAnnealingLR会在20个epoch内把学习率从初始值按余弦曲线降到接近0,比手写StepLR更省心。

4.3 train.py 的训练与验证循环

训练循环里最容易出错的是没有切换model.train()model.eval()BatchNormDropout在两种模式下的行为完全不同,忘记切换会让验证集指标失真。下面这段代码把训练和验证放在同一个epoch循环里。

# train.py device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = HWDBCNN(num_classes=3755).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(dim=1) top5 = outputs.topk(5, dim=1).indices correct_1 += (top1 == labels).sum().item() correct_5 += (top5 == labels.view(-1, 1)).sum().item() total += labels.size(0) print(f'epoch {epoch+1}/{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(axis=0) worst_classes = bad_score.argsort()[-10:][::-1]

输出前十个被错误预测最多的类别,再结合对应汉字反向映射,能快速发现是数据清洗问题还是网络结构问题。注意这里传入的all_labelsall_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].name

providers列表指定了执行优先顺序,没有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, dtype=np.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]

这里把图像像素从0~255缩放到-1~1,等同于训练时的ToTensor + Normalize((0.5,), (0.5,))idx2char需要提前从process_gnt.py导出的类别目录构建:按数字目录名排序后,每个位置对应的汉字就是chr(int(dir_name))。这样返回的列表是五个最可能的汉字,按置信度从高到低排列,适合接入候选框等前端交互。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询