@
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:
+108
-60
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user