Files
railway-client/tools/sam3_multi_prompt.py
T
minsung 4e5173522d @
feat: SAM3.1 다중 프롬프트 배치 검출 및 라벨 병합 후처리

sam3_multi_prompt.py: 프롬프트 N개를 forward 1회에 배치 처리한다.
서버(프롬프트당 forward 1회) 대비 forward 횟수가 1/N로 줄고, 이미지
임베딩과 텍스트 인코딩을 타일 전체에서 재사용한다. RTX 3060 12GB
기준 배치 16이 최적(32 이상은 VRAM 압박으로 4배 이상 느려짐).

타일보다 큰 대상(숲, 도로, 주차장)은 타일 경계에서 잘려 사각형
마스크가 되므로 --wide-prompts 로 지정해 이미지 전체를 한 장으로
처리한다.

merge_labels.py: 검출 결과 후처리. 같은 병합 그룹에 속한 라벨끼리
외곽선이 gap px 이내로 인접하면 하나로 합치고 대표 라벨을 붙인다
(building + building rooftop + blue roof -> building). 병합 전
claim 규칙으로 소유권을 재배정해, 차량 위에 잡힌 *roof 폴리곤이
건물이 아니라 차량에 합쳐지도록 한다.

make_viewer.py: 라벨별 on/off 가능한 단독 HTML 뷰어 생성.
프롬프트 파일의 그룹 주석 기준으로 라벨을 묶어 나열하고, 줌/팬과
선택 상태 저장을 지원한다.

cut_tiles.py: R{행}C{열} 격자 오버레이 + 원본 배율 타일 저장.

전역 규칙 반영:
- CUDA 사용 불가 시 CPU로 폴백하지 않고 즉시 에러
- set_per_process_memory_fraction 으로 드라이버의 시스템 메모리
  폴백(10배 이상 느려짐) 전에 OOM 발생
- 체크포인트/BPE 경로 하드코딩 제거. CLI 인자 -> 환경변수 ->
  sam31server 설정파일 순으로 해결

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@
2026-08-20 10:05:23 +09:00

299 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
SAM3.1 다중 프롬프트 배치 세그멘테이션 (in-process, 서버 불필요)
프롬프트 N개를 forward 1회에 함께 처리한다. 서버 방식(프롬프트당 forward 1회)과
달리 이미지 임베딩·텍스트 인코딩을 재사용하므로 프롬프트 수가 많을수록 유리하다.
사용법:
D:/MYCLAUDE_PROJECT/sam31server/.venv/Scripts/python.exe tools/sam3_multi_prompt.py \
--input "data/everyimage/xxx.JPG" \
--prompts prompts/discovery_v1.txt \
--cols 9 --rows 6 --conf 0.25 --merge
사전 조건: SAM3 서버는 내려둘 것 (GPU에 모델 2벌 올라감)
"""
import argparse
import json
import os
import sys
import time
from collections import Counter
from pathlib import Path
import cv2
import numpy as np
import torch
from PIL import Image
SERVER_PATH = Path(os.environ.get(
"SAM31SERVER_DIR",
Path(__file__).resolve().parent.parent.parent / "sam31server"))
if not SERVER_PATH.is_dir():
raise SystemExit(f"sam31server 없음: {SERVER_PATH} (SAM31SERVER_DIR 로 지정)")
sys.path.insert(0, str(SERVER_PATH))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sam3_everything_explore import ( # noqa: E402 후처리·시각화 재사용
_bbox, nms_shapes, merge_adjacent, draw_everything, analyze_labels,
)
def resolve_model_paths(server_dir: Path, ckpt_arg=None, bpe_arg=None):
"""체크포인트·BPE 경로 해결: CLI 인자 → 환경변수 → sam31server 설정파일."""
ckpt, bpe = ckpt_arg or os.environ.get("SAM3_CHECKPOINT"), \
bpe_arg or os.environ.get("SAM3_BPE")
if not (ckpt and bpe):
cfg = server_dir / "configs" / "auto_labeling" / "segment_anything_3.yaml"
if not cfg.is_file():
raise SystemExit(f"설정 파일 없음: {cfg} (--checkpoint / --bpe 로 직접 지정)")
import yaml
params = yaml.safe_load(cfg.read_text(encoding="utf-8")).get("params", {})
ckpt = ckpt or params.get("model_path")
bpe = bpe or params.get("bpe_path")
for name, p in (("체크포인트", ckpt), ("BPE 사전", bpe)):
if not p or not Path(p).is_file():
raise SystemExit(f"{name} 파일 없음: {p}")
return ckpt, bpe
def load_prompts(path: Path) -> list:
"""# 주석과 빈 줄을 제외한 프롬프트 목록."""
lines = path.read_text(encoding="utf-8").splitlines()
return [ln.strip() for ln in lines
if ln.strip() and not ln.strip().startswith("#")]
def tile_boxes(W, H, cols, rows, overlap):
"""(x0, y0, x1, y1) 타일 목록. overlap 비율만큼 확장."""
bw, bh = W / cols, H / rows
px, py = int(bw * overlap), int(bh * overlap)
boxes = []
for r in range(rows):
for c in range(cols):
boxes.append((
max(0, int(c * bw) - px), max(0, int(r * bh) - py),
min(W, int((c + 1) * bw) + px), min(H, int((r + 1) * bh) + py),
))
return boxes
def masks_to_polygons(masks, epsilon_factor=0.001):
"""[K,h,w] bool 텐서 → 폴리곤 리스트 (없으면 None)."""
polys = []
for m in masks:
mu = m.astype(np.uint8)
contours, _ = cv2.findContours(mu, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
if not contours:
polys.append(None)
continue
largest = max(contours, key=cv2.contourArea)
eps = epsilon_factor * cv2.arcLength(largest, True)
approx = cv2.approxPolyDP(largest, eps, True)
polys.append(approx if len(approx) >= 3 else None)
return polys
def predict_tile(model, processor, find_stage_cls, tile_bgr, text_outs,
chunks, conf, mask_bytes=6 * 10**8):
"""타일 1장에 프롬프트 전체를 배치로 물어본다. 반환: shape dict 리스트."""
th, tw = tile_bgr.shape[:2]
# 업샘플 한 번에 올릴 마스크 수 — 타일이 클수록 줄인다 (통짜 패스 OOM 방지)
mask_batch = max(1, min(32, mask_bytes // (th * tw * 4)))
state = processor.set_image(Image.fromarray(tile_bgr[:, :, ::-1]))
shapes = []
for captions, text_out in zip(chunks, text_outs):
n = len(captions)
state["backbone_out"].update(text_out)
find = find_stage_cls(
img_ids=torch.zeros(n, dtype=torch.long, device=model.device),
text_ids=torch.arange(n, dtype=torch.long, device=model.device),
input_boxes=None, input_boxes_mask=None, input_boxes_label=None,
input_points=None, input_points_mask=None,
)
out = model.forward_grounding(
backbone_out=state["backbone_out"],
find_input=find,
geometric_prompt=model._get_dummy_prompt(num_prompts=n),
find_target=None,
)
probs = out["pred_logits"].sigmoid() # [n,Q,1]
presence = out["presence_logit_dec"].sigmoid().unsqueeze(1)
probs = (probs * presence).squeeze(-1) # [n,Q]
keep = probs > conf
idx = keep.nonzero(as_tuple=False)
if idx.numel() == 0:
continue
sel_masks = out["pred_masks"][keep] # [K,mh,mw]
sel_scores = probs[keep]
# 마스크를 타일 크기로 키운 뒤 외곽선을 뽑는다. 원본 해상도(252px 정도)에서
# 뽑으면 좌표가 격자에 박혀 계단 현상이 생긴다. 메모리 때문에 조각내서 처리.
for s in range(0, sel_masks.shape[0], mask_batch):
chunk = sel_masks[s:s + mask_batch].unsqueeze(1).float()
up = torch.nn.functional.interpolate(
chunk, (th, tw), mode="bilinear", align_corners=False)
binary = (up > 0).squeeze(1).cpu().numpy().astype(np.uint8)
for k, poly in enumerate(masks_to_polygons(binary)):
if poly is None:
continue
b = int(idx[s + k, 0])
shapes.append({
"label": captions[b],
"score": float(sel_scores[s + k]),
"shape_type": "polygon",
"points": [[float(p[0][0]), float(p[0][1])] for p in poly],
})
del chunk, up
del out
return shapes
def run_pass(model, processor, find_stage_cls, image_bgr, boxes, captions,
conf, batch, tag):
"""타일 목록 전체에 프롬프트 집합을 돌린다. 반환: 전역 좌표 shape 리스트."""
chunks = [captions[i:i + batch] for i in range(0, len(captions), batch)]
shapes, t0 = [], time.time()
with torch.inference_mode():
text_outs = [model.backbone.forward_text(c, device=model.device)
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)
for s in got: # 전역 좌표로 이동
s["points"] = [[p[0] + x0, p[1] + y0] for p in s["points"]]
shapes.extend(got)
torch.cuda.empty_cache() # 타일 간 VRAM 누적 방지
print(f" [{tag}] 타일 {i}/{len(boxes)} +{len(got)}개 "
f"(누적 {len(shapes)}, {time.time()-t0:.0f}초, "
f"VRAM {torch.cuda.memory_reserved()/2**30:.1f}GB)")
return shapes
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("--prompts", default="prompts/discovery_v1.txt")
ap.add_argument("--wide-prompts", default=None,
help="타일보다 큰 대상 목록. 여기 적힌 라벨은 타일 패스에서 빼고 "
"이미지 전체를 한 장으로 검출한다 (예: prompts/wide_v1.txt)")
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("--merge", action="store_true", help="같은 라벨 인접 폴리곤 병합")
ap.add_argument("--merge-gap", type=int, default=8)
ap.add_argument("--checkpoint", default=None,
help="SAM3.1 체크포인트 (기본: 환경변수 SAM3_CHECKPOINT → 서버 설정파일)")
ap.add_argument("--bpe", default=None,
help="BPE 사전 (기본: 환경변수 SAM3_BPE → 서버 설정파일)")
ap.add_argument("--vram-fraction", type=float, default=0.92,
help="VRAM 사용 상한 비율. 넘으면 느려지는 대신 OOM 에러 (기본 0.92)")
args = ap.parse_args()
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]
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)
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)}회")
if wide:
print(f"통짜 패스 : 프롬프트 {len(wide)}개 → forward {nchunk(len(wide))}회 "
f"(타일보다 큰 대상)")
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.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
ckpt_path, bpe_path = resolve_model_paths(SERVER_PATH, args.checkpoint, args.bpe)
print(f"체크포인트: {ckpt_path}")
print("SAM3.1 로딩...")
model = build_sam3_image_model(
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, "통짜")
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)}개")
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")
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)))
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({
"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}")
if sys.platform == "win32": # 완료 알림음
import winsound
winsound.Beep(880, 150)
winsound.Beep(1175, 250)
if __name__ == "__main__":
main()