pixal3d_mrp_mlx/pixal3d_mlx/decoders.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

270 lines
9.6 KiB
Python

"""Pixal3D sparse VAE decoders in MLX — `shape_dec` and `tex_dec`.
Both are the same U-Net: stages of `SparseConvNeXtBlock3d`, each stage ending in a
`SparseResBlockC2S3d` that upsamples via channel->spatial. The occupied set grows
selectively at every stage, driven by a predicted subdivision mask, which is how the
decoder reaches 256^3-equivalent detail without ever materialising a dense grid.
model_channels [1024, 512, 256, 128, 64]
num_blocks [4, 16, 8, 4, 0] <- last stage is empty and has no up-block
block_type SparseConvNeXtBlock3d
up_block_type SparseResBlockC2S3d
`FlexiDualGridVaeDecoder` (shape_dec) subclasses it and hardcodes **7** output channels
regardless of config — 3 for vertex offsets (squashed through a sigmoid widened by
`voxel_margin`) plus 4 more — so its `out_channels` is not a config field at all.
`SparseUnetVaeDecoder` (tex_dec) uses the config's `out_channels` (6) and sets
`pred_subdiv=False`, meaning it needs subdivision masks supplied from outside rather
than predicting its own.
NOTE ON VERIFICATION: unlike the flow models and ss_dec, these use sparse convolution,
so upstream cannot run here (no spconv on Metal) and there is no numerical oracle. They
are built from blocks that ARE individually tested, but the assembled graphs are checked
only for shape, finiteness and plausible behaviour. Treat them as less certain than
anything reporting a correlation.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
import mlx.core as mx
import mlx.nn as nn
from trellis_sparse_mlx import (
SparseConvNeXtBlock3d,
SparseLinear,
SparseResBlockC2S3d,
SparseTensor,
)
_BLOCKS = {
"SparseConvNeXtBlock3d": SparseConvNeXtBlock3d,
"SparseResBlockC2S3d": SparseResBlockC2S3d,
}
class SparseUnetVaeDecoder(nn.Module):
def __init__(
self,
out_channels: int,
model_channels: List[int],
latent_channels: int,
num_blocks: List[int],
block_type: List[str],
up_block_type: List[str],
block_args: Optional[List[Dict[str, Any]]] = None,
pred_subdiv: bool = True,
**_ignored,
):
super().__init__()
self.out_channels = out_channels
self.model_channels = list(model_channels)
self.num_blocks = list(num_blocks)
self.pred_subdiv = pred_subdiv
block_args = block_args or [{} for _ in num_blocks]
self.from_latent = SparseLinear(latent_channels, model_channels[0])
self.output_layer = SparseLinear(model_channels[-1], out_channels)
stages = []
for i in range(len(num_blocks)):
stage = [
_BLOCKS[block_type[i]](model_channels[i], **block_args[i])
for _ in range(num_blocks[i])
]
if i < len(num_blocks) - 1:
stage.append(
_BLOCKS[up_block_type[i]](
model_channels[i],
model_channels[i + 1],
pred_subdiv=pred_subdiv,
)
)
stages.append(stage)
self.blocks = stages
def __call__(
self,
x: SparseTensor,
guide_subs: Optional[List[SparseTensor]] = None,
return_subs: bool = False,
):
h = self.from_latent(x)
subs = []
for i, stage in enumerate(self.blocks):
for j, blk in enumerate(stage):
is_up = i < len(self.blocks) - 1 and j == len(stage) - 1
if is_up:
if self.pred_subdiv:
h, sub = blk(h)
subs.append(sub)
else:
h = blk(h, subdiv=guide_subs[i] if guide_subs else None)
else:
h = blk(h)
# Same parameterless final LayerNorm as every other model in this family.
f = h.feats
f = (f - mx.mean(f, -1, keepdims=True)) * mx.rsqrt(
mx.var(f, -1, keepdims=True) + 1e-5
)
out = self.output_layer(h.replace(f))
return (out, subs) if return_subs else out
def upsample(self, x: SparseTensor, upsample_times: int) -> mx.array:
"""Run only the first `upsample_times` stages and return the COORDS.
The cascade needs a refined coordinate set, not features: the low-res SLAT is
pushed part-way through the decoder purely so its predicted subdivisions grow
the occupied set, and the resulting coords seed the high-res flow. Each stage
doubles resolution, so `upsample_times=4` yields coords at 16x the input.
Deliberately stops BEFORE running stage `upsample_times`, matching upstream —
running one stage further would return coords at 2x the intended resolution
and silently misplace every voxel in the high-res pass.
"""
if not self.pred_subdiv:
raise ValueError("upsample needs a decoder with pred_subdiv=True")
h = self.from_latent(x)
for i, stage in enumerate(self.blocks):
if i == upsample_times:
return h.coords
for j, blk in enumerate(stage):
if i < len(self.blocks) - 1 and j == len(stage) - 1:
h, _ = blk(h)
else:
h = blk(h)
return h.coords
class FlexiDualGridVaeDecoder(SparseUnetVaeDecoder):
"""shape_dec. Emits 7 channels; the first 3 are vertex offsets within the voxel."""
def __init__(
self,
resolution: int,
model_channels: List[int],
latent_channels: int,
num_blocks: List[int],
block_type: List[str],
up_block_type: List[str],
block_args: Optional[List[Dict[str, Any]]] = None,
voxel_margin: float = 0.5,
**_ignored,
):
# out_channels is hardcoded to 7 upstream, not read from config
super().__init__(
7,
model_channels,
latent_channels,
num_blocks,
block_type,
up_block_type,
block_args,
pred_subdiv=True,
)
self.resolution = resolution
self.voxel_margin = voxel_margin
def decode_vertices(self, h: SparseTensor) -> mx.array:
"""Channels 0:3 -> vertex offsets, sigmoid widened by `voxel_margin`.
`(1 + 2m)*sigmoid(v) - m` maps to [-m, 1+m], letting a vertex sit slightly
outside its own voxel — that overshoot is what lets the dual grid represent
surfaces that do not pass exactly through cell centres.
"""
m = self.voxel_margin
return (1 + 2 * m) * mx.sigmoid(h.feats[..., 0:3]) - m
# ------------------------------------------------------------------ loading
def _remap(k: str) -> str:
"""Upstream: blocks.<stage>.<idx>.<...>; SparseLinear wraps an nn.Linear."""
for name in ("from_latent", "output_layer", "to_subdiv"):
k = k.replace(f"{name}.weight", f"{name}.linear.weight")
k = k.replace(f"{name}.bias", f"{name}.linear.bias")
k = k.replace(".mlp.0.", ".mlp_0.").replace(".mlp.2.", ".mlp_2.")
k = k.replace(".conv1.conv.", ".conv1.").replace(".conv2.conv.", ".conv2.")
k = k.replace(".conv.conv.", ".conv.")
k = k.replace(".norm.weight", ".norm.weight").replace(".norm1.weight", ".norm1.weight")
return k
def load(weights_path: str | Path, config_path: str | Path):
cfg = json.loads(Path(config_path).read_text())
name, args = cfg["name"], dict(cfg.get("args", {}))
args.pop("use_fp16", None)
cls = {
"FlexiDualGridVaeDecoder": FlexiDualGridVaeDecoder,
"SparseUnetVaeDecoder": SparseUnetVaeDecoder,
}[name]
model = cls(**args)
w = mx.load(str(weights_path))
flat = dict(_flatten(model.parameters()))
mapped, unmapped, mismatched = {}, [], []
for k, v in w.items():
m = _remap(k)
if m in flat:
if flat[m].shape != v.shape:
mismatched.append(f"{k} -> {m}: want {flat[m].shape} got {v.shape}")
continue
mapped[m] = v
else:
unmapped.append(f"{k} -> {m} {tuple(v.shape)}")
missing = [k for k in flat if k not in mapped]
if mapped:
model.update(_unflatten(mapped))
return model, {
"config": name,
"loaded": len(mapped),
"params": len(flat),
"missing": missing,
"unmapped": unmapped,
"mismatched": mismatched,
}
def _flatten(tree, prefix=""):
if isinstance(tree, dict):
for k, v in tree.items():
yield from _flatten(v, f"{prefix}{k}.")
elif isinstance(tree, list):
for i, v in enumerate(tree):
yield from _flatten(v, f"{prefix}{i}.")
elif isinstance(tree, mx.array):
yield prefix[:-1], tree
def _unflatten(flat: dict):
root: dict = {}
for key, val in flat.items():
parts = key.split(".")
node = root
for i, p in enumerate(parts[:-1]):
nxt = parts[i + 1]
default = [] if nxt.isdigit() else {}
if isinstance(node, list):
idx = int(p)
while len(node) <= idx:
node.append({})
if isinstance(default, list) and not isinstance(node[idx], list):
node[idx] = default
node = node[idx]
else:
if p not in node or not isinstance(node[p], (dict, list)):
node[p] = default
node = node[p]
if isinstance(node, list):
idx = int(parts[-1])
while len(node) <= idx:
node.append(None)
node[idx] = val
else:
node[parts[-1]] = val
return root