☰
TensorFlow手写文字识别Python源码案例解析与实战
2026/10/13 13:48:15 网站建设 项目流程

简介:这是一套基于TensorFlow框架的手写文字识别Python源码案例,面向深度学习入门者与图像识别初学者。案例以卷积神经网络(CNN)为主线,详细展示数据加载与预处理、网络结构定义、训练验证及字符预测等关键步骤,并额外提供Softmax回归模型作为对照,便于读者理解不同模型的识别效果。包内共有6个文件,以4个Python脚本为主,承担模型构建、数据处理与识别调用功能;另附1份PDF文档说明原理,以及1个嵌套压缩包用于补充材料,整体大小仅2.32MB,部署运行轻便。已有1580人学习浏览,热度可观。通过动手运行该案例,读者不仅能掌握TensorFlow搭建CNN处理图像的基本流程,还能学习数据归一化、过拟合预防与模型保存等实用技巧,为后续开展更复杂的视觉项目打下扎实基础。整个项目源码结构清晰,适合二次修改与实验。

1. tensorflow框架手写文字识别python源码案例:下载后真正要解决的问题

tensorflow框架手写文字识别python源码案例,下载回来能直接跑起来的概率其实不到一半,这一点我拆过不少项目之后深有体会。多数时候你在命令行里敲入 python train.py,两三分钟就报错,不是路径找不到就是编码对不上,然后你会开始怀疑自己的环境配置有问题。实际上,这类项目的模型结构并不神秘,真正拉开差距的是数据准备脚本、字符字典映射和训练参数设定。这个源码包的价值,在于把从原始手写图片到可用识别模型的完整链路补齐,适合想在本地快速验证手写识别效果、同时又打算后续迁移到自己数据集上训练的开发者。

2. 数据准备:从图片目录到可训练的 TFRecord 流水线

2.1 目录结构与约定:先搞清图片、标签、字典怎么对得上

拿到源码包第一步不是打开 train.py 看模型,而是先把目录结构梳理清楚。常见做法是根目录下放着 image_data、tfrecords、models、utils 这几个子目录,train.py 和 predict.py 在最外层。图片文件名的命名方式通常有两种:一种是文件名带汉字前缀,比如“中_001.png”,另一种是每个字符建一个子目录,目录名本身就是标签。

我拆的这个案例包用的是文件名前缀模式,对于几十到几百个字符的小规模验证集足够用,不需要额外维护一套文件夹索引。但如果你打算把字符集扩大,我建议改成子目录模式,新增类别时不用改文件名,只需要加一个文件夹。这里有一个容易被忽略的约定:字符集合要在第一次训练前固定下来,之后不要随便增删。训练时标签列表的顺序一旦变化,模型输出层的类别 id 和汉字映射就对不上了,推理阶段输出全是乱的。

我一般会把字符列表单独存成一个文本文件,每一行一个字符,train.py 和 predict.py 都从同一个文件读取。这样至少能保证两边的字典是同一份,不会出现训练时用的是第 15 个字符,推理时却把第 15 个字符当成另一个字的情况。目录结构参考如下:

tensorflow-handwrite/ ├── image_data/ │ ├── 中_001.png │ ├── 中_002.png │ ├── 国_001.png │ └── ... ├── labels.txt ├── train.py ├── predict.py ├── utils/ │ ├── preprocess.py │ └── tfrecord_io.py └── checkpoints/

labels.txt 和 image_data 的对应关系是项目的命脉。你可以在训练前跑一个脚本检查一下:读入 labels.txt 的每一行,确认 image_data 目录下存在对应的图片文件,顺便统计每个字符的样本数量。这个检查花不了两分钟,却能避免训练到最后才发现某个类别图片路径写错,导致准确率莫名少几个百分点。

2.2 预处理脚本:灰度化、归一化与大小统一

手写文字识别的预处理其实就三件事:去颜色、转浮点、统一尺寸。汉字图像的颜色信息对识别任务几乎没有贡献,所以把三通道 RGB 转成单通道灰度图后,模型的计算量直接降三分之一。归一化用除以 255.0 就行,不一定非要再做零均值标准化。手写数据集里就包含了不同书写环境下的亮度差异,保持 0 到 1 的分布反而有利于泛化。

统一尺寸这一项,案例包里默认是 64x64。这个分辨率对手写单字来说是够用的,汉字结构虽然密,但 64x64 已经能保留笔画的粗细变化。再往上加到 128x128,训练时间会翻两倍到三倍,而准确率提升往往不超过一个百分点,性价比不高。

import cv2 import numpy as np def preprocess_image(image_path, target_size=(64, 64)): # 以灰度模式读取,避免后续还要拆通道 img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f"图片读取失败: {image_path}") # 用 INTER_AREA 做缩放,保留笔画边缘信息 img = cv2.resize(img, target_size, interpolation=cv2.INTER_AREA) # 归一化到 0~1,并补充 channel 维度 img = img.astype(np.float32) / 255.0 img = np.expand_dims(img, axis=-1) return img

这里用 INTER_AREA 是做了一点考量的。INTER_AREA 在缩小图像时按区域平均像素,相比双线性插值能保留更多的笔画边缘,对汉字这种边缘信息密集的图像更友好。如果你手里的案例包用的是其他插值方式,训练效果不一定差,但如果发现字符细笔画在缩放后变淡,可以先检查这一步。expand_dims(img, axis=-1)是把 shape 从 (64, 64) 变成 (64, 64, 1),因为卷积层期望的输入张量是四维的,最后一维表示通道数。

2.3 TFRecord 序列化与 tf.data 读取参数

图像处理好之后,建议你走一遍 TFRecord 流程,而不是训练时直接读原始图片。TFRecord 是 tensorflow 原生的二进制序列化格式,读取时走独立的数据管线,不会和模型训练抢 CPU 资源。几百张图片的时候区别不明显,数据集上万张之后,每轮 epoch 的耗时差距可能拉到两倍以上。

import tensorflow as tf def write_tfrecord(image_list, label_list, output_path): writer = tf.io.TFRecordWriter(output_path) for img_array, label_id in zip(image_list, label_list): # img_array 是预处理后的 uint8 数组,转成字节流存入 feature = { "image_raw": tf.train.Feature( bytes_list=tf.train.BytesList(value=[img_array.tobytes()]) ), "label": tf.train.Feature( int64_list=tf.train.Int64List(value=[label_id]) ), } example = tf.train.Example( features=tf.train.Features(feature=feature) ) writer.write(example.SerializeToString()) writer.close()

写入时有两个细节容易踩坑。第一,tobytes()序列化的是内存里的一维字节流,读取之后必须先用tf.io.decode_raw把字节流还原成数组,不能直接 reshape,否则拿到的就是乱码。第二,label 字段用 int64 而不是 int32,TFRecord 的 int64 在 CPU 上读取更省事,不用额外做类型转换。

训练脚本侧用 TFRecordDataset 读取,配合 map 和 batch 构建输入管线:

def parse_tfrecord(example): feature_desc = { "image_raw": tf.io.FixedLenFeature([], tf.string), "label": tf.io.FixedLenFeature([], tf.int64), } parsed = tf.io.parse_single_example(example, feature_desc) image = tf.io.decode_raw(parsed["image_raw"], tf.uint8) image = tf.reshape(image, [64, 64, 1]) image = tf.cast(image, tf.float32) / 255.0 return image, parsed["label"] dataset = tf.data.TFRecordDataset(["train.tfrecords"]) dataset = dataset.map(parse_tfrecord).shuffle(2048).batch(64)

parse 函数里 FixedLenFeature 的两个参数分别表示字段名称和数据类型,因为 TFRecord 是把特征写成一个协议缓冲,读取时要通过这个描述把字段解包出来。shuffle 的缓冲区大小不是越大越好,设置为训练集样本数的五分之一左右比较合理:太小,随机性不够,每个 batch 里的类别分布容易偏;太大,内存占用上去了,小数据集反而有溢出风险。

提示:如果把 TFRecord 里的图像直接存成了 float32,文件体积会是 uint8 的四倍。我一般写入时用 uint8,读取后再转 float32,磁盘占用和训练速度都更理想。

3. 模型搭建:为手写汉字设计的 CNN 结构与参数选择

3.1 卷积块设计:深度、卷积核数量与池化

手写汉字识别的基础网络结构不复杂,通常就是卷积层加池化层重复几次,最后接全连接输出。很多人容易陷入“模型越深越好”的误区,但手写单字的结构相对固定,笔画特征在两层卷积加一层池化的组合重复三次之后基本就提取得差不多了。我见过有人直接套用几十层的分类网络,参数数量翻了几十倍,小数据集上训练出来的准确率反而不如轻量网络。

这里的核心原因是:汉字识别本质上是一个细粒度分类任务,每个字符的区分度主要来自局部笔画组合,而不是全局语义。所以卷积核的感受野不需要覆盖整张图,3x3 的卷积核堆叠两层的感受野是 5x5,已经能覆盖常见笔画的交接结构。我一般会写一个可复用的卷积块:

import tensorflow as tf def conv_block(x, filters, kernel_size=(3, 3)): # 两层 3x3 卷积,等效于一个 5x5 感受野,参数更少 x = tf.keras.layers.Conv2D(filters, kernel_size, padding="same", activation="relu")(x) x = tf.keras.layers.Conv2D(filters, kernel_size, padding="same", activation="relu")(x) # 池化把特征图尺寸减半,保留主要结构 x = tf.keras.layers.MaxPooling2D(pool_size=(2, 2))(x) return x def build_model(num_classes=100): inputs = tf.keras.Input(shape=(64, 64, 1), name="handwrite_input") x = conv_block(inputs, 32) x = conv_block(x, 64) x = conv_block(x, 128) # 放弃 Flatten 接全连接,改用全局平均池化 x = tf.keras.layers.GlobalAveragePooling2D()(x) x = tf.keras.layers.Dropout(0.3)(x) outputs = tf.keras.layers.Dense(num_classes, activation="softmax")(x) model = tf.keras.Model(inputs, outputs) return model

卷积核数量从 32 增加到 128,是因为三次池化之后特征图的边长从 64 降到了 8,通道数可以适当增加,让每个位置能表达更丰富的特征组合。kernel_size 固定 3x3,padding 用 same,这样池化前特征图尺寸不变,边缘信息不会被过早丢掉。如果你看到案例包里用的是 Flatten 接全连接的写法,也别着急换,对于万级以下样本量,全局平均池化的过拟合风险确实更低,参数量对比可以参考下面这张表:

输出层方案参数量量级过拟合风险适用场景
Flatten + Dense(1024)数千万较高样本量十万以上
GlobalAveragePooling + Dense(num_classes)数百万较低样本量千到万级

3.2 全连接层与 Dropout:防止过拟合的设置细节

如果案例包里保留了 Dense(512) 这层,我建议把神经元数量控制在 256 到 512 之间。手写识别任务最后的特征是局部笔画的组合,不需要像图像分类那样学习大规模的全局语义关系。全连接层的参数量占了整个模型的大头,512 个神经元接在 Flatten 层之后,参数量动辄上千万,而实际起作用的可能只是其中的一小部分连接。

Dropout 位置也有讲究。放在最后一个全连接层之后效果最明显,因为那个位置的神经元数量最多,最容易过拟合。Dropout 的失活比例设置在 0.3 到 0.5 之间比较合适。太小,正则化效果不明显;太大,训练收敛速度明显变慢,甚至出现欠拟合。

数据增强在这个任务里也值得做。手写汉字的笔画会出现轻微平移和粗细变化,单纯靠原始样本训练容易记住训练集的书写习惯。常见做法是在训练时对图像做随机旋转 5 度以内、随机缩放 0.9 到 1.1 倍、随机平移两个像素:

def augment_image(image, label): # 旋转角度控制在 5 度以内,超过会破坏汉字结构 image = tf.image.random_rotation( image, 0.1, fill_mode="constant" ) # 添加一点随机亮度变化,模拟不同书写笔压 image = tf.image.random_brightness(image, 0.05) return image, label train_dataset = dataset.map(augment_image).batch(64)

旋转角度这里我特意强调 5 度以内,因为汉字的笔画方向本身是分类的重要线索。角度过大,字的形态变化太夸张,模型容易学到错误的方向特征。

3.3 损失函数与优化器:softmax 交叉熵在多分类下的实际取值

多分类任务的标准选择是 softmax 交叉熵,但 tensorflow 里有两个实现,用错会带来不小麻烦。如果标签是整数形式,比如 0、5、12 这种,损失函数用 SparseCategoricalCrossentropy;如果标签是独热编码向量,用 CategoricalCrossentropy。很多新手在数据读取时没留意标签的形态,直接套损失函数,报错出来往往是一长串 shape mismatch。

手写识别项目里标签天然是整数,因为我们的字典就是从 0 开始计数的字符列表下标。所以代码里直接这么写就行:

model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss=tf.keras.losses.SparseCategoricalCrossentropy(), metrics=["accuracy"] )

Adam 优化器在该场景下比 SGD 收敛快,而且对学习率的敏感度低,你从 1e-3 开始跑基本不会太差。但要注意,如果训练集中某些字符样本特别多,另一些特别少,模型会偏向高频类别。最简单的方法是给损失函数加类别权重:

import numpy as np def compute_class_weight(labels, num_classes): # 统计每个类别样本数,反比作为权重 counts = np.bincount(labels, minlength=num_classes) total = counts.sum() weights = { i: total / (len(counts) * max(c, 1)) for i, c in enumerate(counts) } return weights

这个权重计算的思路是:样本少的类别,每个样本的损失贡献被放大,让模型在训练时对低频字符给予更多注意力。实际操作时你可以打印一下权重值,如果发现某个类别的权重超过 10,说明那个类的样本量严重不足,光靠权重还不够,最好再补充一些该字符的变体样本。

4. 训练与断点恢复:学习率调度、checkpoint 与训练曲线解读

4.1 学习率策略与 batch size 调节

固定学习率在整个训练过程中往往不是最优解。前期损失下降快,后期容易在局部最优点附近震荡。手写汉字识别任务常见的学习率策略是阶梯衰减:每经过固定步数,学习率乘一个衰减系数。tensorflow 里内置了 ExponentialDecay 调度器:

lr_schedule = tf.keras.optimizers.schedules.ExponentialDecay( initial_learning_rate=1e-3, decay_steps=500, decay_rate=0.9, staircase=True ) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=lr_schedule), loss=tf.keras.losses.SparseCategoricalCrossentropy(), metrics=["accuracy"] )

decay_steps 设置为 500 步,意思是模型每看到 500 个 batch,学习率就会乘以 0.9。staircase=True 表示学习率呈阶梯状下降,而不是每个 step 都在变,这样更容易复现实验结果,也方便对比不同参数的效果。如果你想让学习率在训练过程中只在特定 epoch 变化,也可以改用 callback 手动修改,但 ExponentialDecay 更省事。

batch size 和学习率需要联动调整。小 batch 引入的梯度噪声反而可以帮模型跳出局部最优,但 batch 太小会导致梯度方向不稳定,训练曲线剧烈震荡。我在这个项目里一般从 32 或 64 开始,先跑几个 epoch 看看损失曲线。如果曲线很平滑但下降缓慢,可以适当调大 batch,并同步把学习率放大一点;如果损失抖动厉害,优先降低学习率而不是减小 batch。

4.2 modelcheckpoint 与恢复:避免训练半途而废

训练到一半系统重启或者手动中断,谁都遇到过。这时候如果没有 checkpoint,前面的训练都白费了。建议从一开始训练就在模型编译后加上 ModelCheckpoint 回调:

checkpoint_cb = tf.keras.callbacks.ModelCheckpoint( filepath="checkpoints/model_best_{epoch:02d}_{val_accuracy:.4f}.h5", monitor="val_accuracy", save_best_only=True, mode="max", verbose=1 ) early_stop_cb = tf.keras.callbacks.EarlyStopping( monitor="val_accuracy", patience=15, restore_best_weights=True ) history = model.fit( train_dataset, validation_data=val_dataset, epochs=50, callbacks=[checkpoint_cb, early_stop_cb] )

filepath 里的{epoch:02d}和{val_accuracy:.4f}是占位符,保存时自动替换成对应的数字,这样文件名里能直接看出是哪一轮、验证准确率是多少。save_best_only 配合 monitor 参数只保留验证集上表现最好的那一份权重,省磁盘空间。EarlyStopping 的 patience 设为 15,含义是验证准确率连续 15 轮没有提升时,自动终止训练并恢复到验证集最佳的状态。

恢复训练也不复杂。tensorflow 的model.load_weights会读取 h5 文件里的全部权重并覆盖当前模型的权重。要注意的是,加载前必须确保构建的模型结构完全一致,包括输入尺寸、卷积层数量和输出类别数。如果是在命令行里手动恢复,我一般会在加载后先打印一层权重的均值,确认加载生效再继续训练。

4.3 训练曲线怎么读:过拟合与欠拟合的判断方法

训练过程中真正的反馈是曲线本身。把训练历史存下来,画成 loss 和 accuracy 曲线:

import matplotlib.pyplot as plt def plot_history(history): plt.figure(figsize=(10, 6)) plt.plot(history.history.get("loss", []), label="train_loss") plt.plot(history.history.get("val_loss", []), label="val_loss") plt.plot(history.history.get("val_accuracy", []), label="val_accuracy") plt.xlabel("epoch") plt.ylabel("value") plt.legend() plt.savefig("training_curve.png", dpi=150)

最常见的过拟合信号是训练 loss 持续下降,而验证 loss 回升,同时验证准确率停在某个平台不再上涨。另一个典型信号是训练 loss 和验证 loss 在趋势上分开,像两根逐渐打开的剪刀,开口越大,过拟合越严重。欠拟合则表现为两者同时处于高位,下降缓慢,这时候需要增大模型容量或者延长训练轮数。

我调试时一般先看 ранний training 前 10 轮。如果前五轮验证准确率完全不升,大概率是学习率太大或者输入数据有问题;如果 loss 前几轮下降很快但后面卡住不动,说明模型容量可能不够。遇到这类情况,优先检查数据预处理,而不是继续调网络层数。

5. 避坑指南:这个项目最让人头疼的五个排查点

5.1 图片解码后全黑或者全灰:tensor 类型与通道坑

现象:训练开始后 loss 不下降,或者预测时输入一张图片,输出结果的置信度全在 0.01 左右,模型像没见过信号一样。

原因:图片读取路径没有真正把像素值喂进去。常见情况是用tf.image.decode_jpeg解码时没有指定通道,图片本身是灰度图,解码出来的形状是 (H, W) 或者 (H, W, 1),但后续代码按三通道 (H, W, 3) 处理,导致大部分维度是空值。另一种情况是归一化前把 uint8 直接除以 255,结果大部分像素值变成了 0。

解决:在解析函数里先打印image.shape和image.dtype,确认是 (64, 64, 1) 且 dtype 是 uint8,归一化操作放在cast之后再执行。如果图片是彩色的但数据本来就是灰度场景,建议读取时就强制转灰度,不要等到 tensor 运算里再转。

5.2 类别不均衡导致验证准确率上不去

现象:整体准确率看起来还可以,但打印每个类别的识别情况后,某几个字符的准确率特别低,而训练时这些字符的样本数量本身就比其他字符少一大截。

原因:验证指标被多数类主导。如果 100 个类别里有 10 个高频字符占据了 80% 的样本,模型学会高频字符就能拿到不错的总分,低频字符几乎不被优化。

解决:用上一章提到的类别权重,同时在评估阶段单独统计每个类别的准确率。如果你的验证集也是随机切分的,低频字符在验证集里可能只出现很少几次,统计结果波动大,建议按类别分层抽样构建验证集,确保每个类别在验证集里至少有 10 个样本。

5.3 显存或内存溢出与 batch size 的妥协

现象:训练脚本在运行中突然报OOM,或者进程被系统杀掉。有时候不是一开始就溢出,而是跑到第几个 epoch 才出现,让人摸不着头脑。

原因:最常见的是全连接层的参数量太大,加上中间层的激活缓存,前向传播和反向传播叠加在一起把显存塞满了。另一个容易被忽略的是 TFRecord 管线里的 shuffle 缓冲区,如果设的特别大,比如几万样本全量进内存,内存占用会直接爆掉。

解决:先把 batch size 降到 16 跑通流程,确认显存占用最高在 50% 以下,再逐步加 batch。同时把全连接层换成全局平均池化那套方案,参数量能砍掉一个量级。tensorflow 2.x 还可以开启混合精度训练,float16 的显存占用大约是 float32 的一半,实际效果基本不受影响。

5.4 不同 tensorflow 版本权重加载失败

现象:在环境 A 里训练好的 h5 文件,换到环境 B 加载时直接报错,提示无法匹配或者找不到权重。

原因:tensorflow 1.x 和 2.x 的权重保存结构不一样,tf.train.Saver生成的 checkpoint 和model.save_weights生成的 h5 不是一回事。有时候即使是同一个 tensorflow 2 大版本,小版本之间 Keras 的层构建方式变了,也会出现加载兼容问题。

解决:项目内统一用model.save_weights保存.h5文件,不要混用 SavedModel 和 h5 两种格式。加载前输出模型的model.summary(),确认每层的名字和权重 shape 与保存时的模型完全一致。最笨也最稳的方法是:先用一个只含输入层和全连接层的测试模型加载权重,能打印出权重名,再切换到完整模型结构。

5.5 中文标签乱码与推理结果方框

现象:模型推理出来的预测下标映射回汉字时,屏幕上显示一堆乱码或者方框,训练时明明一切正常。

原因:labels.txt 文件可能是用 GBK 编码保存的,Python 打开时按 utf-8 读取,字符解码错位,导致字典里的汉字全对不上。终端输出方框则是字体问题,控制台或 GUI 工具没有安装支持中文的字体。

解决:读取标签文件时统一指定 utf-8 编码,如果脚本是在 Windows 上跑的,文件保存时也选 utf-8 with BOM。推理阶段不要直接打印中文,先打印预测的类别 id,再把 id 映射到字符。GUI 调试时找一个乱码的样本,打印出它对应的 id 和 char_list 里该位置的字符,能快速判断是编码问题还是映射问题。

注意:这三个坑我基本在每个项目里都遇到过至少一次。改代码前先打印关键张量的 shape 和 label 的前几位值,比盲改参数有效得多。

6. 推理验证与接口化:从命令行脚本到小工具的最后一公里

6.1 单张图片推理与置信度阈值

模型训练完,第一步是用单张图片验证推理链路是否通畅。注意这里的预处理和训练时完全一致,图片尺寸、灰度化、归一化都不能省,否则模型输出会偏离正常置信度。

def load_and_predict(image_path, model, char_list): # 复用训练时的预处理函数,保证输入分布一致 img = preprocess_image(image_path) img_batch = np.expand_dims(img, axis=0) probs = model.predict(img_batch, verbose=0)[0] top_idx = int(np.argmax(probs)) confidence = float(probs[top_idx]) # 低于阈值的预测宁可判为不可识别 if confidence < 0.6: return "无法识别", confidence return char_list[top_idx], confidence

置信度阈值设成 0.6 是我自己调试出来的经验值。手写识别场景里,模型的预测置信度通常要么很高,集中在 0.9 以上,要么很低,在 0.3 左右徘徊。如果阈值设得太低,比如 0.3,系统会把大量不明所以的图像强行对应到某个汉字上,这在实际应用里很伤人。从 0.6 起步测试,如果发现查准率和查全率还有调整空间,再对应业务需求上下微调。

6.2 用真实手写数据做冒烟测试

验证集准确率只能说明模型在已知分布下的表现,真正上线前要拿一批“没参与训练”的手写图片做冒烟测试。我自己习惯在手机上随手拍几张纸上的字,让同事写几个不同的字,然后统一预处理后跑一遍预测,把每张图的 top 3 置信度记录下来。重点观察两个指标:第一,不认识的字是否被强行归到某个类别;第二,相似字之间的混淆是否集中在合理的笔画细节上。

从那以后我每次把模型接到新数据集上,都会强制走一遍这套冒烟测试:拿验证集外的真实手写图跑 top 概率输出,确认三类错误(低频类别被吞掉、相似字混淆、空白背景误判)的占比都在可接受范围内,才敢把模型接进接口。这套流程不花多少时间,但能挡住绝大多数调参过程中看不出来的问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询