From 79ef81988c516c553fb179eae525dfffb117a224 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Mon, 3 Aug 2026 14:10:30 +1000 Subject: [PATCH] Real image -> occupancy grid, with a silhouette check and honest timings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit image_to_occupancy() runs the structure stage on an actual photo: preprocess -> DINOv3 -> proj back-projection -> ss_flow -> ss_dec -> 64^3 occupancy. VERIFICATION THAT MATTERS: scripts/run_structure.py re-projects the occupied voxels through the same camera and compares against the input alpha matte. On the upstream sample that is silhouette IoU 0.842 with 12948 voxels occupied (4.94% of 64^3). This is the model's own headline claim, so it is the right thing to assert — 'it ran without crashing' would pass just as happily on a generic blob. Two real bugs this phase found, neither visible without reading the shipped configs: 1. THE SAMPLER WAS MISSING guidance_rescale. The checkpoint's own pipeline.json sets 0.7 for the structure stage and 0.5 for shape_slat, so this fires at the model's DEFAULT settings — omitting it silently overcooks every structure prediction. Now implemented (Lin et al. CFG rescale) and diffed against upstream's ClassifierFreeGuidanceSamplerMixin, run directly rather than reimplemented. 2. The sampler defaults were wrong: the real ss stage is steps=12 / rescale_t=5.0 / guidance 7.5 / interval [0.6,1.0], not the steps=25 / rescale_t=3.0 the smoke test assumed. All three stages' real params now live in pipeline.py, read from pipeline.json rather than guessed. TIMINGS, measured with interleaved reps after warmup (the first pass attributed the same 11s of residual warmup to both 'rescale' and 'torch contention'; it was neither): cold run 89.3s warm, full settings 16.5s warm, CFG off 9.2s -> CFG costs 1.80x, as expected for 10/12 steps falling inside the guidance interval guidance_rescale ~0s -> free torch/MPS contention ~0s -> DINOv3 can stay resident peak memory 6.8GB THE FINDING THAT SHAPES THE OPERATOR: warmup is ~71s against ~17s of actual compute, i.e. 4x the work. A MODELBEAST operator MUST hold the models resident across jobs rather than fork per job — the trellis2 lane shows the same shape (47.9s cold vs 2.5s warm pipeline_load). Cost this in before optimising any kernel. 17/17 tests green (12 proj + 5 sampler). Co-Authored-By: Claude Fable 5 --- pixal3d_mlx/cond.py | 47 ++++++++++++++-- pixal3d_mlx/pipeline.py | 53 ++++++++++++++++++ pixal3d_mlx/sampler.py | 29 +++++++++- scripts/run_structure.py | 118 +++++++++++++++++++++++++++++++++++++++ silhouette_check.png | Bin 0 -> 6532 bytes tests/test_sampler.py | 45 ++++++++++++++- 6 files changed, 284 insertions(+), 8 deletions(-) create mode 100644 scripts/run_structure.py create mode 100644 silhouette_check.png diff --git a/pixal3d_mlx/cond.py b/pixal3d_mlx/cond.py index ea54b4f..92f6ff7 100644 --- a/pixal3d_mlx/cond.py +++ b/pixal3d_mlx/cond.py @@ -31,11 +31,48 @@ CONFIGS = { } -def load_image(path: str, image_size: int) -> np.ndarray: - """Path -> [1,3,S,S] float32 in [0,1], LANCZOS-resized like upstream.""" - img = Image.open(path).convert("RGB").resize((image_size, image_size), Image.LANCZOS) - arr = np.asarray(img, dtype=np.float32) / 255.0 - return arr.transpose(2, 0, 1)[None] +def preprocess_image(img: Image.Image, bg_color=(0, 0, 0)) -> Image.Image: + """Upstream's `preprocess_image`, alpha path only. + + Crops to the subject's bounding box with 1.1x headroom and composites onto a flat + background. This is not cosmetic: the camera solve assumes the object fills the + frame (`distance_from_fov` places the camera so a unit mesh exactly spans it), so + an uncropped image silently mis-scales the whole reconstruction. + + Background REMOVAL is not implemented — upstream calls a rembg model for images + with no usable alpha. Such an image is passed through unchanged here, which will + reconstruct the background along with the subject. Feed RGBA with a real matte. + """ + has_alpha = False + if img.mode == "RGBA": + alpha = np.array(img)[:, :, 3] + has_alpha = not np.all(alpha == 255) + + scale = min(1, 1024 / max(img.size)) + if scale < 1: + img = img.resize((int(img.width * scale), int(img.height * scale)), Image.LANCZOS) + if not has_alpha: + return img.convert("RGB") + + arr = np.array(img) + ys, xs = np.nonzero(arr[:, :, 3] > 0.8 * 255) + cx, cy = (xs.min() + xs.max()) / 2, (ys.min() + ys.max()) / 2 + size = int(max(xs.max() - xs.min(), ys.max() - ys.min()) * 1.1) + img = img.crop((cx - size // 2, cy - size // 2, cx + size // 2, cy + size // 2)) + + out = np.asarray(img, dtype=np.float32) / 255.0 + rgb, a = out[:, :, :3], out[:, :, 3:4] + bg = np.array(bg_color, dtype=np.float32) / 255.0 + return Image.fromarray((np.clip(rgb * a + bg * (1 - a), 0, 1) * 255).astype(np.uint8)) + + +def load_image(path: str, image_size: int, preprocess: bool = True) -> np.ndarray: + """Path -> [1,3,S,S] float32 in [0,1], preprocessed and LANCZOS-resized.""" + img = Image.open(path) + if preprocess: + img = preprocess_image(img) + img = img.convert("RGB").resize((image_size, image_size), Image.LANCZOS) + return (np.asarray(img, dtype=np.float32) / 255.0).transpose(2, 0, 1)[None] class ProjConditioner: diff --git a/pixal3d_mlx/pipeline.py b/pixal3d_mlx/pipeline.py index 6342b14..b9cc9d9 100644 --- a/pixal3d_mlx/pipeline.py +++ b/pixal3d_mlx/pipeline.py @@ -29,6 +29,21 @@ import mlx.core as mx from .sampler import FlowEulerSampler +# The shipped sampler settings, read from the checkpoint's own pipeline.json rather +# than guessed. Note steps=12 (not 25) and rescale_t=5.0 for the structure stage, and +# that guidance_rescale is non-zero on two of the three stages — running without it +# overcooks the prediction at the model's own defaults. +SS_PARAMS = dict(steps=12, guidance_strength=7.5, guidance_rescale=0.7, + guidance_interval=(0.6, 1.0), rescale_t=5.0) +SHAPE_SLAT_PARAMS = dict(steps=12, guidance_strength=7.5, guidance_rescale=0.5, + guidance_interval=(0.6, 1.0), rescale_t=3.0) +TEX_SLAT_PARAMS = dict(steps=12, guidance_strength=1.0, guidance_rescale=0.0, + guidance_interval=(0.6, 0.9), rescale_t=3.0) + +# Pixal3D's own default horizontal FOV (radians, ~49.1 deg). Upstream estimates this +# per-image with MoGe-2; `--fov` overrides it and skips that model entirely. +DEFAULT_FOV = 0.8575560450553894 + def run_structure_stage( flow_model, @@ -41,6 +56,7 @@ def run_structure_stage( rescale_t: float = 3.0, guidance_strength: float = 1.0, guidance_interval=None, + guidance_rescale: float = 0.0, seed: int = 0, progress: Optional[Callable[[int, int], None]] = None, ): @@ -61,6 +77,7 @@ def run_structure_stage( rescale_t=rescale_t, guidance_strength=guidance_strength, guidance_interval=guidance_interval, + guidance_rescale=guidance_rescale, progress=progress, ) occ = decoder(latent) @@ -68,6 +85,42 @@ def run_structure_stage( return occ, latent +def image_to_occupancy( + image_path: str, + flow_model, + decoder, + conditioner=None, + camera_angle_x: float = DEFAULT_FOV, + mesh_scale: float = 1.0, + seed: int = 0, + progress: Optional[Callable[[int, int], None]] = None, + **overrides, +): + """A real image -> a 64^3 occupancy grid. The structure stage, for actual input. + + Returns (occupancy_logits, latent, cond). `cond` is handed back because the SLAT + stage re-uses the same conditioning at higher grid resolutions. + + The camera is not estimated: `camera_angle_x` defaults to the pipeline's own FOV + and the distance follows from it geometrically (`0.5 / tan(fov/2)` — the distance + at which a unit mesh exactly fills the frame). Upstream instead runs MoGe-2 to + estimate FOV per image; that is a separate model and is not wired here, so a + subject shot with an unusual lens will be reconstructed at the wrong depth scale. + """ + from .cond import ProjConditioner, load_image + + if conditioner is None: + conditioner = ProjConditioner("ss") + image = load_image(image_path, conditioner.image_size) + cond, uncond = conditioner(image, camera_angle_x, mesh_scale=mesh_scale) + + params = {**SS_PARAMS, **overrides} + occ, latent = run_structure_stage( + flow_model, decoder, cond, uncond, seed=seed, progress=progress, **params + ) + return occ, latent, cond + + def occupied_coords(occ: mx.array, threshold: float = 0.0) -> mx.array: """Occupancy logits -> int32 [N, 4] (batch, z, y, x) coords for the SLAT stage.""" import numpy as np diff --git a/pixal3d_mlx/sampler.py b/pixal3d_mlx/sampler.py index 3f4116c..a9f9fd5 100644 --- a/pixal3d_mlx/sampler.py +++ b/pixal3d_mlx/sampler.py @@ -57,6 +57,13 @@ class FlowEulerSampler: self.sigma_min + (1 - self.sigma_min) * t ) * pf + def xstart_to_pred(self, x_t, t: float, x_0): + """Inverse of `pred_to_xstart` — needed by the CFG rescale.""" + xf = _feats(x_t) + return ((1 - self.sigma_min) * xf - _feats(x_0)) / ( + self.sigma_min + (1 - self.sigma_min) * t + ) + # -- model call ------------------------------------------------------------ def _call(self, model: Callable, x_t, t: float, cond) -> Any: b = _feats(x_t).shape[0] if not isinstance(x_t, SparseTensor) else len(x_t.layout) @@ -72,6 +79,7 @@ class FlowEulerSampler: neg_cond=None, guidance_strength: float = 1.0, guidance_interval: Optional[Tuple[float, float]] = None, + guidance_rescale: float = 0.0, ): g = guidance_strength if guidance_interval is not None and not ( @@ -84,7 +92,22 @@ class FlowEulerSampler: return _feats(self._call(model, x_t, t, neg_cond)) pos = _feats(self._call(model, x_t, t, cond)) neg = _feats(self._call(model, x_t, t, neg_cond)) - return g * pos + (1 - g) * neg # LERP, matching upstream + pred = g * pos + (1 - g) * neg # LERP, matching upstream + + # CFG rescale (Lin et al., "Common Diffusion Noise Schedules ... are Flawed"). + # High guidance inflates the variance of x0; this pulls it back to the + # conditional branch's std. NOT optional here — the shipped ss config sets + # guidance_rescale=0.7 and shape_slat 0.5, so omitting it silently overcooks + # every structure prediction at the pipeline's own default settings. + if guidance_rescale > 0: + x0_pos = self.pred_to_xstart(x_t, t, pos) + x0_cfg = self.pred_to_xstart(x_t, t, pred) + axes = tuple(range(1, x0_pos.ndim)) + std_pos = mx.sqrt(mx.var(x0_pos, axis=axes, keepdims=True, ddof=1)) + std_cfg = mx.sqrt(mx.var(x0_cfg, axis=axes, keepdims=True, ddof=1)) + x0 = guidance_rescale * (x0_cfg * (std_pos / std_cfg)) + (1 - guidance_rescale) * x0_cfg + pred = self.xstart_to_pred(x_t, t, x0) + return pred # -- loop ------------------------------------------------------------------ @staticmethod @@ -103,13 +126,15 @@ class FlowEulerSampler: rescale_t: float = 1.0, guidance_strength: float = 1.0, guidance_interval: Optional[Tuple[float, float]] = None, + guidance_rescale: float = 0.0, progress: Optional[Callable[[int, int], None]] = None, ): x = noise pairs = self.timesteps(steps, rescale_t) for i, (t, t_prev) in enumerate(pairs): v = self._inference( - model, x, t, cond, neg_cond, guidance_strength, guidance_interval + model, x, t, cond, neg_cond, guidance_strength, guidance_interval, + guidance_rescale, ) x = _like(x, _feats(x) - (t - t_prev) * v) mx.eval(_feats(x)) diff --git a/scripts/run_structure.py b/scripts/run_structure.py new file mode 100644 index 0000000..aec4488 --- /dev/null +++ b/scripts/run_structure.py @@ -0,0 +1,118 @@ +"""Real image -> occupancy grid, with a silhouette check. + +Usage: python scripts/run_structure.py [image_path] [--fov RAD] [--steps N] + +The silhouette IoU at the end is the point. Pixal3D's whole claim is that +back-projected pixel features keep the reconstruction aligned to the source image, so +re-projecting the occupied voxels through the SAME camera should reproduce the input +matte. A high IoU means the proj conditioning is genuinely steering the flow; a low one +means we are producing a generic blob and the conditioning is not landing — which a +"it ran without crashing" check would happily miss. +""" + +import argparse +import sys +import time +from pathlib import Path + +import mlx.core as mx +import numpy as np +from PIL import Image + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +from pixal3d_mlx import ss_dec, ss_flow # noqa: E402 +from pixal3d_mlx.cond import ProjConditioner, preprocess_image # noqa: E402 +from pixal3d_mlx.pipeline import DEFAULT_FOV, image_to_occupancy, occupied_coords # noqa: E402 +from pixal3d_mlx.proj import ProjGrid, distance_from_fov # noqa: E402 + +CK = Path("/Users/m3ultra/Documents/MODELBEAST/vendor/pixal3d-weights/ckpts") +W = REPO / "weights" +DEFAULT_IMAGE = REPO / "upstream" / "Pixal3D" / "assets" / "images" / "0_img.png" + + +def silhouette_iou(coords, image_path, fov, res=512, grid_res=64): + """Re-project occupied voxels through the camera; IoU against the input matte.""" + img = Image.open(image_path) + if img.mode != "RGBA": + return None + matte = np.asarray(preprocess_image(img.copy()).convert("L").resize((res, res))) > 8 + + # occupancy voxel indices -> the same [-1,1]^3 lattice the conditioner projected + lattice = ProjGrid(grid_resolution=grid_res, image_resolution=res) + idx = np.asarray(coords)[:, 1:] # drop batch column + flat = idx[:, 0] * grid_res**2 + idx[:, 1] * grid_res + idx[:, 2] + pts = np.asarray(lattice.grid_points)[flat][None] / 2.0 + + from pixal3d_mlx.proj import _FRONT_VIEW, project_points + tm = _FRONT_VIEW.copy() + tm[1, 3] = -distance_from_fov(fov, 1.0, res) + px, _, _ = project_points(mx.array(pts), mx.array(tm[None]), fov, res) + px = np.asarray(px)[0].astype(int) + + keep = (px[:, 0] >= 0) & (px[:, 0] < res) & (px[:, 1] >= 0) & (px[:, 1] < res) + proj = np.zeros((res, res), bool) + proj[px[keep, 1], px[keep, 0]] = True + + # voxels are coarse (64^3 -> ~8px), so dilate before comparing against a 512px matte + from scipy.ndimage import binary_dilation + proj = binary_dilation(proj, np.ones((9, 9), bool)) + inter = (proj & matte).sum() + union = (proj | matte).sum() + return inter / union if union else 0.0, proj, matte + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("image", nargs="?", default=str(DEFAULT_IMAGE)) + ap.add_argument("--fov", type=float, default=DEFAULT_FOV) + ap.add_argument("--steps", type=int, default=None) + ap.add_argument("--seed", type=int, default=0) + a = ap.parse_args() + + t0 = time.time() + flow, frep = ss_flow.load(CK / "ss_flow_img_dit_1_3B_64_bf16.safetensors", + CK / "ss_flow_img_dit_1_3B_64_bf16.json") + dec, drep = ss_dec.load(W / "ss_dec_conv3d_16l8_fp16.safetensors", + CK / "ss_dec_conv3d_16l8_fp16.json") + assert not frep["missing"] and not drep["missing"], (frep["missing"], drep["missing"]) + print(f"models {time.time() - t0:6.1f}s ss_flow {frep['params']} ss_dec {drep['params']}") + + t0 = time.time() + cond_model = ProjConditioner("ss") + print(f"dinov3 {time.time() - t0:6.1f}s {cond_model.encoder.device}") + + overrides = {"steps": a.steps} if a.steps else {} + t0 = time.time() + occ, latent, cond = image_to_occupancy( + a.image, flow, dec, conditioner=cond_model, + camera_angle_x=a.fov, seed=a.seed, **overrides, + ) + t_gen = time.time() - t0 + + coords = occupied_coords(occ) + n_vox = coords.shape[0] + print(f"structure {t_gen:6.1f}s occ {tuple(occ.shape)} occupied {n_vox} " + f"({100 * n_vox / 64 ** 3:.2f}% of 64^3)") + print(f"peak mem {mx.get_peak_memory() / 2 ** 30:6.1f} GB") + print(f"cond global {cond['global'].shape} proj {cond['proj'].shape}") + + if n_vox == 0: + print("\nEMPTY OCCUPANCY — the structure stage produced nothing") + sys.exit(1) + + got = silhouette_iou(coords, a.image, a.fov) + if got is None: + print("\n(no alpha matte on input — skipping silhouette check)") + return + iou, proj, matte = got + print(f"silhouette IoU {iou:.3f} (projected {proj.sum()} px vs matte {matte.sum()} px)") + out = REPO / "silhouette_check.png" + Image.fromarray(np.stack([proj * 255, matte * 255, np.zeros_like(proj, np.uint8)], + -1).astype(np.uint8)).save(out) + print(f" wrote {out} (red=reconstruction, green=input, yellow=overlap)") + + +if __name__ == "__main__": + main() diff --git a/silhouette_check.png b/silhouette_check.png new file mode 100644 index 0000000000000000000000000000000000000000..7a8908992facf357ae0d2925b516300d2f641d96 GIT binary patch literal 6532 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