한국 로컬 식당 메뉴판 이미지를 NAVER CLOVA General OCR V2로 처리한 뒤, 백엔드가 저장하기 쉬운 JSON으로 구조화하는 Python 모듈입니다.
이 파트는 OCR, 메뉴명/가격 추출, 메뉴명 후처리, ERD 기반 JSON 생성까지만 담당합니다. 번역, 위험도 판단, 매움 여부 판단, DB 저장은 후속 파트와 백엔드가 담당합니다.
AI 파이프라인 모듈 목록
ai_ocr/— 메뉴판 이미지 OCR 및 구조화 (이 문서)ai_ruleengine/— 사용자 프로필 기반 메뉴 위험도 판정 → README
AI_industry_lecture/
ai_ocr/ # OCR 실행 코드
main.py # 실제 이미지 OCR 실행
ocr_client.py # CLOVA General OCR V2 호출
clova_layout.py # 가격 anchor 기반 공간 파싱
parser.py # OCR token에서 메뉴 후보 추출
normalizer.py # 가격/메뉴명 정규화와 OCR 잡문자 제거
menu_dictionary.py # 메뉴명 사전
preprocess_image.py # 이미지 전처리
reprocess_raw.py # 저장된 raw OCR 재처리
compare_models.py # 원본/전처리 CLOVA 결과 비교
result_builder.py # ERD 기반 최종 JSON 생성
test_parser_with_mock.py # mock 데이터 테스트
images/ # OCR 테스트 이미지
sample_data/ # CLOVA 호출 없는 회귀 테스트 데이터
outputs/
raw/ # 공급자 중립 CLOVA token JSON
final/ # 백엔드 전달용 최종 JSON
preprocess_compare/ # 원본/전처리 비교 결과
requirements.txt
README.md
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt프로젝트 루트에 .env 파일이 없으면 새로 만들고 아래 값을 입력합니다. 이미 있으면 그대로 사용하면 됩니다.
CLOVA_OCR_URL=https://...apigw.ntruss.com/custom/v1/...
CLOVA_OCR_SECRET=your_clova_ocr_secret
CLOVA_OCR_TIMEOUT_SECONDS=10
CLOVA_OCR_MAX_ATTEMPTS=2
CLOVA_OCR_MAX_CALLS_PER_SCAN=2
CLOVA_OCR_MIN_INTERVAL_SECONDS=1.0
OCR_REQUEST_BUDGET_SECONDS=25
OCR_TOTAL_BUDGET_SECONDS=22
OCR_QUALITY_RETRY_MIN_REMAINING_SECONDS=8
OCR_MAX_CONCURRENT_SCANS=2
OPENAI_API_KEY=your_openai_api_key
OPENAI_MODEL=gpt-4o-mini.env에는 실제 key가 들어가므로 Git에 올리면 안 됩니다.
운영에서는 OCR_ALLOWED_IMAGE_HOSTS에 Presigned GET URL의 S3 호스트를 지정하고, OCR_MAX_IMAGE_BYTES와 OCR_MAX_IMAGE_PIXELS로 입력 크기를 제한할 수 있습니다.
기본 실행:
python3 ai_ocr/main.py --image images/menu_001.jpgCLI 기본 실행은 최종 JSON 생성 직전에 GPT-4o-mini 후처리와 품질 판단을 시도합니다. 반면 FastAPI /v1/ocr은 30초 이내 결과를 위해 두 기능을 기본으로 끄고, 결정론적 파싱·정규화·품질 점수만 사용합니다. 운영 경로에서 GPT를 다시 켜려면 OCR_ENABLE_GPT_POST_PROCESS, OCR_ENABLE_GPT_JUDGMENT를 명시적으로 true로 설정해야 합니다.
다른 이미지를 실행하려면 images/ 폴더에 이미지를 넣고 --image만 바꿉니다.
python3 ai_ocr/main.py --image images/내이미지파일.jpg생성 파일:
outputs/raw/이미지명_clova-general-v2_raw.json
outputs/final/이미지명_result.json
원본 결과가 낮아도 전처리 재시도를 하지 않으려면:
python3 ai_ocr/main.py --image images/menu_001.jpg --no-preprocessGPT 후처리 또는 품질 판단만 끄려면:
python3 ai_ocr/main.py --image images/menu_001.jpg --no-gpt-post-process
python3 ai_ocr/main.py --image images/menu_001.jpg --no-gpt-judgment기본 실행은 로컬 품질 지표(해상도·흐림·기울기)로 원본과 보정본 중 첫 CLOVA 입력을 선택합니다. 첫 결과의 메뉴-가격 구조가 불량하고 전체 시간 예산이 8초 이상 남았을 때만 반대 입력으로 한 번 더 호출합니다. 네트워크 재시도와 품질 재시도를 합쳐 스캔당 CLOVA 외부 호출은 기본 최대 2회입니다.
python3 ai_ocr/main.py --image images/tilted_menu.jpg전처리만 따로 확인하려면:
python3 ai_ocr/preprocess_image.py --image images/tilted_menu.jpg --output images/preprocessed/tilted_menu_fixed.jpg원근 보정이나 기울기 보정이 오히려 결과를 망치면 개별 단계만 끌 수 있습니다.
python3 ai_ocr/main.py --image images/menu_001.jpg --no-perspective
python3 ai_ocr/main.py --image images/menu_001.jpg --no-deskew
python3 ai_ocr/main.py --image images/menu_001.jpg --max-deskew-angle 25백엔드가 이미지를 저장한 뒤 OCR을 호출할 때는 저장 정보를 함께 넘길 수 있습니다.
python3 ai_ocr/main.py \
--image uploads/menu_003.jpg \
--source camera \
--storage-key scans/menu_003.jpg \
--image-url https://example.com/scans/menu_003.jpg \
--print-json연동 옵션:
| 옵션 | JSON 필드 | 설명 |
|---|---|---|
--source |
menu_image.source |
camera 또는 upload |
--storage-key |
menu_image.storage_key |
서버/S3 등에 저장된 이미지 키 |
--image-url |
menu_image.image_url |
백엔드가 접근 가능한 이미지 URL |
--print-json |
stdout | 최종 JSON을 터미널에도 출력 |
백엔드(Spring) AiClient가 호출하는 내부 서비스용 HTTP 래퍼입니다.
기존 OCR/룰엔진/결과생성 로직을 그대로 재사용하며, 파이프라인(OCR → RuleEngine → Result)을 엔드포인트로 노출합니다. 인증은 없고 사설 네트워크를 가정합니다.
로컬 직접 실행:
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8000Docker (compose 권장):
docker compose up -d --build # 처음 / 코드 변경 후: 이미지 빌드 + 백그라운드 실행
docker compose up -d # 그 다음부터: 그냥 다시 띄우기
docker compose logs -f ai # 로그 보기
docker compose down # 내리기CLOVA OCR과 OpenAI 키는 .env 또는 ECS Secret 환경 변수로 주입합니다.
| Method | Endpoint | 기능 설명 |
|---|---|---|
POST |
/v1/ocr |
기존 { "source", "storage_key", "image_url" }와 호환. OCR_S3_FETCH_ENABLED=true면 storage_key + ECS task role로 S3에서 스트리밍하고, 아니면 image_url을 사용. OCR Fast Path는 GPT를 기본 사용하지 않음. |
POST |
/v1/ruleengine |
{ "profile", "ocr_result" }를 받아 analyze_all(ocr_result, profile) 결과 dict를 그대로 반환. menu_analyses가 위험도 판정으로 교체되고 scan_session.risky_menu_count가 채워짐. |
POST |
/v1/result |
/v1/ruleengine 응답(judged_result) dict를 그대로 받아 build_final_results_from_judged로 menu_analyses를 최종 FinalOutput(message/owner_card 포함)으로 교체해 반환. 처리 실패 시 500. |
GET |
/health |
헬스체크 ({"status": "ok"}). |
profile 키: religion_type, is_vegetarian, vegetarian_type, no_alcohol, allergies, no_spicy. allergies는 이미 is_* 태그 형태(예: ["is_milk"])로 전달합니다.
# OCR 단일 호출 timeout / 전체 deadline
CLOVA_OCR_TIMEOUT_SECONDS=10
CLOVA_OCR_MAX_ATTEMPTS=2
CLOVA_OCR_MAX_CALLS_PER_SCAN=2
OCR_REQUEST_BUDGET_SECONDS=25
OCR_TOTAL_BUDGET_SECONDS=22
OCR_QUALITY_RETRY_MIN_REMAINING_SECONDS=8
# 768MB AI 컨테이너에서 메모리 폭주를 막는 backpressure
OCR_MAX_CONCURRENT_SCANS=2
OCR_QUEUE_WAIT_SECONDS=1
OCR_PREPROCESS_MAX_PIXELS=20000000scan_quality에 ocr_attempt_count, selected_ocr_attempt, preprocessing_applied, ocr_processing_time_ms, queue_wait_ms, image_fetch_source가 포함되어 병목을 구간별로 관찰할 수 있습니다.
OCR_S3_FETCH_ENABLED=true
OCR_S3_BUCKET=hanspoon-prod-images-...
AWS_REGION=ap-northeast-2AI ECS task role에 해당 버킷의 s3:GetObject가 있어야 합니다. 활성화 전에는 기존 Presigned URL 다운로드가 그대로 작동합니다. URL fallback을 운영에서 사용하면 OCR_ALLOWED_IMAGE_HOSTS를 정확한 S3 호스트로 설정하고 OCR_REQUIRE_IMAGE_HOST_ALLOWLIST=true로 fail-closed 처리하세요.
parser만 테스트할 때는 mock 또는 저장된 CLOVA 응답을 사용합니다. CLOVA 비용이 발생하지 않습니다.
python3 ai_ocr/test_parser_with_mock.py --input sample_data/mock_ocr_lines.json이미 저장된 raw OCR 결과를 다시 후처리하려면:
python3 ai_ocr/reprocess_raw.py --input sample_data/clova_response_menu_001.json원본과 전처리 결과를 실제로 비교할 때만 아래 명령을 사용합니다. CLOVA를 두 번 호출하므로 비용이 더 발생할 수 있습니다.
python3 ai_ocr/compare_models.py --image images/menu_001.jpg- 메뉴판 이미지를 입력합니다.
- 필요하면 로컬 전처리 이미지를 만듭니다.
- CLOVA General OCR로 polygon과 confidence가 포함된 field를 추출합니다.
- 엄격한 가격 형식으로 가격 anchor를 찾습니다.
- polygon의 지역 기준선과 앞 가격 열 경계를 이용해 낱글자 메뉴명을 결합하고 가격과 매칭합니다.
- 대/중/소, 1인/2인, 세트, 곱빼기 같은 옵션 가격은 가능한 경우
options로 보존합니다. - 메뉴명 앞뒤의 OCR 잡문자와 용량 표기를 제거합니다.
예:
■김치찌개–->김치찌개,■두루치기200g出->두루치기 scan_session,menu_image,menu_analyses구조의 최종 JSON을 생성합니다.
최종 출력은 outputs/final/이미지명_result.json입니다.
{
"scan_session": {
"title": "menu_001.jpg",
"menu_count": 1,
"risky_menu_count": null,
"scan_status": "completed",
"scanned_at": "2026-05-28T12:00:00Z"
},
"menu_image": {
"source": "upload",
"storage_key": null,
"image_url": null,
"mime_type": "image/jpeg",
"file_size": 123456
},
"scan_quality": {
"status": "usable",
"score": 100,
"raw_line_count": 12,
"price_match_count": 1,
"price_match_ratio": 1.0,
"image_width": 1280,
"image_height": 960,
"image_quality": {
"available": true,
"score": 92,
"blur_score": 180.5,
"brightness": 132.4,
"contrast": 54.2,
"glare_ratio": 0.01,
"skew_angle": 1.2,
"reasons": [],
"suggestions": []
},
"retake_suggestions": [],
"reasons": []
},
"menu_analyses": [
{
"menu_name_ko": "수육국밥",
"menu_name_en": null,
"description_ko": "",
"description_en": null,
"price_text": "10,000",
"risk_level": null,
"is_spicy": false,
"image_url": null,
"display_order": 1
}
]
}ERD 매핑:
| JSON 키 | 저장 대상 |
|---|---|
scan_session |
scan_sessions |
menu_image |
menu_images |
scan_quality |
재촬영/검수 판단용 OCR 품질 메타데이터 |
menu_analyses[] |
menu_analyses |
재촬영 판단 기준:
scan_quality.status == "needs_retake": 재촬영 요청scan_quality.status == "low_confidence": 결과는 보여주되 사용자가 확인하도록 안내scan_quality.status == "usable": 정상 사용 가능
메뉴 후보가 없거나 OCR token이 3개 미만이면 재촬영으로 판단합니다. 낮은 해상도는 단독으로 결과를 폐기하지 않고, 가격 anchor 대비 정상 메뉴-가격 쌍 비율(pair_coverage)까지 낮을 때만 재촬영을 강제합니다. 흐림, 밝기, 대비, 빛 반사, 기울기는 scan_quality.image_quality와 scan_quality.retake_suggestions에 기록합니다.
후속 파트가 채우는 필드:
menu_name_endescription_enrisk_levelmenu_analyses.image_urlscan_session.risky_menu_count
is_spicy는 OCR 텍스트에 잡힌 메뉴명/설명/맵기 표기/고추 아이콘 문자 기준으로 true 또는 false를 자동 설정합니다.
권장 흐름:
- 프론트가 카메라 촬영 또는 이미지 업로드로 메뉴판 이미지를 백엔드에 보냅니다.
- 백엔드는 이미지를 서버나 외부 저장소에 저장합니다.
- 백엔드는 저장된 이미지 경로로
ai_ocr/main.py를 호출합니다. - OCR 결과 JSON을 읽어
scan_sessions,menu_images,menu_analyses에 저장합니다. - 후속 분석 파트가 저장된 메뉴 분석 데이터를 기준으로 번역/위험도/매움 여부를 업데이트합니다.
초기에는 파일 기반 연결이 가장 단순합니다. 나중에는 ai_ocr/result_builder.py가 만드는 JSON 구조를 유지하면서 백엔드 API 응답으로 바로 넘기도록 바꾸면 됩니다.
- 메뉴판이 이미지 대부분을 차지하게 촬영합니다.
- 글자가 흐리지 않게 초점을 맞춥니다.
- 빛 반사, 그림자, 가림이 메뉴명과 가격을 덮지 않게 합니다.
- 한 이미지에 여러 메뉴판이나 포스터가 섞이지 않게 합니다.
- JPG, PNG, WEBP 이미지를 권장합니다.
- 가능하면 1280px 이상 해상도를 사용합니다.