简介:这是一份基于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", encoding="utf-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", encoding="utf-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(f"train={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 为什么选 CNN+LSTM+CTC 而不是直接分类
直接分类这条路有个前提:图片能被切成单字母。手写连笔让这个前提不成立,强行切分轻则切碎,重则把u和n弄混。正确选择是把它当作序列识别问题来处理,用 CTC 损失让模型自动学会“图片特征”和“字符序列”之间的对齐。
结构上,CNN 负责从图像中提取视觉特征,它的输出是一系列特征帧,按从左到右的顺序排列。LSTM 在这些特征帧上建模长距离依赖,比如zh、ch、sh这种多字母组合。CTC 负责在训练时计算损失,它允许模型每个时间步预测一个字符,然后把连续重复字符去重、去掉空白帧,得到最终拼音串。这个“去重 + 去空白”的过程在推理阶段非常重要,后面会细讲。
选型上不建议一上来就用 Transformer,手写拼音序列通常很短,Transformer 的全局注意力在短序列上优势不大,反而对训练数据量和调参要求更高。CNN+LSTM+CTC 是这类任务里最稳妥、可复现的组合,参数量小,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_size=3, padding=1), # (N,32,64,W) nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # (N,32,32,W/2) nn.Conv2d(32, 64, kernel_size=3, padding=1), # (N,64,32,W/2) nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), # (N,64,16,W/4) nn.Conv2d(64, 128, kernel_size=3, padding=1),# (N,128,16,W/4) nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), # (N,128,8,W/4) nn.Conv2d(128, 256, kernel_size=3, padding=1),# (N,256,8,W/4) nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), # (N,256,4,W/4) nn.Conv2d(256, 256, kernel_size=3, padding=1),# (N,256,4,W/4) nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), # (N,256,2,W/4) nn.Conv2d(256, 256, kernel_size=3, padding=1),# (N,256,2,W/4) nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), # (N,256,1,W/4) ) # 把高度 1 压缩掉,得到 (N, W/4, 256) self.lstm = nn.LSTM( input_size=256, hidden_size=lstm_hidden, num_layers=2, batch_first=True, bidirectional=True ) # 双向 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_first=True配合数据维度为(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必须对应字符集索引 0,zero_infinity=True可以避免某些序列长度下 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(blank=0, zero_infinity=True) 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_value=T, dtype=torch.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_norm=5.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 里按Ctrl+Shift+P打开命令面板,选择 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, device="cpu"): 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", encoding="utf-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对应正确标签、每个字符都在字符集里,再往下走。这条习惯养成之后,后面所有训练都顺很多。希望帮到你。
本文还有配套的精品资源,点击获取