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> @
190 lines
7.4 KiB
Python
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()
|