手写汉字识别实战:从chinese_test.zip到CNN模型部署
简介这份资源面向计算机视觉与深度学习方向的开发者及学生聚焦手写汉字识别这一典型图像分类任务帮助读者理解如何借助深度卷积网络完成从样本加载到模型评估的完整流程。压缩包内共1个文件为Python脚本整体约1KB属于轻量级代码示例便于快速阅读与二次修改。脚本中预计涵盖数据加载与预处理、卷积网络结构定义、损失函数与优化器设定、训练验证循环以及模型评估保存等核心环节并可能借鉴MNIST的数据组织方式处理汉字样本。目前已有177人学习下载适合希望入门手写汉字识别、了解DCN在复杂字形上应用思路的读者参考可据此搭建自己的训练与测试框架体会汉字多样性、结构复杂性与书写风格变化带来的识别挑战。1. 手写汉字识别从 chinese_test.zip 到可复现的识别流水线手写汉字识别跟 mnist 手写数字识别最大的区别不是类别从 10 变成几千而是同一个字在不同人笔下的形态差异可以大到让模型怀疑人生。mnist 里一个“3”再怎么歪也就那几种写法但“龙”字有人写成草书连笔有人写成印刷体骨架笔画数、笔顺、结构比例全不一样。chinese_test.zip 这类数据集通常包含一批手写汉字图片按类别分目录存放用来做训练和测试。这篇文章面向的是想用这份数据跑通一条完整识别流水线的工程师——不管你是刚接触手写识别还是从手写数字识别转过来想试试汉字下面的内容都能让你从数据检查一路走到模型推理中间该踩的坑我会提前标出来。核心思路很直接把每张手写汉字图片归一化成固定尺寸的灰度图用一个 CNN 提取特征最后接分类头输出汉字类别。听起来跟手写数字识别差不多但汉字识别的难点在于类别数多、类间差异小、书写风格差异大。所以数据预处理和增强策略比模型结构本身更值得花时间。常见做法是用 ResNet 或轻量级 CNN 做骨干配合数据增强和标签平滑来提升泛化。下面按实际落地顺序展开。2. 数据检查与预处理chinese_test.zip 解压后先别急着训练2.1 解压后的目录结构长什么样拿到 chinese_test.zip 之后第一步不是写模型而是搞清楚数据到底怎么组织的。常见的手写汉字数据集有两种布局一种是按类别分文件夹每个文件夹名就是汉字标签另一种是平铺图片加一个标签文件。先解压看看。# 解压到指定目录避免中文路径带来的编码问题 unzip chinese_test.zip -d ./chinese_data # 查看目录结构确认是按类别分文件夹还是平铺 find ./chinese_data -maxdepth 2 -type d | head -30 # 统计图片总数和类别数 find ./chinese_data -type f \( -name *.png -o -name *.jpg -o -name *.bmp \) | wc -l ls ./chinese_data | wc -l如果输出显示有几百个文件夹、每个文件夹里若干张图那就是按类别组织的。这时候要特别注意文件夹名的编码——中文文件夹名在有些系统上会变成乱码导致后面标签映射出错。我一般会先把文件夹名打印出来确认一遍。import os data_root ./chinese_data classes sorted(os.listdir(data_root)) print(f类别数: {len(classes)}) print(f前 20 个类别: {classes[:20]}) # 检查每个类别的样本数分布 counts {} for c in classes: cpath os.path.join(data_root, c) if os.path.isdir(cpath): counts[c] len([f for f in os.listdir(cpath) if f.lower().endswith((.png, .jpg, .bmp))]) import numpy as np vals list(counts.values()) print(f样本数 min{min(vals)}, max{max(vals)}, mean{np.mean(vals):.1f})这段代码做了三件事确认类别总数、打印类别名检查编码、统计每个类别的样本量。参数说明data_root指向解压后的根目录sorted保证类别顺序一致后面训练和推理要用同一个映射。如果发现某些类别只有个位数样本那基本可以判定是长尾分布训练时需要做重采样或加权损失。2.2 图片归一化尺寸、灰度、二值化的取舍手写汉字图片的原始尺寸往往不统一有的 64×64有的 128×128 甚至更大。直接 resize 到统一尺寸是最简单的做法但要注意长宽比——汉字是方块字强行拉成非正方形会扭曲笔画结构。常见做法是保持长宽比缩放后 padding 到正方形。import cv2 import numpy as np def preprocess_image(img_path, target_size64): 读取手写汉字图片并归一化为 target_size x target_size 的灰度图 img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: return None # 保持长宽比缩放到目标尺寸以内 h, w img.shape scale target_size / max(h, w) new_h, new_w int(h * scale), int(w * scale) img_resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_AREA) # padding 到正方形背景填 0黑底白字或 255白底黑字 canvas np.zeros((target_size, target_size), dtypenp.uint8) top (target_size - new_h) // 2 left (target_size - new_w) // 2 canvas[top:topnew_h, left:leftnew_w] img_resized # 归一化到 [0, 1] canvas canvas.astype(np.float32) / 255.0 return canvas逻辑说明先灰度读取再等比缩放最后居中 padding。interpolationcv2.INTER_AREA在缩小图片时效果比默认的线性插值更干净能减少笔画边缘的锯齿。参数说明target_size一般设 64 或 12864 够用且训练快128 适合笔画复杂的字。背景填充值取决于原图是黑底白字还是白底黑字要统一。注意如果数据集里黑底白字和白底黑字混在一起必须先做反色统一否则模型会学到“背景颜色”这个无关特征。2.3 标签编码与训练集划分类别名是汉字模型需要整数标签。用sorted后的类别列表建立映射保证可复现。from sklearn.model_selection import train_test_split # 建立类别到索引的映射 class_to_idx {c: i for i, c in enumerate(classes)} idx_to_class {i: c for c, i in class_to_idx.items()} # 收集所有样本路径和标签 all_paths, all_labels [], [] for c in classes: cpath os.path.join(data_root, c) if not os.path.isdir(cpath): continue for fname in os.listdir(cpath): if fname.lower().endswith((.png, .jpg, .bmp)): all_paths.append(os.path.join(cpath, fname)) all_labels.append(class_to_idx[c]) # 按 8:2 划分训练集和验证集stratify 保证类别比例一致 X_train, X_val, y_train, y_val train_test_split( all_paths, all_labels, test_size0.2, random_state42, stratifyall_labels ) print(f训练集: {len(X_train)}, 验证集: {len(X_val)})参数说明stratifyall_labels很关键尤其当类别样本不均衡时不加这个可能导致某些类别在验证集中完全缺失。random_state42固定随机种子方便复现。如果某些类别样本太少无法 stratify可以先过滤掉样本数少于 5 的类别或者改用分层 K 折。3. 模型选型与训练从手写数字识别迁移到汉字识别3.1 为什么直接用 MNIST 那套 CNN 会翻车mnist 手写数字识别的经典 CNN 通常是两三个卷积层加全连接参数量几十万在 10 分类上轻松跑到 99%。但把这套结构直接搬到汉字识别上问题立刻暴露类别数从 10 变成几百甚至几千全连接层的输出维度暴涨参数量爆炸同时汉字笔画细节比数字丰富得多浅层 CNN 的感受野不够提取不到足够的结构特征。我一般会选 ResNet-18 或更小的自定义残差网络作为起点。ResNet 的残差连接能缓解深层网络的梯度消失问题而汉字识别恰恰需要足够的深度来捕捉笔画组合和结构信息。如果算力有限可以把 ResNet-18 的输入层改成单通道并把第一层卷积的 stride 调小保留更多空间细节。import torch import torch.nn as nn import torchvision.models as models def build_model(num_classes, input_size64): 构建用于手写汉字识别的 ResNet-18 model models.resnet18(weightsNone) # 修改第一层输入改为单通道灰度图 model.conv1 nn.Conv2d(1, 64, kernel_size7, stride2, padding3, biasFalse) # 修改最后的全连接层输出改为汉字类别数 model.fc nn.Linear(model.fc.in_features, num_classes) return model num_classes len(classes) model build_model(num_classes) print(f输出类别数: {num_classes}) print(f模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M)逻辑说明weightsNone表示不从 ImageNet 加载预训练权重——手写汉字是灰度图跟 ImageNet 的 RGB 自然图像差异太大预训练收益有限。如果数据量确实很少也可以试试加载预训练权重后只微调后面几层。参数说明conv1的stride2会把 64×64 的输入降到 32×32如果笔画特别细可以改成stride1保留更多细节但计算量会翻倍。3.2 数据增强让模型见过更多“丑字”手写汉字识别的泛化瓶颈不在模型容量而在书写风格的多样性。数据增强是性价比最高的手段。常用的增强包括随机旋转±10 度、随机平移±10%、随机缩放0.91.1、弹性形变。弹性形变对汉字特别有效因为它能模拟不同人的笔画粗细和弯曲程度。from torchvision import transforms train_transform transforms.Compose([ transforms.ToPILImage(), transforms.RandomRotation(10, fill0), transforms.RandomAffine(degrees0, translate(0.1, 0.1), scale(0.9, 1.1), fill0), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ]) val_transform transforms.Compose([ transforms.ToPILImage(), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ])参数说明fill0表示旋转和平移后空白区域填黑色跟预处理时的 padding 保持一致。Normalize的均值和标准差设 0.5 是把 [0,1] 映射到 [-1,1]这是单通道图像的常用做法。注意验证集只做归一化不做随机增强否则评估结果不稳定。注意弹性形变需要用scipy.ndimage或albumentations单独实现torchvision 没有内置。如果不想引入额外依赖旋转加平移已经能带来明显提升。3.3 训练循环与关键超参数训练循环本身不复杂但有几个参数直接影响收敛速度和最终精度。我一般用 AdamW 优化器学习率 1e-3配合余弦退火调度。批次大小根据显存来64 或 128 都行。损失函数用交叉熵如果类别不均衡可以加weight参数或改用 Focal Loss。from torch.utils.data import Dataset, DataLoader from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR import torch.nn.functional as F class HandwritingDataset(Dataset): def __init__(self, paths, labels, transformNone, target_size64): self.paths paths self.labels labels self.transform transform self.target_size target_size def __len__(self): return len(self.paths) def __getitem__(self, idx): img preprocess_image(self.paths[idx], self.target_size) if img is None: img np.zeros((self.target_size, self.target_size), dtypenp.float32) if self.transform: img self.transform(img) return img, self.labels[idx] # 构建 DataLoader train_ds HandwritingDataset(X_train, y_train, train_transform) val_ds HandwritingDataset(X_val, y_val, val_transform) train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_ds, batch_size64, shuffleFalse, num_workers4) # 训练配置 device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max50) criterion nn.CrossEntropyLoss(label_smoothing0.1) # 训练循环 for epoch in range(50): model.train() total_loss, correct, total 0, 0, 0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (outputs.argmax(1) labels).sum().item() total imgs.size(0) scheduler.step() train_acc correct / total print(fEpoch {epoch1}: loss{total_loss/total:.4f}, acc{train_acc:.4f})参数说明label_smoothing0.1能缓解过拟合尤其当某些类别样本少时效果明显。weight_decay1e-4是 AdamW 的默认正则化强度数据量大时可以调小。T_max50对应 50 个 epoch 的余弦周期如果训练更久要相应调整。num_workers4根据 CPU 核心数调整设太大反而会拖慢数据加载。训练过程中要盯住验证集准确率如果训练集准确率持续上升但验证集停滞或下降说明过拟合了需要加强增强或加 dropout。如果两者都上不去可能是学习率太大或模型容量不够。4. 推理与部署把模型跑在新图片上4.1 单张图片推理的完整流程训练完之后拿一张新的手写汉字图片测试流程是预处理 → 转 tensor → 模型前向 → 取 argmax → 映射回汉字。def predict_single(model, img_path, idx_to_class, target_size64): 对单张手写汉字图片进行识别 model.eval() img preprocess_image(img_path, target_size) if img is None: return 无法读取图片 # 加上 batch 维度和通道维度: (H,W) - (1,1,H,W) tensor torch.from_numpy(img).unsqueeze(0).unsqueeze(0) tensor (tensor - 0.5) / 0.5 # 与训练时的 Normalize 一致 with torch.no_grad(): tensor tensor.to(device) outputs model(tensor) probs F.softmax(outputs, dim1) conf, pred_idx probs.max(1) return idx_to_class[pred_idx.item()], conf.item() # 示例 result, confidence predict_single(model, ./test_char.png, idx_to_class) print(f识别结果: {result}, 置信度: {confidence:.4f})逻辑说明unsqueeze(0)两次分别加上 batch 和 channel 维度因为模型期望输入是 (N, C, H, W)。归一化必须跟训练时完全一致否则精度会掉。参数说明confidence低于某个阈值比如 0.5时可以标记为“不确定”在实际应用中很有用。4.2 批量推理与结果导出实际使用中往往需要批量处理一个文件夹的图片导出 CSV 结果。import pandas as pd def batch_predict(model, img_dir, idx_to_class, target_size64): 批量识别文件夹中的所有图片返回 DataFrame model.eval() results [] for fname in sorted(os.listdir(img_dir)): if not fname.lower().endswith((.png, .jpg, .bmp)): continue fpath os.path.join(img_dir, fname) img preprocess_image(fpath, target_size) if img is None: results.append({filename: fname, prediction: 读取失败, confidence: 0.0}) continue tensor torch.from_numpy(img).unsqueeze(0).unsqueeze(0) tensor ((tensor - 0.5) / 0.5).to(device) with torch.no_grad(): outputs model(tensor) probs F.softmax(outputs, dim1) conf, pred_idx probs.max(1) results.append({ filename: fname, prediction: idx_to_class[pred_idx.item()], confidence: round(conf.item(), 4) }) return pd.DataFrame(results) df batch_predict(model, ./test_images, idx_to_class) df.to_csv(predictions.csv, indexFalse, encodingutf-8-sig) print(df.head(10))参数说明encodingutf-8-sig保证 Excel 打开 CSV 时中文不乱码。confidence保留四位小数方便后续筛选低置信度样本做人工复核。5. 避坑与排查手写汉字识别里最容易翻车的 5 个地方5.1 准确率虚高但实际用不了现象验证集准确率 95% 以上但拿真实手写图片测试时错得离谱。原因训练集和验证集来自同一批书写者模型学到了书写者的个人风格而不是汉字本身的结构特征。解决按书写者划分训练集和验证集而不是随机划分。如果数据集没有书写者标注至少确保验证集图片在视觉风格上跟训练集有明显差异。5.2 中文路径导致图片读取失败现象cv2.imread返回 None但图片明明存在。原因OpenCV 的imread在 Windows 上对中文路径支持不好。解决用cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_GRAYSCALE)替代或者先把文件读成 bytes 再解码。def imread_unicode(img_path, flagscv2.IMREAD_GRAYSCALE): 支持中文路径的图片读取 try: data np.fromfile(img_path, dtypenp.uint8) return cv2.imdecode(data, flags) except Exception: return None5.3 类别不平衡导致少数类完全被忽略现象模型对所有输入都预测为样本最多的那几个类别。原因交叉熵损失在类别极度不平衡时会被多数类主导。解决给CrossEntropyLoss传weight参数权重设为类别频率的倒数或者用重采样让每个 batch 内类别大致均衡。5.4 数据增强过度导致训练不收敛现象训练 loss 震荡剧烈准确率上不去。原因旋转角度太大或弹性形变太强把汉字结构破坏得面目全非。解决旋转控制在 ±10 度以内缩放控制在 0.91.1弹性形变的 alpha 参数不要超过 30。增强的目的是模拟真实书写变化不是制造噪声。5.5 推理时预处理跟训练时不一致现象训练时验证准确率很高推理时结果全错。原因推理时的归一化参数、图片尺寸、通道顺序跟训练时不一致。解决把预处理逻辑封装成一个函数训练和推理共用同一份代码。归一化的均值和标准差要写死不要在两处分别定义。6. 进阶技巧用混淆矩阵定位模型的“死穴”训练完模型、跑完准确率之后别急着收工。我习惯做一件事画混淆矩阵找出模型最容易混淆的汉字对。手写汉字识别里形近字是最大的敌人——“未”和“末”、“日”和“曰”、“己”和“已”这些字在低分辨率下几乎一模一样。混淆矩阵能直接告诉你哪些类别对需要额外关注。from sklearn.metrics import confusion_matrix import seaborn as sns import matplotlib.pyplot as plt def plot_confusion_matrix(model, val_loader, idx_to_class, top_n20): 绘制验证集上的混淆矩阵只展示最易混淆的 top_n 个类别 model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in val_loader: imgs imgs.to(device) outputs model(imgs) preds outputs.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) # 找出混淆最多的类别非对角线元素之和最大的 off_diag cm.sum(axis1) - np.diag(cm) top_indices np.argsort(off_diag)[-top_n:] cm_sub cm[np.ix_(top_indices, top_indices)] labels_sub [idx_to_class[i] for i in top_indices] plt.figure(figsize(12, 10)) sns.heatmap(cm_sub, annotTrue, fmtd, xticklabelslabels_sub, yticklabelslabels_sub, cmapBlues) plt.xlabel(预测) plt.ylabel(真实) plt.title(最易混淆的汉字类别) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150) print(混淆矩阵已保存) plot_confusion_matrix(model, val_loader, idx_to_class)参数说明top_n20表示只展示最容易混淆的 20 个类别太多会看不清。off_diag计算每个类别被错分到其他类别的总次数排序后取最大的几个。如果发现某对汉字混淆特别严重可以考虑针对性补充训练数据或者在损失函数里给这些类别对加惩罚项。除了混淆矩阵还有一个实用技巧对低置信度样本做人工复核。把推理置信度低于 0.6 的样本单独导出来人工看一眼往往能发现数据标注错误或预处理问题。我自己的习惯是每轮训练后都跑一遍这个流程比盯着准确率数字有用得多。最后说一个血泪教训别在预处理上偷懒。我见过太多项目模型结构调了又调最后发现是训练时用了Normalize而推理时忘了或者训练图片是黑底白字而推理图片是白底黑字。把预处理逻辑写成一个独立模块训练和推理都调它能省下大量排查时间。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →