""" Mesh generator: text/image → 3D mesh. - Preferred: Hunyuan3D-2 full pipeline (text → HunyuanDiT → shape → texture). Set HUNYUAN3D2_ROOT or clone ./Hunyuan3D-2. - Fallback: TripoSR (image→mesh). Expects TripoSR repo at project_root/TripoSR. Text→mesh uses SD for text→image then TripoSR. """ import os import subprocess import sys import time from pathlib import Path def find_triposr_root(project_root: str | None = None) -> str | None: """Locate TripoSR repo: ./TripoSR or ../TripoSR from script dir.""" if project_root is None: project_root = str(Path(__file__).resolve().parent.parent) candidates = [ os.path.join(project_root, "TripoSR"), os.path.join(project_root, "..", "TripoSR"), ] for p in candidates: run_py = os.path.join(p, "run.py") if os.path.isfile(run_py): return p return None def _obj_texture_to_glb(obj_path: str, texture_path: str, glb_path: str) -> None: """ Convert OBJ + texture.png to a valid GLB with embedded texture. TripoSR's xatlas.export writes OBJ format regardless of extension; use this to produce real GLB. """ import trimesh from PIL import Image mesh = trimesh.load(obj_path, file_type="obj", process=False) if not isinstance(mesh, trimesh.Trimesh): mesh = mesh.dump(concatenate=True) if hasattr(mesh, "dump") else None if mesh is None: return # Ensure texture image is set (xatlas may not write MTL) if hasattr(mesh, "visual") and mesh.visual is not None and getattr(mesh.visual, "uv", None) is not None: try: img = Image.open(texture_path) from trimesh.visual import TextureVisuals mesh.visual = TextureVisuals(uv=mesh.visual.uv, image=img) except Exception: pass mesh.export(glb_path) def _is_obj_content(path: str) -> bool: """Return True if file content looks like OBJ (TripoSR xatlas writes OBJ even when path is .glb).""" try: with open(path, "rb") as f: return f.read(2).strip() == b"v" except Exception: return False def _smooth_mesh_file(mesh_path: str, iterations: int = 3, lamb: float = 0.5) -> None: """Apply light Laplacian smoothing to a mesh file in-place. Preserves UVs.""" import trimesh loaded = trimesh.load(mesh_path, force="mesh", process=False) if isinstance(loaded, trimesh.Trimesh): mesh = loaded elif hasattr(loaded, "dump"): mesh = loaded.dump(concatenate=True) else: return if mesh is None: return try: trimesh.smoothing.filter_laplacian(mesh, lamb=lamb, iterations=iterations) except Exception: return mesh.export(mesh_path) def generate_mesh_from_image( image_path: str, output_dir: str = "outputs", mesh_format: str = "glb", triposr_root: str | None = None, device: str | None = None, use_hunyuan3d2: bool | None = None, mc_resolution: int = 512, bake_texture: bool = True, texture_resolution: int = 2048, smooth_mesh: bool = True, ) -> tuple[str | None, float, str]: """ Image → 3D mesh. Uses Hunyuan3D-2 when available (use_hunyuan3d2=True or repo found), else TripoSR. mc_resolution: marching cubes grid (256=faster, 512=higher quality). bake_texture: use texture atlas. smooth_mesh: apply light Laplacian smoothing. Returns (path_to_mesh, inference_time_sec, message). """ project_root = str(Path(__file__).resolve().parent.parent) hunyuan_root = find_hunyuan3d2_root(project_root) if use_hunyuan3d2 is True or (use_hunyuan3d2 is None and hunyuan_root is not None): if hunyuan_root: from scripts.hunyuan3d_text_to_mesh import generate_mesh_from_image_hunyuan3d2 return generate_mesh_from_image_hunyuan3d2( image_path, output_dir=output_dir, mesh_format=mesh_format, seed=42, with_texture=True, hunyuan_root=hunyuan_root, ) if use_hunyuan3d2 is True: return (None, 0.0, "Hunyuan3D-2 repo not found. Set HUNYUAN3D2_ROOT or clone ./Hunyuan3D-2 (see README).") triposr_root = triposr_root or find_triposr_root(project_root) if not triposr_root: return ( None, 0.0, "TripoSR not found. Clone it: git clone https://github.com/VAST-AI-Research/TripoSR.git", ) device = device or _infer_device() Path(output_dir).mkdir(parents=True, exist_ok=True) # When baking texture, xatlas.export() always writes OBJ format (ignores .glb extension). # Ask for OBJ when bake_texture + glb, then we convert OBJ+texture to real GLB. triposr_format = "obj" if (bake_texture and mesh_format == "glb") else mesh_format run_py = os.path.join(triposr_root, "run.py") cmd = [ sys.executable, run_py, image_path, "--output-dir", output_dir, "--model-save-format", triposr_format, "--device", device, "--mc-resolution", str(mc_resolution), ] if bake_texture: cmd += ["--bake-texture", "--texture-resolution", str(texture_resolution)] # Use only the current env (venv) so torch and torchvision match; avoids # "operator torchvision::nms does not exist" when user site-packages mixed in. env = os.environ.copy() env["PYTHONNOUSERSITE"] = "1" # TripoSR downloads its model from Hugging Face Hub on first run; ensure token is available in subprocess hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN") if hf_token: env["HF_TOKEN"] = hf_token env["HUGGING_FACE_HUB_TOKEN"] = hf_token t0 = time.perf_counter() try: result = subprocess.run( cmd, cwd=triposr_root, env=env, capture_output=True, text=True, timeout=600, ) t1 = time.perf_counter() if result.returncode != 0: err_text = (result.stderr or result.stdout or "unknown").strip() if "403" in err_text or "forbidden" in err_text.lower(): return ( None, t1 - t0, "TripoSR got 403 from Hugging Face Hub (model download). " "Set HF_TOKEN in this Space: Settings → Variables and secrets (get a token at huggingface.co/settings/tokens).", ) return (None, t1 - t0, f"TripoSR failed: {err_text}") # Output is output_dir/0/mesh.glb or mesh.obj out_subdir = os.path.join(output_dir, "0") mesh_path = os.path.join(out_subdir, f"mesh.{triposr_format}") if not os.path.isfile(mesh_path): return (None, t1 - t0, f"TripoSR did not produce {mesh_path}") # If we asked for GLB but TripoSR wrote OBJ (bake_texture), convert to real GLB if bake_texture and mesh_format == "glb": texture_path = os.path.join(out_subdir, "texture.png") glb_path = os.path.join(out_subdir, "mesh.glb") try: _obj_texture_to_glb(mesh_path, texture_path, glb_path) mesh_path = glb_path except Exception: pass # keep mesh.obj path if conversion fails # If existing file is misnamed (OBJ content in .glb), repair it elif mesh_format == "glb" and _is_obj_content(mesh_path): texture_path = os.path.join(out_subdir, "texture.png") try: _obj_texture_to_glb(mesh_path, texture_path, mesh_path) except Exception: pass if smooth_mesh: try: _smooth_mesh_file(mesh_path, iterations=3, lamb=0.5) except Exception: pass return (os.path.abspath(mesh_path), t1 - t0, "OK") except subprocess.TimeoutExpired: t1 = time.perf_counter() return (None, t1 - t0, "TripoSR timed out (10 min). Try lower resolution (256) or disable bake texture.") except Exception as e: t1 = time.perf_counter() return (None, t1 - t0, str(e)) def find_hunyuan3d2_root(project_root: str | None = None) -> str | None: """Locate Hunyuan3D-2 repo. Delegates to hunyuan3d_text_to_mesh.""" try: from scripts.hunyuan3d_text_to_mesh import find_hunyuan3d2_root as _find return _find(project_root) except Exception: return None def _infer_device() -> str: """Use GPU if available, else CPU (for CPU-only Spaces).""" try: import torch return "cuda:0" if torch.cuda.is_available() else "cpu" except Exception: return "cpu" def generate_mesh_from_text( prompt: str, output_dir: str = "outputs", mesh_format: str = "glb", seed: int | None = None, use_hunyuan3d2: bool | None = None, mc_resolution: int = 512, bake_texture: bool = True, texture_resolution: int = 2048, smooth_mesh: bool = True, device: str | None = None, ) -> tuple[str | None, float, str]: """ Text → 3D mesh. Uses Hunyuan3D-2 full pipeline when available (use_hunyuan3d2=True or repo found), else TripoSR (SD for text→image then TripoSR). Returns (path_to_mesh, total_time_sec, message). """ _root = str(Path(__file__).resolve().parent.parent) if _root not in sys.path: sys.path.insert(0, _root) Path(output_dir).mkdir(parents=True, exist_ok=True) seed = seed if seed is not None else 42 # Prefer Hunyuan3D-2 when requested or when it's the only backend available hunyuan_root = find_hunyuan3d2_root(_root) if use_hunyuan3d2 is True or (use_hunyuan3d2 is None and hunyuan_root is not None): if hunyuan_root: from scripts.hunyuan3d_text_to_mesh import generate_mesh_from_text_hunyuan3d2 return generate_mesh_from_text_hunyuan3d2( prompt, output_dir=output_dir, mesh_format=mesh_format, seed=seed, with_texture=True, hunyuan_root=hunyuan_root, ) if use_hunyuan3d2 is True: return (None, 0.0, "Hunyuan3D-2 repo not found. Set HUNYUAN3D2_ROOT or clone ./Hunyuan3D-2 (see README).") # TripoSR path: text → image (SD) → mesh from scripts.text_to_image import text_to_image t0 = time.perf_counter() try: image_path, _ = text_to_image(prompt, output_dir=output_dir, seed=seed) except Exception as e: return (None, 0.0, f"Text-to-image failed: {e}") device = device or _infer_device() mesh_path, mesh_time, msg = generate_mesh_from_image( image_path, output_dir=os.path.join(output_dir, "mesh_run"), mesh_format=mesh_format, mc_resolution=mc_resolution, bake_texture=bake_texture, texture_resolution=texture_resolution, smooth_mesh=smooth_mesh, device=device, ) total_time = time.perf_counter() - t0 if mesh_path: return (mesh_path, total_time, msg) return (None, total_time, msg)