☰
TensorFlow手写文字识别源码实战:CNN训练、推理与TFLite部署
2026/11/5 4:05:51 网站建设 项目流程

简介:面向深度学习与图像识别初学者,这份TensorFlow手写文字识别Python案例包围绕手写字符分类任务,提供从数据读取、模型搭建、训练调参到识别验证的完整参考。内容兼顾Softmax回归与卷积神经网络两种建模思路,既可对照理解全连接分类与卷积特征提取的差异,也能帮助读者熟悉卷积层、池化层、激活函数和全连接层的组合方式,适合课程实验、毕业设计预研或自学练手。包内共6个文件,以4个Python源码为主,分别承担数据加载、模型定义、训练循环与识别测试等环节;另附1份PDF说明材料和1个zip压缩包,整体约2.32MB,体量轻便,便于解压后按模块查阅和调试。已有1584人学习,说明该案例在入门群体中具有不错的参考度。读者可从中获得可复用的手写识别流程,包括图像归一化等预处理、交叉熵损失与优化器设置、训练集验证集划分、过拟合观察以及模型保存与测试思路,并借助两种模型实现理解TensorFlow构建神经网络的关键步骤,为后续图像识别项目打下基础。

1. 先让模型认出一个数字,再回头读代码

很多人拿到一份 TensorFlow 框架手写文字识别 Python 源码案例,第一反应是运行python train.py,然后在各种形状不匹配的报错里卡两天。我的顺序反过来:先跑推理脚本,让它认出我写在纸上的一个数字,再回头读数据管线和网络结构,理解成本立刻降一半。

这份案例做的事很具体:把一张灰度手写图送进卷积网络,输出字符类别,附训练脚本、数据加载、单图推理和一套可以直接改的目录结构。它适合三类人——刚学完卷积想找完整项目练手的新手、要把手写表单识别塞进业务流程的后端、想给自家字符集做迁移训练的同学。下面按我实际拆的顺序讲。

2. 工程骨架与首次运行:从解压到拿到第一个预测结果

拿到源码包,不要急着打开编辑器读全部文件。先看目录分工,判断哪几个文件是「主链路」,哪几个是「辅助工具」,这决定了你后面改代码时该动哪里、不该动哪里。

2.1 目录分工:哪些是主链路,哪些是脚手架

这类手写识别案例的目录结构大同小异,核心思想是把「数据」「模型」「训练」「推理」四件事拆开,避免所有逻辑堆在一个脚本里。

handwriting-recognition/ ├── config.py # 路径、图像尺寸、batch size 等超参集中在这里 ├── train.py # 训练入口:搭数据、建模型、fit、存盘 ├── predict.py # 单图/批量推理入口 ├── app.py # 可选:画板或摄像头实时识别界面 ├── dataset/ │ ├── loader.py # 数据读取、归一化、增强 │ └── split.py # 训练/验证集划分(自建数据集时才用) ├── models/ │ ├── cnn.py # 网络结构定义,只负责 build │ └── saved/ # 训练产物:权重、SavedModel、TFLite ├── utils/ │ ├── preprocess.py # 反色、居中填充、字符切分 │ └── visualize.py # 训练曲线、混淆矩阵画图 └── requirements.txt

判断主链路的方法很简单:从predict.py往上追 import。它一般只依赖config.py、models/cnn.py(或直接 load 已保存模型)和utils/preprocess.py,这三者加一个权重文件就是最小可运行集合。train.py和dataset/是第二条链路,app.py、visualize.py属于锦上添花。新手翻车最常见的方式,是把四条链路一起改,改完发现哪条都跑不通。

提示:先在config.py里把所有魔法数字(图像边长、类别数、权重保存路径)看一遍,后面读代码时看到28、10这类数字就知道它从哪来。

2.2 环境准备:版本对齐比装得多更重要

TensorFlow 的版本差异对老代码很不友好,tf.keras在高版本里已经取代了独立的 Keras 包,.h5和.keras两种保存格式的加载方式也不一样。我的习惯是建独立虚拟环境,先装 TensorFlow,再按报错补包,而不是一次性把requirements.txt全装完。

# 建环境,Python 3.9~3.11 对 TF 2.x 兼容性最好 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 先装主框架,让 pip 自己解依赖 pip install "tensorflow>=2.10,<2.17" # 推理和可视化常用的几个 pip install numpy opencv-python pillow matplotlib scikit-learn flask

装完立刻验证,别等到跑训练才发现 GPU 没挂上:

import tensorflow as tf print(tf.__version__) print("GPU:", tf.config.list_physical_devices("GPU"))

输出里 GPU 列表为空并不影响跑通,CPU 训一个小型手写 CNN 也够用,只是每个 epoch 慢几倍。真正要警惕的是版本太高导致load_model读不了老权重——如果源码里的权重文件是.h5格式,而你的环境是 TF 2.16 以上,加载时大概率报缺少compile_args或自定义层找不到,这时候降版本比改代码省事。

2.3 跑通第一个预测:一条命令验证整条链路

先别训练,直接用包里自带的权重跑推理脚本。这一步能同时验证三件事:模型文件能不能加载、预处理逻辑对不对、类别名映射有没有错位。

# 单张图片推理,指定模型和图片路径 python predict.py \ --model models/saved/best.keras \ --image samples/demo_7.png \ --topk 3

正常输出大概是这样的结构:

预测结果: 7 (置信度 0.9982) Top-3: [('7', 0.9982), ('1', 0.0011), ('9', 0.0004)]

如果输出的类别跟图片里的字完全对不上,但置信度又很高,那十有八九是类别索引错位——训练时class_names是按目录名字母序排的,推理时你手动写了一个列表,两边顺序不一致。这类问题最坑,因为程序不报错,只是安静地给你错答案。验证办法是拿训练集里每个类别各取一张图跑一遍,看准确率是否接近 100%。

如果报的是形状错误,比如expected shape=(None, 28, 28, 1), found shape=(None, 28, 28),说明预处理少了一个通道维度。手写图是单通道,但 Keras 的Input声明的是(h, w, 1),喂数据时必须img[None, ..., None]这样扩两维(batch + channel),光扩 batch 不够。

3. 数据与模型:CNN 训练管线逐段拆

跑通推理之后,再看训练。这部分是整份源码里信息密度最高的地方,也是二次开发时改动最频繁的地方——换个字符集、加几类样本、调一下增强策略,都落在这里。

3.1 数据读取:从 MNIST 到自建字符集

案例默认多半用 MNIST(0-9 十个数字,28×28 灰度图),但真实业务几乎不会是干净的居中手写数字。源码里通常会留一条「按目录读图」的路径,目录名就是类别名,这个设计很实用:

import tensorflow as tf AUTOTUNE = tf.data.AUTOTUNE def build_dataset(data_dir, image_size=28, batch_size=64, training=True): """从目录读取图片,子目录名即类别名,返回 (dataset, class_names)""" ds = tf.keras.utils.image_dataset_from_directory( data_dir, labels="inferred", # 用子目录名当标签 label_mode="int", # 输出整数标签,配合 sparse 交叉熵 color_mode="grayscale", # 手写样本几乎都是单通道 image_size=(image_size, image_size), batch_size=batch_size, shuffle=training, # 验证集不打乱,方便对齐混淆矩阵 ) class_names = ds.class_names # 字母序,务必存下来给推理用 ds = ds.map( lambda x, y: (tf.cast(x, tf.float32) / 255.0, y), num_parallel_calls=AUTOTUNE, ) return ds.prefetch(AUTOTUNE), class_names

几个参数值得说清楚。label_mode="int"配合sparse_categorical_crossentropy使用,省掉 one-hot 编码那一步内存开销;如果你的网络输出层用了categorical_crossentropy,就得改成"categorical",两边必须一致,否则 loss 会一直卡在奇怪的值上。color_mode="grayscale"保证读进来的通道数是 1 而不是 3,这是形状报错的高发点。shuffle=training让验证集保持原序,后面统计混淆矩阵时y_true和y_pred才能一一对上。

prefetch(AUTOTUNE)是几乎零成本的加速,让 CPU 在读下一批数据的同时 GPU 在算当前批,小模型上提升不明显,数据量大时能省掉可观的等待时间。

自建字符集要注意的一点是类别不平衡。手写表单里数字「1」和「0」的出现频率往往远高于其他,直接训练会让模型偏向多数类。常见做法是统计每类样本数,对少的类别做过采样或在fit里传class_weight:

import numpy as np counts = np.bincount([y for _, y in train_ds.unbatch()], minlength=len(class_names)) class_weight = {i: counts.max() / max(c, 1) for i, c in enumerate(counts)}

这个权重字典直接传给model.fit(..., class_weight=class_weight)即可,不用改任何网络结构。

3.2 预处理流水线:反色、居中、填充,一步都不能省

MNIST 是黑底白字、字符居中、四周留白,这套「隐式约定」是很多人自建数据集后准确率暴跌的根本原因。你的样本是白纸黑字,笔画贴边,直接 resize 到 28×28 喂进去,和训练分布完全对不上。

预处理要按顺序做三件事:反色对齐极性、等比缩放不压扁、居中填充补齐边长。

import numpy as np import cv2 def to_model_input(gray: np.ndarray, image_size: int = 28) -> np.ndarray: """gray: HxW uint8,任意极性;返回 (1, image_size, image_size, 1) float32""" img = gray.astype("float32") if img.mean() > 127: # 白底黑字 -> 反色,和训练集同极性 img = 255.0 - img # 按最大边等比缩放,保留内部 4px 边距 h, w = img.shape scale = (image_size - 4) / max(h, w) new_w, new_h = max(1, int(w * scale)), max(1, int(h * scale)) img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA) # 贴到黑底画布中央 canvas = np.zeros((image_size, image_size), dtype="float32") top = (image_size - new_h) // 2 left = (image_size - new_w) // 2 canvas[top:top + new_h, left:left + new_w] = img return (canvas / 255.0)[None, ..., None]

img.mean() > 127这个判断看起来很粗糙,但在实际项目里非常好用——手写图要么大面积白,要么大面积黑,均值天然分开。interpolation=cv2.INTER_AREA在缩小图像时比默认的双线性更少引入伪影,笔画不会糊成一团。留 4px 边距是因为训练集样本四周本来就是黑的,边缘贴死会让卷积核在边界处的响应偏移。

注意:缩放比例要用max(h, w)而不是分别对宽高缩放。分别缩放会把瘦长的「1」压成胖块,模型基本认不出来,这个坑我在第一批样本上踩得很实。

训练时的数据增强也要克制。手写数字的合理形变范围大概是旋转 ±15°、平移 ±8%、缩放 ±8%,超过这个范围生成的样本已不像人写的字,反而干扰收敛:

augment = tf.keras.Sequential([ tf.keras.layers.RandomRotation(0.04, fill_mode="constant", fill_value=0.0), # ≈±14° tf.keras.layers.RandomTranslation(0.08, 0.08, fill_mode="constant", fill_value=0.0), tf.keras.layers.RandomZoom(0.08, fill_mode="constant", fill_value=0.0), ], name="augment")

fill_mode="constant"加fill_value=0.0是关键,默认的"nearest"会在旋转后的空角上复制边缘像素,凭空造出白色条带,模型会把这些条带当成特征学进去。

3.3 网络结构:三层卷积够用,别再堆了

手写单字识别是个简单任务,MNIST 上 99% 以上的准确率用三个卷积块就能达到。源码里如果堆了很深的网络,反而是负担——参数多、训练慢、小数据集上更容易过拟合。

import tensorflow as tf from tensorflow.keras import layers, regularizers def build_cnn(num_classes: int, image_size: int = 28) -> tf.keras.Model: inputs = layers.Input(shape=(image_size, image_size, 1), name="image") x = layers.Conv2D(32, 3, padding="same", use_bias=False)(inputs) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.MaxPooling2D(2)(x) # 28 -> 14 x = layers.Conv2D(64, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.MaxPooling2D(2)(x) # 14 -> 7 x = layers.Conv2D(128, 3, padding="same", use_bias=False)(x) x = layers.BatchNormalization()(x) x = layers.Activation("relu")(x) x = layers.GlobalAveragePooling2D()(x) # 替代 Flatten,参数少一半 x = layers.Dense(128, activation="relu", kernel_regularizer=regularizers.l2(1e-4))(x) x = layers.Dropout(0.4)(x) outputs = layers.Dense(num_classes, activation="softmax", name="probs")(x) return tf.keras.Model(inputs, outputs, name="handwriting_cnn")

逐层说明几个设计选择。use_bias=False是因为紧跟着BatchNormalization,BN 自带的 beta 参数已经承担了偏置的作用,再加 bias 是重复计算。padding="same"保证每次卷积后特征图尺寸不变,尺寸变化只发生在池化层,这样调试时形状推演很直观:28→14→7。GlobalAveragePooling2D把 7×7×128 压成 128 维向量,相比Flatten的 6272 维少了约 80 万参数,小数据集上泛化更好,这是我在多个手写项目里验证过的默认选择。

Dropout(0.4)放在全连接层前,比例别调太高,0.5 以上在小数据集上会导致欠拟合,训练 loss 下降很慢。

输出层用softmax配合sparse_categorical_crossentropy,如果你后面要做多字符序列识别(一行字),这一层就得换成 CTC 那套,那不是这份单字案例覆盖的范围,需要另起一个模型。

3.4 训练循环与回调:断点续训是必需品

训练脚本里最容易省掉、又最不该省的是回调。没有ModelCheckpoint,训到第 20 个 epoch 断电就得重来;没有ReduceLROnPlateau,学习率固定在一个值上,后期 loss 会在最优点附近来回跳。

def make_callbacks(ckpt_path: str, log_dir: str): return [ # 只保留验证准确率最高的那一份,避免最后一轮恰好过拟合 tf.keras.callbacks.ModelCheckpoint( ckpt_path, monitor="val_accuracy", save_best_only=True, verbose=1), # 验证 loss 连续 2 轮不降就砍半学习率 tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=2, min_lr=1e-6, verbose=1), # 连续 6 轮没改善就停,并回滚到最佳权重 tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=6, restore_best_weights=True), tf.keras.callbacks.TensorBoard(log_dir=log_dir, histogram_freq=1), ] model = build_cnn(num_classes=len(class_names)) model.compile( optimizer=tf.keras.optimizers.Adam(1e-3), loss="sparse_categorical_crossentropy", metrics=["accuracy"], ) model.summary() history = model.fit( train_ds, validation_data=val_ds, epochs=30, callbacks=make_callbacks("models/saved/best.keras", "logs/fit"), )

save_best_only=True配合monitor="val_accuracy"是标准组合,注意监控指标要和你的业务目标一致——如果各类别样本极不均衡,val_accuracy会被多数类主导,这时候监控val_loss或自定义的宏平均 F1 更靠谱。

restore_best_weights=True是很多人忽略的后悔药:EarlyStopping触发时模型已经又训了几轮,权重是在变差的,加上这个参数才会回滚到最佳那一轮。没有它,你保存的「早停模型」其实是过拟合版本。

训练完第一次看曲线,重点看两条线的间距。训练准确率 99.5%、验证准确率 96%,这是典型过拟合,加 dropout 或增强;两条都只有 85%,那是欠拟合或者数据本身有问题,先别调网络,去抽查几十张预处理后的图,看看是不是缩放糊了、反色反错了。

4. 推理流程:从单图到画板、摄像头和一行字

训练只是手段,能稳定吃进真实输入才是目的。这一章讲三种推理形态:单张图片、实时画板/摄像头、以及一整行手写文字的切分识别。

4.1 单图推理:把预处理和模型封在一起

推理代码最忌讳预处理散落在各处,今天脚本里写一遍、明天接口里再写一遍,迟早两边不一致。封装成一个类,模型和预处理绑死:

import numpy as np import tensorflow as tf class Recognizer: def __init__(self, model_path: str, class_names: list, image_size: int = 28): self.model = tf.keras.models.load_model(model_path) self.class_names = class_names self.image_size = image_size def predict(self, gray: np.ndarray, topk: int = 3): x = to_model_input(gray, self.image_size) # 复用 3.2 的预处理 prob = self.model.predict(x, verbose=0)[0] idx = np.argsort(prob)[::-1][:topk] return [(self.class_names[i], float(prob[i])) for i in idx]

verbose=0一定要加,否则每预测一张图就往终端打一行进度条,在接口服务里会疯狂刷日志。class_names从训练时的ds.class_names存成 json 一起落盘,别手写,这是索引错位的根治办法。

返回 Top-K 而不是只返回 argmax,是因为真实业务里「置信度低于阈值就转人工」比「必须给一个答案」更合理。0.6 以下的预测结果,人工复核的成本远低于一次错误自动处理的成本。

4.2 实时识别:画板取 ROI,二值化再切分

画板或摄像头场景的核心难点不在模型,而在怎么把用户写的那一小块区域准确抠出来。整帧画面直接送进 28×28 的模型,字只占几个像素,必然认错。

import cv2 import numpy as np def extract_roi(frame, min_area: int = 800): """从摄像头帧里找出最大的一块深色区域,作为书写区""" gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值对光照不均更鲁棒,固定阈值在侧光下会整片变黑 binary = cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 10) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [cv2.boundingRect(c) for c in contours if cv2.contourArea(c) > min_area] if not boxes: return None, binary x0 = min(b[0] for b in boxes) y0 = min(b[1] for b in boxes) x1 = max(b[0] + b[2] for b in boxes) y1 = max(b[1] + b[3] for b in boxes) pad = 4 roi = binary[max(0, y0 - pad):y1 + pad, max(0, x0 - pad):x1 + pad] return roi, binary

THRESH_BINARY_INV让笔画变成白色(255),背景变黑,正好对应模型要的黑底白字,省掉一次反色。blockSize=25、C=10这两个参数需要按实际画面调:块太小噪声敏感,整片背景会被判成笔画;C 太大笔画会被削细甚至断开。

拿到 ROI 后按 3.2 的流程走,唯一区别是这里已经是黑底白字,to_model_input里的反色判断会自动跳过。

4.3 一行字的切分:列投影 + 断笔合并

「手写文字识别」在真实场景里很少只写一个字符,更多是一行数字或一串编号。单字模型没法直接吃整行,得先切。

按列投影是最朴素也最够用的方法:把二值图沿垂直方向求和,笔画所在的列和值大,字与字之间的空隙和值接近 0。

def split_characters(binary: np.ndarray, min_width: int = 3, gap: int = 2): """按列投影切分一行字符,返回从左到右的二值小块列表""" col_sum = (binary > 0).sum(axis=0) spans, start = [], None for x, v in enumerate(col_sum): if v > 0 and start is None: start = x elif v == 0 and start is not None: if x - start >= min_width: # 过滤孤立噪点 spans.append((start, x)) start = None if start is not None: spans.append((start, len(col_sum))) # 相邻块间距过小则合并,处理 "i"、"=" 这类天然断笔 merged = [] for s, e in spans: if merged and s - merged[-1][1] < gap: merged[-1] = (merged[-1][0], e) else: merged.append((s, e)) return [binary[:, s:e] for s, e in merged]

min_width=3用来挡掉毛刺,gap=2决定合并力度。这个参数没有通用值——写得开的人 gap 可以设大,写得挤的人设大就会把两个字粘成一个。我一般先按 2 跑一批样本,统计切出来的块数跟实际字符数的差值,再回来微调。

切完之后每个小块单独走Recognizer.predict,按从左到右的顺序拼成字符串输出。这套方案对印刷体和工整手写效果好,对连笔草书基本失效——那种场景得上有检测框的序列模型,属于另一个量级的工作。

4.4 批量测试与混淆矩阵:找出最差的那几类

单张图看得出「准不准」,看不出「哪里不准」。跑一遍完整验证集,输出分类报告和混淆矩阵,问题才暴露得出来:

import numpy as np from sklearn.metrics import confusion_matrix, classification_report y_true = np.concatenate([y.numpy() for _, y in val_ds], axis=0) y_pred = model.predict(val_ds, verbose=0).argmax(axis=1) print(classification_report(y_true, y_pred, target_names=class_names, digits=4)) cm = confusion_matrix(y_true, y_pred) per_class = cm.diagonal() / cm.sum(axis=1) for i in np.argsort(per_class)[:5]: # 准确率最低的 5 类 row = cm[i].copy() row[i] = -1 # 排除自己 top_wrong = class_names[int(row.argmax())] print(f"{class_names[i]}: acc={per_class[i]:.3f}, 最常误判为 {top_wrong} ({row.max()} 次)")

输出会直接告诉你哪两类容易混。手写数字里最常见的混淆是 4/9、3/8、1/7、5/6,如果你的数据里某一对混淆次数特别高,优先补这一类样本,比整体加数据划算得多。加两百张针对性的样本,往往比加两千张随机样本提升更大。

5. 避坑与排查:手写识别项目最容易翻车的几个点

这一章按「现象 → 原因 → 解决」记几条,都是这类项目里反复出现的。

5.1 形状与通道类报错

现象:ValueError: Input 0 of layer "conv2d" is incompatible with the layer: expected shape=(None, 28, 28, 1), found shape=(None, 28, 28)。

原因:预处理只扩了 batch 维,没扩通道维。cv2.imread(..., cv2.IMREAD_GRAYSCALE)返回的是(H, W),img[None]得到(1, H, W),还差一维。

解决:统一用img[None, ..., None]同时扩 batch 和 channel。如果代码里到处是手写的维度操作,干脆在to_model_input这类函数出口处固定形状,并在里面加一句assert x.shape == (1, size, size, 1),让错误在源头炸掉而不是在网络中间炸。

现象:训练正常,推理时load_model报Unknown layer: BatchNormalization或自定义层找不到。

原因:有些老代码用了脱离tf.keras的独立 Keras 包,权重存成.h5,在新环境里加载时命名空间对不上。

解决:短期降 TensorFlow 版本对齐;长期把权重重新存成.keras格式,并在加载时传custom_objects。我在项目里会额外存一份只含权重的model.weights.h5,加载时先重建结构再load_weights,这条路对版本最不敏感。

5.2 训练不收敛与过拟合

现象:loss 从第一个 epoch 起就在 2.3 附近不动,accuracy 卡在 10%(正好等于类别数的倒数,说明模型在瞎猜)。

原因:九成是标签和 loss 不匹配。label_mode="int"配了categorical_crossentropy,或者 one-hot 标签配了sparse_categorical_crossentropy。这两种组合都不报错,只是梯度方向全错。

解决:打印一个 batch 的y看形状和取值。整数标签应形如[3 7 1 ...],one-hot 应是(batch, num_classes)的 0/1 矩阵。对上之后再检查归一化——输入是 0-255 而不是 0-1 时,loss 也会长时间不降,因为初始权重下的激活值全被推到饱和区。

现象:训练集准确率 99.8%,验证集只有 92%,且差距随 epoch 增大。

原因:过拟合,或者验证集和训练集分布不一致(比如验证集是另一个人写的、另一种笔)。

解决:先排除分布问题——把验证集样本混进训练集跑一次,如果准确率立刻上去,那就是分布差异,需要补同分布的样本,调网络没用。确认是过拟合再加 dropout、加 L2、加增强、减网络宽度,按这个顺序试,成本从低到高。

5.3 部署环节的错位

现象:本地脚本预测全对,包成接口后结果全错,或者置信度普遍偏低。

原因:接口里读图用了cv2.imdecode但忘了转灰度,三通道图被当成单通道喂进去,通道数被静默截断;或者前端传来的图已经过压缩,笔画边缘出现 JPEG 振铃。

解决:接口入口处强制cv2.IMREAD_GRAYSCALE,并在返回体里带上input_shape和polarity(是否反色)两个调试字段。出问题时一眼就能看出是哪一步变了形。

现象:模型导出 TFLite 后精度掉好几个点。

原因:默认的动态范围量化会把权重压成 int8,对小模型、低对比度输入比较敏感。

解决:先用不量化的converter.convert()导出一次做对照,确认精度损失来自量化;然后加代表性数据集做全整型量化,或者干脆只量化权重、保持浮点激活。手写识别这类输入动态范围大的任务,我一般宁可牺牲一点体积,也不做激进量化。

6. 二次开发:换字符集、导出和接服务

这份案例真正的价值不在那十个数字,而在它给出了一条能替换的管线。换成自家字符集的最小改动是三步:把图片按类别放进data/train/<类别名>/、重新跑build_dataset拿到新的class_names、把build_cnn(num_classes=len(class_names))重训一遍。中文字符集类别多、样本少的时候,别从零训,冻结前两个卷积块只训后面的层,学习率调到 1e-4,收敛会快很多。

改造方向需要动的文件关键参数注意点
换字符集dataset/loader.py、config.pynum_classes、image_size类别数变了输出层必须重建
迁移学习models/cnn.py、train.py冻结层数、lr=1e-4冻结层要用trainable=False后再 compile
导出 TFLite新增export.pyOptimize.DEFAULT量化前后各跑一遍验证集对比
接 HTTP 服务app.py请求体大小、超时入口强制转灰度,返回带调试字段

导出这一步值得单独写个脚本,别塞进train.py:

import tensorflow as tf # 先导出 SavedModel,作为 TFLite 和服务部署的共同源头 model = tf.keras.models.load_model("models/saved/best.keras") model.export("models/saved/export") converter = tf.lite.TFLiteConverter.from_saved_model("models/saved/export") converter.optimizations = [tf.lite.Optimize.DEFAULT] # 动态范围量化,先试这个 tflite_model = converter.convert() with open("models/saved/handwriting.tflite", "wb") as f: f.write(tflite_model) print(f"tflite size: {len(tflite_model) / 1024:.1f} KB")

导出后必须做的事是拿同一批验证样本对比keras和tflite的预测结果,统计不一致率。超过 1% 就说明量化伤到精度了,回到浮点导出或者补代表性数据集。

接服务的时候,我习惯把模型加载放在应用启动阶段而不是每个请求里,Recognizer实例做成模块级单例。TensorFlow 的模型加载是几百毫秒级别,放在请求里会让接口响应时间直接失控,并发一上来就是灾难。

最后说个我自己的习惯。这份源码我拆完之后做的第一件事,是写一个verify.py:从验证集每类抽 20 张,走完整的「预处理 → 模型 → 后处理」链路,输出每类准确率和最差的三类。之后不管我改预处理、换模型、调量化,改完都强制跑一遍这个脚本,两次结果对比。没有这条基线,任何改动都是玄学;有了它,掉点能立刻定位到是哪一步引入的。从那以后我每次动这类识别项目,都强制走一遍全链路验证,再去看那些局部指标的漂亮数字。

希望帮到你。

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

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

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

立即咨询