사진 한 장이 구조화된 데이터가 되기까지 — 이미지 전처리, 필드 추출, bbox 좌표계, 신뢰도 시스템의 모든 것
OCR 시스템의 핵심 설계 결정은 역할 분리다. 하나의 엔진이 모든 걸 처리하는 대신, "무엇이 쓰여 있는가"와 "그것이 어디에 있는가"를 분리했다.
왜 Gemini에게 좌표를 함께 요청하지 않는가?
처음에는 Gemini에게 텍스트 추출과 bbox 좌표를 동시에 요청했다. 그런데 코스트코 영수증처럼 품목이 17개가 넘는 문서에서, Gemini가 좌표 계산에 출력 토큰을 소진하면서 뒷부분 품목 3개를 누락해버렸다. 17개 → 14개. 그래서 원칙을 세웠다: Gemini는 값만, PaddleOCR는 위치만.
| 엔진 | 역할 | 장점 | 실행 환경 |
|---|---|---|---|
| Gemini | 이미지를 보고 "이건 상호명, 저건 가격" 판단 | 맥락 이해, 다국어, 오류 교정 | Google API (클라우드) |
| PaddleOCR | 모든 글자의 정확한 픽셀 좌표 감지 | 결정론적, 빠름, 정밀한 좌표 | 로컬 (CPU/GPU) |
회전 보정, 기울기 교정, 샤프닝, 대비 향상 — 문제가 있는 경우에만 조건부 적용
긴 변 2048px 축소 → 배경 제거, 문서 영역만 잘라냄 (문서가 80% 미만일 때만)
Gemini Vision이 첫 페이지 분석 → 영수증 / 청구서 / 계약서 / 기타 자동 분류
이미지를 Gemini에 전송 → 품목명, 가격, 합계 등 구조화된 필드 + 신뢰도 반환
쿠폰 필터링, 합계 검증(산술), 필드 정규화(날짜/금액/전화번호 형식 통일)
crop된 이미지에서 모든 텍스트 좌표 감지 → Gemini 추출값과 텍스트 유사도로 1:1 매칭
95%↑ 자동 승인(초록), 80~94% 빠른 확인(주황), 80%↓ 수동 검토(빨강)
입력: 스마트폰 촬영 3024×4032 JPEG (책상 배경 포함)
전처리: deskew 1.0° 적용 (수평 라인 ≥10개, std < 1.5° 충족 시만) → 1536×2048으로 리사이즈
Auto-crop: 배경 제거 → 958×1695 (영수증 영역만)
추출: 31개 필드 (품목 17개 + 가격 17개 + 합계/할인/결제수단 등)
Bbox 스냅: 30/31 성공 (96.8%)
처리 시간: 약 65초 (Gemini API 포함)
preprocess_for_ocr() 함수가 이미지 상태를 자동 진단하고, 문제가 있는 경우에만 보정한다. 고품질 스캔에서는 아무것도 하지 않는다.
| 단계 | 진단 조건 | 적용 예시 | 기법 |
|---|---|---|---|
| 1. 회전 보정 | Tesseract OSD가 90°/180°/270° 감지 | 거꾸로 찍힌 사진 | OpenCV rotate |
| 2. 기울기 보정 | Hough Line 중앙값 각도 ±0.5°~15° | 비뚤어진 스캔 | Affine rotation |
| 3. 샤프닝 | Laplacian 분산 < 50 | 초점 안 맞은 촬영 | Unsharp mask |
| 4. 대비 향상 | 그레이스케일 range < 120 | 바래진 감열지 영수증 | CLAHE |
코스트코 영수증 사진
→ 1.0° 기울어짐 (감지됨)
→ 대비 양호 (range=210)
→ 초점 양호 (variance=125)
// 결과: deskew만 적용
고해상도 PDF 스캔
→ 기울기 0° (정상)
→ 대비 양호 (range=248)
→ 초점 양호 (variance=200)
// 결과: 전처리 없음 (스킵)
왜 조건부 적용인가? 이미 깨끗한 이미지에 샤프닝을 걸면 오히려 노이즈가 생긴다. CLAHE도 정상 대비 이미지에 적용하면 색이 뒤틀린다. "항상 적용"이 아니라 "필요할 때만 적용"이 OCR 정확도를 높인다.
책상 위에서 찍은 사진에는 나무 질감, 손, 그림자 같은 배경이 포함된다. auto_crop_document()가 문서 영역만 자동으로 잘라낸다.
이미지를 자동 임계값으로 흑백 분리 — 밝은 영수증 vs 어두운 배경
흑백 이미지에서 경계선을 찾고, 가장 큰 윤곽선 = 문서 영역으로 판단
윤곽선을 감싸는 사각형 + 약간의 여백 → 최종 crop 영역
입력: 1536×2048 (영수증 + 나무 책상 배경)
문서 영역 비율: 33% (80% 미만이므로 crop 실행)
출력: 958×1695 (영수증만)
제거된 영역: 상단 184px, 좌측 531px의 배경
핵심: 이후 모든 bbox 좌표는 이 crop된 이미지 기준이다. 검수 화면 API도 동일한 crop을 적용하므로 좌표계가 일치한다. 이 "보이지 않는 계약"이 깨지면 bbox가 어긋난다.
문서가 이미지의 80% 이상을 차지하면 crop을 건너뛴다. 깨끗한 PDF 스캔, 풀페이지 문서 등은 배경이 거의 없으므로 crop이 불필요하다.
전처리+리사이즈된 이미지를 Gemini Vision API에 전송하면, 구조화된 필드 목록이 반환된다.
// 코스트코 영수증에서 추출된 필드
[
{ "field_name": "상호명", "value": "COSTCO WHOLESALE", "confidence": 0.99 },
{ "field_name": "품목_1", "value": "KS 먹는 샘물2L X 6", "confidence": 0.97 },
{ "field_name": "가격_1", "value": "3,520 T", "confidence": 0.96 },
{ "field_name": "품목_2", "value": "이롬약콩두유", "confidence": 0.95 },
{ "field_name": "가격_2", "value": "24,980 T", "confidence": 0.98 },
// ... 총 31개 필드
{ "field_name": "합계금액", "value": "173,800", "confidence": 0.99 },
]
문서 유형에 따라 Gemini 프롬프트에 기대 필드 힌트가 추가된다:
| 문서 유형 | 기대 필드 | 특수 처리 |
|---|---|---|
| receipt | 상호명, 품목, 가격, 합계, 결제수단 | 쿠폰 필터링, 합계 검증 |
| invoice | 공급자, 공급받는자, 세금, 합계 | 세금 계산 검증 |
| contract | 당사자, 계약일, 금액, 조건 | 교차 검증(hybrid_validated) 모드 |
| other | 자동 감지 | 기본 추출 |
검수자가 AI 결과를 수정하면 AuditLog에 기록된다. "이 유형 문서에서 공급받는자 필드가 자주 틀린다"는 패턴이 감지되면, 다음 추출 시 프롬프트에 주의 힌트가 자동 추가된다. 모델 재학습 없이 프롬프트만으로 점진적 개선.
Gemini가 "무엇(값)"을 추출했다면, PaddleOCR가 "어디(좌표)"를 찾는다. 두 결과를 텍스트 유사도로 매칭하는 과정이 bbox 스냅핑이다.
bbox 좌표는 픽셀이 아니라 0~1 범위의 비율로 저장된다. 이미지 크기가 바뀌어도 같은 위치를 가리킨다.
// PaddleOCR가 crop된 이미지(958×1695)에서 감지한 "KS먹는심물2L×6"
pixel_x = 87, pixel_y = 668, pixel_w = 159, pixel_h = 28
// 이미지 크기로 나눠서 정규화
bbox = {
x: 87 / 958 = 0.0908, // "왼쪽에서 9%"
y: 668 / 1695 = 0.3940, // "위에서 39%"
width: 159 / 958 = 0.1660,
height: 28 / 1695 = 0.0165,
}
1. 작은 영역 필터링 — 너비 < 1.5% 또는 높이 < 0.8%인 노이즈 영역 제거
2. 수평 병합 — 같은 줄에서 간격 3% 이내인 영역을 합침
// "KS" + "먹는" + "심물" → "KS 먹는 심물"
// 개별 단어 조각이 하나의 품목명으로 합쳐짐
3. 스코어 계산
score = text_similarity(gemini_value, ocr_text) × 5 - distance(gemini_bbox, ocr_bbox)
// 텍스트 일치가 5배 더 중요. 거리는 동점 처리용.
// → 비스듬히 찍은 사진에서도 텍스트만 맞으면 정확히 매칭
4. 배타적 1:1 할당 — 스코어 높은 순으로 greedy 매칭. 한 OCR 영역은 하나의 필드에만 할당.
// 영수증에 "12,990"이 3번 나와도 → 각각 다른 품목에 할당
가격_3 → "12,990" (y=0.39) // 첫 번째 12,990에 할당
가격_7 → "12,990" (y=0.55) // 두 번째 12,990에 할당 (첫 번째는 이미 사용됨)
| 매칭 유형 | 점수 | 예시 |
|---|---|---|
| 완전 일치 | 1.0 | "3,520 T" = "3,520 T" |
| 숫자만 일치 | 0.95 | "¥815,000" ↔ "815000" |
| 정규화 일치 | 0.92 | "KS 먹는 샘물" ↔ "KS먹는샘물" |
| 부분 포함 | 0.88 | "김치전" in "김치전130GX10" |
| 퍼지 매칭 | 0.5+ | SequenceMatcher ratio |
| 매칭 실패 | <0.5 | Gemini bbox 유지 (fallback) |
Gemini 추출 결과를 그대로 저장하지 않는다. 규칙 기반 코드가 AI를 검증하고 보완한다.
코스트코 영수증에는 세 종류의 할인이 있다:
// 1. CPN 행 — 쿠폰 할인
"CPN -500 T" → 직전 품목에 할인 마커 추가, 이 행은 제거
// 2. IRC 접미사 — 즉시 할인 품목
"한우곰탕 IRC" → 할인 품목으로 인식
// 3. 음수 가격 — 별도 할인 행
"밀감 한상자" → 가격이 음수면 할인으로 처리
추출된 모든 품목 가격을 더해서 합계 금액과 대조한다.
sum(가격_1 + 가격_2 + ... + 가격_17) = 173,800
합계금액 = 173,800
// ✅ 일치 → 신뢰도 유지
// ❌ 불일치 → 품목 필드 신뢰도 0.75로 하향 → 수동 검토 대상
| 유형 | 원본 | 정규화 결과 |
|---|---|---|
| 날짜 | "12/11/2025", "令和6年3月" | "2025-12-11", "2024-03-01" |
| 금액 | "¥173,800", "173,800원" | "173800" |
| 전화번호 | "0312345678" | "03-1234-5678" |
| 회사명 | "(주)코스트코코리아" | "(주) 코스트코코리아" |
모든 필드에 같은 95% 기준을 적용하는 건 비효율적이다. AuditLog(수정 이력)를 분석해서 필드별 실제 정확도를 계산하고, 임계값을 동적으로 조정한다.
// 임계값 공식
threshold = base - (accuracy - 0.90) × 0.5
// clamped to [0.80, 0.99]
// 예시:
합계금액 정확도 98% → 임계값 0.85 // 쉽게 자동 승인
전화번호 정확도 70% → 임계값 0.98 // 거의 항상 수동 검토
AI가 잘하는 필드에서 불필요한 수동 검토를 줄이고, 못하는 필드에서 더 엄격하게 체크한다.
글자가 텍스트 레이어로 내장. 복사-붙여넣기 가능. OCR 불필요 — PyMuPDF로 직접 추출하면 픽셀 단위 정확.
글자가 이미지로만 존재. 복사하면 아무것도 안 나옴. OCR 필수 — 이미지 변환 후 PaddleOCR/Gemini 실행.
핵심 문제: 하나의 PDF에 두 종류가 섞여 있을 수 있다. 텍스트 본문 뒤에 스캔된 서명 페이지가 있는 계약서가 전형적인 예.
해결: 페이지 단위 분류. 각 페이지의 유의미 문자 수(CJK + 3자 이상 영단어)가 100자 이상이면 text_rich, 미만이면 text_poor. text_rich 페이지는 내장 텍스트 사용, text_poor 페이지만 이미지 변환 후 OCR.
13페이지: text_rich (내장 텍스트 사용, OCR 스킵)
4페이지: text_poor (이미지 변환 → OCR 실행)
→ 4페이지에만 OCR을 돌려서 처리 시간과 메모리 절약
PPStructureV3 모델은 한 페이지에 최대 18.5GB 피크 메모리를 사용한다. 17페이지를 동시에 로딩하면 어떤 서버도 못 버틴다.
// 전체 페이지를 한꺼번에 로딩
images = convert_from_path(pdf)
for image in images:
predict(image)
// → 이전 이미지가 메모리에 남아있음 → OOM
for page in range(1, total+1):
img = load_page(page) // 1장만
result = predict(img)
del img, result // 즉시 해제
gc.collect() // 강제 GC
bbox 파이프라인에서 가장 까다로운 부분은 좌표계 일치다. "bbox를 계산한 이미지"와 "화면에 표시하는 이미지"가 정확히 같아야 한다. 이 "보이지 않는 계약"이 깨지는 대표적인 경우들:
증상: bbox가 전체적으로 어긋남
원인: 파이프라인은 preprocess → resize 순서인데, API가 resize → preprocess 순서로 처리
해결: 항상 동일한 순서 유지. 우리는 preprocess → resize → auto_crop으로 통일.
증상: 모든 bbox가 정확히 한 줄 위를 가리킴
원인: PaddleOCR v5의 UVDoc(문서 곡면 보정)이 내부적으로 이미지를 변형 → 감지 좌표가 변형된 이미지 기준 → 원본과 ~20px 불일치
해결: use_doc_unwarping=False. 우리는 이미 preprocess_for_ocr로 전처리하므로 UVDoc 불필요.
// 디버깅으로 발견한 증거
input_img vs output_img:
Max pixel diff: 231
Pixels changed: 94% // PaddleOCR가 몰래 이미지를 바꾸고 있었다!
Vertical shift: -20.9px // 위쪽으로 약 21픽셀 이동 = 딱 한 줄
증상: bbox가 한쪽으로 쏠림
원인: auto-crop 후 좌표를 원본 기준으로 역변환했는데, 화면에서도 crop된 이미지를 표시
해결: crop 이미지 기준 좌표를 그대로 사용. API도 동일한 crop 적용.
증상: 뒷부분 품목 3개 누락 (17 → 14개)
원인: Gemini가 좌표 계산에 출력 토큰 소진
해결: bbox는 PaddleOCR 스냅핑으로만 부여. Gemini는 값만 추출.