ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PyTorch+CNN中文手写汉字识别:HWDB数据集全流程解析

PyTorch+CNN中文手写汉字识别:HWDB数据集全流程解析 简介一份基于PyTorch实现的中文手写汉字识别项目数据源自HWDB数据库适合计算机相关专业学生、毕业设计开发者及CNN入门者使用。项目同时实现VGG19与EfficientNetV2两种网络涵盖数据预处理、模型构建、训练、评估、演示等完整流程可快速跑通识别任务也便于替换或增加新模块进行二次开发。压缩包共45个文件总大小约349KB以Python脚本为主包含16个py源码、10个pyc编译文件以及xml配置、png示例图、Markdown说明文档、依赖清单等并将两种模型分目录存放结构直观易懂。已有70人学习使用。资源内含HWDB数据解析工具、字符集映射文件与详细说明可显著降低数据处理和环境搭建的门槛代码风格规范、注释清晰适合课程设计、毕业设计或CNN实践项目直接参考也可在此基础上扩展更多汉字识别优化方案。1. 从“识别一个汉字”到“造一条可用流水线”中文手写汉字识别从来不是“图像分类”的简单中文版。相比 MNIST 里 28×28 的英文数字HWDB 数据集里单个汉字的分辨率更高、笔画结构复杂、形近字多而且不同书写者的风格差异极大——同一个“永”字有人写到格子里占满有人只写中间一小块有人连笔有人一笔一划。你把图片缩到 32×32 丢进一个 LeNet 改的 CNN 里可能连 70% 准确率都上不去不是模型不行是输入预处理和标签空间没处理对。这个标题组合“PyTorchCNN中文手写汉字识别HWDB数据库”正好对应一条完整链路用 PyTorch 搭 CNN在 HWDB 数据集上做中文手写汉字识别。HWDBHarbin Institute of Technology Open Database是国内最常用的中文手写汉字数据库包含单字和手写文档两部分离线单字数据集适合直接做分类任务。这篇文章就按工业上最常见的做法把数据准备、模型搭建、训练调参和最后的提升技巧串起来适合已经跑通 MNIST、想转向真实中文场景的工程师也适合准备课程设计或算法岗面试时想讲清楚“我处理过中文手写”的从业者。2. HWDB 数据集的下载、解包与样本均衡2.1 先搞清楚 HWDB 到底是什么格式HWDB 官网提供的是.gnt文件不是现成的图片。每个.gnt文件里包含多张图像每张图像的存储结构是4 字节样本总长度、4 字节标签编码GBK 码、2 字节图像宽度、2 字节图像高度之后是宽度×高度的灰度像素数组。也就是说你得自己解析二进制而不是torchvision.datasets.ImageFolder直接读目录。解析逻辑如下import struct import numpy as np import os def read_gnt_to_samples(gnt_path): samples [] with open(gnt_path, rb) as f: while True: packed_length f.read(4) if not packed_length or len(packed_length) 4: break length struct.unpack(I, packed_length)[0] # 总长度包含自身4字节 tag_code struct.unpack(H, f.read(2))[0] width struct.unpack(H, f.read(2))[0] height struct.unpack(H, f.read(2))[0] bitmap np.frombuffer(f.read(width * height), dtypenp.uint8) bitmap bitmap.reshape((height, width)).astype(np.float32) samples.append((tag_code, bitmap)) return samples逻辑说明每次先读 4 字节小端无符号整数表示这一条样本的总字节数。注意这个长度包含自身 4 字节所以读完 222 字节头之后剩下的像素数据长度是length - 10而不是length - 4否则读到最后会错位。图像是灰度范围 0~255通常在送入网络前要归一化到 [0,1] 或标准化。实际使用中有两个容易踩的坑标签编码是 GBK 的 int 值不是字符。你需要chr(tag_code)或者用tag_code.to_bytes(2, big).decode(gbk)转成汉字。.gnt文件里样本顺序是随机的不同文件里的汉字类别分布不均衡。如果直接按顺序切训练集和测试集可能出现某个类别只存在于训练集、测试集里完全没有的情况导致评估时类别数对不上。2.2 构建训练集与测试集时的类别筛选HWDB1.0 大约有 3755 个一级汉字HWDB1.1 有大约 3319 个汉字两者有交集。常见的做法是只保留两个集合共有的类别避免标签空间不一致。以 HWDB1.0 作为训练集、HWDB1.1 作为测试集是很多论文的标准设置但也有人把两者合并后随机切分。区别在于前者测试集包含训练集里没有的书写者更能反映泛化能力后者数据量更大但可能高估准确率。我一般建议# 假设你已经把两个 gnt 目录下所有样本解析成 (image, label) 列表 # train_samples 来自 HWDB1.0test_samples 来自 HWDB1.1 common_labels set(train_labels) set(test_labels) def filter_samples(samples, common_labels): return [(img, label) for img, label in samples if label in common_labels] train_samples filter_samples(train_samples, common_labels) test_samples filter_samples(test_samples, common_labels)逻辑说明先求训练集和测试集标签集合的交集再过滤样本。这样模型输出的类别数就是固定的比如 3755 或两者交集的数目。如果你连 HWDB1.1 都下载不全也可以只用 HWDB1.0按 9:1 随机切分成训练和验证集但这样的话测试集里会出现和训练集相同书写者的笔迹准确率会偏高报告时要注明。2.3 针对中文手写特点的预处理中文手写图和 MNIST 最大的区别是字形位置漂移严重。同样一个“中”字有的写在左上角有的写在右下角如果不做位置归一化卷积核要同时学习“位置”和“字形”效率很低。推荐预处理步骤二值化用 Otsu 阈值把灰度图转成前景/背景。计算前景像素的质心。以质心为中心裁剪出包含全部前景的最小包围盒。将包围盒缩放到 64×64 或 96×96长边等比缩放短边补零到正方形。归一化到 [0,1]。为什么不用直接 resize因为 HWDB 原图宽高比不固定直接拉伸会破坏笔画比例比如“一”会被拉成竖线。等比缩放加 pad 能保留字形。PyTorch 里可以这样写import torch from torchvision import transforms from PIL import Image class CentroidCrop: def __call__(self, img): # img: PIL Image, 模式 L arr np.array(img) mask arr 128 if mask.sum() 0: return img ys, xs np.where(mask) cx, cy int(np.mean(xs)), int(np.mean(ys)) # 以质心为中心但要确保不超出边界 # 实际用包围盒更稳定 return img transform_train transforms.Compose([ CentroidCrop(), transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])逻辑说明CentroidCrop类里先找出较暗的像素手写笔迹计算质心返回以质心为中心的裁剪区域。这个操作的目的是消除书写位置偏移让网络不需要额外学习“平移不变性”之外的位移。Resize 到固定尺寸前建议先手动把包围盒等比缩放再 pad否则单独transforms.Resize会拉伸。还有一个细节不要对所有样本做随机裁剪增强因为手写汉字本身就小随机裁剪容易把笔画裁掉。你可以在训练时做小范围随机平移和旋转±5°但不能用随机 resize 裁剪那种 ImageNet 风格。3. CNN 模型结构从经典 LeNet 到适合汉字的变体3.1 为什么直接抄 ResNet 不一定好中文手写汉字是灰度图纹理和颜色信息几乎为零结构信息占主导。ImageNet 上预训练的 ResNet 输入是 224×224 三通道你如果硬改成单通道需要自己改第一层卷积的输入通道数而且预训练权重就废了。更现实的问题是HWDB 训练样本量相对类别数来说并不算多3755 类每类大约 200~400 个样本用 ResNet50 这种大模型极容易过拟合训练速度也慢。更常见的做法是在 VGG 或 ResNet 的浅层版本上修改。我自己的实验里一个类似 VGG-11 但去掉全连接层后接 Global Average PoolingGAP的结构在 HWDB1.0 上能跑到 95% 以上而参数量只有 ResNet18 的一半。下面给出一个能在单卡 GPU 上快速跑通的结构import torch.nn as nn class HWDB_CNN(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), # 64 - 32 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 32 - 16 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 16 - 8 nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 8 - 4 ) self.classifier nn.Sequential( nn.Dropout(0.3), nn.Conv2d(256, 512, kernel_size1), # 代替全连接 nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Linear(512, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x逻辑说明输入是 1×64×64 的单通道灰度图。四个卷积 block 后空间尺寸降到 4×4通道数升到 256。最后用1×1卷积做通道变换再接 GAP 和全连接层。相比传统 VGG 在最后一个卷积层后直接拉平接Linear(256*4*4, 4096)这里用 GAP 大幅减少参数量4096 的全连接在 3755 分类上很容易过拟合。参数选择说明卷积核用 3×3两个 3×3 堆叠感受野相当于 5×5但参数量更少。每层卷积后都接 BatchNorm因为手写图像不同样本的灰度分布差异不大但 BatchNorm 能加速收敛尤其当 batch size 较小时效果显著。Dropout 放在 classifier 里而不是 features 里因为卷积层本身有 BatchNorm 和池化做正则化。最后一个池化不是MaxPool2d(2)而是AdaptiveAvgPool2d这样模型可以接受不同尺寸的输入。如果你固定 64×64用普通池化也行。3.2 损失函数与标签空间的陷阱3755 类分类任务用nn.CrossEntropyLoss()是最直接的但要注意两个问题类别不均衡。HWDB 里“的”“一”“是”这类常用字样本多生僻字样本少。如果你不做任何处理模型会偏向常见字。最简单的做法是在损失函数里传入weight参数权重与类别样本数成反比。标签必须是torch.long且取值范围在[0, num_classes-1]。如果从汉字字符串映射到索引时出现缺失会报错。设置权重方法import torch # class_counts: dict 或 tensor长度等于类别数 class_counts torch.tensor([...], dtypetorch.float) class_weights 1.0 / class_counts class_weights class_weights / class_weights.sum() * num_classes # 归一化 criterion nn.CrossEntropyLoss(weightclass_weights)逻辑说明class_weights每个元素对应一个类别的样本数倒数。训练时样本少的类别梯度更大模型不会一味追赶高频字。但注意这也可能让样本少的类别过拟合因为权重放大了它们的损失。如果训练集本身足够均衡比如你重新采样可以不加权重。一个常见的错误是直接用sklearn.preprocessing.LabelEncoder把汉字转成 0~n-1 的数字然后忘了保存映射关系。测试时新样本的汉字没法反向映射。建议训练结束后把class_to_idx和idx_to_class存成 json。3.3 优化器和学习率调度别上来就用 Adam这里给一个反直觉的经验在 3755 分类这种大规模分类任务上SGD with momentum 比 Adam 最终准确率高 1~2 个百分点。Adam 收敛快但后期容易在局部最优附近震荡尤其在类别数多、样本噪音大的情况下。推荐配置optimizer torch.optim.SGD( model.parameters(), lr0.1, momentum0.9, weight_decay1e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-5 )逻辑说明lr 初始 0.1 需要配合batch_size256和 BN 层。如果你的 batch size 小lr 要相应调低。CosineAnnealing 在训练后期把学习率平滑降到eta_min比 StepLR 更好用因为手写识别在 30 个 epoch 后容易进入平台期cosine 能自动帮你做微调。weight_decay是 L2 正则防止全连接层权重过大。如果你的显卡显存不够batch size 用 64那 lr 改成 0.03。经验公式是lr_bs lr_ref * sqrt(bs_ref / bs)比如参考设 256 配 0.1则 64 配0.1 * sqrt(256/64) 0.2但实际我会保守一点用 0.05 起步。4. 训练流程与常见报错排查4.1 一个能直接跑的 Main 训练脚本下面给出一个精简但完整的训练循环包含数据加载、前向、反向、验证和模型保存import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from tqdm import tqdm class HWDBDataset(Dataset): def __init__(self, samples, transformNone): self.samples samples # list of (img_array, label_idx) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): img, label self.samples[idx] # img: HxW numpy array, dtypefloat32, [0,1] if self.transform: img self.transform(img) return img, label def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0 correct 0 total 0 pbar tqdm(loader, desctrain) for images, labels in pbar: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) pbar.set_postfix(lossloss.item()) return total_loss / total, correct / total def validate(model, loader, criterion, device): model.eval() total_loss 0 correct 0 total 0 with torch.no_grad(): for images, labels in tqdm(loader, descval): images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return total_loss / total, correct / total逻辑说明每个 epoch 里遍历 DataLoader把图片和标签搬到 GPU前向计算损失反向传播更新权重。验证时用torch.no_grad()关闭梯度计算省内存也避免意外改动权重。tqdm可以实时看 loss不是必须但建议保留方便观察是否发散。对应的主流程device torch.device(cuda if torch.cuda.is_available() else cpu) model HWDB_CNN(num_classeslen(common_labels)).to(device) criterion nn.CrossEntropyLoss(weightclass_weights.to(device)) optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30, eta_min1e-5) best_acc 0 for epoch in range(30): train_loss, train_acc train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc validate(model, val_loader, criterion, device) scheduler.step() print(fEpoch {epoch}: train_acc{train_acc:.4f}, val_acc{val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)这里把 train 和 val 分开写因为训练过程中 data loader 的 shuffle 状态不同。如果你在同一个函数里先 train 再 eval要记得model.train()和model.eval()切换否则 BatchNorm 的 running stats 会被污染。4.2 训练时最常见的 4 个报错4.2.1Expected input batch_size to match target batch_size原因通常是 DataLoader 最后一批样本数量不等于标签数量或者你的__getitem__返回了多个值但 DataLoader 拼装方式不对。检查你的 dataset 返回的images和labels尺寸是否一致。还有可能是模型 forward 里多做了个squeeze(1)把 batch 维挤掉了。4.2.2RuntimeError: Sizes of tensors must match except in dimension 1通常发生在卷积层之后全连接层的维度对不上。你输入是 64×64经过 4 次池化后应该是 4×4但如果你在某个卷积层用了 stride2 和 padding1尺寸计算会和预期不同。调试方法是在模型里加一行print(x.shape)逐层打印。4.2.3CUDA out of memoryHWDB 图像如果是 96×96 且使用 batch_size128显存占用可能超过 8GB。解决办法调小 batch size同时调低学习率或者把图像 resize 到 64×64或者开启torch.utils.data.DataLoader的pin_memoryTrue加快数据搬运但不减显存。4.2.4 验证集准确率始终在 1/类别数附近这说明模型没有收敛。先看训练 loss 有没有下降。如果 loss 在 5 左右抖动可能是学习率太大如果 loss 缓慢下降但准确率不动可能是标签映射错误——比如两个不同的汉字映射到了同一个索引。用一个小测试集比如 100 张先跑一个 batch打印预测结果和真实标签确认不是数据问题。4.3 如何选择验证集与评估指标中文手写识别除了整体准确率还需要看每个类别的准确率。如果你只报告 accuracy遇到生僻字全错的情况就会被掩盖。建议计算以下指标Top-1 accuracy常规准确率。Top-5 accuracy因为很多形近字比如“未”和“末”人类都容易混淆Top-5 更能反映模型排序能力。样本量少于 50 的类别准确率单独统计看低资源类别是否完全不可用。验证集选择如果直接按书写者划分测试集上的准确率通常比随机划分低 3~5 个百分点。所以在论文里报告时一定要说明是按书写者划分还是随机划分。实际应用场景中新用户的手写风格是训练集中没见过的所以按书写者划分更接近真实部署。5. 提升识别率的三个关键技巧5.1 数据增强平移、旋转加弹性形变手写汉字比打印体更具随机性适当的数据增强能显著提升泛化能力。我的常用组合是train_transform transforms.Compose([ transforms.ToPILImage(), transforms.RandomAffine( degrees5, translate(0.03, 0.03), scale(0.95, 1.05), shear2 ), transforms.RandomPerspective(distortion_scale0.05, p0.5), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])注意RandomAffine的fill参数默认填 0但归一化后 0 对应的是(0-0.5)/0.5 -1会产生黑边。建议图像已经预处理成背景为 0、前景为 1 的格式这样旋转时 fill0 是背景色。如果你用的是灰度原图背景是 255需要先归一化再增强或者设置fill(255,)。另一个技巧是字符级随机擦除。借鉴 Cutout 的思想随机在图像中挖掉一个 8×8 的小方块强制模型不依赖单个笔画class RandomErase: def __call__(self, img): img img.clone() h, w img.shape[-2], img.shape[-1] eh, ew 8, 8 x torch.randint(0, w - ew, (1,)).item() y torch.randint(0, h - eh, (1,)).item() img[:, y:yeh, x:xew] 0 return img5.2 类别均衡采样器替代损失函数权重前面提到用class_weights处理类别不均衡但那只是调整 loss 的权重梯度更新仍然由 batch 内的高频类别主导。更激进的做法是直接对 DataLoader 使用WeightedRandomSampler让每个 batch 里低频类别更多出现from torch.utils.data import WeightedRandomSampler sample_weights [1.0 / class_counts[label] for label in all_train_labels] sampler WeightedRandomSampler( sample_weights, num_sampleslen(all_train_labels), replacementTrue ) train_loader DataLoader( dataset, batch_size128, samplersampler, num_workers4 )逻辑说明每个样本被采样的概率与它的类别样本数成反比。replacementTrue允许同一个样本在一个 epoch 被抽多次这样低频类别每个 epoch 能被看到多次。配合class_weights一起使用时注意不要过度加权否则模型会对低频类别过拟合。5.3 用中心损失或 ArcFace 约束特征分布最后一个技巧也是面试时能加分的点在分类层之前加一个特征嵌入层使用CenterLoss或ArcFace配合交叉熵联合训练。中心损失让同类样本在特征空间里向类中心聚拢能有效提升形近字的区分度。PyTorch 里没有内置 CenterLoss需要自己实现class CenterLoss(nn.Module): def __init__(self, num_classes, feat_dim, device): super().__init__() self.centers nn.Parameter( torch.randn(num_classes, feat_dim).to(device) ) self.lamda 0.01 def forward(self, features, labels): # features: [N, feat_dim] batch_size features.size(0) centers_batch self.centers[labels] diff features - centers_batch loss (diff.pow(2).sum(1) / 2.0).mean() return loss * self.lamda使用时把模型最后一层替换成带feat_dim的全连接比如输出 256 维特征然后分别接分类器和中心损失。训练时总损失 交叉熵损失 λ * CenterLossλ 一般取 0.01。这样做的代价是需要多存一个类别中心矩阵3755×256 的 float 大约是 3.8 MB显存开销可忽略。验证技巧在测试时你可以把每个类别的特征中心预先算出来然后对新样本只计算与所有中心的距离取最近中心作为预测。这种基于距离的分类器对异常样本更鲁棒而且你不需要加载分类器权重模型体积更小。最后注意一点不管用哪种技巧都要在固定验证集上反复对比不要只盯训练集准确率。手写识别的真实瓶颈往往不在模型结构而在数据清洗和增强策略。当你在 HWDB 上能达到 95% 以上的验证准确率时再考虑迁移到自己的业务场景——把新数据按同样的质心裁剪和归一化流程过一遍得到的结果才可信。本文还有配套的精品资源点击获取
返回列表