Initial commit

This commit is contained in:
2026-06-15 09:54:01 +09:00
commit ce22ba962d
21 changed files with 3465 additions and 0 deletions

10
routers/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}