pixal3d_mrp_mlx/pixal3d_mlx/cond.py
m3ultra 165de26db5 The shipped cascade: image -> GLB at silhouette IoU 0.969
image_to_mesh() now runs the real cascade, not the single-stage shortcut:

  structure    3048 voxels @32^3      (64^3 occupancy, MAX-POOLED DOWN)
  LR SLAT      3048 x 32              shape_512 extractor
  refine      13147 coords @64^3      four decoder stages -> coords -> quantise
  HR SLAT     13147 x 32              shape_1024 extractor
  mesh      3988052 verts, 7996876 faces @1024^3

  TOTAL 258.4s, peak 27.9GB with every model resident   silhouette IoU 0.969

Three things the cascade needed:

1. occupied_coords_at() - ss_dec always decodes 64^3 but the cascade STARTS at 32^3.
   Upstream max-pools the boolean grid down by the ratio (a voxel survives if ANY of
   its eight children was occupied). I had been feeding the raw 64^3 set to the HR flow.
2. decoder.upsample() - pushes the LR latent four stages in and returns COORDS, not
   features. The predicted subdivisions grow the occupied set; those coords quantise
   onto the HR flow's grid. Stops BEFORE stage `upsample_times`, as upstream does;
   one stage further doubles the resolution and misplaces every voxel.
3. grid_resolution override on ProjConditioner - upstream backs the HR grid off in
   128-unit steps while the token count exceeds max_num_tokens, so a dense object
   degrades instead of exploding. refine_coords() implements that loop.

I WAS WRONG ABOUT THE HALO. The previous commit blamed the single-stage shortcut for a
0.639 silhouette IoU and predicted the cascade would fix it. The cascade measured
0.640 - no change. The real fault was in my VERIFICATION, not the pipeline: o_voxel
returns vertices in the voxel-grid frame, while ProjGrid rotates its lattice by
_BLENDER_ROT before projecting. Rotating the mesh the same way scores 0.969 on the
same geometry the earlier commit had already produced. Added mesh.to_camera_frame()
so the trap is named where it bites; the earlier mesh was correct all along.

The cascade is still the right thing - it is the shipped path, and staged loading
halves peak memory (12.8GB vs 22.6GB) when models are released between stages.

Also adds models.load_all(), so a server builds all five models plus both conditioners
ONCE. Warmup is ~71s against ~17s of compute, so an operator must never fork per job.
Holding everything resident costs 27.9GB peak - nothing on a 256GB box.

scripts/image_to_mesh.py exits non-zero if IoU < 0.85: a run that completes with a bad
reconstruction has failed even though nothing raised.

27/27 green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 15:13:20 +10:00

146 lines
6.8 KiB
Python

"""Image -> conditioning dicts, the MLX side of upstream's `encode_image_proj`.
Produces exactly the two-key contract the DiT blocks expect:
cond = {'global': [B, 1+regs, D], 'proj': [B, R^3, C]}
uncond = zeros of the same shapes (classifier-free guidance negative)
`global` is cross-attended; `proj` is projected and ADDED, one token per voxel. The
token counts differ (5 vs R^3) and that asymmetry IS the architecture — see proj.py.
"""
from __future__ import annotations
from typing import Dict, Tuple
import mlx.core as mx
import numpy as np
from PIL import Image
from .dino import DinoV3Encoder
from .proj import ProjGrid, distance_from_fov
# The four extractor configurations upstream builds in inference.py. Only `ss` is wired
# so far; the other three additionally need the NAF upsampler, which doubles their
# proj_channels to embed_dim*2 by concatenating a high-res branch.
CONFIGS = {
"ss": dict(image_size=512, grid_resolution=16, use_naf_upsample=False),
"shape_512": dict(image_size=512, grid_resolution=32, use_naf_upsample=True, naf_target_size=512),
"shape_1024": dict(image_size=1024, grid_resolution=64, use_naf_upsample=True, naf_target_size=512),
"tex_1024": dict(image_size=1024, grid_resolution=64, use_naf_upsample=True, naf_target_size=1024),
}
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:
"""One extractor stage: DINOv3 + a ProjGrid at that stage's voxel resolution."""
def __init__(self, stage: str = "ss", device: str | None = None):
cfg = CONFIGS[stage]
self.stage = stage
self.image_size = cfg["image_size"]
self.grid_resolution = cfg["grid_resolution"]
self.use_naf = bool(cfg.get("use_naf_upsample"))
self.naf_target = cfg.get("naf_target_size")
self.device = device
self.encoder = DinoV3Encoder(image_size=self.image_size, device=device)
# ONE grid for both branches, at image_size — upstream reuses `self.proj_grid`
# for the high-res sample too. That is not interchangeable with a grid built at
# naf_target_size: the normalised coordinate carries a 1/resolution term, so
# projecting at 1024 and normalising by 1024 lands ~0.001 off in [-1,1] — a
# sub-pixel shift on every voxel, in a model whose whole point is pixel
# alignment. Sampling is resolution-agnostic anyway; grid_sample maps [-1,1]
# onto whatever feature map it is handed.
self.proj_grid = ProjGrid(grid_resolution=self.grid_resolution,
image_resolution=self.image_size)
self.proj_channels = self.encoder.embed_dim * (2 if self.use_naf else 1)
def _grid(self, grid_resolution: int | None):
"""ProjGrid at `grid_resolution`, cached. Always at image_size (see __init__)."""
r = grid_resolution or self.grid_resolution
if r == self.grid_resolution:
return self.proj_grid
cache = getattr(self, "_grid_cache", None) or {}
if r not in cache:
cache[r] = ProjGrid(grid_resolution=r, image_resolution=self.image_size)
self._grid_cache = cache
return cache[r]
def __call__(self, image_chw01: np.ndarray, camera_angle_x: float,
distance: float | None = None, mesh_scale: float = 1.0,
grid_resolution: int | None = None,
) -> Tuple[Dict[str, mx.array], Dict[str, mx.array]]:
"""`grid_resolution` overrides this stage's lattice — the cascade backs the
high-res grid off in steps when an object is too dense for max_num_tokens."""
if distance is None:
distance = distance_from_fov(camera_angle_x, mesh_scale, self.image_size)
grid = self._grid(grid_resolution)
z_global, z_patch = self.encoder(image_chw01)
z_proj = grid(z_patch, camera_angle_x, distance, mesh_scale)
if self.use_naf:
from .naf import upsample
import numpy as _np
from PIL import Image as _Image
# NAF's guide is the UNNORMALISED image at the target size
guide = image_chw01
if guide.shape[-1] != self.naf_target:
arr = (guide[0].transpose(1, 2, 0) * 255).astype(_np.uint8)
arr = _Image.fromarray(arr).resize((self.naf_target, self.naf_target),
_Image.LANCZOS)
guide = (_np.asarray(arr, dtype=_np.float32) / 255.0).transpose(2, 0, 1)[None]
hr = upsample(z_patch, guide, self.naf_target,
device=self.device or "mps")
z_hr = grid(hr, camera_angle_x, distance, mesh_scale)
z_proj = mx.concatenate([z_proj, z_hr], axis=-1)
cond = {"global": z_global, "proj": z_proj}
uncond = {"global": mx.zeros_like(z_global), "proj": mx.zeros_like(z_proj)}
return cond, uncond