☰
手写汉字识别实战:从数据集解压到CNN调参避坑全指南
2026/10/5 7:59:14 网站建设 项目流程

简介:面向手写汉字识别学习者和深度学习初学者的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, cmap="gray") ax.set_title(clazz, fontsize=12) ax.axis("off") plt.tight_layout() plt.savefig("chinese_test_preview.png", dpi=120)

逻辑说明:随机类别加随机样本的网格图,重点看三件事:笔画有没有断裂,文字是不是被压成了低分辨率糊图,字在画布上是否居中。这三条直接决定后面要不要做中心裁剪和尺寸归一化。

参数说明:convert("L") 把图像统一成单通道灰度,和训练输入一致。cmap="gray" 只是让显示接近黑白扫描件。如果样本里混了红色批注或印章,灰度化之前要先想清楚要不要保留颜色信息,否则红色墨迹会被当作背景噪声丢掉。

预览时还要留意一类隐蔽问题:包名是 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(pretrained=False) # 输入是灰度图,把第一层卷积从3通道改成1通道 net.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) # 输出类别数以实际统计为准,不要写死3755 net.fc = nn.Linear(net.fc.in_features, num_classes) return net

逻辑说明:conv1 改成单通道输入,才能喂灰度图。fc 输出改成 num_classes,而不是固定 3755。pretrained=False 是因为第一层改动后,ImageNet 预训练权重无法加载,硬加载会报形状不匹配。

参数说明:resnet18 第一层默认 stride=2,64x64 输入会先降到 32x32。如果想把第一层输出保持在 64x64,可以把 stride 改成 1 并配合 padding=3,但显存占用会明显上涨。先跑通再优化,别在结构上一步到位。

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, transform=None): 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_classes=len(train_ds.class_to_idx)).to(device) optimizer = optim.SGD(net.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50) 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(f"epoch {epoch+1} 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, target=96, padding=4): # 裁掉白边,保留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。这样无论原图文字大小和位置如何,进入网络的字形都居中且比例统一。

参数说明:padding=4 防止 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(degrees=6, translate=(0.05, 0.05), scale=(0.9, 1.1)), transforms.ColorJitter(brightness=0.2, contrast=0.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_factor=0.01, total_iters=5) cosine = CosineAnnealingLR(optimizer, T_max=50) scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[5])

逻辑说明:预热把学习率从 0.0001 线性爬到基础学习率,降低早期梯度震荡。milestones=[5] 表示第 5 个 epoch 结束后切换到 cosine。这个组合在超大类别任务上比固定学习率收敛更稳。

参数说明:start_factor=0.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_samples=len(sample_weights), replacement=True) train_loader = DataLoader(train_ds, batch_size=128, sampler=sampler, num_workers=4)

逻辑说明:每个样本被抽到的概率按类别样本量的倒数加权,低频字在每轮训练里都有机会被抽到。replacement=True 表示有放回采样,保证低频类不会在某轮完全缺位。

参数说明: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), keepdim=True) cam = (weights * feature_map).sum(dim=1, keepdim=True) cam = F.interpolate(cam, size=(96, 96), mode="bilinear")

示意说明:热力图叠加到原图后,看高亮区域集中在汉字笔画还是画布白边。如果关注点散落在白边和角落,说明模型学到了某种捷径,比如不同类别的扫描噪声差异,而不是字形。用热力图做一次模型体检,比只盯准确率可靠。

6.2 做一个难例集当回归测试用

把验证集里预测错的样本捞出来,按类别和书写风格分组,固定成一个难例集。每次改动模型或数据处理后,先在难例集上跑一遍,看原本错的有没有改对,原本对的有没有被改错。难例集比随机验证集更早暴露回归问题。

这类数据集不像 MNIST 那样点开就能用,它的价值在于贴近真实书写环境的复杂性。我养成的习惯是:每个新版本模型发布前,先跑一遍难例集,再用热力图抽查 20 张图,最后才看准确率。这套流程帮我避免了好几次上线翻车。希望帮到你。

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

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

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

立即咨询