THE DECIMATION FLOOR WAS MISDIAGNOSED. I attributed it to ~180k boundary edges. It is non-manifold edges. Measured on the shipped 500k mesh: boundary edges 32,370 NON-MANIFOLD 81,112 <- the actual blocker, 2.5x more Quadric decimation cannot collapse an edge shared by more than two faces. Upstream's own fix (fill_holes, via CUDA-only cumesh) targets boundaries and caps at max_hole_perimeter=3e-2, so it was never going to help: trimesh's equivalent moved boundaries 32,370 -> 30,990 and the floor only 214k -> 210k. That falsified it. --manifold: voxelise -> fill -> marching cubes. Removes BOTH classes at once and so closes three of the four items in one change: as shipped 499,984 faces bnd 32,370 nonmani 81,112 watertight=F IoU 0.969 remeshed 1,178,142 faces bnd 0 nonmani 0 watertight=T IoU 0.949 -> 20k 19,998 faces bnd 0 winding consistent IoU 0.956 25x smaller, fully manifold, consistent winding, for 1.3% silhouette IoU. Lossy by design - it gives up the dual grid's open-surface representation - so it is opt-in. UV BAKE is unblocked by the same change: its cost is driven by face count, not by remesh. 5.0s at 20k faces against >20min at 214k. No longer offline-only when paired with manifold. THE SCALING TRAP, worth knowing: marching_cubes returns vertices in VOXEL INDEX space. Translating without apply_scale(pitch) leaves the mesh ~292x too large. It still exports and renders as a plausible object; it silhouettes at IoU 0.08. That is how it was caught. MoGe-2 CAMERA is now wired and is the default, matching upstream; --fixed-fov keeps the old constant. It runs once per image in torch/MPS, ~0.4s after load. Reporting this one straight: it did NOT improve the samples. On 1_img, fixed 49.1 deg scored 0.893 and MoGe's 29.7 deg scored 0.883. Two caveats keep it as the default anyway - the silhouette metric projects with the SAME FOV used to generate, so a wrong-but-consistent camera can still score well and the metric cannot fully arbitrate camera correctness; and the bundled samples are synthetic renders, not the photographs MoGe reads. Real photos are the intended input here, and upstream estimates too. But the constant is one flag away and the measurement is on record rather than assumed. Operator gains manifold, divisions, fixed_fov. README and PROFILE.md corrected where they repeated the boundary-edge claim. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
221 lines
9.9 KiB
Python
221 lines
9.9 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(mesh_or_vertices, image_path, fov, res=512, samples=3_000_000):
|
|
"""Re-project the mesh through the generating camera; IoU against the input matte.
|
|
|
|
Points are SAMPLED UNIFORMLY OVER THE SURFACE, not taken from the vertex list.
|
|
Projecting vertices makes the score depend on tessellation: the same shape scored
|
|
0.965 at 227k vertices and 0.790 at 134k, purely because a sparser point cloud
|
|
leaves holes inside its own silhouette. That would fail good assets for the crime
|
|
of being decimated. Fixed-count surface sampling makes density a constant of the
|
|
metric instead of a property of the mesh.
|
|
|
|
Points MUST be rotated into the camera frame — o_voxel returns geometry 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
|
|
|
|
if isinstance(mesh_or_vertices, trimesh.Trimesh):
|
|
pts3, _ = trimesh.sample.sample_surface(mesh_or_vertices, samples)
|
|
else:
|
|
pts3 = np.asarray(mesh_or_vertices) # bare vertices: caller accepts the bias
|
|
|
|
tm = _FRONT_VIEW.copy()
|
|
tm[1, 3] = -distance_from_fov(fov, 1.0, res)
|
|
pts = to_camera_frame(pts3).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 gate(mesh, a, info):
|
|
"""Silhouette check + non-zero exit. Shared by the textured and geometry paths."""
|
|
if a.min_iou <= 0:
|
|
return
|
|
got = silhouette_iou(mesh, a.image, a.fov)
|
|
if got is None:
|
|
print("(input has no alpha matte — skipping silhouette check)")
|
|
return
|
|
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)
|
|
|
|
|
|
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=None,
|
|
help="camera FOV in radians; omitted = estimate per image with "
|
|
"MoGe-2 (upstream's behaviour)")
|
|
ap.add_argument("--fixed-fov", action="store_true",
|
|
help=f"skip MoGe and use the constant {DEFAULT_FOV:.4f} rad")
|
|
ap.add_argument("--manifold", action="store_true",
|
|
help="voxel-remesh to a watertight manifold surface — the only way "
|
|
"past the ~214k non-manifold decimation floor; lossy, opt-in")
|
|
ap.add_argument("--divisions", type=int, default=256,
|
|
help="voxel resolution for --manifold")
|
|
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)")
|
|
|
|
# Camera FIRST — everything downstream is placed by it. A wrong FOV reconstructs
|
|
# at the wrong depth scale and fails silently, so estimation is the default.
|
|
if a.fov is None:
|
|
if a.fixed_fov:
|
|
a.fov = DEFAULT_FOV
|
|
print(f" camera fixed {a.fov:.4f} rad ({np.degrees(a.fov):.1f} deg)")
|
|
else:
|
|
from pixal3d_mlx.camera import camera_for
|
|
t = time.time()
|
|
cam = camera_for(a.image)
|
|
a.fov = cam["camera_angle_x"]
|
|
print(f" camera MoGe-2 {a.fov:.4f} rad ({np.degrees(a.fov):.1f} deg), "
|
|
f"distance {cam['distance']:.3f} {time.time() - t:5.1f}s")
|
|
info_fov = a.fov
|
|
|
|
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)
|
|
info["fov"] = round(float(info_fov), 6)
|
|
info["fov_source"] = "fixed" if a.fixed_fov else "moge2"
|
|
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,
|
|
manifold=a.manifold, divisions=a.divisions)
|
|
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}")
|
|
|
|
# The gate applies to TEXTURED runs too. Skipping it here would mean the
|
|
# textured path — the one most likely to be used for real assets — is the only
|
|
# one that can ship a blob silently.
|
|
gate(mesh, a, info)
|
|
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,
|
|
manifold=a.manifold, divisions=a.divisions)
|
|
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}")
|
|
|
|
gate(mesh, a, info)
|
|
|
|
if a.json:
|
|
Path(a.json).write_text(json.dumps(info, indent=2))
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|