toy-room-v3 / scripts /generate_sam_environment_models.py
sanjuhs's picture
Ship Fire Boy toy room v3
27a1410 verified
Raw
History Blame Contribute Delete
9.3 kB
"""Generate fal SAM 3D Object GLBs for Tiny Toybox room props.
Run from the project root:
python3 scripts/generate_sam_environment_models.py berry-rose
Outputs:
assets/generated/environment-models/raw/<object-id>-sam.glb
assets/generated/environment-models/raw/<object-id>-sam-result.json
assets/generated/environment-models/sam-environment-inputs.json
"""
from __future__ import annotations
import argparse
import base64
import json
import mimetypes
import subprocess
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import fal.apps
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = ROOT / "assets" / "generated" / "environment-concepts" / "environment-manifest.json"
OUTPUT_ROOT = ROOT / "assets" / "generated" / "environment-models"
RAW_ROOT = OUTPUT_ROOT / "raw"
INPUT_INDEX = OUTPUT_ROOT / "sam-environment-inputs.json"
MODEL_ID = "fal-ai/sam-3/3d-objects"
@dataclass(frozen=True)
class EnvironmentSpec:
id: str
name: str
kind: str
image: Path
prompt: str
affordances: list[str]
tags: list[str]
@property
def output_glb(self) -> Path:
return RAW_ROOT / f"{self.id}-sam.glb"
@property
def metadata_path(self) -> Path:
return RAW_ROOT / f"{self.id}-sam-result.json"
def data_url(path: Path) -> str:
mime = mimetypes.guess_type(path.name)[0] or "image/png"
return f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii")
def repo_path_from_asset_url(url: str) -> Path:
prefix = "/toy-assets/"
if not url.startswith(prefix):
raise ValueError(f"Expected a /toy-assets/ URL, got {url}")
return ROOT / "assets" / url.removeprefix(prefix)
def full_prompt(item: dict[str, Any]) -> str:
base = str(item.get("prompt") or item.get("name") or item.get("id"))
return (
f"{base}. Isolated centered subject on a plain light background, single complete object, "
"cute chibi toy proportions, soft rounded forms, clean readable silhouette, textured GLB "
"suitable for a Three.js virtual pet room, no extra objects, no text, no scene."
)
def estimate_box_prompt(path: Path, padding_ratio: float = 0.04) -> dict[str, int]:
image = Image.open(path).convert("RGB")
width, height = image.size
pixels = image.load()
samples: list[tuple[int, int, int]] = []
step = max(1, min(width, height) // 80)
for x in range(0, width, step):
samples.append(pixels[x, 0])
samples.append(pixels[x, height - 1])
for y in range(0, height, step):
samples.append(pixels[0, y])
samples.append(pixels[width - 1, y])
background = tuple(sorted(color[channel] for color in samples)[len(samples) // 2] for channel in range(3))
xs: list[int] = []
ys: list[int] = []
threshold = 36
for y in range(0, height, 2):
for x in range(0, width, 2):
red, green, blue = pixels[x, y]
diff = abs(red - background[0]) + abs(green - background[1]) + abs(blue - background[2])
if diff > threshold:
xs.append(x)
ys.append(y)
if not xs:
inset_x = round(width * 0.10)
inset_y = round(height * 0.10)
return {
"x_min": inset_x,
"y_min": inset_y,
"x_max": width - inset_x,
"y_max": height - inset_y,
}
padding = round(max(width, height) * padding_ratio)
return {
"x_min": max(0, min(xs) - padding),
"y_min": max(0, min(ys) - padding),
"x_max": min(width - 1, max(xs) + padding),
"y_max": min(height - 1, max(ys) + padding),
}
def load_specs() -> list[EnvironmentSpec]:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
specs: list[EnvironmentSpec] = []
for item in manifest["objects"]:
specs.append(
EnvironmentSpec(
id=item["id"],
name=item["name"],
kind=item["kind"],
image=repo_path_from_asset_url(item["image"]),
prompt=full_prompt(item),
affordances=[str(value) for value in item.get("affordances", [])],
tags=[str(value) for value in item.get("tags", [])],
)
)
return specs
def write_input_index(specs: list[EnvironmentSpec]) -> None:
OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
INPUT_INDEX.write_text(
json.dumps(
[
{
"id": spec.id,
"name": spec.name,
"kind": spec.kind,
"image": spec.image.relative_to(ROOT).as_posix(),
"prompt": spec.prompt,
"box_prompt": estimate_box_prompt(spec.image),
"affordances": spec.affordances,
"tags": spec.tags,
"output": spec.output_glb.relative_to(ROOT).as_posix(),
"metadata": spec.metadata_path.relative_to(ROOT).as_posix(),
}
for spec in specs
],
indent=2,
),
encoding="utf-8",
)
def download(url: str, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
try:
with urllib.request.urlopen(url) as response:
path.write_bytes(response.read())
except Exception:
subprocess.run(["curl", "-fL", "--retry", "3", url, "-o", str(path)], check=True)
def download_from_metadata(spec: EnvironmentSpec) -> bool:
if not spec.metadata_path.exists() or spec.output_glb.exists():
return spec.output_glb.exists()
result = json.loads(spec.metadata_path.read_text(encoding="utf-8"))
url = (result.get("model_glb") or {}).get("url")
if not url:
return False
print(f"{spec.id}: recovering download from saved metadata")
download(url, spec.output_glb)
return True
def generate(spec: EnvironmentSpec, force: bool, prepare_only: bool) -> Path:
if not spec.image.exists():
raise FileNotFoundError(spec.image)
if prepare_only:
return spec.output_glb
if download_from_metadata(spec) and not force:
print(f"{spec.id}: already exists at {spec.output_glb}")
return spec.output_glb
if spec.output_glb.exists() and not force:
print(f"{spec.id}: already exists at {spec.output_glb}")
return spec.output_glb
spec.output_glb.parent.mkdir(parents=True, exist_ok=True)
spec.metadata_path.parent.mkdir(parents=True, exist_ok=True)
box_prompt = estimate_box_prompt(spec.image)
params: dict[str, Any] = {
"image_url": data_url(spec.image),
"prompt": spec.prompt,
"point_prompts": [],
"box_prompts": [box_prompt],
"detection_threshold": 0.1,
"export_textured_glb": True,
}
def submit_and_fetch(request_params: dict[str, Any]) -> dict[str, Any]:
print(f"{spec.id}: submitting to {MODEL_ID}")
handle = fal.apps.submit(MODEL_ID, request_params)
print(f"{spec.id}: request_id={handle.request_id}")
for event in handle.iter_events(logs=True):
print(f"{spec.id}: {type(event).__name__}")
return handle.fetch_result()
try:
result = submit_and_fetch(params)
except Exception as exc:
if "Auto-segmentation produced no masks" not in str(exc):
raise
print(f"{spec.id}: retrying with expanded box prompt after no-mask response")
retry_params = params | {
"prompt": "single isolated object",
"box_prompts": [estimate_box_prompt(spec.image, padding_ratio=0.10)],
}
result = submit_and_fetch(retry_params)
spec.metadata_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
url = (result.get("model_glb") or {}).get("url")
if not url:
raise RuntimeError(f"{spec.id}: fal result did not include model_glb.url")
download(url, spec.output_glb)
print(f"{spec.id}: saved {spec.output_glb}")
return spec.output_glb
def select_specs(specs: list[EnvironmentSpec], requested: list[str]) -> list[EnvironmentSpec]:
if not requested:
return specs
wanted = set(requested)
return [spec for spec in specs if spec.id in wanted or spec.kind in wanted]
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--force", action="store_true", help="Regenerate even if an output GLB already exists.")
parser.add_argument("--prepare-only", action="store_true", help="Write the SAM input index without submitting jobs.")
parser.add_argument("ids", nargs="*", help="Optional object ids or kinds to generate.")
args = parser.parse_args()
specs = load_specs()
selected = select_specs(specs, args.ids)
if not selected:
raise SystemExit("No matching environment object specs selected.")
write_input_index(specs)
print(f"Prepared {len(selected)} SAM jobs from {MANIFEST_PATH.relative_to(ROOT)}")
for index, spec in enumerate(selected, start=1):
print(f"[{index}/{len(selected)}] {spec.id}")
generate(spec, force=args.force, prepare_only=args.prepare_only)
if __name__ == "__main__":
main()