ARTICLE DETAIL

资讯详情

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

手写汉字识别实战:从数据集解压到CNN调参避坑全指南

手写汉字识别实战:从数据集解压到CNN调参避坑全指南 简介面向手写汉字识别学习者和深度学习初学者的Python示例聚焦如何借助深度卷积网络DCN将手写汉字图像自动分类可应用于自动文档处理、电子签名验证与教育辅助等场景也适合作为课程设计或毕业设计的起始参考。压缩包内含1个Python源文件整体仅1KB结构非常精简便于快速通读核心代码而不被工程文件干扰。脚本思路明确借鉴MNIST手写数字识别的数据预处理方法预计涵盖图像归一化与尺寸调整、卷积与池化层设计、损失函数选择、优化器配置及训练验证流程深度卷积网络通过逐层提取边缘、形状与纹理特征逐步构建适合汉字复杂结构的抽象表示因此针对汉字类别多、书写风格差异大的特点脚本中也给出了可参考的处理方式。已有177人学习如果你是正在摸索卷积神经网络在中文场景应用的开发者这份小体积代码能帮你快速建立DCN汉字识别的整体流程认知缩短从数据准备到模型调优的起步链路也可作为进一步扩展和优化识别精度的基础模板。1. chinese_test.zip 到底装了什么手写汉字识别这件事的门道拿到一个叫 chinese_test.zip 的压缩包第一反应别是解压跑个 demo。它大概率是一份手写汉字样本集对应 MNIST 在数字识别里的生态位类目是国标一级汉字样本是扫出来的手写体。手写汉字识别HCCR和手写数字识别的难度不在一个量级——数字只有 10 类汉字光 GB2312 一级就有 3755 类形近字、连笔、潦草体叠在一起准确率每提一个点都要从数据处理和模型结构上抠。下面按数据、模型、调参、避坑的顺序展开最后讲怎么把它落地成可用性的验证流程。2. 先别急着训练解压、统计与可视化这套数据集的第一步chinese_test.zip 这类包最常见的组织方式有两种一是按汉字分目录目录名就是标签二是一个扁平目录加一个 labels.txt 映射文件。前者对新手友好后者对训练脚本友好。解压之前别直接用系统自带解压工具——老包里的条目经常是 GBK 编码Windows 直接解压会把中文目录名变成乱码。我一般用 Python 的 zipfile 做解压和审计一步完成。2.1 确认压缩包内部结构目录名可能就藏着标签用 zipfile 打开包之后先看顶层条目分布再决定解压到哪里。下面这个脚本会列出顶层目录或文件的数量分布然后把整个包解压到本地目录。import zipfile from collections import Counter zip_path chinese_test.zip with zipfile.ZipFile(zip_path) as zf: names zf.namelist() # 统计顶层目录的条目数目录名大概率就是类别名 top_dirs Counter(name.split(/)[0] for name in names if not name.endswith(/)) print(总条目数:, len(names)) for name, cnt in top_dirs.most_common(10): print(f {name}: {cnt}) # 解压到独立目录避免污染当前工程 zf.extractall(chinese_test_extracted)逻辑说明这段脚本先统计顶层目录分布再执行解压。Counter 的 key 对应目录名如果包按汉字分类这些 key 就是标签。先统计后解压是为了在解压前发现编码异常老数据集的 zip 条目常常是 GBK 编码但没有置位 UTF-8 标志直接 extractall 会把“手”解成“脭”后续做标签映射时全是乱码。参数说明zipfile.ZipFile 默认按 UTF-8 解析文件名遇到 GBK 编码的旧包会解出乱码。处理这种包的正确姿势是遍历 infolist()把 zi.filename 先按 cp437 重新编码成字节再用 gbk 解码还原中文名。这个还原逻辑在第 5 章避坑记录里还会再遇到。2.2 统计类别数和样本量判断这是不是一锤子买卖光看目录结构不够还要确认类别总数、每类样本量和图像尺寸。手写汉字数据集常用两个规模GB2312 一级字表是 3755 个类别通用规范汉字表是 8105 个类别。如果统计出来的类别数和预期对不上说明包可能做了子集筛选训练时要以实际类别数为准不要把输出层写死在 3755。import os from collections import Counter from PIL import Image root chinese_test_extracted class_count Counter() size_counter Counter() for clazz in sorted(os.listdir(root)): clazz_dir os.path.join(root, clazz) if not os.path.isdir(clazz_dir): continue # 过滤常见图像扩展名兼容大写后缀 files [f for f in os.listdir(clazz_dir) if f.lower().endswith((.png, .jpg, .bmp))] class_count[clazz] len(files) if files: with Image.open(os.path.join(clazz_dir, files[0])) as img: size_counter[img.size] 1 print(类别总数:, len(class_count)) print(样本量最小, min(class_count.values()), 最大, max(class_count.values()), 平均, sum(class_count.values()) / len(class_count)) print(图像尺寸分布:, size_counter.most_common(5))逻辑说明遍历每个目录记录类别样本数和第一张图的尺寸。min 如果为 0说明有目录是空的这类目录得在 DataLoader 里过滤掉尺寸分布出现多个值则说明包内混了扫描件和截图预处理时不能只做一个固定 resize。参数说明扩展名过滤用 lower() 处理避免 .JPG 和 .jpg 被当成两种文件。PIL.Image.open 要配合 with 使用防止文件句柄泄漏。只取每个类别第一张图做尺寸统计速度很快没必要全量读图。2.3 把样本可视化出来先用人眼过一遍统计数字看不出字形质量。批量打印随机样本这一步看起来琐碎但特别关键。手写数据集的质量方差很大有的包里混着打印体有的混着繁体有的标注本身就是错的。人眼抽查能提前发现问题省去后面模型翻车再排查的时间。import matplotlib.pyplot as plt import random from PIL import Image # 随机抽16个类别每个类别取第一张图拼成网格 sample_classes random.sample(sorted(class_count), min(16, len(class_count))) fig, axes plt.subplots(4, 4, figsize(12, 12)) for ax, clazz in zip(axes.ravel(), sample_classes): files [f for f in os.listdir(os.path.join(root, clazz)) if f.lower().endswith((.png, .jpg, .bmp))] img Image.open(os.path.join(root, clazz, files[0])).convert(L) ax.imshow(img, cmapgray) ax.set_title(clazz, fontsize12) ax.axis(off) plt.tight_layout() plt.savefig(chinese_test_preview.png, dpi120)逻辑说明随机类别加随机样本的网格图重点看三件事笔画有没有断裂文字是不是被压成了低分辨率糊图字在画布上是否居中。这三条直接决定后面要不要做中心裁剪和尺寸归一化。参数说明convert(L) 把图像统一成单通道灰度和训练输入一致。cmapgray 只是让显示接近黑白扫描件。如果样本里混了红色批注或印章灰度化之前要先想清楚要不要保留颜色信息否则红色墨迹会被当作背景噪声丢掉。预览时还要留意一类隐蔽问题包名是 chinese_test样本里却混杂了繁体字、异体字甚至日文汉字。人眼看混合字不会觉得异常模型却会把“与”和“與”当成完全不同的类别。做简体识别就把这些样本清掉想保留就得维护一份完整的简繁对照关系这笔账在训练前算清楚。3. 用 CNN 跑通基线从 LeNet 到适配汉字的网络结构怎么选数据确认没问题之后下一步是选网络结构。这个选择不能照搬 MNIST 手写数字识别的经验但也别一上来就用大模型。关键是把类别数、输入分辨率、显存三者权衡好先跑通一个基线再迭代。3.1 为什么 MNIST 那套 LeNet 在汉字上不够用MNIST 手写数字识别的经典套路是 28x28 灰度输入、两层卷积加两层全连接。数字只有 10 类LeNet 最后一层全连接只有 84x10参数开销极小。把同样的结构搬到汉字识别上输出层改成 3755 类参数直接涨了两个数量级而且浅层网络对汉字这种复杂结构完全不够。汉字的形近字差别在局部笔画细节比如“己、已、巳”只在右上角开口有区别LeNet 的感受野经过两次池化之后这些细节基本被抹掉了。常见做法是换 ResNet 或 VGG 风格的骨干网络输入分辨率提到 64x64 以上。这里有个细节灰度图只有一个通道而 torchvision 里的预训练模型输入是 RGB 三通道。办法有两个一是把灰度复制成三份走预训练权重二是改第一层卷积的入通道数从零开始训。样本量小于 3 万张时预训练权重的收益更明显样本量大了之后从零训练加数据增强也能拉到接近的水平。3.2 推荐结构一个穷人也跑得起的 ResNet 变体自己从零写 ResNet 不现实用 torchvision 现成的 resnet18改第一层卷积和最后一层全连接单张 1080 级别显卡就能训得动。import torch.nn as nn from torchvision.models import resnet18 def build_net(num_classes): net resnet18(pretrainedFalse) # 输入是灰度图把第一层卷积从3通道改成1通道 net.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) # 输出类别数以实际统计为准不要写死3755 net.fc nn.Linear(net.fc.in_features, num_classes) return net逻辑说明conv1 改成单通道输入才能喂灰度图。fc 输出改成 num_classes而不是固定 3755。pretrainedFalse 是因为第一层改动后ImageNet 预训练权重无法加载硬加载会报形状不匹配。参数说明resnet18 第一层默认 stride264x64 输入会先降到 32x32。如果想把第一层输出保持在 64x64可以把 stride 改成 1 并配合 padding3但显存占用会明显上涨。先跑通再优化别在结构上一步到位。3.3 训练脚本的主干逻辑dataloader、优化器与 loss网络结构定了接下来是数据加载和训练主循环。手写汉字数据集的标签通常就是目录名Dataset 里要把目录名映射成整数索引并在getitem里完成统一缩放。from torch.utils.data import Dataset from PIL import Image import os class ChineseHandwritingDataset(Dataset): def __init__(self, root, transformNone): self.samples [] self.transform transform self.class_to_idx {} for idx, clazz in enumerate(sorted(os.listdir(root))): clazz_dir os.path.join(root, clazz) if not os.path.isdir(clazz_dir): continue self.class_to_idx[clazz] idx for f in os.listdir(clazz_dir): if f.lower().endswith((.png, .jpg, .bmp)): self.samples.append((os.path.join(clazz_dir, f), idx)) def __len__(self): return len(self.samples) def __getitem__(self, i): path, label self.samples[i] img Image.open(path).convert(L).resize((64, 64)) if self.transform: img self.transform(img) return img, label逻辑说明init阶段把路径和标签展开成列表避免每个 epoch 都重新遍历目录。getitem里做 resize目标尺寸先用 64 跑通流程。标签映射用 sorted 保证稳定后续做类别均衡时也方便。参数说明resize 默认用 Pillow 的 BILINEAR对手写字够用。如果发现笔画边缘糊可以换 LANCZOS但训练会慢一点。这里先不接 transform 之外的数据增强后面调参时再补。训练主循环用 SGD 加余弦退火开头不要用 Adam前者在大类别任务里收敛更稳后者后期容易在峰值附近震荡。import torch import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) net build_net(num_classeslen(train_ds.class_to_idx)).to(device) optimizer optim.SGD(net.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler CosineAnnealingLR(optimizer, T_max50) criterion nn.CrossEntropyLoss() for epoch in range(50): net.train() running_loss 0.0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() out net(imgs) loss criterion(out, labels) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() print(fepoch {epoch1} loss{running_loss/len(train_loader):.4f})逻辑说明SGD 加 momentum 在 3755 类分类任务上是稳妥的组合。CosineAnnealingLR 让学习率在 50 个 epoch 内平滑降到接近 0。注意第一个 epoch 的 loss 会在 8 附近这是正常现象别急着调参。参数说明weight_decay 5e-4 是常见起点数据集欠拟合时可以降到 1e-5。batch size 没有写在这里因为它由 DataLoader 决定8G 显存配 64x64 输入用 128 比较稳。4. 汉字识别调参的 4 个关键点尺寸、增强、学习率与类别不均衡基线跑通之后真正拉开差距的是调参。手写汉字识别不是光堆数据就能涨点下面四个参数的优先级最高。4.1 输入尺寸64 不一定够96 到 128 才是安全区chinese_test.zip 这类数据集的原图如果来自扫描仪分辨率通常在 128 到 256 之间。如果按 MNIST 的习惯缩成 28 或 64形近字的笔画细节损失很大。我在这类任务上的血泪经验是64x64 只是起步96x96 是性价比最高的区间显存允许就上 128。这里有个经常被忽略的问题直接 resize 会把整张画布等比拉伸文字四周的空白也被拉变形。更好的做法是先做中心裁剪把文字外接矩形裁出来再等比缩放到目标画布上。def center_crop_to_canvas(pil_img, target96, padding4): # 裁掉白边保留padding像素防止切掉笔画 box pil_img.getbbox() if box is None: return pil_img.resize((target, target)) box (max(0, box[0] - padding), max(0, box[1] - padding), min(pil_img.width, box[2] padding), min(pil_img.height, box[3] padding)) cropped pil_img.crop(box) # 等比缩放到target尺寸剩余区域用白色填充 ratio min(target / cropped.width, target / cropped.height) new_w max(1, int(cropped.width * ratio)) new_h max(1, int(cropped.height * ratio)) resized cropped.resize((new_w, new_h)) canvas Image.new(L, (target, target), 255) offset ((target - new_w) // 2, (target - new_h) // 2) canvas.paste(resized, offset) return canvas逻辑说明getbbox 返回非零区域的外接矩形先把文字从白底画布里抠出来。等比缩放后放到 target 画布中央背景填 255。这样无论原图文字大小和位置如何进入网络的字形都居中且比例统一。参数说明padding4 防止 getbbox 贴边把半笔切掉。白底黑字填 255黑底白字要把 canvas 改成 0。target 从 96 开始试验证集涨点不明显再上 128不要一上来就 224训练时间成倍增长。4.2 数据增强旋转角度别超过 6 度顺序也有讲究手写识别里常用的增强包括随机旋转、轻微缩放、弹性形变。但旋转角度必须克制。汉字不像数字能容忍 30 度旋转超过 8 度可能改变笔画相对位置把“古”转成“右”的错觉。常用区间是正负 5 到 6 度。from torchvision import transforms # 训练集增强先做仿射变换再做颜色扰动最后张量化和归一化 train_transforms transforms.Compose([ transforms.RandomAffine(degrees6, translate(0.05, 0.05), scale(0.9, 1.1)), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) val_transforms transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ])逻辑说明RandomAffine 做小幅旋转、平移和缩放模拟实际书写位置的偏差。ColorJitter 模拟墨迹浓淡让模型对笔压变化不过度敏感。验证集不做几何增强只做归一化避免验证分数虚高。参数说明translate 和 scale 是相对值5% 平移和 10% 缩放对手写字是比较稳的范围。Normalize 用 (0.5, 0.5) 把像素搬到 [-1, 1]更严格的做法是从训练集统计真实 mean/std但差异通常很小。注意 RandomAffine 必须在 ToTensor 之前PIL 张量在插值行为上和 tensor 不一致。4.3 学习率与预热别把第一个 epoch 的 loss 当回事3755 类输出的随机初始化网络第一轮 cross entropy loss 会停在大约 ln(3755) ≈ 8.23 的位置。看到 loss 不降先别急着改学习率先确认它是不是处于这个数量级。真正要处理的是前期梯度把权重冲歪这需要加一个预热阶段。from torch.optim.lr_scheduler import LinearLR, SequentialLR, CosineAnnealingLR # 前5个epoch从0.0001线性升到0.01之后走50个epoch的余弦退火 warmup LinearLR(optimizer, start_factor0.01, total_iters5) cosine CosineAnnealingLR(optimizer, T_max50) scheduler SequentialLR(optimizer, schedulers[warmup, cosine], milestones[5])逻辑说明预热把学习率从 0.0001 线性爬到基础学习率降低早期梯度震荡。milestones[5] 表示第 5 个 epoch 结束后切换到 cosine。这个组合在超大类别任务上比固定学习率收敛更稳。参数说明start_factor0.01 表示初始学习率是 optimizer 里 lr 的 1%所以 optimizer 里的 lr 还是写 0.01。如果数据集很小预热缩到 2 个 epoch 就行。T_max 要和预热后的主训练轮数匹配不要多算预热占掉的轮数。4.4 类别不均衡从采样器和损失函数两头下手手写汉字数据集里类别分布通常相对均匀但总有例外。生僻字只有几十张常用字上千张。平均准确率看着不错抽检生僻字时会发现全被错分到高频类。最简单有效的处理是往 DataLoader 里插一个 WeightedRandomSampler。from collections import Counter from torch.utils.data import WeightedRandomSampler, DataLoader # 按样本标签统计频率给低频样本更大权重 label_count Counter(label for _, label in train_ds.samples) sample_weights [1.0 / label_count[label] for _, label in train_ds.samples] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_ds, batch_size128, samplersampler, num_workers4)逻辑说明每个样本被抽到的概率按类别样本量的倒数加权低频字在每轮训练里都有机会被抽到。replacementTrue 表示有放回采样保证低频类不会在某轮完全缺位。参数说明sample_weights 的长度必须等于数据集长度顺序要和 Dataset 的样本列表对齐。num_samples 一般保持和原数据集长度一致类别差异大时可以放大到 1.5 倍但训练时间会相应拉长。如果重采样之后指标上来了再考虑把损失函数换成类别加权版本别两头同时动。5. 避坑记录手写汉字数据集上最常见的 5 个翻车现场手写汉字识别这个任务数据层面的坑比模型层面的坑多。下面 5 条都是实际踩过的每条按现象、原因、解决写清楚。5.1 解压后中文目录变成乱码类别数暴涨到 8000 多现象解压之后训练脚本统计出 8000 多个类别验证集准确率只有 1%。打开目录一看中文名全是“脭脼”一类乱码。原因zip 条目名是 GBK 编码系统按 UTF-8 误解码每个乱码字符串都被当成了独立类别。模型学的不是汉字结构而是乱码字符串的分布。解决解压时手动还原编码在 zipfile 里对 infolist() 做一次转换。with zipfile.ZipFile(chinese_test.zip) as zf: for zi in zf.infolist(): raw zi.filename.encode(cp437, ignore) try: name raw.decode(gbk) except UnicodeDecodeError: name zi.filename print(name)逻辑说明cp437 是 zipfile 对非 UTF-8 字节名的默认回退编码把它重新解码成 gbk 就能还原中文。解码失败的条目说明本来就不是 GBK 中文直接跳过不要混进训练集。5.2 随机切分训练验证集导致指标虚高现象训练集准确率 98%验证集 72%且无论怎么加数据增强都不变。原因手写数据通常按书写者排列随机切分让同一个人的笔迹同时出现在训练集和验证集。模型见过相似的书写习惯验证分数虚高部署后面对陌生人笔迹立刻打回原形。解决按书写者或文件名前缀分组切分保证验证集的笔迹风格在训练集里从未出现过。切分比例还是 8:2但必须按块切不能按单张随机切。5.3 模型把所有输入都预测成高频类现象loss 降得很快但验证集 top-1 和随机猜差不多看混淆矩阵发现全预测成“的”这一个字。原因常用字样本上千生僻字只有几十张。Softmax 分类面被高频类主导低频类的梯度贡献微弱。解决先上 WeightedRandomSampler 重采样再统计各类样本量。样本量低于 50 的类别要么从评估指标里剔除要么用增强从已有样本扩充别指望模型从 20 张图里学出泛化。5.4 训练到一半 loss 变 NaN现象前几个 epoch 正常第 5 个 epoch loss 突然变成 nan。原因输入图像里存在全零或全白张量BatchNorm 在零方差上算出除零或者异常样本撞上峰值学习率梯度直接溢出。解决DataLoader 里检查 batch 是否存在全零样本空白图片直接丢弃或加噪声填充。同时把峰值学习率降到 0.005。如果急着跑通把 BatchNorm 换成 GroupNorm 可以绕开一部分数值问题但收敛速度要重调。5.5 top-1 长期卡在 85% 附近不动现象增强换了、优化器换了、dropout 也试了指标一直在 84% 到 86% 之间波动。原因这个位置通常不是模型容量不够而是输入分辨率太低。64x64 让细微笔画差异在池化后消失模型分不清形近字。解决复制一份训练脚本只把输入尺寸从 64 改成 96其余全部不动同 epoch 数下对比 top-1。如果涨了 0.5% 以上继续试 128没涨再换 resnet34。先改数据侧再改模型侧顺序别反。6. 把准确率变成可用性验证集设计、可视化与增量学习的小技巧离线准确率只说明模型在测试集上表现好部署后用户手写风格一变指标可能瞬间掉十个点。项目后期我习惯把精力从“刷准确率”转到“验证模型到底学了什么”上。6.1 用热力图确认模型在看笔画而不是背景用一个 Grad-CAM 实现取 ResNet 最后一个卷积层的输出把梯度加权回传到输入尺寸叠在原图上观察。以下只是示意# 取最后一个stage输出的特征图计算类激活热力图 feature_map net.get_feature_map(x) grad torch.autograd.grad(loss, feature_map)[0] weights grad.mean(dim(2, 3), keepdimTrue) cam (weights * feature_map).sum(dim1, keepdimTrue) cam F.interpolate(cam, size(96, 96), modebilinear)示意说明热力图叠加到原图后看高亮区域集中在汉字笔画还是画布白边。如果关注点散落在白边和角落说明模型学到了某种捷径比如不同类别的扫描噪声差异而不是字形。用热力图做一次模型体检比只盯准确率可靠。6.2 做一个难例集当回归测试用把验证集里预测错的样本捞出来按类别和书写风格分组固定成一个难例集。每次改动模型或数据处理后先在难例集上跑一遍看原本错的有没有改对原本对的有没有被改错。难例集比随机验证集更早暴露回归问题。这类数据集不像 MNIST 那样点开就能用它的价值在于贴近真实书写环境的复杂性。我养成的习惯是每个新版本模型发布前先跑一遍难例集再用热力图抽查 20 张图最后才看准确率。这套流程帮我避免了好几次上线翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表