简介:这份基于TensorFlow2.0的中文汉字手写体识别项目,面向计算机、数学、电子信息等专业学生,可作为课程设计、期末大作业及毕业设计的完整参考,也适合希望上手深度学习图像识别的初学者进行实战演练。压缩包内共94个文件,以Python源码(6个py)、预测结果演示图片(72个png)为主,同时包含模型结构定义、训练脚本、数据集转换工具、说明文档(md/txt)及PyCharm工程配置等,整体大小6.71MB,目录划分清晰,便于按流程研读与复用。已有89人学习浏览,证明其具有一定的参考价值。整个项目基于TensorFlow2.0实现中文汉字手写体识别,涵盖数据预处理、模型构建、训练、测试和单字预测完整链路;附带大量“pred_*.png”示例输出,可直观对比识别效果,并配有readme与脚本使用说明,能帮助使用者快速跑通流程、理解关键代码逻辑,为后续扩展或二次开发打下基础。
1. TensorFlow2.0中文汉字手写体识别算法:从数据管道到部署的完整链路
在学籍录入、票据归档这类场景里,中文汉字手写体识别常被当成普通图像分类处理,但真正落地的团队很快会发现,这比MNIST难出一个数量级。类别数是3755这个量级(GB2312一级汉字),叠加样本长尾、运笔形变与大量形近字,直接暴露了“数据集简单、模型深一点”这套思维的不适用。TensorFlow2.0的eager执行、tf.data管道和Keras训练接口,让这条从原始笔迹到可部署模型的链路清晰起来。下面按数据准备、模型训练、评估排错、导出部署四段展开,源码组织上遵循“数据管道独立、模型封装为函数、训练脚本带参数入口”的常见做法,适合有图像分类基础、要上手中文汉字手写体识别的算法与工程人员。
2. 数据与tf.data管道:将原始笔迹整理成可训练样本
2.1 标签体系与数据集选型:一级汉字3755并不是“分类数”的全部
大多数中文汉字手写体识别系统的起步点,不是模型而是标签体系。GB2312一级汉字有3755个常用字,覆盖日常书写的大部分场景,但它不是唯一选择——如果用于人名识别,还要追加姓氏高频字和GB2312二级字库中的常用人名用字;如果用于表单结构化,往往在3755之外叠加数字、字母和标点,最终类别数在4000上下。每多一个类别,就需要新增该字的训练样本,长尾会随之拉长,这决定了后面所有工程决策的走向。
数据来源上,研究阶段可以直接使用公开的手写体数据集,例如CASIA-HWDB系列,其在线与离线数据都能覆盖常用汉字集;生产环境通常要自采或依托业务积累。常见做法是先用公开数据做冷启动,再用业务侧手写样本做finetune。公共数据集的标签是汉字而非索引,需要手工构建字表到索引的映射,这个映射文件本身就是源码包中最重要的资产之一——顺序一旦写错,整个评估结果都会失真。
2.1.1 标签映射与目录组织
常见的代码组织是data/char_dict.txt按行存字,每行索引与整数标签一一对应。读取时用脚本生成两个字典:
# build_dict.py from pathlib import Path char_path = Path("data/char_dict.txt") chars = [line.rstrip("\n") for line in char_path.read_text(encoding="utf-8").splitlines() if line.strip()] char2idx = {c: i for i, c in enumerate(chars)} idx2char = {i: c for i, c in enumerate(chars)} # 类别数量,模型最后一层 Dense 的输出维度就由它决定 num_classes = len(chars) print(num_classes, idx2char[:10])这段脚本将字表文件转成双向映射,训练和推理必须复用它。很多踩坑案例是训练用了A版字表,导出推理时手写了B版字表,评估时只差一个索引,线上就变成错字串。这个文件要放在版本管理下,而不是临时目录。
2.2 用tf.data搭建解码-增强-批处理的读取链路
和直接把图片读进numpy数组不同,TensorFlow2.0推荐用tf.data把“文件路径到张量”的变换链写成惰性管道。这样训练集再大也不会一次性占满内存,prefetch能自动与GPU计算重叠。
# dataset.py import tensorflow as tf IMG_SIZE = 64 def parse_example(image_path, label): image = tf.io.read_file(image_path) image = tf.image.decode_jpeg(image, channels=1) # 按灰度读入 image = tf.image.resize(image, [IMG_SIZE, IMG_SIZE]) # 统一尺寸 image = tf.cast(image, tf.float32) / 255.0 # 归一化到[0,1] return image, label def augment(image, label): image = tf.image.random_brightness(image, max_delta=0.2) image = tf.image.random_contrast(image, lower=0.8, upper=1.2) image = tf.image.random_rotation(image, 0.05, fill_mode="constant") return image, label def build_dataset(file_paths, labels, batch_size=64, training=True): ds = tf.data.Dataset.from_tensor_slices((file_paths, labels)) ds = ds.map(parse_example, num_parallel_calls=tf.data.AUTOTUNE) if training: ds = ds.map(augment, num_parallel_calls=tf.data.AUTOTUNE) ds = ds.shuffle(4096) ds = ds.batch(batch_size) ds = ds.prefetch(tf.data.AUTOTUNE) return ds逻辑说明:from_tensor_slices把路径数组与标签数组绑定为数据集;map阶段完成解码和增强,num_parallel_calls=AUTOTUNE让数据加载自动用满多核。shuffle必须在batch之前,否则每个batch内只会出现连续文件。prefetch放在最末端,提前准备下一批数据,在GPU训练时效果最明显。
需要调整的参数有三个:IMG_SIZE、batch_size、random_rotation的角度。IMG_SIZE过小会损失笔画细节,过大则增大显存压力。对于64x64输入,channel设为1足矣,彩色对手写笔迹没有额外信息量。
2.2.1 数据增强参数推荐范围
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| rotation | 0.03~0.08弧度 | 过大会把横平竖直的结构破坏,未/末这类字会互相干扰 |
| brightness | 0.1~0.2 | 模拟扫描底色差异 |
| contrast | 0.8~1.2 | 模拟铅笔、圆珠笔深浅 |
| zoom | 0.9~1.1 | 模拟书写大小差异,建议同时配合resize |
| 平移 | 0~0.1 | 模拟田字格内位置漂移 |
提示:增强强度的上限不是由数据决定的,而是由任务中最细的判别信息决定的。汉字识别里,笔画之间的相对位置是核心特征,旋转和缩放都要设小。
2.3 长尾样本的处理:过采样低频类,控制增强强度
字频差异是中文汉字手写体识别最大的数据陷阱。业务数据里,“的”“一”“是”这类高频字动辄上万样本,生僻字可能只有几十张。如果不处理,模型会把所有模糊输入都推向高频类。
常见做法有两类:一类是过采样低频类,把它们在数据集中复制多份,让每个epoch里每个类别出现的期望次数接近;另一类是对低频类做更强的扰动,用增强来“造”出更多变形。实际工程中两者结合使用,并建议为低频类单独记录样本数,在每个epoch结束后打印该字在训练集和验证集上的准确率。
# 低频类过采样示例:把样本数少于阈值的图片路径重复 n 次 from collections import Counter def oversample(paths, labels, threshold=100): counter = Counter(labels) new_paths, new_labels = [], [] for p, l in zip(paths, labels): new_paths.append(p) new_labels.append(l) if counter[l] < threshold: repeat = threshold // counter[l] new_paths.extend([p] * repeat) new_labels.extend([l] * repeat) return new_paths, new_labels逻辑说明:该函数在构造数据集之前执行,把低频类重复到下限附近。注意repeat计算后高频类不会被重复,整体样本规模增幅取决于低频类数量。如果某个字样本极少且重复太多,会出现同一个epoch里同一张图多次出现,模型容易记住它——此时配合较小的增强强度波动反而更稳妥。
3. 模型结构与训练配置:把3755类分类器调稳
3.1 网络设计:为什么在汉字识别上不能把输入图片缩得太小
经典的LeNet式结构在MNIST上表现良好,但直接搬到中文汉字手写体识别上会立刻暴露出容量问题。3755类的判别需要更细的纹理特征,像“己/已/巳”这类字,差异只在一个笔画的开合角度和长度上。因此常见做法是把输入放大到64x64,并用残差块堆叠出中等深度的CNN。
假设输入为64x64x1,网络按4组残差块组织,特征图从64x64逐步下采样到8x8,最后接全局平均池化和Dense层。整体张量形状变化如下:
| 层 | 输出形状 | 作用 |
|---|---|---|
| Conv3x3, stride=1 | 64x64x32 | 保留笔画边缘 |
| ResBlock, 64, stride=1 | 64x64x64 | 提取局部结构 |
| ResBlock, 128, stride=2 | 32x32x128 | 扩大感受野 |
| ResBlock, 256, stride=2 | 16x16x256 | 组合笔画部件 |
| ResBlock, 512, stride=2 | 8x8x512 | 全局结构抽象 |
| GlobalAvgPool | 512 | 聚合空间信息 |
| Dense+Softmax | 3755 | 类别预测 |
选择stride=2而不是maxpool的原因,是下采样过程可学习的卷积更能保留对形近字敏感的边缘响应。层数不必盲目加深,在样本量不变时加深会带来过拟合风险,512维的瓶颈层在多类分类中足够承载判别信息。
3.2 损失与优化器:用label_smoothing压住3755类的过拟合
多分类任务默认使用交叉熵,但3755类的softmax输出极易产生过度自信的分布。训练后期模型对训练集达到99%以上准确率时,验证集还在92%附近,典型表现就是softmax输出接近one-hot。做法是用label smoothing,把one-hot标签的0和1换成epsilon和1-epsilon,抑制过拟合。
# model.py def conv_block(x, filters, kernel_size=3, stride=1): x = tf.keras.layers.Conv2D(filters, kernel_size, strides=stride, padding="same")(x) x = tf.keras.layers.BatchNormalization()(x) x = tf.keras.layers.ReLU()(x) return x def residual_block(x, filters, stride=1): shortcut = x x = conv_block(x, filters, stride=stride) x = tf.keras.layers.Conv2D(filters, 3, padding="same")(x) x = tf.keras.layers.BatchNormalization()(x) if stride != 1 or shortcut.shape[-1] != filters: shortcut = tf.keras.layers.Conv2D(filters, 1, strides=stride)(shortcut) x = tf.keras.layers.Add()([x, shortcut]) return tf.keras.layers.ReLU()(x) inputs = tf.keras.Input(shape=(64, 64, 1)) x = conv_block(inputs, 32) x = residual_block(x, 64, stride=1) x = residual_block(x, 128, stride=2) x = residual_block(x, 256, stride=2) x = residual_block(x, 512, stride=2) x = tf.keras.layers.GlobalAveragePooling2D()(x) outputs = tf.keras.layers.Dense(num_classes, activation="softmax", name="classifier")(x) model = tf.keras.Model(inputs, outputs) model.compile( optimizer=tf.keras.optimizers.SGD(learning_rate=0.01, momentum=0.9), loss=tf.keras.losses.CategoricalCrossentropy(label_smoothing=0.1), metrics=["accuracy", tf.keras.metrics.TopKCategoricalAccuracy(k=5)] )逻辑说明:残差块的shortcut连接让梯度能跨层回传,BatchNorm稳定深层网络的激活分布。Dense层输出维度绑定num_classes,需要和2.1的字表映射严格一致。优化器选SGD+momentum而不是Adam,是因为多分类任务中SGD配合学习率衰减往往能找到更平滑的极小值;如果训练速度太慢,可以换成AdamW并把learning_rate降到0.001。
label_smoothing=0.1的含义是:真实类别的目标概率从1降到0.9,其余类别共享0.1。这个参数不宜过大,否则模型的判别力会被明显削弱。Top-5准确率在这里就作为第二个监视指标,它表示真实标签是否落在模型预测概率最大的前5个类别中,评估阶段会用它来判断模型到底有没有学到这个字。
3.3 训练回调与迭代节奏:checkpoint、lr衰减与EMA
训练流程建议做成脚本而不是notebook,方便复现和换数据。下面这段回调节奏是在类似任务上常用的配置:训练120轮,每20轮学习率衰减0.1倍,只保存验证集准确率最优的权重。
# train.py 关键片段 callbacks = [ tf.keras.callbacks.ModelCheckpoint( "checkpoints/best.ckpt", monitor="val_accuracy", save_best_only=True, save_weights_only=True ), tf.keras.callbacks.ReduceLROnPlateau( monitor="val_accuracy", factor=0.5, patience=5, min_lr=1e-5 ), tf.keras.callbacks.EarlyStopping( monitor="val_accuracy", patience=15, restore_best_weights=True ), tf.keras.callbacks.TensorBoard(log_dir="logs") ] model.fit( train_ds, validation_data=val_ds, epochs=120, callbacks=callbacks )参数说明:save_best_only保证文件始终是验证集上最好的结果;ReduceLROnPlateau在指标5轮不涨时把学习率减半,比固定时间表更贴合收敛进度;EarlyStopping的patience设为15,防止即将过拟合时多跑浪费算力。TensorBoard的log_dir按时间命名更好,方便对比多轮实验。
提示:EMA(指数移动平均)在3755类任务上值得开,设置tf.train.ExponentialMovingAverage并在每个batch后更新,推理时用平均权重代替最新权重,通常能带来0.3~1个百分点的验证集提升,代价仅为额外的显存开销。
4. 评估与排错:从Top-1准确率看到混淆结构
4.1 Top-1与Top-5:用两个指标覆盖识别算法的两种使用形态
Top-1准确率适合直接展示给业务方,但真正的识别链路往往不是“一个模型一张图出唯一解”。在人工复核流程里,常见做法是模型给出Top-5候选,再由规则或人工选出最终汉字,此时Top-5准确率才是系统可用性的更真实度量。评估代码要在训练结束后独立运行,避免在训练循环内统计造成偏差。
# evaluate.py val_ds = build_dataset(val_paths, val_labels, training=False) model.load_weights("checkpoints/best.ckpt") model.compile(metrics=[ tf.keras.metrics.SparseCategoricalAccuracy(name="top1"), tf.keras.metrics.SparseTopKCategoricalAccuracy(k=5, name="top5") ]) result = model.evaluate(val_ds) print(f"val top1: {result[1]:.4f}, val top5: {result[2]:.4f}")逻辑说明:SparseCategoricalAccuracy接收整数标签,而训练时的CategoricalCrossentropy接收one-hot向量。如果用训练时的输入来评估,需要在parse_example把标签转为one-hot,或者保留整数标签数据集,二者必须与损失函数匹配。SparseTopKCategoricalAccuracy的k值与业务候选数保持一致,若人工复核框展示5个候选,k=5;若只展示3个,可以改成k=3。
4.2 混淆矩阵里的形近字问题与bad case归因表
仅仅看准确率看不出问题发生在哪。把验证集预测结果收集起来,找出错误样本中真实标签与预测标签的对数频率,按频率排序就能定位系统性的混乱点。
| 常见混淆对 | 失败原因 | 调整方向 |
|---|---|---|
| 已/己/巳 | 笔画开合差异微小,64x64下信息不足 | 输入尺寸提到96,减少旋转增强 |
| 未/末 | 横画长度比例被resize破坏 | 统一书写框尺度,别做随机缩放 |
| 土/士 | 上下横长度差异被对比度增强削弱 | 关掉对比度扰动或缩小幅度 |
| 日/曰 | 长宽比对透视类增强敏感 | 不要用shear类变换 |
| 天/夭 | 撇捺角度被旋转抹平 | rotation上限降到0.03 |
每个混淆对都指向一个具体的增强参数或数据问题。排错时不要只调模型,先看混淆对列表,再反向检查该字的样本量和增强管道——很多时候改增强参数比加深网络有效得多。
4.3 用混淆矩阵反哺数据处理:提取高频错误对
# analyze_confusion.py pred_idx = model.predict(val_ds).argmax(axis=-1) from collections import Counter wrong_pairs = Counter() for true, pred in zip(val_labels, pred_idx): if true != pred: wrong_pairs[(idx2char[true], idx2char[pred])] += 1 for (t, p), cnt in wrong_pairs.most_common(20): print(f"{t} -> {p}: {cnt}")逻辑说明:model.predict返回形状为(N, 3755)的概率矩阵,argmax取每个样本得分最高的类别。Counter统计所有错误对的频次,most_common(20)展示Top-20高频错误组合。看到“未->末”这类系统性错误,优先怀疑缩放增强过度而不是模型容量;看到高频字错到低频字,多半是过采样不够。
5. 导出与推理:把模型做成一条可复用的识别命令行
5.1 导出SavedModel并固定预处理参数
训练脚本里的预处理逻辑服务于训练分布,但它未必会跟着权重一起交付。常见做法是导出阶段把resize、归一化和通道转换封装进同一个函数,并把它直接编进模型签名中,避免推理端重新实现造成不一致。
# export.py import tensorflow as tf # 用 classifier 层前的张量作为 logits,避免在导出时叠加 softmax 的压缩 model_logits = tf.keras.Model(inputs=model.input, outputs=model.get_layer("classifier").input) @tf.function(input_signature=[tf.TensorSpec(shape=[None, None, 1], dtype=tf.uint8)]) def infer(image): image = tf.image.resize(image, [64, 64]) image = tf.cast(image, tf.float32) / 255.0 logits = model_logits(image, training=False) indices = tf.argsort(logits, direction="DESCENDING")[:, :5] return {"top_k_indices": indices, "top_k_logits": tf.gather(logits, indices, batch_dims=1)} tf.saved_model.save(model_logits, "exported_model", signatures={"serving_default": infer})逻辑说明:tf.function的input_signature固定输入类型和维度,uint8灰度图由调用方负责转换。导出后的模型把resize和归一化与权重绑定,推理端只负责读取图片为灰度张量,这消除了“训练是64x64,推理用了不同尺寸”的经典错误。导出对象选择model_logits而不是带softmax的原始model,是因为3755类的softmax分母巨大,长尾类别的概率被压缩到很小的量级,原始logits的相对顺序区分度更高。
5.2 取TopK而不是argmax:把概率转成候选列表
导出签名已返回排序后的前5个索引,推理端要做的是把它们映射为汉字字符串。源码包中单独提供一个infer.py命令行入口:
python infer.py --image sample/handwritten/天.png --topk 5脚本内先加载char_dict.txt,再用tf.saved_model.load载入exported_model,把签名输出中的索引查表得到“天 夭 夫 大 无”这样的候选序列。调用签名时注意两点:输入的灰度张量要带batch维,形状为(1, H, W, 1);读取图片后不要做任何归一化,管道会处理。配合人工复核界面时,这五个候选依次渲染在输入框下方,点选即完成录入,比单输出一个结果更贴合真实审核流。
本文还有配套的精品资源,点击获取