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:
minsung
2026-09-01 20:04:45 +09:00
parent 4e5173522d
commit 28bee39196
12 changed files with 1947 additions and 60 deletions
+118
View File
@@ -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()