OCR과 문서 이해
OCR(Optical Character Recognition; 광학 문자 인식)은 세 단계 파이프라인입니다. 텍스트 박스를 검출하고, 문자를 인식한 뒤, 문서 안에서 다시 배치합니다. 최신 OCR 시스템은 이 단계를 재정렬하거나 하나로 합치기도 합니다.
유형: Learn + Use
언어: Python
선수 조건: Phase 4 Lesson 06(검출), Phase 7 Lesson 02(Self-Attention)
소요 시간: 약 45분
학습 목표
- 고전적인 OCR 파이프라인인 검출(detect) -> 인식(recognise) -> 레이아웃(layout) 흐름과 Donut, Qwen-VL-OCR 같은 최신 엔드투엔드(end-to-end) 대안을 추적합니다.
- 시퀀스-투-시퀀스(sequence-to-sequence) OCR 학습을 위한 CTC(Connectionist Temporal Classification) 손실을 구현합니다.
- 별도 학습 없이 PaddleOCR 또는 EasyOCR로 운영 환경의 문서를 파싱합니다.
- OCR, 레이아웃 파싱(layout parsing), 문서 이해(document understanding)를 구분하고 작업에 맞는 도구를 고릅니다.
문제
텍스트가 가득한 이미지는 어디에나 있습니다. 영수증, 송장, 신분증, 스캔한 책, 양식, 화이트보드, 표지판, 스크린샷이 모두 여기에 해당합니다. 이미지에서 구조화된 데이터를 추출하는 일, 즉 문자만 읽는 것이 아니라 "이 값이 총액이다"라고 이해하는 일은 응용 비전에서 가장 가치가 큰 문제 중 하나입니다.
이 분야는 세 가지 기술 계층으로 나뉩니다.
- OCR 자체: 픽셀을 텍스트로 바꿉니다.
- 레이아웃 파싱(Layout parsing): OCR 결과를 제목, 본문, 표, 헤더 같은 영역으로 묶습니다.
- 문서 이해(Document understanding): 레이아웃에서
invoice_total = $42.50 같은 구조화 필드를 추출합니다.
각 계층에는 고전적 접근과 최신 접근이 모두 있습니다. "이미지에서 텍스트를 얻고 싶다"와 "이 영수증에서 총액이 필요하다" 사이의 간격은 많은 팀이 생각하는 것보다 훨씬 큽니다.
개념
고전적인 파이프라인
flowchart LR
IMG["이미지"] --> DET["텍스트 검출<br/>(DB, EAST, CRAFT)"]
DET --> BOX["단어/줄<br/>바운딩 박스"]
BOX --> CROP["각 영역 자르기"]
CROP --> REC["인식<br/>(CRNN + CTC)"]
REC --> TXT["텍스트 문자열"]
TXT --> LAY["레이아웃<br/>정렬"]
LAY --> OUT["읽기 순서 텍스트"]
style DET fill:#dbeafe,stroke:#2563eb
style REC fill:#fef3c7,stroke:#d97706
style OUT fill:#dcfce7,stroke:#16a34a
- 텍스트 검출(Text detection)은 줄 또는 단어 단위 사각형, 대개 사변형(quadrilateral)을 만듭니다.
- 인식(Recognition)은 각 영역을 고정 높이로 잘라 CNN + BiLSTM + CTC에 넣고 문자 시퀀스를 만듭니다.
- 레이아웃(Layout)은 읽기 순서를 다시 만듭니다. 라틴 문자는 위에서 아래, 왼쪽에서 오른쪽이지만, 아랍어와 일본어처럼 다른 규칙을 쓰는 경우도 있습니다.
CTC 한 문단 설명
OCR 인식은 고정 길이 특징 맵에서 가변 길이 시퀀스를 만듭니다. CTC(Graves et al., 2006)는 문자 단위 정렬(character-level alignment) 없이도 이를 학습하게 해줍니다. 모델은 매 시간 단계마다 어휘(vocab)와 공백(blank)에 대한 분포를 출력하고, CTC 손실은 반복을 합치고 공백을 제거했을 때 목표 텍스트가 되는 모든 정렬을 주변화(marginalise)합니다.
원시 출력: "h h h _ _ e e l l _ l l o _ _"
반복을 합치고 공백을 제거한 뒤: "hello"
CTC는 2015년에 CRNN이 잘 동작하게 만든 이유이며, 2026년에도 많은 운영 OCR 모델을 학습시키는 기반입니다.
최신 엔드투엔드 모델
- Donut(Kim et al., 2022): ViT 인코더 + 텍스트 디코더입니다. 이미지를 읽고 JSON을 직접 출력합니다. 텍스트 검출기나 레이아웃 모듈이 없습니다.
- TrOCR: 줄 단위 OCR을 위한 ViT + transformer decoder입니다.
- Qwen-VL-OCR / InternVL: OCR 작업에 맞게 파인튜닝한 전체 비전-언어 모델(Vision-Language Model)입니다. 2026년 기준 복잡한 문서에서 가장 높은 정확도를 냅니다.
- PaddleOCR: 고전적인 DB + CRNN 파이프라인을 성숙한 운영 패키지로 제공하는 도구입니다. 여전히 오픈소스 운영 OCR의 핵심 선택지입니다.
엔드투엔드 모델은 더 많은 데이터와 컴퓨팅이 필요하지만, 여러 단계 파이프라인에서 발생하는 오류 누적을 피할 수 있습니다.
레이아웃 파싱
구조화 문서에서는 LayoutLMv3, DocLayNet 같은 레이아웃 검출기를 실행해 각 영역에 제목(Title), 문단(Paragraph), 그림(Figure), 표(Table), 각주(Footnote) 같은 라벨을 붙입니다. 그다음 읽기 순서는 "레이아웃 순서대로 영역을 순회하고 이어 붙이기"가 됩니다.
양식에서는 키-값 추출(Key-Value extraction) 모델을 사용합니다. 시각적으로 복잡한 문서에는 Donut, 단순 스캔에는 LayoutLMv3가 쓰입니다. 이 모델들은 이미지, 검출된 텍스트, 위치를 받아 구조화된 key-value 쌍을 예측합니다.
평가 지표
- 문자 오류율(Character Error Rate, CER): Levenshtein 거리 / 기준 문자열 길이입니다. 낮을수록 좋습니다. 깨끗한 스캔의 운영 목표는 보통 2% 미만입니다.
- 단어 오류율(Word Error Rate, WER): 단어 수준에서 같은 방식으로 계산합니다.
- 구조화 필드 F1(F1 on structured fields): key-value 작업에서
{invoice_total: 42.50} 같은 필드가 올바르게 추출되었는지 측정합니다.
- JSON 편집 거리(Edit distance on JSON): 엔드투엔드 문서 파싱에서 사용합니다. Donut 논문은 정규화 트리 편집 거리(normalised tree edit distance)를 도입했습니다.
만들어 보기
Step 1: CTC 손실과 greedy decoder
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) 정수 target(blank 없음)
input_lengths: (N,) 샘플별 사용한 시간 단계 수
target_lengths: (N,) 샘플별 target 길이
"""
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: 인덱스 시퀀스 리스트(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 구현을 사용합니다. Greedy decoder는 beam search보다 단순하지만, 보통 CER 기준 1% 이내 차이에 머뭅니다.
Step 2: 작은 CRNN 인식기
줄 단위 OCR을 위한 최소 CNN + BiLSTM입니다.
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):
f = self.cnn(x)
f = f.mean(dim=2).transpose(1, 2)
h, _ = self.rnn(f)
return F.log_softmax(self.head(h).transpose(0, 1), dim=-1)
입력 높이는 고정합니다. CNN max-pooling이 높이를 1로 줄이기 때문입니다. 너비는 CTC의 시간 차원이 됩니다.
Step 3: 합성 OCR
엔드투엔드 스모크 테스트를 위해 흰 배경 위 검은 숫자 문자열을 만듭니다.
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: {targets.shape} 길이: {lengths.tolist()}")
실제 OCR 데이터셋은 폰트, 노이즈, 회전, 블러, 색상을 추가합니다. 하지만 위 파이프라인과 구조는 같습니다.
Step 4: 학습 스케치
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)
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 step 동안 손실이 약 3에서 0.2까지 내려가야 합니다.
사용하기
운영 환경에서는 세 가지 경로가 있습니다.
- PaddleOCR: 성숙하고 빠른 다국어 도구입니다. 한 줄 사용 예시는
paddleocr.PaddleOCR(lang="en").ocr(image_path)입니다.
- EasyOCR: Python 기반 다국어 도구이며 PyTorch backbone을 사용합니다.
- Tesseract: 고전적인 도구입니다. 모델이 어려워하는 오래된 스캔 문서에는 여전히 유용합니다.
엔드투엔드 문서 파싱에는 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 중 무엇을 쓸지 고르는 프롬프트입니다.
outputs/skill-ctc-decoder.md: greedy와 beam-search CTC decoder를 처음부터 작성하는 skill입니다. 길이 정규화(length normalisation)를 포함합니다.
연습문제
- (쉬움) TinyCRNN을 5자리 무작위 숫자 문자열에 대해 500 step 학습합니다. 보류(held-out) 세트의 CER을 보고합니다.
- (보통) Greedy decoding을 beam search(
beam_width=5)로 바꿉니다. CER 변화량을 보고합니다. 어떤 입력에서 beam search가 이기는지 확인합니다.
- (어려움) 영수증 20장에 PaddleOCR을 적용해 line item을 추출하고, 손으로 라벨링한
{item_name, price} 쌍 기준 정답과 비교해 F1을 계산합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| OCR | "픽셀에서 텍스트 얻기" | 이미지 영역을 문자 시퀀스로 바꾸는 작업 |
| CTC | "정렬 없는 손실" | 시간 단계별 라벨 없이 시퀀스 모델을 학습시키는 손실. 가능한 정렬을 주변화한다 |
| CRNN | "고전 OCR 모델" | Conv 특징 추출기 + BiLSTM + CTC. 2015년 기준선이지만 여전히 운영에 쓰인다 |
| Donut | "엔드투엔드 OCR" | ViT 인코더 + 텍스트 디코더. 이미지에서 JSON을 직접 출력한다 |
| 레이아웃 파싱(Layout parsing) | "영역 찾기" | 문서에서 제목, 표, 그림, 문단 영역을 검출하고 라벨링하는 작업 |
| 읽기 순서(Reading order) | "텍스트 순서" | 인식된 영역을 문장으로 배열하는 순서. 라틴 문서에서는 단순하지만 혼합 레이아웃에서는 어렵다 |
| CER / WER | "오류율" | 문자 또는 단어 단위 Levenshtein 거리 / 기준 길이 |
| VLM-OCR | "읽는 LLM" | OCR 작업에 맞게 학습되거나 프롬프트된 비전-언어 모델. 복잡한 문서의 최신 SOTA에 가깝다 |
더 읽을거리