pixal3d_mrp_mlx/scripts/image_to_mesh.py
m3ultra d338eca925 Vertex-colour baker: 0.2s against the UV path's >20 minutes
The texture stage produced correct PBR voxels, but getting them ONTO a mesh through
o_voxel's UV path is not viable on this build. Measured, on an already welded,
floater-free, decimated 214k-face mesh:

  o_voxel to_glb, remesh=True    killed at 20min
  o_voxel to_glb, remesh=False   >20min CPU, killed
  bake_vertex_colors             0.2s

xatlas scales badly and 214k is the decimation floor, so it cannot be fed a smaller
mesh either. The trellis-2 lane reached the same conclusion independently and ships
--baker vertex as its fast path; this now matches.

bake_vertex_colors samples the PBR attribute volume at each vertex and writes COLOR_0.
Positions map to voxel indices by the same linear aabb relation fdg_to_mesh uses, so
nothing is resampled; lookup is a sorted-key searchsorted, and misses keep neutral
grey rather than black.

Verified on the real pipeline output:

  bake     vertex colours, 96.1% of vertices hit      0.2s
  result   90,093 verts / 214,322 faces
           57,925 unique colours, mean RGB [105 100 87], std [40 35 38]
           3.9% still default grey (matches the 4% miss rate)
  TOTAL    266.2s end to end, peak 32.6GB

What this costs: no metallic/roughness maps, base colour only. That is the honest
trade and it is stated in the operator description rather than buried - remesh also
now defaults OFF in to_glb, since upstream's remesh=True assumes CUDA.

Operator gains a baker param (vertex default, uv opt-in and flagged offline-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 18:23:58 +10:00

169 lines
7.4 KiB
Python

"""Image -> GLB through the full Pixal3D cascade, with a silhouette check.
Usage: python scripts/image_to_mesh.py IMAGE [-o OUT.glb] [--fov RAD] [--seed N]
The silhouette IoU is the acceptance test: re-project the mesh through the same camera
and compare against the input matte. Pixal3D's entire claim is pixel alignment, so a
run that completes with a poor IoU has failed even though nothing raised.
"""
import argparse
import json
import sys
import time
from pathlib import Path
import mlx.core as mx
import numpy as np
import trimesh
REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))
from pixal3d_mlx.mesh import PBR_ATTR_LAYOUT, to_camera_frame # noqa: E402
from pixal3d_mlx.models import load_all, normalization # noqa: E402
from pixal3d_mlx.pipeline import DEFAULT_FOV, image_to_mesh # noqa: E402
DEFAULT_IMAGE = REPO / "upstream" / "Pixal3D" / "assets" / "images" / "0_img.png"
def silhouette_iou(vertices, image_path, fov, res=512):
"""Re-project the mesh through the generating camera; IoU against the input matte.
Vertices MUST be rotated into the camera frame first — o_voxel returns them in the
voxel-grid frame while ProjGrid rotates its lattice before projecting.
"""
from PIL import Image
from scipy.ndimage import binary_dilation
from pixal3d_mlx.cond import preprocess_image
from pixal3d_mlx.proj import _FRONT_VIEW, distance_from_fov, project_points
img = Image.open(image_path)
if img.mode != "RGBA":
return None
matte = np.asarray(preprocess_image(img).convert("L").resize((res, res))) > 8
tm = _FRONT_VIEW.copy()
tm[1, 3] = -distance_from_fov(fov, 1.0, res)
pts = to_camera_frame(vertices).astype(np.float32)[None]
px, _, _ = project_points(mx.array(pts), mx.array(tm[None]), fov, res)
px = np.asarray(px)[0].astype(int)
keep = (px[:, 0] >= 0) & (px[:, 0] < res) & (px[:, 1] >= 0) & (px[:, 1] < res)
proj = np.zeros((res, res), bool)
proj[px[keep, 1], px[keep, 0]] = True
proj = binary_dilation(proj, np.ones((3, 3), bool))
return (proj & matte).sum() / (proj | matte).sum(), proj, matte
def main():
ap = argparse.ArgumentParser()
ap.add_argument("image", nargs="?", default=str(DEFAULT_IMAGE))
ap.add_argument("-o", "--output", default=str(REPO / "output.glb"))
ap.add_argument("--fov", type=float, default=DEFAULT_FOV)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--target-faces", type=int, default=100_000,
help="face budget after cleanup; 0 disables decimation")
ap.add_argument("--raw", action="store_true",
help="skip cleanup entirely and export the decoder output as-is")
ap.add_argument("--min-iou", type=float, default=0.85,
help="fail the run below this silhouette IoU; 0 disables the gate")
ap.add_argument("--json", help="write run metadata here")
ap.add_argument("--texture", action="store_true",
help="run the texture stage and bake PBR maps through o_voxel")
ap.add_argument("--texture-size", type=int, default=2048)
ap.add_argument("--baker", choices=("vertex", "uv"), default="vertex",
help="vertex = seconds, base colour only (default); "
"uv = o_voxel unwrap + full PBR maps, but >20min CPU here")
a = ap.parse_args()
t = time.time()
models = load_all(with_texture=a.texture)
print(f"loaded models ({time.time() - t:.1f}s, lazy — weights fault in on first use)")
v, f, info = image_to_mesh(a.image, models, camera_angle_x=a.fov, seed=a.seed,
normalization=normalization("shape"),
tex_normalization=normalization("tex") if a.texture else None,
texture=a.texture)
subs = info.pop("subs", None)
info.pop("hr_slat", None)
tex_voxels = info.pop("tex_voxels", None)
if a.texture and tex_voxels is not None:
# CLEAN BEFORE BAKING. o_voxel's remesh+unwrap on the raw ~8M-face mesh hangs
# (killed at 20min); the trellis2 lane hit the same wall and its operator note
# says the uncapped bake peaks at 75GB. Welding and stripping floaters first
# makes it tractable, and the baker samples the attribute VOLUME at mesh
# positions, so a decimated mesh still gets correct colours.
from pixal3d_mlx.cleanup import clean
from pixal3d_mlx.mesh import bake_vertex_colors, to_glb
t = time.time()
pre, _ = clean(v.cpu().numpy(), f.cpu().numpy(),
target_faces=a.target_faces or 500_000)
print(f" pre-bake {len(pre.faces):,} faces {time.time() - t:6.1f}s")
t = time.time()
if a.baker == "vertex":
mesh, bstat = bake_vertex_colors(pre, tex_voxels, info["output_resolution"])
info["bake"] = bstat
print(f" bake vertex colours, {bstat['hit_rate']:.1%} of vertices hit"
f" {time.time() - t:6.1f}s")
else:
import torch
scene = to_glb(torch.from_numpy(np.asarray(pre.vertices, np.float32)),
torch.from_numpy(np.asarray(pre.faces, np.int32)),
tex_voxels, PBR_ATTR_LAYOUT, info["output_resolution"],
texture_size=a.texture_size,
decimation_target=a.target_faces or 500_000)
mesh = scene if isinstance(scene, trimesh.Trimesh) else scene.dump(concatenate=True)
print(f" bake UV {len(mesh.faces):,} faces, {a.texture_size}px "
f"{time.time() - t:6.1f}s")
info["baker"] = a.baker
mesh.export(a.output)
info["faces"], info["vertices"] = len(mesh.faces), len(mesh.vertices)
print(f"\nTOTAL {info['seconds']}s peak {info['peak_gb']} GB -> {a.output}")
if a.json:
Path(a.json).write_text(json.dumps(info, indent=2))
return
if a.raw:
mesh = trimesh.Trimesh(v.cpu().numpy(), f.cpu().numpy(), process=False)
else:
from pixal3d_mlx.cleanup import clean
t = time.time()
mesh, stats = clean(v.cpu().numpy(), f.cpu().numpy(),
target_faces=a.target_faces or None)
print(f" cleanup {time.time() - t:6.1f}s")
info["cleanup"] = stats
mesh.export(a.output)
info["faces"] = len(mesh.faces)
info["vertices"] = len(mesh.vertices)
print(f"\nTOTAL {info['seconds']}s peak {info['peak_gb']} GB "
f"{len(mesh.faces):,} faces -> {a.output}")
if a.min_iou > 0:
got = silhouette_iou(mesh.vertices, a.image, a.fov)
if got is None:
print("(input has no alpha matte — skipping silhouette check)")
else:
iou = float(got[0])
info["silhouette_iou"] = round(iou, 4)
print(f"silhouette IoU {iou:.3f}")
if iou < a.min_iou:
# completing is not succeeding — a run can finish cleanly and still
# have produced a blob that does not match the input at all
print(f"FAIL: IoU {iou:.3f} < {a.min_iou} — not tracking the input")
if a.json:
Path(a.json).write_text(json.dumps(info, indent=2))
sys.exit(1)
if a.json:
Path(a.json).write_text(json.dumps(info, indent=2))
if __name__ == "__main__":
main()