Initial commit
This commit is contained in:
113
classifier_service.py
Normal file
113
classifier_service.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import argparse
|
||||||
|
import gc
|
||||||
|
import os
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from routers import router
|
||||||
|
from routers import classifier
|
||||||
|
from utils.classifier_model_store import get_model
|
||||||
|
|
||||||
|
app = FastAPI(title="classifier_service")
|
||||||
|
app.state.request_counter = 0
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
# app.include_router(classifier.router)
|
||||||
|
|
||||||
|
# ── 로거 설정 ─────────────────────────────────────
|
||||||
|
logger.remove()
|
||||||
|
logger.add(
|
||||||
|
"logs/{time:YYYY-MM-DD}.log",
|
||||||
|
level="DEBUG",
|
||||||
|
rotation="00:00",
|
||||||
|
retention="30 days",
|
||||||
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메모리 유틸 ───────────────────────────────────
|
||||||
|
def log_memory_usage() -> float:
|
||||||
|
process = psutil.Process(os.getpid())
|
||||||
|
return process.memory_info().rss / 1024 / 1024
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP 미들웨어 ─────────────────────────────────
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_requests(request: Request, call_next):
|
||||||
|
try:
|
||||||
|
memory_before = log_memory_usage()
|
||||||
|
request_counter = getattr(app.state, "request_counter", 0) + 1
|
||||||
|
app.state.request_counter = request_counter
|
||||||
|
|
||||||
|
logger.info(f"request_counter: {request_counter}")
|
||||||
|
logger.info(f"Received: {request.method} {request.url}")
|
||||||
|
logger.info(f"Headers: {dict(request.headers)}")
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
logger.info(f"Response status: {response.status_code}")
|
||||||
|
|
||||||
|
if request_counter % 100 == 0:
|
||||||
|
logger.info("gc.collect 실행")
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
memory_after = log_memory_usage()
|
||||||
|
logger.info(
|
||||||
|
f"Memory: Before={memory_before:.2f}MB, "
|
||||||
|
f"After={memory_after:.2f}MB, "
|
||||||
|
f"Diff={memory_after - memory_before:.2f}MB"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"요청 처리 중 예외 발생: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal Server Error", "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", tags=["기타"])
|
||||||
|
async def root():
|
||||||
|
return {"message": "document classifier service"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["기타"])
|
||||||
|
async def health():
|
||||||
|
m = get_model()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"model_loaded": m is not None,
|
||||||
|
"classes": list(m.classes_) if m else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 진입점 ────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-host", default="0.0.0.0")
|
||||||
|
parser.add_argument("-port", default=10941)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logger.info(f"서버 시작: {args.host}:{args.port}")
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=args.host,
|
||||||
|
port=int(args.port),
|
||||||
|
limit_concurrency=1000,
|
||||||
|
timeout_keep_alive=120,
|
||||||
|
log_level="debug",
|
||||||
|
)
|
||||||
113
layoutlmv3_service.py
Normal file
113
layoutlmv3_service.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import argparse
|
||||||
|
import gc
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from loguru import logger
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from routers import router
|
||||||
|
|
||||||
|
app = FastAPI(title="layoutlmv3_service")
|
||||||
|
app.state.request_counter = 0
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
# ── 로거 설정 ─────────────────────────────────────
|
||||||
|
logger.remove()
|
||||||
|
logger.add(
|
||||||
|
"logs/{time:YYYY-MM-DD}.log",
|
||||||
|
level="DEBUG",
|
||||||
|
rotation="00:00",
|
||||||
|
retention="30 days",
|
||||||
|
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 메모리 유틸 ───────────────────────────────────
|
||||||
|
def log_memory_usage() -> float:
|
||||||
|
process = psutil.Process(os.getpid())
|
||||||
|
return process.memory_info().rss / 1024 / 1024 # MB
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP 미들웨어 ─────────────────────────────────
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_requests(request: Request, call_next):
|
||||||
|
try:
|
||||||
|
memory_before = log_memory_usage()
|
||||||
|
request_counter = getattr(app.state, "request_counter", 0) + 1
|
||||||
|
app.state.request_counter = request_counter
|
||||||
|
|
||||||
|
logger.info(f"request_counter: {request_counter}")
|
||||||
|
logger.info(f"Received: {request.method} {request.url}")
|
||||||
|
logger.info(f"Headers: {dict(request.headers)}")
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
logger.info(f"Response status: {response.status_code}")
|
||||||
|
|
||||||
|
if request_counter % 100 == 0:
|
||||||
|
logger.info("gc.collect 실행")
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
memory_after = log_memory_usage()
|
||||||
|
logger.info(
|
||||||
|
f"Memory: Before={memory_before:.2f}MB, "
|
||||||
|
f"After={memory_after:.2f}MB, "
|
||||||
|
f"Diff={memory_after - memory_before:.2f}MB"
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"요청 처리 중 예외 발생: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "Internal Server Error", "error": str(e)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", tags=["기타"])
|
||||||
|
async def root():
|
||||||
|
return {"message": "layoutlmv3 invoice classifier"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["기타"])
|
||||||
|
async def health():
|
||||||
|
import config
|
||||||
|
from utils.cache import load_cache
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"device": str(config.DEVICE),
|
||||||
|
"model_ready": (config.MODEL_SAVE_PATH / "config.json").exists(),
|
||||||
|
"dataset_size": len(load_cache()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 진입점 ────────────────────────────────────────
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("-host", default="0.0.0.0")
|
||||||
|
parser.add_argument("-port", default=10941)
|
||||||
|
parser.add_argument("-pool_size", default="10")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logger.info(f"서버 시작: {args.host}:{args.port}")
|
||||||
|
uvicorn.run(
|
||||||
|
app,
|
||||||
|
host=args.host,
|
||||||
|
port=int(args.port),
|
||||||
|
limit_concurrency=1000,
|
||||||
|
timeout_keep_alive=120,
|
||||||
|
log_level="debug",
|
||||||
|
)
|
||||||
13
ocrlogin-431508-8db9e68e7a47.json
Normal file
13
ocrlogin-431508-8db9e68e7a47.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"type": "service_account",
|
||||||
|
"project_id": "ocrlogin-431508",
|
||||||
|
"private_key_id": "8db9e68e7a47fc0116656b68cc99b51eb070a6b4",
|
||||||
|
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpFAIMT0E2S0eH\nwXxApGev8kGqaCRXN8DuG8pSM/rRjs/TvYTu8FDlAWkgiMgymxYrpzhWvqAFWoD8\n837L+ntqF2Ll8nkZOmlluSg+1TgKvGWS00OA6pgpVYkMJKcD3S7mJHqlO4LIKRDy\nHhcc6fmjv67KxUROllJoMAZCO6qsyGVB0xJmV+rzXZavXNJ00Gmi0kYG9gSvCW38\nur98gmaa0cZLIbDi6Tml3r5Zfb/oNzX1ktEjpEIIbwoamFujjr1tWuXtQH7Sf7OJ\ne3N+F83/EurkSWrORoF3SziltXQ1Sutuw3Ns5R+Ky1Mg/gO1Wyk3Bw0F4Keefobt\nWxSfD+H5AgMBAAECggEAPtZJRpLj7Q5AONNvXsTbJjhWMENBEksNwFCCuldIJb66\nPXrHX1ff8KQ8ElPTd39M149vsElrRmIS4y+JlbxzRoQHhOc/G2GqjxwnuWZbzB2l\ncFJk2ZIWV/JKm0E58wUua2juTd9WpRYiDqGhPGU2mqVgDEsRLlXOrZr/kHkFXu4F\nSSGuDSwSRSEN02sgdhhK9/DcfuyHS8YwP9Cq+fb2W+Jdd6kMaA3eQjVZo+UAG9zy\nxQxq+6qPXPstocIjSk5Aa18Z7eOQr+bwd4oIjZpthlJa9V5lNuWYrYmAYk3lBh7r\nLJ+KCsdoS3lIN1gom9EK4p6oLLSAVuXIpxHBK3znYwKBgQC22s/DYBJH+/uMnyvV\nTdUzAcDnpzhJ2Bmup+cyLwLIhQDZPar72Bh/wU0uptsW1/QUFNqHYiZZLOeAeUC8\nVGdb/rUl9r+eMFr+8KqFGRpB4yLqRZniRtGxmGXd/wbx3MYA4GV7AdNgEoavQkip\nl0iKVn7K587wJjow1HQLBJnoMwKBgQDstmjwPfNIJzUw+TtJA9jemdagOuQTQvgi\nchq3PQd1JfU8eFBOZS1EpvcVkA86oB05hBwJvuk6J/jgeWjuwkve7ppCOFt24xzr\nJEEVPspBJ+T+tGAu4Fs/5ACNC8I1LV5oY0xCJXa7gNljeHU6SrRD7tN+sO+4SRFs\nN99uPT9RIwKBgGHQAI1hacYJ29CoIHl0rhQf3wHL6IdPysUr2bd1gEalJwQOQdWA\nDfLhAxludgntMQpA8Xi0HxFavOdzdRaJC9UhFeOd73h+I172fDDAcdRG3Rl2a8+n\n1GnsvKkYz603TM+ROZeoLVrZ7iP4EAhv/YTKqf5+K6s4t64BJ6XxKycTAoGAWNAT\nzVehCMhVJ7vLJ5j+7H4RzepqmmN9EAd5yJhoTObh/T8y+kbx1hlDCV8Up61dabAM\niQeNIBnRQf+rhDF4H/ur+v6EKrYJqpveo2b8obejLoFkuRHKis0z+7eWtTcBfe8L\ntKGzy6QLbEvMyAMxYW+hAJ7IQn9/vvezp/vo3rsCgYEAsjIb+H82uY2wwNJlf4ar\nuASzvE7debIvuNX/ThaX0lyBX0a1dXGxtLJp7U/E/eky/IXXCE/hm4qrOV8UZxwv\nYQak8whLeu3xgiu6DIlYsEwRCtV3sCdh1pSfIWjhDKGdaHn1BKCBd+Cfxqa6bon5\nphAyYfmLSEYotI4JPlbGCwU=\n-----END PRIVATE KEY-----\n",
|
||||||
|
"client_email": "vertaxai-ocr@ocrlogin-431508.iam.gserviceaccount.com",
|
||||||
|
"client_id": "111900626593542552794",
|
||||||
|
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
||||||
|
"token_uri": "https://oauth2.googleapis.com/token",
|
||||||
|
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
||||||
|
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/vertaxai-ocr%40ocrlogin-431508.iam.gserviceaccount.com",
|
||||||
|
"universe_domain": "googleapis.com"
|
||||||
|
}
|
||||||
87
requirements.txt
Normal file
87
requirements.txt
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.1
|
||||||
|
cachetools==5.5.2
|
||||||
|
certifi==2026.2.25
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.1
|
||||||
|
colorama==0.4.6
|
||||||
|
dnspython==2.8.0
|
||||||
|
email-validator==2.3.0
|
||||||
|
et_xmlfile==2.0.0
|
||||||
|
fastapi==0.135.1
|
||||||
|
fastapi-cli==0.0.24
|
||||||
|
filelock==3.20.0
|
||||||
|
fsspec==2025.12.0
|
||||||
|
google-api-core==2.30.0
|
||||||
|
google-auth==2.29.0
|
||||||
|
google-cloud-vision==3.7.2
|
||||||
|
googleapis-common-protos==1.72.0
|
||||||
|
grpcio==1.78.0
|
||||||
|
grpcio-status==1.62.3
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httptools==0.7.1
|
||||||
|
httpx==0.28.1
|
||||||
|
huggingface_hub==0.36.2
|
||||||
|
idna==3.11
|
||||||
|
Jinja2==3.1.6
|
||||||
|
joblib==1.5.3
|
||||||
|
loguru==0.7.2
|
||||||
|
markdown-it-py==4.0.0
|
||||||
|
MarkupSafe==3.0.2
|
||||||
|
mdurl==0.1.2
|
||||||
|
mpmath==1.3.0
|
||||||
|
networkx==3.6.1
|
||||||
|
numpy==1.26.4
|
||||||
|
openpyxl==3.1.5
|
||||||
|
orjson==3.11.7
|
||||||
|
packaging @ file:///C:/miniconda3/conda-bld/packaging_1761049137378/work
|
||||||
|
pandas==3.0.1
|
||||||
|
pillow==10.3.0
|
||||||
|
proto-plus==1.27.1
|
||||||
|
protobuf==4.25.8
|
||||||
|
psutil==5.9.8
|
||||||
|
pyasn1==0.6.2
|
||||||
|
pyasn1_modules==0.4.2
|
||||||
|
pydantic==2.7.1
|
||||||
|
pydantic_core==2.18.2
|
||||||
|
Pygments==2.19.2
|
||||||
|
PyMuPDF==1.24.3
|
||||||
|
PyMuPDFb==1.24.3
|
||||||
|
python-dateutil==2.9.0.post0
|
||||||
|
python-dotenv==1.2.2
|
||||||
|
python-multipart==0.0.9
|
||||||
|
PyYAML==6.0.3
|
||||||
|
regex==2026.2.28
|
||||||
|
requests==2.32.5
|
||||||
|
rich==14.3.3
|
||||||
|
rich-toolkit==0.19.7
|
||||||
|
rsa==4.9.1
|
||||||
|
safetensors==0.7.0
|
||||||
|
scikit-learn==1.8.0
|
||||||
|
scipy==1.17.1
|
||||||
|
setuptools==80.10.2
|
||||||
|
shellingham==1.5.4
|
||||||
|
six==1.17.0
|
||||||
|
sse-starlette==3.3.2
|
||||||
|
starlette==0.52.1
|
||||||
|
sympy==1.13.1
|
||||||
|
threadpoolctl==3.6.0
|
||||||
|
timm==0.9.16
|
||||||
|
tokenizers==0.19.1
|
||||||
|
torch==2.6.0+cu124
|
||||||
|
torchvision==0.21.0+cu124
|
||||||
|
tqdm==4.66.4
|
||||||
|
transformers==4.41.2
|
||||||
|
typer==0.24.1
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
tzdata==2025.3
|
||||||
|
ujson==5.11.0
|
||||||
|
urllib3==2.6.3
|
||||||
|
uvicorn==0.30.1
|
||||||
|
watchfiles==1.1.1
|
||||||
|
websockets==16.0
|
||||||
|
wheel==0.46.3
|
||||||
|
win32_setctime==1.2.0
|
||||||
10
routers/__init__.py
Normal file
10
routers/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from routers import dataset, train, evaluate, predict, visualize, classifier
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(dataset.router, prefix="/dataset", tags=["1. 데이터셋"])
|
||||||
|
router.include_router(train.router, prefix="/train", tags=["2. 학습"])
|
||||||
|
router.include_router(evaluate.router, prefix="/evaluate", tags=["3. 평가"])
|
||||||
|
router.include_router(predict.router, prefix="/predict", tags=["4. 추론"])
|
||||||
|
router.include_router(visualize.router, prefix="/visualize", tags=["5. 시각화"])
|
||||||
|
router.include_router(classifier.router, prefix="/classifier", tags=["6. 문서분류"]) # 추가
|
||||||
520
routers/classifier.py
Normal file
520
routers/classifier.py
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
import time
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
from utils import classifier_model_store
|
||||||
|
from train import classifier_trainer
|
||||||
|
from train import classifier_dataset
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
|
||||||
|
from pathlib import Path
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import config
|
||||||
|
from utils.ocr import ocr_batch, get_ocr_functions
|
||||||
|
from utils.pdf import is_pdf, pdf_to_images
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 스키마 ────────────────────────────────────────
|
||||||
|
class PredictRequest(BaseModel):
|
||||||
|
words: list[str]
|
||||||
|
threshold: float = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
class PredictResponse(BaseModel):
|
||||||
|
label: str
|
||||||
|
file_name: str
|
||||||
|
confidence: float
|
||||||
|
all_probs: dict[str, float]
|
||||||
|
|
||||||
|
|
||||||
|
class PredictListResponse(BaseModel):
|
||||||
|
results: list[PredictResponse]
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TrainResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
class DatasetResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
message: str
|
||||||
|
total: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dataset ───────────────────────────────────────
|
||||||
|
@router.post("/dataset/build", response_model=DatasetResponse, summary="데이터셋 생성")
|
||||||
|
async def build_dataset(background_tasks: BackgroundTasks):
|
||||||
|
background_tasks.add_task(classifier_dataset.build)
|
||||||
|
return DatasetResponse(status="building", message="데이터셋 생성 시작. /classifier/dataset/status 확인하세요.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset/status", response_model=DatasetResponse, summary="데이터셋 생성 상태")
|
||||||
|
async def dataset_status():
|
||||||
|
s = classifier_dataset.get_status()
|
||||||
|
return DatasetResponse(**s)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dataset/info", summary="데이터셋 정보 조회")
|
||||||
|
async def dataset_info():
|
||||||
|
data = classifier_dataset.load()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=404, detail="데이터셋이 없습니다. /classifier/dataset/build 먼저 실행하세요.")
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
counts = Counter(item["label"] for item in data)
|
||||||
|
return {
|
||||||
|
"total": len(data),
|
||||||
|
"label_count": len(counts),
|
||||||
|
"labels": dict(sorted(counts.items(), key=lambda x: -x[1])),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Train ─────────────────────────────────────────
|
||||||
|
@router.post("/train", response_model=TrainResponse, summary="모델 학습 시작")
|
||||||
|
async def train(background_tasks: BackgroundTasks):
|
||||||
|
data = classifier_dataset.load()
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=400, detail="데이터셋이 없습니다. /classifier/dataset/build 먼저 실행하세요.")
|
||||||
|
|
||||||
|
s = classifier_trainer.get_status()
|
||||||
|
if s["status"] == "training":
|
||||||
|
raise HTTPException(status_code=409, detail="이미 학습 중입니다.")
|
||||||
|
|
||||||
|
background_tasks.add_task(classifier_trainer.run_train)
|
||||||
|
logger.info("classifier 학습 백그라운드 태스크 등록")
|
||||||
|
return TrainResponse(status="training", message="학습을 시작했습니다. /classifier/train/status 로 확인하세요.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/train/status", response_model=TrainResponse, summary="학습 상태 확인")
|
||||||
|
async def train_status():
|
||||||
|
return TrainResponse(**classifier_trainer.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Train SSE 스트리밍 ────────────────────────────
|
||||||
|
@router.get("/train/stream", summary="학습 진행상태 SSE 스트리밍")
|
||||||
|
async def train_stream():
|
||||||
|
async def generator():
|
||||||
|
prev = ""
|
||||||
|
while True:
|
||||||
|
s = classifier_trainer.get_status()
|
||||||
|
data = json.dumps(s, ensure_ascii=False)
|
||||||
|
|
||||||
|
if data != prev:
|
||||||
|
yield {"event": "status", "data": data}
|
||||||
|
prev = data
|
||||||
|
|
||||||
|
if s.get("status") not in ("training", "building"):
|
||||||
|
yield {"event": "done", "data": json.dumps({"message": s.get("message", "완료")})}
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
return EventSourceResponse(generator())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Train 모니터 HTML ─────────────────────────────
|
||||||
|
@router.get("/train/monitor", summary="학습 모니터 HTML", response_class=HTMLResponse)
|
||||||
|
def train_monitor():
|
||||||
|
html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Classifier 학습 모니터</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: sans-serif; margin: 0; background: #f5f5f5;
|
||||||
|
display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
.header { padding: 16px 24px; background: #6a1b9a; color: #fff; flex-shrink: 0; }
|
||||||
|
.header h2 { margin: 0; font-size: 18px; }
|
||||||
|
.container { padding: 20px 24px; flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
.cards { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
flex: 1; min-width: 130px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.card .label { font-size: 12px; color: #888; margin-bottom: 6px; }
|
||||||
|
.card .value { font-size: 26px; font-weight: bold; color: #6a1b9a; }
|
||||||
|
|
||||||
|
.progress-wrap { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.progress-label { display: flex; justify-content: space-between;
|
||||||
|
font-size: 13px; color: #555; margin-bottom: 8px; }
|
||||||
|
.progress-bar { height: 18px; background: #e0e0e0; border-radius: 9px; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #6a1b9a; border-radius: 9px;
|
||||||
|
transition: width 0.4s ease; width: 0%; }
|
||||||
|
|
||||||
|
.msg-box { background: #fff; border-radius: 8px; padding: 12px 20px;
|
||||||
|
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
font-size: 13px; color: #555; font-family: monospace; word-break: break-all; }
|
||||||
|
|
||||||
|
.log-wrap { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.log-wrap h3 { margin: 0 0 10px; font-size: 14px; color: #333; }
|
||||||
|
.log-list { list-style: none; margin: 0; padding: 0;
|
||||||
|
max-height: 400px; overflow-y: auto; }
|
||||||
|
.log-list li { padding: 5px 8px; font-size: 13px; border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex; gap: 10px; }
|
||||||
|
.log-list li .time { color: #aaa; flex-shrink: 0; }
|
||||||
|
.log-list li.done { color: #2e7d32; font-weight: bold; }
|
||||||
|
.log-list li.error { color: #c62828; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||||
|
font-size: 12px; font-weight: bold; }
|
||||||
|
.badge.running { background: #f3e5f5; color: #6a1b9a; }
|
||||||
|
.badge.done { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.badge.idle { background: #f5f5f5; color: #757575; }
|
||||||
|
|
||||||
|
.btn { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: bold; }
|
||||||
|
.btn-start { background: #6a1b9a; color: #fff; }
|
||||||
|
.btn-start:hover { background: #4a148c; }
|
||||||
|
.btn-start:disabled { background: #ce93d8; cursor: not-allowed; }
|
||||||
|
.actions { display: flex; gap: 10px; align-items: center; margin-bottom: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header"><h2>🧠 Classifier 학습 모니터</h2></div>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-start" id="btnStart" onclick="startTrain()">▶ 학습 시작</button>
|
||||||
|
<span id="badge" class="badge idle">대기 중</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span id="epochLabel">Epoch 0 / 0</span>
|
||||||
|
<span id="pctLabel">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">샘플 수</div>
|
||||||
|
<div class="value" id="cardSamples">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">클래스 수</div>
|
||||||
|
<div class="value" id="cardClasses">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">진행률</div>
|
||||||
|
<div class="value" id="cardPct">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg-box" id="msgBox">대기 중...</div>
|
||||||
|
|
||||||
|
<div class="log-wrap">
|
||||||
|
<h3>📋 진행 로그</h3>
|
||||||
|
<ul class="log-list" id="logList"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let es = null;
|
||||||
|
|
||||||
|
function now() { return new Date().toLocaleTimeString("ko-KR"); }
|
||||||
|
|
||||||
|
function addLog(msg, cls = "") {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = cls;
|
||||||
|
li.innerHTML = `<span class="time">${now()}</span><span>${msg}</span>`;
|
||||||
|
document.getElementById("logList").prepend(li);
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
function updateUI(d) {
|
||||||
|
const progress = d.status === "training" ? 50 : d.status === "done" ? 100 : 0;
|
||||||
|
|
||||||
|
document.getElementById("progressFill").style.width = progress + "%";
|
||||||
|
document.getElementById("epochLabel").textContent = d.status === "training" ? "학습 진행 중..." : d.status === "done" ? "완료" : "대기 중";
|
||||||
|
document.getElementById("pctLabel").textContent = progress + "%";
|
||||||
|
document.getElementById("cardSamples").textContent = d.samples ?? "-";
|
||||||
|
document.getElementById("cardClasses").textContent = d.classes ?? "-";
|
||||||
|
document.getElementById("cardPct").textContent = progress + "%";
|
||||||
|
document.getElementById("msgBox").textContent = d.message ?? "";
|
||||||
|
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
if (d.status === "training") {
|
||||||
|
badge.textContent = "학습 중"; badge.className = "badge running";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
function updateUI(d) {
|
||||||
|
let progress = 0;
|
||||||
|
let stepLabel = "대기 중";
|
||||||
|
|
||||||
|
if (d.status === "training") {
|
||||||
|
if (d.message.includes("로딩")) {
|
||||||
|
progress = 10; stepLabel = "데이터 로딩 중...";
|
||||||
|
} else if (d.message.includes("학습 중")) {
|
||||||
|
progress = 40; stepLabel = "TF-IDF 변환 중...";
|
||||||
|
} else if (d.message.includes("fit") || d.message.includes("학습")) {
|
||||||
|
progress = 70; stepLabel = "SVM 학습 중...";
|
||||||
|
} else {
|
||||||
|
progress = 50; stepLabel = "학습 중...";
|
||||||
|
}
|
||||||
|
} else if (d.status === "done") {
|
||||||
|
progress = 100; stepLabel = "완료";
|
||||||
|
} else if (d.status === "error") {
|
||||||
|
progress = 0; stepLabel = "오류 발생";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("progressFill").style.width = progress + "%";
|
||||||
|
document.getElementById("epochLabel").textContent = stepLabel;
|
||||||
|
document.getElementById("pctLabel").textContent = progress + "%";
|
||||||
|
document.getElementById("cardSamples").textContent = d.samples ?? "-";
|
||||||
|
document.getElementById("cardClasses").textContent = d.classes ?? "-";
|
||||||
|
document.getElementById("cardPct").textContent = progress + "%";
|
||||||
|
document.getElementById("msgBox").textContent = d.message ?? "";
|
||||||
|
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
if (d.status === "training") {
|
||||||
|
badge.textContent = "학습 중"; badge.className = "badge running";
|
||||||
|
} else if (d.status === "error") {
|
||||||
|
badge.textContent = "오류"; badge.className = "badge idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTrain() {
|
||||||
|
fetch("/classifier/train", { method: "POST" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
addLog("학습 시작: " + data.message);
|
||||||
|
document.getElementById("btnStart").disabled = true;
|
||||||
|
subscribeSSE();
|
||||||
|
})
|
||||||
|
.catch(e => addLog("오류: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeSSE() {
|
||||||
|
if (es) es.close();
|
||||||
|
es = new EventSource("/classifier/train/stream");
|
||||||
|
|
||||||
|
es.addEventListener("status", e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
console.log(d);
|
||||||
|
updateUI(d);
|
||||||
|
if (d.message) addLog(d.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("done", e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
addLog("✅ " + d.message, "done");
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
badge.textContent = "완료"; badge.className = "badge done";
|
||||||
|
document.getElementById("btnStart").disabled = false;
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
addLog("SSE 연결 끊김", "error");
|
||||||
|
es.close();
|
||||||
|
document.getElementById("btnStart").disabled = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로드 시 이미 학습 중이면 자동 연결
|
||||||
|
fetch("/classifier/train/status")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
updateUI(d);
|
||||||
|
if (d.status === "training") {
|
||||||
|
document.getElementById("btnStart").disabled = true;
|
||||||
|
addLog("학습 진행 중 - 자동 연결");
|
||||||
|
subscribeSSE();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Predict ───────────────────────────────────────
|
||||||
|
@router.post("/predict", response_model=PredictResponse, summary="문서 분류 예측 (PDF/이미지 파일)")
|
||||||
|
async def predict(
|
||||||
|
file: UploadFile = File(..., description="PDF 또는 이미지 파일"),
|
||||||
|
threshold: float = Form(0.4, description="신뢰도 임계값"),
|
||||||
|
ocr_engine: str = Form(config.OCR_ENGINE, description="ocr 엔진 선택: paddle | google"),
|
||||||
|
):
|
||||||
|
m = classifier_model_store.get_model()
|
||||||
|
if m is None:
|
||||||
|
raise HTTPException(status_code=503, detail="모델이 없습니다. /classifier/train 먼저 실행하세요.")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
tmp_path = config.TMP_DIR / file.filename
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
f.flush()
|
||||||
|
os.fsync(f.fileno())
|
||||||
|
|
||||||
|
# try:
|
||||||
|
t_total = time.time()
|
||||||
|
|
||||||
|
# 1. 이미지 변환
|
||||||
|
t0 = time.time()
|
||||||
|
if is_pdf(file.filename):
|
||||||
|
img_paths = pdf_to_images(str(tmp_path), dpi=config.OCR_DPI, max_pages=1)
|
||||||
|
else:
|
||||||
|
# 이미지 리사이즈
|
||||||
|
if hasattr(config, 'PDF_MAX_WIDTH'):
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(tmp_path)
|
||||||
|
if img.width > config.PDF_MAX_WIDTH:
|
||||||
|
scale = config.PDF_MAX_WIDTH / img.width
|
||||||
|
new_size = (int(img.width * scale), int(img.height * scale))
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
ext = tmp_path.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
img.save(str(tmp_path), quality=config.PDF_JPEG_QUALITY)
|
||||||
|
else:
|
||||||
|
img.save(str(tmp_path))
|
||||||
|
img_paths = [tmp_path]
|
||||||
|
|
||||||
|
t_convert = time.time() - t0
|
||||||
|
logger.info(f"[{file.filename}] 이미지변환: {t_convert:.3f}s | {len(img_paths)}페이지")
|
||||||
|
|
||||||
|
if not img_paths:
|
||||||
|
raise HTTPException(status_code=400, detail="이미지 변환 실패")
|
||||||
|
|
||||||
|
# 2. OCR
|
||||||
|
t0 = time.time()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
# results = await loop.run_in_executor(None, ocr_batch, img_paths)
|
||||||
|
_, batch_fn, client = get_ocr_functions(ocr_engine)
|
||||||
|
results = await loop.run_in_executor(None, batch_fn, img_paths, client)
|
||||||
|
|
||||||
|
t_ocr = time.time() - t0
|
||||||
|
logger.info(f"[{file.filename}] OCR: {t_ocr:.3f}s | {len(results)}페이지")
|
||||||
|
|
||||||
|
ocr_errors = [r for r in results if r.get("error")]
|
||||||
|
if ocr_errors:
|
||||||
|
raise HTTPException(status_code=422, detail=f"OCR 실패: {ocr_errors[0]['error']}")
|
||||||
|
|
||||||
|
# 3. 추론
|
||||||
|
t0 = time.time()
|
||||||
|
best_label, best_conf, best_probs = "OTHER", 0.0, {}
|
||||||
|
for r in results:
|
||||||
|
words = r.get("words", [])
|
||||||
|
if len(words) < config.OCR_MIN_WORDS:
|
||||||
|
continue
|
||||||
|
lbl, conf, probs = classifier_model_store.predict(m, " ".join(words), threshold)
|
||||||
|
if conf > best_conf:
|
||||||
|
best_label, best_conf, best_probs = lbl, conf, probs
|
||||||
|
t_infer = time.time() - t0
|
||||||
|
logger.info(f"[{file.filename}] 추론: {t_infer:.3f}s | label={best_label}, confidence={best_conf:.4f}")
|
||||||
|
|
||||||
|
# 4. 최종
|
||||||
|
t_total = time.time() - t_total
|
||||||
|
logger.info(f"[{file.filename}] 최종: {t_total:.3f}s (변환={t_convert:.3f}s / OCR={t_ocr:.3f}s / 추론={t_infer:.3f}s)")
|
||||||
|
|
||||||
|
if best_conf == 0.0:
|
||||||
|
raise HTTPException(status_code=422, detail="OCR 결과가 없거나 단어 수 미달입니다.")
|
||||||
|
|
||||||
|
return PredictResponse(label=best_label, file_name=file.filename, confidence=best_conf, all_probs=best_probs)
|
||||||
|
# finally:
|
||||||
|
# # ── 임시 파일 정리 ──────────────────────────
|
||||||
|
# tmp_path.unlink(missing_ok=True)
|
||||||
|
# if is_pdf(file.filename):
|
||||||
|
# for p in img_paths:
|
||||||
|
# Path(p).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/predictMulti", response_model=PredictListResponse, summary="문서 분류 예측 (PDF/이미지 파일)")
|
||||||
|
async def predictMulti(
|
||||||
|
files: List[UploadFile] = File(..., description="PDF 또는 이미지 파일"),
|
||||||
|
threshold: float = Form(0.4, description="신뢰도 임계값"),
|
||||||
|
ocr_engine: str = Form(config.OCR_ENGINE, description="ocr 엔진 선택: paddle | google"),
|
||||||
|
):
|
||||||
|
m = classifier_model_store.get_model()
|
||||||
|
if m is None:
|
||||||
|
raise HTTPException(status_code=503, detail="모델이 없습니다. /classifier/train 먼저 실행하세요.")
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for file in files:
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
# tmp_path = config.TMP_DIR / file.filename
|
||||||
|
today = datetime.now().strftime("%Y%m%d")
|
||||||
|
tmp_path = config.TMP_DIR / today / file.filename
|
||||||
|
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# 1. 이미지 변환
|
||||||
|
if is_pdf(file.filename):
|
||||||
|
img_paths = pdf_to_images(str(tmp_path), dpi=config.OCR_DPI, max_pages=1)
|
||||||
|
else:
|
||||||
|
# 이미지 리사이즈
|
||||||
|
if hasattr(config, 'PDF_MAX_WIDTH'):
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(tmp_path)
|
||||||
|
if img.width > config.PDF_MAX_WIDTH:
|
||||||
|
scale = config.PDF_MAX_WIDTH / img.width
|
||||||
|
new_size = (int(img.width * scale), int(img.height * scale))
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
ext = tmp_path.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
img.save(str(tmp_path), quality=config.PDF_JPEG_QUALITY)
|
||||||
|
else:
|
||||||
|
img.save(str(tmp_path))
|
||||||
|
img_paths = [tmp_path]
|
||||||
|
|
||||||
|
if not img_paths:
|
||||||
|
raise ValueError("이미지 변환 실패")
|
||||||
|
|
||||||
|
# 2. OCR
|
||||||
|
def on_done(idx: int, result: dict):
|
||||||
|
if result.get("words"):
|
||||||
|
image_path = result.get("image_path")
|
||||||
|
json_path = Path(image_path).with_name(Path(image_path).stem + "_google.json")
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f2:
|
||||||
|
json.dump(result, f2, ensure_ascii=False, indent=2)
|
||||||
|
logger.debug(f"OCR 저장: {json_path}")
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
# results = await loop.run_in_executor(None, ocr_batch, img_paths)
|
||||||
|
|
||||||
|
_, batch_fn, client = get_ocr_functions(ocr_engine)
|
||||||
|
results = await loop.run_in_executor(None, batch_fn, img_paths, client)
|
||||||
|
# print(results)
|
||||||
|
|
||||||
|
# 3. 추론
|
||||||
|
best_label, best_conf, best_probs = "OTHER", 0.0, {}
|
||||||
|
for r in results:
|
||||||
|
words = r.get("words", [])
|
||||||
|
if len(words) < config.OCR_MIN_WORDS:
|
||||||
|
continue
|
||||||
|
lbl, conf, probs = classifier_model_store.predict(m, " ".join(words), threshold)
|
||||||
|
if conf > best_conf:
|
||||||
|
best_label, best_conf, best_probs = lbl, conf, probs
|
||||||
|
|
||||||
|
if best_conf == 0.0:
|
||||||
|
raise ValueError("OCR 결과가 없거나 단어 수 미달입니다.")
|
||||||
|
|
||||||
|
responses.append(PredictResponse(label=best_label, file_name=file.filename, confidence=best_conf, all_probs=best_probs))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[{file.filename}] 처리 실패: {e}")
|
||||||
|
responses.append(PredictResponse(label="ERROR", confidence=0.0, all_probs={}))
|
||||||
|
|
||||||
|
return PredictListResponse(results=responses, success=True)
|
||||||
|
|
||||||
430
routers/dataset.py
Normal file
430
routers/dataset.py
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from loguru import logger
|
||||||
|
import json
|
||||||
|
|
||||||
|
from utils.ocr import get_ocr_functions
|
||||||
|
import config
|
||||||
|
from train.schemas import DatasetStatus
|
||||||
|
from utils.cache import (
|
||||||
|
save_cache_by_file,
|
||||||
|
get_cached_paths, cache_status, load_label2id, save_label2id,
|
||||||
|
)
|
||||||
|
from utils.ocr import ocr_batch
|
||||||
|
from utils.pdf import is_pdf, pdf_to_images
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCR 진행 상태 전역 객체 ──────────────────────────
|
||||||
|
class OcrBuildStatus:
|
||||||
|
def __init__(self):
|
||||||
|
self.running: bool = False
|
||||||
|
self.total: int = 0
|
||||||
|
self.done: int = 0
|
||||||
|
self.failed: int = 0
|
||||||
|
self.message: str = "대기 중"
|
||||||
|
self.current: str = "" # 현재 처리 파일명
|
||||||
|
|
||||||
|
def reset(self, total: int):
|
||||||
|
self.running = True
|
||||||
|
self.total = total
|
||||||
|
self.done = 0
|
||||||
|
self.failed = 0
|
||||||
|
self.message = f"OCR 시작 ({total}건)"
|
||||||
|
self.current = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress(self) -> float:
|
||||||
|
return round(self.done / self.total * 100, 1) if self.total > 0 else 0
|
||||||
|
|
||||||
|
ocr_status = OcrBuildStatus()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 업로드 ────────────────────────────────────────────
|
||||||
|
@router.post("/upload", summary="학습 이미지/PDF 업로드")
|
||||||
|
async def upload_images(
|
||||||
|
label: str = Form(..., description="양식 종류 (예: TSMC_TypeA)"),
|
||||||
|
files: list[UploadFile] = File(..., description="이미지 또는 PDF 파일"),
|
||||||
|
):
|
||||||
|
save_dir = config.DATA_DIR / label
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
saved = []
|
||||||
|
for file in files:
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
if is_pdf(file.filename):
|
||||||
|
tmp_pdf = save_dir / file.filename
|
||||||
|
with open(tmp_pdf, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
img_paths = pdf_to_images(str(tmp_pdf), dpi=config.OCR_DPI)
|
||||||
|
saved.extend([str(p) for p in img_paths])
|
||||||
|
tmp_pdf.unlink()
|
||||||
|
logger.info(f"PDF 업로드: {file.filename} → {len(img_paths)}페이지 변환")
|
||||||
|
else:
|
||||||
|
# dest = save_dir / file.filename
|
||||||
|
# with open(dest, "wb") as f:
|
||||||
|
# f.write(content)
|
||||||
|
# saved.append(str(dest))
|
||||||
|
# logger.info(f"이미지 업로드: {dest}")
|
||||||
|
|
||||||
|
dest = save_dir / file.filename
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
# 이미지 리사이즈 적용
|
||||||
|
if hasattr(config, 'PDF_MAX_WIDTH'):
|
||||||
|
from PIL import Image
|
||||||
|
img = Image.open(dest)
|
||||||
|
if img.width > config.PDF_MAX_WIDTH:
|
||||||
|
scale = config.PDF_MAX_WIDTH / img.width
|
||||||
|
new_size = (int(img.width * scale), int(img.height * scale))
|
||||||
|
img = img.resize(new_size, Image.LANCZOS)
|
||||||
|
ext = dest.suffix.lower()
|
||||||
|
if ext in (".jpg", ".jpeg"):
|
||||||
|
img.save(str(dest), quality=config.PDF_JPEG_QUALITY)
|
||||||
|
else:
|
||||||
|
img.save(str(dest))
|
||||||
|
logger.info(f"이미지 리사이즈: {dest.name} → {new_size}")
|
||||||
|
|
||||||
|
saved.append(str(dest))
|
||||||
|
logger.info(f"이미지 업로드: {dest}")
|
||||||
|
|
||||||
|
return {"label": label, "uploaded": len(files), "saved": len(saved), "files": saved}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 데이터셋 빌드 (백그라운드 + SSE) ──────────────────
|
||||||
|
async def _run_build():
|
||||||
|
"""OCR 빌드를 백그라운드에서 실행하며 ocr_status 갱신"""
|
||||||
|
if not config.DATA_DIR.exists():
|
||||||
|
ocr_status.running = False
|
||||||
|
ocr_status.message = "오류: data/ 폴더가 없습니다."
|
||||||
|
return
|
||||||
|
|
||||||
|
classes = sorted([d.name for d in config.DATA_DIR.iterdir() if d.is_dir()])
|
||||||
|
label2id = {c: i for i, c in enumerate(classes)}
|
||||||
|
|
||||||
|
cached_paths = get_cached_paths()
|
||||||
|
|
||||||
|
new_paths, new_labels = [], []
|
||||||
|
for cls in classes:
|
||||||
|
for ext in ["*.png", "*.jpg", "*.jpeg", "*.tiff"]:
|
||||||
|
for img_path in (config.DATA_DIR / cls).rglob(ext):
|
||||||
|
if str(img_path) not in cached_paths:
|
||||||
|
new_paths.append(img_path)
|
||||||
|
new_labels.append(label2id[cls])
|
||||||
|
|
||||||
|
# for cls in classes:
|
||||||
|
# for ext in ["*.png", "*.jpg", "*.jpeg", "*.tiff"]:
|
||||||
|
# for img_path in (config.DATA_DIR / cls).rglob(ext):
|
||||||
|
# s = str(img_path)
|
||||||
|
# if s in cached_paths:
|
||||||
|
# print(f"[캐시HIT] {s}")
|
||||||
|
# else:
|
||||||
|
# print(f"[캐시MISS] {s}")
|
||||||
|
# print(f" → cached 샘플: {next(iter(cached_paths))}")
|
||||||
|
|
||||||
|
if not new_paths:
|
||||||
|
status = cache_status()
|
||||||
|
ocr_status.running = False
|
||||||
|
ocr_status.message = "신규 이미지 없음. 캐시가 최신 상태입니다."
|
||||||
|
return
|
||||||
|
|
||||||
|
ocr_status.reset(len(new_paths))
|
||||||
|
|
||||||
|
import time
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
total_new = 0
|
||||||
|
|
||||||
|
# 완료 콜백: ocr_batch가 1건 끝날 때마다 호출
|
||||||
|
# def on_done(idx: int, result: dict):
|
||||||
|
# ocr_status.current = Path(new_paths[idx]).name
|
||||||
|
# ocr_status.message = f"[{ocr_status.done+1}/{ocr_status.total}] {ocr_status.current}"
|
||||||
|
# if result.get("error"):
|
||||||
|
# ocr_status.failed += 1
|
||||||
|
# ocr_status.done += 1
|
||||||
|
|
||||||
|
def on_done(idx: int, result: dict):
|
||||||
|
nonlocal total_new
|
||||||
|
# ocr_status.current = Path(new_paths[idx]).name
|
||||||
|
ocr_status.current = f"{Path(new_paths[idx]).parent.name}/{Path(new_paths[idx]).name}"
|
||||||
|
ocr_status.message = f"[{ocr_status.done + 1}/{ocr_status.total}] {ocr_status.current}"
|
||||||
|
if result.get("error"):
|
||||||
|
ocr_status.failed += 1
|
||||||
|
else:
|
||||||
|
# OCR 완료 즉시 저장
|
||||||
|
if result.get("words"):
|
||||||
|
result["label"] = new_labels[idx]
|
||||||
|
save_cache_by_file(result)
|
||||||
|
total_new += 1
|
||||||
|
ocr_status.done += 1
|
||||||
|
|
||||||
|
_, batch_fn, client = get_ocr_functions(config.OCR_ENGINE)
|
||||||
|
|
||||||
|
results = await loop.run_in_executor(
|
||||||
|
None, lambda: batch_fn(new_paths, client=client, max_workers=config.OCR_MAX_WORKERS, on_done=on_done)
|
||||||
|
)
|
||||||
|
|
||||||
|
# results = await loop.run_in_executor(
|
||||||
|
# None, ocr_batch, new_paths, config.OCR_MAX_WORKERS, on_done
|
||||||
|
# )
|
||||||
|
|
||||||
|
# for ocr, label in zip(results, new_labels):
|
||||||
|
# if not ocr.get("words"):
|
||||||
|
# continue
|
||||||
|
# ocr["label"] = label
|
||||||
|
# save_cache_by_file(ocr)
|
||||||
|
# total_new += 1
|
||||||
|
|
||||||
|
elapsed = time.time() - start
|
||||||
|
mins, secs = divmod(int(elapsed), 60)
|
||||||
|
save_label2id(label2id)
|
||||||
|
|
||||||
|
st = cache_status()
|
||||||
|
ocr_status.running = False
|
||||||
|
ocr_status.message = (
|
||||||
|
f"완료: 신규 {total_new}건 추가 | "
|
||||||
|
f"실패 {ocr_status.failed}건 | "
|
||||||
|
f"소요 {mins}분 {secs}초"
|
||||||
|
)
|
||||||
|
logger.info(ocr_status.message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/build", summary="데이터셋 생성 (OCR 실행)")
|
||||||
|
async def build_dataset(background_tasks=None):
|
||||||
|
from fastapi import BackgroundTasks
|
||||||
|
if ocr_status.running:
|
||||||
|
raise HTTPException(status_code=409, detail="이미 OCR이 실행 중입니다.")
|
||||||
|
|
||||||
|
# 백그라운드 실행 (SSE로 상태 조회 가능)
|
||||||
|
asyncio.create_task(_run_build())
|
||||||
|
return {"message": "OCR 빌드 시작. /dataset/stream 에서 진행상태 확인"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSE 스트리밍 ──────────────────────────────────────
|
||||||
|
@router.get("/stream", summary="OCR 진행상태 SSE 스트리밍")
|
||||||
|
async def stream_build():
|
||||||
|
async def generator():
|
||||||
|
prev = ""
|
||||||
|
while True:
|
||||||
|
data = json.dumps({
|
||||||
|
"running": ocr_status.running,
|
||||||
|
"total": ocr_status.total,
|
||||||
|
"done": ocr_status.done,
|
||||||
|
"failed": ocr_status.failed,
|
||||||
|
"progress": ocr_status.progress,
|
||||||
|
"message": ocr_status.message,
|
||||||
|
"current": ocr_status.current,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
if data != prev:
|
||||||
|
yield {"event": "status", "data": data}
|
||||||
|
prev = data
|
||||||
|
|
||||||
|
if not ocr_status.running and ocr_status.done > 0:
|
||||||
|
yield {"event": "done", "data": json.dumps({"message": ocr_status.message})}
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
return EventSourceResponse(generator())
|
||||||
|
|
||||||
|
|
||||||
|
# ── 상태 조회 ─────────────────────────────────────────
|
||||||
|
@router.get("/status", summary="데이터셋 현황 조회", response_model=DatasetStatus)
|
||||||
|
def dataset_status():
|
||||||
|
label2id = load_label2id()
|
||||||
|
by_label = cache_status()
|
||||||
|
total = sum(by_label.values())
|
||||||
|
return DatasetStatus(total_samples=total, by_label=by_label, label2id=label2id)
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCR 모니터 HTML ────────────────────────────────────
|
||||||
|
@router.get("/monitor", summary="OCR 빌드 모니터", response_class=HTMLResponse)
|
||||||
|
def dataset_monitor():
|
||||||
|
html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OCR 빌드 모니터</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: sans-serif; margin: 0; background: #f5f5f5;
|
||||||
|
display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
.header { padding: 16px 24px; background: #00796b; color: #fff; flex-shrink: 0; }
|
||||||
|
.header h2 { margin: 0; font-size: 18px; }
|
||||||
|
.container { padding: 20px 24px; flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
.cards { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
flex: 1; min-width: 120px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.card .label { font-size: 12px; color: #888; margin-bottom: 6px; }
|
||||||
|
.card .value { font-size: 26px; font-weight: bold; color: #00796b; }
|
||||||
|
.card .value.fail { color: #c62828; }
|
||||||
|
|
||||||
|
.progress-wrap { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.progress-label { display: flex; justify-content: space-between;
|
||||||
|
font-size: 13px; color: #555; margin-bottom: 8px; }
|
||||||
|
.progress-bar { height: 18px; background: #e0e0e0; border-radius: 9px; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #00796b; border-radius: 9px;
|
||||||
|
transition: width 0.4s ease; width: 0%; }
|
||||||
|
|
||||||
|
.current-file { background: #fff; border-radius: 8px; padding: 12px 20px;
|
||||||
|
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
font-size: 13px; color: #555; font-family: monospace; word-break: break-all; }
|
||||||
|
|
||||||
|
.log-wrap { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.log-wrap h3 { margin: 0 0 10px; font-size: 14px; color: #333; }
|
||||||
|
.log-list { list-style: none; margin: 0; padding: 0;
|
||||||
|
max-height: 400px; overflow-y: auto; }
|
||||||
|
.log-list li { padding: 5px 8px; font-size: 13px; border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex; gap: 10px; }
|
||||||
|
.log-list li .time { color: #aaa; flex-shrink: 0; }
|
||||||
|
.log-list li.done { color: #2e7d32; font-weight: bold; }
|
||||||
|
.log-list li.error { color: #c62828; }
|
||||||
|
|
||||||
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||||
|
font-size: 12px; font-weight: bold; }
|
||||||
|
.badge.running { background: #e0f2f1; color: #00695c; }
|
||||||
|
.badge.done { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.badge.idle { background: #f5f5f5; color: #757575; }
|
||||||
|
|
||||||
|
.btn { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: bold; }
|
||||||
|
.btn-start { background: #00796b; color: #fff; }
|
||||||
|
.btn-start:hover { background: #00695c; }
|
||||||
|
.btn-start:disabled { background: #80cbc4; cursor: not-allowed; }
|
||||||
|
.actions { display: flex; gap: 10px; align-items: center; margin-bottom: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header"><h2>🔍 OCR 빌드 모니터</h2></div>
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-start" id="btnStart" onclick="startBuild()">▶ 빌드 시작</button>
|
||||||
|
<span id="badge" class="badge idle">대기 중</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span id="progressLabel">0 / 0</span>
|
||||||
|
<span id="pctLabel">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">전체</div>
|
||||||
|
<div class="value" id="cardTotal">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">완료</div>
|
||||||
|
<div class="value" id="cardDone">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">실패</div>
|
||||||
|
<div class="value fail" id="cardFail">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">진행률</div>
|
||||||
|
<div class="value" id="cardPct">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="current-file" id="currentFile">대기 중...</div>
|
||||||
|
|
||||||
|
<div class="log-wrap">
|
||||||
|
<h3>📋 진행 로그</h3>
|
||||||
|
<ul class="log-list" id="logList"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let es = null;
|
||||||
|
|
||||||
|
function now() { return new Date().toLocaleTimeString("ko-KR"); }
|
||||||
|
|
||||||
|
function addLog(msg, cls = "") {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = cls;
|
||||||
|
li.innerHTML = `<span class="time">${now()}</span><span>${msg}</span>`;
|
||||||
|
document.getElementById("logList").prepend(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(d) {
|
||||||
|
document.getElementById("progressFill").style.width = d.progress + "%";
|
||||||
|
document.getElementById("progressLabel").textContent = `${d.done} / ${d.total}`;
|
||||||
|
document.getElementById("pctLabel").textContent = d.progress + "%";
|
||||||
|
document.getElementById("cardTotal").textContent = d.total || "-";
|
||||||
|
document.getElementById("cardDone").textContent = d.done || "-";
|
||||||
|
document.getElementById("cardFail").textContent = d.failed || "0";
|
||||||
|
document.getElementById("cardPct").textContent = d.progress + "%";
|
||||||
|
document.getElementById("currentFile").textContent = d.message || "";
|
||||||
|
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
if (d.running) {
|
||||||
|
badge.textContent = "실행 중"; badge.className = "badge running";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBuild() {
|
||||||
|
fetch("/dataset/build", { method: "POST" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
addLog("빌드 시작: " + data.message);
|
||||||
|
document.getElementById("btnStart").disabled = true;
|
||||||
|
subscribeSSE();
|
||||||
|
})
|
||||||
|
.catch(e => addLog("오류: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeSSE() {
|
||||||
|
if (es) es.close();
|
||||||
|
es = new EventSource("/dataset/stream");
|
||||||
|
|
||||||
|
es.addEventListener("status", e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
updateUI(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("done", e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
addLog("✅ " + d.message, "done");
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
badge.textContent = "완료"; badge.className = "badge done";
|
||||||
|
document.getElementById("btnStart").disabled = false;
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
addLog("SSE 연결 끊김", "error");
|
||||||
|
es.close();
|
||||||
|
document.getElementById("btnStart").disabled = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로드 시 이미 실행 중이면 자동 연결
|
||||||
|
fetch("/dataset/status")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
// ocr_status는 별도이므로, 단순히 running 여부만 폴링으로 확인
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
17
routers/evaluate.py
Normal file
17
routers/evaluate.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
from train.trainer import run_evaluate, _get_latest_model_path
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", summary="모델 평가")
|
||||||
|
def evaluate(test_size: float = 0.2):
|
||||||
|
if not _get_latest_model_path():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="저장된 모델 없음. 학습 먼저 실행하세요."
|
||||||
|
)
|
||||||
|
logger.info(f"평가 요청 | test_size={test_size}")
|
||||||
|
return run_evaluate(test_size)
|
||||||
272
routers/predict.py
Normal file
272
routers/predict.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from fastapi import APIRouter, File, HTTPException, UploadFile, Form
|
||||||
|
from PIL import Image
|
||||||
|
from transformers import LayoutLMv3ForSequenceClassification, LayoutLMv3Processor
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
from train.schemas import PredictResult
|
||||||
|
from utils.cache import load_label2id
|
||||||
|
from utils.ocr import get_vision_client, ocr_single, ocr_batch
|
||||||
|
from utils.pdf import is_pdf, pdf_to_images, pdf_to_images_batch
|
||||||
|
|
||||||
|
from train.trainer import _get_latest_model_path
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
_model_cache: dict = {
|
||||||
|
"model": None,
|
||||||
|
"processor": None,
|
||||||
|
"id2label": None,
|
||||||
|
"path": None, # 로드된 모델 경로 (재학습 후 갱신 감지용)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _infer(img_path: str, filename: str,
|
||||||
|
model, processor, id2label: dict,
|
||||||
|
threshold: float = 0.6) -> dict:
|
||||||
|
"""이미지 1장 → 추론 결과"""
|
||||||
|
# client = get_vision_client()
|
||||||
|
# ocr = ocr_single(img_path, client)
|
||||||
|
|
||||||
|
# 이미지와 같은 폴더에 json 캐시 저장/로드
|
||||||
|
cache_file = Path(img_path).with_suffix(".json")
|
||||||
|
if cache_file.exists():
|
||||||
|
with open(cache_file, encoding="utf-8") as f:
|
||||||
|
ocr = json.load(f)
|
||||||
|
logger.debug(f"OCR 캐시 사용: {cache_file}")
|
||||||
|
else:
|
||||||
|
# 캐시 없으면 OCR 실행 후 같은 폴더에 저장
|
||||||
|
client = get_vision_client()
|
||||||
|
ocr = ocr_single(img_path, client)
|
||||||
|
if ocr["words"]:
|
||||||
|
with open(cache_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(ocr, f, ensure_ascii=False, indent=2)
|
||||||
|
logger.debug(f"OCR 신규 실행 → 저장: {cache_file}")
|
||||||
|
|
||||||
|
if not ocr["words"]:
|
||||||
|
return {"filename": filename, "error": "OCR 결과 없음. 이미지 품질 확인 필요."}
|
||||||
|
|
||||||
|
image = Image.open(img_path).convert("RGB")
|
||||||
|
encoding = processor(
|
||||||
|
image, text=ocr["words"], boxes=ocr["boxes"],
|
||||||
|
return_tensors="pt", truncation=True,
|
||||||
|
padding="max_length", max_length=config.MAX_LEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model(
|
||||||
|
input_ids = encoding["input_ids"].to(config.DEVICE),
|
||||||
|
attention_mask = encoding["attention_mask"].to(config.DEVICE),
|
||||||
|
bbox = encoding["bbox"].to(config.DEVICE),
|
||||||
|
pixel_values = encoding["pixel_values"].to(config.DEVICE),
|
||||||
|
output_attentions= True,
|
||||||
|
)
|
||||||
|
|
||||||
|
probs = torch.softmax(outputs.logits, dim=-1).squeeze().cpu()
|
||||||
|
pred_id = int(probs.argmax())
|
||||||
|
|
||||||
|
confidence = round(float(probs[pred_id]), 4)
|
||||||
|
label = id2label[pred_id] if confidence >= threshold else "OTHER"
|
||||||
|
|
||||||
|
attn = outputs.attentions[-1][0].mean(0)[0]
|
||||||
|
n_words = min(len(ocr["words"]), attn.shape[0] - 1)
|
||||||
|
top5_idx = attn[1:n_words+1].topk(min(5, n_words)).indices.tolist()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"filename": filename,
|
||||||
|
"label": label,
|
||||||
|
"confidence": confidence,
|
||||||
|
"all_probs": {id2label[i]: round(float(p), 4) for i, p in enumerate(probs)},
|
||||||
|
"key_tokens": [ocr["words"][i] for i in top5_idx],
|
||||||
|
"ocr_word_count": len(ocr["words"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# return {
|
||||||
|
# "filename": filename,
|
||||||
|
# "label": id2label[pred_id],
|
||||||
|
# "confidence": round(float(probs[pred_id]), 4),
|
||||||
|
# "all_probs": {id2label[i]: round(float(p), 4) for i, p in enumerate(probs)},
|
||||||
|
# "key_tokens": [ocr["words"][i] for i in top5_idx],
|
||||||
|
# "ocr_word_count": len(ocr["words"]),
|
||||||
|
# }
|
||||||
|
|
||||||
|
|
||||||
|
def _load_model_and_processor():
|
||||||
|
model_path = _get_latest_model_path()
|
||||||
|
if not model_path:
|
||||||
|
raise HTTPException(status_code=400, detail="저장된 모델 없음. 학습 먼저 실행하세요.")
|
||||||
|
|
||||||
|
# 이미 로드된 모델이고 경로가 같으면 캐시 반환
|
||||||
|
if (_model_cache["model"] is not None and
|
||||||
|
_model_cache["path"] == str(model_path)):
|
||||||
|
logger.debug("모델 캐시 사용")
|
||||||
|
return _model_cache["model"], _model_cache["processor"], _model_cache["id2label"]
|
||||||
|
|
||||||
|
# 최초 로드 or 재학습 후 경로 변경 시
|
||||||
|
logger.info(f"모델 로드: {model_path}")
|
||||||
|
label2id = load_label2id()
|
||||||
|
id2label = {v: k for k, v in label2id.items()}
|
||||||
|
processor = LayoutLMv3Processor.from_pretrained(str(model_path), apply_ocr=False)
|
||||||
|
model = LayoutLMv3ForSequenceClassification.from_pretrained(
|
||||||
|
str(model_path)
|
||||||
|
).to(config.DEVICE)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
# 캐시 갱신
|
||||||
|
_model_cache["model"] = model
|
||||||
|
_model_cache["processor"] = processor
|
||||||
|
_model_cache["id2label"] = id2label
|
||||||
|
_model_cache["path"] = str(model_path)
|
||||||
|
|
||||||
|
return model, processor, id2label
|
||||||
|
|
||||||
|
|
||||||
|
def _check_model():
|
||||||
|
if not _get_latest_model_path():
|
||||||
|
raise HTTPException(status_code=400, detail="저장된 모델 없음. 학습 먼저 실행하세요.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", summary="단일 이미지/PDF 추론", response_model=PredictResult)
|
||||||
|
async def predict(file: UploadFile = File(...), group_id: str = Form(...)):
|
||||||
|
_check_model()
|
||||||
|
|
||||||
|
# tmp_path = Path(f"tmp_{file.filename}")
|
||||||
|
# img_paths = []
|
||||||
|
|
||||||
|
date_str = datetime.now().strftime("%Y%m%d")
|
||||||
|
tmp_dir = config.TMP_DIR / group_id / date_str # files/tmp/{group_id}/년월일
|
||||||
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tmp_path = tmp_dir / file.filename # ← 수정
|
||||||
|
img_paths = []
|
||||||
|
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
f.write(await file.read())
|
||||||
|
|
||||||
|
# try:
|
||||||
|
model, processor, id2label = _load_model_and_processor()
|
||||||
|
|
||||||
|
if is_pdf(file.filename):
|
||||||
|
img_paths = pdf_to_images(str(tmp_path), dpi=config.OCR_DPI)
|
||||||
|
|
||||||
|
if not img_paths:
|
||||||
|
raise HTTPException(status_code=422, detail="PDF 변환 실패.")
|
||||||
|
|
||||||
|
page_results = [
|
||||||
|
_infer(str(p), file.filename, model, processor, id2label)
|
||||||
|
for p in img_paths
|
||||||
|
]
|
||||||
|
|
||||||
|
valid = [r for r in page_results
|
||||||
|
if "label" in r and r.get("ocr_word_count", 0) >= config.OCR_MIN_WORDS]
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
best = {"error": "전체 페이지 OCR 실패"}
|
||||||
|
elif len(valid) == 1:
|
||||||
|
best = valid[0]
|
||||||
|
else:
|
||||||
|
# 2페이지 confidence 합산 → 라벨별 점수가 높은 쪽 선택
|
||||||
|
score = defaultdict(float)
|
||||||
|
for r in valid:
|
||||||
|
for label, prob in r["all_probs"].items():
|
||||||
|
score[label] += prob
|
||||||
|
best_label = max(score, key=score.get)
|
||||||
|
best = max(valid, key=lambda r: r["all_probs"].get(best_label, 0))
|
||||||
|
best = {**best, "label": best_label,
|
||||||
|
"confidence": round(score[best_label] / len(valid), 4)} # 평균 confidence
|
||||||
|
|
||||||
|
logger.info(f"PDF 추론 완료: {file.filename} | {best.get('label')}")
|
||||||
|
return PredictResult(**{**best,
|
||||||
|
"total_pages": len(img_paths),
|
||||||
|
"per_page": page_results})
|
||||||
|
else:
|
||||||
|
result = _infer(str(tmp_path), file.filename, model, processor, id2label)
|
||||||
|
logger.info(f"이미지 추론 완료: {file.filename} | {result.get('label')}")
|
||||||
|
return PredictResult(**result)
|
||||||
|
|
||||||
|
# finally:
|
||||||
|
# tmp_path.unlink(missing_ok=True)
|
||||||
|
# for p in img_paths:
|
||||||
|
# Path(p).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch", summary="다중 이미지/PDF 배치 추론")
|
||||||
|
async def predict_batch(files: list[UploadFile] = File(...), group_id: str = Form(...)):
|
||||||
|
_check_model()
|
||||||
|
model, processor, id2label = _load_model_and_processor()
|
||||||
|
|
||||||
|
date_str = datetime.now().strftime("%Y%m%d")
|
||||||
|
tmp_dir = config.TMP_DIR / group_id / date_str
|
||||||
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. 파일 저장
|
||||||
|
tmp_paths, pdf_tmp_paths, img_meta = [], [], []
|
||||||
|
for file in files:
|
||||||
|
tmp = Path(f"tmp_{file.filename}")
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
f.write(await file.read())
|
||||||
|
tmp_paths.append(tmp)
|
||||||
|
|
||||||
|
if is_pdf(file.filename):
|
||||||
|
pdf_tmp_paths.append((file.filename, str(tmp)))
|
||||||
|
else:
|
||||||
|
img_meta.append((file.filename, [tmp]))
|
||||||
|
|
||||||
|
# 2. PDF 병렬 변환
|
||||||
|
if pdf_tmp_paths:
|
||||||
|
paths_only = [p for _, p in pdf_tmp_paths]
|
||||||
|
converted = pdf_to_images_batch(paths_only) # ← 병렬 변환
|
||||||
|
for filename, tmp_path in pdf_tmp_paths:
|
||||||
|
img_meta.append((filename, converted.get(tmp_path, [])))
|
||||||
|
|
||||||
|
try:
|
||||||
|
results = []
|
||||||
|
for filename, img_list in img_meta:
|
||||||
|
if not img_list:
|
||||||
|
results.append(PredictResult(filename=filename, error="PDF 변환 실패"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
page_results = [
|
||||||
|
_infer(str(p), filename, model, processor, id2label)
|
||||||
|
for p in img_list
|
||||||
|
]
|
||||||
|
valid = [r for r in page_results if "label" in r]
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
best = {"filename": filename, "error": "OCR 실패"}
|
||||||
|
elif len(valid) == 1:
|
||||||
|
best = valid[0]
|
||||||
|
else:
|
||||||
|
score = defaultdict(float)
|
||||||
|
for r in valid:
|
||||||
|
for label, prob in r["all_probs"].items():
|
||||||
|
score[label] += prob
|
||||||
|
best_label = max(score, key=score.get)
|
||||||
|
best = max(valid, key=lambda r: r["all_probs"].get(best_label, 0))
|
||||||
|
best = {**best, "label": best_label,
|
||||||
|
"confidence": round(score[best_label] / len(valid), 4)}
|
||||||
|
|
||||||
|
is_multi = len(img_list) > 1
|
||||||
|
results.append(PredictResult(**{
|
||||||
|
**best,
|
||||||
|
"total_pages": len(img_list) if is_multi else None,
|
||||||
|
"per_page": page_results if is_multi else None,
|
||||||
|
}))
|
||||||
|
logger.info(f"배치 추론: {filename} | {best.get('label')}")
|
||||||
|
|
||||||
|
return {"total": len(files), "results": [r.dict() for r in results]}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
for p in tmp_paths:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
for _, img_list in img_meta:
|
||||||
|
if len(img_list) > 1:
|
||||||
|
for p in img_list:
|
||||||
|
Path(p).unlink(missing_ok=True)
|
||||||
305
routers/train.py
Normal file
305
routers/train.py
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
from loguru import logger
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import config
|
||||||
|
import train.trainer as trainer
|
||||||
|
from train.schemas import TrainRequest, TrainStatus
|
||||||
|
from utils.cache import load_cache
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/start", summary="학습 실행")
|
||||||
|
async def start_train(
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
req: TrainRequest = None,
|
||||||
|
):
|
||||||
|
if req is None:
|
||||||
|
req = TrainRequest()
|
||||||
|
|
||||||
|
if trainer.train_status.running:
|
||||||
|
raise HTTPException(status_code=409, detail="이미 학습이 실행 중입니다.")
|
||||||
|
|
||||||
|
samples = load_cache()
|
||||||
|
if not samples:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="학습 데이터가 없습니다. /dataset/build 먼저 실행하세요.",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"학습 요청: {req.dict()}")
|
||||||
|
background_tasks.add_task(trainer.run_train, req)
|
||||||
|
return {"message": "학습 시작", "config": req.dict()}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stop", summary="학습 중단")
|
||||||
|
def stop_train():
|
||||||
|
if not trainer.train_status.running:
|
||||||
|
raise HTTPException(status_code=409, detail="학습이 실행 중이 아닙니다.")
|
||||||
|
trainer.stop_requested = True
|
||||||
|
trainer.train_status.message = "중단 요청됨..."
|
||||||
|
return {"message": "중단 요청 완료"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status", summary="학습 진행 상태 조회", response_model=TrainStatus)
|
||||||
|
def get_train_status():
|
||||||
|
return trainer.train_status
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stream", summary="학습 진행 상태 SSE 스트리밍")
|
||||||
|
async def stream_status():
|
||||||
|
"""
|
||||||
|
SSE(Server-Sent Events)로 학습 상태 실시간 전송
|
||||||
|
학습 완료 시 자동 종료
|
||||||
|
"""
|
||||||
|
async def event_generator():
|
||||||
|
prev_msg = ""
|
||||||
|
while True:
|
||||||
|
status = trainer.train_status
|
||||||
|
data = json.dumps({
|
||||||
|
"running": status.running,
|
||||||
|
"epoch": status.epoch,
|
||||||
|
"total": status.total,
|
||||||
|
"loss": status.loss,
|
||||||
|
"val_acc": status.val_acc,
|
||||||
|
"message": status.message,
|
||||||
|
# "progress": round(status.epoch / status.total * 100, 1) if status.total > 0 else 0,
|
||||||
|
"progress": round(status.batch / status.total_batches * 100, 1) if status.total_batches > 0 else 0, # ← 배치 기준으로 변경
|
||||||
|
"batch_message": status.message, # 배치 단위 메시지
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
if data != prev_msg:
|
||||||
|
yield {"event": "status", "data": data}
|
||||||
|
prev_msg = data
|
||||||
|
|
||||||
|
if not status.running and status.epoch > 0:
|
||||||
|
yield {"event": "done", "data": json.dumps({"message": status.message})}
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.5) # 0.5초마다 polling → 배치 진행 반영
|
||||||
|
|
||||||
|
return EventSourceResponse(event_generator())
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitor", summary="학습 모니터 HTML", response_class=HTMLResponse)
|
||||||
|
def train_monitor():
|
||||||
|
"""브라우저에서 학습 진행 상태 실시간 확인"""
|
||||||
|
html = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>학습 모니터</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: sans-serif; margin: 0; background: #f5f5f5;
|
||||||
|
display: flex; flex-direction: column; height: 100vh; }
|
||||||
|
.header { padding: 16px 24px; background: #1976d2; color: #fff; flex-shrink: 0; }
|
||||||
|
.header h2 { margin: 0; font-size: 18px; }
|
||||||
|
.container { padding: 20px 24px; flex: 1; overflow-y: auto; }
|
||||||
|
|
||||||
|
/* 상태 카드 */
|
||||||
|
.cards { display: flex; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
|
.card { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
flex: 1; min-width: 140px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.card .label { font-size: 12px; color: #888; margin-bottom: 6px; }
|
||||||
|
.card .value { font-size: 24px; font-weight: bold; color: #1976d2; }
|
||||||
|
|
||||||
|
/* 진행바 */
|
||||||
|
.progress-wrap { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.progress-label { display: flex; justify-content: space-between;
|
||||||
|
font-size: 13px; color: #555; margin-bottom: 8px; }
|
||||||
|
.progress-bar { height: 18px; background: #e0e0e0; border-radius: 9px; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: #1976d2; border-radius: 9px;
|
||||||
|
transition: width 0.5s ease; width: 0%; }
|
||||||
|
|
||||||
|
/* 배치 진행 메시지 */
|
||||||
|
.batch-msg { background: #fff; border-radius: 8px; padding: 12px 20px;
|
||||||
|
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.1);
|
||||||
|
font-size: 13px; color: #555; font-family: monospace; }
|
||||||
|
.log-wrap { background: #fff; border-radius: 8px; padding: 16px 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.1); }
|
||||||
|
.log-wrap h3 { margin: 0 0 10px; font-size: 14px; color: #333; }
|
||||||
|
.log-list { list-style: none; margin: 0; padding: 0;
|
||||||
|
max-height: 450px; overflow-y: auto; }
|
||||||
|
.log-list li { padding: 5px 8px; font-size: 13px; border-bottom: 1px solid #f0f0f0;
|
||||||
|
display: flex; gap: 10px; }
|
||||||
|
.log-list li .time { color: #aaa; flex-shrink: 0; }
|
||||||
|
.log-list li.done { color: #2e7d32; font-weight: bold; }
|
||||||
|
.log-list li.error { color: #c62828; }
|
||||||
|
|
||||||
|
/* 상태 뱃지 */
|
||||||
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 12px;
|
||||||
|
font-size: 12px; font-weight: bold; }
|
||||||
|
.badge.running { background: #e3f2fd; color: #1565c0; }
|
||||||
|
.badge.done { background: #e8f5e9; color: #2e7d32; }
|
||||||
|
.badge.idle { background: #f5f5f5; color: #757575; }
|
||||||
|
|
||||||
|
/* 버튼 */
|
||||||
|
.btn { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
font-size: 13px; font-weight: bold; }
|
||||||
|
.btn-start { background: #1976d2; color: #fff; }
|
||||||
|
.btn-start:hover { background: #1565c0; }
|
||||||
|
.btn-start:disabled { background: #90caf9; cursor: not-allowed; }
|
||||||
|
.btn-stop { background: #d32f2f; color: #fff; }
|
||||||
|
.btn-stop:hover { background: #b71c1c; }
|
||||||
|
.actions { display: flex; gap: 10px; align-items: center; margin-bottom: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h2>🤖 LayoutLMv3 학습 모니터</h2>
|
||||||
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-start" id="btnStart" onclick="startTrain()">▶ 학습 시작</button>
|
||||||
|
<button class="btn btn-stop" id="btnStop" onclick="stopTrain()" style="display:none">■ 학습 중단</button>
|
||||||
|
<span id="badge" class="badge idle">대기 중</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 진행바 -->
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-label">
|
||||||
|
<span id="epochLabel">Epoch 0 / 0</span>
|
||||||
|
<span id="pctLabel">0%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 카드 -->
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">현재 Loss</div>
|
||||||
|
<div class="value" id="cardLoss">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">Val Accuracy</div>
|
||||||
|
<div class="value" id="cardAcc">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="label">진행률</div>
|
||||||
|
<div class="value" id="cardPct">0%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 배치 진행 메시지 -->
|
||||||
|
<div class="batch-msg" id="batchMsg">대기 중...</div>
|
||||||
|
|
||||||
|
<!-- 메시지 로그 -->
|
||||||
|
<div class="log-wrap">
|
||||||
|
<h3>📋 진행 로그</h3>
|
||||||
|
<ul class="log-list" id="logList"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let es = null;
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return new Date().toLocaleTimeString("ko-KR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLog(msg, cls = "") {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
li.className = cls;
|
||||||
|
li.innerHTML = `<span class="time">${now()}</span><span>${msg}</span>`;
|
||||||
|
const list = document.getElementById("logList");
|
||||||
|
list.prepend(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI(d) {
|
||||||
|
// 배치 단위 실시간 메시지
|
||||||
|
document.getElementById("batchMsg").textContent = d.message || "";
|
||||||
|
|
||||||
|
// 진행바 (에포크 기준)
|
||||||
|
document.getElementById("progressFill").style.width = d.progress + "%";
|
||||||
|
document.getElementById("epochLabel").textContent =
|
||||||
|
`Epoch ${d.epoch} / ${d.total}`;
|
||||||
|
document.getElementById("pctLabel").textContent = d.progress + "%";
|
||||||
|
|
||||||
|
// 카드
|
||||||
|
document.getElementById("cardLoss").textContent = d.loss || "-";
|
||||||
|
document.getElementById("cardAcc").textContent =
|
||||||
|
d.val_acc ? (d.val_acc * 100).toFixed(1) + "%" : "-";
|
||||||
|
document.getElementById("cardPct").textContent = d.progress + "%";
|
||||||
|
|
||||||
|
// 뱃지
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
if (d.running) {
|
||||||
|
badge.textContent = "학습 중";
|
||||||
|
badge.className = "badge running";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTrain() {
|
||||||
|
fetch("/train/start", { method: "POST" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
addLog("학습 시작 요청: " + JSON.stringify(data.config));
|
||||||
|
document.getElementById("btnStart").disabled = true;
|
||||||
|
document.getElementById("btnStop").style.display = "inline-block";
|
||||||
|
subscribeSSE();
|
||||||
|
})
|
||||||
|
.catch(e => addLog("오류: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTrain() {
|
||||||
|
fetch("/train/stop", { method: "POST" })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => addLog("⏹ " + d.message))
|
||||||
|
.catch(e => addLog("오류: " + e, "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribeSSE() {
|
||||||
|
if (es) es.close();
|
||||||
|
es = new EventSource("/train/stream");
|
||||||
|
|
||||||
|
es.addEventListener("status", e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
updateUI(d);
|
||||||
|
if (d.message) addLog(d.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener("done", e => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
addLog("✅ " + d.message, "done");
|
||||||
|
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
badge.textContent = "완료";
|
||||||
|
badge.className = "badge done";
|
||||||
|
document.getElementById("btnStart").disabled = false;
|
||||||
|
document.getElementById("btnStop").style.display = "none";
|
||||||
|
es.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
addLog("SSE 연결 끊김", "error");
|
||||||
|
es.close();
|
||||||
|
document.getElementById("btnStart").disabled = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로드 시 이미 학습 중이면 자동 구독
|
||||||
|
fetch("/train/status")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
updateUI({...d, progress: d.total > 0 ? d.epoch / d.total * 100 : 0});
|
||||||
|
if (d.running) {
|
||||||
|
document.getElementById("btnStart").disabled = true;
|
||||||
|
addLog("학습 진행 중 - 자동 연결");
|
||||||
|
subscribeSSE();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
289
routers/visualize.py
Normal file
289
routers/visualize.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
|
import config
|
||||||
|
from utils.cache import _cache_path
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _bbox_denormalize(box: list, w: int, h: int) -> list:
|
||||||
|
"""0~1000 정규화 좌표 → 픽셀 좌표 복원"""
|
||||||
|
return [
|
||||||
|
int(box[0] * w / 1000),
|
||||||
|
int(box[1] * h / 1000),
|
||||||
|
int(box[2] * w / 1000),
|
||||||
|
int(box[3] * h / 1000),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_ocr(image_path: str, words: list, boxes: list) -> tuple[str, int, int]:
|
||||||
|
"""
|
||||||
|
원본 이미지를 base64로 변환만 함
|
||||||
|
바운딩박스/텍스트는 canvas에서 처리
|
||||||
|
"""
|
||||||
|
image = Image.open(image_path).convert("RGB")
|
||||||
|
w, h = image.size
|
||||||
|
buf = io.BytesIO()
|
||||||
|
image.save(buf, format="PNG")
|
||||||
|
return base64.b64encode(buf.getvalue()).decode("utf-8"), w, h
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{label}/{filename:path}",
|
||||||
|
summary="OCR 결과 시각화",
|
||||||
|
description="양식명과 파일명으로 OCR 바운딩박스를 이미지 위에 표시합니다.",
|
||||||
|
response_class=HTMLResponse,
|
||||||
|
)
|
||||||
|
def visualize(label: str, filename: str):
|
||||||
|
"""
|
||||||
|
사용법:
|
||||||
|
GET /visualize/TSMC_TypeA/invoice_001
|
||||||
|
→ 이미지 + 바운딩박스 + 텍스트를 HTML로 반환
|
||||||
|
"""
|
||||||
|
# json 캐시 로드
|
||||||
|
json_path = config.OCR_CACHE_DIR / label / f"{filename}.json"
|
||||||
|
if not json_path.exists():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"캐시 없음: {label}/{filename}.json | /dataset/build 먼저 실행하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(json_path, encoding="utf-8") as f:
|
||||||
|
ocr = json.load(f)
|
||||||
|
|
||||||
|
image_path = ocr.get("image_path")
|
||||||
|
if not image_path or not Path(image_path).exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"이미지 파일 없음: {image_path}")
|
||||||
|
|
||||||
|
words = ocr.get("words", [])
|
||||||
|
boxes = ocr.get("boxes", [])
|
||||||
|
|
||||||
|
# 시각화 이미지 생성
|
||||||
|
img_b64, img_w, img_h = _draw_ocr(image_path, words, boxes)
|
||||||
|
|
||||||
|
# boxes를 픽셀 좌표로 변환 (JS에서 사용)
|
||||||
|
# pixel_boxes = [_bbox_denormalize(b, img_w, img_h) for b in boxes]
|
||||||
|
|
||||||
|
box_type = ocr.get("box_type", "normalized")
|
||||||
|
|
||||||
|
if box_type == "pixel":
|
||||||
|
pixel_boxes = boxes # 픽셀 좌표 그대로 사용
|
||||||
|
else:
|
||||||
|
pixel_boxes = [_bbox_denormalize(b, img_w, img_h) for b in boxes] # 역변환
|
||||||
|
|
||||||
|
boxes_json = json.dumps(pixel_boxes)
|
||||||
|
words_json = json.dumps(words)
|
||||||
|
|
||||||
|
# OCR 텍스트 목록
|
||||||
|
ocr_rows = "".join([
|
||||||
|
f'<tr onclick="focusBox({i})" id="row-{i}">'
|
||||||
|
f'<td>{i+1}</td><td>{w}</td><td>{b}</td></tr>'
|
||||||
|
for i, (w, b) in enumerate(zip(words, boxes))
|
||||||
|
])
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>OCR 시각화 - {label}/{filename}</title>
|
||||||
|
<style>
|
||||||
|
* {{ box-sizing: border-box; }}
|
||||||
|
body {{ font-family: sans-serif; margin: 0; background: #f5f5f5;
|
||||||
|
height: 100vh; display: flex; flex-direction: column; overflow: hidden; }}
|
||||||
|
.header {{ padding: 12px 20px 4px; flex-shrink: 0; }}
|
||||||
|
h2 {{ color: #333; margin: 0 0 4px; font-size: 16px; }}
|
||||||
|
.meta {{ font-size: 13px; color: #555; margin-bottom: 8px; }}
|
||||||
|
|
||||||
|
/* 토글 고정 영역 */
|
||||||
|
.toolbar {{ padding: 6px 20px; background: #fff;
|
||||||
|
border-bottom: 1px solid #ddd; flex-shrink: 0;
|
||||||
|
display: flex; gap: 24px; align-items: center; font-size: 13px; }}
|
||||||
|
.switch {{ position: relative; width: 40px; height: 22px; }}
|
||||||
|
.switch input{{ opacity: 0; width: 0; height: 0; }}
|
||||||
|
.slider {{ position: absolute; inset: 0; background: #ccc;
|
||||||
|
border-radius: 22px; cursor: pointer; transition: .3s; }}
|
||||||
|
.slider:before{{ content:""; position: absolute; width: 16px; height: 16px;
|
||||||
|
left: 3px; bottom: 3px; background: white;
|
||||||
|
border-radius: 50%; transition: .3s; }}
|
||||||
|
input:checked + .slider {{ background: #1976d2; }}
|
||||||
|
input:checked + .slider:before {{ transform: translateX(18px); }}
|
||||||
|
.toggle-item{{ display: flex; align-items: center; gap: 8px; }}
|
||||||
|
|
||||||
|
/* 본문 컨테이너 - 남은 높이 전부 */
|
||||||
|
.container {{ display: flex; gap: 0; flex: 1; overflow: hidden; }}
|
||||||
|
|
||||||
|
/* 이미지 영역 - 독립 스크롤 */
|
||||||
|
.left {{ flex: 1; overflow: auto; padding: 12px 12px 12px 20px;
|
||||||
|
border-right: 1px solid #ddd; background: #fff; }}
|
||||||
|
.canvas-wrap{{ position: relative; display: inline-block; }}
|
||||||
|
canvas {{ position: absolute; top: 0; left: 0; pointer-events: none; }}
|
||||||
|
img {{ display: block; border: 1px solid #ccc; max-width: none; }}
|
||||||
|
|
||||||
|
/* 테이블 영역 - 독립 스크롤 */
|
||||||
|
.right {{ width: 400px; flex-shrink: 0; overflow: auto;
|
||||||
|
padding: 12px 20px 12px 12px; background: #fafafa; }}
|
||||||
|
.right h3 {{ margin: 0 0 8px; font-size: 14px; position: sticky;
|
||||||
|
top: 0; background: #fafafa; padding: 4px 0; z-index: 1; }}
|
||||||
|
table {{ border-collapse: collapse; width: 100%; font-size: 13px; }}
|
||||||
|
th, td {{ border: 1px solid #ddd; padding: 5px 8px; text-align: left; cursor: pointer; }}
|
||||||
|
th {{ background: #e8e8e8; position: sticky; top: 30px; z-index: 1; }}
|
||||||
|
tr:hover {{ background: #fff9c4; }}
|
||||||
|
tr.active {{ background: #ffe082 !important; font-weight: bold; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 헤더 고정 -->
|
||||||
|
<div class="header">
|
||||||
|
<h2>OCR 시각화 | {label} / {filename}</h2>
|
||||||
|
<div class="meta">총 단어 수: <b>{len(words)}</b>개</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 토글 고정 툴바 -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toggle-item">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="boxToggle" checked onchange="drawAll(activeIdx)">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>바운딩박스</span>
|
||||||
|
</div>
|
||||||
|
<div class="toggle-item">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="textToggle" onchange="drawAll(activeIdx)">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
<span>OCR 텍스트</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 이미지(좌) + 테이블(우) 각각 독립 스크롤 -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="left">
|
||||||
|
<div class="canvas-wrap">
|
||||||
|
<img id="img" src="data:image/png;base64,{img_b64}"
|
||||||
|
onload="initCanvas()" />
|
||||||
|
<canvas id="overlay"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="right" id="tablePane">
|
||||||
|
<h3>OCR 텍스트 목록</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>텍스트</th><th>박스 (정규화)</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tbody">{ocr_rows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const BOXES = {boxes_json};
|
||||||
|
const WORDS = {words_json};
|
||||||
|
const img = document.getElementById("img");
|
||||||
|
const canvas = document.getElementById("overlay");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
let scaleX = 1, scaleY = 1;
|
||||||
|
let activeIdx = -1;
|
||||||
|
|
||||||
|
function initCanvas() {{
|
||||||
|
canvas.width = img.clientWidth;
|
||||||
|
canvas.height = img.clientHeight;
|
||||||
|
scaleX = img.clientWidth / {img_w};
|
||||||
|
scaleY = img.clientHeight / {img_h};
|
||||||
|
drawAll(-1);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function drawAll(highlight) {{
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
const showBox = document.getElementById("boxToggle").checked;
|
||||||
|
const showText = document.getElementById("textToggle").checked;
|
||||||
|
|
||||||
|
BOXES.forEach((b, i) => {{
|
||||||
|
const x1 = b[0] * scaleX, y1 = b[1] * scaleY;
|
||||||
|
const x2 = b[2] * scaleX, y2 = b[3] * scaleY;
|
||||||
|
const bw = x2 - x1, bh = y2 - y1;
|
||||||
|
|
||||||
|
// 강조 배경
|
||||||
|
if (i === highlight) {{
|
||||||
|
ctx.fillStyle = "rgba(255,235,59,0.45)";
|
||||||
|
ctx.fillRect(x1, y1, bw, bh);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// 바운딩박스
|
||||||
|
if (showBox) {{
|
||||||
|
ctx.strokeStyle = i === highlight ? "#e53935" : "rgba(229,57,53,0.55)";
|
||||||
|
ctx.lineWidth = i === highlight ? 3 : 1.5;
|
||||||
|
ctx.strokeRect(x1, y1, bw, bh);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// OCR 텍스트
|
||||||
|
if (showText) {{
|
||||||
|
const fs = Math.max(11, Math.min(bh * 0.85, 15));
|
||||||
|
ctx.font = `bold ${{fs}}px sans-serif`;
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.strokeStyle = "rgba(255,255,255,0.95)";
|
||||||
|
ctx.fillStyle = i === highlight ? "#b71c1c" : "#1565c0";
|
||||||
|
ctx.strokeText(WORDS[i], x1 + 1, y1 + fs);
|
||||||
|
ctx.fillText(WORDS[i], x1 + 1, y1 + fs);
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
function focusBox(i) {{
|
||||||
|
activeIdx = i;
|
||||||
|
drawAll(i);
|
||||||
|
|
||||||
|
// 테이블 행 강조 + 스크롤
|
||||||
|
document.querySelectorAll("#tbody tr").forEach(r => r.classList.remove("active"));
|
||||||
|
const row = document.getElementById("row-" + i);
|
||||||
|
if (row) {{
|
||||||
|
row.classList.add("active");
|
||||||
|
row.scrollIntoView({{ block: "center", behavior: "smooth" }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// 이미지 영역 해당 박스 위치로 스크롤
|
||||||
|
const b = BOXES[i];
|
||||||
|
const cx = (b[0] + b[2]) / 2 * scaleX;
|
||||||
|
const cy = (b[1] + b[3]) / 2 * scaleY;
|
||||||
|
const leftPane = document.querySelector(".left");
|
||||||
|
const scrollX = cx - leftPane.clientWidth / 2;
|
||||||
|
const scrollY = cy - leftPane.clientHeight / 2;
|
||||||
|
leftPane.scrollTo({{ left: scrollX, top: scrollY, behavior: "smooth" }});
|
||||||
|
}}
|
||||||
|
|
||||||
|
window.addEventListener("resize", initCanvas);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{label}",
|
||||||
|
summary="양식별 파일 목록 조회",
|
||||||
|
description="캐시된 파일 목록을 반환합니다.",
|
||||||
|
)
|
||||||
|
def list_files(label: str):
|
||||||
|
cls_dir = config.OCR_CACHE_DIR / label
|
||||||
|
if not cls_dir.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"양식 없음: {label}")
|
||||||
|
|
||||||
|
# 서브폴더 포함 전체 json 탐색
|
||||||
|
files = [
|
||||||
|
str(p.relative_to(cls_dir).with_suffix("")) # 서브폴더/파일명 형태로 반환
|
||||||
|
for p in sorted(cls_dir.rglob("*.json"))
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"count": len(files),
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
183
train/classifier_dataset.py
Normal file
183
train/classifier_dataset.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
files/json/{LABEL}/{파일명}/page_xxx.json 구조를 읽어
|
||||||
|
페이지 단위로 샘플을 생성하여 캐시 저장
|
||||||
|
문서 1개 = 샘플 N개 (페이지 수만큼)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
# JSON_DIR = os.getenv("CLASSIFIER_JSON_DIR", "./files/json")
|
||||||
|
# CACHE_PATH = os.getenv("CLASSIFIER_CACHE_PATH", "./files/classifier_dataset.pkl")
|
||||||
|
# MIN_WORDS = int(os.getenv("CLASSIFIER_MIN_WORDS", "20")) # 페이지당 최소 단어 수
|
||||||
|
|
||||||
|
JSON_DIR = config.CLASSIFIER_JSON_DIR
|
||||||
|
CACHE_PATH = config.CLASSIFIER_CACHE_PATH
|
||||||
|
MIN_WORDS = config.CLASSIFIER_MIN_WORDS
|
||||||
|
|
||||||
|
_status: dict = {"status": "idle", "message": "", "total": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
return _status
|
||||||
|
|
||||||
|
|
||||||
|
# def build() -> None:
|
||||||
|
# global _status
|
||||||
|
# try:
|
||||||
|
# _status = {"status": "building", "message": "JSON 파일 스캔 중...", "total": 0}
|
||||||
|
# json_path = Path(JSON_DIR)
|
||||||
|
#
|
||||||
|
# if not json_path.exists():
|
||||||
|
# raise FileNotFoundError(f"JSON 디렉토리 없음: {JSON_DIR}")
|
||||||
|
#
|
||||||
|
# data = []
|
||||||
|
# skip_count = 0
|
||||||
|
#
|
||||||
|
# for label_dir in sorted(json_path.iterdir()):
|
||||||
|
# if not label_dir.is_dir():
|
||||||
|
# continue
|
||||||
|
# label = label_dir.name
|
||||||
|
# label_count = 0
|
||||||
|
#
|
||||||
|
# for doc_dir in sorted(label_dir.iterdir()):
|
||||||
|
# if not doc_dir.is_dir():
|
||||||
|
# continue
|
||||||
|
#
|
||||||
|
# for page_file in sorted(doc_dir.glob("page_*.json")):
|
||||||
|
# try:
|
||||||
|
# with open(page_file, encoding="utf-8") as f:
|
||||||
|
# d = json.load(f)
|
||||||
|
# words = d.get("words", [])
|
||||||
|
# except Exception as e:
|
||||||
|
# logger.warning(f"파일 읽기 실패 {page_file}: {e}")
|
||||||
|
# continue
|
||||||
|
#
|
||||||
|
# # 단어 수 미달 페이지 제외
|
||||||
|
# if len(words) < MIN_WORDS:
|
||||||
|
# print(f"skip file : {len(words)} : {page_file}")
|
||||||
|
# skip_count += 1
|
||||||
|
# continue
|
||||||
|
#
|
||||||
|
# data.append({
|
||||||
|
# "text": " ".join(words),
|
||||||
|
# "label": label,
|
||||||
|
# "src": str(page_file), # 디버깅용
|
||||||
|
# })
|
||||||
|
# label_count += 1
|
||||||
|
#
|
||||||
|
# _status["message"] = f"{label}: {label_count}페이지 샘플 추가 (누적 {len(data)}개)"
|
||||||
|
# logger.info(_status["message"])
|
||||||
|
#
|
||||||
|
# if not data:
|
||||||
|
# raise ValueError("수집된 데이터가 없습니다.")
|
||||||
|
#
|
||||||
|
# os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
||||||
|
# with open(CACHE_PATH, "wb") as f:
|
||||||
|
# pickle.dump(data, f)
|
||||||
|
#
|
||||||
|
# _status = {
|
||||||
|
# "status": "done",
|
||||||
|
# "message": f"데이터셋 생성 완료 (단어 {MIN_WORDS}개 미만 {skip_count}페이지 제외)",
|
||||||
|
# "total": len(data),
|
||||||
|
# }
|
||||||
|
# logger.info(f"데이터셋 저장: {CACHE_PATH} ({len(data)}개, 제외 {skip_count}개)")
|
||||||
|
#
|
||||||
|
# except Exception as e:
|
||||||
|
# _status = {"status": "error", "message": str(e), "total": 0}
|
||||||
|
# logger.exception(f"데이터셋 생성 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _process_page_file(page_file: Path, label: str, data: list, skip_count: int) -> tuple[int, int]:
|
||||||
|
"""단일 page_*.json 파일 처리. (label_count 증가분, skip_count) 반환"""
|
||||||
|
try:
|
||||||
|
with open(page_file, encoding="utf-8") as f:
|
||||||
|
d = json.load(f)
|
||||||
|
words = d.get("words", [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"파일 읽기 실패 {page_file}: {e}")
|
||||||
|
return 0, skip_count
|
||||||
|
|
||||||
|
if len(words) < MIN_WORDS:
|
||||||
|
print(f"skip file : {len(words)} : {page_file}")
|
||||||
|
return 0, skip_count + 1
|
||||||
|
|
||||||
|
data.append({"text": " ".join(words), "label": label, "src": str(page_file)})
|
||||||
|
return 1, skip_count
|
||||||
|
|
||||||
|
|
||||||
|
def _process_label_dir(label_dir: Path, data: list, skip_count: int) -> tuple[int, int]:
|
||||||
|
"""label 폴더 처리. label_count, skip_count 반환"""
|
||||||
|
label = label_dir.name
|
||||||
|
label_count = 0
|
||||||
|
|
||||||
|
# label 폴더 직속 page_*.json
|
||||||
|
for page_file in sorted(label_dir.glob("*.json")):
|
||||||
|
added, skip_count = _process_page_file(page_file, label, data, skip_count)
|
||||||
|
label_count += added
|
||||||
|
|
||||||
|
# 하위 doc_dir 폴더
|
||||||
|
for doc_dir in sorted(label_dir.iterdir()):
|
||||||
|
if not doc_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for page_file in sorted(doc_dir.glob("*.json")):
|
||||||
|
added, skip_count = _process_page_file(page_file, label, data, skip_count)
|
||||||
|
label_count += added
|
||||||
|
|
||||||
|
return label_count, skip_count
|
||||||
|
|
||||||
|
|
||||||
|
def build() -> None:
|
||||||
|
global _status
|
||||||
|
try:
|
||||||
|
_status = {"status": "building", "message": "JSON 파일 스캔 중...", "total": 0}
|
||||||
|
json_path = Path(JSON_DIR)
|
||||||
|
|
||||||
|
if not json_path.exists():
|
||||||
|
raise FileNotFoundError(f"JSON 디렉토리 없음: {JSON_DIR}")
|
||||||
|
|
||||||
|
data = []
|
||||||
|
skip_count = 0
|
||||||
|
|
||||||
|
for label_dir in sorted(json_path.iterdir()):
|
||||||
|
if not label_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
label_count, skip_count = _process_label_dir(label_dir, data, skip_count)
|
||||||
|
|
||||||
|
_status["message"] = f"{label_dir.name}: {label_count}페이지 샘플 추가 (누적 {len(data)}개)"
|
||||||
|
logger.info(_status["message"])
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
raise ValueError("수집된 데이터가 없습니다.")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True)
|
||||||
|
with open(CACHE_PATH, "wb") as f:
|
||||||
|
pickle.dump(data, f)
|
||||||
|
|
||||||
|
_status = {
|
||||||
|
"status": "done",
|
||||||
|
"message": f"데이터셋 생성 완료 (단어 {MIN_WORDS}개 미만 {skip_count}페이지 제외)",
|
||||||
|
"total": len(data),
|
||||||
|
}
|
||||||
|
logger.info(f"데이터셋 저장: {CACHE_PATH} ({len(data)}개, 제외 {skip_count}개)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_status = {"status": "error", "message": str(e), "total": 0}
|
||||||
|
logger.exception(f"데이터셋 생성 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> list[dict]:
|
||||||
|
if not os.path.exists(CACHE_PATH):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with open(CACHE_PATH, "rb") as f:
|
||||||
|
return pickle.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"데이터셋 로드 실패: {e}")
|
||||||
|
return []
|
||||||
215
train/classifier_trainer.py
Normal file
215
train/classifier_trainer.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
classifier_dataset.load() 로 데이터 로드 후 TF-IDF + SVM 학습
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from sklearn.calibration import CalibratedClassifierCV
|
||||||
|
from sklearn.feature_extraction.text import TfidfVectorizer
|
||||||
|
from sklearn.metrics import classification_report
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
from sklearn.svm import LinearSVC
|
||||||
|
|
||||||
|
from config import SERVICE
|
||||||
|
from train import classifier_dataset
|
||||||
|
from utils import classifier_model_store
|
||||||
|
|
||||||
|
_status: dict = {"status": "idle", "message": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def get_status() -> dict:
|
||||||
|
return _status
|
||||||
|
|
||||||
|
|
||||||
|
def augment_text(text: str, n: int = 25) -> list:
|
||||||
|
"""텍스트 증강 - 단어 순서 변경, 일부 단어 제거"""
|
||||||
|
words = text.split()
|
||||||
|
augmented = []
|
||||||
|
|
||||||
|
for _ in range(n):
|
||||||
|
aug = words.copy()
|
||||||
|
|
||||||
|
# 1. 랜덤 단어 10% 제거
|
||||||
|
drop_count = max(1, int(len(aug) * 0.1))
|
||||||
|
for _ in range(drop_count):
|
||||||
|
if aug:
|
||||||
|
aug.pop(random.randint(0, len(aug) - 1))
|
||||||
|
|
||||||
|
# 2. 랜덤 단어 일부 순서 섞기
|
||||||
|
if len(aug) > 5:
|
||||||
|
idx = random.randint(0, len(aug) - 3)
|
||||||
|
aug[idx], aug[idx + 1] = aug[idx + 1], aug[idx]
|
||||||
|
|
||||||
|
augmented.append(" ".join(aug))
|
||||||
|
|
||||||
|
return augmented
|
||||||
|
|
||||||
|
|
||||||
|
def run_train() -> None:
|
||||||
|
global _status
|
||||||
|
try:
|
||||||
|
_status = {"status": "training", "message": "데이터셋 로딩 중..."}
|
||||||
|
|
||||||
|
data = classifier_dataset.load()
|
||||||
|
if not data:
|
||||||
|
raise ValueError("데이터셋이 없습니다. /classifier/dataset/build 먼저 실행하세요.")
|
||||||
|
|
||||||
|
texts = [d["text"] for d in data]
|
||||||
|
labels = [d["label"] for d in data]
|
||||||
|
|
||||||
|
# ── 특정 클래스 데이터 증강 ──────────────────────
|
||||||
|
if SERVICE == "amko":
|
||||||
|
AUGMENT_LABELS = ["AMKOR TECHNOLOGY TAIWAN_B"] # 증강할 클래스
|
||||||
|
AUGMENT_TARGET = 30 # 목표 샘플 수
|
||||||
|
|
||||||
|
augmented_texts, augmented_labels = [], []
|
||||||
|
label_groups = {}
|
||||||
|
for t, l in zip(texts, labels):
|
||||||
|
label_groups.setdefault(l, []).append(t)
|
||||||
|
|
||||||
|
for label in AUGMENT_LABELS:
|
||||||
|
samples = label_groups.get(label, [])
|
||||||
|
current_count = len(samples)
|
||||||
|
|
||||||
|
if current_count < AUGMENT_TARGET:
|
||||||
|
need = AUGMENT_TARGET - current_count
|
||||||
|
logger.info(f"{label}: {current_count}개 → {AUGMENT_TARGET}개 증강 ({need}개 추가)")
|
||||||
|
|
||||||
|
for i in range(need):
|
||||||
|
base_text = samples[i % current_count] # 기존 샘플 순환
|
||||||
|
aug = augment_text(base_text, n=1)[0]
|
||||||
|
augmented_texts.append(aug)
|
||||||
|
augmented_labels.append(label)
|
||||||
|
|
||||||
|
texts = texts + augmented_texts
|
||||||
|
labels = labels + augmented_labels
|
||||||
|
logger.info(f"증강 후 클래스별 샘플: {Counter(labels)}")
|
||||||
|
# ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# # ── 클래스 언더샘플링 ─────────────────────────
|
||||||
|
# MAX_SAMPLES_PER_CLASS = 20 # B타입 8개의 2~3배 수준으로 제한
|
||||||
|
#
|
||||||
|
# balanced_data = []
|
||||||
|
# class_counts = Counter(labels)
|
||||||
|
#
|
||||||
|
# for label in set(labels):
|
||||||
|
# class_texts = [t for t, l in zip(texts, labels) if l == label]
|
||||||
|
# # MAX_SAMPLES_PER_CLASS 초과 시 랜덤 샘플링
|
||||||
|
# if len(class_texts) > MAX_SAMPLES_PER_CLASS:
|
||||||
|
# class_texts = random.sample(class_texts, MAX_SAMPLES_PER_CLASS)
|
||||||
|
# balanced_data.extend([(t, label) for t in class_texts])
|
||||||
|
#
|
||||||
|
# texts, labels = zip(*balanced_data)
|
||||||
|
# texts, labels = list(texts), list(labels)
|
||||||
|
# logger.info(f"언더샘플링 후: {Counter(labels)}")
|
||||||
|
|
||||||
|
# # ── 핵심 키워드 가중치 부여 (특정 클래스만) ──────────────────────
|
||||||
|
# KEYWORD_BOOST_BY_LABEL = {
|
||||||
|
# "AMKOR TECHNOLOGY TAIWAN_A": { # A타입에만
|
||||||
|
# "Process Fee": 5,
|
||||||
|
# "Total Process Fee": 5,
|
||||||
|
# "Consigned Value": 5,
|
||||||
|
# "Total Process Value": 5,
|
||||||
|
# },
|
||||||
|
# "AMKOR TECHNOLOGY TAIWAN_B": { # B타입에만
|
||||||
|
# "PACKING LIST SUMMARY": 5,
|
||||||
|
# "FOC Y": 3,
|
||||||
|
# },
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# def boost_keywords(text: str, label: str) -> str:
|
||||||
|
# boost_map = KEYWORD_BOOST_BY_LABEL.get(label, {}) # 해당 label 없으면 빈 dict
|
||||||
|
# boosted = text
|
||||||
|
# for keyword, repeat in boost_map.items():
|
||||||
|
# if keyword.lower() in text.lower():
|
||||||
|
# boosted += f" {keyword}" * repeat
|
||||||
|
# return boosted
|
||||||
|
#
|
||||||
|
# texts = [boost_keywords(t, l) for t, l in zip(texts, labels)]
|
||||||
|
# logger.info("핵심 키워드 가중치 부여 완료")
|
||||||
|
# # ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# # ── 샘플 부족 클래스 필터링 ──────────────────────
|
||||||
|
# counts = Counter(labels)
|
||||||
|
# MIN_SAMPLES = 5
|
||||||
|
# before = len(texts)
|
||||||
|
# filtered = [(t, l) for t, l in zip(texts, labels) if counts[l] >= MIN_SAMPLES]
|
||||||
|
# if not filtered:
|
||||||
|
# raise ValueError("모든 클래스가 샘플 부족으로 제외되었습니다.")
|
||||||
|
# texts, labels = map(list, zip(*filtered))
|
||||||
|
# excluded = {k: v for k, v in counts.items() if v < MIN_SAMPLES}
|
||||||
|
# if excluded:
|
||||||
|
# logger.warning(f"샘플 부족 제외: {excluded}")
|
||||||
|
# logger.info(f"필터링: {before}개 → {len(texts)}개")
|
||||||
|
# # ────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# counts = Counter(labels)
|
||||||
|
# min_count = min(counts.values())
|
||||||
|
# cv = 5
|
||||||
|
# stratify = labels if min_count >= cv else None
|
||||||
|
|
||||||
|
MIN_SAMPLES = 5
|
||||||
|
|
||||||
|
# ── 필터링 ───────────────────────────────────────
|
||||||
|
counts = Counter(labels)
|
||||||
|
before = len(texts)
|
||||||
|
filtered = [(t, l) for t, l in zip(texts, labels) if counts[l] >= MIN_SAMPLES]
|
||||||
|
if not filtered:
|
||||||
|
raise ValueError("모든 클래스가 샘플 부족으로 제외되었습니다.")
|
||||||
|
texts, labels = map(list, zip(*filtered))
|
||||||
|
excluded = {k: v for k, v in counts.items() if v < MIN_SAMPLES}
|
||||||
|
if excluded:
|
||||||
|
logger.warning(f"샘플 부족 제외: {excluded}")
|
||||||
|
logger.info(f"필터링: {before}개 → {len(texts)}개")
|
||||||
|
|
||||||
|
# ── MIN_SAMPLES 기준으로 cv/stratify 자동 결정 ──
|
||||||
|
counts = Counter(labels)
|
||||||
|
min_count = min(counts.values())
|
||||||
|
cv = max(2, min(5, int(MIN_SAMPLES * 0.8))) # MIN_SAMPLES의 80% (train 비율)
|
||||||
|
stratify = labels if min_count >= cv else None
|
||||||
|
|
||||||
|
logger.info(f"MIN_SAMPLES={MIN_SAMPLES} → cv={cv}, stratify={'적용' if stratify else '비적용'}")
|
||||||
|
|
||||||
|
_status["message"] = f"{len(texts)}개 샘플 / {len(counts)}개 클래스 학습 중..."
|
||||||
|
_status["samples"] = len(texts)
|
||||||
|
_status["classes"] = len(counts)
|
||||||
|
logger.info(_status["message"])
|
||||||
|
|
||||||
|
X_train, X_test, y_train, y_test = train_test_split(
|
||||||
|
texts, labels,
|
||||||
|
test_size=0.2,
|
||||||
|
stratify=stratify,
|
||||||
|
random_state=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
# train_counts = Counter(y_train)
|
||||||
|
# min_train_count = min(train_counts.values())
|
||||||
|
# cv = max(2, min(5, min_train_count))
|
||||||
|
|
||||||
|
pipeline = Pipeline([
|
||||||
|
("tfidf", TfidfVectorizer(max_features=50000, ngram_range=(1, 2))),
|
||||||
|
("clf", CalibratedClassifierCV(LinearSVC(max_iter=2000, class_weight="balanced"), cv=cv)),
|
||||||
|
])
|
||||||
|
pipeline.fit(X_train, y_train)
|
||||||
|
|
||||||
|
report = classification_report(y_test, pipeline.predict(X_test), zero_division=0)
|
||||||
|
logger.info(f"\n{report}")
|
||||||
|
|
||||||
|
classifier_model_store.set_model(pipeline)
|
||||||
|
|
||||||
|
_status = {
|
||||||
|
"status": "done",
|
||||||
|
"message": f"학습 완료 | 샘플={len(texts)}, 클래스={len(counts)}",
|
||||||
|
"samples": len(texts),
|
||||||
|
"classes": len(counts),
|
||||||
|
"report": report, # 필요 시 별도 API로 노출
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("classifier 학습 완료")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_status = {"status": "error", "message": str(e)}
|
||||||
|
logger.exception(f"classifier 학습 실패: {e}")
|
||||||
37
train/schemas.py
Normal file
37
train/schemas.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TrainRequest(BaseModel):
|
||||||
|
epochs: int = Field(10, description="학습 에폭 수")
|
||||||
|
lr: float = Field(1e-5, description="학습률")
|
||||||
|
batch_size: int = Field(4, description="배치 사이즈")
|
||||||
|
ewc_lambda: float = Field(1000., description="EWC 패널티 강도 (클수록 기존 지식 보호)")
|
||||||
|
strategy: str = Field("full",description="auto | full | ewc | replay")
|
||||||
|
|
||||||
|
|
||||||
|
class TrainStatus(BaseModel):
|
||||||
|
running: bool
|
||||||
|
epoch: int
|
||||||
|
total: int
|
||||||
|
loss: float
|
||||||
|
val_acc: float = 0.0
|
||||||
|
message: str
|
||||||
|
batch: int = 0 # 현재 배치
|
||||||
|
total_batches: int = 0 # 전체 배치 수
|
||||||
|
|
||||||
|
class PredictResult(BaseModel):
|
||||||
|
filename: str
|
||||||
|
label: str | None = None
|
||||||
|
confidence: float | None = None
|
||||||
|
all_probs: dict | None = None
|
||||||
|
key_tokens: list[str] | None = None
|
||||||
|
ocr_word_count: int | None = None
|
||||||
|
total_pages: int | None = None
|
||||||
|
per_page: list | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetStatus(BaseModel):
|
||||||
|
total_samples: int
|
||||||
|
by_label: dict
|
||||||
|
label2id: dict
|
||||||
348
train/trainer.py
Normal file
348
train/trainer.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import random
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from PIL import Image
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from sklearn.metrics import classification_report
|
||||||
|
from torch.utils.data import Dataset, DataLoader
|
||||||
|
from torch.optim import AdamW
|
||||||
|
from transformers import (
|
||||||
|
LayoutLMv3Processor,
|
||||||
|
LayoutLMv3ForSequenceClassification,
|
||||||
|
get_scheduler,
|
||||||
|
)
|
||||||
|
from tqdm import tqdm
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
from train.schemas import TrainRequest, TrainStatus
|
||||||
|
from utils.cache import (
|
||||||
|
load_cache, save_label2id,
|
||||||
|
load_replay_buffer, update_replay_buffer, load_label2id
|
||||||
|
)
|
||||||
|
|
||||||
|
# 학습 상태 전역
|
||||||
|
train_status = TrainStatus(
|
||||||
|
running=False, epoch=0, total=0, loss=0.0, message="대기 중"
|
||||||
|
)
|
||||||
|
stop_requested = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dataset ───────────────────────────────────────
|
||||||
|
class InvoiceDataset(Dataset):
|
||||||
|
def __init__(self, samples: list, processor):
|
||||||
|
self.samples = samples
|
||||||
|
self.processor = processor
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self.samples)
|
||||||
|
|
||||||
|
def __getitem__(self, idx):
|
||||||
|
s = self.samples[idx]
|
||||||
|
image = Image.open(s["image_path"]).convert("RGB")
|
||||||
|
encoding = self.processor(
|
||||||
|
image, text=s["words"], boxes=s["boxes"],
|
||||||
|
return_tensors="pt", truncation=True,
|
||||||
|
padding="max_length", max_length=config.MAX_LEN,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"input_ids": encoding["input_ids"].squeeze(),
|
||||||
|
"attention_mask": encoding["attention_mask"].squeeze(),
|
||||||
|
"bbox": encoding["bbox"].squeeze(),
|
||||||
|
"pixel_values": encoding["pixel_values"].squeeze(),
|
||||||
|
"labels": torch.tensor(s["label"], dtype=torch.long),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fisher (EWC) ──────────────────────────────────
|
||||||
|
def compute_fisher(model, loader) -> dict:
|
||||||
|
fisher = {n: torch.zeros_like(p)
|
||||||
|
for n, p in model.named_parameters() if p.requires_grad}
|
||||||
|
model.eval()
|
||||||
|
for batch in loader:
|
||||||
|
model.zero_grad()
|
||||||
|
out = model(
|
||||||
|
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||||
|
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||||
|
bbox = batch["bbox"].to(config.DEVICE),
|
||||||
|
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||||
|
labels = batch["labels"].to(config.DEVICE),
|
||||||
|
)
|
||||||
|
out.loss.backward()
|
||||||
|
for n, p in model.named_parameters():
|
||||||
|
if p.requires_grad and p.grad is not None:
|
||||||
|
fisher[n] += p.grad.pow(2)
|
||||||
|
return {n: f / max(len(loader), 1) for n, f in fisher.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 검증 ──────────────────────────────────────────
|
||||||
|
def evaluate_loader(model, loader) -> float:
|
||||||
|
model.eval()
|
||||||
|
correct, total = 0, 0
|
||||||
|
with torch.no_grad():
|
||||||
|
for batch in loader:
|
||||||
|
out = model(
|
||||||
|
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||||
|
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||||
|
bbox = batch["bbox"].to(config.DEVICE),
|
||||||
|
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||||
|
)
|
||||||
|
preds = out.logits.argmax(dim=-1).cpu()
|
||||||
|
correct += (preds == batch["labels"]).sum().item()
|
||||||
|
total += len(batch["labels"])
|
||||||
|
return correct / total if total else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_model_path():
|
||||||
|
"""
|
||||||
|
날짜/차수 폴더에서 가장 최신 모델 경로 반환
|
||||||
|
files/models/20260305/0002/ ← 최신
|
||||||
|
"""
|
||||||
|
base = config.MODEL_BASE_PATH
|
||||||
|
all_models = sorted([
|
||||||
|
d for d in base.rglob("config.json")
|
||||||
|
])
|
||||||
|
if not all_models:
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(all_models[-1].parent)
|
||||||
|
return all_models[-1].parent
|
||||||
|
|
||||||
|
|
||||||
|
# ── 학습 루프 ─────────────────────────────────────
|
||||||
|
def run_train_loop(model, processor, train_s, val_s, req: TrainRequest,
|
||||||
|
label2id: dict, strategy: str, save_path: Path):
|
||||||
|
global train_status
|
||||||
|
|
||||||
|
train_loader = DataLoader(
|
||||||
|
InvoiceDataset(train_s, processor),
|
||||||
|
batch_size=req.batch_size, shuffle=True
|
||||||
|
)
|
||||||
|
val_loader = DataLoader(
|
||||||
|
InvoiceDataset(val_s, processor),
|
||||||
|
batch_size=req.batch_size
|
||||||
|
)
|
||||||
|
optimizer = AdamW(model.parameters(), lr=req.lr)
|
||||||
|
scheduler = get_scheduler(
|
||||||
|
"cosine", optimizer=optimizer,
|
||||||
|
num_warmup_steps=len(train_loader),
|
||||||
|
num_training_steps=len(train_loader) * req.epochs,
|
||||||
|
)
|
||||||
|
|
||||||
|
# EWC 사전 계산
|
||||||
|
ewc_params, ewc_fisher = {}, {}
|
||||||
|
if strategy == "ewc":
|
||||||
|
ewc_params = {n: p.clone().detach()
|
||||||
|
for n, p in model.named_parameters() if p.requires_grad}
|
||||||
|
ewc_fisher = compute_fisher(model, val_loader)
|
||||||
|
logger.info("EWC Fisher 계산 완료")
|
||||||
|
|
||||||
|
best_acc = 0.0
|
||||||
|
total_batches = len(train_loader) * req.epochs
|
||||||
|
|
||||||
|
for epoch in range(req.epochs):
|
||||||
|
model.train()
|
||||||
|
total_loss = 0
|
||||||
|
|
||||||
|
pbar = tqdm(
|
||||||
|
train_loader,
|
||||||
|
desc=f"Epoch {epoch+1}/{req.epochs}",
|
||||||
|
ncols=100,
|
||||||
|
leave=True,
|
||||||
|
)
|
||||||
|
for batch_idx, batch in enumerate(pbar):
|
||||||
|
if stop_requested:
|
||||||
|
logger.info("학습 중단 요청 감지")
|
||||||
|
return
|
||||||
|
|
||||||
|
out = model(
|
||||||
|
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||||
|
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||||
|
bbox = batch["bbox"].to(config.DEVICE),
|
||||||
|
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||||
|
labels = batch["labels"].to(config.DEVICE),
|
||||||
|
)
|
||||||
|
loss = out.loss
|
||||||
|
if strategy == "ewc" and ewc_fisher:
|
||||||
|
for n, p in model.named_parameters():
|
||||||
|
if n in ewc_fisher:
|
||||||
|
loss += req.ewc_lambda * (
|
||||||
|
ewc_fisher[n] * (p - ewc_params[n]).pow(2)
|
||||||
|
).sum()
|
||||||
|
|
||||||
|
loss.backward()
|
||||||
|
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
|
||||||
|
optimizer.step()
|
||||||
|
scheduler.step()
|
||||||
|
optimizer.zero_grad()
|
||||||
|
total_loss += loss.item()
|
||||||
|
|
||||||
|
pbar.set_postfix(loss=f"{loss.item():.4f}")
|
||||||
|
|
||||||
|
# 배치 단위 상태 업데이트
|
||||||
|
done_batches = epoch * len(train_loader) + (batch_idx + 1)
|
||||||
|
train_status.epoch = epoch + 1
|
||||||
|
train_status.total = req.epochs
|
||||||
|
train_status.loss = round(loss.item(), 4)
|
||||||
|
train_status.batch = done_batches # 완료된 전체 배치
|
||||||
|
train_status.total_batches = total_batches # 전체 배치 수
|
||||||
|
train_status.message = (
|
||||||
|
f"Epoch {epoch+1}/{req.epochs} | "
|
||||||
|
f"batch {batch_idx+1}/{len(train_loader)} | "
|
||||||
|
f"loss={loss.item():.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
avg_loss = total_loss / len(train_loader)
|
||||||
|
val_acc = evaluate_loader(model, val_loader)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"Epoch {epoch+1}/{req.epochs} | "
|
||||||
|
f"loss={avg_loss:.4f} | val_acc={val_acc:.4f}"
|
||||||
|
)
|
||||||
|
tqdm.write(f"[결과] {msg}")
|
||||||
|
logger.info(msg)
|
||||||
|
|
||||||
|
train_status.epoch = epoch + 1
|
||||||
|
train_status.loss = round(avg_loss, 4)
|
||||||
|
train_status.val_acc = round(val_acc, 4)
|
||||||
|
train_status.message = msg
|
||||||
|
|
||||||
|
if val_acc > best_acc:
|
||||||
|
best_acc = val_acc
|
||||||
|
model.save_pretrained(str(save_path))
|
||||||
|
processor.save_pretrained(str(save_path))
|
||||||
|
save_label2id(label2id)
|
||||||
|
logger.info(f"모델 저장 (val_acc={val_acc:.4f}) → {save_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 학습 진입점 ───────────────────────────────────
|
||||||
|
def run_train(req: TrainRequest):
|
||||||
|
global train_status
|
||||||
|
stop_requested = False
|
||||||
|
train_status = TrainStatus(
|
||||||
|
running=True, epoch=0, total=req.epochs,
|
||||||
|
loss=0.0, message="학습 준비 중"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from utils.cache import load_label2id
|
||||||
|
|
||||||
|
# 날짜/차수 폴더 생성
|
||||||
|
save_path = config.get_model_save_path()
|
||||||
|
logger.info(f"모델 저장 경로: {save_path}")
|
||||||
|
train_status.message = f"저장 경로: {save_path}"
|
||||||
|
|
||||||
|
samples = load_cache()
|
||||||
|
label2id = load_label2id()
|
||||||
|
id2label = {v: k for k, v in label2id.items()}
|
||||||
|
|
||||||
|
processor_path = (
|
||||||
|
str(config.MODEL_BASE_PATH / "preprocessor_config.json")
|
||||||
|
if (config.MODEL_BASE_PATH / "preprocessor_config.json").exists()
|
||||||
|
else config.MODEL_NAME
|
||||||
|
)
|
||||||
|
# 최신 모델 폴더 찾기 (이어 학습)
|
||||||
|
latest_model = _get_latest_model_path()
|
||||||
|
model_path = str(latest_model) if latest_model else config.MODEL_NAME
|
||||||
|
logger.info(f"기반 모델: {model_path}")
|
||||||
|
|
||||||
|
processor = LayoutLMv3Processor.from_pretrained(
|
||||||
|
latest_model and str(latest_model) or config.MODEL_NAME,
|
||||||
|
apply_ocr=False
|
||||||
|
)
|
||||||
|
model = LayoutLMv3ForSequenceClassification.from_pretrained(
|
||||||
|
model_path,
|
||||||
|
num_labels=len(label2id),
|
||||||
|
id2label=id2label,
|
||||||
|
label2id=label2id,
|
||||||
|
ignore_mismatched_sizes=True,
|
||||||
|
).to(config.DEVICE)
|
||||||
|
|
||||||
|
# 전략 자동 선택
|
||||||
|
strategy = req.strategy
|
||||||
|
if strategy == "auto":
|
||||||
|
buf = load_replay_buffer()
|
||||||
|
existing_types = set(buf.keys())
|
||||||
|
new_types = {id2label[s["label"]] for s in samples}
|
||||||
|
strategy = "replay" if (new_types - existing_types) else "ewc"
|
||||||
|
train_status.message = f"전략: {strategy}"
|
||||||
|
logger.info(f"학습 전략: {strategy}")
|
||||||
|
|
||||||
|
train_s, val_s = train_test_split(
|
||||||
|
samples, test_size=0.2, random_state=42,
|
||||||
|
stratify=[s["label"] for s in samples],
|
||||||
|
)
|
||||||
|
|
||||||
|
if strategy == "replay":
|
||||||
|
replay_s = [s for v in load_replay_buffer().values() for s in v]
|
||||||
|
combined = train_s + replay_s
|
||||||
|
random.shuffle(combined)
|
||||||
|
run_train_loop(model, processor, combined, val_s, req, label2id, strategy, save_path)
|
||||||
|
update_replay_buffer(train_s, id2label)
|
||||||
|
else:
|
||||||
|
run_train_loop(model, processor, train_s, val_s, req, label2id, strategy, save_path)
|
||||||
|
update_replay_buffer(train_s, id2label)
|
||||||
|
|
||||||
|
train_status.message = f"학습 완료 → {save_path}"
|
||||||
|
logger.info(f"학습 완료: {save_path}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
train_status.message = f"오류: {traceback.format_exc()}"
|
||||||
|
logger.exception("학습 중 예외 발생")
|
||||||
|
finally:
|
||||||
|
stop_requested = False
|
||||||
|
train_status.running = False
|
||||||
|
|
||||||
|
|
||||||
|
# ── 평가 진입점 ───────────────────────────────────
|
||||||
|
def run_evaluate(test_size: float = 0.2) -> dict:
|
||||||
|
samples = load_cache()
|
||||||
|
label2id = load_label2id()
|
||||||
|
id2label = {v: k for k, v in label2id.items()}
|
||||||
|
|
||||||
|
_, test_s = train_test_split(
|
||||||
|
samples, test_size=test_size, random_state=42,
|
||||||
|
stratify=[s["label"] for s in samples],
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = _get_latest_model_path()
|
||||||
|
if not model_path:
|
||||||
|
raise RuntimeError("저장된 모델이 없습니다.")
|
||||||
|
|
||||||
|
processor = LayoutLMv3Processor.from_pretrained(
|
||||||
|
str(model_path), apply_ocr=False
|
||||||
|
)
|
||||||
|
model = LayoutLMv3ForSequenceClassification.from_pretrained(
|
||||||
|
str(model_path)
|
||||||
|
).to(config.DEVICE)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
loader = DataLoader(InvoiceDataset(test_s, processor), batch_size=config.BATCH_SIZE)
|
||||||
|
all_preds, all_labels = [], []
|
||||||
|
with torch.no_grad():
|
||||||
|
for batch in loader:
|
||||||
|
out = model(
|
||||||
|
input_ids = batch["input_ids"].to(config.DEVICE),
|
||||||
|
attention_mask = batch["attention_mask"].to(config.DEVICE),
|
||||||
|
bbox = batch["bbox"].to(config.DEVICE),
|
||||||
|
pixel_values = batch["pixel_values"].to(config.DEVICE),
|
||||||
|
)
|
||||||
|
all_preds.extend(out.logits.argmax(dim=-1).cpu().tolist())
|
||||||
|
all_labels.extend(batch["labels"].tolist())
|
||||||
|
|
||||||
|
target_names = [id2label[i] for i in range(len(id2label))]
|
||||||
|
report_dict = classification_report(
|
||||||
|
all_labels, all_preds, target_names=target_names, output_dict=True
|
||||||
|
)
|
||||||
|
report_str = classification_report(
|
||||||
|
all_labels, all_preds, target_names=target_names
|
||||||
|
)
|
||||||
|
logger.info(f"평가 완료 | accuracy={report_dict['accuracy']:.4f}")
|
||||||
|
return {
|
||||||
|
"test_samples": len(test_s),
|
||||||
|
"accuracy": round(report_dict["accuracy"], 4),
|
||||||
|
"report": report_dict,
|
||||||
|
"report_text": report_str,
|
||||||
|
}
|
||||||
123
utils/cache.py
Normal file
123
utils/cache.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import json
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCR 캐시 (파일별 json 분리) ───────────────────
|
||||||
|
|
||||||
|
def _cache_path(image_path: str) -> Path:
|
||||||
|
"""
|
||||||
|
이미지 경로 → json 저장 경로
|
||||||
|
files/data/TSMC_A/1000114581_INV/page_001.png
|
||||||
|
→ files/json/TSMC_A/1000114581_INV/page_001.json
|
||||||
|
"""
|
||||||
|
img = Path(image_path)
|
||||||
|
try:
|
||||||
|
rel = img.relative_to(config.DATA_DIR)
|
||||||
|
except ValueError:
|
||||||
|
rel = Path(img.parent.name) / img.name
|
||||||
|
return config.OCR_CACHE_DIR / rel.with_suffix(".json")
|
||||||
|
|
||||||
|
|
||||||
|
def save_cache_by_file(ocr_result: dict):
|
||||||
|
"""이미지 1장의 OCR 결과를 개별 json으로 저장"""
|
||||||
|
path = _cache_path(ocr_result["image_path"])
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(ocr_result, f, ensure_ascii=False, indent=2)
|
||||||
|
logger.debug(f"캐시 저장: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_cache() -> list:
|
||||||
|
"""전체 캐시 통합 로드 (학습/평가 시 사용)"""
|
||||||
|
samples = []
|
||||||
|
for p in config.OCR_CACHE_DIR.rglob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
samples.append(json.load(f))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
logger.warning(f"캐시 로드 실패 (건너뜀): {p} | {e}")
|
||||||
|
return samples
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_paths() -> set:
|
||||||
|
"""캐시된 이미지 경로 전체 반환 (중복 방지용)"""
|
||||||
|
cached = []
|
||||||
|
for p in config.OCR_CACHE_DIR.rglob("*.json"):
|
||||||
|
try:
|
||||||
|
with open(p, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
cached.append(data.get("image_path", ""))
|
||||||
|
# image_path = data.get("image_path", "")
|
||||||
|
# if image_path:
|
||||||
|
# cached.append(str(Path(image_path))) # 정규화
|
||||||
|
# print(str(Path(image_path)))
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
return set(cached)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_status() -> dict:
|
||||||
|
"""양식별 캐시 현황 (폴더별 json 파일 수 집계)"""
|
||||||
|
if not config.OCR_CACHE_DIR.exists():
|
||||||
|
return {}
|
||||||
|
status = {}
|
||||||
|
for cls_dir in sorted(config.OCR_CACHE_DIR.iterdir()):
|
||||||
|
if cls_dir.is_dir():
|
||||||
|
status[cls_dir.name] = len(list(cls_dir.rglob("*.json")))
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
# ── label2id ──────────────────────────────────────
|
||||||
|
|
||||||
|
def load_label2id() -> dict:
|
||||||
|
path = config.MODEL_SAVE_PATH / "label2id.json"
|
||||||
|
if path.exists():
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
if config.DATA_DIR.exists():
|
||||||
|
classes = sorted([d.name for d in config.DATA_DIR.iterdir() if d.is_dir()])
|
||||||
|
return {c: i for i, c in enumerate(classes)}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_label2id(label2id: dict):
|
||||||
|
config.MODEL_SAVE_PATH.mkdir(exist_ok=True)
|
||||||
|
with open(config.MODEL_SAVE_PATH / "label2id.json", "w", encoding="utf-8") as f:
|
||||||
|
json.dump(label2id, f, ensure_ascii=False, indent=2)
|
||||||
|
logger.info(f"label2id 저장: {label2id}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 리플레이 버퍼 ──────────────────────────────────
|
||||||
|
|
||||||
|
def load_replay_buffer() -> dict:
|
||||||
|
if not config.REPLAY_BUFFER_FILE.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(config.REPLAY_BUFFER_FILE, encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
|
backup = config.REPLAY_BUFFER_FILE.with_suffix(".json.bak")
|
||||||
|
config.REPLAY_BUFFER_FILE.rename(backup)
|
||||||
|
logger.warning(f"replay_buffer.json 손상 → 백업 후 초기화: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def update_replay_buffer(new_samples: list, id2label: dict, per_class: int = 10):
|
||||||
|
buf = load_replay_buffer()
|
||||||
|
by_label = {}
|
||||||
|
for s in new_samples:
|
||||||
|
name = id2label[s["label"]]
|
||||||
|
by_label.setdefault(name, []).append(s)
|
||||||
|
|
||||||
|
for name, samples in by_label.items():
|
||||||
|
combined = buf.get(name, []) + samples
|
||||||
|
buf[name] = random.sample(combined, min(per_class, len(combined)))
|
||||||
|
|
||||||
|
with open(config.REPLAY_BUFFER_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(buf, f, ensure_ascii=False, indent=2)
|
||||||
|
logger.info(f"리플레이 버퍼 갱신: { {k: len(v) for k, v in buf.items()} }")
|
||||||
68
utils/classifier_model_store.py
Normal file
68
utils/classifier_model_store.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""
|
||||||
|
모델 로드/저장/예측 담당
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import joblib
|
||||||
|
import numpy as np
|
||||||
|
from loguru import logger
|
||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
MODEL_FILENAME = "classifier.pkl"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_latest_path() -> Optional[Path]:
|
||||||
|
candidates = sorted(config.CLASSIFIER_MODEL_BASE_PATH.rglob(MODEL_FILENAME))
|
||||||
|
return candidates[-1] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_save_path() -> Path:
|
||||||
|
from datetime import datetime
|
||||||
|
date_dir = config.CLASSIFIER_MODEL_BASE_PATH / datetime.now().strftime("%Y%m%d")
|
||||||
|
date_dir.mkdir(exist_ok=True)
|
||||||
|
existing = sorted([d for d in date_dir.iterdir() if d.is_dir() and d.name.isdigit()])
|
||||||
|
next_idx = int(existing[-1].name) + 1 if existing else 1
|
||||||
|
save_dir = date_dir / f"{next_idx:04d}"
|
||||||
|
save_dir.mkdir(exist_ok=True)
|
||||||
|
return save_dir / MODEL_FILENAME
|
||||||
|
|
||||||
|
|
||||||
|
_model: Optional[Pipeline] = None
|
||||||
|
|
||||||
|
# 서버 시작 시 최신 모델 자동 로드
|
||||||
|
_latest = _get_latest_path()
|
||||||
|
if _latest:
|
||||||
|
try:
|
||||||
|
_model = joblib.load(_latest)
|
||||||
|
logger.info(f"classifier 모델 로드: {_latest} | classes={list(_model.classes_)}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"모델 로드 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_model() -> Optional[Pipeline]:
|
||||||
|
return _model
|
||||||
|
|
||||||
|
|
||||||
|
def set_model(pipeline: Pipeline) -> None:
|
||||||
|
global _model
|
||||||
|
path = _get_save_path()
|
||||||
|
joblib.dump(pipeline, path)
|
||||||
|
_model = pipeline
|
||||||
|
logger.info(f"classifier 모델 저장: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def predict(
|
||||||
|
pipeline: Pipeline,
|
||||||
|
text: str,
|
||||||
|
threshold: float = 0.6,
|
||||||
|
) -> tuple[str, float, dict[str, float]]:
|
||||||
|
probs = pipeline.predict_proba([text])[0]
|
||||||
|
classes = pipeline.classes_
|
||||||
|
max_idx = int(np.argmax(probs))
|
||||||
|
confidence = float(probs[max_idx])
|
||||||
|
label = "OTHER" if confidence < threshold else classes[max_idx]
|
||||||
|
all_probs = {c: round(float(p), 4) for c, p in zip(classes, probs)}
|
||||||
|
return label, round(confidence, 4), all_probs
|
||||||
38
utils/ocr.py
Normal file
38
utils/ocr.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# import config
|
||||||
|
#
|
||||||
|
# if config.OCR_ENGINE == "google":
|
||||||
|
# from utils.ocr_google import ocr_single, ocr_batch, get_vision_client
|
||||||
|
# else:
|
||||||
|
# from utils.ocr_paddle import ocr_single, ocr_batch
|
||||||
|
# def get_vision_client():
|
||||||
|
# return None # paddle은 client 불필요, 호환성 유지용
|
||||||
|
|
||||||
|
|
||||||
|
from utils.ocr_paddle import ocr_single as paddle_ocr_single, ocr_batch as paddle_ocr_batch
|
||||||
|
from utils.ocr_google import ocr_single as google_ocr_single, ocr_batch as google_ocr_batch, get_vision_client
|
||||||
|
import config
|
||||||
|
|
||||||
|
_google_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_vision_client_cached():
|
||||||
|
global _google_client
|
||||||
|
if _google_client is None:
|
||||||
|
_google_client = get_vision_client()
|
||||||
|
return _google_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_ocr_functions(engine: str):
|
||||||
|
if engine == "google":
|
||||||
|
return google_ocr_single, google_ocr_batch, get_vision_client_cached() # 캐싱된 클라이언트 사용
|
||||||
|
return paddle_ocr_single, paddle_ocr_batch, None
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_single(image_path, client=None):
|
||||||
|
fn, _, c = get_ocr_functions(config.OCR_ENGINE)
|
||||||
|
return fn(image_path, c)
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_batch(img_paths, client=None):
|
||||||
|
_, fn, c = get_ocr_functions(config.OCR_ENGINE)
|
||||||
|
return fn(img_paths, c)
|
||||||
92
utils/ocr_google.py
Normal file
92
utils/ocr_google.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.cloud import vision_v1
|
||||||
|
from google.oauth2 import service_account
|
||||||
|
from loguru import logger
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
|
||||||
|
def get_vision_client() -> vision_v1.ImageAnnotatorClient:
|
||||||
|
credentials = service_account.Credentials.from_service_account_file(
|
||||||
|
config.SERVICE_ACCOUNT_FILE,
|
||||||
|
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
||||||
|
)
|
||||||
|
return vision_v1.ImageAnnotatorClient(credentials=credentials)
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_single(image_path: str, client: vision_v1.ImageAnnotatorClient) -> dict:
|
||||||
|
"""
|
||||||
|
이미지 1장 → words + 정규화 boxes (0~1000) 반환
|
||||||
|
"""
|
||||||
|
image_pil = Image.open(image_path).convert("RGB")
|
||||||
|
w, h = image_pil.size
|
||||||
|
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
response = client.document_text_detection(
|
||||||
|
image=vision_v1.Image(content=content)
|
||||||
|
)
|
||||||
|
|
||||||
|
words, boxes = [], []
|
||||||
|
for page in response.full_text_annotation.pages:
|
||||||
|
for block in page.blocks:
|
||||||
|
for para in block.paragraphs:
|
||||||
|
for word in para.words:
|
||||||
|
text = "".join([s.text for s in word.symbols])
|
||||||
|
if not text.strip():
|
||||||
|
continue
|
||||||
|
v = word.bounding_box.vertices
|
||||||
|
boxes.append([
|
||||||
|
max(0, int(1000 * v[0].x / w)),
|
||||||
|
max(0, int(1000 * v[0].y / h)),
|
||||||
|
min(1000, int(1000 * v[2].x / w)),
|
||||||
|
min(1000, int(1000 * v[2].y / h)),
|
||||||
|
])
|
||||||
|
words.append(text)
|
||||||
|
|
||||||
|
result = {"image_path": str(image_path), "words": words, "boxes": boxes, "box_type": "normalized"}
|
||||||
|
|
||||||
|
# OCR 결과 저장
|
||||||
|
# json_path = Path(image_path).with_name(Path(image_path).stem + "_google.json")
|
||||||
|
# with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
# json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
# logger.debug(f"OCR 저장: {json_path}")
|
||||||
|
|
||||||
|
logger.debug(f"OCR 완료: {image_path} | 단어수={len(words)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_batch(image_paths: list, client=None, max_workers: int = config.OCR_MAX_WORKERS, on_done=None) -> list:
|
||||||
|
"""
|
||||||
|
ThreadPoolExecutor 병렬 OCR
|
||||||
|
Vision API는 네트워크 I/O 기반 → 병렬 효과 큼
|
||||||
|
"""
|
||||||
|
# client = get_vision_client()
|
||||||
|
if client is None:
|
||||||
|
client = get_vision_client()
|
||||||
|
results = [None] * len(image_paths)
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_idx = {
|
||||||
|
executor.submit(ocr_single, str(p), client): i
|
||||||
|
for i, p in enumerate(image_paths)
|
||||||
|
}
|
||||||
|
for future in concurrent.futures.as_completed(future_to_idx):
|
||||||
|
idx = future_to_idx[future]
|
||||||
|
try:
|
||||||
|
results[idx] = future.result()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"OCR 실패: {image_paths[idx]} | {e}")
|
||||||
|
results[idx] = {
|
||||||
|
"image_path": str(image_paths[idx]),
|
||||||
|
"words": [], "boxes": [], "error": str(e),
|
||||||
|
}
|
||||||
|
if on_done:
|
||||||
|
on_done(idx, results[idx])
|
||||||
|
|
||||||
|
return results
|
||||||
107
utils/ocr_paddle.py
Normal file
107
utils/ocr_paddle.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
_client = httpx.Client(
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
connect=3.0,
|
||||||
|
read=30.0, # 파일 전송이므로 기존 30초 유지
|
||||||
|
write=30.0,
|
||||||
|
pool=3.0
|
||||||
|
),
|
||||||
|
limits=httpx.Limits(
|
||||||
|
max_keepalive_connections=5,
|
||||||
|
keepalive_expiry=50 # A서버 --timeout-keep-alive 60 보다 약간 낮게
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_single_pdf(pdf_path: str) -> dict:
|
||||||
|
with open(pdf_path, "rb") as f:
|
||||||
|
resp = httpx.post(
|
||||||
|
config.PADDLE_OCR_URL,
|
||||||
|
files={"file": (Path(pdf_path).name, f, "application/pdf")},
|
||||||
|
data={"group_id": "infer"},
|
||||||
|
timeout=120, # PDF는 이미지보다 오래 걸리므로 timeout 증가
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
ocr_result = resp.json()
|
||||||
|
|
||||||
|
words, boxes = [], []
|
||||||
|
for page in ocr_result: # PDF는 페이지가 여러 장일 수 있으므로 전체 순회
|
||||||
|
for text, box in zip(page.get("words", []), page.get("boxes", [])):
|
||||||
|
text = text.strip()
|
||||||
|
if not text or not box:
|
||||||
|
continue
|
||||||
|
words.append(text)
|
||||||
|
boxes.append(box)
|
||||||
|
|
||||||
|
logger.debug(f"PaddleOCR PDF 완료: {pdf_path} | 단어수={len(words)}")
|
||||||
|
return {"image_path": str(pdf_path), "words": words, "boxes": boxes, "box_type": "pixel"}
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_single(image_path: str, client=None) -> dict:
|
||||||
|
with open(image_path, "rb") as f:
|
||||||
|
data = {"group_id": "infer"}
|
||||||
|
if config.SERVICE == "ucar":
|
||||||
|
data["gubun"] = "ocr_with_boxes"
|
||||||
|
|
||||||
|
resp = _client.post(
|
||||||
|
config.PADDLE_OCR_URL,
|
||||||
|
files={"file": (Path(image_path).name, f, "image/jpeg")},
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
ocr_result = resp.json()
|
||||||
|
|
||||||
|
words, boxes = [], []
|
||||||
|
|
||||||
|
# 페이지가 1장이므로 첫 번째 항목만 사용
|
||||||
|
page = ocr_result[0] if ocr_result else {}
|
||||||
|
|
||||||
|
for text, box in zip(page.get("words", []), page.get("boxes", [])):
|
||||||
|
text = text.strip()
|
||||||
|
if not text or not box:
|
||||||
|
continue
|
||||||
|
words.append(text)
|
||||||
|
boxes.append(box)
|
||||||
|
|
||||||
|
result = {"image_path": str(image_path), "words": words, "boxes": boxes, "box_type": "pixel"}
|
||||||
|
|
||||||
|
# OCR 결과 저장
|
||||||
|
# json_path = Path(image_path).with_name(Path(image_path).stem + "_paddle.json")
|
||||||
|
# with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
# json.dump(result, f, ensure_ascii=False, indent=2)
|
||||||
|
# logger.debug(f"OCR 저장: {json_path}")
|
||||||
|
|
||||||
|
logger.debug(f"PaddleOCR 완료: {image_path} | 단어수={len(words)}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ocr_batch(image_paths: list, client=None, max_workers: int = config.OCR_MAX_WORKERS,
|
||||||
|
on_done=None) -> list: # ← on_done 추가
|
||||||
|
results = [None] * len(image_paths)
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_idx = {
|
||||||
|
executor.submit(ocr_single, str(p)): i
|
||||||
|
# executor.submit(ocr_single_pdf, str(p)): i
|
||||||
|
for i, p in enumerate(image_paths)
|
||||||
|
}
|
||||||
|
for future in concurrent.futures.as_completed(future_to_idx):
|
||||||
|
idx = future_to_idx[future]
|
||||||
|
try:
|
||||||
|
results[idx] = future.result()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PaddleOCR 실패: {image_paths[idx]} | {e}")
|
||||||
|
results[idx] = {"image_path": str(image_paths[idx]),
|
||||||
|
"words": [], "boxes": [], "error": str(e)}
|
||||||
|
if on_done: # ← 콜백 호출
|
||||||
|
on_done(idx, results[idx])
|
||||||
|
return results
|
||||||
85
utils/pdf.py
Normal file
85
utils/pdf.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fitz # PyMuPDF
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
import config
|
||||||
|
import concurrent.futures
|
||||||
|
|
||||||
|
|
||||||
|
def is_pdf(filename: str) -> bool:
|
||||||
|
return Path(filename).suffix.lower() == ".pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_to_images(pdf_path: str, dpi: int = config.OCR_DPI,
|
||||||
|
max_pages: int = config.PDF_MAX_PAGES) -> list[Path]:
|
||||||
|
pdf_path = Path(pdf_path)
|
||||||
|
output_dir = pdf_path.parent / pdf_path.stem
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
ext = config.PDF_IMAGE_FORMAT # "jpeg"
|
||||||
|
page_indices = list(range(max_pages))
|
||||||
|
|
||||||
|
# 이미 변환된 이미지가 모두 있으면 스킵
|
||||||
|
cached = [output_dir / f"page_{i+1:03d}.{ext}" for i in page_indices]
|
||||||
|
if all(p.exists() for p in cached):
|
||||||
|
logger.debug(f"PDF 변환 캐시 사용: {pdf_path.name}")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
pdf_path = Path(pdf_path)
|
||||||
|
print(pdf_path.exists())
|
||||||
|
print(pdf_path.stat().st_size)
|
||||||
|
if not pdf_path.exists() or pdf_path.stat().st_size == 0:
|
||||||
|
raise ValueError(f"유효하지 않은 PDF 파일입니다: {pdf_path.name}")
|
||||||
|
|
||||||
|
matrix = fitz.Matrix(dpi / 72, dpi / 72)
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
|
||||||
|
img_paths = []
|
||||||
|
for i in range(min(max_pages, len(doc))):
|
||||||
|
img_path = output_dir / f"page_{i+1:03d}.{ext}"
|
||||||
|
if img_path.exists():
|
||||||
|
logger.debug(f"페이지 캐시 사용: {img_path.name}")
|
||||||
|
else:
|
||||||
|
pixmap = doc[i].get_pixmap(matrix=matrix, alpha=False)
|
||||||
|
|
||||||
|
# 최대 너비 초과 시 비율 유지하며 축소
|
||||||
|
if hasattr(config, 'PDF_MAX_WIDTH') and pixmap.width > config.PDF_MAX_WIDTH:
|
||||||
|
scale = config.PDF_MAX_WIDTH / pixmap.width
|
||||||
|
pixmap = doc[i].get_pixmap(matrix=fitz.Matrix(dpi / 72 * scale, dpi / 72 * scale), alpha=False)
|
||||||
|
|
||||||
|
if ext in ("jpeg", "jpg"):
|
||||||
|
pixmap.save(str(img_path), jpg_quality=config.PDF_JPEG_QUALITY) # JPEG 저장
|
||||||
|
else:
|
||||||
|
pixmap.save(str(img_path))
|
||||||
|
logger.debug(f"PDF 변환: {pdf_path.name} | 페이지 {i+1}")
|
||||||
|
img_paths.append(img_path)
|
||||||
|
|
||||||
|
doc.close()
|
||||||
|
logger.info(f"PDF 변환 완료: {pdf_path.name} | {len(img_paths)}페이지 | {dpi}dpi {ext}")
|
||||||
|
return img_paths
|
||||||
|
|
||||||
|
|
||||||
|
def pdf_to_images_batch(pdf_paths: list[str],
|
||||||
|
max_workers: int = config.OCR_MAX_WORKERS) -> dict[str, list[Path]]:
|
||||||
|
"""
|
||||||
|
여러 PDF를 ThreadPoolExecutor로 병렬 변환
|
||||||
|
반환: {pdf_path: [img_path, ...]}
|
||||||
|
"""
|
||||||
|
results = {}
|
||||||
|
|
||||||
|
def _convert(pdf_path: str):
|
||||||
|
return pdf_path, pdf_to_images(pdf_path)
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
futures = {executor.submit(_convert, p): p for p in pdf_paths}
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
try:
|
||||||
|
path, imgs = future.result()
|
||||||
|
results[path] = imgs
|
||||||
|
except Exception as e:
|
||||||
|
path = futures[future]
|
||||||
|
logger.error(f"PDF 변환 실패: {path} | {e}")
|
||||||
|
results[path] = []
|
||||||
|
|
||||||
|
return results
|
||||||
Reference in New Issue
Block a user