CloneForge / cloneforge /silhouette.py
avaliev's picture
Upload 75 files
8f1f637 verified
Raw
History Blame Contribute Delete
3.98 kB
"""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