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>
146 lines
5.9 KiB
Python
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
|