@
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,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()
|
||||
Reference in New Issue
Block a user