news 2026/9/11 22:37:35

中文手写识别实战:基于PyTorch和CNN的HWDB数据集训练全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文手写识别实战:基于PyTorch和CNN的HWDB数据集训练全流程

简介:基于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', encoding='utf-8') as f: json.dump({'char_to_idx': char_to_idx, 'idx_to_char': idx_to_char}, f, ensure_ascii=False)

这段代码背后的思想是:先把类别空间固定下来,训练和推理都用同一个映射,否则验证集和模型输出对不上号。sort()保证同一字符在多次运行时获得相同的索引,也便于在多个脚本之间复用。

2.3 用PyTorch Dataset与DataLoader吃下数据集

有了图像目录,数据读取就交给torch.utils.data.DatasetDataLoader。在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, transform=None): 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(degrees=5, 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', transform=train_transform), batch_size=128, shuffle=True, num_workers=4)

逻辑说明:Dataset只负责按索引读图并做预处理,随机仿射变换可以模拟手写笔迹的不规则旋转与平移,对HWDB这种风格多样的数据有明显收益。num_workers设为4以上可以利用多核CPU加载图像,避免GPU空闲。

这里有一个坑:HWDB原始图片是白色背景黑色笔画,而ToTensor()会把像素除以255,Normalize里的均值和方差需要按灰度图实际分布设定。直接把ImageNet的RGB均值和方差搬过来虽然不会报错,但收敛速度会变慢。更合理的做法是先手动统计训练集灰度图的均值和标准差,再填入Normalize

3. 模型设计:适用于汉字识别的CNN结构

3.1 为什么通用CNN在汉字上不够用

我们常说的CNN卷积神经网络结构图,通常包含卷积、激活、池化和全连接层。但汉字识别有它的特殊性:字符拓扑信息非常密集,比如“己、已、巳”三个字只差笔画的闭合位置;而“未、末”则差在横划长度比例。通用图像分类模型往往在低层只捕捉纹理和边缘,对这类细节不够敏感。因此设计模型时,需要在第一层就保证足够的输入分辨率和卷积核感受野搭配。

常见做法是把输入放大到96x96或128x128。如果显存不足,可以在第一个卷积层用stride=1而不是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_classes=3755): super(HWDB_CNN, self).__init__() self.features = nn.Sequential( # 第1层:输入1通道,输出32通道 nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 输出 32x32 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 16x16 nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 8x8 nn.Conv2d(128, 256, kernel_size=3, padding=1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d(2), # 4x4 ) self.classifier = nn.Sequential( nn.Dropout(p=0.3), nn.Linear(256 * 4 * 4, 1024), nn.ReLU(inplace=True), nn.Dropout(p=0.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_size=3配合padding=1保证特征图分辨率不缩水,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_smoothing=0.1)即可实现。相比直接硬编码one-hot标签,平滑后的标签相当于给模型一点“容错空间”,对手写字迹的随机变形更友好。

类别映射在训练脚本里也需要和转换阶段保持一致,最好在训练和验证时都从同一个JSON文件加载。

criterion = nn.CrossEntropyLoss(label_smoothing=0.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(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-5)

逻辑说明:动量0.9可以加速沿梯度方向一致的参数更新,weight_decay是权重衰减,等效于一个轻量L2正则化。CosineAnnealingLR让学习率在30个epoch内从0.01余弦衰减到1e-5,比固定学习率更容易跳出局部最优。如果机器没有GPU,可以把lr降到0.001,否则训练会非常不稳定。

这里的T_max=30要和总epoch数匹配。如果只想训练20轮,就把T_max设为20。太小的T_max会在后期频繁重启学习率,导致损失震荡。下表给出常用参数的推荐范围和调整原则:

参数推荐范围调整方向
lr0.001 - 0.05loss发散时调小,收敛太慢时调大
batch_size64 - 256显存够就放大,类别多时建议至少64
momentum0.85 - 0.95默认0.9,震荡时微调到0.85
weight_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, dim=1) max_probs, _ = probs.max(dim=1) 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,每调整一次都重新统计各形近字对的混淆比例,再决定下一步调参方向。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 22:37:25

Sentinel-1卫星SAR数据在GEE中的应用与解析

1. Sentinel-1卫星系统基础认知Sentinel-1是欧空局(ESA)哥白尼计划中的雷达卫星星座&#xff0c;由Sentinel-1A&#xff08;2014年发射&#xff09;和Sentinel-1B&#xff08;2016年发射&#xff0c;已退役&#xff09;组成。作为全天候、全天时的对地观测系统&#xff0c;它搭…

作者头像 李华
网站建设 2026/9/11 22:34:50

React.memo性能优化:正确使用场景与常见陷阱

1. 为什么我们需要重新审视React.memo的使用作为一名React开发者&#xff0c;你可能已经习惯了在组件上随手加上memo&#xff0c;认为这是提升性能的"银弹"。但实际情况是&#xff0c;过度使用memo反而可能导致性能下降。让我们先理解memo的核心机制&#xff1a;它通…

作者头像 李华