619 lines
24 KiB
Python
619 lines
24 KiB
Python
"""
|
|
Railway Detection Web UI
|
|
사용법: python tools/web_ui.py
|
|
브라우저: http://localhost:7000
|
|
"""
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import os
|
|
import queue
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
import numpy as np
|
|
|
|
try:
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
|
import uvicorn
|
|
except ImportError:
|
|
print("FastAPI/uvicorn 설치 필요: pip install fastapi uvicorn")
|
|
sys.exit(1)
|
|
|
|
app = FastAPI()
|
|
jobs: dict = {} # job_id -> {queue, status, result_path}
|
|
|
|
ROOT = Path(__file__).parent.parent # 프로젝트 루트
|
|
|
|
|
|
# ── 미리보기 이미지 생성 ───────────────────────────────────────────────────────
|
|
def make_preview(image_path: str, cols: int, rows: int, selected_rows: list) -> str:
|
|
buf = np.fromfile(image_path, dtype=np.uint8)
|
|
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
|
|
if img is None:
|
|
raise ValueError(f"이미지 로드 실패: {image_path}")
|
|
|
|
H, W = img.shape[:2]
|
|
tw = 2000
|
|
scale = tw / W
|
|
vis = cv2.resize(img, (tw, int(H * scale)))
|
|
H_s, W_s = vis.shape[:2]
|
|
|
|
tile_w = W_s / cols
|
|
tile_h = H_s / rows
|
|
|
|
for r in range(rows):
|
|
for c in range(cols):
|
|
idx = r * cols + c + 1
|
|
x0, y0 = int(c * tile_w), int(r * tile_h)
|
|
x1, y1 = min(W_s, int((c + 1) * tile_w)), min(H_s, int((r + 1) * tile_h))
|
|
row_num = r + 1
|
|
|
|
if row_num in selected_rows:
|
|
overlay = vis.copy()
|
|
cv2.rectangle(overlay, (x0, y0), (x1, y1), (0, 200, 0), -1)
|
|
cv2.addWeighted(overlay, 0.25, vis, 0.75, 0, vis)
|
|
cv2.rectangle(vis, (x0, y0), (x1, y1), (0, 255, 0), 2)
|
|
else:
|
|
cv2.rectangle(vis, (x0, y0), (x1, y1), (180, 180, 180), 1)
|
|
|
|
label = str(idx)
|
|
fs = max(0.35, tile_w / 120)
|
|
(tw2, th2), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, fs, 1)
|
|
cx, cy = (x0 + x1) // 2, (y0 + y1) // 2
|
|
cv2.putText(vis, label, (cx - tw2 // 2, cy + th2 // 2),
|
|
cv2.FONT_HERSHEY_SIMPLEX, fs, (255, 255, 255), 1, cv2.LINE_AA)
|
|
|
|
# Row 번호
|
|
y0 = int(r * tile_h)
|
|
row_num = r + 1
|
|
color = (0, 255, 0) if row_num in selected_rows else (160, 160, 160)
|
|
cv2.putText(vis, f"Row{row_num}", (4, y0 + 18),
|
|
cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA)
|
|
|
|
_, enc = cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 88])
|
|
return base64.b64encode(enc).decode("utf-8")
|
|
|
|
|
|
def tiles_from_rows(selected_rows: list, cols: int) -> str:
|
|
if not selected_rows:
|
|
return "all"
|
|
ids = []
|
|
for r in sorted(selected_rows):
|
|
ids.extend(range((r - 1) * cols + 1, r * cols + 1))
|
|
if ids and ids[-1] - ids[0] + 1 == len(ids):
|
|
return f"{ids[0]}-{ids[-1]}"
|
|
return ",".join(str(t) for t in ids)
|
|
|
|
|
|
# ── API ───────────────────────────────────────────────────────────────────────
|
|
@app.post("/api/preview")
|
|
async def api_preview(data: dict):
|
|
try:
|
|
b64 = make_preview(
|
|
data["image_path"],
|
|
data.get("cols", 8),
|
|
data.get("rows", 6),
|
|
data.get("selected_rows", []),
|
|
)
|
|
return {"ok": True, "image": b64}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e)}
|
|
|
|
|
|
@app.post("/api/detect")
|
|
async def api_detect(data: dict):
|
|
job_id = str(uuid.uuid4())[:8]
|
|
q: queue.Queue = queue.Queue()
|
|
jobs[job_id] = {"queue": q, "status": "running", "result_path": None, "proc": None}
|
|
|
|
def run():
|
|
tiles_str = tiles_from_rows(data.get("selected_rows", []), data.get("cols", 8))
|
|
cmd = [
|
|
sys.executable, "-u", str(ROOT / "tools" / "detect_all_objects.py"),
|
|
"--input", data["image_path"],
|
|
"--categories", data.get("categories", "configs/railway_zone.json"),
|
|
"--tiles", tiles_str,
|
|
"--cols", str(data.get("cols", 8)),
|
|
"--rows", str(data.get("rows", 6)),
|
|
"--overlap", str(data.get("overlap", 0.20)),
|
|
"--conf", str(data.get("conf", 0.20)),
|
|
"--workers", str(data.get("workers", 8)),
|
|
"--save-json"
|
|
]
|
|
|
|
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUNBUFFERED": "1"}
|
|
flags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
|
try:
|
|
proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
text=True, encoding="utf-8", errors="replace",
|
|
env=env, cwd=str(ROOT), creationflags=flags,
|
|
)
|
|
jobs[job_id]["proc"] = proc
|
|
for raw in proc.stdout:
|
|
for line in raw.replace("\r", "\n").split("\n"):
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
q.put(("log", line))
|
|
if line.startswith("저장:"):
|
|
jobs[job_id]["result_path"] = line.replace("저장:", "").strip()
|
|
proc.wait()
|
|
if jobs[job_id]["status"] == "stopped":
|
|
q.put(("error", "사용자가 중지했습니다"))
|
|
elif proc.returncode == 0:
|
|
q.put(("done", jobs[job_id]["result_path"] or "완료"))
|
|
jobs[job_id]["status"] = "done"
|
|
else:
|
|
q.put(("error", f"종료코드 {proc.returncode}"))
|
|
jobs[job_id]["status"] = "error"
|
|
except Exception as e:
|
|
q.put(("error", str(e)))
|
|
jobs[job_id]["status"] = "error"
|
|
|
|
threading.Thread(target=run, daemon=True).start()
|
|
return {"job_id": job_id}
|
|
|
|
|
|
@app.post("/api/stop/{job_id}")
|
|
async def api_stop(job_id: str):
|
|
job = jobs.get(job_id)
|
|
if not job:
|
|
return {"ok": False, "error": "not found"}
|
|
job["status"] = "stopped"
|
|
proc = job.get("proc")
|
|
if proc and proc.poll() is None:
|
|
if os.name == "nt":
|
|
subprocess.call(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
else:
|
|
proc.terminate()
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/api/progress/{job_id}")
|
|
async def api_progress(job_id: str):
|
|
if job_id not in jobs:
|
|
return JSONResponse({"error": "not found"}, status_code=404)
|
|
|
|
async def event_gen():
|
|
q = jobs[job_id]["queue"]
|
|
while True:
|
|
try:
|
|
type_, msg = q.get_nowait()
|
|
yield f"data: {json.dumps({'type': type_, 'msg': msg})}\n\n"
|
|
if type_ in ("done", "error"):
|
|
break
|
|
except queue.Empty:
|
|
await asyncio.sleep(0.25)
|
|
yield ": ping\n\n"
|
|
|
|
return StreamingResponse(event_gen(), media_type="text/event-stream",
|
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
|
|
|
|
|
@app.get("/api/result/{job_id}")
|
|
async def api_result(job_id: str):
|
|
job = jobs.get(job_id, {})
|
|
path = job.get("result_path")
|
|
if not path or not Path(path).exists():
|
|
return {"ok": False, "error": "결과 없음"}
|
|
buf = np.fromfile(path, dtype=np.uint8)
|
|
img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
|
|
if img is None:
|
|
return {"ok": False, "error": "이미지 로드 실패"}
|
|
h, w = img.shape[:2]
|
|
if max(h, w) > 3000:
|
|
s = 3000 / max(h, w)
|
|
img = cv2.resize(img, (int(w * s), int(h * s)))
|
|
_, enc = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
|
return {"ok": True, "image": base64.b64encode(enc).decode(), "path": path}
|
|
|
|
|
|
@app.post("/api/open-folder")
|
|
async def api_open_folder(data: dict):
|
|
path = Path(data.get("path", "output/detect"))
|
|
folder = path.parent if path.is_file() else path
|
|
if os.name == "nt":
|
|
os.startfile(str(folder.resolve()))
|
|
return {"ok": True}
|
|
|
|
|
|
# ── HTML ──────────────────────────────────────────────────────────────────────
|
|
@app.get("/", response_class=HTMLResponse)
|
|
async def index():
|
|
return HTML
|
|
|
|
|
|
HTML = r"""<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Railway Detection UI</title>
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:'Segoe UI',sans-serif;background:#1a1a2e;color:#e0e0e0;height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
|
header{background:#16213e;padding:10px 20px;border-bottom:2px solid #0f3460;flex-shrink:0}
|
|
header h1{font-size:1.1rem;color:#e94560;letter-spacing:1px}
|
|
.tabs{display:flex;background:#16213e;border-bottom:1px solid #0f3460;flex-shrink:0}
|
|
.tab-btn{padding:9px 26px;background:none;border:none;color:#888;cursor:pointer;font-size:0.92rem;border-bottom:3px solid transparent;transition:all .2s}
|
|
.tab-btn.active{color:#e94560;border-bottom-color:#e94560}
|
|
.tab-content{display:none;flex:1;overflow:hidden;min-height:0}
|
|
.tab-content.active{display:flex}
|
|
|
|
/* 입력 탭 */
|
|
#tab-input{flex-direction:row}
|
|
.controls{width:320px;min-width:260px;background:#16213e;padding:14px;overflow-y:auto;border-right:1px solid #0f3460;display:flex;flex-direction:column;gap:12px;flex-shrink:0}
|
|
.preview-area{flex:1;background:#0d0d1f;overflow:hidden;position:relative;cursor:grab;min-height:0}
|
|
.preview-area.drag{cursor:grabbing}
|
|
.preview-area img{position:absolute;transform-origin:0 0;user-select:none;pointer-events:none}
|
|
.preview-area .hint{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#444;font-size:.9rem}
|
|
.preview-hud{position:absolute;top:8px;right:10px;font-size:.74rem;color:#555;background:rgba(0,0,0,.35);padding:3px 8px;border-radius:4px;pointer-events:none;z-index:2}
|
|
.preview-fit{position:absolute;top:8px;left:10px;font-size:.78rem;background:#0f3460;color:#e0e0e0;border:1px solid #2a4a7a;padding:4px 10px;border-radius:4px;cursor:pointer;z-index:2}
|
|
.preview-fit:hover{background:#1a5299}
|
|
.fg{display:flex;flex-direction:column;gap:4px}
|
|
.fg>label{font-size:.75rem;color:#888;font-weight:600;text-transform:uppercase;letter-spacing:.5px}
|
|
.path-row{display:flex;gap:6px}
|
|
.path-row input{flex:1;min-width:0}
|
|
input[type=text],input[type=number]{background:#0f3460;border:1px solid #2a4a7a;color:#e0e0e0;padding:6px 9px;border-radius:4px;font-size:.88rem;width:100%}
|
|
input[type=text]:focus,input[type=number]:focus{outline:none;border-color:#e94560}
|
|
.row-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px}
|
|
.row-lbl{display:flex;align-items:center;gap:6px;padding:7px 9px;background:#0f3460;border:1px solid #2a4a7a;border-radius:4px;cursor:pointer;transition:all .15s;font-size:.85rem}
|
|
.row-lbl:hover{border-color:#0c6}
|
|
.row-lbl.on{border-color:#0f6;background:#0d3320;color:#0f6}
|
|
.row-lbl input{width:14px;height:14px;cursor:pointer;accent-color:#0f6}
|
|
.params-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
|
.pi{display:flex;flex-direction:column;gap:3px}
|
|
.pi label{font-size:.72rem;color:#888}
|
|
.btn-load{background:#0f3460;color:#e0e0e0;border:1px solid #2a4a7a;padding:6px 13px;border-radius:4px;cursor:pointer;font-size:.84rem;white-space:nowrap;transition:background .2s}
|
|
.btn-load:hover{background:#1a5299}
|
|
.btn-run{background:#e94560;color:#fff;border:none;padding:11px;border-radius:6px;cursor:pointer;font-size:.98rem;font-weight:700;width:100%;margin-top:2px;transition:background .2s}
|
|
.btn-run:hover{background:#c73652}
|
|
.btn-run:disabled{background:#555;cursor:not-allowed}
|
|
.btn-stop{background:#555;color:#fff;border:none;padding:11px;border-radius:6px;cursor:pointer;font-size:.98rem;font-weight:700;width:100%;margin-top:2px;display:none}
|
|
.btn-stop.visible{display:block}
|
|
.btn-stop:hover{background:#c73652}
|
|
|
|
/* 결과 탭 */
|
|
#tab-result{flex-direction:column}
|
|
.res-toolbar{background:#16213e;padding:9px 14px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #0f3460;flex-shrink:0}
|
|
.btn-act{background:#0f3460;color:#e0e0e0;border:1px solid #2a4a7a;padding:6px 13px;border-radius:4px;cursor:pointer;font-size:.84rem;transition:background .2s}
|
|
.btn-act:hover{background:#1a5299}
|
|
#result-path{font-size:.78rem;color:#888;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}
|
|
.viewer{flex:1;overflow:hidden;cursor:grab;position:relative;background:#0a0a1a;min-height:0}
|
|
.viewer.drag{cursor:grabbing}
|
|
.viewer img{position:absolute;transform-origin:0 0;user-select:none;pointer-events:none}
|
|
.viewer-hint{display:flex;align-items:center;justify-content:center;height:100%;color:#333;font-size:.95rem}
|
|
|
|
/* 진행 */
|
|
.prog-section{background:#16213e;padding:8px 16px;border-top:1px solid #0f3460;flex-shrink:0}
|
|
.prog-track{background:#0a0a1a;border-radius:4px;height:7px;overflow:hidden;margin-bottom:5px}
|
|
.prog-fill{height:100%;background:linear-gradient(90deg,#e94560,#0f6);width:0%;transition:width .4s;border-radius:4px}
|
|
.prog-log{font-size:.76rem;color:#777;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header><h1>🛤 Railway Detection UI</h1></header>
|
|
|
|
<div class="tabs">
|
|
<button class="tab-btn active" id="tbtn-input" onclick="switchTab('input')">입력</button>
|
|
<button class="tab-btn" id="tbtn-result" onclick="switchTab('result')">결과</button>
|
|
</div>
|
|
|
|
<div id="tab-input" class="tab-content active">
|
|
<div class="controls">
|
|
|
|
<div class="fg">
|
|
<label>이미지 경로</label>
|
|
<div class="path-row">
|
|
<input type="text" id="image-path" placeholder="경로 입력 후 Enter 또는 버튼">
|
|
<button class="btn-load" onclick="loadPreview()">미리보기</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fg">
|
|
<label>Row 선택 (철도 구역)</label>
|
|
<div class="row-grid" id="row-grid"></div>
|
|
</div>
|
|
|
|
<div class="fg">
|
|
<label>Categories JSON</label>
|
|
<input type="text" id="categories" value="configs/railway_zone.json">
|
|
</div>
|
|
|
|
<div class="fg">
|
|
<label>파라미터</label>
|
|
<div class="params-grid">
|
|
<div class="pi"><label>Cols</label><input type="number" id="cols" value="8" min="1" onchange="loadPreview()"></div>
|
|
<div class="pi"><label>Rows</label><input type="number" id="rows" value="6" min="1" onchange="initRows();loadPreview()"></div>
|
|
<div class="pi"><label>Overlap</label><input type="number" id="overlap" value="0.20" step="0.05" min="0" max="0.5"></div>
|
|
<div class="pi"><label>Conf</label><input type="number" id="conf" value="0.20" step="0.05" min="0.05" max="1"></div>
|
|
<div class="pi"><label>Workers</label><input type="number" id="workers" value="8" min="1" max="32"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn-run" id="btn-run" onclick="startDetect()">▶ 검출 시작</button>
|
|
<button class="btn-stop" id="btn-stop" onclick="stopDetect()">■ 중지</button>
|
|
|
|
</div>
|
|
|
|
<div class="preview-area" id="preview-area">
|
|
<button class="preview-fit" onclick="resetPreviewZoom()">화면 맞춤</button>
|
|
<span class="preview-hud">휠: 확대/축소 | 드래그: 이동</span>
|
|
<span class="hint" id="preview-hint">이미지 경로 입력 후 미리보기 클릭</span>
|
|
<img id="preview-img" style="display:none">
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tab-result" class="tab-content">
|
|
<div class="res-toolbar">
|
|
<button class="btn-act" onclick="openFolder()">📁 출력 폴더 열기</button>
|
|
<span id="result-path">결과 없음</span>
|
|
<button class="btn-act" onclick="resetZoom()" style="margin-left:auto">화면 맞춤</button>
|
|
<span style="font-size:.76rem;color:#555">휠: 확대/축소 | 드래그: 이동</span>
|
|
</div>
|
|
<div class="viewer" id="viewer">
|
|
<div class="viewer-hint" id="viewer-hint">검출 완료 후 결과가 표시됩니다</div>
|
|
<img id="result-img" style="display:none">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="prog-section">
|
|
<div class="prog-track"><div class="prog-fill" id="prog-fill"></div></div>
|
|
<div class="prog-log" id="prog-log">대기 중</div>
|
|
</div>
|
|
|
|
<script>
|
|
let resultPath = null;
|
|
let sc = 1, tx = 0, ty = 0, dragging = false, sx, sy;
|
|
|
|
// ── 탭 전환
|
|
function switchTab(name) {
|
|
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
|
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
|
|
document.getElementById('tab-' + name).classList.add('active');
|
|
document.getElementById('tbtn-' + name).classList.add('active');
|
|
}
|
|
|
|
// ── Row 체크박스
|
|
function initRows() {
|
|
const numRows = parseInt(document.getElementById('rows').value) || 6;
|
|
const grid = document.getElementById('row-grid');
|
|
grid.innerHTML = '';
|
|
for (let r = 1; r <= numRows; r++) {
|
|
const lbl = document.createElement('label');
|
|
lbl.className = 'row-lbl';
|
|
lbl.innerHTML = `<input type="checkbox" value="${r}" onchange="onRowChange(this)"><span>Row ${r}</span>`;
|
|
grid.appendChild(lbl);
|
|
}
|
|
}
|
|
|
|
function onRowChange(cb) {
|
|
cb.parentElement.classList.toggle('on', cb.checked);
|
|
loadPreview();
|
|
}
|
|
|
|
function getSelectedRows() {
|
|
return Array.from(document.querySelectorAll('#row-grid input:checked')).map(c => parseInt(c.value));
|
|
}
|
|
|
|
// ── 미리보기
|
|
async function loadPreview() {
|
|
const imgPath = document.getElementById('image-path').value.trim();
|
|
if (!imgPath) return;
|
|
const hint = document.getElementById('preview-hint');
|
|
const pimg = document.getElementById('preview-img');
|
|
hint.style.display = 'block';
|
|
hint.style.color = '';
|
|
hint.textContent = '로딩 중...';
|
|
try {
|
|
const res = await post('/api/preview', {
|
|
image_path: imgPath,
|
|
cols: +document.getElementById('cols').value,
|
|
rows: +document.getElementById('rows').value,
|
|
selected_rows: getSelectedRows()
|
|
});
|
|
if (res.ok) {
|
|
const keepZoom = pimg.style.display === 'block';
|
|
pimg.onload = () => { if (!keepZoom) resetPreviewZoom(); else applyPreviewT(); };
|
|
pimg.src = 'data:image/jpeg;base64,' + res.image;
|
|
pimg.style.display = 'block';
|
|
hint.style.display = 'none';
|
|
} else {
|
|
hint.style.color = '#e94560';
|
|
hint.textContent = '오류: ' + res.error;
|
|
}
|
|
} catch(e) {
|
|
hint.style.color = '#e94560';
|
|
hint.textContent = String(e);
|
|
}
|
|
}
|
|
|
|
// ── 검출 시작
|
|
async function startDetect() {
|
|
const rows = getSelectedRows();
|
|
if (!rows.length) { alert('Row를 하나 이상 선택하세요'); return; }
|
|
const imgPath = document.getElementById('image-path').value.trim();
|
|
if (!imgPath) { alert('이미지 경로를 입력하세요'); return; }
|
|
|
|
document.getElementById('btn-run').disabled = true;
|
|
document.getElementById('btn-stop').classList.add('visible');
|
|
document.getElementById('prog-fill').style.width = '0%';
|
|
document.getElementById('prog-log').textContent = '시작 중...';
|
|
|
|
const res = await post('/api/detect', {
|
|
image_path: imgPath,
|
|
selected_rows: rows,
|
|
cols: +document.getElementById('cols').value,
|
|
rows: +document.getElementById('rows').value,
|
|
overlap: +document.getElementById('overlap').value,
|
|
conf: +document.getElementById('conf').value,
|
|
workers: +document.getElementById('workers').value,
|
|
categories: document.getElementById('categories').value.trim()
|
|
});
|
|
listenProgress(res.job_id);
|
|
}
|
|
|
|
// ── SSE 진행
|
|
function listenProgress(jobId) {
|
|
const es = new EventSource(`/api/progress/${jobId}`);
|
|
es.onmessage = async e => {
|
|
const {type, msg} = JSON.parse(e.data);
|
|
document.getElementById('prog-log').textContent = msg || '';
|
|
if (type === 'log') {
|
|
const m = msg.match(/타일\s+(\d+)\/(\d+)/);
|
|
if (m) {
|
|
document.getElementById('prog-fill').style.width =
|
|
Math.round(+m[1] / +m[2] * 100) + '%';
|
|
}
|
|
} else if (type === 'done') {
|
|
es.close();
|
|
document.getElementById('prog-fill').style.width = '100%';
|
|
document.getElementById('prog-log').textContent = '완료! 결과 탭으로 이동합니다.';
|
|
setRunning(false);
|
|
await showResult(jobId);
|
|
} else if (type === 'error') {
|
|
es.close();
|
|
document.getElementById('prog-log').textContent = msg;
|
|
setRunning(false);
|
|
}
|
|
};
|
|
es.onerror = () => { es.close(); setRunning(false); };
|
|
}
|
|
|
|
// ── 결과 표시
|
|
async function showResult(jobId) {
|
|
const res = await fetch(`/api/result/${jobId}`).then(r => r.json());
|
|
if (!res.ok) return;
|
|
resultPath = res.path;
|
|
document.getElementById('result-path').textContent = res.path;
|
|
const img = document.getElementById('result-img');
|
|
img.src = 'data:image/jpeg;base64,' + res.image;
|
|
img.style.display = 'block';
|
|
document.getElementById('viewer-hint').style.display = 'none';
|
|
img.onload = resetZoom;
|
|
switchTab('result');
|
|
}
|
|
|
|
// ── Preview Zoom / Pan
|
|
const previewArea = document.getElementById('preview-area');
|
|
const pimg = document.getElementById('preview-img');
|
|
let psc = 1, ptx = 0, pty = 0, pdragging = false, psx, psy;
|
|
|
|
previewArea.addEventListener('wheel', e => {
|
|
if (pimg.style.display !== 'block') return;
|
|
e.preventDefault();
|
|
const rect = previewArea.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
const f = e.deltaY < 0 ? 1.15 : 0.87;
|
|
const ns = Math.min(Math.max(0.05, psc * f), 40);
|
|
ptx = mx - (mx - ptx) * (ns / psc);
|
|
pty = my - (my - pty) * (ns / psc);
|
|
psc = ns;
|
|
applyPreviewT();
|
|
}, {passive: false});
|
|
|
|
previewArea.addEventListener('mousedown', e => {
|
|
if (pimg.style.display !== 'block') return;
|
|
if (e.target.classList.contains('preview-fit')) return;
|
|
pdragging = true; psx = e.clientX - ptx; psy = e.clientY - pty;
|
|
previewArea.classList.add('drag');
|
|
});
|
|
document.addEventListener('mousemove', e => {
|
|
if (!pdragging) return;
|
|
ptx = e.clientX - psx; pty = e.clientY - psy; applyPreviewT();
|
|
});
|
|
document.addEventListener('mouseup', () => { pdragging = false; previewArea.classList.remove('drag'); });
|
|
|
|
function applyPreviewT() { pimg.style.transform = `translate(${ptx}px,${pty}px) scale(${psc})`; }
|
|
|
|
function resetPreviewZoom() {
|
|
const vw = previewArea.clientWidth, vh = previewArea.clientHeight;
|
|
if (!pimg.naturalWidth) return;
|
|
psc = Math.min(vw / pimg.naturalWidth, vh / pimg.naturalHeight) * 0.98;
|
|
ptx = (vw - pimg.naturalWidth * psc) / 2;
|
|
pty = (vh - pimg.naturalHeight * psc) / 2;
|
|
applyPreviewT();
|
|
}
|
|
|
|
// ── Result Zoom / Pan
|
|
const viewer = document.getElementById('viewer');
|
|
const rimg = document.getElementById('result-img');
|
|
|
|
viewer.addEventListener('wheel', e => {
|
|
e.preventDefault();
|
|
const rect = viewer.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
const f = e.deltaY < 0 ? 1.15 : 0.87;
|
|
const ns = Math.min(Math.max(0.05, sc * f), 40);
|
|
tx = mx - (mx - tx) * (ns / sc);
|
|
ty = my - (my - ty) * (ns / sc);
|
|
sc = ns;
|
|
applyT();
|
|
}, {passive: false});
|
|
|
|
viewer.addEventListener('mousedown', e => {
|
|
dragging = true; sx = e.clientX - tx; sy = e.clientY - ty;
|
|
viewer.classList.add('drag');
|
|
});
|
|
document.addEventListener('mousemove', e => {
|
|
if (!dragging) return;
|
|
tx = e.clientX - sx; ty = e.clientY - sy; applyT();
|
|
});
|
|
document.addEventListener('mouseup', () => { dragging = false; viewer.classList.remove('drag'); });
|
|
|
|
function applyT() { rimg.style.transform = `translate(${tx}px,${ty}px) scale(${sc})`; }
|
|
|
|
function resetZoom() {
|
|
const vw = viewer.clientWidth, vh = viewer.clientHeight;
|
|
if (!rimg.naturalWidth) return;
|
|
sc = Math.min(vw / rimg.naturalWidth, vh / rimg.naturalHeight) * 0.95;
|
|
tx = (vw - rimg.naturalWidth * sc) / 2;
|
|
ty = (vh - rimg.naturalHeight * sc) / 2;
|
|
applyT();
|
|
}
|
|
|
|
// ── 중지
|
|
let currentES = null;
|
|
async function stopDetect() {
|
|
if (!currentJobId) return;
|
|
await post('/api/stop/' + currentJobId, {});
|
|
document.getElementById('prog-log').textContent = '중지 요청됨...';
|
|
}
|
|
|
|
function setRunning(on) {
|
|
document.getElementById('btn-run').disabled = on;
|
|
document.getElementById('btn-stop').classList.toggle('visible', on);
|
|
}
|
|
|
|
// ── 폴더 열기
|
|
async function openFolder() {
|
|
await post('/api/open-folder', {path: resultPath || 'output/detect'});
|
|
}
|
|
|
|
// ── 유틸
|
|
async function post(url, data) {
|
|
const r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(data)});
|
|
return r.json();
|
|
}
|
|
|
|
// ── 초기화
|
|
initRows();
|
|
document.getElementById('image-path').addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') loadPreview();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
print("Railway Detection UI 시작: http://localhost:7000")
|
|
uvicorn.run(app, host="0.0.0.0", port=7000, log_level="warning")
|