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
+108 -60
View File
@@ -151,7 +151,7 @@ def predict_tile(model, processor, find_stage_cls, tile_bgr, text_outs,
def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
conf, batch, tag):
conf, batch, tag, mask_bytes=6 * 10**8):
"""타일 목록 전체에 프롬프트 집합을 돌린다. 반환: 전역 좌표 shape 리스트."""
chunks = [captions[i:i + batch] for i in range(0, len(captions), batch)]
shapes, t0 = [], time.time()
@@ -160,7 +160,8 @@ def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
for c in chunks]
for i, (x0, y0, x1, y1) in enumerate(boxes, 1):
got = predict_tile(model, processor, find_stage_cls,
image_bgr[y0:y1, x0:x1], text_outs, chunks, conf)
image_bgr[y0:y1, x0:x1], text_outs, chunks, conf,
mask_bytes)
for s in got: # 전역 좌표로 이동
s["points"] = [[p[0] + x0, p[1] + y0] for p in s["points"]]
shapes.extend(got)
@@ -174,20 +175,29 @@ def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--input", required=True)
ap.add_argument("--output", default=None, help="기본: 입력명_multi.jpg")
ap.add_argument("--input", default=None)
ap.add_argument("--output", default=None, help="단일 이미지용 출력 경로")
ap.add_argument("--outdir", default=None,
help="결과 폴더. --input 이 폴더면 필수 — <이름>_multi.jpg/.json 로 저장")
ap.add_argument("--prompts", default="prompts/discovery_v1.txt")
ap.add_argument("--wide-prompts", default=None,
help="타일보다 큰 대상 목록. 여기 적힌 라벨은 타일 패스에서 빼고 "
"이미지 전체를 한 장으로 검출한다 (예: prompts/wide_v1.txt)")
ap.add_argument("--wide-in-tiles", action="store_true",
help="통짜 프롬프트를 타일 패스에서도 돌린다. 통짜는 대상 하나에 "
"인스턴스가 하나만 살아남아 도로 한쪽 차로·논 한 필지만 "
"잡히는 일이 있다. 겹치는 결과는 NMS 가 지운다. 느려진다")
ap.add_argument("--cols", type=int, default=9)
ap.add_argument("--rows", type=int, default=6)
ap.add_argument("--overlap", type=float, default=0.10)
ap.add_argument("--conf", type=float, default=0.25)
ap.add_argument("--nms", type=float, default=0.40)
ap.add_argument("--batch", type=int, default=16,
help="forward 1회에 넣을 최대 프롬프트 수 (기본 16). "
"RTX 3060 12GB 기준 16이 최적 — 32 이상은 VRAM 압박으로 4배 이상 느려짐")
ap.add_argument("--device", default="cuda:0",
help="사용할 GPU (예: cuda:0, cuda:1). --list-gpus 로 확인")
ap.add_argument("--list-gpus", action="store_true", help="GPU 목록만 출력하고 종료")
ap.add_argument("--batch", type=int, default=0,
help="forward 1회에 넣을 최대 프롬프트 수. 0이면 VRAM에서 자동 결정 "
"(12GB→16, 24GB→32). 3060 12GB에서 32는 4배 이상 느려짐")
ap.add_argument("--merge", action="store_true", help="같은 라벨 인접 폴리곤 병합")
ap.add_argument("--merge-gap", type=int, default=8)
ap.add_argument("--checkpoint", default=None,
@@ -198,44 +208,67 @@ def main():
help="VRAM 사용 상한 비율. 넘으면 느려지는 대신 OOM 에러 (기본 0.92)")
args = ap.parse_args()
if not torch.cuda.is_available():
raise SystemExit("CUDA 사용 불가. CPU 폴백하지 않는다 — GPU 환경을 확인하라.")
if args.list_gpus:
for i in range(torch.cuda.device_count()):
p = torch.cuda.get_device_properties(i)
print(f" cuda:{i} {p.name} {p.total_memory/2**30:.0f}GB")
return
if not args.input:
raise SystemExit("--input 필요")
idx = torch.device(args.device).index or 0
if idx >= torch.cuda.device_count():
raise SystemExit(f"{args.device} 없음 — 장착 GPU {torch.cuda.device_count()}개. "
f"--list-gpus 로 확인하라.")
torch.cuda.set_device(idx)
# model_builder 가 device == "cuda" 문자열을 정확 비교하므로 "cuda:N" 을 주면
# 모델이 CPU에 남는다. 장치 선택은 set_device 로 하고 문자열은 "cuda" 고정.
device = "cuda"
vram_gb = torch.cuda.get_device_properties(idx).total_memory / 2**30
if args.batch <= 0: # VRAM에 맞춰 자동 결정
args.batch = 32 if vram_gb >= 20 else 16
print(f"GPU cuda:{idx} {torch.cuda.get_device_name(idx)} "
f"{vram_gb:.0f}GB batch={args.batch}")
captions = load_prompts(Path(args.prompts))
if not captions:
print(f"프롬프트 없음: {args.prompts}")
return
img_path = Path(args.input)
image_bgr = cv2.imdecode(np.fromfile(str(img_path), dtype=np.uint8),
cv2.IMREAD_COLOR)
if image_bgr is None:
print(f"이미지 로드 실패: {img_path}")
return
H, W = image_bgr.shape[:2]
src = Path(args.input)
if src.is_dir():
images = sorted(p for p in src.iterdir()
if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".tif"))
if not images:
raise SystemExit(f"이미지 없음: {src}")
if not args.outdir:
raise SystemExit("폴더 입력에는 --outdir 필요")
else:
images = [src]
wide = load_prompts(Path(args.wide_prompts)) if args.wide_prompts else []
fine = [c for c in captions if c not in set(wide)]
boxes = tile_boxes(W, H, args.cols, args.rows, args.overlap)
fine = captions if args.wide_in_tiles else [c for c in captions if c not in set(wide)]
def nchunk(n):
return (n + args.batch - 1) // args.batch
print(f"이미지 : {W}×{H}")
print(f"타일 : {args.cols}×{args.rows}={len(boxes)}개 overlap={args.overlap*100:.0f}%")
print(f"타일 패스 : 프롬프트 {len(fine)}개 → forward {nchunk(len(fine))*len(boxes)}")
print(f"입력 : {len(images)}")
print(f"타일 : {args.cols}×{args.rows}={args.cols*args.rows}"
f"overlap={args.overlap*100:.0f}%")
print(f"타일 패스 : 프롬프트 {len(fine)}개 → forward "
f"{nchunk(len(fine))*args.cols*args.rows}회/장")
if wide:
print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))} "
f"(타일보다 큰 대상)")
print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))}/장")
print(f"conf={args.conf} nms={args.nms}\n")
from sam3.model_builder import build_sam3_image_model
from sam3.model.sam3_image_processor import Sam3Processor
from sam3.model.data_misc import FindStage
if not torch.cuda.is_available():
raise SystemExit("CUDA 사용 불가. CPU 폴백하지 않는다 — GPU 환경을 확인하라.")
device = "cuda"
# VRAM 상한을 걸어 드라이버가 시스템 메모리로 폴백(10배 이상 느려짐)하기 전에
# OOM으로 실패하게 만든다
torch.cuda.set_per_process_memory_fraction(args.vram_fraction)
torch.cuda.set_per_process_memory_fraction(args.vram_fraction, idx)
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
@@ -246,47 +279,62 @@ def main():
bpe_path=bpe_path, device=device, checkpoint_path=ckpt_path)
processor = Sam3Processor(model, confidence_threshold=args.conf, device=device)
t0 = time.time()
all_shapes = run_pass(model, processor, FindStage, image_bgr, boxes,
fine, args.conf, args.batch, "타일")
if wide:
# 타일보다 큰 대상은 이미지 전체를 한 장으로 보고 검출
all_shapes += run_pass(model, processor, FindStage, image_bgr,
[(0, 0, W, H)], wide, args.conf, args.batch, "통짜")
mask_bytes = int(6 * 10**8 * vram_gb / 12) # VRAM에 비례해 업샘플 청크 조정
t_all = time.time()
for n, img_path in enumerate(images, 1):
print(f"\n===== [{n}/{len(images)}] {img_path.name} =====")
image_bgr = cv2.imdecode(np.fromfile(str(img_path), dtype=np.uint8),
cv2.IMREAD_COLOR)
if image_bgr is None:
raise SystemExit(f"이미지 로드 실패: {img_path}")
H, W = image_bgr.shape[:2]
boxes = tile_boxes(W, H, args.cols, args.rows, args.overlap)
print(f"\n검출 {len(all_shapes)}개 → NMS(iou={args.nms})...")
all_shapes = nms_shapes(all_shapes, iou_thresh=args.nms)
print(f"NMS 후 {len(all_shapes)}")
t0 = time.time()
all_shapes = run_pass(model, processor, FindStage, image_bgr, boxes,
fine, args.conf, args.batch, "타일", mask_bytes)
if wide:
# 타일보다 큰 대상은 이미지 전체를 한 장으로 보고 검출
all_shapes += run_pass(model, processor, FindStage, image_bgr,
[(0, 0, W, H)], wide, args.conf, args.batch,
"통짜", mask_bytes)
if args.merge:
before = len(all_shapes)
all_shapes = merge_adjacent(all_shapes, gap=args.merge_gap)
print(f"병합(gap={args.merge_gap}px) {before}{len(all_shapes)}")
print(f"{time.time()-t0:.0f}\n")
print(f" 검출 {len(all_shapes)}개 → NMS(iou={args.nms})...")
all_shapes = nms_shapes(all_shapes, iou_thresh=args.nms)
print(f" NMS 후 {len(all_shapes)}")
if args.merge:
before = len(all_shapes)
all_shapes = merge_adjacent(all_shapes, gap=args.merge_gap)
print(f" 병합(gap={args.merge_gap}px) {before}{len(all_shapes)}")
print(f" {time.time()-t0:.0f}")
analyze_labels(all_shapes)
vis = draw_everything(image_bgr, all_shapes, args.cols, args.rows)
h, w = vis.shape[:2]
if max(h, w) > 4096:
s = 4096 / max(h, w)
vis = cv2.resize(vis, (int(w * s), int(h * s)))
vis = draw_everything(image_bgr, all_shapes, args.cols, args.rows)
h, w = vis.shape[:2]
if max(h, w) > 4096:
s = 4096 / max(h, w)
vis = cv2.resize(vis, (int(w * s), int(h * s)))
if args.outdir:
out_path = Path(args.outdir) / (img_path.stem + "_multi.jpg")
elif args.output:
out_path = Path(args.output)
else:
out_path = img_path.parent / (img_path.stem + "_multi.jpg")
out_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 93])[1].tofile(str(out_path))
out_path = (Path(args.output) if args.output
else img_path.parent / (img_path.stem + "_multi.jpg"))
out_path.parent.mkdir(parents=True, exist_ok=True)
cv2.imencode(".jpg", vis, [cv2.IMWRITE_JPEG_QUALITY, 93])[1].tofile(str(out_path))
print(f"\n저장: {out_path}")
json_path = out_path.with_suffix(".json")
json_path.write_text(json.dumps({
"source_image": str(img_path),
"total_segments": len(all_shapes),
"label_counts": dict(Counter(s.get("label", "") for s in all_shapes)),
"segments": [{"label": s.get("label", ""), "score": s.get("score", 0),
"bbox": list(_bbox(s["points"])), "points": s["points"]}
for s in all_shapes],
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" 저장: {out_path.name} / {json_path.name}")
json_path = out_path.with_suffix(".json")
json_path.write_text(json.dumps({
"total_segments": len(all_shapes),
"label_counts": dict(Counter(s.get("label", "") for s in all_shapes)),
"segments": [{"label": s.get("label", ""), "score": s.get("score", 0),
"bbox": list(_bbox(s["points"])), "points": s["points"]}
for s in all_shapes],
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"라벨 데이터: {json_path}")
print(f"\n전체 {len(images)}장 완료 — {time.time()-t_all:.0f}")
if sys.platform == "win32": # 완료 알림음
import winsound