Initial commit
This commit is contained in:
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,
|
||||
}
|
||||
Reference in New Issue
Block a user