简介:这份资源面向深度学习入门者与计算机视觉方向的学习者,聚焦手写汉字识别这一典型图像分类任务,帮助读者理解如何借助深度卷积网络完成从样本到识别结果的完整流程。压缩包内共1个文件,为Python脚本,整体约1KB,属于轻量级代码示例,便于快速阅读与二次修改。脚本内容预计涵盖数据加载与预处理、卷积网络结构定义、训练与验证循环以及模型评估等核心环节,可对照MNIST的经典处理思路迁移到汉字识别场景。目前已有177人学习下载,适合希望以最小成本跑通手写汉字识别基线、理解DCN在复杂字形上特征提取逻辑的读者参考,也可作为课程实验或项目起步的脚手架。
1. 拆开 chinese_test.zip:一个能跑的手写汉字识别起点
如果你正在找一份能直接跑起来的手写汉字识别代码,而不是又一篇讲 CNN 原理的科普,那chinese_test.zip值得花十分钟拆开看看。它里面只有一个chinese_test.py,配合一份手写汉字样本数据,用深度卷积网络完成从图像到汉字类别的映射。这正好卡在「mnist手写数字识别」和真实汉字识别之间的过渡地带——MNIST 那套 28×28 灰度图、归一化、卷积池化堆叠的流程可以直接借鉴,但汉字类别更多、笔画结构更复杂,直接套 MNIST 的 LeNet 会翻车。这份资源适合已经跑通过手写数字识别、想把手感迁移到汉字场景的开发者,也适合需要快速验证某个预处理或网络结构改动是否有效的熟手。它不解决通用 OCR 的全部问题,但能让你在一个干净的小闭环里看清:数据怎么进、卷积怎么提特征、损失怎么收敛、结果怎么评估。
2. 从 MNIST 到汉字识别:数据管线与网络选型的落地差异
2.1 为什么不能直接复用 MNIST 的输入尺寸和类别数
MNIST 是 10 类、28×28 单通道灰度图,而手写汉字识别的类别数取决于你用的字表。常见做法是先从几百个常用字起步,输入尺寸拉到 32×32 或 64×64,保留单通道灰度。这里有个容易被忽略的点:汉字笔画密度远高于数字,28×28 下很多相似字(比如「未」和「末」)的区分特征会被池化层吃掉。我一般会把输入统一到 64×64,第一层卷积核从 3×3 起步,步长 1,padding 设为 same,先保住空间分辨率,再靠后续池化降维。
chinese_test.py里数据加载部分大概率是遍历文件夹、按类别名做标签映射,然后做归一化和尺寸调整。如果你拿到的样本是按字分文件夹存放的,下面这段预处理逻辑可以直接抄:
import os import cv2 import numpy as np from sklearn.model_selection import train_test_split def load_dataset(root_dir, img_size=64): images, labels = [], [] class_names = sorted(os.listdir(root_dir)) for idx, char in enumerate(class_names): char_dir = os.path.join(root_dir, char) if not os.path.isdir(char_dir): continue for fname in os.listdir(char_dir): img_path = os.path.join(char_dir, fname) img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: continue # 先二值化再缩放,避免插值把笔画糊掉 _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) img = cv2.resize(img, (img_size, img_size), interpolation=cv2.INTER_AREA) images.append(img) labels.append(idx) X = np.array(images, dtype=np.float32) / 255.0 y = np.array(labels, dtype=np.int64) X = X.reshape(-1, img_size, img_size, 1) return train_test_split(X, y, test_size=0.2, random_state=42, stratify=y), class_names逻辑说明:先做 OTSU 二值化是为了把扫描或拍照带来的灰度渐变压成黑白两级,减少光照不均对卷积核的干扰;INTER_AREA在缩小图像时比默认的双线性更少产生伪影。参数上,img_size建议从 64 起步,如果你的样本本身是 128×128 以上且笔画细,可以拉到 96 或 128,但显存和训练时间会线性上涨。stratify=y保证每个字在训练集和验证集里的比例一致,否则某些字可能全被分到一边,验证准确率会变成玄学。
2.2 卷积网络结构怎么搭才不浪费算力
chinese_test.py里用的深度卷积网络,结构上大概率是「卷积-池化」堆叠若干次后接全连接。对于汉字识别,我一般会控制在 4 到 6 个卷积块,每块两个 3×3 卷积加一个 2×2 最大池化,通道数从 32 翻到 64、128、256。再深的话,在小样本上容易过拟合,除非你加了 BatchNorm 和 Dropout。下面是一个可以直接替换进脚本的 Keras 风格定义:
from tensorflow.keras import layers, models def build_dcn(num_classes, input_shape=(64, 64, 1)): model = models.Sequential([ layers.Conv2D(32, (3, 3), padding='same', activation='relu', input_shape=input_shape), layers.BatchNormalization(), layers.Conv2D(32, (3, 3), padding='same', activation='relu'), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), padding='same', activation='relu'), layers.BatchNormalization(), layers.Conv2D(64, (3, 3), padding='same', activation='relu'), layers.MaxPooling2D((2, 2)), layers.Conv2D(128, (3, 3), padding='same', activation='relu'), layers.BatchNormalization(), layers.MaxPooling2D((2, 2)), layers.Flatten(), layers.Dense(256, activation='relu'), layers.Dropout(0.5), layers.Dense(num_classes, activation='softmax') ]) return model逻辑说明:每个卷积块里两个 3×3 卷积叠在一起,感受野等效于一个 5×5,但参数量更少,非线性更强。BatchNorm 放在卷积和激活之间还是之后,Keras 里默认顺序是 Conv-BN-Activation,我上面写的是 Conv-Activation-BN,实际跑的时候建议改成 Conv-BN-ReLU,收敛更稳。Dropout 只加在全连接层前,卷积层不加,因为卷积核本身有参数共享,过拟合风险相对低。num_classes就是你字表的字数,如果只有几十个字,最后一层 Dense 可以降到 128,防止参数过多。
2.3 训练循环里损失函数和优化器的选择
汉字识别是多分类任务,损失函数用 categorical crossentropy,标签需要 one-hot;如果标签是整数,就用 sparse categorical crossentropy。优化器我习惯用 Adam,学习率从 1e-3 起步,配合 ReduceLROnPlateau 在验证损失不降时减半。chinese_test.py里如果用的是 SGD,收敛会慢很多,除非你调了动量。下面这段训练配置可以直接嵌进去:
from tensorflow.keras import optimizers, callbacks model = build_dcn(num_classes=len(class_names)) model.compile( optimizer=optimizers.Adam(learning_rate=1e-3), loss='sparse_categorical_crossentropy', metrics=['accuracy'] ) callbacks_list = [ callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=3, min_lr=1e-6), callbacks.EarlyStopping(monitor='val_loss', patience=8, restore_best_weights=True) ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=50, batch_size=64, callbacks=callbacks_list )逻辑说明:patience=3表示验证损失连续 3 个 epoch 不降就减半学习率,min_lr防止学习率降到 0 导致训练停滞。EarlyStopping的restore_best_weights=True是关键,否则你拿到的可能是过拟合后的权重。batch_size 设 64 是在显存和梯度稳定性之间的折中,如果样本少于 5000,可以降到 32。训练时盯着val_accuracy,如果它比accuracy低超过 10 个百分点,说明过拟合已经很明显,需要加数据增强或减网络深度。
3. 把 chinese_test.py 跑起来:环境、参数与验证步骤
3.1 环境依赖和最小可运行配置
chinese_test.py是 Python 脚本,依赖无外乎 TensorFlow 或 PyTorch、OpenCV、NumPy、scikit-learn。我建议用 conda 建一个干净环境,Python 3.8 到 3.10 之间,TensorFlow 2.x 或 PyTorch 1.12 以上。下面这套命令在我这边跑通过,直接抄:
conda create -n chinese_ocr python=3.9 -y conda activate chinese_ocr pip install tensorflow==2.12.0 opencv-python==4.8.0.74 numpy==1.24.3 scikit-learn==1.3.0 matplotlib==3.7.2逻辑说明:TensorFlow 2.12 对 CUDA 11.8 支持较好,如果你用 CPU 跑,把tensorflow换成tensorflow-cpu可以省掉一堆显卡驱动报错。OpenCV 用 4.8 是因为cv2.threshold和cv2.resize的接口稳定,老版本 3.x 在INTER_AREA上行为略有差异。scikit-learn 只用来做 train_test_split 和混淆矩阵,版本不敏感。
3.2 数据目录结构和标签映射的坑
chinese_test.zip解压后,样本数据大概率是按字分文件夹的。如果你拿到的是一张张散图加一个标签文件,需要先整理成root/字/图片的结构。下面这个脚本可以把「图片名即标签」的格式转成文件夹结构:
import os import shutil def reorganize_by_label(src_dir, dst_dir): os.makedirs(dst_dir, exist_ok=True) for fname in os.listdir(src_dir): if not fname.lower().endswith(('.png', '.jpg', '.jpeg', '.bmp')): continue # 假设文件名格式为 "字_序号.png",取第一个下划线前的部分作为标签 label = fname.split('_')[0] label_dir = os.path.join(dst_dir, label) os.makedirs(label_dir, exist_ok=True) shutil.copy(os.path.join(src_dir, fname), os.path.join(label_dir, fname))逻辑说明:split('_')[0]只适用于文件名里用下划线分隔标签的情况,如果你的命名规则不同,改这一行就行。复制而不是移动,是为了保留原始数据,万一整理错了还能重来。整理完之后,load_dataset里的sorted(os.listdir(root_dir))会按字排序生成类别索引,这个索引顺序在训练和推理时必须一致,否则预测结果会张冠李戴。
3.3 训练过程监控和模型保存
训练时除了看 loss 和 accuracy,我建议把混淆矩阵和错分样本可视化出来。chinese_test.py如果只打印准确率,你很难知道模型到底在哪些字上翻车。下面这段代码可以在训练结束后跑一次验证集评估:
import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns y_pred = model.predict(X_val) y_pred_classes = np.argmax(y_pred, axis=1) print(classification_report(y_val, y_pred_classes, target_names=class_names)) cm = confusion_matrix(y_val, y_pred_classes) plt.figure(figsize=(12, 10)) sns.heatmap(cm, annot=False, cmap='Blues', xticklabels=class_names, yticklabels=class_names) plt.xlabel('Predicted') plt.ylabel('True') plt.savefig('confusion_matrix.png', dpi=150)逻辑说明:classification_report会给出每个字的 precision、recall、f1-score,如果某个字的 recall 明显低,说明模型把它和别的字搞混了。混淆矩阵热力图能直观看到哪两个字之间误判最多,比如「日」和「曰」、「己」和「已」,这些往往是笔画长短或开口方向差异,需要针对性做数据增强。模型保存用model.save('chinese_dcn.h5'),加载时用tf.keras.models.load_model,注意自定义层或损失函数要加custom_objects参数。
4. 避坑与排查:手写汉字识别里最容易翻车的五个点
4.1 现象:训练准确率很高,验证准确率死活上不去
原因:样本量太少或者类别不均衡,模型把多数类背下来了。汉字识别里常用字和生僻字的样本数可能差几十倍,stratify只能保证划分比例,不能解决总量不足。解决:对样本少的字做数据增强,旋转 ±10 度、平移 10%、轻微弹性形变,用ImageDataGenerator或albumentations在线生成。同时把Dropout从 0.5 提到 0.6,全连接层神经元减半。
4.2 现象:模型把「未」和「末」全判成同一个字
原因:输入分辨率太低,两个字的横画长短差异在池化后消失了。解决:把img_size从 32 提到 64 或 96,第一层卷积不要急着池化,先做两个 3×3 卷积保住细节。如果还不行,在数据预处理阶段对图像做骨架化或笔画方向特征提取,把横画长度作为显式特征拼进去。
4.3 现象:训练 loss 震荡剧烈,偶尔跳到 nan
原因:学习率太大,或者输入数据没有归一化到 0-1 之间。解决:检查X = X / 255.0这行有没有漏掉,Adam 的学习率从 1e-3 降到 1e-4,加梯度裁剪clipnorm=1.0。如果用了 BatchNorm,检查 batch_size 是不是太小,小于 16 时 BN 的统计量不稳定。
4.4 现象:推理时单张图片预测结果和验证集对不上
原因:推理时的预处理和训练时不一致,比如训练用了 OTSU 二值化,推理时直接传了灰度图。解决:把预处理逻辑封装成一个函数,训练和推理都调同一个。另外检查输入 shape 是不是(1, 64, 64, 1),少一维会报错,多一维会静默出错。
4.5 现象:模型文件加载报「Unknown layer」或「Unknown loss」
原因:保存时用了自定义层或自定义损失函数,加载时没有注册。解决:如果是自定义层,在load_model时传custom_objects={'MyLayer': MyLayer}。更省事的做法是保存权重model.save_weights('weights.h5'),加载时先重建结构再load_weights,这样不依赖序列化。
5. 进阶技巧:用迁移学习和学习率预热把准确率再拉一截
如果你已经把chinese_test.py跑通,验证准确率卡在 85% 左右上不去,可以试试迁移学习。拿一个在 ImageNet 上预训练过的轻量网络,比如 MobileNetV2 或 EfficientNetB0,把输入改成单通道,去掉顶层,接自己的全连接分类头。下面这段代码展示怎么在 Keras 里做:
from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras import layers, models base = MobileNetV2(input_shape=(64, 64, 3), include_top=False, weights='imagenet') base.trainable = False model = models.Sequential([ layers.Input(shape=(64, 64, 1)), layers.Conv2D(3, (3, 3), padding='same', activation='relu'), # 单通道转三通道 base, layers.GlobalAveragePooling2D(), layers.Dense(256, activation='relu'), layers.Dropout(0.5), layers.Dense(num_classes, activation='softmax') ])逻辑说明:第一层 3×3 卷积把灰度图升到三通道,是为了适配 ImageNet 预训练权重的输入要求。base.trainable = False先冻结主干,只训练分类头,等 loss 稳定后再解冻最后几个 block 做微调,学习率降到 1e-5。GlobalAveragePooling2D替代 Flatten,参数量少很多,小样本上更不容易过拟合。
学习率预热是另一个立竿见影的技巧。前 5 个 epoch 把学习率从 1e-5 线性升到 1e-3,再按余弦退火降下去。这样模型不会一开始就跳进局部最优,尤其在你换了新数据或新结构的时候。我一般用tf.keras.optimizers.schedules.CosineDecay配合WarmUp回调,或者手写一个LearningRateScheduler。
验证方法上,除了看整体准确率,我习惯把验证集里置信度低于 0.6 的样本单独拎出来看一遍。这些是模型的「犹豫区」,往往对应着书写潦草或标注错误的样本。把标注错的挑出来修正,比调网络结构带来的提升更直接。从那以后我每次跑完训练,都强制走一遍「低置信度样本人工复核」的流程,十次里有三次能发现标签问题。希望帮到你。
本文还有配套的精品资源,点击获取