
简介面向手写汉字识别方向的学习者与开发者这份压缩包提供基于深度卷积网络DCN的汉字识别Python脚本。内容围绕数据加载与归一化预处理、DCN模型构建、训练与验证循环、模型评估与保存等核心环节展开可借鉴MNIST数据集的思路处理汉字样本帮助读者理解卷积神经网络在图像分类任务中的完整流程适合作为课程设计或入门实践的参考实现。压缩包共1个文件为py脚本整体仅1KB代码精简便于直接阅读和二次修改。目前已有177人学习下载。通过该脚本可快速掌握图像预处理、网络结构搭建、损失函数与优化器配置等关键代码写法并针对汉字多样性、结构复杂性和书写风格变化进行模型调优为后续构建更完整的手写汉字识别系统提供可运行的基线版本。1. 手写汉字识别不是 MNIST 换皮chinese_test 这套资源到底能做什么手写汉字识别卡住的从来不是模型而是数据。MNIST 那种 28x28 白底黑字数字随便一个两层 CNN 就能跑到 99% 以上但换成汉字一个「永」字的书写风格差异比 0 和 1 的区别还大结构复杂、笔画重叠、同类样本分布极散。这套 chinese_test.zip 资源把训练集、测试集和一个加载脚本打包在一起解决的是「从哪搞到带标签的汉字图片、怎么把图片喂进深度卷积网络、怎么评估结果」这一个完整链路。它借鉴了 MNIST 的数据组织方式和预处理思路但把任务难度整体提升了一个量级。适合两类人一类是刚跑通图像分类、想换个更有挑战性任务的入门者另一类是做中文 OCR 预研、需要快速验证卷积网络在手写场景下效果的工程师。2. 拆解 chinese_test.zip数据集组织与 MNIST 风格预处理流程2.1 数据集形态按类别目录组织的手写汉字图片集解压之后再对照 chinese_test.py 的读取逻辑会发现这个数据集的组织方式本质上就是 MNIST 的目录版一个根目录下按汉字类别分文件夹每个文件夹里放该汉字的手写样本。这种「类别即目录」的布局在小型数据集里非常常见好处是加载代码不用依赖额外的 CSV 标注文件坏处是如果样本来自多个书写者目录名里往往不带书写者信息后期做训练集/验证集划分时容易把同一人的笔迹同时分进两边。我先说结论拿到 zip 之后不要急着写模型先做三件事。第一统计每个类别目录下的样本数量确认类别分布是否均衡——如果有的字 500 张、有的字只有 50 张后面训练时 loss 会被大类别带偏需要在采样时做类别权重修正。第二随机抽几张图看一眼像素值范围手写汉字数据集的预处理差异极大有的是黑底白字、有的是白底黑字有的已经做过二值化、有的还是灰度图这直接影响归一化的写法。第三确认图片尺寸是否统一——我见过不少打包好的汉字数据集混着 32x32、48x48、64x64 三种尺寸如果不统一处理模型训练时 BatchNorm 的统计量会被尺寸变化干扰。这套资源在摘要描述里明确提到了借鉴 MNIST 的 28x28 灰度格式但汉字的结构复杂度决定了实际使用时一般会放大到 48x48 或 64x64 再训练。28x28 对数字够用对汉字来说信息量偏紧——「鬱」「龘」这种笔画密集的字在低分辨率下笔画会糊成一团。所以解压后第一步就是用脚本统一确认尺寸分布不要假设所有图片都符合预期。2.2 预处理三板斧灰度、归一化、标签编码chinese_test.py 这个脚本的核心价值在数据加载段。它做的事情可以拆成三步读图、统一尺寸、转为网络输入格式。下面是我基于这个数据集常见写法整理的模板直接替换文件路径就能跑import os import cv2 import numpy as np from sklearn.preprocessing import LabelEncoder IMG_SIZE 64 # 统一缩放到 64x64比 28x28 留更多笔画细节 def load_handwritten_chars(root_dir): images, labels [], [] encoder LabelEncoder() all_labels [] # 第一遍收集所有类别建立稳定的标签编码 for class_name in sorted(os.listdir(root_dir)): class_path os.path.join(root_dir, class_name) if not os.path.isdir(class_path): continue for fname in os.listdir(class_path): if fname.lower().endswith((.png, .jpg, .jpeg, .bmp)): all_labels.append(class_name) encoder.fit(all_labels) # 类别名 - 0, 1, 2, ... class_names list(encoder.classes_) print(f共 {len(class_names)} 个汉字类别) # 第二遍读取图像并统一预处理 for class_name in sorted(os.listdir(root_dir)): class_path os.path.join(root_dir, class_name) if not os.path.isdir(class_path): continue for fname in os.listdir(class_path): img cv2.imread(os.path.join(class_path, fname), cv2.IMREAD_GRAYSCALE) if img is None: print(f跳过无法读取的文件: {fname}) continue # 统一尺寸缩放比裁剪更稳避免切掉汉字主干 img cv2.resize(img, (IMG_SIZE, IMG_SIZE), interpolationcv2.INTER_AREA) # 归一化到 [0,1]保持单通道 img img.astype(np.float32) / 255.0 images.append(img) labels.append(encoder.transform([class_name])[0]) return np.array(images), np.array(labels), class_names X, y, class_names load_handwritten_chars(./chinese_test) print(f加载完成: X.shape{X.shape}, y.shape{y.shape})这里有两个参数值得展开说。第一个是cv2.IMREAD_GRAYSCALE它强制以单通道灰度读取返回的 shape 是(H, W)而不是(H, W, 3)少一层通道能省不少内存和计算量如果源文件本身是彩色的——比如扫描件带红头印章——这个参数也能保证网络拿到的是纯粹的笔画信号不会被颜色干扰。第二个是interpolationcv2.INTER_AREA缩小图像时用区域插值比双线性插值更能保留笔画边缘的锐度汉字识别对细笔画很敏感插值方式选错了会在预处理阶段就损失掉一部分特征。标签编码那段逻辑看起来绕实际是为了避免一个经典问题直接用os.listdir的遍历顺序去生成标签编号一旦目录顺序变化标签编号就全乱了。LabelEncoder先拟合一遍再转换把「类别名 → 整数」的映射固定下来后面推理阶段反查是哪个字的时候也方便。数据集如果样本量不大几千张这个两遍遍历的耗时完全可接受如果上了几十万样本建议改成先读类别目录名、再按目录名顺序构建固定映射。2.3 训练集/验证集划分必须按书写者拆不能按图片随机拆MNIST 的官方划分是纯随机的但这个做法搬到手写汉字场景会出问题。手写汉字数据集里同一个字的样本往往来自有限的几个书写者如果按图片维度随机分 train/val同一书写者的笔迹会同时出现在两边模型相当于「见过」验证集的书写风格val 准确率会虚高。我一般会这么做如果目录名或文件名里带书写者编号就按书写者分组一个书写者的所有样本只进训练集或只进验证集如果文件没有携带书写者信息退而求其次按「每个类别目录内随机抽取 20%」划分至少保证类别分布一致。from sklearn.model_selection import train_test_split # 按类别做分层划分保证每个字在训练集和验证集中的比例一致 X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 ) print(f训练集: {X_train.shape}, 验证集: {X_val.shape}) # 检查每个类别的样本数是否均衡 unique, counts np.unique(y_train, return_countsTrue) print(f训练集中类别样本数范围: {counts.min()} ~ {counts.max()})stratifyy这行参数是核心它保证每个汉字类别在训练集和验证集里的比例一致。如果不加这行遇到「中」「国」这类高频字和「龘」这类冷僻字混在一起时冷僻字可能整个类别都掉进验证集训练时根本没见过预测时自然全是错的。第一次加载完数据后我建议顺手打印一下counts.min() ~ counts.max()这个区间如果最小值和最大值差了一个数量级后续训练必须给样本量少的类别加权重或者干脆过滤掉样本数低于阈值的类别。3. 选择深度卷积网络从 LeNet 骨架到汉字识别的参数调整3.1 网络结构设计三层卷积加池化适配 64x64 输入摘要描述里提到了 LeNet、AlexNet、VGG、ResNet 等架构都可能被用到。实际做手写汉字识别时我的判断是ResNet 的残差结构对防止梯度消失有效但如果数据集只有几万张、还不是百万级 ImageNet 预训练迁移深层 ResNet 在小数据集上容易过拟合训练时间翻倍但收益不明显。相反LeNet 的「卷积-池化-卷积-池化-全连接」骨架非常适合这个任务因为它层数浅、参数量可控而且手写汉字的结构特征——横竖撇捺、偏旁部首——本质上就是低中层级特征不需要特别深的网络去抽象。我在 LeNet 骨架上做了三个改动。第一卷积核大小从 5x5 改成 3x3两个 3x3 堆叠的等效感受野和 5x5 相同但参数量更少、非线性更强。第二每个卷积层后面加 BatchNorm手写汉字样本的笔画粗细差异很大BatchNorm 能让每层输入的分布稳定下来训练收敛明显更快。第三全连接层加 Dropout汉字类别的类内方差大很容易过拟合到训练集的书写风格上。import torch import torch.nn as nn class CharCNN(nn.Module): def __init__(self, num_classes, dropout0.5): super().__init__() # 输入: (batch, 1, 64, 64)单通道灰度图 self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # 64x64 - 64x64 nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 64x64 - 32x32 nn.Conv2d(32, 64, kernel_size3, padding1), # 32x32 - 32x32 nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 32x32 - 16x16 nn.Conv2d(64, 128, kernel_size3, padding1), # 16x16 - 16x16 nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 16x16 - 8x8 ) # 经过三层池化后特征图尺寸是 128 x 8 x 8 self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 8 * 8, 256), nn.ReLU(inplaceTrue), nn.Dropout(dropout), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x)) # 实例化num_classes 换成实际汉字类别数 model CharCNN(num_classeslen(class_names), dropout0.5) print(f模型参数量: {sum(p.numel() for p in model.parameters()):,})注意全连接层的输入维度128 * 8 * 8是写死的它由输入尺寸推导而来64x64 经过三次 MaxPool2d 每次减半得到 8x8。如果换数据集时改了输入尺寸、却没有同步改Linear的第一维训练时第一个 forward 就会报维度不匹配。我踩过一次这个坑从那以后就用一个小脚本在模型实例化之后打印每一层的输出形状先验证再训练。卷积核数量的选择逻辑是第一层 32、第二层 64、第三层 128呈倍数递增。这是因为池化之后空间分辨率减半但通道数翻倍信息总量大致保持。如果数据集样本量少比如每类只有几十张可以把三层通道数都减半到 16/32/64参数少一半、过拟合风险小一些如果每类样本几百张以上32/64/128 是稳妥起点。3.2 训练配置优化器、学习率、早停设置训练配置这块摘要里说脚本里包含损失函数选择、优化器设定、训练与验证循环。我直接给一组经过验证可用的默认参数照抄能跑再解释为什么这么设配置项推荐值说明输入尺寸64x64 灰度比 28x28 保留更多笔画细节比 128x128 训练速度快 4 倍batch_size32兼顾显存占用和梯度稳定性样本不足时用 16优化器Adamlr1e-3Adam 对学习率不敏感适合快速验证追求极致精度换 SGDMomentum损失函数CrossEntropyLoss多分类标准选择内部已包含 Softmax学习率衰减每 10 个 epoch 乘以 0.1前期快速收敛后期精细调节早停patience8监控验证集 loss防止过拟合节省训练时间数据增强旋转 ±10°、平移 ±2 像素手写风格波动大增强能显著提升泛化import torch.optim as optim from torch.optim.lr_scheduler import StepLR device torch.device(cuda if torch.cuda.is_available() else cpu) model CharCNN(num_classeslen(class_names)).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) scheduler StepLR(optimizer, step_size10, gamma0.1) best_val_loss float(inf) patience_counter 0 for epoch in range(50): model.train() train_loss 0.0 # 训练循环每次取一个 batch前向、计算 loss、反向、更新 for x_batch, y_batch in train_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) optimizer.zero_grad() outputs model(x_batch) loss criterion(outputs, y_batch) loss.backward() optimizer.step() train_loss loss.item() # 验证循环不计算梯度省显存也防止意外改动权重 model.eval() val_loss 0.0 correct 0 total 0 with torch.no_grad(): for x_batch, y_batch in val_loader: x_batch, y_batch x_batch.to(device), y_batch.to(device) outputs model(x_batch) val_loss criterion(outputs, y_batch).item() preds outputs.argmax(dim1) correct (preds y_batch).sum().item() total y_batch.size(0) val_loss / len(val_loader) val_acc correct / total scheduler.step() print(fEpoch {epoch1}: train_loss{train_loss/len(train_loader):.4f}, fval_loss{val_loss:.4f}, val_acc{val_acc:.4f}) # 早停逻辑验证集 loss 连续 8 个 epoch 不降就停 if val_loss best_val_loss: best_val_loss val_loss patience_counter 0 torch.save(model.state_dict(), best_char_cnn.pth) else: patience_counter 1 if patience_counter 8: print(f早停触发验证集 loss 连续 {patience_counter} 轮未下降) break代码里有几个细节值得展开。optimizer.zero_grad()必须放在每个 batch 的前向传播之前如果放在loss.backward()之后梯度会累加到上一轮loss 曲线会出现周期性震荡——这是新手最容易踩的隐性 bug。with torch.no_grad()包住验证循环能省下大量显存因为验证阶段不需要保存中间激活值用于反向传播不写这行的话batch_size 一大就会 OOM。scheduler.step()放在每个 epoch 结束时调一次它配合StepLR(step_size10)的意思是每训练满 10 个 epoch学习率自动乘以 0.1。前 10 轮用 1e-3 快速逼近最优区域后 10 轮用 1e-4 精细调整。早停的 patience 设为 8 比较适中。设太小比如 3训练过程遇到正常的 loss 平台期就停了白白浪费已经训到一半的模型设太大比如 20过拟合之后又白跑几十轮。每轮保存best_val_loss对应的权重文件也很关键——训练后期的模型虽然验证集准确率可能下降但保存的那个历史最优版本依然可用这就是「后悔药」。4. 避坑与排查五个让训练翻车的常见问题4.1 数据集层面的三类高频问题第一个问题是标签编号从 1 开始而不是从 0 开始。现象训练一开始 loss 巨大大于 5且长时间不下降准确率卡在随机水平附近。原因如果手写代码时用了enumerate(class_names, start1)来生成标签而CrossEntropyLoss期望的 target 范围是[0, num_classes-1]那么所有样本的标签都整体偏移了 1网络永远学不到正确映射。解决打印np.unique(y)看一下标签的最小值和最大值如果最小值是 1改成LabelEncoder或者start0我习惯在数据加载完立刻断言一下assert y.min() 0 and y.max() len(class_names) - 1, 标签范围异常检查编码是否从 0 开始第二个问题是图片预处理不一致导致训练和推理尺寸错位。现象训练过程一切正常准确率也不错但导出模型后单独跑一张测试图片直接报RuntimeError: size mismatch。原因训练时数据经过 resize 到 64x64推理时直接cv2.imread后送入网络原始图片如果是 128x128 甚至 300x200全连接层的输入维度完全对不上。解决把预处理逻辑抽成独立函数训练和推理共用同一个函数而不是在训练脚本里写一份、在推理脚本里又复制一份。第三个问题是像素值方向搞反了。现象loss 偏高但还能降val_acc 始终上不去可视化预测结果发现模型把空白区域当作特征。原因有些数据集的扫描件是白底黑字像素值 255 是背景、0 是笔画有些经过反转是黑底白字。如果代码里写死了归一化方向模型实际学到的是「背景的纹理」而不是笔画的形状。解决加载后打印img.min()和img.max()再取一个样本的中间行像素值确认 0 和 255 分别对应什么如果发现背景是 255在归一化前加一行img 255 - img。4.2 训练过程中两个最隐蔽的坑第四个问题是训练集准确率 99%、验证集只有 60% 左右典型过拟合。现象train_loss 持续下降val_loss 先降后升准确率差距越拉越大。原因手写汉字类别数通常几百到几千每类样本几十张模型参数量远超样本量相当于把训练集的书写风格背下来了。解决先加数据增强最有效的是随机旋转 ±10° 和随机平移 ±2 像素这两个变换正好模拟手写时的自然抖动同时把 Dropout 从 0.5 提到 0.6并检查卷积层是否有足够的池化——如果数据集每类样本不足 50 张直接砍掉一个卷积层比加正则更管用。from torchvision import transforms train_transform transforms.Compose([ transforms.ToTensor(), transforms.RandomRotation(degrees10), # 模拟书写倾斜 transforms.RandomAffine(degrees0, translate(0.03, 0.03)), # 模拟落笔偏移 ])第五个问题是 batch 内样本风格扎堆导致训练震荡。现象训练 loss 出现周期性的剧烈波动每隔几个 batch 就跳高一次验证集准确率也跟着抖动。原因如果数据集目录是按书写者组织的话按目录顺序读取时同一个 batch 里可能全是同一个人写的字——笔画的粗细、倾斜方向高度一致BatchNorm 的均值和方差被这个 batch 带偏下一个 batch 的风格突变又拉回来整个训练过程就在来回拉扯。解决在 DataLoader 里把shuffleTrue打开并且确保数据加载阶段先整体打乱再做 batching如果问题仍然存在手动对数据做一次全局 shuffleperm np.random.permutation(len(X_train)) X_train, y_train X_train[perm], y_train[perm]排查这批问题我总结出一个顺序先打印数据形状和标签范围再可视化几张预处理后的图最后看训练曲线的形态。不要一上来就调网络结构——大部分手写汉字识别训练翻车翻在数据准备上不翻在模型结构上。模型那块反而是最稳的抄 LeNet 骨架基本不会出大问题。5. 收尾验证与进阶用法混淆矩阵、单张推理和模型导出训练结束后光看 val_acc 数字是不够的——它只告诉你整体准确率不告诉你是哪些字在互相混淆。我训练完手写汉字模型的第一件事就是画混淆矩阵而且专门看对角线之外那些响应值高的格子。实际操作里「日」和「目」、「土」和「士」、「己」和「已」这类形近字的混淆率通常会远高于平均水平这不是模型 bug而是人写快了本来就会把笔画搞混属于数据本身的可分性上限。import torch import numpy as np def predict_single(model, img_path, class_names, img_size64): 单张手写汉字图片推理返回 Top-3 候选 import cv2 model.eval() img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (img_size, img_size), interpolationcv2.INTER_AREA) img img.astype(np.float32) / 255.0 x torch.from_numpy(img).unsqueeze(0).unsqueeze(0) # (1, 1, 64, 64) with torch.no_grad(): logits model(x) probs torch.softmax(logits, dim1).squeeze() top3 probs.topk(3) return [(class_names[idx], prob.item()) for idx, prob in zip(top3.indices, top3.values)] # 用法示例 result predict_single(model, ./test_samples/永.png, class_names) print(fTop-3 预测: {result})推理代码里的unsqueeze操作是维度对齐的关键cv2读出来是二维矩阵网络要求的是四维张量(batch, channel, height, width)两次unsqueeze分别在 batch 维和 channel 维各补一个 1。验证模型是否真的可用我还习惯测一组「对抗样本」——用和训练集风格差异很大的字迹图片去测比如毛笔字、小学生作业扫描件如果这些都能稳定识别说明模型学到了笔画结构而不是训练集的表面纹理。模型导出这块我建议训练收敛后直接导出 ONNX方便后续用 ONNX Runtime 在 CPU 上部署。PyTorch 训练时的自动求导图和推理时需要的计算图不是一回事导出可以省去在部署环境装 PyTorch 的麻烦dummy_input torch.randn(1, 1, 64, 64) torch.onnx.export( model, dummy_input, char_cnn.onnx, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}, logits: {0: batch}}, # 允许可变 batch 维度 opset_version12 ) print(ONNX 模型已导出到 char_cnn.onnx)dynamic_axes这段参数值得解释一下如果不声明动态轴导出的模型把 batch size 固定为 1部署时一次只能推理一张图效率很低声明之后可以把多张图拼成一个 batch 并行推理。opset_version 选 12 基本覆盖主流 Runtime 版本太老的版本可能不支持某些算子太新的版本在低版本 Runtime 上反而会报错。整个流程走下来我的习惯是训练收敛后必做三件事先拿一张训练集之外的真实手写图片过一遍单张推理确认前处理链路没断再看混淆矩阵确认形近字混淆比例在合理范围最后导出 ONNX 模型验证部署格式下的推理结果和 PyTorch 一致。从那次「训练集 99%、验证集 60%」的过拟合翻车之后我每次跑完训练都强制自己走完这三个步骤才敢说模型可用这个习惯帮我少走了很多弯路。希望你用这套资源训练时能一次跑通少踩我踩过的那些坑。本文还有配套的精品资源点击获取