手写识别(Handwriting Recognition)这个方向,2016 年前后已经走过了好几轮技术代际。从早期模板匹配,到 HMM/GMM,再到深度学习把端到端模型变成主流,那一年刚好站在一个关键转折点上。现在回头看,很多问题并没有消失:笔画连写、行切分错误、同音字、长句上下文、标注数据稀缺、不同人笔迹差异大…… 如果你今天还在做 OCR、票据识别、试卷批改、签名识别这些场景,2016 年留下的这套工程经验仍然值得重新过一遍。
这篇文章会按我实际操作时的顺序来拆。先弄清楚手写识别到底在解决什么,再还原一条典型识别管线,然后讲如何用公开数据集跑通最小实验,最后是我这些年反复踩过的坑和排查思路。它不是单纯的技术考古。很多现在看起来“理所当然”的步骤,放到 2016 年都是靠大量试错才确定下来的。
1. 先弄懂 2016 年的手写识别在解什么问题
1.1 手写识别不是普通 OCR
很多人第一次接触手写识别,会误以为它和印刷体 OCR 差不多:把图片丢进模型,输出文字就行。实际上差别很大。
印刷体字符形状规范,字体相对固定,背景也容易控制。手写体不一样,同一个字不同人写出来,可能在大小、倾斜、笔画粗细、连笔方式上完全不同。更麻烦的是,手写经常不是逐字分开的。英文有字母连写,中文单字内部有大量复杂笔画,还会出现行线歪斜、字与字重叠、涂改痕迹、阴影和低对比度。
OCR 更像“图像分类 + 检测”的组合。手写识别往往还需要处理序列信息:某个字符是什么,不仅取决于这张图,还取决于前后字符和整句语义。2016 年前后最大的变化,就是整个领域开始把手写识别当成“序列识别”而不是单纯的“图像识别”来做。
1.2 离线识别和在线识别的区别
手写识别必须分清楚两个场景:
- 在线手写识别:有笔画采样数据,能拿到笔画的顺序、速度、压力等信息,常用于触屏输入、电子签名。
- 离线手写识别:只有最终图像,没有笔画顺序,只能从像素出发,常见于扫描文档、旧档案数字化、拍照翻译。
2016 年讨论得比较多的是离线整句识别,尤其是扫描文档中的手写文本。因为在线数据可以拿到时序,模型相对容易做;离线识别只有静态图,连笔画都要靠模型猜,难度高很多。
另一个维度是识别粒度:
- 单字识别:把每个字符单独切出来,做分类。
- 整行识别:输入是一行手写文字,输出是字符序列。
- 整篇识别:输入是整页文本,需要先分块、分行、再识别。
单字识别最容易,但现实很少给你切好的标准单字。整行和整篇识别在工程上更实用,也更能体现 2016 年深度学习方法的优势。
1.3 2016 年前后为什么变化这么大
2016 年前后,手写识别的研究重点从“人工设计特征 + 分类器”转向“神经网络自动学习特征 + 序列建模”。
早期系统常见流程是:先做二值化、去噪、倾斜校正,然后切字,再对每个字提取方向直方图、Gabor 特征、边缘特征,最后送进 SVM、KNN 或 HMM 分类。这套流程的问题在于,切字一旦出错,后面全错。而手写连笔经常让字符边界非常模糊,人工规则很难覆盖所有写法。
后来大家发现,与其强行把所有字符切干净,不如让模型看到一整段图像,直接预测字符序列。CNN 负责提取图像特征,RNN/LSTM 负责建模上下文关系,CTC 负责把每一步预测对齐到最终文字。这种“输入图像、输出文本”的端到端方式,让手写识别进入了一个更实用的阶段。
2016 年最有价值的不单是某个模型,而是一整套思路:先预处理图像,再用卷积网络提特征,再用序列模型处理上下文,最后通过解码与词典约束输出结果。这套思路直到今天仍然是许多手写识别系统的主干。
2. 从图像到序列:经典识别管线拆解
2.1 预处理决定上限
很多初学项目把精力都放在模型结构上,我反而建议大家先认真做预处理。原因很简单:模型可以学习特征,但学不会“凭空修复一张质量很差的图”。
常见预处理步骤包括:
- 灰度化与归一化:把彩色图转成灰度,缩放到统一高度,保持宽高比。
- 二值化:把文字和背景分离,但要注意阴影和纸张噪声。
- 倾斜校正:扫描时页面经常歪,需要先转正。
- 去除噪点:去除孤立点、划痕、印章,但这些操作也要克制,过度去噪会抹掉细笔画。
- 对比度增强:对于铅笔字、浅色笔迹和低质量扫描件,对比度增强能明显改善识别效果。
我一般会先把所有样本缩放到同一高度,比如 32 像素或 64 像素,宽度按比例缩放,但限制一个最大值。然后做归一化,让像素值落到 0 到 1 之间。这样模型输入更稳定,训练时收敛也更快。
这里要特别提醒一点:不要把预处理当成固定的“万能流水线”。不同笔迹、不同纸张、不同拍照条件下的效果相差很大。第一次实验可以先最小预处理,如果效果差,再逐步增加去噪和校正步骤。
2.2 切行切字不能想当然
手写识别的难点经常不在分类,而在切分。
如果是印刷体,行间有明确边界,字间距也相对均匀,切分相对简单。手写体的行甚至可能是歪的,字与字之间可能重叠,同一个字内部也可能因为连笔被误切成两半。
2016 年前后的常用做法是先做行切分,再对每一行做纵向投影,找出字符边界。这个方法在工整手写体上效果不错,但遇到任意角度、重叠严重的文本,投影法会失效。
更稳妥的思路是“不做显式字符切分”,让模型对整行图直接输出字符序列。这也是 CNN + RNN + CTC 这类结构越来越流行的原因。CTC 允许模型输出一个比目标文本更长的特征序列,再通过动态规划找出最可能的对齐路径。这个机制绕开了字符边界问题。
如果你非要切字,建议至少加一个人工检查环节。自动切完以后随机抽几十张图看边界,尤其是连笔样本。不要只盯着准确率,切分质量直接影响最终文本质量。
2.3 特征从手工提取变成自动学习
在传统方案里,特征工程是核心工作。方向直方图、笔道密度、局部二值模式、轮廓特征,每一种都有人尝试过。问题是特征设计需要针对特定语言和书写风格,换一个数据集效果就可能掉一截。
2016 年主流做法已经转向用卷积神经网络自动提特征。CNN 的结构优势在于局部感受野,也就是每个神经元只关注图像的一小块区域,并且同一套卷积核在整个图片上滑动。这不仅减少了参数,还能捕捉笔画在不同位置的局部模式。
对于手写识别,常见的底层结构是:先用若干卷积层和池化层把图像转成特征图,再沿水平方向压缩成特征序列,最后接 RNN/LSTM。这种结构让模型同时拥有了空间特征提取能力和序列建模能力。
使用 CNN 提特征时,要注意池化会降低分辨率。手写字符很细,如果池化次数太多,细笔画信息可能丢失。一般建议在识别任务里不要连续堆太多池化层。高度从 64 降到 4 或 8 是可以接受的,但降到 1 之前要谨慎。
2.4 序列模型和 CTC 为什么关键
手写图像不是一列孤立的字符,字符之间存在鲜明的上下文依赖。比如英文里 “th” 后面出现 “e” 的概率非常高,中文里“我”后面接“们”或“们”的概率也不低。这类信息很难靠单字分类器捕捉。
LSTM 这种带有记忆结构的循环网络,在 2016 年非常适合手写序列建模。它可以记住之前已经识别过的字符信息,用于帮助判断当前字符。不过标准 LSTM 只能从左往右看,而实际文字中前后文都重要,所以双向 LSTM 更常用。
双向 LSTM 会同时读入图像特征的正向序列和反向序列,输出每个时间步的上下文表示。这个上下文表示再经过一个全连接层,预测当前时间步属于哪个字符。但问题来了:模型并不知道每个字符在图像上对应哪个位置,或者说模型没有一份按字符对齐好的标签。
这时候 CTC 出现。CTC 不要求每一步都有精确标签,它允许模型输出“重复字符”和“空白符”。训练时,CTC 会计算所有可能对齐方式的概率和,并和真实文本对比求损失;解码时,模型会合并重复字符、去掉空白符,得到最终文字。
这套“CNN + 双向 LSTM + CTC”的组合,是 2016 年前后手写识别最重要的技术路线之一。即便后来出现 Attention 和 Transformer 结构,CTC 仍然在很多轻量离线识别任务里占有一席之地。
3. 环境准备与最小实验:用公开数据集跑通一条流程
3.1 数据集先选小规模
学习阶段不要急着找很大的私有数据集。公开数据集更合适,因为大家都可以对比,问题也容易查。
常见选择有:
- MNIST:数字手写体,适合验证图像分类基础,但太简单,不能代表真实手写识别。
- IAM Handwriting Database:英文手写文本,包含整行和整页内容,适合做离线英文手写识别。
- CASIA-HWDB:中文脱机手写数据库,适合中文单字和文档级实验。
- EMNIST:MNIST 的扩展版,包含字母和数字,适合英文手写字符识别。
如果是第一次跑,我建议先用 MNIST 跑通一个单字分类器,再用 IAM 或 CASIA 的子集跑一个“图像到文本”的序列模型。这里并不要求所有人复现同一套指标,重点是先把输入、输出、日志、评估这套流程走通。
如果使用 IAM 这类研究用数据集,要留意授权协议。实际工程中如果要商用,尽量使用自建数据或明确允许商用的数据源。
3.2 运行环境与依赖
2016 年跑这种实验,环境比较复杂:TensorFlow 1.x、Caffe、Theano 同时存在,安装和版本冲突经常让人头疼。现在如果你重新复现,可以直接用 PyTorch 或新版 TensorFlow/Keras,API 更现代,排查也方便。
一个比较稳妥的实验环境:
- 操作系统:Ubuntu 20.04 或 22.04,Windows 也有对应方案,但命令行和包管理在 Linux 下更顺手。
- 深度学习框架:PyTorch 或 TensorFlow,任选一个。
- 图像处理:OpenCV 和 Pillow,用来做缩放、灰度化、透视校正。
- 数据处理:NumPy,用于数组和批量数据操作。
- 计算设备:有 NVIDIA GPU 最好,训练速度快很多;没有 GPU,先用小数据集、小 batch 跑通也可以,只是别期待快速迭代。
我一般建议先装一个虚拟环境,把依赖隔离起来。比如用 conda 创建环境,Python 版本用 3.9 以上。千万别直接往系统 Python 里无脑装包,不然后面项目一多会互相干扰。
3.3 一条最小可运行流水线
第一步,先把数据整理成模型能吃的格式。以“图像到文本”为例,每条样本包含一张图片路径和一个文本标签。建议统一把图片高度设为 32,宽度按比例缩放,最长不超过某个值,比如 512 或 1024。过长的行可以截断或分段处理。
第二步,构建数据读取器。不要一次性把全量图片读进内存。图片数量多时,内存很容易爆掉。更稳妥的方式是写一个 Dataset 类,每次按 batch 读取图片并做归一化。
第三步,定义模型。下面是一个示意结构,并不代表 2016 年某份官方代码,而是把常见思路串起来。
# 示意结构:CNN 提特征 + 双向 LSTM + CTC 解码 # 实际运行时需要根据框架版本和输入尺寸调整 import torch import torch.nn as nn class ConvLSTM(nn.Module): def __init__(self, num_classes): super().__init__() self.cnn = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), ) self.lstm = nn.LSTM(input_size=64 * 8, hidden_size=128, bidirectional=True, batch_first=True) self.fc = nn.Linear(128 * 2, num_classes) def forward(self, x): # x: batch, channel, height, width x = self.cnn(x) # 输出: batch, 64, h/4, w/4 b, c, h, w = x.size() x = x.permute(0, 3, 1, 2).reshape(b, w, c * h) # 变成序列 x, _ = self.lstm(x) x = self.fc(x) # batch, seq_len, num_classes return x这个代码看起来简单,但真正训练时要注意几点。
- 输入高度固定,宽度可以不同,因此 batch 内图片宽度要 padding 到同一长度。
- LSTM 会把 padding 部分也当成有效输入,所以训练时要注意 mask,否则模型可能学到大量空白帧。
- CTC loss 需要模型输出长度为
T,而目标长度为U,且T必须大于等于U;如果卷积池化后宽度过小,会报错或训练不稳定。
第四步,训练时使用小 batch。先跑 20 个 batch,确认 loss 在下降,再跑完整训练。常见损失函数用ctc_loss,优化器用 Adam,学习率从 0.001 开始。如果发现 loss 不降,可以把学习率调到 0.0003 或检查标签是否有空串。
3.4 验证输出长什么样
训练结束后,需要把模型的预测结果写出来。解码方式最基础的是贪心解码:每个时间步取概率最大的字符,再合并重复字符并去空白。用 PyTorch 自带的 CTC 解码或者自己写一个简易解码都可以。
如果输出结果像这样:
真实标签: handwriting recognition 预测结果: handwriting recognition说明流程跑通。如果预测结果是一堆单个字母,中间没有空格,多半是解码后没有合并空白符,或者数据集里把空格当普通字符处理时没有对应设计。这是初学阶段最常遇到的问题之一。
更严格的做法是计算字符错误率 CER 和词错误率 WER。CER 是编辑距离除以真实字符数,WER 类似,但以词为单位。这两个指标比准确率更能反映序列识别质量。只要 CER 不为 0,说明预测结果和目标存在插入、删除或替换,就可以继续调。
4. 参数、评估与调优:不能只看准确率
4.1 指标:准确率、CER、WER
手写识别不是“猜对图片类别”的任务。整行识别中,多一个字符、漏一个字符、把一个字符识别成另一个,都是不同性质的问题。
常用指标如下:
| 指标 | 含义 | 使用场景 |
|---|---|---|
| 单字准确率 | 正确字符数 / 总字符数 | 单字分类,容易实现但不利于定位错误 |
| CER 字符错误率 | 编辑距离 / 真实字符数 | 整行、整篇识别,能反映插入删除替换 |
| WER 词错误率 | 编辑距离 / 真实词数 | 英文和大部分以词为单位的语言 |
| 行级准确率 | 整行完全正确的比例 | 表单、票据等强格式场景 |
我记得早期跑实验时,模型单字准确率看起来很高,但 CER 一直不低。原因是预测结果经常把词组顺序搞乱,或者把空格识别成字符。只看准确率完全发现不了这些问题。
建议每次实验都同时记录 loss、CER、WER,并且把预测错误的样本单独存成文本文件。只盯着训练集 loss,不打开预测结果看,等于没有调试。
4.2 关键参数怎么定
2016 年前后做实验时,很多参数靠猜,现在虽然工具更完善,但核心参数仍然要逐个确认。
| 参数 | 作用 | 常见调整思路 |
|---|---|---|
| 输入高度 | 决定图像信息量 | 手写英文常用 32 或 64,中文可以更高 |
| 输入宽度上限 | 限制单行长度 | 过长行截断或分段,否则显存不够 |
| 卷积核数量 | 特征通道数 | 32 起步,显存允许可以逐步增加 |
| 池化次数 | 降低分辨率 | 控制在下采样后宽度仍大于字符数 |
| LSTM 隐藏层大小 | 上下文建模能力 | 128 或 256,太大会过拟合 |
| 批大小 batch size | 训练稳定性和显存占用 | 小显存用 8 或 16,大显存用 32 |
| 学习率 | 收敛速度 | Adam 常用 0.001,不稳时降到 0.0003 |
| 词典大小 | 解码约束 | 低频词要保留,不要随便删 |
这些参数之间不是独立的。输入高度提高后,CNN 卷积核数量和池化次数可能要同步调整;batch size 变大后,学习率可能需要相应调整。建议一次只改一个变量,并且每次记录对比结果。
4.3 调优顺序
我倾向于按这个顺序调优:
- 先把单条样本跑通,确认数据读取、标签对齐、CTC loss 都没有问题。
- 用很小的训练集跑几十步,确认 loss 能下降,模型没有结构错误。
- 用全量训练集跑一个较短周期,记录 CER。
- 先调输入预处理,比如行高、归一化、二值化。
- 再调模型结构,比如卷积层数、LSTM 层数。
- 最后调训练参数,比如学习率、batch size、dropout。
- 每次改完都输出预测样例,不要只看指标。
有一个很常见的错误是:模型效果差,马上去改网络结构,但真正的问题是训练数据里标签和图片对不上。或者图像处理环节把字符压缩成一条黑线,导致模型无法学到任何特征。所以调优前先检查“模型到底看到了什么”。
4.4 词典和后处理
模型输出只是原始字符序列。实际产品里通常还需要词典纠错和后处理。
2016 年就已经有这一步。常见做法是准备一个词表或语言模型,在解码时结合词典约束。例如英文手写识别中,模型可能输出 “teh”,词典语言模型会把它纠正为 “the”。中文场景则会做“用户词典”和“自学习词典”,把特定领域术语的正确写法优先输出。
后处理还包括:
- 空格修正:把多余的空白符去掉,或者在正确位置补空格。
- 标点修正:中文场景要区分全角半角。
- 置信度过滤:低置信度的字符标记出来,让人工复核。
- 固定格式校验:日期、身份证号、金额等字段用规则校验。
这一层即使不使用重型语言模型,也能明显改善最终结果。关键是要把词典和规则放在解码阶段,而不是训练完之后在结果字符串上硬改。硬改容易把正确内容也改错,词典约束则是在候选序列中寻找概率最高的组合。
5. 常见坑点与排查链路:先怀疑数据,再怀疑模型
5.1 输出为空或乱码
最让人崩溃的现象是模型训练完,预测结果全是空白或者乱码。
这时候不要急着改模型,先按顺序排查:
- 解码逻辑是否正确:CTC 解码后是否去掉了空白符和重复字符。
- 标签映射是否一致:训练时的字符索引和预测时的字符索引是否用了同一张表。
- 序列长度是否被压没了:卷积池化后宽度可能小于目标长度,导致 CTC 无法对齐。
- 输入图片是否预处理失败:比如图片被 resize 成全黑或全白。
- 标签是否包含训练时没见过的字符:如果字符表遗漏了某个中文或英文字母,预测结果里可能直接为空。
我遇到过最离谱的一次,是读取图片时用了默认灰度方式,结果把透明通道当成了图像的一部分,所有图片变成黑色块。模型当然什么都学不到。所以在训练前,一定要把随机 batch 的图片保存成网格图,亲眼确认输入正常。
5.2 训练曲线不合理
训练 loss 一开始就震荡,或者直接 NaN,通常不是网络结构的问题。
先看数值稳定性:
- 是否存在空标签。
- 标签长度是否超过模型输出长度。
- 学习率是否设置过大。
- 是否使用 log softmax 后再接 CTC loss,很多框架的 CTC loss 内部会做归一化,不要重复加 softmax。
- 是否有除零问题,比如归一化时图像标准差为 0。
如果 loss 下降但 CER 不降,大概率是模型发生了“过拟合到训练集”或“模型只学会了最多字符的常见模式”。此时可以增加数据增强,例如随机旋转、随机缩放、随机灰度扰动,或者增大 dropout。
手写识别的数据增强要谨慎,不能用太强的旋转或透视变换。手写已经很难,过度增强会让字符变得更难认,反而伤害模型。
5.3 单字好但整句不好
很多项目在单字数据集上效果好,但一到整行识别就崩。原因通常是缺少上下文建模,或者训练数据里没有足够的整句样本。
单字识别模型的缺陷是无法利用上下文。比如英文手写中 “cl” 和 “d” 很像,单看图像很难区分;但如果前面是 “wor”,后面大概率是 “ld”,而不是 “d”。整句模型可以利用这些信息,所以不能用单字模型直接替代。
如果整句识别效果差,先检查训练集是否有足够的行标注。如果一条样本只有一个字,模型很难学到字符间的依赖关系。另一种方案是引入语言模型后处理,而不是重新训练网络。
5.4 排查顺序
我总结的通用排查顺序,适用于大多数手写识别工程:
- 先看输出日志和保存的错误样例:报错?空输出?乱码?还是结果不稳定?
- 再看输入数据:图片路径、标签映射、预处理、归一化、batch 内 padding。
- 再看资源占用:显存是否溢出,内存是否持续增长,CPU 是否成为瓶颈。
- 再看模型结构:输出维度、序列长度、dropout、是否双向。
- 再看训练过程:loss 曲线、学习率、batch size、评估指标。
- 最后才考虑换更大模型或换更新结构。
不要一上来就改成 transformer 或重写模型。很多问题在数据读取和预处理环节就能定位。2016 年如此,现在仍然是如此。
6. 回到“未来”:2016 的方法对现在还有多少参考价值
6.1 已被取代的部分
2016 年使用的一些具体技术,现在已经不是最优选项。
比如老版本的 Tesseract、特定领域的 HMM 模型,在通用扫描场景里基本被深度学习方法取代。用 TensorFlow 1.x 或 Theano 写的代码也很难直接迁移到现代环境。如果只是为了复现,没必要执着于当年的依赖版本。
另外,CTC 在手写长文本解码时,虽然还能工作,但遇到明显上下文歧义时表现有限。注意力机制和 Transformer 结构能更好建模字符间长距离依赖,很多现代手写识别项目已经转向这类结构。
不过这不代表 2016 年的工程经验过时。恰恰相反,当年踩过的数据坑,今天依然存在。
6.2 仍然需要保留的工程能力
从 2016 年到现在,有几个问题始终没有变:
- 数据质量比模型结构更影响上限。
- 预处理必须和场景绑定,不能一套流程打天下。
- 序列评估不能只看单字准确率。
- 解码和后处理决定产品落地质量。
- 日志、错误样例和评估指标必须完整,否则没法排查。
我之前遇到一个手写票据识别项目,团队花了很多时间换模型结构,最后发现真正的问题是扫描件里有很多底色花纹,预处理时没有有效去除。当把背景归一化做对后,哪怕用较小的模型,识别率也明显提升。
这说明什么?技术演进解决了一部分问题,但工程化能力依然是核心。
6.3 如果现在要开始,可以怎么吸收这段历史
如果你现在打算做手写识别,我的建议是:
- 先确定业务场景是单字、整行还是整篇。这决定了模型复杂度。
- 准备 100 到 500 张有代表性的样本,先人工标注,再评估预处理流程。
- 用轻量 CNN + 序列模型跑通最小流程,不要一开始就构建大模型。
- 用 CER、WER 和错误样例来评估,而不是只看准确率。
- 把小规模可靠结果做出来后,再考虑数据增强、更大的训练集、语言模型后处理。
- 最后把模型封装成服务时,要预留置信度输出、超时控制和日志记录。
2016 年给我们留下的最重要经验,不是某个模型结构不可替代,而是“把问题拆清楚、把数据洗干净、把评估做完整”这套方法。直到今天,这仍然是手写识别项目成功的底色。
如果你也想做手写识别,我建议不要先追新模型,先把数据问题和评估指标想清楚。这是 2016 年最值钱的经验,放到今天也一样。