Files
samgeo3-lab/scripts/multi_prompt_segment.py
minsung 34a885bcf8 Initial publish: SamGeo3 multi-prompt segmentation lab.
Scripts, prompt JSON tiers, usage docs, and README. Input images
(data/) and segmentation outputs (output/) are gitignored.
2026-07-15 16:11:59 +09:00

571 lines
19 KiB
Python

"""Run SamGeo3 text segmentation for multiple prompts (one model load).
Prompt sets are Grok + Gemini vision merged lists (see prompts/*.json).
Usage (from project root, venv active):
python scripts/multi_prompt_segment.py
python scripts/multi_prompt_segment.py --tier compact
python scripts/multi_prompt_segment.py --tier A_high
python scripts/multi_prompt_segment.py --tier all
python scripts/multi_prompt_segment.py --prompts "building,solar panel,tree"
python scripts/multi_prompt_segment.py --image data/DJI_20260306100802_0016.JPG --tier compact
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_PROMPTS_JSON = ROOT / "prompts" / "dji_20260306_0016.json"
DEFAULT_IMAGE = ROOT / "data" / "DJI_20260306100802_0016.JPG"
# fallback to sample folder outside lab if data copy missing
SAMPLE_FALLBACK = Path(
r"D:\MYCLAUDE_PROJECT\segment-geospatial\sample\DJI_20260306100802_0016.JPG"
)
def safe_name(prompt: str) -> str:
return "".join(c if c.isalnum() or c in "-_" else "_" for c in prompt.strip())
def _dedupe(prompts: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for p in prompts:
p = p.strip()
if p and p not in seen:
seen.add(p)
out.append(p)
return out
def _merge_prompt_lists(*lists: list[str]) -> list[str]:
merged: list[str] = []
for lst in lists:
merged.extend(lst or [])
return _dedupe(merged)
def load_prompt_config(path: Path, _stack: list[Path] | None = None) -> dict[str, Any]:
"""Load JSON; if 'extends' is set, inherit base (image-1) and apply scene delta.
Extension model (image N expands image 1, not a separate system):
- base tiers / compact / gap / improved / gap2* are kept
- child may add scene_delta.prompts and optional tier/set additions
- child image path overrides base image
"""
path = path.resolve()
stack = _stack or []
if path in stack:
raise ValueError(f"Circular extends: {path}")
stack = stack + [path]
with open(path, encoding="utf-8") as f:
cfg = json.load(f)
extends = cfg.get("extends")
if not extends:
return cfg
base_path = Path(extends)
if not base_path.is_absolute():
# relative to project root first, then to this file's dir
cand = (ROOT / extends).resolve()
if not cand.is_file():
cand = (path.parent / extends).resolve()
base_path = cand
if not base_path.is_file():
raise FileNotFoundError(f"extends not found: {extends} (from {path})")
base = load_prompt_config(base_path, stack)
# Start from base, then overlay child metadata / image
merged: dict[str, Any] = json.loads(json.dumps(base)) # deep copy
merged["id"] = cfg.get("id", base.get("id"))
merged["extends"] = str(extends)
merged["extends_resolved"] = str(base_path)
for key in (
"image",
"image_abs_hint",
"sources",
"scene_notes_ko",
"recommended",
"expected_difficulty",
):
if key in cfg:
merged[key] = cfg[key]
# scene-only additions (this image expands base)
delta = list(cfg.get("scene_delta", {}).get("prompts", []))
merged["scene_delta"] = cfg.get("scene_delta", {"prompts": delta})
# Merge tiers: base list + child additions (if child redefines tier fully, use add only
# unless replace_tiers=true)
child_tiers = cfg.get("tiers", {})
if cfg.get("replace_tiers"):
merged["tiers"] = child_tiers
else:
base_tiers = dict(merged.get("tiers", {}))
for tname, tblock in child_tiers.items():
add = list(tblock.get("prompts", []))
if tname in base_tiers:
base_tiers[tname] = {
**base_tiers[tname],
"description": tblock.get(
"description", base_tiers[tname].get("description", "")
),
"prompts": _merge_prompt_lists(
base_tiers[tname].get("prompts", []), add
),
}
else:
base_tiers[tname] = tblock
# also append scene_delta into A_high by default for visibility
if delta and "A_high" in base_tiers:
base_tiers["A_high"] = {
**base_tiers["A_high"],
"prompts": _merge_prompt_lists(
base_tiers["A_high"].get("prompts", []), delta
),
}
merged["tiers"] = base_tiers
# Named sets: inherit base, extend with delta / child prompts
for set_name in (
"compact",
"gap",
"improved",
"gap2",
"gap2_core",
"compact_plus",
):
base_set = merged.get(set_name, {})
base_prompts = list(base_set.get("prompts", [])) if isinstance(base_set, dict) else []
child_set = cfg.get(set_name)
if child_set is None:
# default: base set + scene_delta for compact / improved
if set_name in ("compact", "improved", "compact_plus") and delta:
merged[set_name] = {
**(base_set if isinstance(base_set, dict) else {}),
"description": (
f"extends base {set_name} + scene_delta "
f"({cfg.get('id', 'child')})"
),
"prompts": _merge_prompt_lists(base_prompts, delta),
}
continue
if child_set.get("mode") == "replace":
merged[set_name] = child_set
else:
# default mode: append (base + child + optional delta)
extra = list(child_set.get("prompts", []))
use_delta = child_set.get("include_scene_delta", True)
parts = [base_prompts, extra]
if use_delta:
parts.append(delta)
merged[set_name] = {
**(base_set if isinstance(base_set, dict) else {}),
**{k: v for k, v in child_set.items() if k != "prompts"},
"description": child_set.get(
"description",
f"extends base {set_name} + additions",
),
"prompts": _merge_prompt_lists(*parts),
}
# If child has no compact at all, still attach delta
if "compact" not in cfg and delta:
bp = list(base.get("compact", {}).get("prompts", []))
merged["compact"] = {
**base.get("compact", {}),
"description": "base compact + scene_delta",
"prompts": _merge_prompt_lists(bp, delta),
}
merged["inheritance_ko"] = (
"1번 이미지 프롬프트 체계를 상속하고, 이 장면 전용 객체만 추가 확장."
)
return merged
def resolve_prompts(
cfg: dict[str, Any],
tier: str,
extra_prompts: list[str] | None,
) -> list[str]:
if extra_prompts:
return [p.strip() for p in extra_prompts if p.strip()]
# scene_delta only (this image's new objects vs base)
if tier in ("delta", "scene_delta", "new"):
return list(cfg.get("scene_delta", {}).get("prompts", []))
# top-level named sets (compact / gap / improved / gap2...)
if tier in cfg and isinstance(cfg[tier], dict) and "prompts" in cfg[tier]:
return list(cfg[tier]["prompts"])
if tier == "all":
seen: set[str] = set()
out: list[str] = []
for key in (
"A_high",
"B_facility",
"C_detail",
"D_rail_domain",
"E_missed",
):
block = cfg.get("tiers", {}).get(key, {})
for p in block.get("prompts", []):
if p not in seen:
seen.add(p)
out.append(p)
# include scene_delta
for p in cfg.get("scene_delta", {}).get("prompts", []):
if p not in seen:
seen.add(p)
out.append(p)
return out
if tier in cfg.get("tiers", {}):
return list(cfg["tiers"][tier]["prompts"])
# allow short aliases
aliases = {
"a": "A_high",
"A": "A_high",
"b": "B_facility",
"B": "B_facility",
"c": "C_detail",
"C": "C_detail",
"d": "D_rail_domain",
"D": "D_rail_domain",
"e": "E_missed",
"E": "E_missed",
"missed": "E_missed",
"gap_fill": "gap",
}
if tier in aliases:
key = aliases[tier]
if key in cfg.get("tiers", {}):
return list(cfg["tiers"][key]["prompts"])
if key in cfg and "prompts" in cfg[key]:
return list(cfg[key]["prompts"])
raise ValueError(
f"Unknown tier '{tier}'. Use compact | gap | gap2 | improved | all | "
f"delta | A_high | B_facility | C_detail | D_rail_domain | E_missed | or --prompts"
)
def resolve_image(path: Path | None, cfg: dict[str, Any]) -> Path:
candidates: list[Path] = []
if path is not None:
candidates.append(path)
candidates.append(ROOT / cfg.get("image", "data/DJI_20260306100802_0016.JPG"))
candidates.append(DEFAULT_IMAGE)
candidates.append(SAMPLE_FALLBACK)
abs_hint = cfg.get("image_abs_hint")
if abs_hint:
candidates.append(Path(abs_hint))
for c in candidates:
if c and Path(c).is_file():
return Path(c).resolve()
raise FileNotFoundError(
"Image not found. Place DJI JPG under data/ or pass --image. Tried:\n "
+ "\n ".join(str(c) for c in candidates)
)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Multi-prompt SamGeo3 segmentation (Grok+Gemini prompt sets)"
)
p.add_argument(
"--prompts-json",
type=Path,
default=DEFAULT_PROMPTS_JSON,
help="JSON with tiers/compact prompts",
)
p.add_argument(
"--tier",
default="compact",
help=(
"compact | gap | gap2 | improved | all | "
"A_high | B_facility | C_detail | D_rail_domain | E_missed"
),
)
p.add_argument(
"--prompts",
default=None,
help='Comma-separated overrides, e.g. "building,tree,car"',
)
p.add_argument("--image", type=Path, default=None)
p.add_argument("--model-id", default="facebook/sam3.1")
p.add_argument("--backend", default="meta", choices=["meta", "transformers"])
p.add_argument("--confidence", type=float, default=0.3)
p.add_argument("--mask-threshold", type=float, default=0.5)
p.add_argument("--resolution", type=int, default=1008)
p.add_argument("--min-size", type=int, default=0)
p.add_argument("--max-size", type=int, default=None)
p.add_argument("--device", default=None)
p.add_argument(
"--checkpoint",
default=os.environ.get("SAM3_CHECKPOINT_PATH"),
)
p.add_argument(
"--output-dir",
type=Path,
default=None,
help="Default: output/<image_stem>_<tier>/",
)
p.add_argument("--no-viz", action="store_true")
p.add_argument(
"--skip-empty",
action="store_true",
default=True,
help="Do not write mask files when 0 objects (default true)",
)
p.add_argument(
"--keep-empty",
action="store_true",
help="Opposite of --skip-empty",
)
p.add_argument(
"--list-only",
action="store_true",
help="Print resolved prompts and exit (no model load)",
)
return p.parse_args()
def main() -> int:
args = parse_args()
cfg_path = args.prompts_json.resolve()
if not cfg_path.is_file():
print(f"ERROR: prompts json not found: {cfg_path}", file=sys.stderr)
return 2
cfg = load_prompt_config(cfg_path)
extra = None
if args.prompts:
extra = [x.strip() for x in args.prompts.split(",") if x.strip()]
try:
prompts = resolve_prompts(cfg, args.tier, extra)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
# de-dupe preserve order
seen: set[str] = set()
prompts = [p for p in prompts if not (p in seen or seen.add(p))]
if args.list_only:
print(f"prompts_json: {cfg_path}")
print(f"tier: {args.tier if not extra else 'custom'}")
print(f"count: {len(prompts)}")
for i, pr in enumerate(prompts, 1):
print(f" {i:02d}. {pr}")
return 0
try:
image_path = resolve_image(args.image, cfg)
except FileNotFoundError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
if args.backend == "transformers" and "sam3.1" in args.model_id:
print(
"ERROR: facebook/sam3.1 requires backend='meta'.",
file=sys.stderr,
)
return 2
tier_tag = "custom" if extra else args.tier
out_dir = (
args.output_dir.resolve()
if args.output_dir
else (ROOT / "output" / f"{image_path.stem}_{tier_tag}").resolve()
)
out_dir.mkdir(parents=True, exist_ok=True)
skip_empty = not args.keep_empty
print("=== SamGeo3 multi-prompt segmentation ===")
print(f"image: {image_path}")
print(f"prompts_json:{cfg_path}")
print(f"tier: {tier_tag}")
print(f"n_prompts: {len(prompts)}")
print(f"model_id: {args.model_id}")
print(f"backend: {args.backend}")
print(f"confidence: {args.confidence}")
print(f"output_dir: {out_dir}")
print("prompts:")
for i, pr in enumerate(prompts, 1):
print(f" {i:02d}. {pr}")
import numpy as np
import torch
from samgeo import SamGeo3
if args.device is None:
device = "cuda" if torch.cuda.is_available() else "cpu"
else:
device = args.device
print(f"\ndevice: {device} | cuda={torch.cuda.is_available()}")
if device == "cuda" and not torch.cuda.is_available():
print("ERROR: --device cuda but CUDA is not available", file=sys.stderr)
return 1
init_kwargs: dict[str, Any] = dict(
backend=args.backend,
model_id=args.model_id,
device=device,
confidence_threshold=args.confidence,
mask_threshold=args.mask_threshold,
resolution=args.resolution,
enable_segmentation=True,
enable_inst_interactivity=False,
)
if args.checkpoint and os.path.isfile(args.checkpoint):
init_kwargs["checkpoint_path"] = args.checkpoint
init_kwargs["load_from_HF"] = False
print(f"checkpoint: {args.checkpoint}")
t0 = time.perf_counter()
print("\nLoading model once...")
sam = SamGeo3(**init_kwargs)
print("set_image once...")
sam.set_image(str(image_path))
t_load = time.perf_counter() - t0
print(f"load+set_image: {t_load:.1f}s\n")
results: list[dict[str, Any]] = []
stem = image_path.stem
for i, prompt in enumerate(prompts, 1):
t1 = time.perf_counter()
print(f"[{i}/{len(prompts)}] generate_masks({prompt!r})...")
gen_kwargs: dict[str, Any] = {"min_size": args.min_size, "quiet": True}
if args.max_size is not None:
gen_kwargs["max_size"] = args.max_size
try:
sam.generate_masks(prompt, **gen_kwargs)
except Exception as e:
print(f" FAIL: {e}")
results.append(
{
"prompt": prompt,
"n_objects": 0,
"error": str(e),
"elapsed_s": round(time.perf_counter() - t1, 3),
}
)
continue
n = len(sam.masks) if getattr(sam, "masks", None) is not None else 0
score_vals: list[float] = []
scores = getattr(sam, "scores", None)
if scores is not None and len(scores):
try:
score_vals = [
float(s.item() if hasattr(s, "item") else s) for s in scores
]
except Exception:
pass
rec: dict[str, Any] = {
"prompt": prompt,
"n_objects": n,
"scores_min": min(score_vals) if score_vals else None,
"scores_max": max(score_vals) if score_vals else None,
"scores_mean": float(np.mean(score_vals)) if score_vals else None,
"elapsed_s": round(time.perf_counter() - t1, 3),
"mask": None,
"ann": None,
"scores_npy": None,
}
if n == 0:
print(f" -> 0 objects ({rec['elapsed_s']}s)")
results.append(rec)
continue
print(
f" -> {n} objects | score "
f"{rec['scores_min']:.3f}~{rec['scores_max']:.3f} "
f"({rec['elapsed_s']}s)"
)
if skip_empty and n == 0:
results.append(rec)
continue
tag = safe_name(prompt)
mask_path = out_dir / f"{stem}_{tag}_mask.png"
ann_path = out_dir / f"{stem}_{tag}_ann.png"
scores_path = out_dir / f"{stem}_{tag}_scores.npy"
try:
sam.save_masks(str(mask_path), unique=True)
rec["mask"] = str(mask_path)
except Exception as e:
print(f" [WARN] save_masks: {e}")
if score_vals:
np.save(str(scores_path), np.array(score_vals, dtype=np.float32))
rec["scores_npy"] = str(scores_path)
if not args.no_viz:
try:
sam.show_anns(output=str(ann_path))
rec["ann"] = str(ann_path)
except Exception as e:
print(f" [WARN] show_anns: {e}")
results.append(rec)
summary = {
"image": str(image_path),
"prompts_json": str(cfg_path),
"tier": tier_tag,
"model_id": args.model_id,
"backend": args.backend,
"confidence": args.confidence,
"min_size": args.min_size,
"device": device,
"load_set_image_s": round(t_load, 3),
"total_s": round(time.perf_counter() - t0, 3),
"n_prompts": len(prompts),
"n_with_objects": sum(1 for r in results if r.get("n_objects", 0) > 0),
"results": results,
"sources": cfg.get("sources"),
"scene_notes_ko": cfg.get("scene_notes_ko"),
}
summary_path = out_dir / "summary.json"
with open(summary_path, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print("\n=== summary ===")
print(f"with objects: {summary['n_with_objects']}/{summary['n_prompts']}")
print(f"total time: {summary['total_s']}s")
print(f"summary: {summary_path}")
for r in results:
n = r.get("n_objects", 0)
mark = "OK" if n else "--"
print(f" [{mark}] {r['prompt']!r:30s} n={n}")
return 0
if __name__ == "__main__":
raise SystemExit(main())