pixal3d_mrp_mlx/pixal3d_mlx/mesh.py
m3ultra b6f0d76619 Texture stage: tex SLAT + PBR decode, and the bake order that makes it tractable
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>
2026-08-03 18:05:17 +10:00

146 lines
5.9 KiB
Python

"""Flexible Dual Grid -> triangle mesh -> GLB, via o_voxel.
The shape decoder emits **7 channels per occupied voxel**, and they are not a
signed-distance field — O-Voxel's Flexible Dual Grid solves a QEF instead, which is
what lets it carry open and non-manifold surfaces that marching cubes cannot:
0:3 vertex offset inside the voxel, `(1+2m)*sigmoid(v) - m` so it may sit
slightly OUTSIDE its own cell (m = voxel_margin = 0.5)
3:6 per-axis intersection flags — logits at inference, thresholded at 0
6:7 quad split weight, through softplus
`o_voxel.convert.flexible_dual_grid_to_mesh` turns those into vertices and faces, and
`o_voxel.postprocess.to_glb` does UV unwrap plus texture baking. Both are native
(C++/Metal) and are NOT ported: o-voxel builds a CPU CppExtension when CUDA is absent,
and the trellis-2 lane on this fleet already runs it with a Metal baker. Reusing that
build is strictly better than reimplementing a QEF solver in MLX.
o_voxel speaks torch, so this module is the MLX->torch boundary for the export path.
"""
from __future__ import annotations
from typing import Tuple
import mlx.core as mx
import numpy as np
# Upstream fixes both: the model always works in a unit cube centred on the origin.
AABB = [[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]]
# How the texture decoder's 6 channels map to PBR slots (pipeline's pbr_attr_layout).
# o_voxel indexes this dict by name and raises KeyError on any missing slot, so a
# partial layout fails deep inside the baker rather than at the call.
PBR_ATTR_LAYOUT = {
"base_color": slice(0, 3),
"metallic": slice(3, 4),
"roughness": slice(4, 5),
"alpha": slice(5, 6),
}
def _torch(a):
import torch
return torch.from_numpy(np.asarray(a))
def output_resolution(h, upsample_factor: int = 16) -> int:
"""The decoder's OUTPUT grid size, which is what o_voxel needs.
The shape decoder applies four 2x upsamples, so a resolution-64 latent decodes into
a 1024^3 grid. The `resolution` field in the checkpoint config is the decoder's
configured default (256) — upstream overrides it per run via `set_resolution`, so
reading it off the config gives the wrong grid and o_voxel's hashmap then raises an
opaque out-of-bounds deep inside `insert`.
"""
return int(mx.max(h.coords[:, 1:]).item()) // upsample_factor * upsample_factor + upsample_factor
def fdg_to_mesh(h, resolution: int, voxel_margin: float = 0.5) -> Tuple:
"""Shape-decoder output -> (vertices, faces) as torch tensors.
`h` is the decoder's SparseTensor: `h.feats` [N,7], `h.coords` [N,4] with the batch
index in column 0. `resolution` is the OUTPUT grid size (see `output_resolution`),
not the decoder's configured one. Single batch item only, which is all inference
ever produces.
"""
from o_voxel.convert import flexible_dual_grid_to_mesh
hi = int(mx.max(h.coords[:, 1:]).item())
if hi >= resolution:
raise ValueError(
f"coords reach {hi} but grid_size={resolution}; pass the decoder's OUTPUT "
f"resolution (input_res * 16), not its configured default"
)
feats = h.feats
m = voxel_margin
vertices = (1 + 2 * m) * mx.sigmoid(feats[..., 0:3]) - m
intersected = feats[..., 3:6] > 0 # logits -> bool at inference
quad_lerp = mx.logaddexp(feats[..., 6:7], mx.zeros_like(feats[..., 6:7])) # softplus
v, f = flexible_dual_grid_to_mesh(
_torch(h.coords[:, 1:]).int(),
_torch(vertices).float(),
_torch(intersected).bool(),
_torch(quad_lerp).float(),
aabb=AABB,
grid_size=resolution,
train=False,
)
return v, f
def to_glb(vertices, faces, tex_voxels, attr_layout: dict, resolution: int,
texture_size: int = 4096, decimation_target: int = 1_000_000,
prefer_metal: bool = True, remesh: bool = False):
"""Bake the texture voxels onto the mesh and return a trimesh GLB scene.
`tex_voxels` is the texture decoder's SparseTensor (attrs in `.feats`, positions in
`.coords`). `attr_layout` maps PBR channel names to slices of that feature vector.
"""
try:
if not prefer_metal:
raise ImportError
from o_voxel import postprocess as pp
except ImportError:
from o_voxel import postprocess_cpu as pp
return pp.to_glb(
vertices=vertices,
faces=faces,
attr_volume=_torch(tex_voxels.feats).float(),
coords=_torch(tex_voxels.coords[:, 1:]).int(),
attr_layout=attr_layout,
grid_size=resolution,
aabb=AABB,
decimation_target=decimation_target,
texture_size=texture_size,
# `remesh` defaults OFF here, unlike upstream. Upstream runs on CUDA; this
# build is the CPU/Metal one and its remesher took >20 minutes on a 214k-face
# mesh before being killed. We also hand it an already-welded, floater-free,
# decimated mesh, so the remesh has much less to fix than it would upstream.
remesh=remesh, remesh_band=1, remesh_project=0,
)
# Upstream rotates the asset out of its internal frame on the way out (inference.py).
EXPORT_ROTATION = np.array([[-1, 0, 0, 0],
[0, 0, -1, 0],
[0, -1, 0, 0],
[0, 0, 0, 1]], dtype=np.float64)
def to_camera_frame(vertices):
"""Mesh vertices -> the frame `proj.project_points` expects.
THE GOTCHA: o_voxel returns vertices in the VOXEL GRID's frame — a linear map from
integer coords into the aabb. `ProjGrid` rotates its lattice by `_BLENDER_ROT`
BEFORE projecting, so mesh vertices must be rotated the same way to be compared
against the source image. Skipping this does not throw; it silently reprojects a
rotated object, which reads as a plausible-looking blob with a halo. It cost a
wrong diagnosis here: a correct 0.969 silhouette IoU measured as 0.640.
"""
from .proj import _BLENDER_ROT
return np.asarray(vertices) @ _BLENDER_ROT.T