Initial commit
This commit is contained in:
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