@
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> @
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python
|
||||
"""검출이 화면을 얼마나 덮는지 잰다 — 미검출을 찾는 단계의 자다.
|
||||
|
||||
prompt_stats.py 의 면적은 다각형 넓이의 단순 합이라 겹치면 부풀고 100%를 넘는다.
|
||||
여기서는 마스크를 굽고 합집합을 세므로 "안 잡힌 곳"이 그대로 나온다.
|
||||
|
||||
무라벨% = 100 - (어떤 라벨이든 덮은 화소 / 전체 화소)
|
||||
|
||||
집합별 합집합도 같이 낸다. 집합끼리는 겹칠 수 있으므로 합이 100을 넘을 수 있다.
|
||||
전체 해상도로 구우면 느리므로 --scale 로 줄여서 잰다 (기본 1/4).
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_groups(path):
|
||||
mapping, cur = {}, None
|
||||
for line in open(path, encoding="utf-8"):
|
||||
s = line.strip()
|
||||
if s.startswith("#"):
|
||||
b = s.lstrip("#").strip()
|
||||
cur = b[1:-1].strip() if b.startswith("[") and b.endswith("]") else None
|
||||
elif s and cur:
|
||||
mapping[s] = cur
|
||||
return mapping
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--run-dir", required=True)
|
||||
ap.add_argument("--pattern", default="*_multi.json")
|
||||
ap.add_argument("--groups", required=True)
|
||||
ap.add_argument("--size", required=True, help="원본 영상 'W,H'")
|
||||
ap.add_argument("--scale", type=float, default=0.25)
|
||||
ap.add_argument("--out-json")
|
||||
args = ap.parse_args()
|
||||
|
||||
W, H = (int(x) for x in args.size.split(","))
|
||||
k = args.scale
|
||||
w, h = int(W * k), int(H * k)
|
||||
grp = load_groups(args.groups)
|
||||
files = sorted(glob.glob(os.path.join(args.run_dir, args.pattern)))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"{args.pattern} 없음: {args.run_dir}")
|
||||
|
||||
reps = sorted(set(grp.values())) + ["없음"]
|
||||
per_photo, gsum = [], {g: 0.0 for g in reps}
|
||||
px = w * h
|
||||
t0 = time.time()
|
||||
for i, p in enumerate(files, 1):
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
segs = json.load(fh)["segments"]
|
||||
anym = np.zeros((h, w), np.uint8)
|
||||
masks = {}
|
||||
for s in segs:
|
||||
lb = s["label"]
|
||||
g = lb if lb in grp.values() else grp.get(lb, "없음")
|
||||
poly = (np.asarray(s["points"], np.float32) * k).astype(np.int32)
|
||||
cv2.fillPoly(anym, [poly], 255)
|
||||
m = masks.get(g)
|
||||
if m is None:
|
||||
m = masks[g] = np.zeros((h, w), np.uint8)
|
||||
cv2.fillPoly(m, [poly], 255)
|
||||
cov = np.count_nonzero(anym) / px
|
||||
row = {
|
||||
"photo": os.path.basename(p),
|
||||
"segments": len(segs),
|
||||
"covered": cov,
|
||||
"groups": {g: np.count_nonzero(m) / px for g, m in masks.items()},
|
||||
}
|
||||
for g, v in row["groups"].items():
|
||||
gsum[g] = gsum.get(g, 0.0) + v
|
||||
per_photo.append(row)
|
||||
print(f" [{i}/{len(files)}] {row['photo']:<44} 덮음 {100 * cov:5.1f}% "
|
||||
f"· 무라벨 {100 * (1 - cov):5.1f}% · 폴리곤 {len(segs):,}")
|
||||
|
||||
cov = np.array([r["covered"] for r in per_photo])
|
||||
print(f"\n사진 {len(files)}장 · {time.time() - t0:.0f}초 · 척도 1/{1 / k:.0f} "
|
||||
f"({w}x{h})")
|
||||
print(f"덮음 평균 {100 * cov.mean():.1f}% 중앙 {100 * np.median(cov):.1f}% "
|
||||
f"최소 {100 * cov.min():.1f}% 최대 {100 * cov.max():.1f}%")
|
||||
print(f"무라벨 평균 {100 * (1 - cov.mean()):.1f}% "
|
||||
f"최악 {100 * (1 - cov.min()):.1f}% ({per_photo[int(cov.argmin())]['photo']})")
|
||||
|
||||
print(f"\n집합별 평균 합집합 화면%")
|
||||
for g, v in sorted(gsum.items(), key=lambda t: -t[1]):
|
||||
if v:
|
||||
print(f" {g:<20}{100 * v / len(files):>7.2f}%")
|
||||
|
||||
print(f"\n무라벨 큰 사진 5장")
|
||||
for r in sorted(per_photo, key=lambda r: r["covered"])[:5]:
|
||||
print(f" {r['photo']:<44}{100 * (1 - r['covered']):>6.1f}%")
|
||||
|
||||
if args.out_json:
|
||||
with open(args.out_json, "w", encoding="utf-8") as fh:
|
||||
json.dump({
|
||||
"run_dir": os.path.abspath(args.run_dir),
|
||||
"pattern": args.pattern,
|
||||
"scale": k,
|
||||
"size": [W, H],
|
||||
"photos": len(files),
|
||||
"covered_mean": float(cov.mean()),
|
||||
"covered_min": float(cov.min()),
|
||||
"group_mean": {g: v / len(files) for g, v in gsum.items() if v},
|
||||
"per_photo": per_photo,
|
||||
}, fh, ensure_ascii=False, indent=2)
|
||||
print(f"\n저장: {args.out_json}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,331 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python
|
||||
"""SAM 검출 원본(_multi.json)을 프롬프트별로 집계한다. 병합 전 상태를 본다.
|
||||
|
||||
집합(병합 그룹)을 다시 짜려면 먼저 각 프롬프트가 실제로 무엇을 얼마나
|
||||
집어냈는지 봐야 한다. 이 도구는 판정하지 않는다 — 센다.
|
||||
|
||||
프롬프트 파일의 `# [카테고리]` 머리글로 프롬프트를 묶고, 병합 그룹 파일에서
|
||||
그 프롬프트가 지금 어느 집합에 들어가는지 붙인다. 어느 집합에도 없으면
|
||||
'없음'으로 나온다 — 그 검출은 지금 버려지고 있다는 뜻이다.
|
||||
|
||||
면적은 다각형 넓이(shoelace)다. 겹침을 빼지 않으므로 합이 화면보다 클 수 있다.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
from collections import OrderedDict, defaultdict
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_categories(path):
|
||||
"""프롬프트 파일 -> {프롬프트: 카테고리}, 그리고 비활성(주석) 프롬프트 집합."""
|
||||
cat, off, cur = OrderedDict(), OrderedDict(), None
|
||||
for line in open(path, encoding="utf-8"):
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.startswith("#"):
|
||||
body = s.lstrip("#").strip()
|
||||
if body.startswith("[") and body.endswith("]"):
|
||||
cur = body[1:-1].strip()
|
||||
continue
|
||||
# '# white roof 비활성 — 이유' 형태의 꺼둔 프롬프트.
|
||||
# 한글이 섞였으면 프롬프트가 아니라 설명문이다.
|
||||
name = body.split("비활성")[0].strip().rstrip("—").strip()
|
||||
if (cur and name and name[0].islower() and len(name.split()) <= 5
|
||||
and not any("가" <= ch <= "힣" for ch in name)):
|
||||
off[name] = cur
|
||||
continue
|
||||
if cur:
|
||||
cat[s] = cur
|
||||
return cat, off
|
||||
|
||||
|
||||
def load_groups(path):
|
||||
"""병합 그룹 파일 -> {라벨: 대표}. 주석 처리된 그룹은 비활성이므로 뺀다."""
|
||||
mapping, cur = OrderedDict(), None
|
||||
for line in open(path, encoding="utf-8"):
|
||||
s = line.strip()
|
||||
if not s:
|
||||
continue
|
||||
if s.startswith("#"):
|
||||
body = s.lstrip("#").strip()
|
||||
cur = body[1:-1].strip() if body.startswith("[") and body.endswith("]") else None
|
||||
continue
|
||||
if cur:
|
||||
mapping[s] = cur
|
||||
return mapping
|
||||
|
||||
|
||||
def poly_area(pts):
|
||||
a = np.asarray(pts, dtype=np.float64).reshape(-1, 2)
|
||||
if len(a) < 3:
|
||||
return 0.0
|
||||
x, y = a[:, 0], a[:, 1]
|
||||
return float(abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--run-dir", required=True, help="검출 JSON 폴더")
|
||||
ap.add_argument("--pattern", default="*_multi.json",
|
||||
help="병합 후를 보려면 '*_multi_merged.json'")
|
||||
ap.add_argument("--prompts", required=True, help="프롬프트 목록 txt")
|
||||
ap.add_argument("--groups", required=True, help="병합 그룹 txt")
|
||||
ap.add_argument("--image-size", help="'W,H' — 주면 화면 대비 면적 비율을 낸다")
|
||||
ap.add_argument("--out-json", help="집계 결과 저장 경로")
|
||||
args = ap.parse_args()
|
||||
|
||||
cat, off = load_categories(args.prompts)
|
||||
grp = load_groups(args.groups)
|
||||
reps = set(grp.values())
|
||||
files = sorted(glob.glob(os.path.join(args.run_dir, args.pattern)))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"{args.pattern} 없음: {args.run_dir}")
|
||||
|
||||
npoly = defaultdict(int)
|
||||
nphoto = defaultdict(int)
|
||||
area = defaultdict(float)
|
||||
scores = defaultdict(list)
|
||||
areas = defaultdict(list)
|
||||
for p in files:
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
segs = json.load(fh)["segments"]
|
||||
here = set()
|
||||
for s in segs:
|
||||
lb = s["label"]
|
||||
a = poly_area(s["points"])
|
||||
npoly[lb] += 1
|
||||
area[lb] += a
|
||||
areas[lb].append(a)
|
||||
scores[lb].append(float(s.get("score", 0)))
|
||||
here.add(lb)
|
||||
for lb in here:
|
||||
nphoto[lb] += 1
|
||||
|
||||
img_area = None
|
||||
if args.image_size:
|
||||
w, h = (float(x) for x in args.image_size.split(","))
|
||||
img_area = w * h * len(files)
|
||||
|
||||
total_poly = sum(npoly.values())
|
||||
total_area = sum(area.values())
|
||||
print(f"사진 {len(files)} · 폴리곤 {total_poly:,} · 라벨 {len(npoly)}종")
|
||||
if img_area:
|
||||
print(f"검출 면적 합 {total_area:,.0f} px = 전체 화면의 {100 * total_area / img_area:.1f}% "
|
||||
f"(겹침 미보정)")
|
||||
|
||||
seen = set(npoly)
|
||||
rows = []
|
||||
order = list(dict.fromkeys(list(cat) + sorted(seen - set(cat))))
|
||||
cur_cat = None
|
||||
print(f"\n{'프롬프트':<28}{'집합':<17}{'폴리곤':>8}{'사진':>7}"
|
||||
f"{'면적%':>8}{'중앙면적px':>11}{'점수중앙':>9}")
|
||||
for lb in order:
|
||||
c = cat.get(lb) or ("(병합 결과)" if lb in reps else "(프롬프트 파일 밖)")
|
||||
if c != cur_cat:
|
||||
print(f"-- [{c}]")
|
||||
cur_cat = c
|
||||
n = npoly.get(lb, 0)
|
||||
# 병합 후 파일은 라벨이 곧 집합 대표 이름이다.
|
||||
g = lb if lb in reps else grp.get(lb, "없음")
|
||||
row = {
|
||||
"prompt": lb, "category": c, "group": g, "polygons": n,
|
||||
"photos": nphoto.get(lb, 0),
|
||||
"area_px": area.get(lb, 0.0),
|
||||
"area_share": (area.get(lb, 0.0) / total_area) if total_area else 0.0,
|
||||
"area_median_px": float(np.median(areas[lb])) if n else 0.0,
|
||||
"score_median": float(np.median(scores[lb])) if n else 0.0,
|
||||
}
|
||||
rows.append(row)
|
||||
if n == 0:
|
||||
print(f"{lb:<28}{g:<17}{'0':>8}{'-':>7}{'-':>8}{'-':>11}{'-':>9}")
|
||||
continue
|
||||
print(f"{lb:<28}{g:<17}{n:>8,}{row['photos']:>7}"
|
||||
f"{100 * row['area_share']:>7.2f}%{row['area_median_px']:>11,.0f}"
|
||||
f"{row['score_median']:>9.3f}")
|
||||
|
||||
if off:
|
||||
print(f"\n비활성 프롬프트 {len(off)}개: {', '.join(off)}")
|
||||
|
||||
nogroup = [r for r in rows if r["group"] == "없음" and r["polygons"]]
|
||||
if nogroup:
|
||||
s = sum(r["polygons"] for r in nogroup)
|
||||
print(f"\n집합 없는 검출 {s:,} 폴리곤 "
|
||||
f"({100 * s / total_poly:.1f}%) — 지금 버려진다")
|
||||
for r in sorted(nogroup, key=lambda r: -r["polygons"]):
|
||||
print(f" {r['prompt']:<28}{r['polygons']:>8,}")
|
||||
|
||||
print(f"\n집합별 합계")
|
||||
gsum = defaultdict(lambda: [0, 0.0, 0])
|
||||
for r in rows:
|
||||
v = gsum[r["group"]]
|
||||
v[0] += r["polygons"]
|
||||
v[1] += r["area_px"]
|
||||
v[2] += 1 if r["polygons"] else 0
|
||||
print(f"{'집합':<20}{'프롬프트(검출>0)':>16}{'폴리곤':>10}{'면적%':>9}")
|
||||
for g, (n, a, k) in sorted(gsum.items(), key=lambda t: -t[1][0]):
|
||||
print(f"{g:<20}{k:>16}{n:>10,}{100 * a / total_area if total_area else 0:>8.2f}%")
|
||||
|
||||
if args.out_json:
|
||||
with open(args.out_json, "w", encoding="utf-8") as fh:
|
||||
json.dump({
|
||||
"run_dir": os.path.abspath(args.run_dir),
|
||||
"prompts_file": os.path.abspath(args.prompts),
|
||||
"groups_file": os.path.abspath(args.groups),
|
||||
"photos": len(files),
|
||||
"total_polygons": total_poly,
|
||||
"total_area_px": total_area,
|
||||
"image_area_px": img_area,
|
||||
"rows": rows,
|
||||
}, fh, ensure_ascii=False, indent=2)
|
||||
print(f"\n저장: {args.out_json}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+108
-60
@@ -151,7 +151,7 @@ def predict_tile(model, processor, find_stage_cls, tile_bgr, text_outs,
|
||||
|
||||
|
||||
def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
|
||||
conf, batch, tag):
|
||||
conf, batch, tag, mask_bytes=6 * 10**8):
|
||||
"""타일 목록 전체에 프롬프트 집합을 돌린다. 반환: 전역 좌표 shape 리스트."""
|
||||
chunks = [captions[i:i + batch] for i in range(0, len(captions), batch)]
|
||||
shapes, t0 = [], time.time()
|
||||
@@ -160,7 +160,8 @@ def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
|
||||
for c in chunks]
|
||||
for i, (x0, y0, x1, y1) in enumerate(boxes, 1):
|
||||
got = predict_tile(model, processor, find_stage_cls,
|
||||
image_bgr[y0:y1, x0:x1], text_outs, chunks, conf)
|
||||
image_bgr[y0:y1, x0:x1], text_outs, chunks, conf,
|
||||
mask_bytes)
|
||||
for s in got: # 전역 좌표로 이동
|
||||
s["points"] = [[p[0] + x0, p[1] + y0] for p in s["points"]]
|
||||
shapes.extend(got)
|
||||
@@ -174,20 +175,29 @@ def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--input", required=True)
|
||||
ap.add_argument("--output", default=None, help="기본: 입력명_multi.jpg")
|
||||
ap.add_argument("--input", default=None)
|
||||
ap.add_argument("--output", default=None, help="단일 이미지용 출력 경로")
|
||||
ap.add_argument("--outdir", default=None,
|
||||
help="결과 폴더. --input 이 폴더면 필수 — <이름>_multi.jpg/.json 로 저장")
|
||||
ap.add_argument("--prompts", default="prompts/discovery_v1.txt")
|
||||
ap.add_argument("--wide-prompts", default=None,
|
||||
help="타일보다 큰 대상 목록. 여기 적힌 라벨은 타일 패스에서 빼고 "
|
||||
"이미지 전체를 한 장으로 검출한다 (예: prompts/wide_v1.txt)")
|
||||
ap.add_argument("--wide-in-tiles", action="store_true",
|
||||
help="통짜 프롬프트를 타일 패스에서도 돌린다. 통짜는 대상 하나에 "
|
||||
"인스턴스가 하나만 살아남아 도로 한쪽 차로·논 한 필지만 "
|
||||
"잡히는 일이 있다. 겹치는 결과는 NMS 가 지운다. 느려진다")
|
||||
ap.add_argument("--cols", type=int, default=9)
|
||||
ap.add_argument("--rows", type=int, default=6)
|
||||
ap.add_argument("--overlap", type=float, default=0.10)
|
||||
ap.add_argument("--conf", type=float, default=0.25)
|
||||
ap.add_argument("--nms", type=float, default=0.40)
|
||||
ap.add_argument("--batch", type=int, default=16,
|
||||
help="forward 1회에 넣을 최대 프롬프트 수 (기본 16). "
|
||||
"RTX 3060 12GB 기준 16이 최적 — 32 이상은 VRAM 압박으로 4배 이상 느려짐")
|
||||
ap.add_argument("--device", default="cuda:0",
|
||||
help="사용할 GPU (예: cuda:0, cuda:1). --list-gpus 로 확인")
|
||||
ap.add_argument("--list-gpus", action="store_true", help="GPU 목록만 출력하고 종료")
|
||||
ap.add_argument("--batch", type=int, default=0,
|
||||
help="forward 1회에 넣을 최대 프롬프트 수. 0이면 VRAM에서 자동 결정 "
|
||||
"(12GB→16, 24GB→32). 3060 12GB에서 32는 4배 이상 느려짐")
|
||||
ap.add_argument("--merge", action="store_true", help="같은 라벨 인접 폴리곤 병합")
|
||||
ap.add_argument("--merge-gap", type=int, default=8)
|
||||
ap.add_argument("--checkpoint", default=None,
|
||||
@@ -198,44 +208,67 @@ def main():
|
||||
help="VRAM 사용 상한 비율. 넘으면 느려지는 대신 OOM 에러 (기본 0.92)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
raise SystemExit("CUDA 사용 불가. CPU 폴백하지 않는다 — GPU 환경을 확인하라.")
|
||||
if args.list_gpus:
|
||||
for i in range(torch.cuda.device_count()):
|
||||
p = torch.cuda.get_device_properties(i)
|
||||
print(f" cuda:{i} {p.name} {p.total_memory/2**30:.0f}GB")
|
||||
return
|
||||
if not args.input:
|
||||
raise SystemExit("--input 필요")
|
||||
idx = torch.device(args.device).index or 0
|
||||
if idx >= torch.cuda.device_count():
|
||||
raise SystemExit(f"{args.device} 없음 — 장착 GPU {torch.cuda.device_count()}개. "
|
||||
f"--list-gpus 로 확인하라.")
|
||||
torch.cuda.set_device(idx)
|
||||
# model_builder 가 device == "cuda" 문자열을 정확 비교하므로 "cuda:N" 을 주면
|
||||
# 모델이 CPU에 남는다. 장치 선택은 set_device 로 하고 문자열은 "cuda" 고정.
|
||||
device = "cuda"
|
||||
vram_gb = torch.cuda.get_device_properties(idx).total_memory / 2**30
|
||||
if args.batch <= 0: # VRAM에 맞춰 자동 결정
|
||||
args.batch = 32 if vram_gb >= 20 else 16
|
||||
print(f"GPU cuda:{idx} {torch.cuda.get_device_name(idx)} "
|
||||
f"{vram_gb:.0f}GB batch={args.batch}")
|
||||
|
||||
captions = load_prompts(Path(args.prompts))
|
||||
if not captions:
|
||||
print(f"프롬프트 없음: {args.prompts}")
|
||||
return
|
||||
|
||||
img_path = Path(args.input)
|
||||
image_bgr = cv2.imdecode(np.fromfile(str(img_path), dtype=np.uint8),
|
||||
cv2.IMREAD_COLOR)
|
||||
if image_bgr is None:
|
||||
print(f"이미지 로드 실패: {img_path}")
|
||||
return
|
||||
H, W = image_bgr.shape[:2]
|
||||
src = Path(args.input)
|
||||
if src.is_dir():
|
||||
images = sorted(p for p in src.iterdir()
|
||||
if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".tif"))
|
||||
if not images:
|
||||
raise SystemExit(f"이미지 없음: {src}")
|
||||
if not args.outdir:
|
||||
raise SystemExit("폴더 입력에는 --outdir 필요")
|
||||
else:
|
||||
images = [src]
|
||||
|
||||
wide = load_prompts(Path(args.wide_prompts)) if args.wide_prompts else []
|
||||
fine = [c for c in captions if c not in set(wide)]
|
||||
boxes = tile_boxes(W, H, args.cols, args.rows, args.overlap)
|
||||
fine = captions if args.wide_in_tiles else [c for c in captions if c not in set(wide)]
|
||||
|
||||
def nchunk(n):
|
||||
return (n + args.batch - 1) // args.batch
|
||||
|
||||
print(f"이미지 : {W}×{H}")
|
||||
print(f"타일 : {args.cols}×{args.rows}={len(boxes)}개 overlap={args.overlap*100:.0f}%")
|
||||
print(f"타일 패스 : 프롬프트 {len(fine)}개 → forward {nchunk(len(fine))*len(boxes)}회")
|
||||
print(f"입력 : {len(images)}장")
|
||||
print(f"타일 : {args.cols}×{args.rows}={args.cols*args.rows}개 "
|
||||
f"overlap={args.overlap*100:.0f}%")
|
||||
print(f"타일 패스 : 프롬프트 {len(fine)}개 → forward "
|
||||
f"{nchunk(len(fine))*args.cols*args.rows}회/장")
|
||||
if wide:
|
||||
print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))}회 "
|
||||
f"(타일보다 큰 대상)")
|
||||
print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))}회/장")
|
||||
print(f"conf={args.conf} nms={args.nms}\n")
|
||||
|
||||
from sam3.model_builder import build_sam3_image_model
|
||||
from sam3.model.sam3_image_processor import Sam3Processor
|
||||
from sam3.model.data_misc import FindStage
|
||||
|
||||
if not torch.cuda.is_available():
|
||||
raise SystemExit("CUDA 사용 불가. CPU 폴백하지 않는다 — GPU 환경을 확인하라.")
|
||||
device = "cuda"
|
||||
# VRAM 상한을 걸어 드라이버가 시스템 메모리로 폴백(10배 이상 느려짐)하기 전에
|
||||
# OOM으로 실패하게 만든다
|
||||
torch.cuda.set_per_process_memory_fraction(args.vram_fraction)
|
||||
torch.cuda.set_per_process_memory_fraction(args.vram_fraction, idx)
|
||||
torch.backends.cuda.matmul.allow_tf32 = True
|
||||
torch.backends.cudnn.allow_tf32 = True
|
||||
|
||||
@@ -246,47 +279,62 @@ def main():
|
||||
bpe_path=bpe_path, device=device, checkpoint_path=ckpt_path)
|
||||
processor = Sam3Processor(model, confidence_threshold=args.conf, device=device)
|
||||
|
||||
t0 = time.time()
|
||||
all_shapes = run_pass(model, processor, FindStage, image_bgr, boxes,
|
||||
fine, args.conf, args.batch, "타일")
|
||||
if wide:
|
||||
# 타일보다 큰 대상은 이미지 전체를 한 장으로 보고 검출
|
||||
all_shapes += run_pass(model, processor, FindStage, image_bgr,
|
||||
[(0, 0, W, H)], wide, args.conf, args.batch, "통짜")
|
||||
mask_bytes = int(6 * 10**8 * vram_gb / 12) # VRAM에 비례해 업샘플 청크 조정
|
||||
t_all = time.time()
|
||||
for n, img_path in enumerate(images, 1):
|
||||
print(f"\n===== [{n}/{len(images)}] {img_path.name} =====")
|
||||
image_bgr = cv2.imdecode(np.fromfile(str(img_path), dtype=np.uint8),
|
||||
cv2.IMREAD_COLOR)
|
||||
if image_bgr is None:
|
||||
raise SystemExit(f"이미지 로드 실패: {img_path}")
|
||||
H, W = image_bgr.shape[:2]
|
||||
boxes = tile_boxes(W, H, args.cols, args.rows, args.overlap)
|
||||
|
||||
print(f"\n검출 {len(all_shapes)}개 → NMS(iou={args.nms})...")
|
||||
all_shapes = nms_shapes(all_shapes, iou_thresh=args.nms)
|
||||
print(f"NMS 후 {len(all_shapes)}개")
|
||||
t0 = time.time()
|
||||
all_shapes = run_pass(model, processor, FindStage, image_bgr, boxes,
|
||||
fine, args.conf, args.batch, "타일", mask_bytes)
|
||||
if wide:
|
||||
# 타일보다 큰 대상은 이미지 전체를 한 장으로 보고 검출
|
||||
all_shapes += run_pass(model, processor, FindStage, image_bgr,
|
||||
[(0, 0, W, H)], wide, args.conf, args.batch,
|
||||
"통짜", mask_bytes)
|
||||
|
||||
if args.merge:
|
||||
before = len(all_shapes)
|
||||
all_shapes = merge_adjacent(all_shapes, gap=args.merge_gap)
|
||||
print(f"병합(gap={args.merge_gap}px) {before} → {len(all_shapes)}개")
|
||||
print(f"총 {time.time()-t0:.0f}초\n")
|
||||
print(f" 검출 {len(all_shapes)}개 → NMS(iou={args.nms})...")
|
||||
all_shapes = nms_shapes(all_shapes, iou_thresh=args.nms)
|
||||
print(f" NMS 후 {len(all_shapes)}개")
|
||||
if args.merge:
|
||||
before = len(all_shapes)
|
||||
all_shapes = merge_adjacent(all_shapes, gap=args.merge_gap)
|
||||
print(f" 병합(gap={args.merge_gap}px) {before} → {len(all_shapes)}개")
|
||||
print(f" {time.time()-t0:.0f}초")
|
||||
|
||||
analyze_labels(all_shapes)
|
||||
vis = draw_everything(image_bgr, all_shapes, args.cols, args.rows)
|
||||
h, w = vis.shape[:2]
|
||||
if max(h, w) > 4096:
|
||||
s = 4096 / max(h, w)
|
||||
vis = cv2.resize(vis, (int(w * s), int(h * s)))
|
||||
|
||||
vis = draw_everything(image_bgr, all_shapes, args.cols, args.rows)
|
||||
h, w = vis.shape[:2]
|
||||
if max(h, w) > 4096:
|
||||
s = 4096 / max(h, w)
|
||||
vis = cv2.resize(vis, (int(w * s), int(h * s)))
|
||||
if args.outdir:
|
||||
out_path = Path(args.outdir) / (img_path.stem + "_multi.jpg")
|
||||
elif args.output:
|
||||
out_path = Path(args.output)
|
||||
else:
|
||||
out_path = img_path.parent / (img_path.stem + "_multi.jpg")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 93])[1].tofile(str(out_path))
|
||||
|
||||
out_path = (Path(args.output) if args.output
|
||||
else img_path.parent / (img_path.stem + "_multi.jpg"))
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 93])[1].tofile(str(out_path))
|
||||
print(f"\n저장: {out_path}")
|
||||
json_path = out_path.with_suffix(".json")
|
||||
json_path.write_text(json.dumps({
|
||||
"source_image": str(img_path),
|
||||
"total_segments": len(all_shapes),
|
||||
"label_counts": dict(Counter(s.get("label", "") for s in all_shapes)),
|
||||
"segments": [{"label": s.get("label", ""), "score": s.get("score", 0),
|
||||
"bbox": list(_bbox(s["points"])), "points": s["points"]}
|
||||
for s in all_shapes],
|
||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f" 저장: {out_path.name} / {json_path.name}")
|
||||
|
||||
json_path = out_path.with_suffix(".json")
|
||||
json_path.write_text(json.dumps({
|
||||
"total_segments": len(all_shapes),
|
||||
"label_counts": dict(Counter(s.get("label", "") for s in all_shapes)),
|
||||
"segments": [{"label": s.get("label", ""), "score": s.get("score", 0),
|
||||
"bbox": list(_bbox(s["points"])), "points": s["points"]}
|
||||
for s in all_shapes],
|
||||
}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"라벨 데이터: {json_path}")
|
||||
print(f"\n전체 {len(images)}장 완료 — {time.time()-t_all:.0f}초")
|
||||
|
||||
if sys.platform == "win32": # 완료 알림음
|
||||
import winsound
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env python
|
||||
"""여러 사진의 SAM 마스크를 3D 면에 투표해 면별 라벨을 정한다.
|
||||
|
||||
면 하나가 여러 사진에 보인다 (가림을 뺀 실제 가시 뷰가 중앙 8장). 각 사진에서
|
||||
그 면의 중심 픽셀에 어떤 라벨이 있는지 읽어 모으고 다수결한다.
|
||||
|
||||
득표가 갈리거나 뷰가 모자란 면은 강제로 정하지 않고 0(미결정)으로 남긴다.
|
||||
틀린 라벨보다 없는 라벨이 낫다 — 학습 데이터에서 0은 ignore_index로 빠진다.
|
||||
|
||||
폴리곤을 라벨맵으로 구울 때 순서가 중요하다. 면적 큰 것부터 그려서 작은 것이
|
||||
위에 오게 한다. 안 그러면 도로 폴리곤이 그 위의 차량을 덮어버린다.
|
||||
|
||||
한 면이 보이는데 어느 폴리곤에도 안 걸리는 경우가 있다 (SAM 커버리지가
|
||||
장당 87%). 그건 기권으로 세지 어느 클래스 표도 아니다.
|
||||
|
||||
작은 것은 이 다수결에서 진다. 도로/건물 폴리곤이 크니 차량 위를 스치는 뷰가
|
||||
몇 장만 있어도 득표율이 0.5 아래로 내려간다. 그래서 --rescue-pred 를 주면
|
||||
"투표에는 졌지만 그 클래스 표가 있고 학습된 모델도 같은 클래스라고 하는 면"을
|
||||
되돌린다. 두 증거가 독립이므로 정밀도를 거의 안 깎고 재현을 올린다.
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def log(msg):
|
||||
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
|
||||
|
||||
|
||||
def poly_area(pts):
|
||||
x, y = pts[:, 0], pts[:, 1]
|
||||
return abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1))) / 2
|
||||
|
||||
|
||||
def build_label_map(seg_path, W, H, cls_id):
|
||||
"""병합 JSON -> 라벨맵 (int16, 0=없음). 면적 내림차순으로 그린다."""
|
||||
with open(seg_path, encoding="utf-8") as fh:
|
||||
segs = json.load(fh)["segments"]
|
||||
items = []
|
||||
for s in segs:
|
||||
pts = np.asarray(s["points"], dtype=np.float64).reshape(-1, 2)
|
||||
if len(pts) < 3:
|
||||
continue
|
||||
lab = s["label"]
|
||||
if lab not in cls_id:
|
||||
continue
|
||||
items.append((poly_area(pts), cls_id[lab], pts))
|
||||
items.sort(key=lambda t: -t[0])
|
||||
lm = np.zeros((H, W), dtype=np.int16)
|
||||
for _, cid, pts in items:
|
||||
cv2.fillPoly(lm, [np.round(pts).astype(np.int32)], int(cid))
|
||||
return lm, len(items)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--vis-dir", required=True, help="rasterize_visibility.py 출력")
|
||||
ap.add_argument("--seg-dir", required=True, help="*_merged.json 폴더")
|
||||
ap.add_argument("--out-dir", required=True)
|
||||
ap.add_argument("--min-votes", type=int, default=2,
|
||||
help="이보다 표가 적으면 미결정")
|
||||
ap.add_argument("--min-ratio", type=float, default=0.5,
|
||||
help="1위 득표율이 이보다 낮으면 미결정")
|
||||
ap.add_argument("--rescue-pred",
|
||||
help="PointVector 예측 npy (면 순서 동일). 주면 구제 규칙을 켠다")
|
||||
ap.add_argument("--rescue-pred-classes",
|
||||
help="--rescue-pred 의 클래스 목록 JSON (classes 키 또는 배열)")
|
||||
ap.add_argument("--rescue-class", default="vehicle", help="구제할 클래스 이름")
|
||||
ap.add_argument("--rescue-min-votes", type=int, default=1,
|
||||
help="구제하려면 그 클래스 표가 최소 몇 개여야 하는지")
|
||||
args = ap.parse_args()
|
||||
if args.rescue_pred and not args.rescue_pred_classes:
|
||||
ap.error("--rescue-pred 를 주면 --rescue-pred-classes 도 필요하다")
|
||||
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
with open(os.path.join(args.vis_dir, "visibility.json"), encoding="utf-8") as fh:
|
||||
vmeta = json.load(fh)
|
||||
n_face = vmeta["tile_faces"]
|
||||
W, H = vmeta["image_size"]
|
||||
names = [r["image"] for r in vmeta["per_camera"]]
|
||||
|
||||
vis = np.unpackbits(np.load(os.path.join(args.vis_dir, "visible.npy")),
|
||||
axis=1, count=n_face).astype(bool)
|
||||
pu = np.load(os.path.join(args.vis_dir, "pix_u.npy"))
|
||||
pv = np.load(os.path.join(args.vis_dir, "pix_v.npy"))
|
||||
log(f"면 {n_face:,} · 카메라 {len(names)} · 영상 {W}x{H}")
|
||||
|
||||
# 클래스 목록은 실제로 등장한 라벨에서 만든다. 지어내지 않는다.
|
||||
labels = OrderedDict()
|
||||
seg_files = {}
|
||||
for n in names:
|
||||
p = os.path.join(args.seg_dir, os.path.splitext(n)[0] + "_multi_merged.json")
|
||||
if not os.path.isfile(p):
|
||||
raise FileNotFoundError(f"병합 JSON 없음: {p}")
|
||||
seg_files[n] = p
|
||||
with open(p, encoding="utf-8") as fh:
|
||||
for s in json.load(fh)["segments"]:
|
||||
labels.setdefault(s["label"], 0)
|
||||
labels[s["label"]] += 1
|
||||
cls_names = ["미결정"] + sorted(labels, key=lambda k: -labels[k])
|
||||
cls_id = {c: i for i, c in enumerate(cls_names) if i > 0}
|
||||
n_cls = len(cls_names)
|
||||
log(f"클래스 {n_cls} (0=미결정 포함): {', '.join(cls_names[1:])}")
|
||||
|
||||
votes = np.zeros((n_face, n_cls), dtype=np.int16) # 0열은 안 쓴다
|
||||
seen = np.zeros(n_face, dtype=np.int16)
|
||||
t0 = time.time()
|
||||
for i, n in enumerate(names):
|
||||
lm, npoly = build_label_map(seg_files[n], W, H, cls_id)
|
||||
m = vis[i]
|
||||
idx = np.flatnonzero(m)
|
||||
lab = lm[pv[i][idx], pu[i][idx]]
|
||||
seen[idx] += 1
|
||||
hit = lab > 0
|
||||
np.add.at(votes, (idx[hit], lab[hit]), 1)
|
||||
log(f" [{i + 1}/{len(names)}] {n} 폴리곤 {npoly} · 가시면 {len(idx):,} "
|
||||
f"· 라벨 적중 {int(hit.sum()):,} ({hit.mean():.1%})")
|
||||
del lm
|
||||
|
||||
votes[:, 0] = 0
|
||||
total = votes.sum(axis=1)
|
||||
best = votes.argmax(axis=1).astype(np.int16)
|
||||
top = votes.max(axis=1)
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
ratio = np.where(total > 0, top / np.maximum(total, 1), 0.0)
|
||||
|
||||
pred = np.where((total >= args.min_votes) & (ratio >= args.min_ratio),
|
||||
best, 0).astype(np.int16)
|
||||
|
||||
rescue = None
|
||||
if args.rescue_pred:
|
||||
if args.rescue_class not in cls_id:
|
||||
raise ValueError(f"투표 클래스에 없다: {args.rescue_class}")
|
||||
with open(args.rescue_pred_classes, encoding="utf-8") as fh:
|
||||
j = json.load(fh)
|
||||
pcls = j["classes"] if isinstance(j, dict) else j
|
||||
if args.rescue_class not in pcls:
|
||||
raise ValueError(f"예측 클래스에 없다: {args.rescue_class}")
|
||||
mp = np.load(args.rescue_pred)
|
||||
if len(mp) != n_face:
|
||||
raise ValueError(f"예측 {len(mp):,} != 면 {n_face:,}")
|
||||
rid = cls_id[args.rescue_class]
|
||||
take = ((pred != rid)
|
||||
& (votes[:, rid] >= args.rescue_min_votes)
|
||||
& (mp == pcls.index(args.rescue_class)))
|
||||
was = pred[take].copy()
|
||||
pred[take] = rid
|
||||
u, c = np.unique(was, return_counts=True)
|
||||
rescue = {cls_names[k]: int(v) for k, v in sorted(zip(u, c), key=lambda t: -t[1])}
|
||||
log("")
|
||||
log(f"구제({args.rescue_class}, 표>={args.rescue_min_votes} & 모델 동의): "
|
||||
f"{int(take.sum()):,}면")
|
||||
for k, v in rescue.items():
|
||||
log(f" {k} 에서 {v:,}")
|
||||
|
||||
log("")
|
||||
log(f"투표 {time.time() - t0:.0f}초")
|
||||
log(f"가시 뷰 0인 면 {int((seen == 0).sum()):,} ({(seen == 0).mean():.2%})")
|
||||
log(f"보이지만 라벨 표가 0인 면 {int(((seen > 0) & (total == 0)).sum()):,} "
|
||||
f"({((seen > 0) & (total == 0)).mean():.2%})")
|
||||
log(f"표는 있으나 기준 미달 {int(((total > 0) & (pred == 0)).sum()):,} "
|
||||
f"({((total > 0) & (pred == 0)).mean():.2%})")
|
||||
log("")
|
||||
cnt = np.bincount(pred, minlength=n_cls)
|
||||
log(f"{'id':>3} {'클래스':<20} {'면':>10} {'비율':>7} {'평균득표율':>9}")
|
||||
for c in np.argsort(-cnt):
|
||||
if cnt[c] == 0:
|
||||
continue
|
||||
r = ratio[pred == c].mean() if c > 0 and cnt[c] else float("nan")
|
||||
log(f"{c:>3} {cls_names[c]:<20} {cnt[c]:>10,} {cnt[c] / n_face:>6.2%} "
|
||||
f"{'' if c == 0 else f'{r:>8.1%}'}")
|
||||
|
||||
np.save(os.path.join(args.out_dir, "face_label.npy"), pred)
|
||||
np.save(os.path.join(args.out_dir, "face_votes.npy"), votes)
|
||||
with open(os.path.join(args.out_dir, "vote.json"), "w", encoding="utf-8") as fh:
|
||||
json.dump({
|
||||
"vis_dir": os.path.abspath(args.vis_dir),
|
||||
"seg_dir": os.path.abspath(args.seg_dir),
|
||||
"faces": int(n_face),
|
||||
"cameras": len(names),
|
||||
"classes": cls_names,
|
||||
"min_votes": args.min_votes,
|
||||
"min_ratio": args.min_ratio,
|
||||
"faces_no_view": int((seen == 0).sum()),
|
||||
"faces_no_label_vote": int(((seen > 0) & (total == 0)).sum()),
|
||||
"faces_below_threshold": int(((total > 0) & (pred == 0)).sum()),
|
||||
"counts": {cls_names[c]: int(cnt[c]) for c in range(n_cls) if cnt[c]},
|
||||
"rescue": None if rescue is None else {
|
||||
"pred": os.path.abspath(args.rescue_pred),
|
||||
"cls": args.rescue_class,
|
||||
"min_votes": args.rescue_min_votes,
|
||||
"faces": int(sum(rescue.values())),
|
||||
"from": rescue,
|
||||
},
|
||||
"note": "face_label.npy는 타일 면 인덱스 순서. 0 = 미결정 = ignore_index",
|
||||
}, fh, indent=2, ensure_ascii=False)
|
||||
log(f"기록: {args.out_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user