Transformer手写文本识别:从架构选型到CER评估

简介:面向深度学习与OCR领域学习者的Transformer手写文本识别完整工程资源,聚焦编码器-解码器架构的端到端序列识别方案,无需字符分割即可处理连续笔迹。系统引入多头自注意力机制捕捉字形长程依赖,融合二维相对位置编码保留空间结构,并在IAM英文与CASIA-HWDB中文数据集上分别取得94.7%和91.2%行级准确率,较LSTM-CTC错误率降低23.6%。资源共18个文件,以9个Python脚本为主体,覆盖数据预处理、弹性形变增强、模型定义、训练引擎与评估模块,另含Notebook可视化演示、依赖说明及Readme文档,压缩包仅132KB。已有133人学习,适合需要对照源码理解Transformer在OCR领域落地的开发者。配套资料提供完整训练流水线、超参数配置、推理接口及可视化分析组件,可直接复现实验并迁移到自定义手写数据集。

1. 从CRNN到Transformer:手写文本识别系统到底改了什么

不要把Transformer接手写文本识别想成“换个玩具再刷一次榜”。真实情况是,demo里小心翼翼调好的模型,一上真实扫描件就容易翻车:单行文字倾斜、字符连笔、纸张底色杂乱,vision transformer的位置编码一越界,输出的句子就开始胡言乱语。这里要说的“基于Transformer的手写文本识别系统”,指的是把视觉特征提取和文本序列建模都换成Transformer的那类方案,常见形式是“视觉编码器 + 文本解码器”,解决的是传统印刷体OCR覆盖不好的场景。适合正在做档案数字化、试卷阅卷、票据识别的工程团队,也适合在CRNN上被CTC对齐折磨过、想换架构的个人开发者。

2. 系统架构选型:TrOCR式“视觉编码器+文本解码器”为什么能成为主流

2.1 为什么CRNN+CTC不够,Transformer强在哪里

传统手写文本识别(HTR)的主干是CRNN+CTC:CNN下采样出二维特征图,丢掉高度方向后压成一维特征序列,喂给双向LSTM,最后CTC做逐帧对齐。这套组合不是不行,而是有三个硬伤。第一,手写字符宽度差异极大,有的"l"占几个像素,有的"mm"挤成一团,CTC却假设每一帧的发射概率相互独立,它对齐的是“图像帧”而不是“语言单元”,一旦连笔严重,对齐就出错。第二,LSTM是顺序建模,一张宽度768的行图特征序列有96步,反向传播路径长,显存和耗时都不低。第三,它几乎没有利用语言上下文,字典信息只体现在CTC的路径空间里。

Transformer把这两个环节分开重构:视觉侧用vision transformer(ViT)或Swin Transformer把图像切成patch再做自注意力,处理长距离的笔画依赖;文本侧用自回归解码器逐个token生成,每一步都显式地以“前面已经生成的字符”为条件。这样模型能学到“上下文里出现过which,所以下一个字符更可能是m而不是n”这种概率关系。需要说明,ViT这类vision transformer在图像分类任务里常当主干,但在HTR里通常只充当视觉编码器,真正让手写识别变强的是它后接的解码器。

但Transformer也带来新的限制。它不擅长小样本,位置编码是定长的,对超宽行图像不友好,而且生成式解码在推理时是逐个token输出的,延迟比CTC一次性输出要高。选型上不能只看精度,还要看数据量和部署环境。很多人以为上了Transformer就能无视这些前提,实际项目里第一个被反噬的往往就是超宽行输入。

2.2 三种常见HTR架构对比与选型表

这里我先列一个对比表,把从CRNN迁到Transformer的几种主流做法放在同一张表里看。

架构视觉特征序列建模优点主要问题适用场景
CRNN + CTCCNN双向LSTM + CTC轻量、CPU可跑、推理快长行识别差,语言上下文弱低功耗设备、实时识别
ViT/Swin + CTCvision transformer仅用输出特征 + CTC比LSTM更能看全局,部署简单没有语言模型,标点恢复差中小数据量、标签多
ViT/Swin + Transformer解码vision transformer自回归Transformer解码器CER低,能利用上下文数据要求高,显存大,训练慢10万行以上、可GPU训练

我一般会把最后一种作为主力,因为它的下限由解码器的语言建模能力兜底,上限也最高。但它对数据量的要求是实打实的:2万行样本跑出来的效果可能还不如同数据量下的CRNN+CTC。如果你手头没有10万行级别的标注,建议先用第二种把流程打通,积累数据后再升级,不要一上来就追求最终形态。

2.3 源码包模块划分:从config到predict先读哪几个文件

所谓源码解析,第一步不是读懂每个文件,而是把“一条数据从图像变成文本”的路径找出来。一个可维护的HTR工程,目录一般长这样。

文件职责关键内容
config.json模型超参数与字典路径encoder/decoder类型、层数、词表大小
feature_extractor.py图像预处理resize、归一化、pixel_values
vision_encoder.py视觉编码器patch embedding、位置编码、自注意力层
text_decoder.py文本解码器masked self-attention、交叉注意力
trainer.py训练循环loss、warmup、梯度裁剪
predict.py推理generate参数、tokenizer解析
evaluate.py指标CER/WER计算

我的阅读顺序是:先打开config.json,把模型结构参数打印出来,这会告诉你整个系统长什么样。下面这截代码就是干这个的:

import json # 读取配置文件,打印模型结构参数 with open("config.json", "r", encoding="utf-8") as f: cfg = json.load(f) print("encoder type:", cfg["encoder"]["type"]) print("encoder layers:", cfg["encoder"]["num_hidden_layers"]) print("decoder vocab size:", cfg["decoder"]["vocab_size"]) print("max position embedding:", cfg["encoder"]["max_position_embeddings"])

然后按“feature_extractor → vision_encoder → text_decoder → trainer → predict”的顺序读下去,重点不是看懂每个矩阵相乘,而是看张量的形状在哪里变化。比如pixel_values进入encoder前是不是(batch, 3, height, width),出来时是不是(batch, seq_len, hidden);decoder的labels在loss里有没有做pad mask。这些地方是整个系统最容易偷偷出错的部分。至于“transformer模型详解”里那么多注意力公式,拿到这个工程里,你只需要盯住两个attention:视觉编码器的self-attention和解码器的masked self-attention。

3. 数据与字典准备:让Transformer吃对训练集的四个前置动作

3.1 公开数据集字段与标注格式:IAM、RIMES、自建票据怎么统一

手写文本识别最常用的公开数据集是IAM和RIMES。IAM以xml形式提供form、line、word三层标注,一个word有独立的bounding box和transcription;RIMES则更偏页面级,标注层级是“页面→区域→行→单词”。直接拿xml喂模型是不现实的,第一步得把这些层级拍平成“一行图对应一行文本”的jsonl格式。

下面这段代码把IAM的xml解析成行级标注,核心是从form下的line节点取bounding box,再把line内的word按顺序拼成文本。

import xml.etree.ElementTree as ET import json def parse_iam_lines(xml_path): tree = ET.parse(xml_path) root = tree.getroot() lines = [] # 遍历form下的所有line节点 for form in root.findall(".//form"): for line in form.findall(".//line"): words = [] # 同一个line内的word按坐标顺序拼接 for word in line.findall(".//word"): transcription = word.get("text") if transcription: words.append(transcription) line_text = " ".join(words) bbox = (int(line.get("x")), int(line.get("y")), int(line.get("width")), int(line.get("height"))) lines.append({"bbox": bbox, "text": line_text}) return lines

这里有个坑:IAM有些词组的transcription会把连字符或字符顺序打乱,直接按词拼接可能得到“the-”和“is”分离;更稳的做法是以line标注为基准,合并时去掉多余空白,再把行文本统一做一次字符级清洗。对于自建数据,我建议在标注阶段就直接保存“行级图片 + txt文本”,少走一层转换。字段统一成image_path和text两项就够了,不要在设计JSON结构上花太多时间。

洗数据的另一个细节是标点。手写文本识别默认输出通常只含字母数字和句号逗号,但你训练时一定要保证训练集里有句号、逗号、问号以及空格。如果只洗掉标点,模型上线后会把你需要的分隔符全部丢弃,这是数据问题不是模型问题。

3.2 字符字典还是BPE子词:影响解码长度和收敛速度

Transformer解码器的词表大小直接决定embedding矩阵和softmax的计算量。手写识别里我强烈建议用字符级字典,而不是BPE子词。原因是手写错误集中在单字符级,比如把e写成c、把r写成n,字符级解码能保留这种细粒度错误分布;BPE会把几个字符捆成一个token,一个笔误可能改变整个token,评估时反而更难定位问题。

一个常用字符表大概是这个结构:

<pad> <s> </s> 空格 0-9 a-z A-Z . , ? ! - '

总token数控制在100以内。这样embedding只有几万参数,训练时解码器收敛快,推理时softmax也便宜。你还需要保证“空格”在字典里是独立token,这个细节我后面避坑章节会专门展开。多语言场景下才需要考虑SentencePiece这类BPE工具,比如数字和中文混排,字符基元数量暴涨,BPE才有优势。

解码长度上也值得算一笔账:一个512宽的字符级词表,解码器最多生成几十个token;BPE词表虽然token少,但每个token需要更长序列才能覆盖同样的语义,beam search的空间反而变大。多数公开代码包默认配GPT-2这类BPE词表,拿到HTR上要重建tokenizer,这是指标上不去的隐形原因。

3.3 图像预处理与数据增强:resize、归一化、掩膜的参数设置

HTR的输入是灰度行图,不是三通道彩色图。我一般会把全流程固定在几个参数上:高度32或48,宽度不超过512,超过就等比缩放到高度32后按最宽截断或padding到512。下面是核心预处理函数。

import cv2 import numpy as np def preprocess_line(image, height=32, max_width=512): # 转灰度并做自适应阈值,去除纸张底色和光照不均 gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray = cv2.adaptiveThreshold( gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15) ih, iw = gray.shape # 保持长宽比缩放到固定高度,宽度超限则截断 scale = height / ih new_w = min(int(iw * scale), max_width) resized = cv2.resize(gray, (new_w, height), interpolation=cv2.INTER_AREA) # 只在右侧padding,保证注意力位置关系不受干扰 if new_w < max_width: resized = np.pad(resized, ((0, 0), (0, max_width - new_w)), mode="constant", constant_values=0) # 归一化到[-1, 1],与常见预训练权重保持一致 pixel = resized.astype(np.float32) / 255.0 pixel = (pixel - 0.5) / 0.5 return pixel

adaptiveThreshold这一步是为了去底色和光影,blockSize取31左右,C值取15,对扫描件比较稳。resize用INTER_AREA而不是INTER_LINEAR,缩小时能保留笔画边缘。padding放在右边,不放在中间,是为了不干扰自注意力的相对位置感知。归一化用0.5/0.5,保持输入落在[-1,1],与常见预训练权重保持一致。如果你用vit-base-patch16这类vision transformer,它内部会再做一次channel维的归一化,这步不能省。

增强方面我固定三件套:亮度对比度扰动、随机平移±4像素、按1到2度的角度做轻微透视旋转,偶尔加RandomErase模拟涂改遮挡。宽度方向不要做随机缩放,因为手写字符的宽高比本身就有语义,乱拉伸会把“l”拉成“i”。

4. 模型实现与训练配置:从config到checkpoint的完整代码

4.1 vision transformer编码器与文本解码器的初始化配置

在代码层面,从头写Transformer自注意力是不现实的,常见做法是复用HuggingFace这类框架里的视觉编码器和文本解码器,把两段“拼”成一个encoder-decoder。我通常用VisionEncoderDecoderModel这一层抽象:左边挂一个vit-base,右边挂一个带masked self-attention的自回归解码器。下面是初始化示例。

from transformers import (VisionEncoderDecoderModel, ViTImageProcessor, AutoTokenizer) # 常见做法是加载预训练vit做视觉编码器,小gpt2只借自回归结构 encoder_id = "google/vit-base-patch16-384-in21k" decoder_id = "sshleifer/tiny-gpt2" model = VisionEncoderDecoderModel.from_encoder_decoder_pretrained( encoder_id, decoder_id ) tokenizer = AutoTokenizer.from_pretrained(decoder_id) model.config.decoder_start_token_id = tokenizer.bos_token_id model.config.pad_token_id = tokenizer.pad_token_id model.config.vocab_size = tokenizer.vocab_size

需要注意vit-base-patch16-384的patch_size是16,image_size是384,意味着输入会被切成24x24的patch序列。decoder用tiny-gpt2纯粹是借用它的自回归attention结构,并不是真的要用GPT的BPE词表;正式训练前你会从config改成自己的字符字典并重建embedding,所以这几行配置只是把模型“拼起来”。设置decoder_start_token_id是为了让生成从开始标记出发,pad_token_id用于batch内的padding和loss掩膜。

这里有个容易忽略的点:从encoder_decoder_pretrained加载时,word embedding的尺寸仍停留在原decoder词表。覆盖成自定义字符表有两种做法:重新初始化decoder的embedding并配上新词表,或者在vision-encoder-decoder模型上做一次embedding resize再加随机初始化。两种我都试过,结论是:字符级字典很小,直接resize加随机初始化就能训起来,不用费心去做子词继承。

4.2 训练循环里的损失函数、warmup、EMA与梯度裁剪

训练循环本身不怎么需要自己拼mask,框架会在传labels时自动构造解码器的因果掩膜和padding掩膜。你需要管的是优化器、调度器、梯度裁剪和EMA。下面是一个基础训练步。

from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=5e-5) total_steps = int(len(train_loader) * epochs) scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(total_steps * 0.1), num_training_steps=total_steps) for step, batch in enumerate(train_loader): pixel_values = batch["pixel_values"].to(device) labels = batch["labels"].to(device) outputs = model(pixel_values=pixel_values, labels=labels) loss = outputs.loss # 内部已做pad mask,损失不计算padding位置 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad()

loss是带掩膜的交叉熵,框架默认把labels中等于pad_token_id的位置排除掉,不用自己传attention_mask。warmup步数先按总步数的10%来,手写识别这种编码器解码器结构对学习率很敏感,前几步不热身经常训飞。学习率从5e-5往下试,encoder和decoder可以分开设,如果只做解码器微调,可以把encoder的lr设成decoder的十分之一。

EMA(指数滑动平均)我在HTR上默认打开:维护一份shadow weights,指数衰减取0.999,每个epoch末尾用EMA权重跑验证。显存不够不想开大batch时,用梯度累积到等效batch 32,比直接调大batch size更稳。关于“transformer分类任务”里常见的一版到底,在HTR这里不要迷信:训练损失和CER不是完全单调关系,我会在第6章给具体的验证方法。

4.3 推理:贪心解码还是beam search,都在改什么

训练完之后,推理链路很短,调参空间集中在generate方法上。下面这几行就是完整调用。

from transformers import ViTImageProcessor processor = ViTImageProcessor.from_pretrained("google/vit-base-patch16-384-in21k") pixel_values = processor(images=[image], return_tensors="pt").pixel_values generated = model.generate( pixel_values=pixel_values, max_new_tokens=64, num_beams=4, # 离线用4,线上可改1 length_penalty=0.6, # 略短于训练均值,防止重复 early_stopping=True, ) text = tokenizer.batch_decode(generated, skip_special_tokens=True)[0]

num_beams越大,候选路径越多,通常能降0.5到2个点的CER,但显存和耗时也跟着倍数涨。HTR场景beam=4是甜点位,再往上收益不明显。length_penalty小于1会使解码偏向更短输出,因为我们训练时大量行文本只有20到40个字符,设0.6到0.8不容易吐一长串重复。early_stopping=True让同一条beam出现结束符时收敛,避免在线请求等太久。还要注意max_new_tokens:设成64不仅能防死循环,也能挡住一个人已经写完但解码器继续编字的情况,后者在真实手写样本里并不少见。

5. 手写文本识别落地避坑:五条让我调了最久的经验

5.1 长文本行位置越界导致解码乱码

现象:训练时把所有图resize到512宽,上线后遇到768宽的票据,前一段decode正常,到中间开始重复字符、乱码。 原因:vision transformer用的是绝对位置编码,位置embedding的行数就是预训练/训练时定好的序列长度;当输入patch序列超过这个长度时,框架不会报错,而是直接取不到对应位置向量,特征被挤压到未初始化区间。 解决:把预处理里的max_width提到768并保持训练、验证、推理三段一致;或者在加载位置编码后做插值resize。插值做法是先把位置embedding从(197, 768)变形成(24, 24, 768),再插值到(32, 32, 768),最后reshape回序列。这两年很多开源工程推荐二维插值,确实能救一批长行,但我还是建议把max_width统一写死在feature_extractor里,插值只做救济措施。

5.2 数据量太小,loss下降慢,CER高得离谱

现象:2万行自建数据训练TrOCR式模型,跑了20个epoch,训练loss还在4以上,CER长期在60%附近;换CRNN+CTC同数据集反而能到20%。 原因:很多人忽略了“Transformer强在数据量大”这个前提。解码器的自回归结构要用大量文本学到语言先验,2万行太少了。vision transformer虽然能加载预训练,但解码器的交叉注意力没初始化好,等价于从零训练。 解决:先冻结encoder,只训练decoder跑通流程;再把小模型的hidden_size从768降到384、层数从6降4,把数据增强加大,允许过拟合再考虑加容量。如果手里有几十万行的公开行级数据,也值得先做一轮预训练,再在自己的票据上微调,这是最省力的解法。

5.3 训练集效果很好,换一批扫描件全崩

现象:在IAM/RIMES上用同样的脚本能到5% CER,换公司自己的扫描件CER回到35%,而且错得没有规律。 原因:所谓“背景过拟合”。公开数据集的纸张和墨迹统一,扫描质量偏高;真实扫描件有底色、皱纹、铅笔痕迹,模型把文本框外的纹理当成了可区分特征。 解决:在预处理里强制做adaptiveThreshold或背景去除,让输入变成“墨迹为1、背景为0”的二值图再进模型。数据增强里加入RandomErase模拟污渍,并对灰度图做直方图匹配。上线前最好先抽样100张目标扫描件,用灰度均值、方差和训练集对比,差异过大时先统一图像质量再谈调模型。

5.4 部署时显存溢出,batch size只能设1

现象:推理一张“高32、宽512”的行图,beam=4,12G显卡直接OOM;把batch设成1还勉强跑,服务延迟不可接受。 原因:自注意力的复杂度随patch数平方增长。512宽的图切成32个patch,序列长度到32x24=768,beam=4意味着同时维护4条生成路径,每步都要在全部历史token上做交叉注意力,显存和时长都绷不住。 解决:输入宽度压到384以下,长行切成重叠窗口分块;encoder换Swin这类金字塔结构,窗口注意力把复杂度降下来;推理时beam改为贪心或beam=2,用半精度和ONNX导出补性能。如果允许离线批量,beam=4跑夜间任务,线上接口退回beam=1,用CER评估这两档差异到底值多少。

5.5 字典里没有空格,输出连成一片

现象:模型输出的“hello world”永远是“helloworld”,字典检查后发现空格根本没有。 原因:多半是建字典时用strip()把label首位空格清了,或者在字符集构造里去掉了空白字符;解码器没有条件输出空格,自然把所有字符连起来。 解决:把空格当成普通token放进vocab,label清洗时只strip首尾,不删中间空格。解码后如果空格token的置信度很低,再补一条规则:在标点和短词之间按字典词频插入空格,但这类“后悔药”不如训练时的空格token可靠。能重训就重训,别依赖规则缝补。

6. 用CER/WER把模型结果量化:一个10行脚本的评估习惯

模型训完别只看loss。手写文本识别领域的通行指标是CER(字符错误率)和WER(词错误率),前者看字符级编辑距离,后者按空格分词后算词级。实际做工程时我通常两个都算,CER判断“长相”,WER判断“可读性”。

import Levenshtein def cer(reference, hypothesis): # 注意先strip首尾,但不做其他修正 ref = reference.strip() hyp = hypothesis.strip() if not ref: return 0.0 if not hyp else 1.0 distance = Levenshtein.distance(ref, hyp) return distance / len(ref) results = [("hello world", "hello world"), ("thank you", "thank yoy")] avg_cer = sum(cer(r, h) for r, h in results) / len(results) print("CER:", round(avg_cer, 4))

这段脚本看起来简单,坑却不少。一是评估前不要对hypothesis做任何书写修正,否则指标会被高估;二是长文本要按行拆开逐行算再平均,整段算编辑距离会把行错位当成大量插入删除;三是参考文本为空时要有默认逻辑,否则除零。

我每次训练结束后的检查顺序是固定的:先随机抽20张图,把图像、真值、预测打印在一起做肉眼冒烟测试,发现空格、标点、大小写这类系统性问题;再跑beam=1和beam=4两档CER,差异太大说明解码器还有空间,差异很小则优先保在线性能。我曾因为评估脚本里没有strip首尾空格,CER被高估了2个点,白白调了一周解码器参数。后来养成了“先看单样本、再算统计量”的习惯,省下过很多次无意义的调参。希望帮到你。

本文还有配套的精品资源,点击获取