Files
railway-client/tools/make_merge_monitor.py
minsung 28bee39196 @
feat: SAM 3.1 검출 커버리지 튜닝 — 무라벨 16% → 3.6%

2D 검출 단계에서 미검출을 없애는 작업. 지표는 "무라벨 화면%" —
마스크 합집합으로 재서 겹침을 뺀 값이다. 다각형 넓이 단순 합(105%)은
겹침 때문에 미검출을 못 잡아낸다.

원인은 프롬프트가 아니라 통짜 패스였다. wide_v1.txt 의 21개 프롬프트는
8192x5460 을 1x1 로 넣어 대상 하나에 인스턴스가 하나만 살아남았다.
도로는 한쪽 차로만, 논은 한 필지만 잡혔다.

BlockYYX 실측 (사진 8장):
  기준선  무라벨 평균 21.6% (69장 전체로는 16.1%, 최악 52.1%)
  튜닝후  무라벨 평균  3.6% (최대 5.0%)

기여도 (0654 기준):
  통짜 -> 타일 패스     -36.7pp
  conf 0.25 -> 0.10      -5.4pp
  타일 4x3 -> 6x4        -4.6pp
  프롬프트 추가 9개      -0.5pp   <- 거의 기여 없음

비용은 140 -> 548초/장 (3.9배).

sam3_multi_prompt.py: --wide-in-tiles 플래그 추가. 통짜 프롬프트를
타일 패스에서도 돌린다. 겹치는 결과는 NMS 가 지운다.

신규 도구:
  coverage_stats.py     마스크 합집합으로 무라벨% 측정
  prompt_stats.py       카테고리-프롬프트별 검출 집계
  make_merge_monitor.py 모니터 HTML 생성 (수치는 집계 JSON 에서만)

vote_labels.py 에 --rescue-pred 추가 (투표에 졌지만 표가 있고 모델도
동의하는 면을 되돌린다). 3D 작업이 다른 담당으로 넘어가 중단된 상태.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-09-01 20:04:45 +09:00

332 lines
15 KiB
Python

#!/usr/bin/env python
"""SAM 후처리 작업 모니터 HTML 을 만든다.
수치는 전부 prompt_stats.py 의 집계 JSON 에서 온다. 이 파일은 숫자를 만들지
않는다. 단계·이슈·설정 같은 사람이 쓰는 것만 상태 JSON 에서 읽는다.
python tools/make_merge_monitor.py \
--stats output/sam3/YYX_run/_prompt_stats.json \
--status configs/sam_merge_status.json \
--out docs/pilot/sam_merge.html
"""
import argparse
import html
import json
import os
import time
from collections import defaultdict
STATE = {
"done": ("완료", "#1a7f4b", "#e3f5ea"),
"running": ("진행", "#8a5a00", "#fdf0d5"),
"blocked": ("막힘", "#a12a2a", "#fbe6e6"),
"todo": ("대기", "#5a5f66", "#eceef0"),
"open": ("미해결", "#a12a2a", "#fbe6e6"),
"closed": ("해결", "#1a7f4b", "#e3f5ea"),
"rejected": ("기각", "#5a5f66", "#eceef0"),
}
def esc(s):
return html.escape(str(s))
def badge(state):
label, fg, bg = STATE.get(state, (state, "#5a5f66", "#eceef0"))
return (f'<span class="badge" style="color:{fg};background:{bg}">'
f'{esc(label)}</span>')
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--stats", required=True, help="병합 전 집계 JSON")
ap.add_argument("--status", required=True, help="단계·이슈 JSON")
ap.add_argument("--stats-after", help="병합 후 집계 JSON (--pattern '*_merged.json')")
ap.add_argument("--coverage", help="coverage_stats.py 집계 JSON (무라벨 지표)")
ap.add_argument("--theater", nargs="*", default=[], metavar="제목=경로",
help="공정을 보여줄 뷰어 HTML. 경로는 --out 기준 상대경로")
ap.add_argument("--out", required=True)
args = ap.parse_args()
with open(args.stats, encoding="utf-8") as fh:
st = json.load(fh)
with open(args.status, encoding="utf-8") as fh:
sv = json.load(fh)
af = None
if args.stats_after:
with open(args.stats_after, encoding="utf-8") as fh:
af = json.load(fh)
cv = None
if args.coverage:
with open(args.coverage, encoding="utf-8") as fh:
cv = json.load(fh)
stages = []
for t in args.theater:
if "=" not in t:
raise SystemExit(f"--theater 형식은 '제목=경로': {t}")
name, path = t.split("=", 1)
if not os.path.isfile(os.path.join(os.path.dirname(os.path.abspath(args.out)), path)):
raise SystemExit(f"뷰어 없음: {path}")
stages.append((name, path))
rows = st["rows"]
gsum = defaultdict(lambda: {"poly": 0, "area": 0.0, "prompts": 0, "used": 0})
for r in rows:
g = gsum[r["group"]]
g["poly"] += r["polygons"]
g["area"] += r["area_px"]
g["prompts"] += 1
g["used"] += 1 if r["polygons"] else 0
tot_area = st["total_area_px"] or 1
tot_poly = st["total_polygons"] or 1
parts = []
A = parts.append
A(f'<title>{esc(sv["title"])}</title>')
A("""<style>
:root{--bg:#fbfbfa;--fg:#1d1f21;--dim:#6b7076;--line:#e2e4e6;--card:#fff;--accent:#2f6f9f}
:root:not([data-theme="light"]){}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
--bg:#16181a;--fg:#e6e8ea;--dim:#9aa1a8;--line:#2c3034;--card:#1d2023;--accent:#79b8e8}}
:root[data-theme="dark"]{--bg:#16181a;--fg:#e6e8ea;--dim:#9aa1a8;--line:#2c3034;--card:#1d2023;--accent:#79b8e8}
body{background:var(--bg);color:var(--fg);font:14px/1.55 -apple-system,"Segoe UI",
"Malgun Gothic",sans-serif;margin:0;padding:28px 22px 60px}
.wrap{max-width:1180px;margin:0 auto}
h1{font-size:21px;margin:0 0 4px}
h2{font-size:15px;margin:30px 0 10px;padding-bottom:6px;border-bottom:1px solid var(--line)}
.sub{color:var(--dim);margin:0 0 2px}
.badge{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;
font-weight:600;white-space:nowrap}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.card{background:var(--card);border:1px solid var(--line);border-radius:8px;padding:11px 13px}
.card .k{color:var(--dim);font-size:12px}
.card .v{font-size:19px;font-weight:600;margin-top:2px}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{padding:5px 9px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}
th:first-child,td:first-child,th:nth-child(2),td:nth-child(2),
th:nth-child(3),td:nth-child(3){text-align:left}
th{background:var(--card);position:sticky;top:0;cursor:pointer;font-weight:600;
color:var(--dim);border-bottom:2px solid var(--line)}
tbody tr:hover{background:var(--card)}
tr.zero td{color:var(--dim)}
tr.nogroup td:nth-child(3){color:#c0392b;font-weight:600}
.scroll{overflow-x:auto;max-height:640px;overflow-y:auto;border:1px solid var(--line);
border-radius:8px}
.bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;min-width:60px}
.bar i{display:block;height:100%;background:var(--accent)}
.step{display:flex;gap:11px;align-items:flex-start;padding:9px 0;border-bottom:1px solid var(--line)}
.step .id{font-family:ui-monospace,Menlo,Consolas,monospace;color:var(--dim);min-width:30px}
.step .d{color:var(--dim);font-size:13px}
.issue{padding:9px 0;border-bottom:1px solid var(--line);display:flex;gap:11px}
.par{display:grid;grid-template-columns:max-content 1fr;gap:3px 16px;font-size:13px}
.par b{color:var(--dim);font-weight:500}
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px}
.controls{display:flex;gap:10px;flex-wrap:wrap;margin:0 0 10px}
.tabs{display:flex;gap:6px;margin:0 0 10px;flex-wrap:wrap}
.tab{background:var(--card);border:1px solid var(--line);border-radius:6px;
padding:6px 13px;cursor:pointer;font:inherit;font-size:13px;color:var(--fg)}
.tab[aria-selected="true"]{background:var(--accent);border-color:var(--accent);color:#fff}
.stage{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--card)}
.stage iframe{display:block;width:100%;height:620px;border:0}
.flow{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;
color:var(--dim);font-size:13px}
.flow b{color:var(--fg);font-weight:600}
.flow .ar{color:var(--accent);font-weight:700}
.d-up{color:#1a7f4b}.d-dn{color:#c0392b}
select,input{background:var(--card);color:var(--fg);border:1px solid var(--line);
border-radius:6px;padding:5px 8px;font:inherit;font-size:13px}
</style>""")
A('<div class="wrap">')
A(f'<h1>{esc(sv["title"])}</h1>')
A(f'<p class="sub">{esc(sv.get("note", ""))}</p>')
A(f'<p class="sub" style="font-size:12px">생성 {time.strftime("%Y-%m-%d %H:%M")} · '
f'집계 <code>{esc(os.path.basename(st["run_dir"]))}</code></p>')
A('<h2>실측</h2><div class="cards">')
cards = [
("사진", f'{st["photos"]:,}'),
("폴리곤", f'{st["total_polygons"]:,}'),
("라벨 종류", f'{sum(1 for r in rows if r["polygons"]):,} / {len(rows):,}'),
("집합", f'{sum(1 for g in gsum if g != "없음"):,}'),
("검출 면적 / 화면", f'{100 * tot_area / st["image_area_px"]:.1f}%'
if st.get("image_area_px") else "-"),
("집합 없는 폴리곤",
f'{gsum["없음"]["poly"]:,}' if "없음" in gsum else "0"),
]
if af:
cards.insert(2, ("병합 후 폴리곤",
f'{af["total_polygons"]:,} '
f'(-{100 * (tot_poly - af["total_polygons"]) / tot_poly:.0f}%)'))
for k, v in cards:
A(f'<div class="card"><div class="k">{esc(k)}</div><div class="v">{esc(v)}</div></div>')
A('</div>')
if cv:
per = sorted(cv["per_photo"], key=lambda r: r["covered"])
un = [100 * (1 - r["covered"]) for r in per]
A('<h2>미검출 — 무라벨 화면%</h2>')
A(f'<p class="sub" style="font-size:12px">마스크 합집합 기준(겹침 제거), '
f'축척 1/{1 / cv["scale"]:.0f}. 목표 5% 이하.</p>')
A('<div class="cards">')
for k, v in [("무라벨 평균", f'{100 * (1 - cv["covered_mean"]):.1f}%'),
("무라벨 중앙", f'{sorted(un)[len(un) // 2]:.1f}%'),
("최악", f'{100 * (1 - cv["covered_min"]):.1f}%'),
("5% 이하 사진", f'{sum(1 for u in un if u <= 5)} / {len(un)}')]:
A(f'<div class="card"><div class="k">{esc(k)}</div>'
f'<div class="v">{esc(v)}</div></div>')
A('</div>')
A('<div class="scroll" style="max-height:280px;margin-top:10px"><table><thead><tr>'
'<th>사진</th><th>폴리곤</th><th>무라벨%</th><th></th></tr></thead><tbody>')
for r in per:
u = 100 * (1 - r["covered"])
A(f'<tr><td>{esc(r["photo"].replace("_multi.json", ""))}</td>'
f'<td data-v="{r["segments"]}">{r["segments"]:,}</td>'
f'<td data-v="{u:.3f}">{u:.1f}%</td>'
f'<td><div class="bar"><i style="width:{min(u, 100):.1f}%;'
f'background:{"#c0392b" if u > 5 else "#1a7f4b"}"></i></div></td></tr>')
A('</tbody></table></div>')
if stages:
A('<h2>공정 보기 — 같은 사진, 단계별</h2>')
if af:
A(f'<div class="flow"><b>SAM 검출 {st["total_polygons"]:,} 폴리곤</b>'
f'<span>({sum(1 for r in st["rows"] if r["polygons"])} 라벨)</span>'
f'<span class="ar">→ 집합 병합 →</span>'
f'<b>{af["total_polygons"]:,} 폴리곤</b>'
f'<span>({sum(1 for r in af["rows"] if r["polygons"])} 집합)</span>'
f'<span>· 사진 {st["photos"]}장 기준</span></div>')
A('<div class="tabs">')
for i, (name, _) in enumerate(stages):
A(f'<button class="tab" role="tab" data-i="{i}" '
f'aria-selected="{"true" if i == 0 else "false"}">{esc(name)}</button>')
A('</div>')
for i, (_, path) in enumerate(stages):
A(f'<div class="stage" data-i="{i}"{"" if i == 0 else " hidden"}>'
f'<iframe src="{esc(path)}" loading="lazy" title="stage{i}"></iframe></div>')
A('<p class="sub" style="font-size:12px">왼쪽 목록에서 라벨을 켜고 끈다. '
'휠 확대, 드래그 이동. 새 창: '
+ " · ".join(f'<a href="{esc(p)}">{esc(n)}</a>' for n, p in stages) + '</p>')
A('<h2>단계</h2>')
for s in sv.get("steps", []):
A(f'<div class="step"><span class="id">{esc(s["id"])}</span>{badge(s["state"])}'
f'<div><div>{esc(s["name"])}</div>'
f'<div class="d">{esc(s.get("detail", ""))}</div></div></div>')
A('<h2>이슈 · 기각된 시도</h2>')
for i in sv.get("issues", []):
A(f'<div class="issue">{badge(i["state"])}<div>{esc(i["text"])}</div></div>')
apoly, aarea = defaultdict(int), defaultdict(float)
if af:
for r in af["rows"]:
apoly[r["group"]] += r["polygons"]
aarea[r["group"]] += r["area_px"]
atot = af["total_area_px"] or 1
A('<h2>집합별 합계</h2><div class="scroll"><table><thead><tr>'
'<th>집합</th><th>프롬프트(검출>0/전체)</th><th>폴리곤</th><th>폴리곤%</th>'
'<th>면적%</th>')
if af:
A('<th>병합 후 폴리곤</th><th>줄어든 비율</th><th>병합 후 면적%</th>')
A('<th></th></tr></thead><tbody>')
for g, v in sorted(gsum.items(), key=lambda t: -t[1]["poly"]):
share = 100 * v["area"] / tot_area
A(f'<tr><td>{esc(g)}</td><td>{v["used"]} / {v["prompts"]}</td>'
f'<td>{v["poly"]:,}</td><td>{100 * v["poly"] / tot_poly:.1f}%</td>'
f'<td>{share:.2f}%</td>')
if af:
n = apoly.get(g, 0)
drop = 100 * (v["poly"] - n) / v["poly"] if v["poly"] else 0
cls = "d-up" if n < v["poly"] else ""
A(f'<td>{n:,}</td><td class="{cls}">{drop:.0f}%</td>'
f'<td>{100 * aarea.get(g, 0.0) / atot:.2f}%</td>')
A(f'<td><div class="bar"><i style="width:{min(share, 100):.1f}%"></i></div>'
f'</td></tr>')
A('</tbody></table></div>')
if af:
A('<p class="sub" style="font-size:12px">병합 후 면적%는 병합 후 총검출면적 기준. '
'겹침을 빼지 않은 다각형 넓이 합이다.</p>')
cats = sorted({r["category"] for r in rows})
groups = sorted({r["group"] for r in rows})
A('<h2>카테고리 · 프롬프트별 검출</h2>')
A('<div class="controls">'
'<select id="fcat"><option value="">카테고리 전체</option>'
+ "".join(f'<option>{esc(c)}</option>' for c in cats) + '</select>'
'<select id="fgrp"><option value="">집합 전체</option>'
+ "".join(f'<option>{esc(g)}</option>' for g in groups) + '</select>'
'<input id="fq" placeholder="프롬프트 검색">'
'<label style="color:var(--dim)"><input type="checkbox" id="fzero"> 검출 0 만</label>'
'</div>')
A('<div class="scroll"><table id="t"><thead><tr>'
'<th>프롬프트</th><th>카테고리</th><th>집합</th><th>폴리곤</th><th>사진</th>'
'<th>면적%</th><th>중앙 면적 px</th><th>점수 중앙</th></tr></thead><tbody>')
for r in rows:
cls = []
if not r["polygons"]:
cls.append("zero")
if r["group"] == "없음":
cls.append("nogroup")
c = f' class="{" ".join(cls)}"' if cls else ""
dash = "-"
A(f'<tr{c} data-cat="{esc(r["category"])}" data-grp="{esc(r["group"])}">'
f'<td>{esc(r["prompt"])}</td><td>{esc(r["category"])}</td>'
f'<td>{esc(r["group"])}</td>'
f'<td data-v="{r["polygons"]}">{r["polygons"]:,}</td>'
f'<td data-v="{r["photos"]}">{r["photos"] or dash}</td>'
f'<td data-v="{r["area_share"]:.8f}">'
f'{100 * r["area_share"]:.2f}%</td>'
f'<td data-v="{r["area_median_px"]:.1f}">'
f'{r["area_median_px"]:,.0f}</td>'
f'<td data-v="{r["score_median"]:.4f}">'
f'{r["score_median"]:.3f}</td></tr>')
A('</tbody></table></div>')
if sv.get("params"):
A('<h2>설정</h2><div class="par">')
for k, v in sv["params"].items():
A(f'<b>{esc(k)}</b><span><code>{esc(v)}</code></span>')
A('</div>')
A('</div>')
A("""<script>
const tb=document.querySelector('#t tbody');
document.querySelectorAll('#t th').forEach((th,i)=>{th.onclick=()=>{
const rows=[...tb.rows], asc=th.dataset.asc!=='1';
th.dataset.asc=asc?'1':'0';
rows.sort((a,b)=>{
const x=a.cells[i].dataset.v, y=b.cells[i].dataset.v;
if(x!==undefined&&y!==undefined) return (asc?1:-1)*(parseFloat(x)-parseFloat(y));
return (asc?1:-1)*a.cells[i].textContent.localeCompare(b.cells[i].textContent,'ko');
});
rows.forEach(r=>tb.appendChild(r));
};});
function apply(){
const c=fcat.value,g=fgrp.value,q=fq.value.toLowerCase(),z=fzero.checked;
[...tb.rows].forEach(r=>{
const ok=(!c||r.dataset.cat===c)&&(!g||r.dataset.grp===g)
&&(!q||r.cells[0].textContent.toLowerCase().includes(q))
&&(!z||r.classList.contains('zero'));
r.hidden=!ok;
});
}
[fcat,fgrp,fzero].forEach(e=>e.onchange=apply); fq.oninput=apply;
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
document.querySelectorAll('.tab').forEach(x=>x.setAttribute('aria-selected',x===b));
document.querySelectorAll('.stage').forEach(s=>s.hidden=s.dataset.i!==b.dataset.i);
});
</script>""")
os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
with open(args.out, "w", encoding="utf-8") as fh:
fh.write("\n".join(parts))
print(f"저장: {args.out} ({os.path.getsize(args.out):,} bytes)")
if __name__ == "__main__":
main()