AoZai

从单字到整行:端到端手写中文识别实战

· 15 分钟阅读

序章

单字模型它的续章做完之后,一个很自然的问题是:能不能直接拿它做行识别?

答案是不能——单字模型看的是居中裁剪好的 64×64 字块,但真实图片是一整行字。你得先把行切成单字才能送给单字模型,而”切字”本身就是个难题。更关键的是,很多手写体字是连笔的,根本没有清晰的切分边界。

所以需要做一个端到端的行级模型:输入一整行图像,输出文字序列。这在整个系统中是承上启下的关键一步——往上接整页检测,往下接语言后处理。

这篇文章记录了从单字底座出发,一路做到可用的端到端行识别模型的完整过程。中间有一段外部验证的惨烈翻车,是整轮研究中最让我清醒的时刻。


第一章:从单字底座到行级模型

核心思路:继承,不是重来

单字模型花了很多精力把 CupBackbone + shared engram 调稳,端到端如果另起炉灶重训一个视觉底座,前面的工作就浪费了。

主体思路很直接:把单字模型的 backbone 和 shared prototype 搬过来,改造成能处理变长图像的序列模型。单字看 64×64,行级看 H×W(高度固定,宽度可变),关键是把宽度轴保留下来做时间维度。

SharedCupLineCTC——第一版行级模型

class SharedCupLineCTC(nn.Module):
    def __init__(self, vocab_size, embed_dim=96, dropout=0.16):
        # 视觉编码:CupLineEncoder,把单字 CupBackbone 改成保留宽度轴的版本
        self.encoder = CupLineEncoder(embed_dim=embed_dim, dropout=dropout)

        # 时序增强:多层膨胀卷积,扩大时间感受野
        self.temporal = nn.Sequential(*[
            TemporalDepthwiseBlock(embed_dim, dilation=d, dropout=dropout)
            for d in (1, 2, 4)
        ])

        # 序列建模:双向 GRU
        self.rnn = nn.GRU(
            input_size=embed_dim, hidden_size=128,
            num_layers=1, batch_first=True, bidirectional=True
        )

        # 投影回 embed_dim 后接 shared prototype CTC 头
        self.post_proj = nn.Sequential(
            nn.Linear(256, embed_dim, bias=False),
            nn.LayerNorm(embed_dim), nn.Dropout(dropout)
        )
        self.shared_weight = nn.Parameter(
            torch.randn(vocab_size, embed_dim) * 0.02
        )

几个关键设计:

  • CupLineEncoder:把单字 CupBackbone 的下采样 stride 从针对 64×64 改成针对行图像的 H×W。宽度方向的下采样要克制——下采样太多会丢掉相邻字的边界信息。
  • TemporalDepthwiseBlock:沿宽度轴做膨胀深度卷积,dilation=(1,2,4) 的组合让模型同时看到局部笔画和跨字上下文。
  • BiGRU:双向 GRU 建模字符间的顺序依赖。这里没上 Transformer 是因为行图像的时间步通常就几十到几百,GRU 在这个规模下效率和效果都不错。
  • Shared prototypes:CTC 分类头的权重直接从单字模型的 shared_weight 初始化,把单字阶段学到的字形知识原样继承过来。

训练数据用 HWDB2.1(CASIA 的行文本数据集),单字模型 checkpoint 做视觉底座和 prototype 的初始化,CTC loss 做序列对齐。

第一版结果

184 版在 HWDB2.1 内部验证做到了 CER 约 7.52%,96 版约 9.61%。看着还行,拿去做 GUI 测试——

翻车了。


第二章:“佚名”退化与外部验证的惨案

”佚名”变”失名”

在 GUI 里手写”佚名”两个字,模型经常输出”失名”。“佚”和”失”确实形近,但单字模型明明已经学会了——7356 类的单字模型对”佚”的 Top-1 很稳。为什么到了行级就退化?

原因有几层:

  1. CTC 的对齐不确定性:CTC 不像单字模型那样有明确的 class label,它通过所有可能的对齐路径求和来优化。对于低频字,对齐路径的概率被周围高频字的路径淹没。
  2. 行级上下文的影响:BiGRU 引入的上下文是一把双刃剑——对常见字有帮助,但对低频字,周围的常见字会把特征空间”拉偏”。
  3. 训练分布:HWDB2.1 中”佚”的出现次数极少,CTC 基本没见过它在行中的样子。

Competition13 外部验证——真正的暴击

内部 CER 7% 出头,觉得差不多了。然后拿 CASIA Competition13 offline text line data 做外部验证。

旧版 184 的 Competition13 外部 CER:27.76%

从 7% 到 28%,差了 20 个点。96 版 28.71%,48 版 30.35%——全军覆没。

说实话,看到这个数字的时候人是懵的。但冷静下来想,HWDB2.1 的内部验证太乐观了——训测同源、采集条件一致、字迹风格相似。Competition13 是不同批次采集的,书写风格、纸张、扫描条件都不一样,才暴露了真正的泛化能力。

内部好看不代表生产可用。这个教训值 20 个点的 CER。


第三章:高分辨率时序与短输入补强

第一个补救:保留更密的宽度轴

回顾 CupLineEncoder 的设计,下采样倍数默认是 8——对于长行还好,但对于只有两三个字的短输入,8 倍下采样后宽度上只剩几个时间步,CTC 的对齐空间太小。

把下采样降到 4,保留更密的时序分辨率。这一改对短输入帮助明显——时间步多了,CTC 有更多对齐路径可选,低频字的概率质量不会被过早压缩。

同时加入了两个数据层面的补强:

  • 单字行混入:从 HWDB1 拿单字样本随机拼成短行,增加模型对短输入和低频字的曝光。
  • 合成英文:HWDB 里几乎没有英文,但实际手写中英混排很常见。用少量合成英文行样本让模型至少认识字母。

HWDB2.1 继续提升,但……

184 的内部 CER 从 7.52% 降到了约 7%,看着在进步。但这时候已经不敢只看内部了。

直接上 Competition13 外部验证:184 约 27.76%。内部 7% 到外部 28% 的巨大鸿沟没有任何改善。

问题不在时序分辨率,在于模型根本没在 Competition13 的域里训练过


第四章:Domain Adapt——真正的解法

rare-line 的弯路

一开始想的是:会不会是 HWDB2.1 里低频字太少,模型没见过?于是统计了 HWDB2.1 中的低频和缺失字符,从 HWDB1 单字样本随机生成对应的短行(rare-line),混入训练集。同时试了冻结 encoder 或 shared weight 来”保护”低频字的知识。

结果不理想。冻结过硬会破坏行级序列学习,rare-line 比例过高会拖累主分布上的性能。rare-line 只能轻量使用,不能代替真实行数据的域适配。

Competition13 domain adapt

核心改动很简单:把 Competition13 的一部分拿出来做训练

具体操作:

  • Competition13 按比例切 train/val
  • Train split 加入训练集做 extra train
  • Val split 做 extra validation,监控泛化
  • 从 server17 的行级 checkpoint 和 server15 的单字 checkpoint 初始化
  • 低学习率微调

但在训练之前,先踩了一个标签坑。

标签归一化

Competition13 的标注里有全角数字(“2008”)、全角字母(“ABC”)、全角标点。如果不处理,这些字符和 HWDB 中的半角版本会被当成不同的类,严重影响 CER 计算——“2008”的标注是半角”2008”,但模型预测了全角字符,每个字都算错。

加入 NFKC 归一化,将全角数字、字母、标点统一映射到半角。再补充几个特殊映射:— → -〔 → [〕 → ]

这个处理虽然简单,但对 CER 的影响非常大——不做的话,Competition13 上平白多出几个点的”错误”。

最终结果

模型参数量Weighted CERHWDB2.1 CERComp13 CER
48 domain_adapt1.11M16.69%12.11%21.27%
96 domain_adapt1.56M13.85%9.22%18.48%
184 domain_adapt2.47M10.80%6.90%14.71%

从 27.76% 到 14.71%,Competition13 的外部 CER 几乎砍半。虽然 14.71% 离”好用”还有距离,但方向已经对了——剩下的不是架构问题,是数据覆盖和路由策略问题。


第五章:一个意外的观察——48 在短输入上反而更好

在 GUI 测试中反复出现了一个现象:对于”佚名”、“时代的弄潮儿”、“I have a ball”这类短输入,48 版的输出经常比 184 更准确。但长行上 184 明显更好。

一开始觉得是巧合。多测了几十条后发现规律确实存在。

原因推测:48 的 embedding 空间维度低,类间边界更模糊,CTC 解码时 beam search 不会过早锁定一个”看似确定但实际上是错的”的路径。184 对低频字的 embedding 更紧凑,反而在短时间步上容易过拟合到近形高频字。

不管原因是什么,这个现象指向了一个更务实的部署策略:不选唯一模型,按输入长度路由

  • 极短输入(1-3 字):优先用单字模型逐字识别,或路由到 48
  • 短词短句(4-10 字):48 和 184 并行,根据置信度和字符合法性选择
  • 正常长行:默认 184

96 版处于不上不下的尴尬位置——参数量比 48 大不少,精度又明显不如 184,暂时不作为主推。


第六章:复盘

单字底座的价值

端到端的视觉底座和 prototype 全部从单字模型继承,没有另起炉灶。这意味着单字模型的每一点改进——CupBackbone 的稳定化、shared engram 的原型质量、7356 类的覆盖面——都直接传导到了行级模型。反过来想,如果当初单字模型用的是某个纯 softmax 分类头、连 embedding 结构都没有的方案,端到端就完全没法迁移。

外部验证比内部指标重要十倍

HWDB2.1 内部 CER 从 9.61% 一路优化到 6.90%,看着是一条漂亮的下降曲线。但 Competition13 第一次测出来 27.76% 的时候,前面所有的”优化”都打了个问号。

domain adapt 之后 Competition13 CER 降到 14.71%,虽然还不完美,但至少说明泛化的大方向是对的。内部指标和外部指标之间的差距本身,就是域差异的量化。

不要强行选一个”最好的”模型

48 在长行上不如 184,但在短输入上反而更稳。如果当初只看整体 CER 就直接淘汰 48,就会丢掉短词场景的最佳选择。多模型路由的成本并不高,带来的鲁棒性提升却很实在。

CTC 的边界

纯 CTC 模型对低频字的判别有天然天花板——它没有显式的语言模型,完全依赖视觉特征和序列上下文。对于”佚名”这类短词,单字模型逐字识别反而更可靠。这不是说 CTC 不好,而是说它需要配套的兜底机制:短输入走单字、长输入走 CTC、不确定的走重排。


写在最后

从单字的 shared engram 到端到端的 SharedCupLineCTC + domain adapt,这条路走了不少弯路。中间那次 Competition13 外部验证 27.76% 的结果,是整轮研究中最有价值的失败——它逼着我把”泛化”从一句口号变成了具体的 domain adapt 设计和路由策略。

模型层面,184 domain_adapt_norm 是当前的长行主力,48 是短输入候选。但更重要的是,这套方案证明了从一个好的单字底座出发,用正确的序列建模和域适配流程,能在不到 3M 参数内做出一套可用的中文手写识别系统

后续如果要继续提升,精力应该花在三个方向:更智能的短/长输入路由、基于语言模型的候选重排、以及和单字模型的联合推理。继续堆训练轮数的边际收益已经不大了。


端到端模型的研究和训练借助了 AI 工具辅助思路梳理和代码实现。

Share: