OCR 与文档理解 本节摘要:OCR 是一条三阶段流水线——检测文本框、识别字符、再排出版式;每个现代系统都在重排或合并这三步。本节追踪经典流水线(检测→识别→版式)与现代端到端方案(Donut、Qwen-VL-OCR),从零实现 CTC 损失(无需字符级对齐就能训练序列模型)和一个微型 CRNN 识别器,用 PaddleOCR/EasyOCR 免训练解析生产文档,并讲清 OCR、版式解析、文档理解三层技能的边界。读完本节,你能为「从图里读字」到「从收据里抽出总金额」这条比想象更长的路,选对每一段的工具。 对应原课程:Phase 4 · Lesson 19 · (原英文 )。
本节摘要:OCR 是一条三阶段流水线——检测文本框、识别字符、再排出版式;每个现代系统都在重排或合并这三步。本节追踪经典流水线(检测→识别→版式)与现代端到端方案(Donut、Qwen-VL-OCR),从零实现 CTC 损失(无需字符级对齐就能训练序列模型)和一个微型 CRNN 识别器,用 PaddleOCR/EasyOCR 免训练解析生产文档,并讲清 OCR、版式解析、文档理解三层技能的边界。读完本节,你能为「从图里读字」到「从收据里抽出总金额」这条比想象更长的路,选对每一段的工具。
对应原课程:Phase 4 · Lesson 19 ·
ocr-document-understanding(原英文phases/04-computer-vision/19-ocr-document-understanding/docs/en.md)。
阅读完本节,你应当能够:
满是文字的图像无处不在:收据、发票、证件、扫描书、表单、白板、路牌、截图。从中抽取结构化数据——不只是字符,而是「这是总金额」——是最高价值的应用视觉问题之一。
这个领域分三层技能:
invoice_total = $42.50)。每层都有经典与现代两套做法,而「我要图里的字」与「我要这张收据的总金额」之间的差距,比大多数团队以为的大得多。
OCR 识别从定长特征图产出变长序列。CTC(Graves 等,2006)让你无需字符级对齐就能训练。模型在每个时间步输出(词表 + blank)上的分布;CTC 损失对所有「合并重复、去掉 blank 后等于目标文本」的对齐做边缘化。
原始输出: "h h h _ _ e e l l _ l l o _ _" 合并重复去 blank 后: "hello"
CTC 是 2015 年 CRNN 能用的原因,2026 年仍在训大多数生产 OCR 模型。
端到端模型要更多数据和算力,但跳过多阶段流水线的误差累积。
对结构化文档,跑版式检测器(LayoutLMv3、DocLayNet),给每个区域打标签:标题、段落、图、表、脚注。阅读顺序就变成「按版式顺序遍历区域、拼接」。
表单用键值抽取模型(富视觉文档用 Donut,纯扫描用 LayoutLMv3),输入图像+检测到的文本+位置,预测结构化键值对。
{invoice_total: 42.50} 是否正确出现。import torch import torch.nn as nn import torch.nn.functional as F def ctc_loss(log_probs, targets, input_lengths, target_lengths, blank=0): """ log_probs: (T, N, C) 词表含 blank(索引 0)上的 log-softmax targets: (N, S) int 目标(无 blank) input_lengths: (N,) 每样本使用的时间步 target_lengths: (N,) 每样本目标长度 """ return F.ctc_loss(log_probs, targets, input_lengths, target_lengths, blank=blank, reduction="mean", zero_infinity=True) def greedy_ctc_decode(log_probs, blank=0): """ log_probs: (T, N, C) log-softmax 返回: 索引序列列表(去 blank、合并重复) """ preds = log_probs.argmax(dim=-1).transpose(0, 1).cpu().tolist() out = [] for seq in preds: decoded = [] prev = None for idx in seq: if idx != prev and idx != blank: decoded.append(idx) prev = idx out.append(decoded) return out
F.ctc_loss 在可用时用高效 CuDNN 实现。贪心解码比束搜索简单,CER 通常只差 1% 以内。
最小 CNN + BiLSTM 做行 OCR。
class TinyCRNN(nn.Module): def __init__(self, vocab_size=40, hidden=128, feat=32): super().__init__() self.cnn = nn.Sequential( nn.Conv2d(1, feat, 3, 1, 1), nn.BatchNorm2d(feat), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(feat, feat * 2, 3, 1, 1), nn.BatchNorm2d(feat * 2), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(feat * 2, feat * 4, 3, 1, 1), nn.BatchNorm2d(feat * 4), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), nn.Conv2d(feat * 4, feat * 4, 3, 1, 1), nn.BatchNorm2d(feat * 4), nn.ReLU(inplace=True), nn.MaxPool2d((2, 1)), ) self.rnn = nn.LSTM(feat * 4, hidden, bidirectional=True, batch_first=True) self.head = nn.Linear(hidden * 2, vocab_size) def forward(self, x): # x: (N, 1, H, W) f = self.cnn(x) # (N, C, H', W') f = f.mean(dim=2).transpose(1, 2) # (N, W', C) h, _ = self.rnn(f) return F.log_softmax(self.head(h).transpose(0, 1), dim=-1) # (W', N, vocab)
定高输入(CNN 把高度 max-pool 到 1),宽度作 CTC 的时间维。
生成黑底白数字串做端到端冒烟测试。
import numpy as np def synthetic_line(text, height=32, char_width=16): W = char_width * len(text) img = np.ones((height, W), dtype=np.float32) for i, c in enumerate(text): x = i * char_width shade = 0.0 if c.isalnum() else 0.5 img[6:height - 6, x + 2:x + char_width - 2] = shade return img def build_batch(strings, vocab): H = 32 W = 16 * max(len(s) for s in strings) imgs = np.ones((len(strings), 1, H, W), dtype=np.float32) target_lengths = [] targets = [] for i, s in enumerate(strings): imgs[i, 0, :, :16 * len(s)] = synthetic_line(s) ids = [vocab.index(c) for c in s] targets.extend(ids) target_lengths.append(len(ids)) return torch.from_numpy(imgs), torch.tensor(targets), torch.tensor(target_lengths) vocab = ["_"] + list("0123456789abcdefghijklmnopqrstuvwxyz") imgs, targets, lengths = build_batch(["hello", "world"], vocab) print(f"图像: {imgs.shape} 目标: {targets.shape} 长度: {lengths.tolist()}")
真实 OCR 数据集会加字体、噪声、旋转、模糊、颜色,但上面的流水线完全一致。
model = TinyCRNN(vocab_size=len(vocab)) opt = torch.optim.Adam(model.parameters(), lr=1e-3) for step in range(200): strings = ["abc" + str(step % 10)] * 4 + ["xyz" + str((step + 1) % 10)] * 4 imgs, targets, target_lens = build_batch(strings, vocab) log_probs = model(imgs) # (W', 8, vocab) input_lens = torch.full((8,), log_probs.size(0), dtype=torch.long) loss = ctc_loss(log_probs, targets, input_lens, target_lens, blank=0) opt.zero_grad(); loss.backward(); opt.step()
在这套平凡合成数据上,200 步内损失应从 ~3 掉到 ~0.2。
三条生产路径:
paddleocr.PaddleOCR(lang="en").ocr(image_path)。端到端文档解析用 Donut 或 VLM:
from transformers import DonutProcessor, VisionEncoderDecoderModel processor = DonutProcessor.from_pretrained("naver-clova-ix/donut-base-finetuned-cord-v2") model = VisionEncoderDecoderModel.from_pretrained("naver-clova-ix/donut-base-finetuned-cord-v2")
收据、发票、结构可复用的表单,微调 Donut;任意文档或带推理的 OCR,Qwen-VL-OCR 这类 VLM 是当前默认。
本节产出两个可复用文件(位于原课程 outputs/):
prompt-ocr-stack-picker.md:一个提示词——给定文档类型、语言、结构,在 Tesseract / PaddleOCR / Donut / VLM-OCR 之间挑。skill-ctc-decoder.md:一个技能——从零写出贪心和束搜索 CTC 解码器,含长度归一化。下一节进入图像检索与度量学习——把图像映成嵌入,学一个让同类靠近、异类远离的距离。