TAI BUI
Back to curriculum
BuildPython

OCR & Document Understanding

OCR is a three-stage pipeline: detect text boxes, recognise the characters, then lay them out. Every modern system either reorders those stages or merges them into one model.

TIME: ~45 minutesPREREQUISITES: Phase 4 Lesson 06 (Detection), Phase 7 Lesson 02 (Self-Attention)

Learning Objectives

  • Trace the classical OCR pipeline (detect -> recognise -> layout) and the modern end-to-end alternatives (Donut, Qwen-VL-OCR)
  • Implement CTC (Connectionist Temporal Classification) loss for sequence-to-sequence OCR training
  • Use PaddleOCR or EasyOCR for production document parsing without training
  • Distinguish OCR, layout parsing, and document understanding, and pick the right tool per task

The Problem

Images full of text are everywhere: receipts, invoices, IDs, scanned books, forms, whiteboards, signs, screenshots. Extracting structured data from them (not just the characters, but "this is the total amount") is one of the highest-value applied-vision problems.

The field splits into three skill layers:

  1. OCR proper: turn pixels into text.
  2. Layout parsing: group OCR output into regions (title, body, table, header).
  3. Document understanding: extract structured fields ("invoice_total = $42.50") from layout.

Each layer has its own classical and modern approaches, and the gap between "I want text from an image" and "I need the total amount from this receipt" is bigger than most teams expect going in.

Warm-up0/3
1. You snap a photo of a receipt at a slight angle, with the printed text filling only the middle of the frame. Before any characters can be read, what does software need to work out first?Warm-up
A tilted photo of a receipt with squiggly lines standing for printed text filling the middle of the frame, an arrow to a dashed question mark box, captioned asking what the first step is before any letter can be read.
2. A model reads a name one letter at a time, and it has no way of knowing in advance how many letters that name will have. In general terms, what kind of output does a problem like that call for?Warm-up
Three solid boxes holding the letters M, O and R read so far with the third highlighted, then three dashed empty boxes and an ellipsis for letters not yet read, a note that how many still follow is not known in advance, and a dashed question mark box asking what kind of output fits.
3. A scanned page is saved as an image file. Why does Ctrl-F find nothing when you search it for a word you can plainly see?Warm-up
A page drawn as squiggly lines standing for printed text, labelled scan.png, next to a search box reading Ctrl-F colon total with 0 matches found, and a line asking why the search finds nothing.
Check: The Problem0/2
1. In document processing, what is OCR proper, the first of the three skill layers?Define
A page drawn as squiggly lines standing for pixels, an arrow into a highlighted box labelled 1 OCR proper, an arrow into a dashed question mark box, a note that layers 2 and 3 run after it on whatever layer 1 hands them, and a line asking what layer 1's job is.
2. A pipeline reads every character on an invoice correctly, yet the system still cannot report the invoice total. Which layer is missing?Explain
A card showing correctly recognised invoice lines, subtotal, tax and total, next to a dashed question mark box, with a line asking which layer is missing since the system still cannot report the invoice total.
Subtotal and tax are illustrative; the 42.50 total is the lesson's own example figure.

The Concept

The classical pipeline

Mermaid diagram

  • Text detection produces per-line or per-word quadrilaterals.
  • Recognition crops each region to a fixed height, then runs a CNN + BiLSTM (a bidirectional LSTM, reading the feature sequence left-to-right and right-to-left at once) + CTC to produce a character sequence.
  • Layout rebuilds reading order (top-to-bottom, left-to-right for Latin; different for Arabic, Japanese).

CTC in one paragraph

OCR recognition produces a variable-length sequence from a fixed-length feature map. CTC (Graves et al., 2006) is what lets you train that without character-level alignment. The model outputs a distribution over (vocab + blank) at every time step, and CTC loss marginalises over every alignment that reduces to the target text once you merge repeats and remove blanks.

raw output: "h h h _ _ e e l l _ l l o _ _"
after merge repeats and remove blanks: "hello"

Analogy: it's like grading a spoken word from a slowed-down recording without a transcript to align against. You don't know exactly when each letter starts or ends, only that some stretch of the recording says "hello." CTC scores every plausible way of stretching "hello" across the recording's timeline and trains on all of them at once, instead of demanding you mark the boundaries by hand first.

CTC is the reason CRNN worked in 2015, and it still trains most production OCR models in 2026.

Modern end-to-end models

  • Donut (Kim et al., 2022): a ViT encoder + text decoder that reads an image and emits JSON directly. No text detector, no layout module.
  • TrOCR: ViT + transformer decoder for line-level OCR.
  • Qwen-VL-OCR / InternVL: full vision-language models fine-tuned for OCR tasks, with the best accuracy in 2026 on complex documents.
  • PaddleOCR: the classical DB + CRNN pipeline, packaged into a mature production library and still the open-source workhorse.

End-to-end models need more data and more compute, but they skip the error accumulation that a multi-stage pipeline builds up.

Layout parsing

For structured documents, run a layout detector (LayoutLMv3, DocLayNet) that labels each region: Title, Paragraph, Figure, Table, Footnote. Reading order then becomes "iterate through regions in layout order, concatenate."

For forms, use Key-Value extraction models (Donut for visually-rich documents, LayoutLMv3 for plain scans). They take image + detected text + positions and predict structured key-value pairs.

Evaluation metrics

  • Character Error Rate (CER): Levenshtein distance (the minimum number of single-character insertions, deletions, or substitutions needed to turn the predicted text into the reference text) divided by the length of the reference. Lower is better, and the production target is under 2% on clean scans.
  • Word Error Rate (WER): the same idea, measured at the word level instead of the character level.
  • F1 on structured fields: for key-value tasks, measuring whether {invoice_total: 42.50} comes out correct.
  • Edit distance on JSON: for end-to-end document parsing; the Donut paper introduced normalised tree edit distance for exactly this.
Check: The Concept0/16
1. What is a CRNN?Define
A highlighted box labelled CRNN with a note that it is still used in production, an arrow to a dashed question mark box, and a line asking which pieces it is built from.
2. What is reading order in a document pipeline?Define
A page containing four unlabelled regions, region A, B, C and D, scattered at different positions, with a line asking in what order these should be read.
3. What is CTC loss?Define
Fifteen empty cells in a row standing for the recogniser's time steps, labelled t1 at the start and t15 at the end, a note that each step carries one distribution over the vocabulary plus blank, a note that the target text for the line is hello and 5 characters long, and a dashed question mark box asking what loss trains a model like this.
4. What is Donut?Define
A small page standing for one image, an arrow into a highlighted box labelled Donut, introduced 2022, an arrow into a dashed question mark box, and a line asking what is inside and what comes out.
5. What is a VLM-OCR model?Define
A page drawn as squiggly text lines, an arrow into a highlighted box labelled VLM-OCR, an arrow into a dashed question mark box, and a line asking what kind of model this is.
6. What does layout parsing produce for a document page?Define
A document page drawn with a short heading bar, four lines of body text, a three-by-three table grid and a short rule near the bottom, an arrow into a box labelled layout parsing, an arrow into a dashed question mark box, and a line asking what comes out of layout parsing.
7. Which metric is Levenshtein distance divided by reference length at the character level, with a production target under 2% on clean scans?Define
A fraction drawn with edit distance between prediction and reference over the length of the reference, a note that it is counted one character at a time, a card working the under 2 percent target out to at most 10 wrong characters on a 500-character page, and a line asking which metric this is.
The 500-character page is illustrative; the 2% target is the lesson's own figure.
8. In the classical OCR pipeline, what does the recognition stage do, as distinct from detection and layout?Explain
A box holding one thick line standing for a cropped text region, an arrow into a box labelled recognition, an arrow into a dashed question mark box, and a line asking what happens inside the recognition stage.
9. What distinguishes TrOCR from Qwen-VL-OCR and InternVL among the modern end-to-end models?Explain
Three boxes labelled TrOCR, Qwen-VL-OCR and InternVL, above a dashed question mark box, with a line asking how TrOCR differs from the other two.
10. Why does Donut avoid the error accumulation that a multi-stage OCR pipeline builds up?Explain
Three boxes labelled stage 1, stage 2 and stage 3 with small x marks on the arrows between them standing for a mistake carried forward, a note that a wrong guess at stage 1 feeds stage 2 then stage 3, a dashed question mark box, and a line asking why Donut doesn't have this problem.
11. Why does OCR use CTC loss instead of plain cross-entropy?Explain
Two cards side by side, one listing what a single training example holds, a cropped line image and the string hello, the other listing what plain cross-entropy asks for, one target label at every time step the recogniser emits, with a line asking why cross-entropy is not the loss here.
12. In CTC decoding, what does the blank token do?Explain
The lesson's raw recogniser output drawn as fifteen cells, h h h blank blank e e l l blank l l o blank blank, with the blank cells highlighted, and a line asking what the blank token is there for.
13. The reference text is 'hello' and the model predicts 'hallo'. What is the CER?Apply
The reference hello and the prediction hallo drawn as two rows of five character cells, one row above the other so the positions line up, with a line asking what the CER is.
14. A production invoice parser reads every character correctly but outputs lines in the wrong order on pages with two columns, so the tax line and the total are swapped. Which component is the likely culprit?Analyze
Two side-by-side page regions standing for a left and a right column, each with squiggly text lines, a note that every character the recogniser output was correct but the tax line and the total print swapped, asking which component is responsible.
15. A classical pipeline's detector keeps missing faint lines, so the recogniser never sees them. A teammate suggests Donut to avoid this error accumulation. What should the team expect to pay in return?Analyze
A box labelled classical pipeline, detector misses faint lines, an arrow to a box labelled switch to Donut, with a line asking what the team pays for skipping the detector's mistakes.
16. Your Latin-only parser must now handle Arabic and Japanese pages. Which assumption in the layout stage breaks?Design
Three page thumbnails drawn as squiggly text lines, labelled Latin pages, Arabic pages and Japanese pages, an arrow into a box labelled layout stage above a dashed question mark box, and a line asking which assumption in the layout stage breaks.

Build It

Step 1: CTC loss + greedy decoder

python
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) log-softmax over vocab including blank at index 0
    targets:        (N, S) int targets (no blanks)
    input_lengths:  (N,) per-sample time steps used
    target_lengths: (N,) per-sample target length
    """
    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
    returns: list of index sequences (blanks removed, repeats merged)
    """
    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 uses the efficient CuDNN implementation when available. The greedy decoder is simpler than a beam search and usually within 1% CER of it.

Step 2: Tiny CRNN recogniser

Minimal CNN + BiLSTM for line OCR.

python
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)

Fixed-height input (the CNN max-pools height to 1). Width is the time dimension for CTC.

Step 3: Synthetic OCR

Generate black-on-white digit strings for an end-to-end smoke test.

python
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"images: {imgs.shape}   targets: {targets.shape}   lengths: {lengths.tolist()}")

A real OCR dataset adds fonts, noise, rotation, blur, and colour. The pipeline above is identical.

Step 4: Training sketch

python
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()

Loss should drop from ~3 to ~0.2 over 200 steps on this trivial synthetic data.

Check: Build It0/6
1. Why does TinyCRNN take fixed-height input and treat image width as the time dimension for CTC?Explain
A box labelled input, shape N by 1 by H by W, an arrow into a box labelled conv plus pool times 4, an arrow into a dashed question mark box, with a line asking what happens to H and what happens to W.
2. Why is greedy CTC decoding a reasonable default over beam search?Explain
A box labelled greedy, take the top choice at each step, next to a box labelled beam search, track several candidates at once, with a line asking which of the two should be the default here.
3. In the synthetic batch code, build_batch(["hello", "world"], vocab) is called and its shapes are printed. What does the print show?Apply
A code box holding the lesson's vocab list, the call to build_batch on the strings hello and world, and the print of the images shape, targets shape and lengths, with a line asking what this prints.
The print is wrapped across two lines so it fits; it builds the same string as the lesson's own one-line version.
4. Images 80 pixels wide from build_batch are fed to TinyCRNN. How many time steps does log_probs have?Apply
A code box listing the 80 pixel input width and TinyCRNN's four MaxPool2d calls in order, with a note that the convs keep width unchanged because of padding, asking how many time steps reach the BiLSTM.
5. greedy_ctc_decode sees the per-step argmax indices [2, 2, 0, 2, 5, 5, 0] for one sample, with blank = 0. What does it return for that sample?Apply
Seven cells holding the per-step argmax indices 2, 2, 0, 2, 5, 5, 0, labelled t1 to t7, with blank equal to 0 noted above them, and a line asking what the decoder returns for this sample.
6. A CTC recogniser should output 'hello' but decodes 'helo'. The raw argmax shows l l l between the e and the o, with no blank. What explains it?Analyze
Five cells holding the raw argmax tokens e, l, l, l, o, a note that no blank token appears between the l's, and a line asking what explains it decoding to helo instead of hello.

Use It

Three production paths:

  • PaddleOCR: mature, fast, multilingual. One line gets you a result: paddleocr.PaddleOCR(lang="en").ocr(image_path).
  • EasyOCR: Python-native, multilingual, built on a PyTorch backbone.
  • Tesseract: classical, and still worth reaching for on old scanned documents where the newer models struggle.

For end-to-end document parsing, use Donut or a VLM:

python
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")

Fine-tune Donut for receipts, invoices, and any form with a repeatable structure. For arbitrary documents, or OCR that needs some reasoning on top, a VLM like Qwen-VL-OCR is the current default.

Check: Use It0/4
1. What lets a team use PaddleOCR or EasyOCR for production document parsing without training anything first?Explain
A terminal showing python started and one line, paddleocr dot PaddleOCR lang equals en dot ocr of image_path, with a line asking what had to happen before this line could run.
Typed from the lesson's Use It section, not captured from a real run.
2. Which OCR tool is still worth reaching for on old scanned documents where the newer models struggle?Explain
An old scanned page drawn as squiggly text lines with scattered speckle dots standing for noise and degradation, with a note that newer models struggle on scans like this, and a line asking which tool is still worth trying here.
3. You need invoice_total from receipts that all share a repeatable structure, and you have labelled examples of them. Which approach fits best?Design
Three receipt thumbnails drawn with the same layout, a heading bar, three text lines and one highlighted line near the bottom, a note that the structure is the same every time and each one is labelled, a dashed question mark box, and a line asking which approach fits best.
4. You must parse scanned documents of many unknown types, and users also ask questions that need reasoning over the content. Which stack fits best?Design
A box labelled many unknown document types, next to a box labelled a question needing reasoning over the content, above a dashed question mark box, with a line asking which stack fits best.

Ship It

This lesson produces:

  • outputs/prompt-ocr-stack-picker.md: a prompt that picks Tesseract, PaddleOCR, Donut, or a VLM-OCR model given the document type, language, and structure.
  • outputs/skill-ctc-decoder.md: a skill that writes greedy and beam-search CTC decoders from scratch, including length normalisation.
Author's Note

The step everyone wants to skip is the boring one: layout. Detection and recognition get all the attention because they're the flashy computer-vision problems, but the reading-order bug is the one that actually breaks production. I've seen a "working" invoice parser confidently swap the tax line and the total because its layout module assumed a single column, top-to-bottom, on a document that actually had two.

If you're choosing between a classical pipeline and an end-to-end model like Donut, don't decide from benchmark numbers alone. Decide from how much labelled data you actually have for your document type. A hundred labelled receipts is plenty to fine-tune Donut into something usable. A hundred labelled receipts is nowhere near enough to trust a CRNN trained from scratch.

Exercises

  1. (Easy) Train the TinyCRNN on 5-digit random numeric strings for 500 steps. Report CER on a held-out set.
  2. (Medium) Replace greedy decoding with beam search (beam_width=5). Report CER delta. On which inputs does beam search win?
  3. (Hard) Use PaddleOCR on a set of 20 receipts, extract line items, and compute F1 against hand-labelled ground truth for {item_name, price} pairs.

Key Terms

TermWhat people sayWhat it actually means
OCR"Text from pixels"Turning image regions into character sequences
CTC"Alignment-free loss"Loss that trains a sequence model without per-timestep labels; marginalises over alignments
CRNN"Classic OCR model"Conv feature extractor + BiLSTM + CTC; the 2015 baseline still used in production
Donut"End-to-end OCR"ViT encoder + text decoder; emits JSON directly from image
Layout parsing"Find regions"Detect and label Title/Table/Figure/Paragraph regions in a document
Reading order"Text sequence"Ordering of recognised regions into a sentence; trivial for Latin, non-trivial for mixed layouts
CER / WER"Error rates"Levenshtein distance / reference length at character or word granularity
VLM-OCR"LLM that reads"A vision-language model trained or prompted for OCR tasks; current SOTA on complex documents

Further Reading