简介一个基于Python与TensorFlow 2实现的字母数字识别项目面向高校学生与深度学习初学者可作为课程设计或计算机视觉入门实践。项目围绕EMNIST数据集展开覆盖手写英文字母与数字的多分类识别采用ResNet结构在TensorFlow 2.1环境下完成建模、训练与推理完整流程包括数据预处理、残差网络搭建、模型训练、评估及演示运行环境为Windows 10与Python 3.7便于本地复现。压缩包共50个文件整体大小约104.79MB主要包含Python源码、模型检查点与预训练权重、训练日志、结果图表以及说明文档文件类型涵盖py、ckpt、data、index、h5、png、md、txt等可用于代码研读、模型加载和效果演示。已有280人学习下载读者可通过演示脚本快速体验识别效果也可结合训练脚本与模型定义代码深入理解ResNet残差连接、数据加载、训练评估等关键环节是兼顾理论理解与动手实践的完整资源。1. 字母数字识别不是玩库先看这套 Python 课程设计给了什么字母数字识别是计算机视觉课程设计里的老熟人但真正要把手写英文字母和数字从训练一路做到演示很多人在 TensorFlow 2 的版本坑里先耗掉一周。这套基于 Python 的字母数字识别课程设计资源把完整链路用 tf2.1 python3.7 封装好了mytrain.py 负责训练checkpoints 里预置了训练阶段的权重分片test.py 负责算准确率demo.py 负责把一张手写图变成最终字符输出。项目用的数据集是 EMNIST模型是 ResNet 在 TF2 上的简单实现输入统一 28×28 灰度图输出接 characters.txt 里的字符映射。适合正在做模式识别或深度学习课程设计、需要一份能落地能答辩的工程源码的人也适合想快速看懂 ResNet 怎么搭、TF2 权重怎么存怎么恢复的人。2. 从 EMNIST 到 ResNet这套代码用的是什么数据与模型很多同学拿到资源第一件事就是跑脚本跑出来一个黑匣子一样的准确率然后什么也答不上来。我的习惯是先把数据和模型这两件事看明白因为答辩问得最多的就是这两个点。2.1 先读 characters.txt你才知道网络输出要接几个类识别任务的第一步不是搭网络而是确认标签集合。项目里那个 characters.txt 不是摆设它就是整个模型的输出映射。我解压后先看一眼它有多大、里面是什么格式cd alphanumericrecognition wc -l characters.txt head -20 characters.txtwc -l显示的就是标签类别总数。这个值决定 ResNet 最后一层Dense的神经元个数也决定CategoricalCrossentropy的类别维度。EMNIST 官方在数据集上其实做了大小写合并很多形态相近的小写字母会被归成同一个类所以字符数不是简单算 10 个数字加 26 个字母这么直接。以项目 README 描述的口径它面向的是手写英文字母和数字识别你最终要记住的是所有字符顺序必须严格按 characters.txt 来训练和推理用同一个映射文件。我在代码里的读法是这样的char_map [line.strip() for line in open(characters.txt, encodingutf-8) if line.strip()] num_classes len(char_map) print(num_classes, char_map[:10])逻辑说明每一行一个字符过滤掉空行后列表下标就是模型输出的类别索引。char_map[0]对应 argmax 为 0 时的预测结果不能拿 ASCII 码去硬套。项目目录下 assets 里的 0.png、3.png 这类样本图就是用来对映射关系的单张跑 demo 之前先用标签文件把预期结果对一遍。这里有一个很小但是很实际的坑如果 later 你自己去下载 EMNIST 原始数据它的标签顺序和这个 txt 文件不一定一致。所以正确的做法不是用 EMNIST 官方标签类名而是以项目里的 characters.txt 为基准把训练标签也重新按它编码一遍。2.2 resfit model.py 里的 ResNet 骨架项目里有个文件叫 resfit model.py名字里带空格解压后第一件事就是把它重命名成 resfit_model.py否则import会直接报语法错误。这个文件的角色就是模型定义里面是 ResNet 在 TF2 上的简单实现。ResNet 的核心不是卷积叠得多深而是那个残差连接它让梯度在反向传播时多一条“高速公路”所以网络层数加深后不至于立刻梯度消失。我把这个项目里最典型的一个残差块拆出来看import tensorflow as tf from tensorflow.keras import layers def residual_block(x, filters, stride1): shortcut x x layers.Conv2D(filters, 3, stridesstride, paddingsame)(x) x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.Conv2D(filters, 3, strides1, paddingsame)(x) x layers.BatchNormalization()(x) if stride ! 1 or shortcut.shape[-1] ! filters: shortcut layers.Conv2D(filters, 1, stridesstride, paddingsame)(shortcut) out layers.add([x, shortcut]) return layers.ReLU()(out) def build_model(num_classes): inputs tf.keras.Input(shape(28, 28, 1)) x layers.Conv2D(32, 3, paddingsame)(inputs) x residual_block(x, 32) x residual_block(x, 64, stride2) x residual_block(x, 128, stride2) x layers.GlobalAveragePooling2D()(x) x layers.Dense(num_classes, activationsoftmax)(x) return tf.keras.Model(inputs, outputsx)参数说明要记牢第一个stride用于降采样当 feature map 尺寸减半时shortcut 那边必须用 1×1 卷积把通道数和尺寸对齐否则add加不起来。BatchNormalization放在卷积之后、激活之前这是 ResNet 常见的排列顺序。最后一层不用 Flatten 再接大 Dense而是先用GlobalAveragePooling2D把特征图压成一维向量参数量会小很多28×28 这种小图完全够用。实际项目里的 resfit_model.py 可能变量名不太整齐但你要找的就是这些结构卷积、BN、残差 add、全局平均池化、softmax 分类头。输入(28, 28, 1)是 EMNIST 图片的标准尺寸最后一个维度 1 表示灰度图。如果你看到有人把这个输入改成(32, 32, 3)那说明他换了数据集和这个项目无关。3. 训练和验证mytrain.py 是入口loss 曲线才是照妖镜课程设计最容易翻车的地方不是准确率低而是训练脚本跑不通。这个项目的核心训练入口是 mytrain.py它把数据集切分、模型构建、训练回调都串起来了。我建议你在改任何网络结构之前先原样跑一遍让整个流程通掉再谈优化。3.1 环境先固定成 window10 tf2.1 python3.7项目 README 写得很明确运行环境是 window10 tf2.1 python3.7。这不是随便写的版本组合TF2.1 对 Python 版本卡得很死。如果你电脑里装着 Python 3.10 就直接pip install tensorflow2.1.0大概率会看到“找不到对应 Wheel”或者安装后 import 直接崩。我一般会用 conda 单独建一个环境conda create -n emnist-rec python3.7 -y conda activate emnist-rec pip install tensorflow2.1.0 pip install numpy1.18.5 matplotlib pillow python -c import tensorflow as tf; print(tf.__version__)最后一行能打出来2.1.0环境就算立住了。numpy 这里要强调一下TF2.1 编译时依赖的 NumPy 版本比较旧如果你后面手贱把 numpy 升到 1.20 以上跑训练时会报numpy.core.multiarray failed to import那基本都是 ABI 不匹配不是我在这故弄玄虚。如果机器没有 NVIDIA 显卡直接装 CPU 版 tensorflow 足够因为这个数据集是 28×28 小图训练一轮用不了太久。强行装tensorflow-gpu但 CUDA/cuDNN 版本不对会在 import 阶段崩得更难看。3.2 训练mytrain.py 里四个关键参数mytrain.py 的核心训练流程我整理成下面这样。它不是一个能直接跑的最小文件但骨架就是项目里那套# mytrain.py 的核心训练流程 with open(characters.txt, r, encodingutf-8) as f: char_map [line.strip() for line in f if line.strip()] train_ds, val_ds load_emnist_data(char_map) model build_model(num_classeslen(char_map)) model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losstf.keras.losses.CategoricalCrossentropy(from_logitsFalse), metrics[accuracy], ) model.fit( train_ds, validation_dataval_ds, epochs20, batch_size32, callbacks[ tf.keras.callbacks.ModelCheckpoint( filepathcheckpoints/pro1-10.ckpt, save_weights_onlyTrue, verbose1 ), tf.keras.callbacks.TensorBoard(log_dirtblogs) ] )load_emnist_data在项目里已经有封装核心作用是把 EMNIST 原始数据按 characters.txt 的标签顺序重新编码。这里我要提醒你一个容易被忽视的细节from_logitsFalse表示最后一层已经过了 softmaxloss 会从概率输出开始算。如果你把最后一层改成没有 softmax 的裸 Dense这个参数必须改成True否则准确率会一直怪怪的。四个关键参数我一般会这样设参数示例值作用learning_rate1e-3Adam 默认学习率小数据集一般不需要更高batch_size3228×28 小图32 在普通内存下跑得很稳epochs20 / 80先 20 轮验证流程再 80 轮做最终结果save_weights_onlyTrue只存权重加载时必须先重建一模一样的模型结构ModelCheckpoint的路径是checkpoints/pro1-10.ckptTF2 会把权重拆成多个文件.index是索引.data-00000-of-00002和.data-00001-of-00002是权重数据分片。这很正常不要手动去合并或改名。3.3 20 epochs 还是 80 epochs从 loss 曲线判断项目里给了两张示例图20epochs.png 和 80epochs.png。这不是让你两个都跑完然后选一个好看的图交差而是让你看趋势。训练时我建议把TensorBoard回调打开tensorboard --logdirtblogs --port6006浏览器打开http://localhost:6006重点看验证集准确率和验证集 loss。判断标准很简单如果 val_accuracy 还在涨、val_loss 还在降继续训练如果 val_loss 已经开始掉头向上train_loss 还在降那就是过拟合再训练多少轮都白搭。我第一次跑这个项目时直接在 80 epochs 上熬跑到 40 轮发现验证集波动很大白白浪费了大半天。后来改成 20 epochs 先看一遍趋势确认数据加载、标签映射、归一化都没问题再放开 80 epochs。这个习惯从一个课程设计一路用到了后面所有训练任务非常值得养成。4. 推理和验证demo.py 和 test.py 的两种用法训练完后项目不是只给你一个准确率数字它把 demo.py 和 test.py 都写好了。这两条路径建议都跑一遍因为 demo.py 验证的是单张图片的端到端效果test.py 验证的是模型在测试集上的整体准确率。两个一起过才能说这份权重真的接住了。4.1 demo.py单张手写图怎么变成最终字符demo.py 的用法很直接它从assets里拿一张手写图走一遍预处理、模型推理、结果映射最后存成 demo.png。我一般先用项目自带的 3.png 跑python demo.py --image assets/3.png如果脚本没接 argparse就直接打开 demo.py 改里面的IMAGE_PATH常量。这个脚本内部干的事情值得多看两眼img load_and_preprocess(assets/3.png) # 28x280~1黑底白字 img tf.expand_dims(img, axis0) # 变成 (1, 28, 28, 1) pred model(img, trainingFalse) idx tf.argmax(pred[0]).numpy() char char_map[idx] print(predicted:, char)tf.expand_dims加的是 batch 维因为模型输入是四维张量。trainingFalse必须写否则 Dropout 和 BatchNormalization 在推理时行为会不一致。输出不是直接拿 argmax 当字符而是把它当作char_map的下标这一步是很多人都跳过不看的坑。demo.py 跑的其实是整个项目里最短的推理链路读图 → 缩放 → 归一化 → 加 batch → 前向 → 查映射表。如果预测结果和实际图对不上九成问题在预处理而不是模型权重。4.2 test.py把准确率跑出来顺便验证权重恢复test.py 是拿来算测试集准确率的。它做的事情本质上就是先加载权重再对测试集 evaluatepython test.py里面核心代码大概是model build_model(num_classeslen(char_map)) model.load_weights(checkpoints/pro1-10.ckpt) loss, acc model.evaluate(test_ds, verbose1) print(ftest acc {acc:.4f})这个脚本有个前提build_model必须和训练时完全一致包括每一层顺序、卷积核数量、池化方式。TF2 的load_weights是按变量名匹配的哪怕你只是把某个Conv2D核数从 32 改成 64加载权重时也会报Failed to find match for key。这也就是为什么我强调不要轻易动模型结构动了就得重训。test.py 的另一个价值是它能让你确认 checkpoints 文件夹里的那组分片权重是完整的。只要model.evaluate能跑出数值说明pro1-10.ckpt.index、pro1-10.ckpt.data-00000-of-00002、pro1-10.ckpt.data-00001-of-00002这几个文件都就位了。以后你自己加训练轮数输出新的 checkpoint这条验证路径可以原样复用。5. 避坑与常见问题tf2.1、归一化和 checkpoint 恢复的四个坑这个项目整体不难但它把容易出问题的点都藏在环境、预处理和权重恢复里。下面四个坑是我实际跑的时候踩过的每一条都按“现象 → 原因 → 解决”的顺序写出来省得你再来一趟。5.1 import tensorflow 就崩报 numpy 兼容问题现象环境建好后执行python -c import tensorflow as tf直接报错错误里有numpy.core.multiarray failed to import或者一堆看不懂的 DLL 加载失败。原因TF2.1 在 Python 3.7 上编译时依赖的是旧版 NumPy后来 pip 默认装的高版本 NumPy 破坏了 ABI 兼容性。这是环境依赖问题不是项目代码问题。解决固定安装numpy1.18.5然后重启 Python 进程再 import。如果你之前已经装过新版 numpy先pip uninstall numpy -y再装旧版不要只pip install numpy1.18.5覆盖有时候旧文件没清干净还是会冲突。5.2 load_weights 报 Failed to find match for key现象跑 test.py 或 demo.py 时模型报错Failed to find match for key conv2d_1/kernel或者提示 checkpoint 里有变量找不到。原因加载 TF2 权重时模型结构和训练时不完全一致。常见误操作包括把 resfit_model.py 里的 ResNet 层数改了或者在build_model函数里用了keras.Sequential重写了一遍导致变量路径对不上。checkpoint 本身没坏是接收它的模型结构变了。解决用训练时的同一个build_model函数创建模型后再load_weights。如果确实改过结构就不要指望旧权重还能用老老实实重训。检查的方法是先model.summary()再对比训练脚本里每层的名字和输出形状。5.3 所有图片都预测成同一个类别现象demo.py 跑任何一张图输出永远是同一个字符。换图片也一样但 test.py 的准确率却不低。原因输入预处理和训练时不一致。最常见的是图片没缩放到 01直接把 0255 的像素喂进模型还有一个常见原因是底色反了。EMNIST 系列默认是黑底白字你拿一张白底黑字的扫描图直接喂模型当然会懵。解决预处理统一成训练时的方式。我的固定写法是x np.array(img).astype(float32) / 255.0 if x.mean() 0.5: x 1.0 - x先缩放到 01再用均值判断当前图是白底还是黑底。均值大于 0.5 说明大部分像素是白色也就是白底黑字直接反转成黑底白字。这个判断对大多数手写图都管用。5.4 预测结果和图上字符对不上但离得很近现象比如图片画的是“7”模型输出“1”或者把“O”和“0”搞混。看起来像是模型弱但换一张又正常甚至同一张图两次预测结果不一样。原因这里要分清是模型本身把相似字形混淆了还是标签映射错位。如果多次预测结果稳定但就是不对先怀疑char_map顺序。比如模型训练时用的是 EMNIST 官方标签 061你推理时却用 ASCII 码减 48那结果自然全错。如果结果不稳定那才轮到数据增强、模型容量这些玄学问题。解决推理时永远只通过char_map[idx]拿字符不要手写从数字到字母的映射。每次加载模型后先打印char_map[:10]和 assets 里样本图对一遍确认 characters.txt 的顺序没被改动过。这是最笨但最有效的对齐方法。6. 进阶把 28×28 单字符识别接到自己的切分流程这个项目做的是单字符识别输入是已经切好的 28×28 图。但课程设计如果只交一个单字符识别答辩时很容易被追问“整张图怎么处理”。最简单的进阶做法是把 demo.py 里的预测逻辑抽成一个函数然后接到自己的字符切分流程后面。我一般会封装成这样的函数import cv2 import numpy as np import tensorflow as tf def predict_single_character(net, patch, char_map): if len(patch.shape) 3: patch cv2.cvtColor(patch, cv2.COLOR_BGR2GRAY) patch cv2.resize(patch, (28, 28), interpolationcv2.INTER_AREA) x patch.astype(float32) / 255.0 if x.mean() 0.5: x 1.0 - x x tf.expand_dims(x, axis0) # (1, 28, 28) x tf.expand_dims(x, axis-1) # (1, 28, 28, 1) pred net(x, trainingFalse) return char_map[int(tf.argmax(pred[0]))]逻辑说明先把外围输入统一转成灰度再缩放到 28×28。INTER_AREA在缩小图片时比双线性插值更能保留字符主干不容易出现锯齿。后面的归一化和反色逻辑和训练时保持一致。net就是已经load_weights好的 ResNet 模型这个函数可以反复调用。拿到这个函数后我可以把一张包含多个字符的图片按列投影切分先统计每列黑色像素数量找到字符之间的空档然后按空档边界把每个字符的 patch 切出来逐个调用predict_single_character。这样做的好处是不用重新训练模型直接把课程设计从“单字符识别”撑到“整行手写识别”。验证这个进阶流程的时候我会专门收集 50 张左右与项目样本风格不同的手写图切成 patch 后逐个预测把错例单独存成一张图看是切分偏差还是识别错误。如果切分出来的 patch 边缘缺了一块识别结果大概率会错这时候先修切分不要急着调模型。项目本身已经把从训练到演示的闭环做好了checkpoints 里有能直接用的权重mytrain.py、test.py、demo.py 三个脚本正好对应训练、评估、推理三件事。从那以后我每次调整网络都强制走一遍这三步用同一张 assets/3.png 作为回归样本如果输出结果和 demo.png 对不上不管测试集准确率多高都不提交。这套流程很基础但很管用希望帮到你。本文还有配套的精品资源点击获取