ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

TensorFlow2.0汉字手写识别:3755类的完整实现与避坑指南

2026/9/26 15:42:18 拓冰建站 浏览量
TensorFlow2.0汉字手写识别:3755类的完整实现与避坑指南 简介面向深度学习实践的中文手写汉字识别项目基于TensorFlow2.0实现提供一套完整的毕业设计源码。项目覆盖数据集获取与转换、CNN模型构建、训练评估、单字识别预测等环节适合计算机专业学生用于课程设计、毕业设计或TensorFlow2.0入门实战。压缩包共94个文件大小6.71MB包含72张PNG格式测试图片、6个Python源码含模型定义、训练、测试、TFRecord转换脚本、1个GNT原始标注库文件以及数据下载脚本、说明文档等目录结构清晰便于按模块阅读。已有165人学习下载。通过demo.py可直接加载模型对手写汉字图片进行预测也能在cnn_net.py中调整网络结构、在train_simple.py中修改超参数重新训练为研究不同参数下的识别效果提供灵活平台整体可作为中文OCR项目的参考基线。1. 中文汉字手写体识别为什么让 TensorFlow2.0 新手集体翻车先看清这个 zip 里装的到底是什么中文汉字手写体识别看起来跟 MNIST 没什么两样都是“图像进去、类别出来”。真把数据集打开你才发现3755 个类别、每个字上千种写法、形近字之间只差一个笔画的粗细——这个任务的难度跟 10 个阿拉伯数字完全不在一个量级。这个 zip 里装的是一套可以跑的方案数据预处理脚本、模型定义、训练参数、导出代码。TensorFlow2.0 的 Keras 高层 API 把搭网络的门槛降得很低但真正卡住人的往往是数据管道怎么喂、标签怎么编码、loss 怎么调。下面的内容按我踩过的顺序来写适合已经跑通过 MNIST、想往中文场景走的工程师能少走不少弯路。2. 数据准备把汉字手写体喂进 TensorFlow2.0 前先过这三关2.1 数据集怎么选CASIA-HWDB、自采样本与划分比例汉字手写体识别的公开数据集最常见的是 CASIA-HWDB。HWDB1.0 和 HWDB1.1 加起来覆盖 GB2312 一级字库的 3755 类每类样本数从几十到几百不等。这个数据量对深度学习来说不算大所以很多人会在上面做数据增强。另一个来源是自己采集——用数位板或者手机拍摄的手写图片好处是贴近业务场景坏处是标注成本高而且不同人写同一个字的差异极大。我一般会这样划分训练集 80%、验证集 10%、测试集 10%。验证集和测试集必须确保来源不同的人否则同一个人的书写风格会同时出现在训练和验证里验证指标虚高后面部署直接翻车。公开数据集自带的作者划分通常已经做了这件事别自己重新 shuffle。如果目录结构是按“类别名/图片文件”组织的用 TensorFlow2.0 加载很快import pathlib import tensorflow as tf data_dir pathlib.Path(hwdb/train) class_names sorted([p.name for p in data_dir.iterdir()]) num_classes len(class_names) train_ds tf.keras.utils.image_dataset_from_directory( data_dir, validation_split0.1, subsettraining, seed42, image_size(64, 64), batch_size64, label_modeint, )这里有个参数值得注意label_modeint返回的是整数标签配合SparseCategoricalCrossentropy用省内存。image_size先统一成 64×64跑通流程。validation_split0.1在目录内部随机切分如果数据集本身已有作者划分就别在这里再切改用subset指向完整目录。自采样本要补进训练集时最稳妥的做法是单独建目录不跟公开数据混在一起方便追溯。采集的时候让不同的人用不同书写工具各写一遍同一批样本不要集中在一个 batch 里否则模型学到的是书写工具的噪声而不是字形结构。2.2 图像预处理灰度、反色、留白裁剪的参数细节这一步是汉字识别里最容易出问题的地方。手写图片来源不同底色可能是白纸、米黄纸笔画颜色可能是黑、蓝、红。统一转灰度是第一件事。转完灰度之后绝大多数公开数据集是白底黑字而卷积网络处理黑底白字更自然——把前景置为高值背景置为低值梯度计算更稳定。反色之后再归一化常见的做法是(pixel / 255.0)缩放到 [0,1]或者用(pixel - mean) / std做标准化。对于手写体我一般只用/255.0因为不同来源的图像亮度差异已经通过反色统一了再做标准化反而会把笔画对比度拉偏。留白裁剪是个隐藏坑。不同人的书写习惯不一样有人写“口”字靠上有人靠下四周留白差异巨大。不做裁剪的话模型会学到“字在图片里的位置”而不是“字本身”同一个字换个人写就识别错。裁剪逻辑是先找所有非背景像素的包围盒然后保留包围盒周围 10% 的边界再缩放到固定尺寸。import cv2 import numpy as np def preprocess_image(path, size(64, 64)): img cv2.imread(path, cv2.IMREAD_GRAYSCALE) # 背景是白色(255)反色让前景变白 img 255 - img # 找非零像素的包围盒 coords np.argwhere(img 30) if len(coords) 0: return np.zeros((size[0], size[1], 1), dtypenp.float32) y0, x0 coords.min(axis0) y1, x1 coords.max(axis0) # 保留10%边界防止切掉笔画边缘 pad_y int((y1 - y0) * 0.1) pad_x int((x1 - x0) * 0.1) y0 max(0, y0 - pad_y); y1 min(img.shape[0], y1 pad_y) x0 max(0, x0 - pad_x); x1 min(img.shape[1], x1 pad_x) img img[y0:y1, x0:x1] img cv2.resize(img, size, interpolationcv2.INTER_AREA) img img.astype(np.float32) / 255.0 return img[..., np.newaxis]这个preprocess_image里两个参数值得留意。30的阈值决定哪些像素算前景对浅色纸张上的淡墨迹阈值要降到10具体值可以用一两张图片的np.percentile统计出来不要拍脑袋定。INTER_AREA在缩小时表现最好能保留笔画的边缘特征换成INTER_LINEAR会让字看起来发虚识别精度会掉一到两个点。写完这个函数不要直接进训练先把几十张图存出来看看效果。我有一次就是没检查这一步结果所有图片都被裁剪成了一条竖线模型训练了一天loss 当然不降——这是整个流程里最典型的一处翻车场景。2.3 标签编码3755 类用 one-hot 还是稀疏标签字数类别多标签编码直接影响显存占用和训练速度。one-hot 编码在 3755 类下每个标签向量有 3755 个元素64 的 batch 就要几十万浮点数虽然不算大但配合CategoricalCrossentropy计算时矩阵运算量比稀疏标签高一个量级。TensorFlow2.0 里我一般直接用整数标签加SparseCategoricalCrossentropy省内存代码还简短。关键是标签的索引要稳定训练、验证、测试三套数据必须用同一份“字到索引”的映射表。很多人踩过这个坑训练时用os.listdir排出来的顺序做映射换一台机器跑测试目录顺序变了预测结果全错。char_to_idx {ch: i for i, ch in enumerate(class_names)} idx_to_char {i: ch for ch, i in char_to_idx.items()} # 解码预测结果 pred_idx int(tf.argmax(logits, axis-1)) pred_char idx_to_char[pred_idx]这个映射表在训练结束后要存成 JSON 或 CSV和模型一起打包。部署的时候只加载模型是不够的没有映射表输出的 3755 维向量没法对应到汉字。常见做法是把idx_to_char写入模型的signature里或者单独存一个资产文件。我一般是存 JSON简单直接别人接手也容易看懂。到这里数据侧的三关就过了数据集选型和划分、图像预处理、标签编码。这三件事做完模型训练才有意义。下一个问题是怎么搭一个适合 3755 分类的网络。3. 模型搭建TensorFlow2.0 的 Keras API 搭汉字分类网络深度和宽度怎么配3.1 最小可运行 CNN先把流程跑通再谈精度面对 3755 分类很多人一上来就上 ResNet152结果显存爆了、训练一天不收敛最后连问题出在哪都说不清。我的习惯是先搭一个最浅的 CNN保证数据管道、损失函数、训练循环全链路跑通在验证集上看到 loss 在下降再逐步加深网络。最小模型用两层卷积加一个全连接层import tensorflow as tf model tf.keras.Sequential([ tf.keras.layers.Input(shape(64, 64, 1)), tf.keras.layers.Conv2D(32, 3, activationrelu, paddingsame), tf.keras.layers.MaxPooling2D(2), tf.keras.layers.Conv2D(64, 3, activationrelu, paddingsame), tf.keras.layers.MaxPooling2D(2), tf.keras.layers.Flatten(), tf.keras.layers.Dense(128, activationrelu), tf.keras.layers.Dense(3755, activationsoftmax), ]) model.compile( optimizertf.keras.optimizers.Adam(1e-3), losstf.keras.losses.SparseCategoricalCrossentropy(), metrics[accuracy], )这个网络参数量只有几百万在 64×64 输入上单 epoch 跑得很快。paddingsame保留边缘信息对手写体这种笔画可能贴边的图很重要。第一层卷积核 32、第二层 64是经验值如果发现验证集不降先改成 16/32 看是不是数据问题不要一上来就调大网络。这里有个细节最后一层Dense(3755)的输出是不是需要softmax激活。如果接SparseCategoricalCrossentropy(from_logitsTrue)就不要给 softmax数值稳定性更好。我一般留from_logitsTrue推理时再对输出做softmax这样训练时避免 softmax 和 crossentropy 联合计算的精度损失。3.2 加深网络残差块、BatchNorm 和 Dropout 怎么组合最小模型能跑通之后就该面对 3755 分类的真实难度了。两层卷积提取的特征太弱直接堆卷积层会带来梯度消失残差连接几乎是必选。TensorFlow2.0 里手写一个残差块不难但有个顺序问题BatchNorm 放在激活前还是激活后效果差很多。我常用的残差块结构是Conv → BN → ReLU → Conv → BN → 残差相加 → ReLU。BN 放在卷积之后、激活之前能加速收敛。Dropout 放在残差块的输出之后而不是块内部否则会破坏残差连接的信息传递。def residual_block(x, filters, stride1, use_dropoutFalse): shortcut x if stride ! 1: shortcut tf.keras.layers.Conv2D(filters, 1, stridesstride)(shortcut) shortcut tf.keras.layers.BatchNormalization()(shortcut) x tf.keras.layers.Conv2D(filters, 3, stridesstride, paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.Conv2D(filters, 3, paddingsame)(x) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.add([x, shortcut]) x tf.keras.layers.ReLU()(x) if use_dropout: x tf.keras.layers.Dropout(0.2)(x) return x注意stride参数当特征图尺寸减半时shortcut 路径也要用Conv2D(1, strides2)把尺寸和通道数对齐否则add会报错。这是初学者最常见的报错点TensorFlow2.0 会提示 shape 不匹配但新手往往不知道是 shortcut 的问题。Dropout 的力度我在 3755 分类上一般取 0.2 到 0.3。太大会欠拟合太小防不住过拟合。汉字手写体数据量不大几千类、每类几百张正则化是必须的不然训练 acc 冲到 99%、验证 acc 只有 40% 的场景天天见。3.3 损失函数与优化器Adam、SGD、标签平滑的取舍3755 分类的损失函数硬编码用稀疏交叉熵就行。但真正影响收敛的是输出层的数值范围和优化器的配合。我试过三种组合Adam 默认参数直接训、SGD 加 momentum、Adam 加标签平滑。结论是Adam 收敛快但后期精度上限不如 SGD 加 momentum。具体来说前 30 个 epoch 用 Adam 把 loss 压到 1 左右然后切换到 SGDmomentum学习率降到 1e-3再跑 20 个 epoch验证 acc 能比全程 Adam 高 2 到 3 个百分点。这个现象在汉字手写体上尤其明显因为类别多、类间相似度高Adam 的适应性学习率在后期容易在局部极小值附近震荡。标签平滑对这类任务效果显著。3755 类里形近字特别多比如“己、已、巳”用label_smoothing0.1让 softmax 的输出不要过于自信能减少过拟合。TensorFlow2.0 里直接传参loss tf.keras.losses.SparseCategoricalCrossentropy( from_logitsTrue, label_smoothing0.1 )label_smoothing取 0.1 或 0.15再大就会让模型学不进去。它等价于把正确标签的概率从 1 降到 0.90.1 分给其他类别。这个技巧在数据量小、类别多的时候比单纯加 Dropout 更有效。4. 训练与调参TensorFlow2.0 跑汉字识别的完整配置与命令4.1 tf.data 管道图片读取、缓存与预取的参数设置训练数据准备好了、模型也搭完了接着就要把数据高效地喂给 GPU。很多人直接在fit里传 NumPy 数组遇到几万张图片内存就爆了。TensorFlow2.0 的tf.data管道能解决这个问题关键是几个参数的配合。核心是map、shuffle、batch、prefetch这四个转换。map负责图像预处理shuffle打乱顺序batch打包prefetch让数据加载和 GPU 计算并行。prefetch(tf.data.AUTOTUNE)让框架自动决定预取缓冲区大小省心。def load_image(path, label): image tf.io.read_file(path) image tf.image.decode_jpeg(image, channels1) image tf.image.resize(image, (64, 64)) image tf.cast(image, tf.float32) / 255.0 return image, label train_ds ( tf.data.Dataset.from_tensor_slices((train_paths, train_labels)) .shuffle(10000) .map(load_image, num_parallel_callstf.data.AUTOTUNE) .batch(64) .prefetch(tf.data.AUTOTUNE) )这里的shuffle(10000)缓冲区大小要跟数据集规模匹配。缓冲区太小shuffle 不充分每个 batch 里的类别分布不随机太大内存占用高。我一般设为总样本数的 10% 到 20%几万样本就设 5000 到 10000。num_parallel_calls设成AUTOTUNEmap 操作会用多线程并行预处理图片。如果 CPU 核数多但 GPU 闲瓶颈在 map这个参数能明显提吞吐。还有一个容易忽略的from_tensor_slices会把路径和标签加载进内存路径长度不要用 Python 字符串统一用tf.constant或者直接传列表避免 TensorFlow 反复做类型推断。4.2 训练循环ModelCheckpoint、EarlyStopping、TensorBoard 的配合训练过程里最重要的不是盯着终态 acc而是让模型在训练中可观测、可恢复。我每次都挂三个回调ModelCheckpoint保存最优权重、EarlyStopping在验证 loss 不再降时停掉、ReduceLROnPlateau在平台期降学习率。TensorBoard 用来盯曲线和找翻车点。callbacks [ tf.keras.callbacks.ModelCheckpoint( best_model.ckpt, monitorval_accuracy, save_best_onlyTrue ), tf.keras.callbacks.EarlyStopping( monitorval_loss, patience8, restore_best_weightsTrue ), tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience3, min_lr1e-6 ), tf.keras.callbacks.TensorBoard(log_dir./logs), ] history model.fit( train_ds, validation_dataval_ds, epochs50, callbackscallbacks, )patience8的意思是验证 loss 连续 8 个 epoch 不下降就停。restore_best_weightsTrue特别重要否则训练结束后保存的是最后一步的权重而不是最优的——这个细节能让人白跑几小时。ReduceLROnPlateau的factor0.5是每次降一半patience3是 3 个 epoch 没降就触发配合 EarlyStopping 的 8先降学习率再决定停不停比直接停效果要好。TensorBoard 的log_dir每轮实验换一个目录不然多轮训练曲线叠在一起没法看。启动tensorboard --logdir./logs后重点看loss和accuracy两条曲线是否同步如果 loss 下降但 acc 不动大概率是标签编码和数据不对齐回头检查映射表。4.3 五个关键超参数学习率、batch、图像尺寸、epoch、类别权重训练到后期拼的就是这几个超参数的组合。我把常用的取值和边界整理成表格参数推荐值边界与影响学习率1e-3 起平台期降到 1e-4超过 3e-3 容易发散低于 1e-5 收敛极慢batch size64 起步128 封顶太大容易显存溢出太小 batch 内类别分布不均图像尺寸64×64 起步128×128 封顶64 省显存但细节丢失128 精度高但训练慢一倍epoch30 到 60加了 EarlyStopping 就不用纠结上限类别权重生僻字 1.5 到 2.0一定是倍数不是偏移量batch size 是这里面最值得调的一个。汉字手写体类别多每个 batch 最好覆盖尽量多的类64 的 batch 在 3755 类里覆盖率很低但增大 batch 会显存溢出。有个折中做法是梯度累积用小 batch 多次迭代再更新一次梯度效果接近大 batch 又省显存。TensorFlow2.0 里用GradientTape手写训练循环才能做梯度累积Keras 的fit不支持这是个边界要提前知道。图像尺寸的影响我实测过64×64 的验证 acc 大约在 85% 到 88%128×128 能到 92% 左右代价是训练时间翻倍。输入尺寸是精度和速度最直接的权衡点业务场景里如果单张推理时延要求苛刻就用 64否则直接上 128。epoch 不需要一个固定值。EarlyStopping(p8)加上ReduceLROnPlateau(p3)让训练在过度拟合之前自动停。我见过有人硬跑 200 个 epoch验证 acc 从 90% 掉到 85%这是典型的过拟合——EarlyStopping 存在的意义就是拦这种操作。5. 避坑手册TensorFlow2.0 汉字识别最常见的 5 个坑与排查5.1 loss 不降反升训练 acc 在 10% 附近打转现象训练了 20 个 epochloss 在 2.5 到 4 之间波动acc 不到 20%。原因最常见的是标签和输出没对齐。3755 类用SparseCategoricalCrossentropy时标签值必须是从 0 到 3754 的整数如果映射表里混入了无用的类别名或者某些类索引从 1 开始loss 就会一直压不下去。还有一种情况是图像预处理把笔画全裁掉了模型看到的输入接近全黑或全白学不到特征。解决先跑一个 batch 的前向传播把logits和标签的形状、取值范围打印出来。再随机挑几张预处理后的图肉眼确认笔画可见。这两步能筛掉 80% 的“loss 不降”问题。别一上来就调学习率那是玄学先把数据和标签确认了再动优化器。5.2 训练 acc 98%验证 acc 40%——过拟合的三种解法现象训练集 acc 一路冲到 98%验证集停留在 40%。原因3755 类、每类样本数从几十到几百模型参数几百万严重过拟合。这是汉字手写体识别最经典的翻车场景几乎每个人都要撞一次。解决按优先级做三件事。第一加数据增强随机旋转±10 度、缩放 0.9 到 1.1、平移 2 像素这些变换对汉字书写变异很有效tf.keras.layers.RandomRotation和RandomZoom直接用。第二加 Dropout全连接层前后都加 0.2 到 0.3。第三把图像尺寸从 128 降到 64参数少一点过拟合轻一点。三件做完验证 acc 一般能回到 70% 以上再考虑换更强的网络。5.3 生僻字识别率接近 0常用字准确率还行现象测试时“的、了、我”这类字识别不错但“龋、癖”这类生僻字几乎全错。原因数据不均衡。HWDB 里生僻字样本少每类只有十几张甚至几张。CNN 对每类的学习能力跟样本量成正比样本少的类几乎学不到可泛化的特征。解决两类方法配合。训练时给生僻字加类别权重把生僻字样本的 loss 放大到常用字的 2 倍让优化器更关注这些类。同时给生僻字做更强的数据增强相当于用人工方式扩充样本。还有一招是把相似写法合并成同一类比如把不太常用的繁体写法映射到常用简体字上减少类别数。5.4 GPU 利用率只有 30%训练一天出不了结果现象nvidia-smi看 GPU 利用率只有 30% 左右显存没满训练速度比预期慢很多。原因数据管道跟不上 GPU 的消费速度。最常见的是没有prefetch或者map里做了太重的图像解码和裁剪CPU 成了瓶颈。解决按顺序做四件事。在batch后面加prefetch(tf.data.AUTOTUNE)。把图像裁剪从 Python 的cv2改成 TensorFlow 的tf.image操作避免py_function反复调 Python 解释器。把解码后的图片缓存到内存或磁盘第一次读完后不用再解码。最后才是检查 GPU 本身是不是被其他任务占用了。5.5 保存的模型在推理时速度巨慢且结果不对现象训练时精度不错导出为 SavedModel 之后单张推理要几百毫秒而且有些字预测错了跟训练时的结果对不上。原因好几件事叠加。推理时输入尺寸和训练不一致模型 resize 之后特征对不上没有批处理单张跑没利用 GPU 并行保存的是model.save()而不是tf.saved_model.save签名没定义好。解决导出时固定输入签名尺寸跟训练一致batch_size设成动态。推理时一次处理一批图片分批的 batch size 用 8 到 16时延能降一个量级。如果对时延要求很高考虑转成 TensorFlow Lite 量化版在 CPU 上也能跑到毫秒级精度损失在 1 到 2 个点以内。6. 端到端验证用 SavedModel 跑通一条完整的手写图片推理链最后一步是把训练好的模型导出并验证。我每次都会做一次端到端测试拿一张现场拍的手写图片走完整的预处理、推理、解码流程确认输出的汉字是对的才敢把这套流程交付出去。import tensorflow as tf import json # 导出固定输入形状方便部署后按签名调用 tf.function(input_signature[tf.TensorSpec([None, 64, 64, 1], tf.float32)]) def serve(x): return {logits: model(x, trainingFalse)} tf.saved_model.save(model, ./saved_model, signaturesserve) # 加载模型和字符映射表 loaded tf.saved_model.load(./saved_model) infer loaded.signatures[serving_default] with open(idx_to_char.json) as f: idx_to_char {int(k): v for k, v in json.load(f).items()} # 端到端推理 img preprocess_image(test_sample.jpg, size(64, 64)) out infer(tf.constant(img[None, ...], dtypetf.float32)) pred int(tf.argmax(out[logits][0])) print(f预测结果{idx_to_char[pred]})这段代码里preprocess_image必须和训练时用完全一样的参数哪怕阈值从30改成40都会让输入分布偏移精度悄悄下降。我把这个函数和模型一起打包不单独复制一份——复制出来的版本经常跟训练版对不上这是血的教训。最后说一个习惯每次训练之后我会把验证集上预测和真实标签不同的 case 打印出来凑成一张拼接图看看。形近字错判占了 80% 以上这说明模型已经学到了书写形态但还没学到足够的上下文区分能力。这个观察基本决定了这个项目值不值得投入更多资源——如果业务场景里形近字识别差那就要考虑加一个字符级别的语言模型来兜底纯视觉模型的上限就在那里。验证做完这套 TensorFlow2.0 的汉字手写体识别流程就可以收尾了。中间踩过的坑大多集中在数据预处理和标签映射模型结构反而不是最难的部分。希望这些经验帮到你少熬几个调 loss 的夜。本文还有配套的精品资源点击获取