first commit
This commit is contained in:
8
.env
Normal file
8
.env
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
REDIS_HOST = ocr_gateway_test_redis
|
||||||
|
REDIS_PORT = 6379
|
||||||
|
REDIS_DB = 0
|
||||||
|
|
||||||
|
CELERY_FLOWER=http://ocr_gateway_test_flower:5556/api/workers
|
||||||
|
|
||||||
|
UPSTAGE_API_KEY=up_Bb8A7xWmYbtaSvEoYarr4EGhqiL4r
|
||||||
|
UPSTAGE_API_URL=https://api.upstage.ai/v1/document-digitization
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
FROM paddlepaddle/paddle:3.2.0-gpu-cuda11.8-cudnn8.9
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y \
|
||||||
|
poppler-utils \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-kor \
|
||||||
|
libgl1 \
|
||||||
|
curl \
|
||||||
|
tree \
|
||||||
|
git \
|
||||||
|
build-essential && \
|
||||||
|
apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt && \
|
||||||
|
pip install --no-cache-dir paddleocr[all]
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "api:app", "--workers", "2", "--host", "0.0.0.0", "--port", "8880"]
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# OCR Gateway 테스트 프로젝트
|
||||||
|
|
||||||
|
이 프로젝트는 OCR Gateway를 테스트하거나 새로운 모델을 연동하여 테스트하기 위해 구성되었습니다.
|
||||||
|
|
||||||
|
## 프로젝트 구조 및 기능
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/c/Python/workspace/ocr_gateway_test/
|
||||||
|
├───.env # 환경 변수 설정 파일 (API 키, 비밀 값 등)
|
||||||
|
├───api.py # FastAPI 애플리케이션의 메인 실행 파일
|
||||||
|
├───docker-compose.yml # Docker 다중 컨테이너 실행을 위한 설정 파일
|
||||||
|
├───Dockerfile # Python 애플리케이션의 Docker 이미지 생성 파일
|
||||||
|
├───pyproject.toml # Python 프로젝트 설정 및 의존성 관리 파일
|
||||||
|
├───requirements.txt # Python 패키지 의존성 목록
|
||||||
|
├───tasks.py # Celery 비동기 작업을 정의하는 파일
|
||||||
|
├───.git/ # Git 버전 관리 폴더
|
||||||
|
├───config/ # 애플리케이션 설정 관련 폴더
|
||||||
|
│ ├───__init__.py
|
||||||
|
│ └───setting.py # 애플리케이션의 주요 설정 값 (경로, 모델 정보 등)
|
||||||
|
├───router/ # API 엔드포인트(라우팅)를 관리하는 폴더
|
||||||
|
│ ├───__init__.py
|
||||||
|
│ └───ocr_api_router.py # OCR 관련 API 라우터를 정의하는 파일
|
||||||
|
└───utils/ # 공통 유틸리티 및 핵심 기능을 모아둔 폴더
|
||||||
|
├───__init__.py
|
||||||
|
├───celery_utils.py # Celery 관련 유틸리티 함수
|
||||||
|
├───checking_keys.py # API 키 유효성 검사 등 키 관련 유틸리티
|
||||||
|
├───file_handler.py # 파일 업로드, 다운로드 등 파일 처리 유틸리티
|
||||||
|
├───ocr_processor.py # OCR 처리 로직을 관리하고 모델을 선택하는 유틸리티
|
||||||
|
├───preprocessor.py # OCR 처리 전 이미지 전처리를 담당하는 유틸리티
|
||||||
|
├───redis_utils.py # Redis 관련 유틸리티 함수
|
||||||
|
└───text_extractor.py # 실제 OCR 모델을 호출하여 텍스트를 추출하는 함수들이 있는 파일
|
||||||
|
```
|
||||||
|
|
||||||
|
## 신규 모델 추가 및 실행 가이드
|
||||||
|
|
||||||
|
새로운 OCR 모델을 추가하려면 다음 단계를 따르세요.
|
||||||
|
|
||||||
|
1. **모델 함수 추가**: `utils/text_extractor.py` 파일에 새로운 OCR 모델을 호출하고 텍스트를 추출하는 Python 함수를 추가합니다.
|
||||||
|
|
||||||
|
2. **빌드 및 실행**: 터미널에서 아래 명령어를 실행하여 변경사항을 적용하고 Docker 컨테이너를 다시 빌드하고 실행합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
126
api.py
Normal file
126
api.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# ocr/api.py
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from config.setting import CELERY_FLOWER
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
from router import ocr_api_router
|
||||||
|
from utils.celery_utils import celery_app
|
||||||
|
from utils.celery_utils import health_check as celery_health_check_task
|
||||||
|
from utils.redis_utils import get_redis_client
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s - %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="OCR GATEWAY", description="OCR API 서비스", docs_url="/docs")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[
|
||||||
|
"http://172.16.42.101",
|
||||||
|
"http://gsim.hanmaceng.co.kr",
|
||||||
|
"http://gsim.hanmaceng.co.kr:6464",
|
||||||
|
],
|
||||||
|
allow_origin_regex=r"http://(172\.16\.\d{1,3}\.\d{1,3}|gsim\.hanmaceng\.co\.kr)(:\d+)?",
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prometheus Metrics Exporter 활성화
|
||||||
|
Instrumentator().instrument(app).expose(app)
|
||||||
|
|
||||||
|
app.include_router(ocr_api_router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/API")
|
||||||
|
async def health_check():
|
||||||
|
"""애플리케이션 상태 확인"""
|
||||||
|
return {"status": "API ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/Redis")
|
||||||
|
def redis_health_check():
|
||||||
|
client = get_redis_client()
|
||||||
|
if client is None:
|
||||||
|
raise HTTPException(status_code=500, detail="Redis connection failed")
|
||||||
|
try:
|
||||||
|
client.ping()
|
||||||
|
return {"status": "Redis ok"}
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=500, detail="Redis ping failed")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/Celery")
|
||||||
|
async def celery_health_check():
|
||||||
|
"""Celery 워커 상태 확인"""
|
||||||
|
# celery_app = get_celery_app() # 이제 celery_utils에서 직접 임포트합니다.
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 워커들에게 ping 보내기
|
||||||
|
active_workers = celery_app.control.ping(timeout=1.0)
|
||||||
|
if not active_workers:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail="No active Celery workers found."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. 간단한 작업 실행하여 E2E 확인
|
||||||
|
task = celery_health_check_task.delay()
|
||||||
|
result = task.get(timeout=10) # 10초 타임아웃
|
||||||
|
|
||||||
|
if task.state == "SUCCESS" and result.get("status") == "ok":
|
||||||
|
return {
|
||||||
|
"status": "Celery is healthy",
|
||||||
|
"active_workers": active_workers,
|
||||||
|
"task_status": "SUCCESS",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Celery health check task failed with state: {task.state}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException as e:
|
||||||
|
# 이미 HTTPException인 경우 그대로 전달
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Celery health check failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"An error occurred during Celery health check: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/Flower")
|
||||||
|
async def flower_health_check():
|
||||||
|
"""Flower 모니터링 대시보드 상태 확인"""
|
||||||
|
try:
|
||||||
|
flower_api_url = CELERY_FLOWER # Use the full URL from settings
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
response = await client.get(flower_api_url)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Just check if the API is reachable
|
||||||
|
if response.status_code == 200:
|
||||||
|
return {"status": "Flower is running"}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=response.status_code,
|
||||||
|
detail=f"Flower API returned status {response.status_code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logging.error(f"Could not connect to Flower: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503, detail=f"Could not connect to Flower: {str(e)}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Flower health check failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"An error occurred during Flower health check: {str(e)}",
|
||||||
|
)
|
||||||
0
config/__init__.py
Normal file
0
config/__init__.py
Normal file
23
config/setting.py
Normal file
23
config/setting.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Redis 기본 설정
|
||||||
|
REDIS_HOST = os.getenv("REDIS_HOST", "ocr_gateway_redis")
|
||||||
|
REDIS_PORT = os.getenv("REDIS_PORT", 6379)
|
||||||
|
REDIS_DB = os.getenv("REDIS_DB", 0)
|
||||||
|
|
||||||
|
# Celery 설정
|
||||||
|
CELERY_BROKER_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}/0"
|
||||||
|
CELERY_RESULT_BACKEND = f"redis://{REDIS_HOST}:{REDIS_PORT}/1"
|
||||||
|
|
||||||
|
# Celery Flower 설정
|
||||||
|
CELERY_FLOWER = os.getenv("CELERY_FLOWER", "http://ocr_gateway_flower:5556/api/workers")
|
||||||
|
|
||||||
|
# Upsage API Key
|
||||||
|
UPSTAGE_API_KEY = os.getenv("UPSTAGE_API_KEY")
|
||||||
|
UPSTAGE_API_URL = os.getenv(
|
||||||
|
"UPSTAGE_API_URL", "https://api.upstage.ai/v1/document-ai/ocr"
|
||||||
|
)
|
||||||
114
docker-compose.yml
Normal file
114
docker-compose.yml
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
services:
|
||||||
|
ocr_gateway_test:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: ocr_gateway_test
|
||||||
|
container_name: ocr_gateway_test
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "8880:8880"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- CELERY_BROKER_URL=redis://ocr_gateway_test_redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://ocr_gateway_test_redis:6379/1
|
||||||
|
- TESSDATA_PREFIX=/usr/share/tesseract-ocr/4.00/tessdata
|
||||||
|
- PADDLE_DEVICE=${PADDLE_DEVICE:-gpu}
|
||||||
|
depends_on:
|
||||||
|
ocr_gateway_test_redis:
|
||||||
|
condition: service_healthy
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"curl -f http://localhost:8880/health/API && curl -f http://localhost:8880/health/Redis && curl -f http://localhost:8880/health/Celery && curl -f http://localhost:8880/health/Flower",
|
||||||
|
]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- llm_gateway_test_net
|
||||||
|
|
||||||
|
ocr_gateway_test_worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
image: ocr_gateway_test
|
||||||
|
container_name: ocr_gateway_test_worker
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- CELERY_BROKER_URL=redis://ocr_gateway_test_redis:6379/0
|
||||||
|
- CELERY_RESULT_BACKEND=redis://ocr_gateway_test_redis:6379/1
|
||||||
|
- TESSDATA_PREFIX=/usr/share/tesseract-ocr/4.00/tessdata
|
||||||
|
- PADDLE_DEVICE=${PADDLE_DEVICE:-gpu}
|
||||||
|
command: celery -A tasks worker --loglevel=info --pool=threads --concurrency=4
|
||||||
|
depends_on:
|
||||||
|
ocr_gateway_test_redis:
|
||||||
|
condition: service_healthy
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: all
|
||||||
|
capabilities: [gpu]
|
||||||
|
networks:
|
||||||
|
- llm_gateway_test_net
|
||||||
|
|
||||||
|
ocr_gateway_test_flower:
|
||||||
|
image: mher/flower:latest
|
||||||
|
container_name: ocr_gateway_test_flower
|
||||||
|
restart: always
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Seoul
|
||||||
|
- FLOWER_UNAUTHENTICATED_API=true
|
||||||
|
- TESSDATA_PREFIX=/usr/share/tessdata
|
||||||
|
entrypoint:
|
||||||
|
["sh", "-c", "celery --broker='redis://ocr_gateway_test_redis:6379/0' flower --port=5557"]
|
||||||
|
ports:
|
||||||
|
- "5557:5557"
|
||||||
|
depends_on:
|
||||||
|
ocr_gateway_test_redis:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- llm_gateway_test_net
|
||||||
|
|
||||||
|
ocr_gateway_test_redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: ocr_gateway_test_redis
|
||||||
|
command:
|
||||||
|
[
|
||||||
|
"redis-server",
|
||||||
|
"--maxmemory",
|
||||||
|
"256mb",
|
||||||
|
"--maxmemory-policy",
|
||||||
|
"allkeys-lru",
|
||||||
|
]
|
||||||
|
ports:
|
||||||
|
- "6383:6379"
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- llm_gateway_test_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
llm_gateway_test_net:
|
||||||
|
name: llm_gateway_test_net
|
||||||
|
external: true
|
||||||
94
pyproject.toml
Normal file
94
pyproject.toml
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# pyproject.toml
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "ocr-gateway"
|
||||||
|
version = "0.0.0"
|
||||||
|
requires-python = ">=3.10,<3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi",
|
||||||
|
"uvicorn[standard]",
|
||||||
|
"pytesseract",
|
||||||
|
"pdf2image",
|
||||||
|
"PyMuPDF",
|
||||||
|
"python-docx",
|
||||||
|
"Pillow",
|
||||||
|
"aiofiles",
|
||||||
|
"httpx",
|
||||||
|
"snowflake-id",
|
||||||
|
"prometheus-fastapi-instrumentator",
|
||||||
|
"python-multipart",
|
||||||
|
"redis",
|
||||||
|
"celery",
|
||||||
|
"minio",
|
||||||
|
"opencv-python-headless",
|
||||||
|
"python-dotenv",
|
||||||
|
"requests",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
test = [
|
||||||
|
"pytest>=8",
|
||||||
|
"pytest-cov",
|
||||||
|
"anyio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# 공통 설정
|
||||||
|
line-length = 120
|
||||||
|
indent-width = 4
|
||||||
|
exclude = [
|
||||||
|
".bzr",
|
||||||
|
".direnv",
|
||||||
|
".eggs",
|
||||||
|
".git",
|
||||||
|
".git-rewrite",
|
||||||
|
".hg",
|
||||||
|
".mypy_cache",
|
||||||
|
".nox",
|
||||||
|
".pants.d",
|
||||||
|
".pytype",
|
||||||
|
".ruff_cache",
|
||||||
|
".svn",
|
||||||
|
".tox",
|
||||||
|
".venv",
|
||||||
|
"__pypackages__",
|
||||||
|
"_build",
|
||||||
|
"buck-out",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"venv",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
# 기본적으로 Pyflakes('F')와 pycodestyle('E') 코드의 하위 집합을 활성화
|
||||||
|
select = ["E4", "E7", "E9", "F"]
|
||||||
|
ignore = []
|
||||||
|
# 활성화된 모든 규칙에 대한 수정 허용
|
||||||
|
fixable = ["ALL"]
|
||||||
|
unfixable = []
|
||||||
|
# 밑줄 접두사가 붙은 경우 사용하지 않는 변수를 허용
|
||||||
|
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-A-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
|
docstring-code-format = false
|
||||||
|
docstring-code-line-length = "dynamic"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
markers = [
|
||||||
|
"integration: mark a test as an integration test.",
|
||||||
|
"e2e: mark a test as an end-to-end test.",
|
||||||
|
"gpu: mark a test as gpu dependent.",
|
||||||
|
"minio: mark a test as requiring MinIO.",
|
||||||
|
]
|
||||||
|
filterwarnings = [
|
||||||
|
"ignore::DeprecationWarning",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
py-modules = ["api", "tasks"]
|
||||||
|
packages = ["config", "router", "utils"]
|
||||||
23
requirements.txt
Normal file
23
requirements.txt
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
asyncio
|
||||||
|
pytesseract
|
||||||
|
pdf2image
|
||||||
|
PyMuPDF
|
||||||
|
python-docx
|
||||||
|
Pillow
|
||||||
|
aiofiles
|
||||||
|
httpx
|
||||||
|
aiofiles
|
||||||
|
snowflake-id
|
||||||
|
|
||||||
|
prometheus-fastapi-instrumentator
|
||||||
|
python-multipart
|
||||||
|
redis
|
||||||
|
celery
|
||||||
|
|
||||||
|
minio
|
||||||
|
opencv-python-headless
|
||||||
|
python-dotenv
|
||||||
|
requests
|
||||||
|
pytest
|
||||||
3
router/__init__.py
Normal file
3
router/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .ocr_api_router import router as ocr_api_router
|
||||||
|
|
||||||
|
__all__ = ["ocr_api_router"]
|
||||||
127
router/ocr_api_router.py
Normal file
127
router/ocr_api_router.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from tasks import (
|
||||||
|
celery_app,
|
||||||
|
run_ocr_pipeline, # 🔁 새로 만든 체인 함수 임포트
|
||||||
|
)
|
||||||
|
from utils.checking_keys import create_key
|
||||||
|
from utils.redis_utils import get_redis_client
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ocr", tags=["OCR"])
|
||||||
|
|
||||||
|
redis_client = get_redis_client()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", summary="🔍 presigned URL 기반 비동기 OCR 처리")
|
||||||
|
async def ocr_endpoint(file_requests: dict):
|
||||||
|
"""
|
||||||
|
Presigned URL과 OCR 모델을 지정하여 비동기 OCR 작업을 요청합니다.
|
||||||
|
|
||||||
|
- **`file_url`**: OCR을 수행할 파일에 접근할 수 있는 Presigned URL
|
||||||
|
- **`filename`**: 원본 파일의 이름
|
||||||
|
- **`ocr_model`**: 사용할 OCR 모델 (`tesseract`, `pp-ocr`, `pp-structure`, `upstage` 중 선택)
|
||||||
|
|
||||||
|
요청이 접수되면, 작업 추적을 위한 `request_id`와 `task_id`가 즉시 반환됩니다.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
file_url = file_requests.get("file_url")
|
||||||
|
filename = file_requests.get("filename")
|
||||||
|
ocr_model = file_requests.get("ocr_model")
|
||||||
|
|
||||||
|
if not file_url or not filename:
|
||||||
|
raise HTTPException(status_code=400, detail="file_url, filename 필수")
|
||||||
|
|
||||||
|
request_id = create_key()
|
||||||
|
task_id = create_key()
|
||||||
|
run_ocr_pipeline(file_url, filename, request_id, task_id, ocr_model)
|
||||||
|
|
||||||
|
# Redis에 request_id → task_id 매핑 저장
|
||||||
|
try:
|
||||||
|
redis_client.hset("ocr_task_mapping", request_id, task_id)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"작업 정보 저장 오류: {str(e)}")
|
||||||
|
|
||||||
|
# 작업 로그 redis에 기록
|
||||||
|
try:
|
||||||
|
log_entry = {
|
||||||
|
"status": "작업 접수",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"task_id": task_id,
|
||||||
|
"initial_file": filename,
|
||||||
|
}
|
||||||
|
redis_client.rpush(f"ocr_status:{request_id}", json.dumps(log_entry))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 작업을 등록한 후, 실제 OCR 처리를 기다리지 않고 즉시 응답
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"message": "OCR 작업이 접수되었습니다.",
|
||||||
|
"request_id": request_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"status_check_url": f"/ocr/progress/{request_id}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(content={"results": results})
|
||||||
|
|
||||||
|
|
||||||
|
# 실제 OCR 결과는 GET /ocr/progress/{request_id} 엔드포인트를 통해 별도로 조회
|
||||||
|
@router.get("/progress/{request_id}", summary="📊 OCR 진행 상태 및 결과 조회")
|
||||||
|
async def check_progress(request_id: str):
|
||||||
|
"""
|
||||||
|
`request_id`를 이용해 OCR 작업의 진행 상태와 최종 결과를 조회합니다.
|
||||||
|
|
||||||
|
- **`celery_status`**: Celery 작업의 현재 상태 (`PENDING`, `STARTED`, `SUCCESS`, `FAILURE` 등)
|
||||||
|
- **`progress_logs`**: 작업 접수부터 완료까지의 단계별 진행 상황 로그
|
||||||
|
- **`final_result`**: OCR 처리가 성공적으로 완료되었을 때, 추출된 텍스트와 좌표 정보가 포함된 최종 결과
|
||||||
|
"""
|
||||||
|
# request_id → task_id 매핑 확인
|
||||||
|
task_id = redis_client.hget("ocr_task_mapping", request_id)
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"Meeting ID {request_id} 작업 없음"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Celery 작업 상태 조회
|
||||||
|
result = AsyncResult(task_id, app=celery_app)
|
||||||
|
status = result.status
|
||||||
|
|
||||||
|
# 작업 로그 조회
|
||||||
|
try:
|
||||||
|
logs = redis_client.lrange(f"ocr_status:{request_id}", 0, -1)
|
||||||
|
parsed_logs = [json.loads(log) for log in logs]
|
||||||
|
except Exception as e:
|
||||||
|
parsed_logs = [
|
||||||
|
{
|
||||||
|
"status": "로그 가져오기 실패",
|
||||||
|
"error": str(e),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 최종 결과 Redis에서 조회
|
||||||
|
final_result = None
|
||||||
|
try:
|
||||||
|
result_str = redis_client.get(f"ocr_result:{task_id}")
|
||||||
|
if result_str:
|
||||||
|
final_result = json.loads(result_str)
|
||||||
|
except Exception as e:
|
||||||
|
final_result = {"error": f"결과 조회 실패: {str(e)}"}
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"request_id": request_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"celery_status": status,
|
||||||
|
"progress_logs": parsed_logs,
|
||||||
|
"final_result": final_result,
|
||||||
|
}
|
||||||
|
)
|
||||||
157
tasks.py
Normal file
157
tasks.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis
|
||||||
|
from celery import Task, chain
|
||||||
|
from config.setting import REDIS_DB, REDIS_HOST, REDIS_PORT
|
||||||
|
from utils.celery_utils import celery_app
|
||||||
|
from utils.ocr_processor import ocr_process
|
||||||
|
from utils.text_extractor import extract_text_from_file
|
||||||
|
|
||||||
|
# ✅ Redis 클라이언트 생성
|
||||||
|
redis_client = redis.Redis(
|
||||||
|
host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB, decode_responses=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ 공통 Task 베이스 클래스 - 상태 로그 기록 및 예외 후킹 제공
|
||||||
|
class BaseTaskWithProgress(Task):
|
||||||
|
"""
|
||||||
|
Celery Task를 상속한 공통 Task 베이스 클래스입니다.
|
||||||
|
주요 목적은:
|
||||||
|
|
||||||
|
- update_progress()로 Redis에 작업 진행상황 저장
|
||||||
|
- on_failure, on_success 메서드를 오버라이딩하여 자동 상태 기록
|
||||||
|
|
||||||
|
주요 기능:
|
||||||
|
- update_progress: 단계별 상태를 ocr_status:{request_id}에 rpush
|
||||||
|
- on_failure: 예외 발생 시 에러 로그 저장
|
||||||
|
- on_success: 작업 성공 시 성공 로그 저장
|
||||||
|
"""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def update_progress(self, request_id, status_message, step_info=None):
|
||||||
|
log_entry = {
|
||||||
|
"status": status_message,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"step_info": step_info,
|
||||||
|
}
|
||||||
|
redis_client.rpush(f"ocr_status:{request_id}", json.dumps(log_entry))
|
||||||
|
logger.info(f"[{request_id}] Task Progress: {status_message}")
|
||||||
|
|
||||||
|
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||||
|
request_id = kwargs.get("request_id", "unknown")
|
||||||
|
self.update_progress(
|
||||||
|
request_id,
|
||||||
|
"작업 오류 발생",
|
||||||
|
{"error": str(exc), "traceback": str(einfo)},
|
||||||
|
)
|
||||||
|
logger.error(f"[{request_id}] Task Failed: {exc}")
|
||||||
|
super().on_failure(exc, task_id, args, kwargs, einfo)
|
||||||
|
|
||||||
|
def on_success(self, retval, task_id, args, kwargs):
|
||||||
|
request_id = kwargs.get("request_id", "unknown")
|
||||||
|
self.update_progress(request_id, "작업 완료")
|
||||||
|
logger.info(f"[{request_id}] Task Succeeded")
|
||||||
|
super().on_success(retval, task_id, args, kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Step 1: presigned URL에서 파일 다운로드
|
||||||
|
@celery_app.task(bind=True, base=BaseTaskWithProgress)
|
||||||
|
def fetch_file_from_url(
|
||||||
|
self, file_url: str, file_name: str, request_id: str, task_id: str
|
||||||
|
):
|
||||||
|
self.update_progress(request_id, "파일 다운로드 중")
|
||||||
|
suffix = os.path.splitext(file_name)[-1]
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
|
||||||
|
tmp_path = tmp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(
|
||||||
|
download_file_from_presigned_url(file_url, tmp_path)
|
||||||
|
) # 비동기 다운로드 함수 호출
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"파일 다운로드 실패: {e}")
|
||||||
|
|
||||||
|
self.update_progress(request_id, "파일 다운로드 완료")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Step 2: OCR 및 후처리 수행
|
||||||
|
@celery_app.task(bind=True, base=BaseTaskWithProgress)
|
||||||
|
def parse_ocr_text(
|
||||||
|
self, tmp_path: str, request_id: str, file_name: str, ocr_model: str = "upstage"
|
||||||
|
):
|
||||||
|
self.update_progress(request_id, "OCR 작업 시작")
|
||||||
|
start_time = time.time()
|
||||||
|
text, coord, ocr_model = asyncio.run(extract_text_from_file(tmp_path, ocr_model))
|
||||||
|
end_time = time.time()
|
||||||
|
self.update_progress(request_id, "텍스트 추출 및 후처리 완료")
|
||||||
|
result_json = ocr_process(file_name, ocr_model, coord, text, start_time, end_time)
|
||||||
|
return {"result": result_json, "tmp_path": tmp_path}
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Step 3: 결과 Redis 저장 및 임시 파일 삭제
|
||||||
|
@celery_app.task(bind=True, base=BaseTaskWithProgress)
|
||||||
|
def store_ocr_result(self, data: dict, request_id: str, task_id: str):
|
||||||
|
self.update_progress(request_id, "결과 저장 중")
|
||||||
|
redis_key = f"ocr_result:{task_id}"
|
||||||
|
redis_client.set(redis_key, json.dumps(data["result"]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(data["tmp_path"])
|
||||||
|
except Exception:
|
||||||
|
logger.warning(f"[{request_id}] 임시 파일 삭제 실패")
|
||||||
|
|
||||||
|
self.update_progress(request_id, "모든 작업 완료")
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ 실제 presigned URL에서 파일 다운로드 수행
|
||||||
|
async def download_file_from_presigned_url(file_url: str, save_path: str):
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(file_url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
with open(save_path, "wb") as f:
|
||||||
|
f.write(resp.content)
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ 전체 OCR 체인 실행 함수
|
||||||
|
def run_ocr_pipeline(file_url, file_name, request_id, task_id, ocr_model):
|
||||||
|
chain(
|
||||||
|
fetch_file_from_url.s(
|
||||||
|
file_url=file_url, file_name=file_name, request_id=request_id, task_id=task_id
|
||||||
|
) # ✅ Step 1: presigned URL에서 파일 다운로드
|
||||||
|
| parse_ocr_text.s(
|
||||||
|
request_id=request_id, file_name=file_name, ocr_model=ocr_model
|
||||||
|
) # ✅ Step 2: OCR 및 후처리 수행
|
||||||
|
| store_ocr_result.s(
|
||||||
|
request_id=request_id, task_id=task_id
|
||||||
|
) # ✅ Step 3: 결과 Redis 저장 및 임시 파일 삭제
|
||||||
|
).apply_async(task_id=task_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ 결과 조회 함수: Redis에서 task_id로 OCR 결과 조회
|
||||||
|
def get_ocr_result(task_id: str):
|
||||||
|
redis_key = f"ocr_result:{task_id}"
|
||||||
|
result = redis_client.get(redis_key)
|
||||||
|
if result:
|
||||||
|
return json.loads(result)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ 상태 로그 조회 함수: Redis에서 request_id 기반 상태 로그 조회
|
||||||
|
def get_ocr_status_log(request_id: str):
|
||||||
|
redis_key = f"ocr_status:{request_id}"
|
||||||
|
logs = redis_client.lrange(redis_key, 0, -1)
|
||||||
|
return [json.loads(entry) for entry in logs]
|
||||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
13
utils/celery_utils.py
Normal file
13
utils/celery_utils.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# utils/celery_utils.py
|
||||||
|
from celery import Celery
|
||||||
|
from config.setting import CELERY_BROKER_URL, CELERY_RESULT_BACKEND
|
||||||
|
|
||||||
|
# Define and export the single Celery app instance
|
||||||
|
celery_app = Celery(
|
||||||
|
"ocr_tasks", broker=CELERY_BROKER_URL, backend=CELERY_RESULT_BACKEND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="health_check")
|
||||||
|
def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
14
utils/checking_keys.py
Normal file
14
utils/checking_keys.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from snowflake import SnowflakeGenerator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
def create_key(node: int = 1) -> str:
|
||||||
|
"""
|
||||||
|
Snowflake 알고리즘 기반 고유 키 생성기 (request_id용)
|
||||||
|
"""
|
||||||
|
generator = SnowflakeGenerator(node)
|
||||||
|
return str(next(generator))
|
||||||
99
utils/file_handler.py
Normal file
99
utils/file_handler.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
import docx
|
||||||
|
import fitz
|
||||||
|
from pdf2image import convert_from_path
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_file(file_path, ocr_model):
|
||||||
|
"""
|
||||||
|
파일 경로를 기반으로 파일 유형을 확인하고 적절한 처리를 수행합니다.
|
||||||
|
- PDF, 이미지는 OCR을 위해 이미지 객체 리스트를 반환합니다.
|
||||||
|
- DOCX는 직접 텍스트를 추출하여 반환합니다.
|
||||||
|
- 지원하지 않는 형식은 ValueError를 발생시킵니다.
|
||||||
|
"""
|
||||||
|
ext = os.path.splitext(file_path)[-1].lower()
|
||||||
|
images = []
|
||||||
|
text_only = None
|
||||||
|
needs_ocr = False
|
||||||
|
|
||||||
|
# Upstage는 원본 파일 업로드 → 변환 불필요
|
||||||
|
if ocr_model == "upstage":
|
||||||
|
# if ext == ".pdf":
|
||||||
|
# text_only = await asyncio.to_thread(extract_text_from_pdf_direct, file_path)
|
||||||
|
# if text_only.strip(): # 텍스트가 충분히 추출되었다면 OCR 생략
|
||||||
|
# logger.info(f"[UTILS-TEXT] {ocr_model}: PDF 텍스트 충분 → OCR 생략")
|
||||||
|
# needs_ocr = False
|
||||||
|
# return images, text_only, needs_ocr
|
||||||
|
# else: # 텍스트가 충분하지 않다면 OCR 필요
|
||||||
|
# logger.info(f"[FILE-HANDLER] {ocr_model}: PDF 텍스트 부족 → OCR 필요")
|
||||||
|
# needs_ocr = True
|
||||||
|
# return images, text_only, needs_ocr
|
||||||
|
# else:
|
||||||
|
logger.info(f"[FILE-HANDLER] {ocr_model}: PDF 외 파일은 OCR 필요 (파일 변환 불필요) ")
|
||||||
|
needs_ocr = True
|
||||||
|
return images, text_only, needs_ocr
|
||||||
|
|
||||||
|
# Upstage가 아닌 경우 파일 형식에 따라 처리
|
||||||
|
if ext == ".pdf":
|
||||||
|
# text_only = await asyncio.to_thread(extract_text_from_pdf_direct, file_path)
|
||||||
|
# if text_only.strip(): # 텍스트가 충분히 추출되었다면 OCR 생략
|
||||||
|
# logger.info(f"[UTILS-TEXT] {ocr_model}: PDF 텍스트 충분 → OCR 생략")
|
||||||
|
# needs_ocr = False
|
||||||
|
# return images, text_only, needs_ocr
|
||||||
|
|
||||||
|
images = await asyncio.to_thread(convert_from_path, file_path, dpi=400)
|
||||||
|
logger.info(f"[FILE-HANDLER] {ocr_model}: PDF → 이미지 변환 완료 ({len(images)} 페이지)")
|
||||||
|
needs_ocr = True
|
||||||
|
|
||||||
|
elif ext in [".jpg", ".jpeg", ".png"]:
|
||||||
|
img = await asyncio.to_thread(Image.open, file_path)
|
||||||
|
images = [img]
|
||||||
|
logger.info(f"[FILE-HANDLER] {ocr_model}: 이미지 파일 로딩 완료")
|
||||||
|
needs_ocr = True
|
||||||
|
|
||||||
|
elif ext == ".docx":
|
||||||
|
text_only = await asyncio.to_thread(extract_text_from_docx, file_path)
|
||||||
|
logger.info(f"[FILE-HANDLER] {ocr_model}: Word 문서 텍스트 추출 완료")
|
||||||
|
needs_ocr = False
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f"[ERROR] 지원하지 않는 파일 형식: {ext}")
|
||||||
|
raise ValueError("지원하지 않는 파일 형식입니다. (PDF, JPG, JPEG, PNG, DOCX)")
|
||||||
|
|
||||||
|
return images, text_only, needs_ocr
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_pdf_direct(pdf_path):
|
||||||
|
text = ""
|
||||||
|
try:
|
||||||
|
with fitz.open(pdf_path) as doc:
|
||||||
|
for page in doc:
|
||||||
|
text += page.get_text()
|
||||||
|
valid_chars = re.findall(r"[가-힣a-zA-Z]", text)
|
||||||
|
logger.info(f"len(valid_chars): {len(valid_chars)}")
|
||||||
|
if len(valid_chars) < 10:
|
||||||
|
return text # 텍스트가 충분하지 않으면 바로 반환
|
||||||
|
else:
|
||||||
|
text += page.get_text()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("[ERROR] PDF 텍스트 추출 실패:", e)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def extract_text_from_docx(docx_path):
|
||||||
|
"""DOCX 파일에서 텍스트를 추출합니다."""
|
||||||
|
text = ""
|
||||||
|
try:
|
||||||
|
doc = docx.Document(docx_path)
|
||||||
|
for para in doc.paragraphs:
|
||||||
|
text += para.text + "\n"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[ERROR] DOCX 텍스트 추출 실패: {e}")
|
||||||
|
return text
|
||||||
14
utils/ocr_processor.py
Normal file
14
utils/ocr_processor.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
def ocr_process(filename, ocr_model, coord, text, start_time, end_time):
|
||||||
|
json_data = {
|
||||||
|
"filename": filename,
|
||||||
|
"model": {"ocr_model": ocr_model},
|
||||||
|
"time": {
|
||||||
|
"duration_sec": f"{end_time - start_time:.2f}",
|
||||||
|
"started_at": start_time,
|
||||||
|
"ended_at": end_time,
|
||||||
|
},
|
||||||
|
"fields": coord,
|
||||||
|
"parsed": text,
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_data
|
||||||
61
utils/preprocessor.py
Normal file
61
utils/preprocessor.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def to_rgb_uint8(img_np: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
입력 이미지를 3채널 RGB, uint8 [0,255] 로 표준화
|
||||||
|
허용 입력: HxW, HxWx1, HxWx3, HxWx4, float[0..1]/[0..255], int 등
|
||||||
|
"""
|
||||||
|
if img_np is None:
|
||||||
|
raise ValueError("Input image is None")
|
||||||
|
|
||||||
|
# dtype/범위 표준화
|
||||||
|
if img_np.dtype != np.uint8:
|
||||||
|
arr = img_np.astype(np.float32)
|
||||||
|
if arr.max() <= 1.0: # [0,1]로 보이면 스케일업
|
||||||
|
arr *= 255.0
|
||||||
|
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
||||||
|
img_np = arr
|
||||||
|
|
||||||
|
# 채널 표준화
|
||||||
|
if img_np.ndim == 2: # HxW
|
||||||
|
img_np = cv2.cvtColor(img_np, cv2.COLOR_GRAY2RGB)
|
||||||
|
elif img_np.ndim == 3:
|
||||||
|
h, w, c = img_np.shape
|
||||||
|
if c == 1:
|
||||||
|
img_np = cv2.cvtColor(img_np, cv2.COLOR_GRAY2RGB)
|
||||||
|
elif c == 4:
|
||||||
|
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGBA2RGB)
|
||||||
|
elif c == 3:
|
||||||
|
pass # 그대로 사용
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported channel count: {c}")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported ndim: {img_np.ndim}")
|
||||||
|
|
||||||
|
return img_np
|
||||||
|
|
||||||
|
# tesseract 전처리 함수
|
||||||
|
def tess_prep_cv2(pil_img):
|
||||||
|
logger.info("[UTILS-OCR] 이미지 전처리 시작")
|
||||||
|
img = np.array(pil_img.convert("RGB")) # PIL → OpenCV 변환
|
||||||
|
img = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) # 그레이스케일 변환
|
||||||
|
img = cv2.bilateralFilter(img, 9, 75, 75) # 노이즈 제거
|
||||||
|
img = cv2.adaptiveThreshold(
|
||||||
|
img,
|
||||||
|
255,
|
||||||
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||||
|
cv2.THRESH_BINARY,
|
||||||
|
31,
|
||||||
|
10, # 대비 향상
|
||||||
|
)
|
||||||
|
img = cv2.resize(
|
||||||
|
img, None, fx=2, fy=2, interpolation=cv2.INTER_LINEAR
|
||||||
|
) # 해상도 확대
|
||||||
|
|
||||||
|
return Image.fromarray(img)
|
||||||
22
utils/redis_utils.py
Normal file
22
utils/redis_utils.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# utils/redis_utils.py
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from config.setting import REDIS_DB, REDIS_HOST, REDIS_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def get_redis_client():
|
||||||
|
"""
|
||||||
|
Redis 클라이언트를 반환합니다. decode_responses=True 설정으로 문자열을 자동 디코딩합니다.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
redis_client = redis.Redis(
|
||||||
|
host=REDIS_HOST,
|
||||||
|
port=REDIS_PORT,
|
||||||
|
db=REDIS_DB,
|
||||||
|
decode_responses=True,
|
||||||
|
)
|
||||||
|
# 연결 확인 (ping)
|
||||||
|
redis_client.ping()
|
||||||
|
return redis_client
|
||||||
|
except redis.ConnectionError as e:
|
||||||
|
raise RuntimeError(f"Redis 연결 실패: {e}")
|
||||||
316
utils/text_extractor.py
Normal file
316
utils/text_extractor.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import httpx
|
||||||
|
import numpy as np
|
||||||
|
import paddle
|
||||||
|
import pytesseract
|
||||||
|
from config.setting import UPSTAGE_API_KEY, UPSTAGE_API_URL
|
||||||
|
from paddleocr import PaddleOCR, PPStructureV3
|
||||||
|
|
||||||
|
from .file_handler import process_file
|
||||||
|
from .preprocessor import tess_prep_cv2, to_rgb_uint8
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# PaddleOCR 및 PPStructure 모델을 전역 변수로 초기화
|
||||||
|
# 이렇게 하면 Celery 워커가 시작될 때 한 번만 모델을 로드합니다.
|
||||||
|
_paddle_ocr_model = None
|
||||||
|
_paddle_structure_model = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_paddle_ocr_model():
|
||||||
|
"""PaddleOCR 모델 인스턴스를 반환합니다 (Singleton)."""
|
||||||
|
global _paddle_ocr_model
|
||||||
|
if _paddle_ocr_model is None:
|
||||||
|
device = os.getenv("PADDLE_DEVICE", "cpu")
|
||||||
|
logger.info(f"Initializing PaddleOCR model on device: {device}")
|
||||||
|
_paddle_ocr_model = PaddleOCR(
|
||||||
|
use_doc_orientation_classify=False,
|
||||||
|
use_doc_unwarping=False,
|
||||||
|
device=device,
|
||||||
|
lang="korean",
|
||||||
|
)
|
||||||
|
logger.info("PaddleOCR model initialized.")
|
||||||
|
return _paddle_ocr_model
|
||||||
|
|
||||||
|
|
||||||
|
def get_paddle_structure_model():
|
||||||
|
"""PPStructure 모델 인스턴스를 반환합니다 (Singleton)."""
|
||||||
|
global _paddle_structure_model
|
||||||
|
if _paddle_structure_model is None:
|
||||||
|
device = os.getenv("PADDLE_DEVICE", "cpu")
|
||||||
|
logger.info(f"Initializing PPStructure model on device: {device}")
|
||||||
|
_paddle_structure_model = PPStructureV3(
|
||||||
|
use_doc_orientation_classify=False,
|
||||||
|
use_doc_unwarping=False,
|
||||||
|
device=device,
|
||||||
|
lang="korean",
|
||||||
|
layout_threshold=0.3, # 레이아웃 인식 실패로 임계값 수정됨
|
||||||
|
)
|
||||||
|
logger.info("PPStructure model initialized.")
|
||||||
|
return _paddle_structure_model
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_text_from_file(file_path, ocr_model):
|
||||||
|
"""
|
||||||
|
파일을 처리하고 OCR 모델을 적용하여 텍스트를 추출합니다.
|
||||||
|
"""
|
||||||
|
images, text_only, needs_ocr = await process_file(file_path, ocr_model)
|
||||||
|
|
||||||
|
if not needs_ocr:
|
||||||
|
return text_only, [], "OCR not used"
|
||||||
|
|
||||||
|
if ocr_model == "tesseract":
|
||||||
|
logger.info(f"[TESSERACT] {ocr_model} 로 이미지에서 텍스트 추출 중...")
|
||||||
|
full_response, coord_response = await asyncio.to_thread(
|
||||||
|
extract_tesseract_ocr, images
|
||||||
|
)
|
||||||
|
elif ocr_model == "pp-ocr":
|
||||||
|
logger.info(f"[PP-OCR] {ocr_model}로 이미지에서 텍스트 추출 중...")
|
||||||
|
full_response, coord_response = await asyncio.to_thread(
|
||||||
|
extract_paddle_ocr, images
|
||||||
|
)
|
||||||
|
elif ocr_model == "pp-structure":
|
||||||
|
logger.info(f"[PP-STRUCTURE] {ocr_model}로 이미지에서 텍스트 추출 중...")
|
||||||
|
full_response, coord_response = await asyncio.to_thread(
|
||||||
|
extract_paddle_structure, images
|
||||||
|
)
|
||||||
|
elif ocr_model == "upstage":
|
||||||
|
logger.info(f"[UPSTAGE] {ocr_model}로 이미지에서 텍스트 추출 중...")
|
||||||
|
full_response, coord_response = await extract_upstage_ocr(file_path)
|
||||||
|
else:
|
||||||
|
logger.error(f"[OCR MODEL] 지원하지 않는 모델입니다. ({ocr_model})")
|
||||||
|
raise ValueError(f"지원하지 않는 OCR 모델입니다: {ocr_model}")
|
||||||
|
|
||||||
|
return full_response, coord_response, ocr_model
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ tesseract
|
||||||
|
def extract_tesseract_ocr(images):
|
||||||
|
"""
|
||||||
|
tesseract를 사용하여 이미지에서 텍스트 추출 및 좌표 정보 반환
|
||||||
|
"""
|
||||||
|
all_texts = []
|
||||||
|
coord_response = []
|
||||||
|
|
||||||
|
for page_idx, img in enumerate(images):
|
||||||
|
logger.info(f"[UTILS-OCR] 페이지 {page_idx + 1} OCR로 텍스트 추출 중...")
|
||||||
|
pre_img = tess_prep_cv2(img)
|
||||||
|
text = pytesseract.image_to_string(
|
||||||
|
pre_img, lang="kor+eng", config="--oem 3 --psm 6"
|
||||||
|
)
|
||||||
|
all_texts.append(text)
|
||||||
|
|
||||||
|
ocr_data = pytesseract.image_to_data(
|
||||||
|
pre_img,
|
||||||
|
output_type=pytesseract.Output.DICT,
|
||||||
|
lang="kor+eng",
|
||||||
|
config="--oem 3 --psm 6",
|
||||||
|
)
|
||||||
|
for i in range(len(ocr_data["text"])):
|
||||||
|
word = ocr_data["text"][i].strip()
|
||||||
|
if word == "":
|
||||||
|
continue
|
||||||
|
x, y, w, h = (
|
||||||
|
ocr_data["left"][i],
|
||||||
|
ocr_data["top"][i],
|
||||||
|
ocr_data["width"][i],
|
||||||
|
ocr_data["height"][i],
|
||||||
|
)
|
||||||
|
coord_response.append(
|
||||||
|
{"text": word, "coords": [x, y, x + w, y + h], "page": page_idx + 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"[UTILS-OCR] 페이지 {page_idx + 1} 텍스트 및 좌표 추출 완료")
|
||||||
|
|
||||||
|
full_response = "\n".join(all_texts)
|
||||||
|
return full_response, coord_response
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ PaddleOCR
|
||||||
|
def extract_paddle_ocr(images):
|
||||||
|
"""
|
||||||
|
PaddleOCR를 사용하여 이미지에서 텍스트 추출 및 좌표 정보 반환
|
||||||
|
"""
|
||||||
|
ocr = get_paddle_ocr_model()
|
||||||
|
|
||||||
|
full_response = []
|
||||||
|
coord_response = []
|
||||||
|
|
||||||
|
for page_idx, img in enumerate(images):
|
||||||
|
print(f"[PaddleOCR] 페이지 {page_idx + 1} OCR로 텍스트 추출 중...")
|
||||||
|
img_np = np.array(img)
|
||||||
|
|
||||||
|
# ✅ 채널/타입 표준화 (grayscale/rgba/float 등 대응)
|
||||||
|
try:
|
||||||
|
img_np = to_rgb_uint8(img_np)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PaddleOCR] 페이지 {page_idx + 1} 입력 표준화 실패: {e}")
|
||||||
|
continue # 문제 페이지 스킵 후 다음 페이지 진행
|
||||||
|
|
||||||
|
# ✅ 과도한 해상도 안정화 (최대 변 4000px)
|
||||||
|
h, w = img_np.shape[:2]
|
||||||
|
max_side = max(h, w)
|
||||||
|
max_side_limit = 4000
|
||||||
|
if max_side > max_side_limit:
|
||||||
|
scale = max_side_limit / max_side
|
||||||
|
new_size = (int(w * scale), int(h * scale))
|
||||||
|
img_np = cv2.resize(img_np, new_size, interpolation=cv2.INTER_AREA)
|
||||||
|
print(f"[PaddleOCR] Resized to {img_np.shape[1]}x{img_np.shape[0]}")
|
||||||
|
|
||||||
|
results = ocr.predict(input=img_np)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if paddle.is_compiled_with_cuda():
|
||||||
|
paddle.device.cuda.synchronize()
|
||||||
|
paddle.device.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[PaddleOCR] 페이지 {page_idx + 1} OCR 결과 개수: {len(results)}")
|
||||||
|
for res_idx, res in enumerate(results):
|
||||||
|
print(f"[PaddleOCR] 페이지 {page_idx + 1} 결과 {res_idx + 1}개 추출 완료")
|
||||||
|
res_dic = dict(res.items())
|
||||||
|
|
||||||
|
texts = res_dic.get("rec_texts", [])
|
||||||
|
boxes = res_dic.get("rec_boxes", [])
|
||||||
|
|
||||||
|
for text, bbox in zip(texts, boxes):
|
||||||
|
full_response.append(text)
|
||||||
|
coord_response.append(
|
||||||
|
{"text": text, "coords": bbox.tolist(), "page": page_idx + 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[PaddleOCR] 전체 페이지 텍스트 및 좌표 추출 완료")
|
||||||
|
return "\n".join(full_response), coord_response
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ PaddleStructure
|
||||||
|
def extract_paddle_structure(images):
|
||||||
|
"""
|
||||||
|
PaddleSTRUCTURE 사용하여 이미지에서 텍스트 추출 및 좌표 정보 반환
|
||||||
|
"""
|
||||||
|
structure = get_paddle_structure_model()
|
||||||
|
|
||||||
|
full_response = []
|
||||||
|
coord_response = []
|
||||||
|
|
||||||
|
for page_idx, img in enumerate(images):
|
||||||
|
print(f"[PaddleSTRUCTURE] 페이지 {page_idx + 1} OCR로 텍스트 추출 중...")
|
||||||
|
img_np = np.array(img)
|
||||||
|
print(f"[Padddle-IMG]{img}")
|
||||||
|
|
||||||
|
# ✅ 채널/타입 표준화 (grayscale/rgba/float 등 대응)
|
||||||
|
try:
|
||||||
|
img_np = to_rgb_uint8(img_np)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[PaddleSTRUCTURE] 페이지 {page_idx + 1} 입력 표준화 실패: {e}")
|
||||||
|
continue # 문제 페이지 스킵 후 다음 페이지 진행
|
||||||
|
|
||||||
|
# ✅ 과도한 해상도 안정화 (최대 변 4000px)
|
||||||
|
h, w = img_np.shape[:2]
|
||||||
|
max_side = max(h, w)
|
||||||
|
max_side_limit = 4000
|
||||||
|
if max_side > max_side_limit:
|
||||||
|
scale = max_side_limit / max_side
|
||||||
|
new_size = (int(w * scale), int(h * scale))
|
||||||
|
img_np = cv2.resize(img_np, new_size, interpolation=cv2.INTER_AREA)
|
||||||
|
print(f"[PaddleSTRUCTURE] Resized to {img_np.shape[1]}x{img_np.shape[0]}")
|
||||||
|
|
||||||
|
results = structure.predict(input=img_np)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if paddle.is_compiled_with_cuda():
|
||||||
|
paddle.device.cuda.empty_cache()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
print(f"[PaddleSTRUCTURE] 페이지 {page_idx + 1} OCR 결과 개수: {len(results)}")
|
||||||
|
for res_idx, res in enumerate(results):
|
||||||
|
print(
|
||||||
|
f"[PaddleSTRUCTURE] 페이지 {page_idx + 1} 결과 {res_idx + 1}개 추출 완료"
|
||||||
|
)
|
||||||
|
res_dic = dict(res.items())
|
||||||
|
blocks = res_dic.get("parsing_res_list", []) or []
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
bd = block.to_dict()
|
||||||
|
|
||||||
|
content = bd.get("content", [])
|
||||||
|
bbox = bd.get("bbox", [])
|
||||||
|
|
||||||
|
full_response.append(content)
|
||||||
|
|
||||||
|
coord_response.append(
|
||||||
|
{"text": content, "coords": bbox, "page": page_idx + 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[PaddleSTRUCTURE] 전체 페이지 텍스트 및 좌표 추출 완료")
|
||||||
|
return "\n".join(full_response), coord_response
|
||||||
|
|
||||||
|
|
||||||
|
# ✅ Upstage OCR API
|
||||||
|
async def extract_upstage_ocr(file_path: str):
|
||||||
|
"""
|
||||||
|
Upstage OCR API를 사용하여 이미지에서 텍스트 및 좌표 추출
|
||||||
|
"""
|
||||||
|
if not UPSTAGE_API_KEY:
|
||||||
|
raise ValueError("Upstage API 키가 설정되지 않았습니다.")
|
||||||
|
if not file_path or not os.path.exists(file_path):
|
||||||
|
raise FileNotFoundError(f"파일이 존재하지 않습니다: {file_path}")
|
||||||
|
|
||||||
|
url = UPSTAGE_API_URL
|
||||||
|
if not url:
|
||||||
|
url = "https://api.upstage.ai/v1/document-ai/ocr"
|
||||||
|
logger.warning(f"UPSTAGE_API_URL not set in config, using default: {url}")
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {UPSTAGE_API_KEY}"}
|
||||||
|
data = {"model": "ocr"}
|
||||||
|
filename = Path(file_path).name
|
||||||
|
full_text_parts = []
|
||||||
|
coord_response = []
|
||||||
|
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
files = {"document": (filename, f, "application/octet-stream")}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||||
|
response = await client.post(
|
||||||
|
url, headers=headers, files=files, data=data
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Upstage API 오류: {e.response.text}")
|
||||||
|
raise RuntimeError(f"Upstage API 오류: {e.response.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pages = result.get("pages", [])
|
||||||
|
for page_idx, p in enumerate(pages, start=1):
|
||||||
|
txt = p.get("text")
|
||||||
|
if txt:
|
||||||
|
full_text_parts.append(txt)
|
||||||
|
|
||||||
|
for w in p.get("words", []):
|
||||||
|
verts = (w.get("boundingBox", {}) or {}).get("vertices")
|
||||||
|
if not verts or len(verts) != 4:
|
||||||
|
continue
|
||||||
|
xs = [v.get("x", 0) for v in verts]
|
||||||
|
ys = [v.get("y", 0) for v in verts]
|
||||||
|
coord_response.append(
|
||||||
|
{
|
||||||
|
"text": w.get("text"),
|
||||||
|
"coords": [min(xs), min(ys), max(xs), max(ys)],
|
||||||
|
"page": page_idx,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[UPSTAGE] JSON 파싱 실패: {e} / 원본 result: {result}")
|
||||||
|
return "", []
|
||||||
|
|
||||||
|
full_response = "\n".join(full_text_parts)
|
||||||
|
return full_response, coord_response
|
||||||
Reference in New Issue
Block a user