尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

手写拼音识别实战:Python+CNN+LSTM+CTC全流程解析

发布时间:2026/9/24 21:13:07

资讯中心
01
ARTICLE

手写拼音识别实战:Python+CNN+LSTM+CTC全流程解析

手写拼音识别实战:Python+CNN+LSTM+CTC全流程解析
简介这是一份基于Python的手写拼音识别课程设计资料包采用K近邻KNN算法实现字符分类适合机器学习初学者、高校学生用于模式识别、图像处理或人工智能相关课程设计参考。资源完整覆盖“设计报告源码数据”三部分设计报告为Word文档可直接作为课设文档写作蓝本Python源码包含模型训练与预测流程数据集中既有大量txt特征向量文件也有大量jpg手写样本图片共2589个文件压缩包仅1.79MB体量紧凑却结构完整。目前已有170人学习下载。借助本包读者能够快速复现KNN从特征提取、距离度量到投票分类的完整链路理解“少数服从多数”的最近邻思想同时可基于现有代码和样本数据做二次扩展例如调整k值、更换距离函数或扩充数据集用于对比实验与算法优化对课设答辩和深入理解机器学习基础都非常有帮助。1. 手写拼音识别是什么一条比汉字识别更轻量的落地路线手写拼音识别这个话题看起来小众其实落点很多学生拿数位板写拼音做听写批改、老人用触摸屏设备书写拼音录入文字、嵌入式设备把手写内容转成输入法候选词。把问题定义成“识别带声调的拼音字母序列”而不是直接识别汉字模型规模能小一个数量级数据也更好组织。基于 Python 的手写拼音识别要做的事很直接输入一张手写图片输出类似hao3、xue2xi2这样的带声调拼音串。它适合想低成本做手写输入、又不打算碰复杂汉字识别的开发者也适合刚入门深度学习、想用一套完整流程把数据、训练、推理串起来的初学者。别把它想成 OCR它其实是序列标注问题。2. 从手写图片到拼音标签数据整理与预处理脚本2.1 为什么用拼音标签而不是直接识别汉字手写汉字识别的类别数是个硬门槛常用汉字 3500 个加上生僻字和异体字轻松上万分类头参数量大样本收集也困难。而拼音由 26 个字母加 4 个声调组成把声调作为独立字符看待全部类别也只有 30 个左右。类别少模型可以做得更小、收敛更快对训练样本量的需求也低很多。常见的做法是把手写内容转录成带声调的拼音串比如“你好”写作ni3 hao3“学习”写作xue2 xi2。声调用 1 到 4 表示轻声可以不标或统一标成 5。这样标签就是一个长度可变的字符串而模型要解决的核心问题变成给定一张包含一行手写拼音的图片输出对应的拼音序列。这个定义决定了后续所有数据组织、模型结构和损失函数的选择一句话总结就是——把识别汉字问题降维成手写拼音序列问题。2.2 目录结构约定与标签格式手写拼音识别通常每人写一行拼音而不是把每个字母单独裁开。因为手写存在连笔字母之间没有稳定边界强行切分会把b和l切成一团。所以数据按“一张图对应一行拼音”来组织最省事。我一般会用这样的目录结构data/ ├── images/ │ ├── 001.png │ ├── 002.png │ └── ... └── labels.csvimage_path,label images/001.png,hao3 images/002.png,xue2xi2labels.csv只有两列图片相对路径和拼音标签。这里有一个很容易踩的细节拼音串不要用空格分隔字母xue2xi2整体作为一个字符串模型按字符序列学习声调数字直接跟在对应音节后面。图片统一要求是灰度图白底黑字或者黑底白字都行但整个数据集必须一致否则训练时模型会学到背景纹理而不是笔画特征。高度建议固定到 64 像素宽度按比例缩放最长不超过 256 像素预处理时统一处理。2.3 数据划分脚本train/val 拆分与异常过滤有了标签文件第一步不是急着训练而是先把数据划分做好。手写数据通常按书写者分桶同一人的字迹如果既在训练集又在验证集验证分数会虚高这点和图像分类数据集不一样需要注意按人来分。下面的脚本假设labels.csv已经按图像文件名前缀标好了书写者 ID如果暂时没有这个信息就退化为随机划分。import csv import random from pathlib import Path def load_labels(label_path: str) - list[dict]: 读入 labels.csv过滤明显异常的行。 rows [] with open(label_path, r, encodingutf-8) as f: reader csv.DictReader(f) for r in reader: img r[image_path].strip() label r[label].strip() if not img or not label: continue if not Path(img).exists(): print(f[warn] 图片不存在: {img}) continue rows.append({image_path: img, label: label}) return rows def split_by_user(rows: list[dict], val_ratio: float 0.15, test_ratio: float 0.15, seed: int 42): 按书写者前缀划分同一个人的字迹只进一个集合。 rng random.Random(seed) user_ids sorted({r[image_path].split(/)[-1][:3] for r in rows}) rng.shuffle(user_ids) n_val max(1, int(len(user_ids) * val_ratio)) n_test max(1, int(len(user_ids) * test_ratio)) val_users set(user_ids[:n_val]) test_users set(user_ids[n_val:n_val n_test]) train, val, test [], [], [] for r in rows: uid r[image_path].split(/)[-1][:3] if uid in val_users: val.append(r) elif uid in test_users: test.append(r) else: train.append(r) return train, val, test def write_csv(rows: list[dict], out_path: str): with open(out_path, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnames[image_path, label]) writer.writeheader() writer.writerows(rows) if __name__ __main__: rows load_labels(data/labels.csv) print(f有效样本数: {len(rows)}) train, val, test split_by_user(rows) write_csv(train, data/train.csv) write_csv(val, data/val.csv) write_csv(test, data/test.csv) print(ftrain{len(train)}, val{len(val)}, test{len(test)})这段脚本做的事很直接读入原始标签过滤掉图片缺失或标签为空的异常行按文件名前缀提取书写者 ID完成按人划分最终输出三个 CSV 文件。参数说明val_ratio和test_ratio控制验证集、测试集占比按人划分时比例是大于等于设定值的因为一个书写者的全部字迹会进入同一个集合seed固定随机数保证重复执行结果一致。如果你没有按人划分的条件把split_by_user换成随机random.shuffle(rows)再做切分就行。这里加一条提示划分完成后务必人工翻一遍val.csv确认同一个人的笔迹没有混进训练集否则后面所有验证指标都会偏乐观。3. 模型主体结构怎么搭CNN 提特征、LSTM 走序列、CTC 来做对齐3.1 为什么选 CNNLSTMCTC 而不是直接分类直接分类这条路有个前提图片能被切成单字母。手写连笔让这个前提不成立强行切分轻则切碎重则把u和n弄混。正确选择是把它当作序列识别问题来处理用 CTC 损失让模型自动学会“图片特征”和“字符序列”之间的对齐。结构上CNN 负责从图像中提取视觉特征它的输出是一系列特征帧按从左到右的顺序排列。LSTM 在这些特征帧上建模长距离依赖比如zh、ch、sh这种多字母组合。CTC 负责在训练时计算损失它允许模型每个时间步预测一个字符然后把连续重复字符去重、去掉空白帧得到最终拼音串。这个“去重 去空白”的过程在推理阶段非常重要后面会细讲。选型上不建议一上来就用 Transformer手写拼音序列通常很短Transformer 的全局注意力在短序列上优势不大反而对训练数据量和调参要求更高。CNNLSTMCTC 是这类任务里最稳妥、可复现的组合参数量小CPU 上也能跑推理。3.2 字符集设计与输入图像规格字符集是整个模型的“字典”训练和推理必须用同一个顺序一旦变模型权重就废了。我的手写拼音字符集定义如下# 索引 0 固定为 CTC 空白符 chars abcdefghijklmnopqrstuvwxyz1234 char_to_idx {c: i for i, c in enumerate(chars)} idx_to_char {i: c for i, c in enumerate(chars)}字符串中第一个空格就是索引 0 的字符实际对应 CTC 的 blank不参与最终输出。字母a到z占索引 1 到 26声调 1 到 4 占后面的位置。注意这里没有把v单列因为拼音输入里ü在绝大多数输入法方案中都写作v如果数据里出现声调 5轻声可以把它并到 4 或者单独加一个字符取决于你的标注习惯。图像输入规格我推荐高度 64、宽度动态、最大 256。高度 64 是为了让 CNN 经过几次池化后能把高度压成 1宽度保持动态可以避免把长拼音串强行压扁。具体做法读入灰度图后先按比例缩放到高度 64如果缩放后宽度超过 256再等比缩小到宽度 256宽度不足 256 的补白边到统一宽度或者使用可变尺寸的 DataLoader 按 batch 内最长图做 padding。3.3 模型定义代码与关键参数说明下面是基于 PyTorch 的模型定义结构是“3 层卷积 2 层双向 LSTM 全连接输出”。代码里我特意把维度变化注释写清楚方便新手对照着改。import torch import torch.nn as nn class PinyinRecognizer(nn.Module): def __init__(self, num_classes: int, lstm_hidden: int 128): super().__init__() # 输入: (N, 1, 64, W) self.cnn nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), # (N,32,64,W) nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (N,32,32,W/2) nn.Conv2d(32, 64, kernel_size3, padding1), # (N,64,32,W/2) nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (N,64,16,W/4) nn.Conv2d(64, 128, kernel_size3, padding1),# (N,128,16,W/4) nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1)), # (N,128,8,W/4) nn.Conv2d(128, 256, kernel_size3, padding1),# (N,256,8,W/4) nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1)), # (N,256,4,W/4) nn.Conv2d(256, 256, kernel_size3, padding1),# (N,256,4,W/4) nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1)), # (N,256,2,W/4) nn.Conv2d(256, 256, kernel_size3, padding1),# (N,256,2,W/4) nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1)), # (N,256,1,W/4) ) # 把高度 1 压缩掉得到 (N, W/4, 256) self.lstm nn.LSTM( input_size256, hidden_sizelstm_hidden, num_layers2, batch_firstTrue, bidirectionalTrue ) # 双向 LSTM 输出维度是 hidden_size * 2 self.fc nn.Linear(lstm_hidden * 2, num_classes) def forward(self, x: torch.Tensor) - torch.Tensor: x self.cnn(x) # (N,C,H,W) x x.squeeze(2).permute(0, 2, 1) # (N, T, C) x, _ self.lstm(x) # (N, T, 2*hidden) x self.fc(x) # (N, T, num_classes) # 返回 (T, N, num_classes)方便直接喂给 CTC Loss return x.permute(1, 0, 2)逻辑说明CNN 逐步把高度从 64 压到 1宽度只缩小 4 倍保留了时序分辨率LSTM 每个时间步接收 CNN 在对应位置提取的特征双向结构让每个位置能看到左右两边的上下文最后接全连接层输出每个时间步在字符集上的分布。参数说明num_classes传len(chars)即可lstm_hidden控制 LSTM 记忆容量128 对拼音任务足够增大到 256 会提升拟合能力但推理更慢batch_firstTrue配合数据维度为(batch, seq_len, features)。这里要注意如果你改动了 CNN 里的池化核高度不一定能压到 1输出squeeze(2)就会报错改架构时要顺着维度变化重新推一遍。4. 训练配置与调参把验证集拼音正确率从 60% 拉到 90% 的关键设置4.1 数据增强让模型见过更真实的连笔和歪斜手写数据天然存在角度偏移、笔画轻重、字间距不均匀的问题。数据增强是解决这些干扰最直接的手段。我常用的增强策略都围绕“模拟真实书写”展开而不是像图像分类那样随意裁剪旋转。一组比较稳的配置如下增强项参数范围作用随机旋转角度 ±5°模拟书写倾斜随机缩放0.9 ~ 1.1模拟字迹大小变化随机平移水平和垂直各 ±8 像素模拟书写位置偏移笔画粗细扰动腐蚀/膨胀 1 像素模拟不同笔压随机噪声高斯噪声 σ5模拟纸张纹理和扫描噪声实现时使用albumentations库最方便一行代码就能组合。核心注意点是旋转和缩放的角度一定要小±5° 的旋转在实际书写中已经很夸张超过这个范围会破坏拼音字母的可读性不要做水平翻转翻转后字母方向就反了标签也会错。增强是在线做的即每个 epoch 读图时实时生成增广样本不用提前存盘。4.2 训练循环、学习率策略与检查点保存训练循环的核心是 CTC Loss 的参数配置。PyTorch 的CTCLoss有几个参数容易搞错blank必须对应字符集索引 0zero_infinityTrue可以避免某些序列长度下 loss 变成无穷大导致训练中断。输入长度input_lengths是 CNN 输出的时间步数这里即W//4目标长度target_lengths是拼音标签去掉空格后的字符数。import torch from torch.nn import CTCLoss def train_one_epoch(model, dataloader, optimizer, device): model.train() criterion CTCLoss(blank0, zero_infinityTrue) total_loss 0.0 for batch in dataloader: images, labels, target_lengths batch images images.to(device) target_lengths target_lengths.to(device) logits model(images) # (T, N, C) T, N, C logits.shape input_lengths torch.full( size(N,), fill_valueT, dtypetorch.long ).to(device) # labels 需要展平成一维 loss criterion(logits, labels, input_lengths, target_lengths) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() total_loss loss.item() return total_loss / len(dataloader)这段代码的要点images经过模型后得到(T, N, C)其中T是时间步数input_lengths全部取T因为 batch 内图像宽度在 DataLoader 中已经做了 padding 到同一长度target_lengths在数据加载时就要算好和labels一同返回。梯度裁剪clip_grad_norm_是训练 LSTM 的必备操作手写任务里梯度爆炸的概率比图像分类高得多不裁剪的话训练中期 loss 会突然跳到 NaN。学习率策略我推荐 OneCycleLR最大学习率 1e-3配合 AdamW 优化器一般 30 到 40 个 epoch 就能收敛。没有特殊原因不用手动衰减学习率OneCycle 会自动完成预热和退火。检查点保存时除了模型权重还要把chars字符串一起存进 JSON方便推理时恢复字符集否则换台机器很容易出现字符序对不上的问题。4.3 评估指标与解码方式贪心解码还是字典束搜索训练时看 loss但 loss 不能直观反映识别效果。我习惯每个 epoch 结束后跑一遍验证集计算两个指标拼音级正确率和字符级准确率。拼音级正确率是整串完全一致才算对字符级准确率是预测序列和真实序列的字符匹配比例。前者反映最终用户体验后者帮助定位模型是整体不行还是局部字母出错。解码是 CTC 推理的关键一步。贪心解码最简单每个时间步取概率最大的字符然后去重、去空白。比如模型输出hh h a o o _ 3去重变成h h a o 3再去掉空白得到hao3。代码如下def greedy_decode(logits: torch.Tensor, idx_to_char: dict) - str: preds logits.argmax(dim-1) # (T,) out [] prev None for idx in preds.tolist(): if idx ! prev and idx ! 0: # 去掉重复和 CTC blank out.append(idx_to_char[idx]) prev idx return .join(out)贪心解码的问题是它没有考虑拼音的整体概率比如xue2xi2可能被拆成x u e 2 x i 2个别时间步的错误会直接影响整串。束搜索能缓解这个问题保留前 K 个候选序列综合所有时间步的概率重新打分。拼音任务字符集小束搜索的代价很低建议直接把 beam size 设为 10 作为默认值。评估时用拼音级正确率作为唯一衡量标准低于 60% 时先检查数据划分是不是混入了同一书写者再从增强参数和模型容量上找原因。5. 手写拼音识别避坑排查5 个高频现象与解决办法5.1 标签错位导致 loss 不下降第一个要排的坑现象训练 loss 在 5 到 10 之间震荡几十个 epoch 过去完全不见下降验证集正确率始终为 0。原因labels.csv里的标签和图片错位了常见于用 Excel 编辑标签后保存时把hao3变成了hao3带尾随空格或者声调数字被自动转成了科学计数法格式。解决在数据加载后做一次长度校验把标签作为定长序列填充到固定最大长度和target_lengths对比如果长度不一致直接打印对应图片路径。这个校验必须在训练前跑一遍不要等训练完了再查。5.2 长尾拼音学不动高频音节压过生僻组合现象模型对shi、de、le这类高频拼音识别很好但qiong、zhuang这类组合几乎全错整体正确率被长尾拖垮。原因拼音分布极不均衡高频音节样本可能是长尾音节的几十倍模型倾向于输出高频结果。解决第一按字符出现频率计算采样权重训练时用WeightedRandomSampler对低频样本做欠采样或过采样第二对低频拼音的图片做更强的数据增强变相扩充样本量第三验证集上分开统计高频和低频音节的正确率定位真正的瓶颈而不是只看整体分数。5.3 图像缩放失真导致预测全空现象训练 loss 正常下降但推理时预测结果是一长串空白或者输出乱码。原因预处理时直接把整张图 resize 到固定尺寸比如 64×256把宽高比破坏了。手写拼音的高宽比差异很大a和l形状完全不同强行拉伸让模型看到变形的笔画。解决按比例缩放固定高度 64宽度动态计算超过 256 的等比缩小到宽度 256不足的右侧补白。还要注意归一化的通道顺序训练时用什么标准差推理时必须一模一样。5.4 环境问题VSCode 提示 cannot be resolved against python helper roots现象在 VSCode 里配置好 Python 环境装完依赖运行训练脚本时却提示找不到 torch或弹出cannot be resolved against python helper roots这类解释器报错。原因VSCode 选择的 Python 解释器和pip install的不是同一个常见于同时装了多个 Python 版本或创建了多个虚拟环境。解决在 VSCode 里按CtrlShiftP打开命令面板选择 Python 解释器指向你虚拟环境里的python.exe同时在终端里执行python -c import torch; print(torch.__version__)确认当前 shell 的解释器和 VSCode 一致。如果还不行删除 VSCode 的缓存目录重新加载窗口。这类问题不涉及任何代码逻辑纯粹是环境配置排查时先确认which python和pip show torch的输出。5.5 训练正常但推理输出全空白像素极性反了现象模型在验证集上正确率 90%但换了一批图片之后预测结果全是空白偶尔只输出一个字母。原因训练数据是白底黑字推理图片是黑底白字所有像素值翻转模型看到的特征和训练分布完全相反。解决预处理函数里固定写死“白底黑字归一化到 0~1背景为 0、笔画为正值”推理前打印一张预处理后的图片检查背景和笔画的数值关系再进模型。这个坑排起来很快但每次换数据集都会碰到建议把预处理逻辑封装成独立函数训练和推理共用同一个入口。6. 从单张图到批量目录模型验证与导出实践6.1 批量预测脚本与结果输出模型训练完真正用得上的功能是批量预测。给一个目录下的手写图片批量输出拼音结果并写入 CSV做法是遍历图片文件逐张做预处理、推理、解码和标签文件一比对就能算出最终准确率。这个脚本同时可以作为模型验收工具跑完看指标再决定要不要迭代。import csv import glob from pathlib import Path import torch from PIL import Image def preprocess(img_path: str, height: int 64, max_width: int 256): img Image.open(img_path).convert(L) w, h img.size ratio height / h new_w int(w * ratio) if new_w max_width: ratio max_width / w new_w max_width height int(h * ratio) img img.resize((new_w, height)) arr torch.from_numpy(np.array(img)).float() / 255.0 arr arr.unsqueeze(0).unsqueeze(0) # (1,1,H,W) return arr def batch_predict(model, img_dir: str, idx_to_char: dict, devicecpu): model.eval().to(device) results [] for p in sorted(glob.glob(str(Path(img_dir) / *.png))): x preprocess(p) with torch.no_grad(): logits model(x.to(device)).squeeze(1) # (T,C) - (T,) pred greedy_decode(logits.argmax(dim-1), idx_to_char) results.append({image: p, prediction: pred}) with open(predictions.csv, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnames[image, prediction]) writer.writeheader() writer.writerows(results)逻辑说明preprocess按比例缩放到统一高度宽度超过阈值再等比缩放batch_predict循环预测并输出 CSV。参数说明max_width影响显存占用设置越大 batch 内 padding 越浪费256 足够覆盖常见手写拼音行。6.2 导出 ONNX 后的部署要点如果要把模型放到服务端或移动端建议导出 ONNX 格式脱离 PyTorch 运行时环境。导出用torch.onnx.export把输入张量固化为(1, 1, 64, 256)的形状同时把预处理函数用 Python 重新实现一遍保证图像缩放和归一化逻辑和训练推理一致。部署时最容易出问题的地方是宽度不是模型训练时的固定值ONNX 导出要求固定形状所以两种选择要么把输入图片都补白到 256 宽度再进模型要么用动态轴导出。动态轴会牺牲部分推理引擎的优化空间静态形状在 CPU 上跑得更快我的建议是静态形状 输入补白性能最稳。我自己在这个项目上最深的体会是模型架构和训练调参其实只占一半精力另一半全花在数据和标签的规范上。把标签校验脚本固定成每次训练前的第一步确认每一条image_path对应正确标签、每个字符都在字符集里再往下走。这条习惯养成之后后面所有训练都顺很多。希望帮到你。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。