ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于TensorFlow2.0的CNN手写汉字识别系统实现

基于TensorFlow2.0的CNN手写汉字识别系统实现 简介针对中文汉字手写体识别这一经典图像分类任务本资源提供了基于TensorFlow2.0的完整工程实现特别适合计算机、电子信息等专业的学生用于课程设计、期末大作业或毕业设计。资源共94个文件、6.71MB核心为6个Python源码文件覆盖CNN网络搭建、训练脚本、测试脚本、数据转换与演示程序等环节72张预测结果图片直观展示模型在各汉字上的识别输出另有readme说明与CASIA-HWDB数据集下载及转换脚本可帮助复现从原始手写样本到模型评估的完整链路。包内还附有样例图片与演示代码方便快速上手体验直接观察不同汉字的识别效果。已有89人学习无论作为算法实战练习还是项目立项演示这套结构清晰、可直接运行的项目都具有明确的参考价值。1. TensorFlow2.0 汉字手写识别为什么直接调用 OCR 引擎不够做过中文 OCR 项目的朋友应该都有体会印刷体识别已经非常成熟但手写体尤其是自由书写的汉字仍然是工业界的硬骨头。市面上的云 OCR 接口对手写体效果尚可但厂商标注的手写训练集通常集中在银行票据、快递面单这类垂直场景遇到自己采集的笔迹数据准确率会明显下降。而且毕设或课程设计往往有「必须自己实现算法」的硬指标调用现成 API 不符合要求。这个基于 TensorFlow2.0 的中文汉字手写体识别项目把数据解析、TFRecord 转换、CNN 模型构建、训练、单字推理的完整链路都放进了源码里特别适合需要快速跑通实验并在此基础上改网络结构做对比实验的场景。整个工程不依赖第三方 OCR 框架模型是纯自己定义的卷积网络数据来自 CASIA-HWDB 标准数据集一套流程走下来对深度学习算法落地中遇到的数据工程问题会有非常直接的认识。2. 数据管线从 CASIA-HWDB 原始 GNT 到 TFRecord2.1 数据集格式与解析逻辑项目依赖的 CASIA-HWDB 数据集分为 1.0 和 1.1 两个版本样本文件以.gnt为后缀。GNT 并不是常见的图像格式而是一种自定义的二进制容器每个.gnt文件里包含多个手写样本每个样本由「样本长度 标签字符GBK 编码 位图宽度 位图高度 灰度像素数组」组成。直接用 OpenCV 读不了这种文件所以项目里专门提供了casia_hwdb.py负责解析。解析思路是把二进制流按固定偏移量切割。读取时先取前 4 字节作为样本总长再取 2 字节作为标签区长度标签区之后就是位图尺寸和像素数据。由于每个.gnt文件可能包含上百个样本需要一个循环持续读取直到文件末尾。项目中的get_hwdb_1.0_1.1.sh是下载脚本它会自动拉取两个版本的压缩包并解压到本地目录。我在实际跑数据时通常会把 1.0 和 1.1 合并使用因为单版本数据量大约在 240 万字左右合并后类别覆盖更全训练出的模型对生僻字的泛化能力会好一些。# casia_hwdb.py 核心解析逻辑 def read_gnt_file(gnt_path): samples [] with open(gnt_path, rb) as f: while True: length_bytes f.read(4) if not length_bytes: break length int.from_bytes(length_bytes, little) tag_bytes f.read(2) tag tag_bytes.decode(gbk) width int.from_bytes(f.read(2), little) height int.from_bytes(f.read(2), little) bitmap f.read(width * height) samples.append((tag, bitmap, width, height)) return samples这段代码按小端序逐字段读取样本。int.from_bytes的第二个参数指定字节序GNT 文件采用小端存储不能漏。标签区固定 2 字节是因为 GBK 编码下一个汉字恰好占 2 字节。宽度和高度各占 2 字节理论上支持最大 65535×65535 的位图但实际 CASIA 数据集里单字图片通常在 100×100 像素到 300×300 像素之间。位图数据是灰度值数组长度等于 width 乘以 height没有额外的压缩直接读取即可。2.2 类别映射与字符表生成解析出标签后需要把所有出现过的汉字映射为从 0 开始的整数索引这一步在训练分类网络时是必须的。项目根目录下有一个characters.txt每一行一个汉字充当类别字典。训练脚本会按行读取这个文件构建{汉字: 索引}的映射表。如果你的数据集里出现了characters.txt之外的字符转换脚本会直接报错这是好事——提前暴露问题比训练到一半才发现类别数量不匹配要省事得多。生成字符表有一个常见坑不要用集合去重后直接写文件因为集合的遍历顺序不稳定多个.gnt文件解析出的字符顺序可能不同类别索引就会乱。正确做法是先收集所有唯一字符排序后再写入。排序建议按 Unicode 码位排这样同一个字符在所有运行环境下都得到同一个索引。项目里的characters.txt已经生成好但如果要换数据集需要重新走一遍这个流程。2.3 TFRecord 转换与数据 Shuffleconvert_to_tfrecord.py负责把解析出的位图数据序列化为 TFRecord 格式。TFRecord 是 TensorFlow 的标准数据容器核心优势是能够把零散的图片文件合并成少数几个大文件减少随机读取时磁盘 I/O 开销。对大规模训练来说这个收益非常明显。# convert_to_tfrecord.py 关键写入逻辑 def write_tfrecord(samples, output_file, char_to_idx): with tf.io.TFRecordWriter(output_file) as writer: for tag, bitmap, width, height in samples: if tag not in char_to_idx: continue image np.frombuffer(bitmap, dtypenp.uint8).reshape(height, width, 1) image tf.image.resize(image, (64, 64)) feature { image: tf.train.Feature(bytes_listtf.train.BytesList(value[image.tobytes()])), label: tf.train.Feature(int64_listtf.train.Int64List(value[char_to_idx[tag]])), } example tf.train.Example(featurestf.train.Features(featurefeature)) writer.write(example.SerializeToString())这段代码有几个值得注意的点。tf.image.resize把原始大小不一的位图统一缩放到 64×64理由在于模型输入的张量形状必须固定。缩放时默认用的是双线性插值对手写笔迹来说效果尚可但我在实验中发现部分笔画较细的汉字经过缩放后会出现断裂这会影响后续的特征提取。如果你追求更高精度可以在写入 TFRecord 之前先做一次中值滤波再进行缩放能明显改善细笔画的连续性。标签直接存整数索引而非字符串可以把 TFRecord 的体积控制在更小的范围读取时也省去解码字符串的开销。转换完成后训练脚本读取 TFRecord 时会通过tf.data.TFRecordDataset做流水线处理包括shuffle、batch、repeat。shuffle的 buffer size 建议设定为整个数据集样本数的十分之一以上否则随机性不足模型容易在 epoch 边界处出现收敛震荡。3. CNN 模型设计cnn_net.py 里的结构与参数选择3.1 网络结构拆解cnn_net.py中定义的模型是典型的卷积分类网络输入为 64×64×1 灰度图输出为类别数量维度的 softmax 概率分布。网络结构在思路上接近 VGG16 的简化版连续堆叠小卷积核配合最大池化逐步扩大感受野最后展平接入全连接层。# cnn_net.py 简化版结构 def build_cnn(num_classes, input_shape(64, 64, 1)): model tf.keras.Sequential([ tf.keras.layers.Conv2D(32, (3, 3), activationrelu, paddingsame, input_shapeinput_shape), tf.keras.layers.Conv2D(32, (3, 3), activationrelu, paddingsame), tf.keras.layers.MaxPooling2D(pool_size(2, 2)), tf.keras.layers.Conv2D(64, (3, 3), activationrelu, paddingsame), tf.keras.layers.Conv2D(64, (3, 3), activationrelu, paddingsame), tf.keras.layers.MaxPooling2D(pool_size(2, 2)), tf.keras.layers.Conv2D(128, (3, 3), activationrelu, paddingsame), tf.keras.layers.Conv2D(128, (3, 3), activationrelu, paddingsame), tf.keras.layers.MaxPooling2D(pool_size(2, 2)), tf.keras.layers.Flatten(), tf.keras.layers.Dense(256, activationrelu), tf.keras.layers.Dropout(0.5), tf.keras.layers.Dense(num_classes, activationsoftmax) ]) return model每一层的作用可以按阶段划分。两个连续 3×3 卷积等价于一个 5×5 卷积的感受野但参数量更少而且中间多了一次非线性变换表达能力更强。paddingsame保证特征图尺寸不缩水让池化层来控制空间维度。三次最大池化后64×64 的输入变成 8×8 的特征图此时每个位置的感受野已经覆盖了整个输入足够提取全局结构信息。全连接层前的 Dropout 是防过拟合的关键汉字类别数动辄上千全连接层的参数量非常大不加 Dropout 基本几轮迭代就过拟合。我在跑这个模型时第一反应是考虑是否需要引入 Batch Normalization。从原理上讲BN 能加速收敛且对梯度消失有抑制作用但在小 batch size 训练手写识别时BN 统计量不稳定反而会带来精度波动。项目里没有用 BN改用调低初始学习率的方式保证训练稳定这个选择在 64 的 batch size 下是合理的。如果你把 batch size 提到 128 以上再叠加 BN通常能压榨出 12 个百分点的提升。3.2 各层参数量与计算瓶颈层名输出尺寸参数量说明Conv2D (32, 3×3)64×64×32320输入 1 通道卷积核参数量为 3×3×1×32Conv2D (32, 3×3)64×64×329248输入 32 通道计算量明显增大MaxPooling2D32×32×320无参数空间减半Conv2D (64, 3×3)32×32×6418496通道数翻倍保持信息量Conv2D (64, 3×3)32×32×6436928计算量峰值区域MaxPooling2D16×16×640空间减半Conv2D (128, 3×3)16×16×12873856通道数翻倍Conv2D (128, 3×3)16×16×128147584共振峰区域MaxPooling2D8×8×1280空间减半Dense 2562562097152全连接层参数量占网络总量近 80%Dense num_classesnum_classes256×类别数假设 3755 类时约 96 万参数从表中能看出卷积层的参数量是逐步递增的但全连接层的参数量占绝对主导。以 3755 个常用汉字为分类目标全连接层贡献了超过 200 万的参数量。这也是为什么 Dropout 放在全连接层之前——它直接作用于参数量最密集的区域正则化效率最高。如果你发现显存或内存吃紧优先砍全连接层的维度从 256 降到 128精度损失通常在一个百分点以内。训练阶段我会额外监控每个小 batch 的分类准确率。如果出现前几轮准确率始终在 0.1% 以下等于随机猜测优先怀疑 TFRecord 的标签映射错位而不是网络结构问题。这种问题在项目里出现的概率不低因为characters.txt的排列顺序必须和convert_to_tfrecord.py里生成索引时完全一致。4. 训练流程与损失函数调试train_simple.py 的迭代细节4.1 训练入口与超参数配置train_simple.py是项目的主训练脚本它串联了数据读取、模型构建、损失函数定义和训练循环。脚本名称里的 simple 指的是代码结构简单不含分布式策略等复杂封装适合单卡训练调试。# train_simple.py 训练循环要点 def train(): train_ds tf.data.TFRecordDataset(data/train.tfrecord) train_ds train_ds.map(parse_tfrecord, num_parallel_callstf.data.AUTOTUNE) train_ds train_ds.shuffle(10000).batch(64).prefetch(tf.data.AUTOTUNE) model build_cnn(num_classeslen(characters)) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-4), losstf.keras.losses.SparseCategoricalCrossentropy(), metrics[accuracy] ) lr_scheduler tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience3, min_lr1e-6 ) model.fit(train_ds, validation_dataval_ds, epochs30, callbacks[lr_scheduler, tf.keras.callbacks.ModelCheckpoint(models/best.h5)])关键参数集中在前几行。num_parallel_callstf.data.AUTOTUNE让 TensorFlow 自动选择解析线程数这个参数在数据量大的时候对训练吞吐影响很大。shuffle(10000)表示从缓冲区随机取样本缓冲区越大随机性越好但内存占用也越高手写识别样本单张只有 64×641 万张约占 400MB 内存属于可接受范围。batch(64)在大部分 GPU 上都能跑满如果是显存只有 4GB 的旧显卡需要降到 32。prefetch让数据加载和 GPU 计算重叠执行是性能优化的常用手段。学习率初始值设为1e-4是我比较认可的选择。手写识别任务的类别数多且部分汉字结构相似学习率太大会导致 loss 在震荡中缓慢下降太小则收敛速度让人着急。ReduceLROnPlateau回调设置了patience3意思是验证集 loss 连续 3 个 epoch 不下降就把学习率减半最低降到1e-6。这个机制比固定学习率更耐受小数据集上的波动。4.2 损失函数与评价指标的选择项目使用的是SparseCategoricalCrossentropy对应标签为整数索引不需要做 one-hot 编码。之所以不用CategoricalCrossentropy是因为 one-hot 编码在类别数量上千时会生成超大稀疏矩阵浪费内存且计算效率低下。如果你改用CategoricalCrossentropy记得同时在数据解析函数里把标签改为 one-hot 形式两者混用会在训练时报 shape mismatch 错误。评价指标直接使用accuracy但这里有一个值得注意的细节汉字手写识别的类别分布不完全均匀常用字的样本量远大于生僻字。整体准确率容易被高频类别主导想要更客观评估模型对每一类汉字的识别能力需要额外记录 per-class accuracy。我在跑完项目后写了一个评估脚本用sklearn.metrics.classification_report输出每一类的精确率、召回率和 F1 值用来定位模型在哪些字上表现最差。结果通常集中在笔画极多或结构左右对称的汉字上这是模型在有限容量下的正常表现不必过度焦虑。4.3 早停策略与模型保存模型保存用的是ModelCheckpoint设置monitorval_loss并保存最优权重。项目里保存的models目录结构是 Keras 的.h5格式注意 H5 文件只保存权重和网络结构不保存优化器状态。如果中断训练想要恢复需要使用完整的tf.train.Checkpoint方式保存。train_simple.py里没有显式加入EarlyStopping回调我在复现时手动加了一个early_stop tf.keras.callbacks.EarlyStopping( monitorval_accuracy, patience5, restore_best_weightsTrue )restore_best_weightsTrue是关键否则 early stop 触发的时刻不一定是最优权重的时刻。加上这个回调后模型在验证集准确率连续 5 个 epoch 不提升时自动停止训练节省了大量时间。实际跑 30 个 epoch 时通常在第 1520 个 epoch 就达到最优验证准确率之后的训练对泛化能力提升有限只会在训练集上缓慢过拟合。5. 推理验证与错误样本分析demo.py 的边界情况处理5.1 单张图片的预测流程demo.py承载的是推理功能把训练好的模型加载进来对输入图片做预测并输出对应的汉字。这个脚本最值得学习的地方在于它处理了训练和推理之间的数据分布差异。# demo.py 推理核心逻辑 def predict_image(model, image_path, char_list): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (64, 64), interpolationcv2.INTER_CUBIC) img img.astype(np.float32) / 255.0 img np.expand_dims(img, axis-1) img np.expand_dims(img, axis0) probs model.predict(img)[0] top_idx np.argsort(probs)[::-1][:5] results [(char_list[i], round(float(probs[i]), 4)) for i in top_idx] return results预处理流程有两处容易踩坑。第一处是cv2.resize的插值算法。训练阶段convert_to_tfrecord.py用的是默认双线性插值虽然推理时也用了INTER_CUBIC但插值结果毕竟有差异。我建议两处都改成INTER_CUBIC保持特征分布一致。第二处是归一化方式。训练脚本中图像除以 255.0 映射到 [0,1] 区间推理代码必须采用相同操作否则模型的输入数据分布偏移输出概率会整体失真。代码里按概率从高到低取前 5 个结果而非只取最大值这是非常实用的做法。手写体识别天然存在歧义性一个「白」字写得草一点可能是「自」一个「日」字写得窄一点可能被当成「口」。给出 Top-5 结果让用户从候选里选比直接返回一个硬标签要人性化得多。5.2 错误样本分析与阈值调整项目根目录里放了大量pred_*.png命名的预测结果图这些是复现时生成的中间产物。我在逐个看过这些图片后发现错误类型大致可以归为三类相似字形混淆、笔画噪声干扰、缩放信息丢失。相似字形类错误比如「傲」和「敖」、「颁」和「颂」这类错误本质上源于视觉特征过于接近通常需要加大模型容量或引入注意力机制才能解决。笔画噪声类错误可以用数据增强中的高斯模糊和随机腐蚀来缓解让模型对细小噪声不敏感。如果项目要部署到实际应用建议在输出概率后加一个置信度阈值confidence probs[top_idx[0]] if confidence 0.6: # 低置信度返回无法识别而不是强行给出结果 return []这个阈值需要根据实际场景调节。如果业务场景允许用户重写阈值可以设高一些如果必须每次都给结果阈值可以放到 0.3 左右。没有绝对正确的阈值只有经过验证集统计后选出的合适值。5.3 模型导出与后续扩展方向项目里的models目录保存的是h5格式如果想要提升推理性能可以使用 TensorFlow 自带的模型转换工具把它导出为 SavedModel 格式再用 TensorRT 做 INT8 量化。量化后模型体积可以压缩到原来的四分之一推理速度提升 2 倍以上精度损失通常在 0.5% 以内对毕设答辩演示来说是很好的加分项。# 将 h5 模型导出为 SavedModel python -c import tensorflow as tf model tf.keras.models.load_model(models/best.h5) tf.saved_model.save(model, models/saved/) 导出后可以用tensorflow_model_server起一个 gRPC 服务把识别能力封装成微服务接口前端拿到 HTTP 响应做展示。整个工程从数据处理到训练再到推理链路完整算是入门级别里做得比较扎实的参考实现。本文还有配套的精品资源点击获取
返回列表