"""Silhouette-IoU numeric gate (pip-free). A cheap, model-free geometric signal: render the candidate mesh from canonical views, extract each silhouette, and compare (best-IoU) to the input object's silhouette. Used to rank best-of-N candidates numerically and to report a "shape match vs photo" score. Foreground extraction is background-color thresholding (robust for clean/white-bg photos and our renders). For cluttered photos, `pip install rembg` and swap in a matting model — the API here (a boolean mask) stays the same. Masks are normalized to their bbox before IoU, so the score is scale/translation-invariant (not rotation — we take the best over views). """ from __future__ import annotations import base64 import io import os import numpy as np from PIL import Image from .render import render_single _VIEWS = ("iso", "front", "side", "top") def _img_from_uri(uri: str) -> Image.Image: b64 = uri.split(",", 1)[1] if "," in uri else uri return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB") def _foreground_mask(im: Image.Image) -> np.ndarray: """Boolean object mask via background-color (corner) thresholding.""" a = np.asarray(im.convert("RGB")).astype(float) h, w, _ = a.shape c = 8 corners = np.concatenate([a[:c, :c].reshape(-1, 3), a[:c, -c:].reshape(-1, 3), a[-c:, :c].reshape(-1, 3), a[-c:, -c:].reshape(-1, 3)]) bg = corners.mean(0) dist = np.linalg.norm(a - bg, axis=2) t = max(28.0, float(dist.mean())) return dist > t def _normalize(mask: np.ndarray, size: int = 128) -> np.ndarray: ys, xs = np.where(mask) if len(xs) == 0: return np.zeros((size, size), bool) crop = mask[ys.min():ys.max() + 1, xs.min():xs.max() + 1] im = Image.fromarray((crop * 255).astype("uint8")).resize((size, size)) return np.asarray(im) > 127 def iou(a: np.ndarray, b: np.ndarray) -> float: inter = np.logical_and(a, b).sum() uni = np.logical_or(a, b).sum() return float(inter / uni) if uni else 0.0 def _silhouette_at(stl: str, out_dir: str, view=None, angles=None) -> np.ndarray: png = render_single(stl, os.path.join(out_dir, "_sil.png"), view=view or "iso", angles=angles) a = np.asarray(Image.open(png).convert("RGB")) return a.min(axis=2) < 240 # non-white = object def _load_target(target_image) -> np.ndarray: if isinstance(target_image, Image.Image): im = target_image elif isinstance(target_image, str) and target_image.startswith("data:"): im = _img_from_uri(target_image) else: im = Image.open(target_image) return _normalize(_foreground_mask(im)) def silhouette_iou(stl: str, target_image, out_dir: str = "outputs") -> float: """Best silhouette IoU over the 4 canonical views (fast; for best-of-N ranking). 0..1.""" os.makedirs(out_dir, exist_ok=True) tgt = _load_target(target_image) best = 0.0 for v in _VIEWS: try: best = max(best, iou(tgt, _normalize(_silhouette_at(stl, out_dir, view=v)))) except Exception: # noqa: BLE001 continue return round(best, 3) # Lightweight camera-pose estimation: search a viewpoint grid for the angle whose rendered # silhouette best matches the input (the discrete render-and-compare pose search from One-2-3-45). _POSE_GRID = [(e, a) for e in (-15, 15, 45) for a in range(0, 360, 45)] # 3×8 = 24 def estimate_pose(stl: str, target_image, out_dir: str = "outputs"): """Return (best_iou, (elev, azim)) — the viewpoint whose silhouette best matches the photo.""" os.makedirs(out_dir, exist_ok=True) tgt = _load_target(target_image) best = (0.0, (15, 45)) for elev, azim in _POSE_GRID: try: sc = iou(tgt, _normalize(_silhouette_at(stl, out_dir, angles=(elev, azim)))) except Exception: # noqa: BLE001 continue if sc > best[0]: best = (round(sc, 3), (elev, azim)) return best