#!/usr/bin/env python3 """End-to-end image -> textured GLB, fully local on Apple Silicon (MLX). Chains Hunyuan3D 2.1 Stage 1 (shape) + Stage 2 (PBR texture) into one command. Distilled from tests/test_stage1_to_stage2.py, plus CLI flags, stage timings, and MLX peak-memory reporting for MODELBEAST job logs. python generate_e2e.py IMAGE --output STEM [flags] Produces (STEM given without extension): STEM_shape.glb Stage 1 mesh (untextured) [omitted with --no-texture] STEM.glb final PBR-textured mesh [or the shape mesh if --no-texture] """ import argparse import gc import os import sys import time from pathlib import Path REPO = Path(__file__).resolve().parent def _mlx_mem(fn_names): import mlx.core as mx candidates = [] for name in fn_names: candidates.append(getattr(mx, name, None)) metal = getattr(mx, "metal", None) if metal is not None: candidates.append(getattr(metal, name, None)) for fn in candidates: if fn is None: continue try: return fn() except Exception: continue return None def peak_gb(): v = _mlx_mem(["get_peak_memory"]) return v / 1e9 if v is not None else float("nan") def reset_peak(): _mlx_mem(["reset_peak_memory"]) def main(): ap = argparse.ArgumentParser(description="Hunyuan3D 2.1 MLX image->textured GLB") ap.add_argument("image", help="input reference image (ideally a bg-removed cutout)") ap.add_argument("--output", required=True, help="output path stem (extension ignored)") # Stage 1 (shape) ap.add_argument("--steps", type=int, default=50, help="Stage 1 denoising steps") ap.add_argument("--guidance", type=float, default=7.5, help="Stage 1 CFG scale") ap.add_argument("--octree-resolution", type=int, default=256, help="Stage 1 marching-cubes grid") # Stage 2 (texture) ap.add_argument("--max-num-view", type=int, default=6, help="Stage 2 baked views") ap.add_argument("--view-resolution", type=int, default=512, help="Stage 2 per-view diffusion res") ap.add_argument("--texture-size", type=int, default=2048, help="Stage 2 UV atlas res (2048|4096)") ap.add_argument("--remesh-faces", type=int, default=40000, help="Stage 2 bake-mesh decimation target") # Common ap.add_argument("--seed", type=int, default=42) ap.add_argument("--no-texture", action="store_true", help="Stage 1 only (geometry, much faster)") ap.add_argument("--weights", default="dgrauet/hunyuan3d-2.1-mlx", help="HF repo id or local dir") a = ap.parse_args() # Resolve I/O to absolute BEFORE chdir; the pipeline reads relative config # paths (hy3dpaint/cfgs/...), so it must run from the repo root. image_path = str(Path(a.image).resolve()) out_stem = Path(a.output).resolve() out_stem.parent.mkdir(parents=True, exist_ok=True) if not os.path.exists(image_path): print(f"ERROR: image not found: {image_path}", flush=True) sys.exit(1) os.environ["HY3D_REMESH_FACES"] = str(a.remesh_faces) os.environ.setdefault("HF_HUB_DISABLE_XET", "1") os.chdir(REPO) sys.path.insert(0, str(REPO / "hy3dshape")) sys.path.insert(0, str(REPO / "hy3dpaint")) # ---- Stage 1: image -> mesh ---- print(f"[Stage 1] shape generation from {image_path}", flush=True) from hy3dshape.pipeline_mlx import ShapePipeline reset_peak() t0 = time.time() shape_pipe = ShapePipeline.from_pretrained(a.weights) print(f" pipeline ready in {time.time() - t0:.1f}s", flush=True) t0 = time.time() mesh = shape_pipe( image_path, num_inference_steps=a.steps, guidance_scale=a.guidance, octree_resolution=a.octree_resolution, seed=a.seed, ) dt1 = time.time() - t0 nverts, nfaces = len(mesh.vertices), len(mesh.faces) print(f" shape in {dt1:.1f}s ({nverts} verts, {nfaces} faces), " f"peak {peak_gb():.1f}GB", flush=True) if a.no_texture: final = out_stem.with_suffix(".glb") mesh.export(str(final)) print(f"[done] {final} (geometry only, {dt1:.0f}s)", flush=True) return shape_glb = str(out_stem.parent / (out_stem.name + "_shape.glb")) mesh.export(shape_glb) print(f" saved shape: {shape_glb}", flush=True) # Free the DiT before loading the paint stack (both are memory-heavy) del shape_pipe, mesh gc.collect() reset_peak() # ---- Stage 2: mesh + reference -> textured GLB ---- print("[Stage 2] PBR texture synthesis", flush=True) from textureGenPipeline_mlx import ( Hunyuan3DPaintConfigMLX, Hunyuan3DPaintPipelineMLX, ) cfg = Hunyuan3DPaintConfigMLX(max_num_view=a.max_num_view, resolution=a.view_resolution) cfg.texture_size = a.texture_size cfg.mlx_seed = a.seed t0 = time.time() paint_pipe = Hunyuan3DPaintPipelineMLX(cfg) print(f" pipeline ready in {time.time() - t0:.1f}s", flush=True) tex_obj = str(out_stem.with_suffix(".obj")) t0 = time.time() paint_pipe( mesh_path=shape_glb, image_path=image_path, output_mesh_path=tex_obj, use_remesh=True, save_glb=True, ) dt2 = time.time() - t0 final = out_stem.with_suffix(".glb") if not final.exists(): print(f"ERROR: expected textured GLB not produced at {final}", flush=True) sys.exit(2) size_mb = final.stat().st_size / 1024 / 1024 print(f" texture in {dt2:.1f}s, peak {peak_gb():.1f}GB", flush=True) print(f"[done] {final} ({size_mb:.1f} MB) " f"shape={dt1:.0f}s tex={dt2:.0f}s total={dt1 + dt2:.0f}s", flush=True) if __name__ == "__main__": main()