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

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,
}