evoneuralIn3D-app / scripts /mesh_generator.py
manav0506's picture
Sync deps: Dockerfile system deps + ffmpeg, single pip flow
c179ef4
Raw
History Blame Contribute Delete
11.2 kB
"""
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)