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> @
119 lines
4.5 KiB
Python
119 lines
4.5 KiB
Python
#!/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()
|