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> @
332 lines
15 KiB
Python
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()
|