Internal Tech Share

AI OCR Pipeline
기술 해부

사진 한 장이 구조화된 데이터가 되기까지 — 이미지 전처리, 필드 추출, bbox 좌표계, 신뢰도 시스템의 모든 것

2026.03.28 · gemini_direct 모드 기준 · 읽기 약 20분

시스템 개요두 AI의 역할 분담: Gemini + PaddleOCR

OCR 시스템의 핵심 설계 결정은 역할 분리다. 하나의 엔진이 모든 걸 처리하는 대신, "무엇이 쓰여 있는가"와 "그것이 어디에 있는가"를 분리했다.

Gemini Vision
"무엇" — 필드값 추출
+
PaddleOCR
"어디" — 텍스트 좌표 감지
↓ 텍스트 유사도 매칭
필드값 + 정확한 위치 = 완성된 데이터

왜 Gemini에게 좌표를 함께 요청하지 않는가?

처음에는 Gemini에게 텍스트 추출과 bbox 좌표를 동시에 요청했다. 그런데 코스트코 영수증처럼 품목이 17개가 넘는 문서에서, Gemini가 좌표 계산에 출력 토큰을 소진하면서 뒷부분 품목 3개를 누락해버렸다. 17개 → 14개. 그래서 원칙을 세웠다: Gemini는 값만, PaddleOCR는 위치만.

엔진역할장점실행 환경
Gemini 이미지를 보고 "이건 상호명, 저건 가격" 판단 맥락 이해, 다국어, 오류 교정 Google API (클라우드)
PaddleOCR 모든 글자의 정확한 픽셀 좌표 감지 결정론적, 빠름, 정밀한 좌표 로컬 (CPU/GPU)

전체 파이프라인 흐름사진 업로드부터 검수 화면까지

1

이미지 전처리

회전 보정, 기울기 교정, 샤프닝, 대비 향상 — 문제가 있는 경우에만 조건부 적용

2

리사이즈 → Auto-crop

긴 변 2048px 축소 → 배경 제거, 문서 영역만 잘라냄 (문서가 80% 미만일 때만)

3

문서 유형 분류

Gemini Vision이 첫 페이지 분석 → 영수증 / 청구서 / 계약서 / 기타 자동 분류

4

Gemini 필드 추출

이미지를 Gemini에 전송 → 품목명, 가격, 합계 등 구조화된 필드 + 신뢰도 반환

5

후처리 & 검증

쿠폰 필터링, 합계 검증(산술), 필드 정규화(날짜/금액/전화번호 형식 통일)

6

PaddleOCR 텍스트 감지 → Bbox 스냅핑

crop된 이미지에서 모든 텍스트 좌표 감지 → Gemini 추출값과 텍스트 유사도로 1:1 매칭

7

신뢰도 라우팅 → DB 저장

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스캔 앱처럼 배경을 제거하고 문서만 남기기

책상 위에서 찍은 사진에는 나무 질감, 손, 그림자 같은 배경이 포함된다. auto_crop_document()가 문서 영역만 자동으로 잘라낸다.

동작 원리

A

Otsu 이진화

이미지를 자동 임계값으로 흑백 분리 — 밝은 영수증 vs 어두운 배경

B

윤곽선(Contour) 검출

흑백 이미지에서 경계선을 찾고, 가장 큰 윤곽선 = 문서 영역으로 판단

C

Bounding Rect + 2% 패딩

윤곽선을 감싸는 사각형 + 약간의 여백 → 최종 crop 영역

실제 예시

입력: 1536×2048 (영수증 + 나무 책상 배경)

문서 영역 비율: 33% (80% 미만이므로 crop 실행)

출력: 958×1695 (영수증만)

제거된 영역: 상단 184px, 좌측 531px의 배경

📌

핵심: 이후 모든 bbox 좌표는 이 crop된 이미지 기준이다. 검수 화면 API도 동일한 crop을 적용하므로 좌표계가 일치한다. 이 "보이지 않는 계약"이 깨지면 bbox가 어긋난다.

crop하지 않는 경우

문서가 이미지의 80% 이상을 차지하면 crop을 건너뛴다. 깨끗한 PDF 스캔, 풀페이지 문서 등은 배경이 거의 없으므로 crop이 불필요하다.


Gemini 필드 추출AI가 이미지를 보고 내용을 읽다

전처리+리사이즈된 이미지를 Gemini Vision API에 전송하면, 구조화된 필드 목록이 반환된다.

Gemini 응답 예시
// 코스트코 영수증에서 추출된 필드 [ { "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에 기록된다. "이 유형 문서에서 공급받는자 필드가 자주 틀린다"는 패턴이 감지되면, 다음 추출 시 프롬프트에 주의 힌트가 자동 추가된다. 모델 재학습 없이 프롬프트만으로 점진적 개선.


Bbox 스냅핑텍스트가 이미지의 어디에 있는지 찾기

Gemini가 "무엇(값)"을 추출했다면, PaddleOCR가 "어디(좌표)"를 찾는다. 두 결과를 텍스트 유사도로 매칭하는 과정이 bbox 스냅핑이다.

좌표 정규화 (Normalized Coordinates)

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, }

매칭 알고리즘

4단계 프로세스

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.5Gemini bbox 유지 (fallback)

후처리코드가 AI를 검증하다

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"
회사명"(주)코스트코코리아""(주) 코스트코코리아"

신뢰도 시스템AI를 얼마나 믿을 것인가

3단계 색분류

🟢
HIGH
≥ 95%
자동 승인 가능
🟠
MEDIUM
80 ~ 94%
빠른 확인 필요
🔴
LOW
< 80%
반드시 수동 검토

동적 임계값 — 필드별로 다른 기준

모든 필드에 같은 95% 기준을 적용하는 건 비효율적이다. AuditLog(수정 이력)를 분석해서 필드별 실제 정확도를 계산하고, 임계값을 동적으로 조정한다.

// 임계값 공식 threshold = base - (accuracy - 0.90) × 0.5 // clamped to [0.80, 0.99] // 예시: 합계금액 정확도 98% → 임계값 0.85 // 쉽게 자동 승인 전화번호 정확도 70% → 임계값 0.98 // 거의 항상 수동 검토

AI가 잘하는 필드에서 불필요한 수동 검토를 줄이고, 못하는 필드에서 더 엄격하게 체크한다.


PDF의 두 얼굴텍스트 PDF와 스캔 PDF를 구분하기

텍스트 PDF (text_rich)

글자가 텍스트 레이어로 내장. 복사-붙여넣기 가능. OCR 불필요 — PyMuPDF로 직접 추출하면 픽셀 단위 정확.

스캔 PDF (text_poor)

글자가 이미지로만 존재. 복사하면 아무것도 안 나옴. OCR 필수 — 이미지 변환 후 PaddleOCR/Gemini 실행.

핵심 문제: 하나의 PDF에 두 종류가 섞여 있을 수 있다. 텍스트 본문 뒤에 스캔된 서명 페이지가 있는 계약서가 전형적인 예.

해결: 페이지 단위 분류. 각 페이지의 유의미 문자 수(CJK + 3자 이상 영단어)가 100자 이상이면 text_rich, 미만이면 text_poor. text_rich 페이지는 내장 텍스트 사용, text_poor 페이지만 이미지 변환 후 OCR.

실제 예시 — 17페이지 혼합 PDF

13페이지: text_rich (내장 텍스트 사용, OCR 스킵)

4페이지: text_poor (이미지 변환 → OCR 실행)

4페이지에만 OCR을 돌려서 처리 시간과 메모리 절약

멀티페이지 메모리 관리

PPStructureV3 모델은 한 페이지에 최대 18.5GB 피크 메모리를 사용한다. 17페이지를 동시에 로딩하면 어떤 서버도 못 버틴다.

이전 방식 (OOM 위험)
// 전체 페이지를 한꺼번에 로딩 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를 계산한 이미지"와 "화면에 표시하는 이미지"가 정확히 같아야 한다. 이 "보이지 않는 계약"이 깨지는 대표적인 경우들:

함정 1: 전처리 순서 불일치

증상: bbox가 전체적으로 어긋남

원인: 파이프라인은 preprocess → resize 순서인데, API가 resize → preprocess 순서로 처리

해결: 항상 동일한 순서 유지. 우리는 preprocess → resize → auto_crop으로 통일.

함정 2: PaddleOCR UVDoc (20px 유령)

증상: 모든 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픽셀 이동 = 딱 한 줄

함정 3: crop 좌표 역변환 혼동

증상: bbox가 한쪽으로 쏠림

원인: auto-crop 후 좌표를 원본 기준으로 역변환했는데, 화면에서도 crop된 이미지를 표시

해결: crop 이미지 기준 좌표를 그대로 사용. API도 동일한 crop 적용.

함정 4: Gemini에 box_2d 요청

증상: 뒷부분 품목 3개 누락 (17 → 14개)

원인: Gemini가 좌표 계산에 출력 토큰 소진

해결: bbox는 PaddleOCR 스냅핑으로만 부여. Gemini는 값만 추출.