pixal3d_mrp_mlx/pixal3d_mlx/sampler.py
John d43a6dfd69 Flow Euler sampler + structure-stage pipeline
The structure stage now runs end to end on Metal: noise -> ss_flow (12-step Euler)
-> latent -> ss_dec -> 64^3 occupancy grid. 759 ms/step for the 1.3B DiT, 50 ms for
the decoder. Output lands at 1.36% occupancy, which is the right order for a surface
in a 64^3 grid.

Sampler details worth recording, both from upstream:
- CFG is a LERP (g*pos + (1-g)*neg), NOT neg + g*(pos-neg). Those differ non-linearly
  in strength rather than failing outright, so it is silent when wrong. Tested.
- Guidance interval forces strength to 1 outside its window, which halves model calls
  there: 6 calls for 4 steps rather than 8. Tested by counting.
Schedule matches upstream to 1e-16 and constant velocity integrates exactly at any
step count.

Remaining for a real image->3D run is CONDITIONING, not models: DINOv3 features plus
Pixal3D's camera back-projection for the view-aligned 'proj' half. Deliberately not
porting DINOv3 - it is a stock ViT run once per image, outside the denoising loop, so
torch on MPS is the right tool and transformers gives exact parity for free.
2026-08-02 16:09:18 +10:00

119 lines
4.4 KiB
Python

"""Flow-matching Euler sampler in MLX, with classifier-free guidance.
Rectified-flow sampling: the model predicts a velocity `v` and the update is simply
x_{t-1} = x_t - (t - t_prev) * v
Timesteps run from 1 down to 0. `rescale_t` warps the schedule
(`r*t / (1 + (r-1)*t)`), which concentrates steps near t=1 for r>1 — Pixal3D uses this
to spend more compute early where the structure is decided.
Two guidance behaviours, both from upstream:
* **CFG** — `pred = g*pos + (1-g)*neg`. Note this is a LERP, not the more common
`neg + g*(pos - neg)`; they agree only because upstream's `g` is defined on the same
scale. Getting it wrong changes strength non-linearly rather than breaking outright.
* **Guidance interval** — guidance applies only while `lo <= t <= hi`; outside that
window strength is forced to 1 (i.e. conditional only, one model call instead of two).
That is a real speedup as well as a quality choice.
Works on both dense `mx.array` latents and `SparseTensor` ones, since the SLAT stage
denoises sparse features — hence the small `_lift`/`_like` helpers rather than raw
arithmetic.
"""
from __future__ import annotations
from typing import Any, Callable, List, Optional, Tuple
import mlx.core as mx
from trellis_sparse_mlx import SparseTensor
def _feats(x):
return x.feats if isinstance(x, SparseTensor) else x
def _like(ref, feats):
"""Rebuild `ref`'s container around new features."""
return ref.replace(feats) if isinstance(ref, SparseTensor) else feats
class FlowEulerSampler:
def __init__(self, sigma_min: float = 1e-5):
self.sigma_min = sigma_min
# -- conversions between the model's velocity and x0/eps -------------------
def v_to_xstart_eps(self, x_t, t: float, v):
xf, vf = _feats(x_t), _feats(v)
eps = (1 - t) * vf + xf
x0 = (1 - self.sigma_min) * xf - (self.sigma_min + (1 - self.sigma_min) * t) * vf
return x0, eps
def pred_to_xstart(self, x_t, t: float, pred):
xf, pf = _feats(x_t), _feats(pred)
return (1 - self.sigma_min) * xf - (
self.sigma_min + (1 - self.sigma_min) * t
) * pf
# -- 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)
tt = mx.full((b,), 1000.0 * t, dtype=mx.float32) # upstream scales t by 1000
return model(x_t, tt, cond)
def _inference(
self,
model,
x_t,
t: float,
cond,
neg_cond=None,
guidance_strength: float = 1.0,
guidance_interval: Optional[Tuple[float, float]] = None,
):
g = guidance_strength
if guidance_interval is not None and not (
guidance_interval[0] <= t <= guidance_interval[1]
):
g = 1.0 # outside the window: conditional only, and only one model call
if g == 1.0 or neg_cond is None:
return _feats(self._call(model, x_t, t, cond))
if g == 0.0:
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
# -- loop ------------------------------------------------------------------
@staticmethod
def timesteps(steps: int, rescale_t: float = 1.0) -> List[Tuple[float, float]]:
seq = [1.0 - i / steps for i in range(steps + 1)]
seq = [rescale_t * t / (1 + (rescale_t - 1) * t) for t in seq]
return [(seq[i], seq[i + 1]) for i in range(steps)]
def sample(
self,
model,
noise,
cond,
neg_cond=None,
steps: int = 25,
rescale_t: float = 1.0,
guidance_strength: float = 1.0,
guidance_interval: Optional[Tuple[float, float]] = None,
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
)
x = _like(x, _feats(x) - (t - t_prev) * v)
mx.eval(_feats(x))
if progress:
progress(i + 1, steps)
return x