05 / 11
모델 / 데이터 처리
모델 비교, Token 추출, INPUT 분류, 전처리/후처리 코드, S3 연결
모델 비교: Donut vs LayoutLM 계열
프로젝트 초기에 여러 문서 이해 모델을 비교 검토. Donut 모델은 간단한 파일(라벨 5~6개)에는 잘 적용되지만, 복잡한 Invoice 타입 문서에서는 성능이 부족했음.
결론: 복잡한 물류 문서 처리에는 LayoutLM 계열이 적합 → LayoutLM 채택
Donut 모델
LayoutLM 버전별 비교
LayoutLM (v1)
| 입력 | 텍스트 + bbox |
| 이미지 | 없음 |
LayoutLMv2
| 입력 | 텍스트 + bbox + 이미지 |
| 이미지 | CNN backbone |
LayoutLM
채택| 입력 | 텍스트 + bbox + 이미지 패치 |
| 이미지 | ViT 기반 패치 임베딩 |
비교 요약
| 모델 | 텍스트 | 위치(bbox) | 이미지 | 복잡 문서 |
|---|---|---|---|---|
| Donut | OCR 불필요 | — | ✓ | ✗ |
| LayoutLM v1 | ✓ | ✓ | ✗ | △ |
| LayoutLMv2 | ✓ | ✓ | ✓ (CNN) | ✓ |
| LayoutLM | ✓ | ✓ | ✓ (ViT) | ✓✓ |
OCR Comparison
Token 추출 테스트
후보 1. EasyOCR
추출 안 된 텍스트
EasyOCR 적용 시 추출하지 못한 텍스트가 존재. 예를 들어 'Sep 13 2025'에서 '13'을 추출하지 못하는 경우 발생.
Threshold 조정 이력
추출된 문자 부정확
텍스트가 추출되더라도 문자 인식 정확도가 떨어지는 경우 발생. 특히 작은 글씨, 겹치는 영역, 특수 문자 등에서 오인식 빈번.
EasyOCR Skipping 로그
Threshold 0.3 적용 시 대량의 토큰이 low score로 skipping 처리됨. score가 0.0~0.3 범위에 분포하는 토큰들이 다수 존재.
[INFO] model::predict_single::222 Skipping result with low score: 0.111... [INFO] model::predict_single::222 Skipping result with low score: 0.244... [INFO] model::predict_single::222 Skipping result with low score: 0.161... ... → 수십 개의 토큰이 low score로 skipping 처리됨
→ Threshold를 0.0으로 낮춰 모든 결과를 포함시키되, 후처리 단계에서 필터링하는 방식으로 전환
후보 2. Tesseract
추출 안 된 텍스트
Tesseract의 경우 EasyOCR보다 텍스트 미추출 문제가 더 심각. 특히 복잡한 레이아웃의 문서에서 누락되는 영역이 더 많음.
추출된 문자 부정확
Tesseract도 문자 인식 정확도 문제가 존재. Label Studio에서 확인 시 잘못 인식된 텍스트가 다수 발견됨.
EasyOCR vs Tesseract 비교
| 항목 | EasyOCR | Tesseract |
|---|---|---|
| 텍스트 미추출 | 일부 누락 | 더 심각 |
| 문자 정확도 | 부정확 발생 | 부정확 발생 |
| Threshold 조정 | 0.3 → 0.0 조정 | — |
→ 두 OCR 엔진 모두 한계가 있으며, 후처리 및 LayoutLM과의 결합으로 보완
Data Classification
INPUT 데이터 분류
문서 타입별 분류
처리 대상 문서를 크게 두 가지 타입으로 분류. 각 타입별로 목적, 필드 성격, 라벨 구조가 다름.
인보이스류
비용 청구 문서
금액 / 기간 / 컨테이너 중심
Sea Waybill (B/L)
화물 운송 정보 문서
Shipper / Consignee / Cargo 중심
인보이스류
| 대상 선사 | 선사 B, 선사 A, 선사 D |
| 문서명 | DET, Invoice 등 |
| 목적 | 비용 청구 |
| 필드 성격 | 금액 / 기간 / 컨테이너 중심 |
Labels (35개)
Invoice Header
INVOICE_NUMBERINVOICE_DATEDUE_DATECustomer Info
CUSTOMER_NAMECUSTOMER_ADDRESSCUSTOMER_TAX_IDCUSTOMER_CODESupplier Info
SUPPLIER_NAMESUPPLIER_ADDRESSShipment Info
VESSEL_NAMEVOYAGEBL_NUMBERPOLPODInvoice Totals
TOTAL_NET_AMOUNTTOTAL_TAX_AMOUNTTOTAL_AMOUNTCURRENCYContainer Table
CONTAINER_NUMBERCONTAINER_SIZE_TYPESERVICE_TYPEPCD_DATESERVICE_CONTRACT_NUMBERERD_DATELine Item (Charges)
LINEITEM_DESCRIPTIONLINEITEM_CONTAINER_NUMBERLINEITEM_START_DATELINEITEM_END_DATELINEITEM_QUANTITYLINEITEM_UNITLINEITEM_RATELINEITEM_CHARGE_CURRENCYLINEITEM_EXTENDED_AMOUNTLINEITEM_TAX_AMOUNTLINEITEM_NET_AMOUNTSea Waybill (B/L)
| 대상 선사 | 선사 E (COSCO 등) |
| 문서명 | Sea Waybill |
| 목적 | 화물 운송 정보 |
| 필드 성격 | Shipper / Consignee / Cargo 중심 |
Labels (30개)
BL Header
BL_NUMBERBOOKING_NUMBEREXPORT_REFERENCEParties
SHIPPER_NAMESHIPPER_ADDRESSCONSIGNEE_NAMECONSIGNEE_ADDRESSNOTIFY_NAMENOTIFY_ADDRESSShipment Info
VESSEL_NAMEVOYAGEPOLPODPLACE_OF_RECEIPTPLACE_OF_DELIVERYSERVICE_CONTRACT_NUMBERCargo Info
CONTAINER_NUMBERSEAL_NUMBERCONTAINER_SIZE_TYPEPACKAGE_COUNTDESCRIPTIONGROSS_WEIGHTMEASUREMENTCharges
CHARGE_NAMECHARGE_RATECHARGE_UNITCHARGE_AMOUNTCURRENCY문서 타입 비교
| 항목 | 인보이스류 | Sea Waybill (B/L) |
|---|---|---|
| 목적 | 비용 청구 | 화물 운송 정보 |
| 핵심 필드 | 금액 / 기간 / 컨테이너 | Shipper / Consignee / Cargo |
| 라벨 수 | 35개 | 30개 |
| Line Item | ✓ (Charges table) | △ (있을 경우) |
Preprocessing
전처리 코드 (PDF → PNG 변환)
전처리 파이프라인 목표
S3 버킷에 PDF가 업로드되면
Lambda가 자동 실행
PDF를 고해상도 단일 PNG로 변환 + 가벼운 전처리 적용
전처리된 PNG를 특정 prefix에 저장
Label Studio는 이 PNG만 바라봄
high_quality_pdf_process.py
Lambda 구성
| Lambda 이름 | high_quality_pdf_process_lambda.py |
| IAM Role | AIOCRPreProcessingRole |
| IAM Policy | AIOCRPreProcessingPolicyAWSLambdaBasicExecutionRole |
| Layer | PyMuPDF + Pillow, OpenCV + NumPy |
Lambda Layer 구성
용량 제한: 콘솔 직접 업로드 50MB / Layer 언집 크기 250MB (총합) → S3 업로드 후 Layer 생성이 정석
Layer 1: PyMuPDF + Pillow
Lambda Python 3.12 이미지 + entrypoint override로 빌드
docker run --rm \
--entrypoint /bin/bash \
-v "$PWD":/var/task \
public.ecr.aws/lambda/python:3.12 \
-lc '
pip install -t python/lib/python3.12/site-packages \
PyMuPDF Pillow
'
# 용량 정리 (strip 금지!)
find python -type d -name "tests" -exec rm -rf {} +
find python -type d -name "__pycache__" -exec rm -rf {} +
# zip 생성
zip -r9 layer-pymupdf-py312.zip pythonLayer 2: OpenCV + NumPy
OpenCV는 용량이 크므로 불필요 데이터 제거 + 바이너리 strip 필수
docker run --rm \
--entrypoint /bin/bash \
-v "$PWD":/var/task \
public.ecr.aws/sam/build-python3.12 \
-lc "
pip install --no-cache-dir \
-t python/lib/python3.12/site-packages \
opencv-python-headless numpy
# 불필요 폴더 정리
rm -rf python/.../cv2/data cv2/qt
rm -rf python/.../numpy/*/tests
# 바이너리 strip (용량 크게 줄어듦)
find python -name '*.so' -exec strip --strip-unneeded {} +
"
zip -r9 layer-opencv-numpy-py312.zip python→ zip을 S3에 업로드 → Lambda Layer 생성 → Lambda 함수에 Customer Layers 2개 연결
S3 Prefix 설계
labelstudio-aiocr-poc-source/ ├── raw-pdf/ ← 사용자가 업로드하는 원본 PDF │ └── *.pdf │ ├── preprocessed/ ← Lambda가 만든 고품질 PNG │ └── *.png (Label Studio 연결 대상) │ └── _failed/ ← 전처리 실패 PDF (선택)
전처리 파이프라인 흐름
Model Comparison
OCR / KV 추출 모델 비교
OCR 모델 비교
역할
이미지 → 텍스트 + bbox 생성
선택 기준
PaddleOCR
korean_PP-OCRv3_mobile_rec
한국어 특화 OCR
PaddleOCR
custom fine-tuned
Label Studio 기반 커스텀 OCR
Tesseract
기본
전통 OCR (rule 기반)
EasyOCR
기본
PyTorch OCR
AWS Textract
DetectText
OCR SaaS
Google Document AI
OCR Processor
OCR SaaS
Key-Value 추출 (Document Understanding) 모델 비교
역할
OCR 결과 → Key-Value 구조 추출
선택 기준
LayoutLM
microsoft/LayoutLM-base
텍스트 + bbox + 이미지 기반 모델
LayoutLM
custom checkpoint
도메인 특화 모델
LayoutXLM
multilingual
다국어 LayoutLM
Donut
naver-clova-ix/donut
OCR-free (이미지→JSON)
AWS Textract
AnalyzeExpense
OCR + KV 통합
Google Document AI
Custom Extractor
KV 추출 SaaS
Post-processing
후처리 코드 & 학습 데이터 전달
Lambda 구성
| 로컬 코드 | single_task_label_classifier.py |
| Lambda 이름 | aiocr-post-processing-function |
| IAM Role | AIOCRPostProcessingRole |
| IAM Policy | AIOCRPostProcessingPolicyAWSLambdaBasicExecutionRole |
Label Studio annotation 결과 구조
Label Studio의 annotation 결과는 "한 개의 텍스트 조각 = 여러 컴포넌트(polygon, textarea, labels)"로 쪼개져 저장됨. 이들을 다시 합치기 위해 id 기준 그룹핑이 반드시 필요.
사람 눈에 보이는 것
[ 선사 A ]
박스 + 텍스트 + 라벨이 한 덩어리
라벨: SUPPLIER_NAME
내부 JSON 구조
polygon→ 어디에 있나? (좌표)textarea→ 무슨 글자인가? (텍스트)labels→ 이게 무슨 의미인가? (라벨)// 같은 id로 3개 컴포넌트가 쪼개져 있음
[
{ "id": "d18baf28", "type": "polygon", ... },
{ "id": "d18baf28", "type": "textarea", ... },
{ "id": "d18baf28", "type": "labels", ... }
]Label Studio의 설계 철학: UI 편집을 자유롭게 하기 위해 분리 (박스만 다시 그리기, 텍스트만 수정, 라벨만 변경 등)
후처리 입장에서 원하는 데이터
// id가 같은 것끼리 묶어서 복원한 결과
{
"text": "선사 A",
"bbox": [...],
"label": "SUPPLIER_NAME"
}→ id가 같은 것끼리 묶어서 사람/모델이 쓰기 좋은 단위로 복원
전체 후처리 메인 흐름 (lambda_handler)
S3 이벤트로 들어온 파일(bucket/key) 확인
results/ 폴더면 재처리 방지로 스킵
S3에서 원본 JSON 읽고 파싱
normalize_payload()로 "task + results 리스트" 형태로 통일
3종류 각각 추출/가공: single_values, container_table, lineitem_table
결과를 results/{원본파일명}.json으로 S3에 저장
핵심 엔진: id 기준 그룹핑 → "의미 단위" 복원
_group_results_by_id(results)
result 전체를 id로 묶어 "한 박스 단위"로 복원하는 작업. 이게 없으면 "ACME가 어떤 라벨인지", "LOGISTICS가 어떤 라벨인지"를 연결할 방법이 없음.
Before (Raw)
id=AAA: polygon
id=AAA: textarea("ACME")
id=AAA: labels(["CUSTOMER_NAME"])
id=BBB: polygon
id=BBB: textarea("LOGISTICS")
id=BBB: labels(["CUSTOMER_NAME"])After (Grouped)
{
"AAA": [polygon, textarea, labels],
"BBB": [polygon, textarea, labels]
}extract_labeled_items() 동작
특정 라벨 리스트(target_labels)에 해당하는 박스들만 뽑아내면서, 각 박스를 (라벨, 텍스트, 좌표, y_position) 형태로 표준화.
라벨 찾기
labels / polygonlabels / rectanglelabels 모두 대응
타겟 라벨과 교집합
matching = ["CUSTOMER_NAME"]
텍스트 찾기
textarea에서 text 추출 → "ACME"
좌표 찾기
polygon에서 points 추출
y_position 계산
행 구분용 y 좌표
// 최종 표준 item 1개
{
"id": rid,
"labels": ["CUSTOMER_NAME"],
"text": "ACME",
"points": [...],
"y_position": 12.5
}단일 값 라벨 처리: 같은 라벨끼리 이어 붙이기
extract_single_values()
CUSTOMER_NAME 라벨을 가진 박스들을 전부 가져와서 각 박스의 text를 확보한 뒤, 같은 라벨끼리 공백으로 이어 붙여 1개 값으로 만듦.
추출된 items
[
{ "labels": ["CUSTOMER_NAME"], "text": "ACME" },
{ "labels": ["CUSTOMER_NAME"], "text": "LOGISTICS" },
{ "labels": ["CUSTOMER_NAME"], "text": "MEXICO" }
]out = {}
for it in items:
for lb in it["labels"]:
if lb not in out:
out[lb] = it["text"]
else:
out[lb] = (out[lb] + " " + it["text"]).strip()CUSTOMER_NAME = "ACME"CUSTOMER_NAME = "ACME LOGISTICS"CUSTOMER_NAME = "ACME LOGISTICS MEXICO"→ 모델/라벨링이 토큰 단위로 박스를 쪼개도, 후처리에서 같은 라벨끼리 합쳐서 1개 값으로 복원
테이블 라벨 처리 (Container / Line Item)
테이블은 단일 값과 다르게 "여러 행"이 필요해서 추가 단계가 있음.
라벨 item 추출
extract_labeled_items(results, CONTAINER_LABELS / LINEITEM_LABELS)
행 단위 묶기 — group_by_rows()
y_position 기준 정렬 → y 차이가 threshold(0.5) 넘으면 새 행
행 내 컬럼 합치기 — process_row()
같은 컬럼 라벨은 " " + text로 이어붙임 (단일값과 동일)
후처리 동작 요약
Label Studio 결과(result)를 id로 그룹핑 → "한 박스 단위" 복원
단일 값 라벨: 같은 라벨끼리 텍스트를 이어 붙여 1개 값으로
테이블 라벨: y좌표로 행을 묶어 행/컬럼 구조로 변환
결과 JSON을 S3 results/에 저장
Training Data Pipeline
Label Studio → SageMaker 학습 데이터 전달 방식
현재 방식: 전체 task에 대한 JSON 전달
Completed(=라벨링 완료) task만 모아서 단일 JSON 파일로 Export
S3 train-data/ 아래 저장
GPU 서버(SageMaker)에서 해당 JSON을 읽어 학습 진행
S3 저장 경로
labelstudio-aiocr-poc-destination/train-data/
Export 전략: API 우선 + Fallback
UI에서 "Export data → JSON"으로 받는 포맷을 최대한 그대로 받기 위해 두 가지 방식을 준비.
Project Export API (우선)
Label Studio의 Project Export API를 호출하여 Completed task를 JSON으로 Export
Tasks API Fallback
버전/설정 차이로 Export API 실패 시, tasks를 API로 직접 모아서 JSON으로 저장
코드 관리
| 스크립트 | layoutlm_train_data_export.py |
| 실행 방법 | source lm_env/bin/activate && python layoutlm_train_data_export.py |
| 배치 설정 | run_layoutlm_export_loop.sh와 동일한 방식으로 cron/배치 구성 |
학습 데이터 전달 흐름
Incremental Export
이전 Export 데이터 제외 후 GPU 서버 전달
기존 방식의 문제점 (Before)
→ 반복 · 수작업 · 중복 위험
새 구조: "Completed + New"만 자동 수집
상태 관리: S3 _state 파일
이전에 export한 task ID 목록을 S3에 상태 파일로 관리. 다음 실행 시 이 목록과 비교하여 새로운 task만 export.
s3://[labelstudio-bucket]/
└── train-data/
├── project-6-completed-NEW-{timestamp}.json ← 새 데이터
└── _state/
└── project-6-exported-ids.json ← export 이력최초 실행
• Completed 전체 task export
• exported-ids.json 생성
이후 실행
• exported-ids.json 로드
• 새로 completed된 task만 export
• exported-ids.json 갱신
실행 방법
source lm_env/bin/activate python /home/user/layoutlm_train_data_export.py
결과 확인
• train-data/project-6-completed-NEW-...json 생성
• train-data/_state/project-6-exported-ids.json 생성/갱신
• 이후부터는 새로 completed된 task만 export됨