ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写拼音识别实战:从数据切分到CNN训练与推理加速

手写拼音识别实战:从数据切分到CNN训练与推理加速 简介该资料包为基于Python的手写拼音识别课程设计项目采用KNNK最近邻算法实现手写拼音字符的分类识别面向机器学习初学者及需要完成相关课程设计的高校学生。资源包含完整设计报告Word文档、Python源码及训练/测试数据集覆盖从数据预处理、特征提取到KNN分类器构建与准确率评估的主要环节适合用于理解最近邻算法思想及动手复现识别流程。压缩包共2589个文件以1649个txt格式的样本特征/标签数据和924个jpg手写图片为主体另含4个py源码文件、xml配置文件、docx设计报告及说明文档整体仅1.79MB轻量易用。目前已有170人下载学习对于希望快速掌握KNN分类原理并获取可运行代码与报告模板的读者是一份高性价比的参考资源。1. 手写拼音识别这个 Python 项目到底在解决什么问题把一页手写汉字拍照下来让程序不仅能看见笔画还要读出每个字的拼音——这比想象中难得多。手写拼音识别不是做 OCR 转汉字而是把图像里的音节结构切出来、认出来输出类似“ni3 hao3”这样的带调拼音序列。很多初学者拿到代码后栽在第一步模型能跑但识别率惨不忍睹玄学调参也救不回来。这篇笔记会把数据准备、图像切分、模型训练、推理加速和踩坑点拆开讲按步骤做至少能跑通一条完整链路。它适合两类人一类是课程设计或毕业设计需要“能演示、能答辩”的完整项目另一类是刚接触 OCR 和卷积网络的开发者想用一份真实的手写数据集理解从图像到序列的完整处理流。你不需要懂语音识别这里只涉及图像识别和序列建模。2. 从零构建手写拼音数据集采集、标注与目录组织2.1 为什么不要直接抓网上图片训练手写拼音识别的精度上限由数据决定不是由模型决定。网上现成的 OCR 数据集大多是印刷体或规范手写汉字而拼音识别需要的是“音节级”图像——一个拼音音节声母韵母声调对应一张标注好的小图。如果数据里混入了整页扫描图标注颗粒度和图像尺寸不一致训练时模型会无所适从识别率很难超过 70%。我一般会先确定数据规模基线单音节类别数在 400 到 1300 之间常用汉字拼音去重后每类至少 50 张样本。少于 30 张时卷积网络基本学不到稳定特征只能靠数据增强硬撑。常见做法是先用公开的手写汉字库如 CASIA-HWDB做预训练再迁移到自己的拼音标注集上。2.2 一个可复现的采集脚本框架最省事的做法是写一个采集脚本把纸上的手写拼音逐格切出来并按“label/样本编号.png”写入磁盘import cv2 import numpy as np import os def cut_pinyin_grid(image_path, output_dir, rows8, cols10, label_listNone): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) h, w img.shape cell_h, cell_w h // rows, w // cols os.makedirs(output_dir, exist_okTrue) count 0 for r in range(rows): for c in range(cols): x1, y1 c * cell_w, r * cell_h x2, y2 (c 1) * cell_w, (r 1) * cell_h cell img[y1:y2, x1:x2] if label_list and count len(label_list): label label_list[count].strip() else: label unknown label_dir os.path.join(output_dir, label) os.makedirs(label_dir, exist_okTrue) save_path os.path.join(label_dir, f{count:04d}.png) cv2.imwrite(save_path, cell) count 1 cut_pinyin_grid(handwrite_page.jpg, ./dataset, rows8, cols10, label_list[ni3, hao3, shi4, jie4])这段脚本把一张包含 8×10 格子的扫描页按等间距切成单音节图。格子要画得足够规整扫描时纸张不能歪斜超过 2 度否则切出来的音节边缘会带着相邻格子的笔画。参数说明rows和cols对应你设计的拼音格子布局label_list是逐格标注顺序必须与图像从左到右、从上到下完全对齐。2.3 标注命名规范与目录结构切完图后要按“音节标识/样本序号.png”的目录层级保存比如dataset/ni3/0001.png、dataset/hao3/0002.png。这个结构可以直接被 PyTorch 的ImageFolder加载省去自己写 Dataset 的繁琐工作。目录组织上我会分三层raw/放原始扫描页cut/放切好的单音节图labels.txt记录每张图对应的完整拼音序列。labels.txt别删它是后面做序列建模和评估的基础。3. 图像切分与预处理让拼音的声母韵母显形3.1 从整行图像切出单个音节手写拼音的识别可以走两条路直接识别整个音节图或先做字符级切分再组合。直接识别是主流因为声母和韵母在视觉上粘连严重强行按字符切会切断笔画。我这里用的是“音节作为最小单位”的路径。切分的目标函数是找纵向投影的空隙。拼音音节的连通区域通常会在 x 轴投影上形成明显的低谷用这个低谷作为切分点import cv2 import numpy as np def split_syllables(line_img, min_width12, gap_thresh8): gray cv2.cvtColor(line_img, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) v_proj np.sum(binary // 255, axis0) in_gap False start 0 boxes [] for x, val in enumerate(v_proj): if val 0 and not in_gap: in_gap True start x elif val 0 and in_gap: continue elif val 0 and in_gap: if x - start gap_thresh: boxes.append((start, x)) in_gap False # 按最小宽度过滤噪声 return [b for b in boxes if b[1] - b[0] min_width]gap_thresh是切分敏感度的关键参数。手写时字母之间常有连笔投影空隙会被笔画填充值设太大会把两个音节粘成一个设太小又把一个音节从中间劈开。我一般先用 8 到 12 之间的值跑一遍再人工核对切分边界图。3.2 归一化到模型输入尺寸时的比例陷阱模型输入通常固定为 64×64 或 128×32直接 resize 会破坏拼音的长宽比。拼音是横向延展的硬压成正方形会把声母压扁韵母拉伸特征完全变形。常见做法是保持长宽比的“填充式归一化”def pad_resize(img, target_w128, target_h64): h, w img.shape scale min(target_w / w, target_h / h) new_w, new_h int(w * scale), int(h * scale) resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_AREA) canvas np.zeros((target_h, target_w), dtypenp.uint8) y_off (target_h - new_h) // 2 x_off (target_w - new_w) // 2 canvas[y_off:y_off new_h, x_off:x_off new_w] resized return canvas这里INTER_AREA是缩小图像最稳的插值方式不会产生明显的摩尔纹。上采样时我会换INTER_CUBIC细节保留更自然。填充值用 0黑底还是 255白底取决于你后续是否做归一化——如果网络第一层有 BatchNorm填充值影响不大。3.3 膨胀腐蚀与骨架化要不要做很多教程会强调对二值图做形态学开运算去掉椒盐噪声。开运算先腐蚀后膨胀能断开细小的笔画连接但手写拼音的字母间粘连恰恰需要保留。我的经验对声母韵母不分开的整音节识别别做腐蚀会直接毁掉韵母的圆角结构。只做一次 2×2 的中值滤波就够了目的是去掉扫描产生的孤立像素点。骨架化thinning更适合做特征提取的学术实验对实际 CNN 识别没有帮助——网络能从原始灰度图里学到的信息比骨架图多一个数量级。骨架化还会引入大量毛刺得不偿失。4. 模型选型与训练把拼音识别做成图像分类任务4.1 为什么用 CNN 分类器而不是循环神经网络如果每个音节已经是切好的独立图像那么“识别拼音”就是一个标准的图像分类问题输入 64×64 灰度图输出是 400 到 1300 个拼音类别的概率分布。用 CNN 分类器比用 CRNNCTC 简单得多训练稳定数据量要求也低。我实测过的组合里一个 4 层卷积的网络在 500 类拼音、每类 80 张样本的条件下能达到 92% 左右的准确率。继续堆层数收益很小反而容易过拟合。结构上用“卷积 最大池化 全连接”的经典范式就够了不用上残差网络除非你的样本类别超过 1300。完整训练脚本的关键部分如下import torch import torch.nn as nn import torch.optim as optim from torchvision import datasets, transforms transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize((0.5,), (0.5,)) ]) train_data datasets.ImageFolder(./dataset/train, transformtransform) train_loader torch.utils.data.DataLoader(train_data, batch_size64, shuffleTrue, num_workers2) class PinyinNet(nn.Module): def __init__(self, num_classes): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(64 * 16 * 16, 256), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x)) model PinyinNet(num_classeslen(train_data.classes)) optimizer optim.Adam(model.parameters(), lr1e-3) criterion nn.CrossEntropyLoss()两个关键设置一是BatchNorm加在激活之前对灰度图像收敛速度提升明显二是全连接层之间加Dropout(0.5)手写拼音数据集通常不大这个比例能有效压住过拟合。学习率 1e-3 起步训练到第 10 个 epoch 左右如果 loss 不再下降就把学习率降到 1e-4再跑 5 个 epoch 收尾。4.2 类别不均衡怎么处理拼音类别天然不均衡——“shi4”、“de5”这类高频音节样本很多“zhuang4”这种拼写复杂的音节本来就少。训练时如果直接按原始分布采样模型会把高频类全学对、低频类全猜错。我一般用WeightedRandomSampler做样本加权低频类的采样权重按“1 / 类别样本数”计算后归一化。还有一个更省事的办法对低频类做额外的数据增强每 epoch 随机对样本做轻微的旋转±5°和位移±2 像素等价于扩充了低频类样本量。4.3 评估指标的选取不要只看整体准确率。手写拼音识别里最容易翻车的是声调标注和前后鼻音区分。建议把准确率拆成“声母正确率”“韵母正确率”“声调正确率”三块分别统计。声调判断在图像上是靠标注符号的位置和形状很多模型韵母认对了但声调标错单独看这个指标才能暴露出问题。评估时还要注意区分“音节错误率”整串错和“字符错误率”部分错。前者是用户感知的核心指标后者是模型调试的抓手。5. 手写拼音识别避坑指南数据、训练与推理的四类翻车现场5.1 切分后的图像混入了大量空白页现象数据增强后训练集里突然冒出几十张全黑的图loss 不降反升。原因扫描页面里格子与格子之间的空隙被切分脚本当成“音节”输出了。split_syllables只按宽度过滤没有检查前景像素占比。解决加一个过滤条件前景像素占比低于 3% 的图直接丢弃。用np.sum(binary // 255) / (h * w)计算占比这批空白样本基本是无效的。5.2 训练 loss 正常但识别时把所有输入都判成同一个高频类原因全连接层输出维度比类别数少或者num_classes传错了。ImageFolder的classes顺序是字典序不是你的labels.txt顺序如果你用了预训练模型做微调最后一层要重新初始化并且确保类别索引映射一致。解决训练前打印一次model.classifier[-1].out_features和len(train_data.classes)确认一致。推理时用model.eval()并且with torch.no_grad()否则 BatchNorm 的均值和方差会乱掉——这是一个非常隐蔽的黑匣子问题。5.3 模型在训练集上 99%验证集只有 75%原因切分和预处理时训练集和验证集走了不同的代码路径。典型的翻车是训练脚本里做了随机旋转增强但验证集没有做数据清洗背景噪声或者边缘残留比训练集严重。解决把预处理流程抽成同一个函数训练和验证共用只保留数据增强的差异。验证集至少人工清洗一遍千万别拿采集时没切干净的图直接评估。5.4 推理比训练慢一倍BatchNorm 被遗留在了训练模式现象模型部署到 CPU 上跑一张图要 40ms训练时 GPU 上只要 2ms。原因model.eval()丢失了 BatchNorm 的累积统计量切换或者你用torch.jit.trace时输入张量的 batch 维度是 1导致了 trace 时固化了不必要的动态逻辑。解决先model.eval()再torch.jit.script(model)最后固定输入尺寸torch.randn(1, 1, 64, 64)做一次 warm-up 推理把图优化跑一遍再测真实耗时。6. 推理落地与工程加速把模型变成可用的调用接口6.1 一个完整的单图预测函数模型训练好了最终要落到一个能接收图像、输出拼音字符串的接口上。这个函数要同时承担切分、预处理、推理和后处理四件事def predict_syllable(model, img_path, class_to_idx, idx_to_label): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) if img is None: return ERROR: 无法读取图像 syllables split_syllables(img) results [] for x1, x2 in syllables: seg img[:, x1:x2] seg pad_resize(seg, 128, 64) tensor torch.from_numpy(seg / 255.0).unsqueeze(0).unsqueeze(0).float() tensor (tensor - 0.5) / 0.5 with torch.no_grad(): logits model(tensor) pred_idx torch.argmax(logits, dim1).item() results.append(idx_to_label[pred_idx]) return .join(results)第一行切分、第二行归一化、第三行推理顺序错了就出问题。注意类的索引映射class_to_idx和idx_to_label必须来自同一个ImageFolder实例从labels.txt手动构建的映射很可能因为排序差异导致系统性错判。6.2 量化与 ONNX 导出CPU 上提速 2 倍手写拼音识别如果跑在树莓派或低配置电脑上推理速度就是用户体验的生死线。我建议优先做 INT8 量化而不是换模型结构。PyTorch 的量化流程在 CPU 上能稳定提速 1.5 到 3 倍精度损失在 1% 左右model_q torch.quantization.quantize_dynamic( model, {nn.Linear, nn.Conv2d}, dtypetorch.qint8 ) torch.jit.save(torch.jit.script(model_q), pinyin_model.pt)注意quantize_dynamic对卷积层的支持取决于后端如果发现耗时没有变化就把{nn.Linear, nn.Conv2d}改成只量化nn.Linear效果也够。ONNX 导出的坑在于 BatchNorm 和输入尺寸。导出前把模型设为eval模式输入用一个实际尺寸的torch.randn(1, 1, 64, 64)做示例导出后用onnxruntime跑一次输出对比误差超过 0.1 就说明某些算子在转换时被替换了需要排查。6.3 批量识别与日志留痕课程设计或项目演示时只做一个单张预测是不够的。我会额外写一个批量处理脚本遍历一个文件夹内的所有扫描页把预测结果和耗时逐条写入result.csv。这个文件有两个作用第一是答辩时可以拿出数据证明识别效果第二是排查问题时能快速定位是哪一页、哪个音节出了问题。批量处理时要留意线程和显存问题PyTorch 默认会占用全部 GPU 显存批量预测用torch.inference_mode()替代torch.no_grad()会更节省内存。我自己的习惯是每训练完一个版本就顺手导出 ONNX 跑一次全量测试集把准确率和推理耗时记录成一行追加到报告里。这样模型迭代到第几个版本、哪次改动导致效果回退一目了然。手写拼音识别这个方向真正决定项目成败的不是哪个模型更前沿而是数据切得干不干净、预处理链路有没有一致、评估指标拆得够不够细。希望这些经验对你有用。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进