The texture flow is imgshape2tex - it denoises 32 PBR channels while SEEING the shape latent, so in_channels is 64 against out_channels 32. Upstream feeds the shape latent as concat_cond and the model does sparse_cat([x, concat_cond], dim=-1); both share coords, so it reduces to a channel concat. Added to slat_flow and carried on the sampler (it is fixed for the whole trajectory and must reach BOTH CFG branches). Verified running on the real checkpoints: 3,988,052 PBR voxels x 6 channels in 65.8s (base_color 0:3, metallic 3:4, roughness 4:5, alpha 5:6). Two things here fail SILENTLY rather than loudly, so both are asserted in comments: 1. shape_slat arrives DENORMALISED - the shape stage un-standardises it for the decoder - but the texture flow was trained against the standardised form. It is re-normalised before use as concat_cond. Skipping that gives a plausible mesh with wrong colours, not an error. 2. tex_dec has pred_subdiv=False: it cannot invent subdivisions and must be handed the shape decoder's subs as guides, so texture voxels land on the geometry that was actually built. The decoder's output is mapped * 0.5 + 0.5 into [0,1], the range o_voxel expects. BAKE ORDER. Handing o_voxel the raw ~8M-face mesh hangs - the same wall the standalone remesh test hit (killed at 20min), and the trellis2 lane's own operator note says the uncapped bake peaks at 75GB. So the mesh is welded, stripped of floaters and decimated BEFORE baking; the baker samples the attribute VOLUME at mesh positions, so a decimated mesh still gets correct colours. Measured on the way through: welded 3,988,052 -> 3,983,672 verts floaters 12 components -> 1 kept, 6,332 faces dropped decimated 7,996,876 -> 214,322 faces pre-bake 34.6s That floater count is worth noting: 12 components, not the 52,855 the first health pass reported. Welding first is what makes the difference. remesh now defaults OFF in to_glb, unlike upstream. Upstream runs on CUDA; this is the CPU/Metal build and its remesher took >20 minutes on a 214k-face mesh. It is also handed an already-clean mesh, so there is far less for it to fix. Operator gains texture + texture_size params; geometry-only stays the default because it is ~3min against the textured path's extra flow and bake. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
6.1 KiB
Python
150 lines
6.1 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, concat_cond: SparseTensor | None = None):
|
|
self.sigma_min = sigma_min
|
|
# Carried on the sampler rather than threaded through every call: it is fixed
|
|
# for the whole trajectory (the shape latent never changes while the texture
|
|
# denoises) and it must reach BOTH the positive and negative CFG branches.
|
|
self.concat_cond = concat_cond
|
|
|
|
# -- 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
|
|
|
|
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)
|
|
tt = mx.full((b,), 1000.0 * t, dtype=mx.float32) # upstream scales t by 1000
|
|
if self.concat_cond is not None:
|
|
return model(x_t, tt, cond, concat_cond=self.concat_cond)
|
|
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,
|
|
guidance_rescale: float = 0.0,
|
|
):
|
|
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))
|
|
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
|
|
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,
|
|
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,
|
|
guidance_rescale,
|
|
)
|
|
x = _like(x, _feats(x) - (t - t_prev) * v)
|
|
mx.eval(_feats(x))
|
|
if progress:
|
|
progress(i + 1, steps)
|
|
return x
|