feat: 단계 비교 뷰어 + factory structure 분리 + gap 스윕

make_stage_viewer.py 신규. 같은 사진 위에서 검출 -> claim -> 병합을
갈아끼워 본다. 사진은 그대로 두고 폴리곤만 바뀌므로 무엇이 달라졌는지
바로 보인다. 바뀐 폴리곤(claim 재배정, 2개 이상 병합)은 빨갛게 강조하고,
커서를 올리면 라벨과 원본 픽셀 좌표, 바뀐 이유를 띄운다.

factory structure 를 [building] 에서 뺐다. 공장 구조물이 일반 건물에
흡수되면 병합 후 되돌릴 수 없다. 집합 밖 라벨은 merge_labels.py 가
그대로 통과시키므로 독립 클래스로 남는다.
16장 실측 24개. 부작용으로 building 이 466 -> 490 으로 늘었다 —
전에는 factory structure 폴리곤이 건물 사이 다리 노릇을 해 여러 건물을
한 덩어리로 잇고 있었다. 라벨 14 -> 15종, 폴리곤 6,370 -> 6,418.

gap 스윕(사진 4장, 폴리곤 3,280): 0px 2,081 · 2px 2,032 · 3px 2,014 ·
8px 1,954 · 16px 1,801 · 32px 1,574. 꺾이는 지점이 없다. 1~3px 차이는
폴리곤 2.4%, 무라벨 0.13pp 로 노이즈 수준이라 2px 를 유지한다.
과다 병합은 16px 부터 보인다 (vehicle 240 -> 229, 32px 에서 209).

status 에서 틀린 이슈 하나를 지웠다. "콘 검출이 버려진다"는 서술은
사실이 아니다 — passthrough 로 독립 클래스로 남는다. 검출 0 프롬프트
목록도 v1 기준 8개에서 확정 설정 16장 기준 12개로 고쳤다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
minsung
2026-09-03 13:19:23 +09:00
parent 833816061b
commit 988049e832
4 changed files with 403 additions and 36 deletions
+352
View File
@@ -0,0 +1,352 @@
#!/usr/bin/env python
"""후처리 단계를 같은 사진 위에서 갈아끼워 비교하는 뷰어.
make_viewer.py 는 한 벌의 결과를 본다. 이건 여러 단계(검출 → claim → 병합)를
한 화면에서 전환한다. 사진은 그대로 두고 폴리곤만 바뀌므로 무엇이 달라졌는지
눈으로 바로 잡힌다.
바뀐 것만 보기: claim 단계는 라벨이 재배정된 폴리곤(claimed_from), 병합 단계는
둘 이상이 합쳐진 폴리곤(merged_from 길이 2 이상)을 강조한다.
python tools/make_stage_viewer.py \
--stage "1. 검출=output/sam3/tune_t64/*_multi.json" \
--stage "2. claim=output/sam3/tune_t64/*_multi_claimed.json" \
--stage "3. 병합=output/sam3/tune_t64/*_multi_merged.json" \
--output docs/pilot/theater_v4/stages.html
"""
import argparse
import colorsys
import glob
import hashlib
import json
import os
import re
from collections import Counter, OrderedDict
import cv2
import numpy as np
def stem_of(path):
"""DJI_..._multi_merged.json → DJI_... (단계 접미사를 벗긴다)"""
s = os.path.basename(path)
return re.sub(r"_multi(_[a-z0-9]+)?\.json$", "", s)
def color_for(label):
h = int(hashlib.md5(label.encode("utf-8")).hexdigest()[:8], 16)
r, g, b = colorsys.hsv_to_rgb((h % 360) / 360, 0.62, 0.98)
return f"#{int(r * 255):02x}{int(g * 255):02x}{int(b * 255):02x}"
HTML = """<title>__TITLE__</title>
<style>
:root{--bg:#0d0f10;--fg:#e6e9ea;--dim:#8b969b;--line:#2c3336;--card:#171c1e;--accent:#4da3ff}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 -apple-system,
"Segoe UI","Malgun Gothic",sans-serif;height:100vh;display:flex;overflow:hidden}
#side{width:250px;flex:none;background:var(--card);border-right:1px solid var(--line);
display:flex;flex-direction:column;overflow:hidden}
#side h1{font-size:13px;margin:0;padding:11px 13px;border-bottom:1px solid var(--line)}
.pager{display:flex;gap:5px;padding:9px 11px;border-bottom:1px solid var(--line)}
.pager select{flex:1;background:#0f1416;color:var(--fg);border:1px solid var(--line);
border-radius:4px;padding:4px;font-size:12px}
button{background:#232a2d;color:var(--fg);border:1px solid var(--line);border-radius:4px;
padding:4px 9px;cursor:pointer;font:inherit;font-size:12px}
button:hover{background:#2e3639}
.stages{padding:10px 11px;border-bottom:1px solid var(--line);display:flex;
flex-direction:column;gap:5px}
.stages button{text-align:left;padding:7px 10px;font-size:13px}
.stages button[aria-selected="true"]{background:var(--accent);border-color:var(--accent);
color:#04121f;font-weight:700}
.stages .n{float:right;opacity:.75;font-variant-numeric:tabular-nums}
.opts{padding:9px 11px;border-bottom:1px solid var(--line);display:flex;
flex-direction:column;gap:5px;color:var(--dim)}
.opts label{display:flex;gap:7px;align-items:center;cursor:pointer}
#legend{overflow-y:auto;padding:7px 11px;flex:1}
#legend .row{display:flex;gap:7px;align-items:center;padding:2px 0}
#legend .sw{width:11px;height:11px;border-radius:2px;flex:none}
#legend .lb{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#legend .ct{color:var(--dim);font-variant-numeric:tabular-nums}
#view{flex:1;position:relative;overflow:hidden;background:#000}
canvas{position:absolute;top:0;left:0}
#hud{position:absolute;left:12px;top:12px;background:#000b;padding:6px 11px;
border-radius:5px;font-size:12px;line-height:1.7;pointer-events:none}
#hud b{color:var(--accent)}
#hint{position:absolute;right:12px;bottom:12px;background:#000b;padding:5px 9px;
border-radius:5px;font-size:11px;color:var(--dim)}
#pick{position:absolute;left:12px;bottom:12px;background:#000c;padding:6px 11px;
border-radius:5px;font-size:12px;line-height:1.6;pointer-events:none;max-width:60%}
#pick .dim{color:var(--dim)}
</style>
<div id="side">
<h1>__TITLE__</h1>
<div class="pager">
<button onclick="go(-1)">◀</button>
<select id="sel" onchange="setPage(+this.value)"></select>
<button onclick="go(1)">▶</button>
</div>
<div class="stages" id="stages"></div>
<div class="opts">
<label><input type="checkbox" id="fill" checked> 면 채우기</label>
<label><input type="checkbox" id="only"> 바뀐 것만</label>
<label><input type="checkbox" id="dim"> 사진 어둡게</label>
<button onclick="fit()">화면 맞춤</button>
</div>
<div id="legend"></div>
</div>
<div id="view"><canvas id="cv"></canvas>
<div id="hud"></div>
<div id="pick"></div>
<div id="hint">1~9 단계 · ←→ 사진 · 휠 확대 · 드래그 이동 · 폴리곤 위에 커서를 올리면 정보</div>
</div>
<script>
const DATA = __DATA__;
const img = new Image();
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const view = document.getElementById('view');
let page = 0, stage = 0, scale = 1, ox = 0, oy = 0;
function curr() { return DATA.pages[page].stages[stage]; }
function changed(s) { return s.c === 1; }
function buildStages() {
const box = document.getElementById('stages');
box.innerHTML = '';
DATA.stages.forEach((name, i) => {
const b = document.createElement('button');
const st = DATA.pages[page].stages[i];
b.innerHTML = name + `<span class="n">${st ? st.p.length.toLocaleString() : '-'}</span>`;
b.setAttribute('aria-selected', i === stage);
b.onclick = () => { stage = i; buildStages(); draw(); };
box.appendChild(b);
});
}
function buildLegend() {
const st = curr(), c = {};
for (const s of st.p) c[s.l] = (c[s.l] || 0) + 1;
const el = document.getElementById('legend');
el.innerHTML = '';
Object.entries(c).sort((a, b) => b[1] - a[1]).forEach(([lb, n]) => {
const d = document.createElement('div');
d.className = 'row';
d.innerHTML = `<span class="sw" style="background:${DATA.colors[lb] || '#888'}"></span>`
+ `<span class="lb">${lb}</span><span class="ct">${n.toLocaleString()}</span>`;
el.appendChild(d);
});
}
function draw() {
const st = curr();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(img, ox, oy, img.width * scale, img.height * scale);
if (document.getElementById('dim').checked) {
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(ox, oy, img.width * scale, img.height * scale);
}
const fill = document.getElementById('fill').checked;
const only = document.getElementById('only').checked;
let nch = 0;
for (const s of st.p) {
const ch = changed(s);
if (ch) nch++;
if (only && !ch) continue;
const col = DATA.colors[s.l] || '#888';
ctx.beginPath();
s.p.forEach((pt, i) => {
const x = ox + pt[0] * scale, y = oy + pt[1] * scale;
i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
ctx.closePath();
if (fill) { ctx.fillStyle = col + (ch ? 'aa' : '55'); ctx.fill(); }
ctx.strokeStyle = ch ? '#ff3b30' : col;
ctx.lineWidth = ch ? 2.5 : 1.2;
ctx.stroke();
}
document.getElementById('hud').innerHTML =
`${DATA.pages[page].name}<br>단계 <b>${DATA.stages[stage]}</b> · 폴리곤 <b>${st.p.length.toLocaleString()}</b>`
+ (nch ? ` · 바뀐 것 <b>${nch.toLocaleString()}</b>` : '');
}
function fit() {
scale = Math.min(view.clientWidth / img.width, view.clientHeight / img.height);
ox = (view.clientWidth - img.width * scale) / 2;
oy = (view.clientHeight - img.height * scale) / 2;
draw();
}
function setPage(i) {
page = (i + DATA.pages.length) % DATA.pages.length;
document.getElementById('sel').value = page;
img.onload = () => { buildStages(); buildLegend(); fit(); };
img.src = DATA.pages[page].img;
}
function go(d) { setPage(page + d); }
function resize() { cv.width = view.clientWidth; cv.height = view.clientHeight; draw(); }
window.addEventListener('resize', () => { resize(); fit(); });
view.addEventListener('wheel', e => {
e.preventDefault();
const f = e.deltaY < 0 ? 1.15 : 1 / 1.15;
ox = e.offsetX - (e.offsetX - ox) * f;
oy = e.offsetY - (e.offsetY - oy) * f;
scale *= f; draw();
}, { passive: false });
let drag = null;
view.addEventListener('mousedown', e => drag = [e.clientX - ox, e.clientY - oy]);
window.addEventListener('mouseup', () => drag = null);
window.addEventListener('mousemove', e => {
if (drag) { ox = e.clientX - drag[0]; oy = e.clientY - drag[1]; draw(); return; }
hover(e);
});
function inside(pt, poly) {
let hit = false;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const [xi, yi] = poly[i], [xj, yj] = poly[j];
if ((yi > pt[1]) !== (yj > pt[1])
&& pt[0] < (xj - xi) * (pt[1] - yi) / (yj - yi) + xi) hit = !hit;
}
return hit;
}
function area(poly) {
let a = 0;
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++)
a += poly[j][0] * poly[i][1] - poly[i][0] * poly[j][1];
return Math.abs(a) / 2;
}
function hover(e) {
const r = view.getBoundingClientRect();
const x = (e.clientX - r.left - ox) / scale, y = (e.clientY - r.top - oy) / scale;
const st = curr();
let best = null, bestA = Infinity;
for (const s of st.p) {
if (!inside([x, y], s.p)) continue;
const a = area(s.p);
if (a < bestA) { best = s; bestA = a; } // 가장 작은 것 = 가장 위
}
const el = document.getElementById('pick');
if (!best) { el.innerHTML = `<span class="dim">커서 (${Math.round(x)}, ${Math.round(y)})</span>`; return; }
let why = '';
if (best.f) why = `<br><span class="dim">원래</span> ${best.f} <span class="dim">→ 뺏김</span>`;
else if (best.m.length) why = `<br><span class="dim">합쳐진 것</span> ${best.m.join(' + ')}`;
el.innerHTML = `<b style="color:${DATA.colors[best.l] || '#888'}">${best.l}</b>`
+ ` <span class="dim">(${Math.round(x)}, ${Math.round(y)})</span>${why}`;
}
window.addEventListener('keydown', e => {
if (e.key === 'ArrowLeft') go(-1);
else if (e.key === 'ArrowRight') go(1);
else if (/^[1-9]$/.test(e.key)) {
const i = +e.key - 1;
if (i < DATA.stages.length) { stage = i; buildStages(); buildLegend(); draw(); }
}
});
['fill', 'only', 'dim'].forEach(id =>
document.getElementById(id).onchange = draw);
const sel = document.getElementById('sel');
DATA.pages.forEach((p, i) => {
const o = document.createElement('option');
o.value = i; o.textContent = `${i + 1}/${DATA.pages.length} ${p.name}`;
sel.appendChild(o);
});
resize();
setPage(0);
</script>"""
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--stage", action="append", required=True, metavar="이름=글롭",
help="단계 하나. 순서대로 여러 번 준다")
ap.add_argument("--output", required=True)
ap.add_argument("--images", help="원본 이미지 폴더 (JSON 의 source_image 가 없을 때)")
ap.add_argument("--max-size", type=int, default=2000)
ap.add_argument("--title", default=None)
args = ap.parse_args()
stages = []
for spec in args.stage:
if "=" not in spec:
raise SystemExit(f"--stage 형식은 '이름=글롭': {spec}")
name, pat = spec.split("=", 1)
files = {stem_of(p): p for p in sorted(glob.glob(pat))}
if not files:
raise SystemExit(f"단계 '{name}' 에 해당하는 JSON 없음: {pat}")
stages.append((name, files))
common = set(stages[0][1])
for _, f in stages[1:]:
common &= set(f)
if not common:
raise SystemExit("모든 단계에 공통으로 있는 사진이 없다")
stems = sorted(common)
print(f"단계 {len(stages)}개 · 공통 사진 {len(stems)}")
out_html = os.path.abspath(args.output)
os.makedirs(os.path.dirname(out_html), exist_ok=True)
pages, colors, counts = [], {}, Counter()
for stem in stems:
img_name = stem + "_view.jpg"
img_path = os.path.join(os.path.dirname(out_html), img_name)
if not os.path.isfile(img_path):
with open(stages[0][1][stem], encoding="utf-8") as fh:
src = json.load(fh).get("source_image")
if (not src or not os.path.isfile(src)) and args.images:
for ext in (".JPG", ".jpg", ".png"):
cand = os.path.join(args.images, stem + ext)
if os.path.isfile(cand):
src = cand
break
if not src or not os.path.isfile(src):
raise SystemExit(f"원본 이미지를 못 찾았다: {stem} (--images 로 지정)")
im = cv2.imdecode(np.fromfile(src, dtype=np.uint8), cv2.IMREAD_COLOR)
hh, ww = im.shape[:2]
k = min(1.0, args.max_size / max(hh, ww))
if k < 1.0:
im = cv2.resize(im, (int(ww * k), int(hh * k)), interpolation=cv2.INTER_AREA)
cv2.imencode(".jpg", im, [cv2.IMWRITE_JPEG_QUALITY, 86])[1].tofile(img_path)
print(f" 이미지 생성 {img_name}")
# 좌표 축척은 원본 기준이므로 이미지 크기에서 되계산한다
im = cv2.imdecode(np.fromfile(img_path, dtype=np.uint8), cv2.IMREAD_COLOR)
vh, vw = im.shape[:2]
page_stages = []
for name, files in stages:
with open(files[stem], encoding="utf-8") as fh:
segs = json.load(fh)["segments"]
if not page_stages: # 첫 단계에서 원본 해상도를 잡는다
mx = max((p[0] for s in segs for p in s["points"]), default=vw)
my = max((p[1] for s in segs for p in s["points"]), default=vh)
k = min(vw / max(mx, 1), vh / max(my, 1))
items = []
for s in segs:
lb = s.get("label", "")
colors.setdefault(lb, color_for(lb))
counts[lb] += 1
ch = 1 if (s.get("claimed_from")
or len(s.get("merged_from", []) or []) > 1) else 0
src = s.get("merged_from") or []
items.append({"l": lb, "c": ch,
"f": s.get("claimed_from") or "",
"m": src if len(src) > 1 else [],
"p": [[round(p[0] * k, 1), round(p[1] * k, 1)]
for p in s["points"]]})
page_stages.append({"p": items})
pages.append({"name": stem, "img": img_name, "stages": page_stages})
title = args.title or os.path.splitext(os.path.basename(out_html))[0]
payload = {"stages": [n for n, _ in stages], "pages": pages, "colors": colors}
with open(out_html, "w", encoding="utf-8") as fh:
fh.write(HTML.replace("__TITLE__", title)
.replace("__DATA__", json.dumps(payload, ensure_ascii=False)))
tot = [sum(len(p["stages"][i]["p"]) for p in pages) for i in range(len(stages))]
print(f"뷰어: {args.output}")
for (name, _), n in zip(stages, tot):
print(f" {name:<16}{n:>8,} 폴리곤")
if __name__ == "__main__":
main()