Files
railway-client/tools/prompt_stats.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

190 lines
7.4 KiB
Python

#!/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()