Spaces:
Runtime error
Runtime error
Sync with main evoneural: quality options, mesh viewer, GLB fix, TripoSR in Dockerfile
Browse files- Dockerfile +4 -0
- app.py +105 -20
- requirements.txt +10 -6
- scripts/hunyuan3d_runner/__init__.py +2 -0
- scripts/hunyuan3d_runner/run_text_to_mesh.py +122 -0
- scripts/hunyuan3d_text_to_mesh.py +186 -0
- scripts/mesh_generator.py +155 -15
- scripts/mesh_viewer.py +69 -0
- scripts/panorama_viewer.py +1 -1
Dockerfile
CHANGED
|
@@ -12,8 +12,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
| 12 |
COPY requirements.txt .
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
|
|
|
|
|
|
|
|
|
| 15 |
COPY app.py .
|
| 16 |
COPY scripts/ ./scripts/
|
|
|
|
| 17 |
|
| 18 |
# HF Spaces expose port 7860
|
| 19 |
EXPOSE 7860
|
|
|
|
| 12 |
COPY requirements.txt .
|
| 13 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 14 |
|
| 15 |
+
# TripoSR for mesh generation (model downloaded at runtime from Hugging Face)
|
| 16 |
+
RUN git clone --depth 1 https://github.com/VAST-AI-Research/TripoSR.git TripoSR
|
| 17 |
+
|
| 18 |
COPY app.py .
|
| 19 |
COPY scripts/ ./scripts/
|
| 20 |
+
COPY pages/ ./pages/
|
| 21 |
|
| 22 |
# HF Spaces expose port 7860
|
| 23 |
EXPOSE 7860
|
app.py
CHANGED
|
@@ -76,12 +76,49 @@ def main() -> None:
|
|
| 76 |
mesh_format = st.selectbox("Mesh format", ["glb", "obj"], key="mesh_fmt")
|
| 77 |
seed_mesh = st.number_input("Seed (optional)", value=42, min_value=0, key="mesh_seed")
|
| 78 |
with col2:
|
| 79 |
-
use_image = st.checkbox("Use
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
if st.button("Generate mesh", key="btn_mesh"):
|
| 83 |
-
if not
|
| 84 |
-
st.warning("Enter a prompt or upload an image.")
|
| 85 |
else:
|
| 86 |
with st.spinner("Running pipeline..."):
|
| 87 |
try:
|
|
@@ -96,27 +133,38 @@ def main() -> None:
|
|
| 96 |
"TripoSR not found. Clone it: "
|
| 97 |
"`git clone https://github.com/VAST-AI-Research/TripoSR.git TripoSR`"
|
| 98 |
)
|
| 99 |
-
elif
|
| 100 |
-
path =
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
else:
|
| 115 |
mesh_path, elapsed, msg = generate_mesh_from_text(
|
| 116 |
prompt_mesh,
|
| 117 |
output_dir=str(OUTPUTS),
|
| 118 |
mesh_format=mesh_format,
|
| 119 |
seed=seed_mesh,
|
|
|
|
|
|
|
|
|
|
| 120 |
)
|
| 121 |
if mesh_path:
|
| 122 |
st.success(f"Done in {elapsed:.1f}s. {msg}")
|
|
@@ -127,6 +175,44 @@ def main() -> None:
|
|
| 127 |
except Exception as e:
|
| 128 |
st.exception(e)
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
with tab_skybox:
|
| 131 |
st.subheader("Generate 2:1 equirectangular skybox")
|
| 132 |
st.markdown(
|
|
@@ -209,7 +295,6 @@ def main() -> None:
|
|
| 209 |
option_names = [f.name for f in output_files]
|
| 210 |
|
| 211 |
if output_files:
|
| 212 |
-
# Keep selectbox state valid: if stored value is not in current options, clear it
|
| 213 |
key = "skybox_select_existing"
|
| 214 |
if key in st.session_state and st.session_state[key] not in option_names:
|
| 215 |
del st.session_state[key]
|
|
|
|
| 76 |
mesh_format = st.selectbox("Mesh format", ["glb", "obj"], key="mesh_fmt")
|
| 77 |
seed_mesh = st.number_input("Seed (optional)", value=42, min_value=0, key="mesh_seed")
|
| 78 |
with col2:
|
| 79 |
+
use_image = st.checkbox("Use image (from outputs or upload)", value=False, key="use_img")
|
| 80 |
+
|
| 81 |
+
with st.expander("Quality options (TripoSR)", expanded=True):
|
| 82 |
+
mc_resolution = st.selectbox(
|
| 83 |
+
"Mesh resolution",
|
| 84 |
+
options=[256, 512],
|
| 85 |
+
index=1,
|
| 86 |
+
format_func=lambda x: f"{x} (faster)" if x == 256 else f"{x} (higher quality)",
|
| 87 |
+
key="mesh_mc_res",
|
| 88 |
+
help="Marching cubes grid. 512 gives finer, less blocky meshes.",
|
| 89 |
+
)
|
| 90 |
+
bake_texture = st.checkbox(
|
| 91 |
+
"Bake texture atlas",
|
| 92 |
+
value=True,
|
| 93 |
+
key="mesh_bake_tex",
|
| 94 |
+
help="Produces a texture map instead of vertex colors; usually looks cleaner.",
|
| 95 |
+
)
|
| 96 |
+
smooth_mesh = st.checkbox(
|
| 97 |
+
"Smooth mesh",
|
| 98 |
+
value=True,
|
| 99 |
+
key="mesh_smooth",
|
| 100 |
+
help="Light Laplacian smoothing to reduce blockiness.",
|
| 101 |
+
)
|
| 102 |
+
output_images = sorted(Path(OUTPUTS).glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
|
| 103 |
+
output_images += sorted(Path(OUTPUTS).glob("*.jpg"), key=lambda p: p.stat().st_mtime, reverse=True)
|
| 104 |
+
selected_from_outputs = None
|
| 105 |
+
if use_image and output_images:
|
| 106 |
+
opt_names = [f.name for f in output_images]
|
| 107 |
+
k = "mesh_pick_output_img"
|
| 108 |
+
if k in st.session_state and st.session_state[k] not in opt_names:
|
| 109 |
+
del st.session_state[k]
|
| 110 |
+
pick = st.selectbox("Pick from outputs (e.g. previous mesh input)", ["(upload below)"] + opt_names, key=k)
|
| 111 |
+
if pick and pick != "(upload below)":
|
| 112 |
+
selected_from_outputs = Path(OUTPUTS) / pick
|
| 113 |
+
uploaded = st.file_uploader("Or upload image for mesh", type=["png", "jpg"], key="mesh_upload") if use_image else None
|
| 114 |
+
|
| 115 |
+
image_path_to_use = None
|
| 116 |
+
if use_image and (selected_from_outputs and selected_from_outputs.exists() or uploaded):
|
| 117 |
+
image_path_to_use = str(selected_from_outputs) if (selected_from_outputs and selected_from_outputs.exists()) else "upload"
|
| 118 |
|
| 119 |
if st.button("Generate mesh", key="btn_mesh"):
|
| 120 |
+
if not image_path_to_use and not prompt_mesh.strip():
|
| 121 |
+
st.warning("Enter a prompt or choose/upload an image.")
|
| 122 |
else:
|
| 123 |
with st.spinner("Running pipeline..."):
|
| 124 |
try:
|
|
|
|
| 133 |
"TripoSR not found. Clone it: "
|
| 134 |
"`git clone https://github.com/VAST-AI-Research/TripoSR.git TripoSR`"
|
| 135 |
)
|
| 136 |
+
elif image_path_to_use:
|
| 137 |
+
path = image_path_to_use
|
| 138 |
+
if path == "upload" and uploaded:
|
| 139 |
+
path = os.path.join(OUTPUTS, "uploaded_mesh_input.png")
|
| 140 |
+
with open(path, "wb") as f:
|
| 141 |
+
f.write(uploaded.getvalue())
|
| 142 |
+
if path != "upload" and os.path.isfile(path):
|
| 143 |
+
mesh_path, elapsed, msg = generate_mesh_from_image(
|
| 144 |
+
path,
|
| 145 |
+
output_dir=str(OUTPUTS / "mesh_run"),
|
| 146 |
+
mesh_format=mesh_format,
|
| 147 |
+
mc_resolution=mc_resolution,
|
| 148 |
+
bake_texture=bake_texture,
|
| 149 |
+
smooth_mesh=smooth_mesh,
|
| 150 |
+
)
|
| 151 |
+
if mesh_path:
|
| 152 |
+
st.success(f"Done in {elapsed:.1f}s. {msg}")
|
| 153 |
+
with open(mesh_path, "rb") as f:
|
| 154 |
+
st.download_button("Download mesh", f, file_name=os.path.basename(mesh_path), key="dl_mesh_upload")
|
| 155 |
+
else:
|
| 156 |
+
st.error(msg)
|
| 157 |
+
elif path == "upload":
|
| 158 |
+
st.warning("Upload an image first.")
|
| 159 |
else:
|
| 160 |
mesh_path, elapsed, msg = generate_mesh_from_text(
|
| 161 |
prompt_mesh,
|
| 162 |
output_dir=str(OUTPUTS),
|
| 163 |
mesh_format=mesh_format,
|
| 164 |
seed=seed_mesh,
|
| 165 |
+
mc_resolution=mc_resolution,
|
| 166 |
+
bake_texture=bake_texture,
|
| 167 |
+
smooth_mesh=smooth_mesh,
|
| 168 |
)
|
| 169 |
if mesh_path:
|
| 170 |
st.success(f"Done in {elapsed:.1f}s. {msg}")
|
|
|
|
| 175 |
except Exception as e:
|
| 176 |
st.exception(e)
|
| 177 |
|
| 178 |
+
# View 3D mesh (GLB): path, upload, or pick from outputs
|
| 179 |
+
with st.expander("View 3D mesh", expanded=False):
|
| 180 |
+
st.caption("Open a .glb file by path, upload, or pick from outputs. Drag to rotate, scroll to zoom.")
|
| 181 |
+
from scripts.mesh_viewer import mesh_viewer_html
|
| 182 |
+
import streamlit.components.v1 as components
|
| 183 |
+
|
| 184 |
+
viewer_glb_path: str | None = None
|
| 185 |
+
viewer_glb_bytes: bytes | None = None
|
| 186 |
+
|
| 187 |
+
path_input = st.text_input(
|
| 188 |
+
"Path to .glb file",
|
| 189 |
+
value="",
|
| 190 |
+
key="mesh_viewer_path",
|
| 191 |
+
placeholder=r"e.g. C:\Users\...\Downloads\mesh (1).glb",
|
| 192 |
+
)
|
| 193 |
+
if path_input and Path(path_input.strip()).is_file():
|
| 194 |
+
viewer_glb_path = path_input.strip()
|
| 195 |
+
|
| 196 |
+
uploaded_glb = st.file_uploader("Or upload a .glb file", type=["glb"], key="mesh_viewer_upload")
|
| 197 |
+
if uploaded_glb is not None:
|
| 198 |
+
viewer_glb_bytes = uploaded_glb.getvalue()
|
| 199 |
+
|
| 200 |
+
output_glbs = sorted(Path(OUTPUTS).rglob("*.glb"), key=lambda p: p.stat().st_mtime, reverse=True)
|
| 201 |
+
if not viewer_glb_path and not viewer_glb_bytes and output_glbs:
|
| 202 |
+
opt_names = [str(p.relative_to(OUTPUTS)) for p in output_glbs]
|
| 203 |
+
k = "mesh_viewer_pick"
|
| 204 |
+
if k in st.session_state and st.session_state[k] not in opt_names:
|
| 205 |
+
del st.session_state[k]
|
| 206 |
+
picked = st.selectbox("Or pick from outputs", ["(none)"] + opt_names, key=k)
|
| 207 |
+
if picked and picked != "(none)":
|
| 208 |
+
viewer_glb_path = str(OUTPUTS / picked)
|
| 209 |
+
|
| 210 |
+
if viewer_glb_path or viewer_glb_bytes:
|
| 211 |
+
html = mesh_viewer_html(glb_path=viewer_glb_path, glb_bytes=viewer_glb_bytes, height_px=480)
|
| 212 |
+
components.html(html, height=500, scrolling=False)
|
| 213 |
+
else:
|
| 214 |
+
st.info("Enter a path to a .glb file, upload one, or generate a mesh above and pick it from outputs.")
|
| 215 |
+
|
| 216 |
with tab_skybox:
|
| 217 |
st.subheader("Generate 2:1 equirectangular skybox")
|
| 218 |
st.markdown(
|
|
|
|
| 295 |
option_names = [f.name for f in output_files]
|
| 296 |
|
| 297 |
if output_files:
|
|
|
|
| 298 |
key = "skybox_select_existing"
|
| 299 |
if key in st.session_state and st.session_state[key] not in option_names:
|
| 300 |
del st.session_state[key]
|
requirements.txt
CHANGED
|
@@ -19,15 +19,19 @@ safetensors>=0.4.0
|
|
| 19 |
# xformers # uncomment if you have CUDA and want lower VRAM
|
| 20 |
|
| 21 |
# Mesh: TripoSR dependencies (also need TripoSR repo cloned - see README)
|
|
|
|
| 22 |
huggingface-hub>=0.20.0
|
| 23 |
-
rembg>=2.0.50
|
| 24 |
numpy>=1.24.0
|
| 25 |
|
| 26 |
# Text-to-image for mesh (same as skybox stack)
|
| 27 |
# (already above)
|
| 28 |
|
| 29 |
-
# TripoSR-specific (
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# xformers # uncomment if you have CUDA and want lower VRAM
|
| 20 |
|
| 21 |
# Mesh: TripoSR dependencies (also need TripoSR repo cloned - see README)
|
| 22 |
+
# rembg needs onnxruntime; use [cpu] or [gpu] so TripoSR background removal works
|
| 23 |
huggingface-hub>=0.20.0
|
| 24 |
+
rembg[cpu]>=2.0.50
|
| 25 |
numpy>=1.24.0
|
| 26 |
|
| 27 |
# Text-to-image for mesh (same as skybox stack)
|
| 28 |
# (already above)
|
| 29 |
|
| 30 |
+
# TripoSR-specific (subprocess runs TripoSR; these must be in app env)
|
| 31 |
+
xatlas>=0.0.9
|
| 32 |
+
trimesh>=4.0.0
|
| 33 |
+
einops>=0.7.0
|
| 34 |
+
omegaconf>=2.3.0
|
| 35 |
+
moderngl>=5.10.0
|
| 36 |
+
imageio>=2.33.0
|
| 37 |
+
# torchmcubes: pip install git+https://github.com/tatsy/torchmcubes.git (needs CUDA/CMake; see TripoSR README)
|
scripts/hunyuan3d_runner/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Runner for Hunyuan3D-2 full text-to-3D pipeline.
|
| 2 |
+
# Run run_text_to_mesh.py with cwd=Hunyuan3D-2 repo root and PYTHONPATH=repo root.
|
scripts/hunyuan3d_runner/run_text_to_mesh.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hunyuan3D-2 full pipeline: text → image (HunyuanDiT) → shape (Hunyuan3D-DiT) → texture (Hunyuan3D-Paint) → mesh.
|
| 3 |
+
Designed to run with cwd = Hunyuan3D-2 repo root and PYTHONPATH = repo root so that 'hy3dgen' is importable.
|
| 4 |
+
|
| 5 |
+
Usage (from evoneural, via subprocess):
|
| 6 |
+
env PYTHONPATH=/path/to/Hunyuan3D-2 python -m scripts.hunyuan3d_runner.run_text_to_mesh ...
|
| 7 |
+
Or from Hunyuan3D-2 repo root:
|
| 8 |
+
PYTHONPATH=. python /path/to/evoneural/scripts/hunyuan3d_runner/run_text_to_mesh.py --prompt "a cat" --output_dir ./out
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def main() -> int:
|
| 19 |
+
parser = argparse.ArgumentParser(description="Hunyuan3D-2: text or image → textured 3D mesh")
|
| 20 |
+
parser.add_argument("--prompt", type=str, default="", help="Text prompt for text-to-3D (omit if using --image)")
|
| 21 |
+
parser.add_argument("--image", type=str, default="", help="Input image path for image-to-3D (overrides --prompt)")
|
| 22 |
+
parser.add_argument("--output_dir", type=str, required=True, help="Directory to write the mesh file(s)")
|
| 23 |
+
parser.add_argument("--output_name", type=str, default="", help="Base name for output file (default: mesh or derived from prompt)")
|
| 24 |
+
parser.add_argument("--format", type=str, choices=["glb", "obj"], default="glb")
|
| 25 |
+
parser.add_argument("--seed", type=int, default=42)
|
| 26 |
+
parser.add_argument("--no-texture", action="store_true", help="Skip texture stage (output untextured mesh)")
|
| 27 |
+
parser.add_argument("--model_path", type=str, default="tencent/Hunyuan3D-2")
|
| 28 |
+
parser.add_argument("--subfolder", type=str, default="", help="Subfolder for shape model (e.g. hunyuan3d-dit-v2-0)")
|
| 29 |
+
parser.add_argument("--device", type=str, default="cuda")
|
| 30 |
+
parser.add_argument("--steps", type=int, default=50)
|
| 31 |
+
parser.add_argument("--guidance_scale", type=float, default=7.5)
|
| 32 |
+
parser.add_argument("--octree_resolution", type=int, default=256)
|
| 33 |
+
parser.add_argument("--num_chunks", type=int, default=200000)
|
| 34 |
+
parser.add_argument("--rembg", action="store_true", default=True, help="Remove background from image (default: True)")
|
| 35 |
+
parser.add_argument("--no-rembg", action="store_false", dest="rembg")
|
| 36 |
+
args = parser.parse_args()
|
| 37 |
+
|
| 38 |
+
try:
|
| 39 |
+
from hy3dgen.rembg import BackgroundRemover
|
| 40 |
+
from hy3dgen.shapegen import Hunyuan3DDiTFlowMatchingPipeline
|
| 41 |
+
from hy3dgen.shapegen.pipelines import export_to_trimesh
|
| 42 |
+
from hy3dgen.texgen import Hunyuan3DPaintPipeline
|
| 43 |
+
except ImportError as e:
|
| 44 |
+
print("hy3dgen not found. Run this script with cwd = Hunyuan3D-2 repo root and PYTHONPATH = repo root.", file=sys.stderr)
|
| 45 |
+
print("Example: PYTHONPATH=/path/to/Hunyuan3D-2 python run_text_to_mesh.py --prompt 'a cat' --output_dir ./out", file=sys.stderr)
|
| 46 |
+
raise SystemExit(1) from e
|
| 47 |
+
|
| 48 |
+
import torch
|
| 49 |
+
from PIL import Image
|
| 50 |
+
|
| 51 |
+
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
|
| 52 |
+
|
| 53 |
+
# Resolve input image: text → image (HunyuanDiT) or use --image
|
| 54 |
+
if args.image and os.path.isfile(args.image):
|
| 55 |
+
image = Image.open(args.image).convert("RGBA")
|
| 56 |
+
elif args.prompt.strip():
|
| 57 |
+
try:
|
| 58 |
+
from hy3dgen.text2image import HunyuanDiTPipeline
|
| 59 |
+
except ImportError:
|
| 60 |
+
print("Text-to-3D requires hy3dgen.text2image (HunyuanDiT). Install Hunyuan3D-2 with text2image support.", file=sys.stderr)
|
| 61 |
+
raise SystemExit(1)
|
| 62 |
+
t2i = HunyuanDiTPipeline("Tencent-Hunyuan/HunyuanDiT-v1.1-Diffusers-Distilled", device=args.device)
|
| 63 |
+
gen = torch.Generator(device=args.device).manual_seed(args.seed)
|
| 64 |
+
image = t2i(args.prompt, generator=gen)
|
| 65 |
+
if not isinstance(image, Image.Image):
|
| 66 |
+
image = image.images[0] if hasattr(image, "images") else image
|
| 67 |
+
image = image.convert("RGBA")
|
| 68 |
+
else:
|
| 69 |
+
print("Provide either --prompt or --image.", file=sys.stderr)
|
| 70 |
+
raise SystemExit(1)
|
| 71 |
+
|
| 72 |
+
# Optional background removal
|
| 73 |
+
if args.rembg and image.mode == "RGBA":
|
| 74 |
+
try:
|
| 75 |
+
rembg = BackgroundRemover()
|
| 76 |
+
image = rembg(image.convert("RGB"))
|
| 77 |
+
if not isinstance(image, Image.Image):
|
| 78 |
+
image = Image.fromarray(image)
|
| 79 |
+
image = image.convert("RGBA")
|
| 80 |
+
except Exception as e:
|
| 81 |
+
print(f"rembg warning: {e}", file=sys.stderr)
|
| 82 |
+
|
| 83 |
+
# Shape generation
|
| 84 |
+
load_kw = {"use_safetensors": True, "device": args.device}
|
| 85 |
+
if args.subfolder:
|
| 86 |
+
load_kw["subfolder"] = args.subfolder
|
| 87 |
+
shape_pipeline = Hunyuan3DDiTFlowMatchingPipeline.from_pretrained(args.model_path, **load_kw)
|
| 88 |
+
generator = torch.Generator(device=args.device).manual_seed(args.seed)
|
| 89 |
+
outputs = shape_pipeline(
|
| 90 |
+
image=image,
|
| 91 |
+
num_inference_steps=args.steps,
|
| 92 |
+
guidance_scale=args.guidance_scale,
|
| 93 |
+
generator=generator,
|
| 94 |
+
octree_resolution=args.octree_resolution,
|
| 95 |
+
num_chunks=args.num_chunks,
|
| 96 |
+
output_type="mesh",
|
| 97 |
+
)
|
| 98 |
+
mesh = export_to_trimesh(outputs)[0]
|
| 99 |
+
|
| 100 |
+
# Texture (optional)
|
| 101 |
+
if not args.no_texture:
|
| 102 |
+
tex_pipeline = Hunyuan3DPaintPipeline.from_pretrained(args.model_path)
|
| 103 |
+
if args.device == "cuda":
|
| 104 |
+
try:
|
| 105 |
+
tex_pipeline.to(args.device)
|
| 106 |
+
except Exception:
|
| 107 |
+
pass
|
| 108 |
+
mesh = tex_pipeline(mesh, image=image)
|
| 109 |
+
|
| 110 |
+
# Output filename
|
| 111 |
+
if args.output_name:
|
| 112 |
+
base = args.output_name
|
| 113 |
+
else:
|
| 114 |
+
base = "mesh"
|
| 115 |
+
out_path = Path(args.output_dir) / f"{base}.{args.format}"
|
| 116 |
+
mesh.export(str(out_path))
|
| 117 |
+
print(str(Path(out_path).resolve()))
|
| 118 |
+
return 0
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
if __name__ == "__main__":
|
| 122 |
+
sys.exit(main())
|
scripts/hunyuan3d_text_to_mesh.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hunyuan3D-2 full text-to-3D pipeline integration.
|
| 3 |
+
Finds the Hunyuan3D-2 repo (env HUNYUAN3D2_ROOT or ./Hunyuan3D-2) and invokes the runner
|
| 4 |
+
with PYTHONPATH and cwd set so hy3dgen is importable. Returns (mesh_path, elapsed_sec, message).
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import subprocess
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def find_hunyuan3d2_root(project_root: str | None = None) -> str | None:
|
| 15 |
+
"""Locate Hunyuan3D-2 repo: HUNYUAN3D2_ROOT env, or ./Hunyuan3D-2, or ../Hunyuan3D-2."""
|
| 16 |
+
root_env = os.environ.get("HUNYUAN3D2_ROOT", "").strip()
|
| 17 |
+
if root_env and os.path.isdir(root_env) and os.path.isdir(os.path.join(root_env, "hy3dgen")):
|
| 18 |
+
return os.path.abspath(root_env)
|
| 19 |
+
|
| 20 |
+
if project_root is None:
|
| 21 |
+
project_root = str(Path(__file__).resolve().parent.parent)
|
| 22 |
+
for p in [
|
| 23 |
+
os.path.join(project_root, "Hunyuan3D-2"),
|
| 24 |
+
os.path.join(project_root, "..", "Hunyuan3D-2"),
|
| 25 |
+
]:
|
| 26 |
+
if os.path.isdir(p) and os.path.isdir(os.path.join(p, "hy3dgen")):
|
| 27 |
+
return os.path.abspath(p)
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _runner_script_path() -> Path:
|
| 32 |
+
"""Path to run_text_to_mesh.py (must be run with cwd=Hunyuan3D-2 so hy3dgen is found)."""
|
| 33 |
+
return Path(__file__).resolve().parent / "hunyuan3d_runner" / "run_text_to_mesh.py"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def generate_mesh_from_text_hunyuan3d2(
|
| 37 |
+
prompt: str,
|
| 38 |
+
output_dir: str = "outputs",
|
| 39 |
+
mesh_format: str = "glb",
|
| 40 |
+
seed: int = 42,
|
| 41 |
+
with_texture: bool = True,
|
| 42 |
+
hunyuan_root: str | None = None,
|
| 43 |
+
device: str = "cuda",
|
| 44 |
+
timeout: int = 600,
|
| 45 |
+
) -> tuple[str | None, float, str]:
|
| 46 |
+
"""
|
| 47 |
+
Full text-to-3D via Hunyuan3D-2: text → HunyuanDiT (image) → shape → texture → mesh.
|
| 48 |
+
Returns (path_to_mesh, elapsed_sec, message). On failure returns (None, elapsed, error_message).
|
| 49 |
+
"""
|
| 50 |
+
root = hunyuan_root or find_hunyuan3d2_root()
|
| 51 |
+
if not root:
|
| 52 |
+
return (
|
| 53 |
+
None,
|
| 54 |
+
0.0,
|
| 55 |
+
"Hunyuan3D-2 repo not found. Set HUNYUAN3D2_ROOT or clone into ./Hunyuan3D-2 (see README).",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
script = _runner_script_path()
|
| 59 |
+
if not script.is_file():
|
| 60 |
+
return (None, 0.0, f"Runner script not found: {script}")
|
| 61 |
+
|
| 62 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 63 |
+
cmd = [
|
| 64 |
+
sys.executable,
|
| 65 |
+
str(script),
|
| 66 |
+
"--prompt",
|
| 67 |
+
prompt,
|
| 68 |
+
"--output_dir",
|
| 69 |
+
output_dir,
|
| 70 |
+
"--format",
|
| 71 |
+
mesh_format,
|
| 72 |
+
"--seed",
|
| 73 |
+
str(seed),
|
| 74 |
+
"--device",
|
| 75 |
+
device,
|
| 76 |
+
]
|
| 77 |
+
if not with_texture:
|
| 78 |
+
cmd.append("--no-texture")
|
| 79 |
+
env = {**os.environ, "PYTHONPATH": root}
|
| 80 |
+
|
| 81 |
+
t0 = time.perf_counter()
|
| 82 |
+
try:
|
| 83 |
+
result = subprocess.run(
|
| 84 |
+
cmd,
|
| 85 |
+
cwd=root,
|
| 86 |
+
env=env,
|
| 87 |
+
capture_output=True,
|
| 88 |
+
text=True,
|
| 89 |
+
timeout=timeout,
|
| 90 |
+
)
|
| 91 |
+
elapsed = time.perf_counter() - t0
|
| 92 |
+
if result.returncode != 0:
|
| 93 |
+
err = (result.stderr or result.stdout or "unknown").strip()
|
| 94 |
+
return (None, elapsed, f"Hunyuan3D-2 failed: {err[:500]}")
|
| 95 |
+
# Runner prints the absolute output path on success
|
| 96 |
+
out_line = (result.stdout or "").strip().splitlines()[-1] if result.stdout else ""
|
| 97 |
+
if out_line and os.path.isfile(out_line):
|
| 98 |
+
return (os.path.abspath(out_line), elapsed, "OK")
|
| 99 |
+
# Fallback: standard output path
|
| 100 |
+
out_path = Path(output_dir) / f"mesh.{mesh_format}"
|
| 101 |
+
if out_path.is_file():
|
| 102 |
+
return (str(out_path.resolve()), elapsed, "OK")
|
| 103 |
+
return (None, elapsed, "Runner did not produce mesh file.")
|
| 104 |
+
except subprocess.TimeoutExpired:
|
| 105 |
+
elapsed = time.perf_counter() - t0
|
| 106 |
+
return (None, elapsed, f"Hunyuan3D-2 timed out ({timeout}s)")
|
| 107 |
+
except Exception as e:
|
| 108 |
+
elapsed = time.perf_counter() - t0
|
| 109 |
+
return (None, elapsed, str(e))
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def generate_mesh_from_image_hunyuan3d2(
|
| 113 |
+
image_path: str,
|
| 114 |
+
output_dir: str = "outputs",
|
| 115 |
+
mesh_format: str = "glb",
|
| 116 |
+
seed: int = 42,
|
| 117 |
+
with_texture: bool = True,
|
| 118 |
+
hunyuan_root: str | None = None,
|
| 119 |
+
device: str = "cuda",
|
| 120 |
+
timeout: int = 600,
|
| 121 |
+
) -> tuple[str | None, float, str]:
|
| 122 |
+
"""
|
| 123 |
+
Image-to-3D via Hunyuan3D-2: image → shape → texture → mesh.
|
| 124 |
+
Returns (path_to_mesh, elapsed_sec, message).
|
| 125 |
+
"""
|
| 126 |
+
root = hunyuan_root or find_hunyuan3d2_root()
|
| 127 |
+
if not root:
|
| 128 |
+
return (
|
| 129 |
+
None,
|
| 130 |
+
0.0,
|
| 131 |
+
"Hunyuan3D-2 repo not found. Set HUNYUAN3D2_ROOT or clone into ./Hunyuan3D-2 (see README).",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
script = _runner_script_path()
|
| 135 |
+
if not script.is_file():
|
| 136 |
+
return (None, 0.0, f"Runner script not found: {script}")
|
| 137 |
+
|
| 138 |
+
if not os.path.isfile(image_path):
|
| 139 |
+
return (None, 0.0, f"Image not found: {image_path}")
|
| 140 |
+
|
| 141 |
+
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 142 |
+
cmd = [
|
| 143 |
+
sys.executable,
|
| 144 |
+
str(script),
|
| 145 |
+
"--image",
|
| 146 |
+
os.path.abspath(image_path),
|
| 147 |
+
"--output_dir",
|
| 148 |
+
output_dir,
|
| 149 |
+
"--format",
|
| 150 |
+
mesh_format,
|
| 151 |
+
"--seed",
|
| 152 |
+
str(seed),
|
| 153 |
+
"--device",
|
| 154 |
+
device,
|
| 155 |
+
]
|
| 156 |
+
if not with_texture:
|
| 157 |
+
cmd.append("--no-texture")
|
| 158 |
+
env = {**os.environ, "PYTHONPATH": root}
|
| 159 |
+
|
| 160 |
+
t0 = time.perf_counter()
|
| 161 |
+
try:
|
| 162 |
+
result = subprocess.run(
|
| 163 |
+
cmd,
|
| 164 |
+
cwd=root,
|
| 165 |
+
env=env,
|
| 166 |
+
capture_output=True,
|
| 167 |
+
text=True,
|
| 168 |
+
timeout=timeout,
|
| 169 |
+
)
|
| 170 |
+
elapsed = time.perf_counter() - t0
|
| 171 |
+
if result.returncode != 0:
|
| 172 |
+
err = (result.stderr or result.stdout or "unknown").strip()
|
| 173 |
+
return (None, elapsed, f"Hunyuan3D-2 failed: {err[:500]}")
|
| 174 |
+
out_line = (result.stdout or "").strip().splitlines()[-1] if result.stdout else ""
|
| 175 |
+
if out_line and os.path.isfile(out_line):
|
| 176 |
+
return (os.path.abspath(out_line), elapsed, "OK")
|
| 177 |
+
out_path = Path(output_dir) / f"mesh.{mesh_format}"
|
| 178 |
+
if out_path.is_file():
|
| 179 |
+
return (str(out_path.resolve()), elapsed, "OK")
|
| 180 |
+
return (None, elapsed, "Runner did not produce mesh file.")
|
| 181 |
+
except subprocess.TimeoutExpired:
|
| 182 |
+
elapsed = time.perf_counter() - t0
|
| 183 |
+
return (None, elapsed, f"Hunyuan3D-2 timed out ({timeout}s)")
|
| 184 |
+
except Exception as e:
|
| 185 |
+
elapsed = time.perf_counter() - t0
|
| 186 |
+
return (None, elapsed, str(e))
|
scripts/mesh_generator.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
-
Mesh generator: image → 3D mesh
|
| 3 |
-
|
| 4 |
-
|
| 5 |
"""
|
| 6 |
|
| 7 |
import os
|
|
@@ -26,18 +26,90 @@ def find_triposr_root(project_root: str | None = None) -> str | None:
|
|
| 26 |
return None
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def generate_mesh_from_image(
|
| 30 |
image_path: str,
|
| 31 |
output_dir: str = "outputs",
|
| 32 |
mesh_format: str = "glb",
|
| 33 |
triposr_root: str | None = None,
|
| 34 |
device: str = "cuda:0",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
) -> tuple[str | None, float, str]:
|
| 36 |
"""
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 39 |
"""
|
| 40 |
project_root = str(Path(__file__).resolve().parent.parent)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
triposr_root = triposr_root or find_triposr_root(project_root)
|
| 42 |
if not triposr_root:
|
| 43 |
return (
|
|
@@ -47,7 +119,9 @@ def generate_mesh_from_image(
|
|
| 47 |
)
|
| 48 |
|
| 49 |
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 50 |
-
#
|
|
|
|
|
|
|
| 51 |
run_py = os.path.join(triposr_root, "run.py")
|
| 52 |
cmd = [
|
| 53 |
sys.executable,
|
|
@@ -56,19 +130,28 @@ def generate_mesh_from_image(
|
|
| 56 |
"--output-dir",
|
| 57 |
output_dir,
|
| 58 |
"--model-save-format",
|
| 59 |
-
|
| 60 |
"--device",
|
| 61 |
device,
|
|
|
|
|
|
|
| 62 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
t0 = time.perf_counter()
|
| 65 |
try:
|
| 66 |
result = subprocess.run(
|
| 67 |
cmd,
|
| 68 |
cwd=triposr_root,
|
|
|
|
| 69 |
capture_output=True,
|
| 70 |
text=True,
|
| 71 |
-
timeout=
|
| 72 |
)
|
| 73 |
t1 = time.perf_counter()
|
| 74 |
if result.returncode != 0:
|
|
@@ -78,44 +161,101 @@ def generate_mesh_from_image(
|
|
| 78 |
f"TripoSR failed: {result.stderr or result.stdout or 'unknown'}",
|
| 79 |
)
|
| 80 |
# Output is output_dir/0/mesh.glb or mesh.obj
|
| 81 |
-
|
|
|
|
| 82 |
if not os.path.isfile(mesh_path):
|
| 83 |
return (None, t1 - t0, f"TripoSR did not produce {mesh_path}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
return (os.path.abspath(mesh_path), t1 - t0, "OK")
|
| 85 |
except subprocess.TimeoutExpired:
|
| 86 |
t1 = time.perf_counter()
|
| 87 |
-
return (None, t1 - t0, "TripoSR timed out (
|
| 88 |
except Exception as e:
|
| 89 |
t1 = time.perf_counter()
|
| 90 |
return (None, t1 - t0, str(e))
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
def generate_mesh_from_text(
|
| 94 |
prompt: str,
|
| 95 |
output_dir: str = "outputs",
|
| 96 |
mesh_format: str = "glb",
|
| 97 |
seed: int | None = None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
) -> tuple[str | None, float, str]:
|
| 99 |
"""
|
| 100 |
-
Text →
|
|
|
|
| 101 |
"""
|
| 102 |
-
# Import here so script can run from any cwd
|
| 103 |
_root = str(Path(__file__).resolve().parent.parent)
|
| 104 |
if _root not in sys.path:
|
| 105 |
sys.path.insert(0, _root)
|
| 106 |
-
from scripts.text_to_image import text_to_image
|
| 107 |
-
|
| 108 |
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
t0 = time.perf_counter()
|
| 110 |
try:
|
| 111 |
image_path, _ = text_to_image(prompt, output_dir=output_dir, seed=seed)
|
| 112 |
except Exception as e:
|
| 113 |
return (None, 0.0, f"Text-to-image failed: {e}")
|
| 114 |
-
|
| 115 |
mesh_path, mesh_time, msg = generate_mesh_from_image(
|
| 116 |
image_path,
|
| 117 |
output_dir=os.path.join(output_dir, "mesh_run"),
|
| 118 |
mesh_format=mesh_format,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
)
|
| 120 |
total_time = time.perf_counter() - t0
|
| 121 |
if mesh_path:
|
|
|
|
| 1 |
"""
|
| 2 |
+
Mesh generator: text/image → 3D mesh.
|
| 3 |
+
- Preferred: Hunyuan3D-2 full pipeline (text → HunyuanDiT → shape → texture). Set HUNYUAN3D2_ROOT or clone ./Hunyuan3D-2.
|
| 4 |
+
- Fallback: TripoSR (image→mesh). Expects TripoSR repo at project_root/TripoSR. Text→mesh uses SD for text→image then TripoSR.
|
| 5 |
"""
|
| 6 |
|
| 7 |
import os
|
|
|
|
| 26 |
return None
|
| 27 |
|
| 28 |
|
| 29 |
+
def _obj_texture_to_glb(obj_path: str, texture_path: str, glb_path: str) -> None:
|
| 30 |
+
"""
|
| 31 |
+
Convert OBJ + texture.png to a valid GLB with embedded texture.
|
| 32 |
+
TripoSR's xatlas.export writes OBJ format regardless of extension; use this to produce real GLB.
|
| 33 |
+
"""
|
| 34 |
+
import trimesh
|
| 35 |
+
from PIL import Image
|
| 36 |
+
mesh = trimesh.load(obj_path, file_type="obj", process=False)
|
| 37 |
+
if not isinstance(mesh, trimesh.Trimesh):
|
| 38 |
+
mesh = mesh.dump(concatenate=True) if hasattr(mesh, "dump") else None
|
| 39 |
+
if mesh is None:
|
| 40 |
+
return
|
| 41 |
+
# Ensure texture image is set (xatlas may not write MTL)
|
| 42 |
+
if hasattr(mesh, "visual") and mesh.visual is not None and getattr(mesh.visual, "uv", None) is not None:
|
| 43 |
+
try:
|
| 44 |
+
img = Image.open(texture_path)
|
| 45 |
+
from trimesh.visual import TextureVisuals
|
| 46 |
+
mesh.visual = TextureVisuals(uv=mesh.visual.uv, image=img)
|
| 47 |
+
except Exception:
|
| 48 |
+
pass
|
| 49 |
+
mesh.export(glb_path)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _is_obj_content(path: str) -> bool:
|
| 53 |
+
"""Return True if file content looks like OBJ (TripoSR xatlas writes OBJ even when path is .glb)."""
|
| 54 |
+
try:
|
| 55 |
+
with open(path, "rb") as f:
|
| 56 |
+
return f.read(2).strip() == b"v"
|
| 57 |
+
except Exception:
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _smooth_mesh_file(mesh_path: str, iterations: int = 3, lamb: float = 0.5) -> None:
|
| 62 |
+
"""Apply light Laplacian smoothing to a mesh file in-place. Preserves UVs."""
|
| 63 |
+
import trimesh
|
| 64 |
+
loaded = trimesh.load(mesh_path, force="mesh", process=False)
|
| 65 |
+
if isinstance(loaded, trimesh.Trimesh):
|
| 66 |
+
mesh = loaded
|
| 67 |
+
elif hasattr(loaded, "dump"):
|
| 68 |
+
mesh = loaded.dump(concatenate=True)
|
| 69 |
+
else:
|
| 70 |
+
return
|
| 71 |
+
if mesh is None:
|
| 72 |
+
return
|
| 73 |
+
try:
|
| 74 |
+
trimesh.smoothing.filter_laplacian(mesh, lamb=lamb, iterations=iterations)
|
| 75 |
+
except Exception:
|
| 76 |
+
return
|
| 77 |
+
mesh.export(mesh_path)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
def generate_mesh_from_image(
|
| 81 |
image_path: str,
|
| 82 |
output_dir: str = "outputs",
|
| 83 |
mesh_format: str = "glb",
|
| 84 |
triposr_root: str | None = None,
|
| 85 |
device: str = "cuda:0",
|
| 86 |
+
use_hunyuan3d2: bool | None = None,
|
| 87 |
+
mc_resolution: int = 512,
|
| 88 |
+
bake_texture: bool = True,
|
| 89 |
+
texture_resolution: int = 2048,
|
| 90 |
+
smooth_mesh: bool = True,
|
| 91 |
) -> tuple[str | None, float, str]:
|
| 92 |
"""
|
| 93 |
+
Image → 3D mesh. Uses Hunyuan3D-2 when available (use_hunyuan3d2=True or repo found), else TripoSR.
|
| 94 |
+
mc_resolution: marching cubes grid (256=faster, 512=higher quality). bake_texture: use texture atlas.
|
| 95 |
+
smooth_mesh: apply light Laplacian smoothing. Returns (path_to_mesh, inference_time_sec, message).
|
| 96 |
"""
|
| 97 |
project_root = str(Path(__file__).resolve().parent.parent)
|
| 98 |
+
hunyuan_root = find_hunyuan3d2_root(project_root)
|
| 99 |
+
if use_hunyuan3d2 is True or (use_hunyuan3d2 is None and hunyuan_root is not None):
|
| 100 |
+
if hunyuan_root:
|
| 101 |
+
from scripts.hunyuan3d_text_to_mesh import generate_mesh_from_image_hunyuan3d2
|
| 102 |
+
return generate_mesh_from_image_hunyuan3d2(
|
| 103 |
+
image_path,
|
| 104 |
+
output_dir=output_dir,
|
| 105 |
+
mesh_format=mesh_format,
|
| 106 |
+
seed=42,
|
| 107 |
+
with_texture=True,
|
| 108 |
+
hunyuan_root=hunyuan_root,
|
| 109 |
+
)
|
| 110 |
+
if use_hunyuan3d2 is True:
|
| 111 |
+
return (None, 0.0, "Hunyuan3D-2 repo not found. Set HUNYUAN3D2_ROOT or clone ./Hunyuan3D-2 (see README).")
|
| 112 |
+
|
| 113 |
triposr_root = triposr_root or find_triposr_root(project_root)
|
| 114 |
if not triposr_root:
|
| 115 |
return (
|
|
|
|
| 119 |
)
|
| 120 |
|
| 121 |
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 122 |
+
# When baking texture, xatlas.export() always writes OBJ format (ignores .glb extension).
|
| 123 |
+
# Ask for OBJ when bake_texture + glb, then we convert OBJ+texture to real GLB.
|
| 124 |
+
triposr_format = "obj" if (bake_texture and mesh_format == "glb") else mesh_format
|
| 125 |
run_py = os.path.join(triposr_root, "run.py")
|
| 126 |
cmd = [
|
| 127 |
sys.executable,
|
|
|
|
| 130 |
"--output-dir",
|
| 131 |
output_dir,
|
| 132 |
"--model-save-format",
|
| 133 |
+
triposr_format,
|
| 134 |
"--device",
|
| 135 |
device,
|
| 136 |
+
"--mc-resolution",
|
| 137 |
+
str(mc_resolution),
|
| 138 |
]
|
| 139 |
+
if bake_texture:
|
| 140 |
+
cmd += ["--bake-texture", "--texture-resolution", str(texture_resolution)]
|
| 141 |
+
# Use only the current env (venv) so torch and torchvision match; avoids
|
| 142 |
+
# "operator torchvision::nms does not exist" when user site-packages mixed in.
|
| 143 |
+
env = os.environ.copy()
|
| 144 |
+
env["PYTHONNOUSERSITE"] = "1"
|
| 145 |
|
| 146 |
t0 = time.perf_counter()
|
| 147 |
try:
|
| 148 |
result = subprocess.run(
|
| 149 |
cmd,
|
| 150 |
cwd=triposr_root,
|
| 151 |
+
env=env,
|
| 152 |
capture_output=True,
|
| 153 |
text=True,
|
| 154 |
+
timeout=600,
|
| 155 |
)
|
| 156 |
t1 = time.perf_counter()
|
| 157 |
if result.returncode != 0:
|
|
|
|
| 161 |
f"TripoSR failed: {result.stderr or result.stdout or 'unknown'}",
|
| 162 |
)
|
| 163 |
# Output is output_dir/0/mesh.glb or mesh.obj
|
| 164 |
+
out_subdir = os.path.join(output_dir, "0")
|
| 165 |
+
mesh_path = os.path.join(out_subdir, f"mesh.{triposr_format}")
|
| 166 |
if not os.path.isfile(mesh_path):
|
| 167 |
return (None, t1 - t0, f"TripoSR did not produce {mesh_path}")
|
| 168 |
+
# If we asked for GLB but TripoSR wrote OBJ (bake_texture), convert to real GLB
|
| 169 |
+
if bake_texture and mesh_format == "glb":
|
| 170 |
+
texture_path = os.path.join(out_subdir, "texture.png")
|
| 171 |
+
glb_path = os.path.join(out_subdir, "mesh.glb")
|
| 172 |
+
try:
|
| 173 |
+
_obj_texture_to_glb(mesh_path, texture_path, glb_path)
|
| 174 |
+
mesh_path = glb_path
|
| 175 |
+
except Exception:
|
| 176 |
+
pass # keep mesh.obj path if conversion fails
|
| 177 |
+
# If existing file is misnamed (OBJ content in .glb), repair it
|
| 178 |
+
elif mesh_format == "glb" and _is_obj_content(mesh_path):
|
| 179 |
+
texture_path = os.path.join(out_subdir, "texture.png")
|
| 180 |
+
try:
|
| 181 |
+
_obj_texture_to_glb(mesh_path, texture_path, mesh_path)
|
| 182 |
+
except Exception:
|
| 183 |
+
pass
|
| 184 |
+
if smooth_mesh:
|
| 185 |
+
try:
|
| 186 |
+
_smooth_mesh_file(mesh_path, iterations=3, lamb=0.5)
|
| 187 |
+
except Exception:
|
| 188 |
+
pass
|
| 189 |
return (os.path.abspath(mesh_path), t1 - t0, "OK")
|
| 190 |
except subprocess.TimeoutExpired:
|
| 191 |
t1 = time.perf_counter()
|
| 192 |
+
return (None, t1 - t0, "TripoSR timed out (10 min). Try lower resolution (256) or disable bake texture.")
|
| 193 |
except Exception as e:
|
| 194 |
t1 = time.perf_counter()
|
| 195 |
return (None, t1 - t0, str(e))
|
| 196 |
|
| 197 |
|
| 198 |
+
def find_hunyuan3d2_root(project_root: str | None = None) -> str | None:
|
| 199 |
+
"""Locate Hunyuan3D-2 repo. Delegates to hunyuan3d_text_to_mesh."""
|
| 200 |
+
try:
|
| 201 |
+
from scripts.hunyuan3d_text_to_mesh import find_hunyuan3d2_root as _find
|
| 202 |
+
return _find(project_root)
|
| 203 |
+
except Exception:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
|
| 207 |
def generate_mesh_from_text(
|
| 208 |
prompt: str,
|
| 209 |
output_dir: str = "outputs",
|
| 210 |
mesh_format: str = "glb",
|
| 211 |
seed: int | None = None,
|
| 212 |
+
use_hunyuan3d2: bool | None = None,
|
| 213 |
+
mc_resolution: int = 512,
|
| 214 |
+
bake_texture: bool = True,
|
| 215 |
+
texture_resolution: int = 2048,
|
| 216 |
+
smooth_mesh: bool = True,
|
| 217 |
) -> tuple[str | None, float, str]:
|
| 218 |
"""
|
| 219 |
+
Text → 3D mesh. Uses Hunyuan3D-2 full pipeline when available (use_hunyuan3d2=True or repo found),
|
| 220 |
+
else TripoSR (SD for text→image then TripoSR). Returns (path_to_mesh, total_time_sec, message).
|
| 221 |
"""
|
|
|
|
| 222 |
_root = str(Path(__file__).resolve().parent.parent)
|
| 223 |
if _root not in sys.path:
|
| 224 |
sys.path.insert(0, _root)
|
|
|
|
|
|
|
| 225 |
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
| 226 |
+
seed = seed if seed is not None else 42
|
| 227 |
+
|
| 228 |
+
# Prefer Hunyuan3D-2 when requested or when it's the only backend available
|
| 229 |
+
hunyuan_root = find_hunyuan3d2_root(_root)
|
| 230 |
+
if use_hunyuan3d2 is True or (use_hunyuan3d2 is None and hunyuan_root is not None):
|
| 231 |
+
if hunyuan_root:
|
| 232 |
+
from scripts.hunyuan3d_text_to_mesh import generate_mesh_from_text_hunyuan3d2
|
| 233 |
+
return generate_mesh_from_text_hunyuan3d2(
|
| 234 |
+
prompt,
|
| 235 |
+
output_dir=output_dir,
|
| 236 |
+
mesh_format=mesh_format,
|
| 237 |
+
seed=seed,
|
| 238 |
+
with_texture=True,
|
| 239 |
+
hunyuan_root=hunyuan_root,
|
| 240 |
+
)
|
| 241 |
+
if use_hunyuan3d2 is True:
|
| 242 |
+
return (None, 0.0, "Hunyuan3D-2 repo not found. Set HUNYUAN3D2_ROOT or clone ./Hunyuan3D-2 (see README).")
|
| 243 |
+
|
| 244 |
+
# TripoSR path: text → image (SD) → mesh
|
| 245 |
+
from scripts.text_to_image import text_to_image
|
| 246 |
t0 = time.perf_counter()
|
| 247 |
try:
|
| 248 |
image_path, _ = text_to_image(prompt, output_dir=output_dir, seed=seed)
|
| 249 |
except Exception as e:
|
| 250 |
return (None, 0.0, f"Text-to-image failed: {e}")
|
|
|
|
| 251 |
mesh_path, mesh_time, msg = generate_mesh_from_image(
|
| 252 |
image_path,
|
| 253 |
output_dir=os.path.join(output_dir, "mesh_run"),
|
| 254 |
mesh_format=mesh_format,
|
| 255 |
+
mc_resolution=mc_resolution,
|
| 256 |
+
bake_texture=bake_texture,
|
| 257 |
+
texture_resolution=texture_resolution,
|
| 258 |
+
smooth_mesh=smooth_mesh,
|
| 259 |
)
|
| 260 |
total_time = time.perf_counter() - t0
|
| 261 |
if mesh_path:
|
scripts/mesh_viewer.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
3D mesh viewer for GLB/glTF. Returns HTML for use with st.components.v1.html().
|
| 3 |
+
Uses Google's <model-viewer> web component with base64-embedded GLB.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import base64
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
# Embedding very large GLBs can make the page slow; warn above this size (bytes)
|
| 10 |
+
MAX_EMBED_BYTES = 15 * 1024 * 1024 # 15 MB
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def mesh_viewer_html(glb_path: str | Path | None = None, glb_bytes: bytes | None = None, height_px: int = 480) -> str:
|
| 14 |
+
"""
|
| 15 |
+
Build HTML for an interactive 3D mesh viewer (model-viewer).
|
| 16 |
+
Provide either glb_path (file path) or glb_bytes (raw GLB). Prefers path if both given.
|
| 17 |
+
Returns HTML string. If no valid input or file too large, returns a short error HTML.
|
| 18 |
+
"""
|
| 19 |
+
data_uri: str | None = None
|
| 20 |
+
if glb_path:
|
| 21 |
+
p = Path(glb_path)
|
| 22 |
+
if p.is_file() and p.suffix.lower() in (".glb", ".gltf"):
|
| 23 |
+
try:
|
| 24 |
+
raw = p.read_bytes()
|
| 25 |
+
except Exception:
|
| 26 |
+
raw = b""
|
| 27 |
+
else:
|
| 28 |
+
raw = b""
|
| 29 |
+
elif glb_bytes:
|
| 30 |
+
raw = glb_bytes
|
| 31 |
+
else:
|
| 32 |
+
raw = b""
|
| 33 |
+
|
| 34 |
+
if not raw or len(raw) < 4:
|
| 35 |
+
return (
|
| 36 |
+
"<p style='padding:1em;color:#888;'>No GLB loaded. Upload a .glb file or choose one from outputs.</p>"
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
if len(raw) > MAX_EMBED_BYTES:
|
| 40 |
+
return (
|
| 41 |
+
f"<p style='padding:1em;color:#c66;'>Mesh is too large to embed in viewer ({len(raw) / 1024 / 1024:.1f} MB). "
|
| 42 |
+
"Use a local viewer or a smaller mesh.</p>"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
b64 = base64.b64encode(raw).decode("utf-8")
|
| 46 |
+
data_uri = f"data:model/gltf-binary;base64,{b64}"
|
| 47 |
+
|
| 48 |
+
# model-viewer from unpkg; use module script
|
| 49 |
+
return f"""<!DOCTYPE html>
|
| 50 |
+
<html>
|
| 51 |
+
<head>
|
| 52 |
+
<meta charset="utf-8">
|
| 53 |
+
<script type="module" src="https://unpkg.com/@google/model-viewer@3.4.0/dist/model-viewer.min.js"></script>
|
| 54 |
+
<style>
|
| 55 |
+
model-viewer {{ width: 100%; height: {height_px}px; background: #1a1a1a; }}
|
| 56 |
+
</style>
|
| 57 |
+
</head>
|
| 58 |
+
<body>
|
| 59 |
+
<model-viewer
|
| 60 |
+
src="{data_uri}"
|
| 61 |
+
alt="3D mesh"
|
| 62 |
+
camera-controls
|
| 63 |
+
auto-rotate
|
| 64 |
+
shadow-intensity="0.6"
|
| 65 |
+
exposure="0.8"
|
| 66 |
+
style="width:100%; height:{height_px}px;"
|
| 67 |
+
></model-viewer>
|
| 68 |
+
</body>
|
| 69 |
+
</html>"""
|
scripts/panorama_viewer.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
360° panorama viewer for equirectangular (2:1) skybox images.
|
| 3 |
-
Returns HTML
|
| 4 |
Resizes image to keep data URL small so it loads reliably in iframes.
|
| 5 |
Fetches Pannellum script server-side and inlines it so the client does not load from CDN (avoids CSP/network block).
|
| 6 |
"""
|
|
|
|
| 1 |
"""
|
| 2 |
360° panorama viewer for equirectangular (2:1) skybox images.
|
| 3 |
+
Returns HTML for use with st.components.v1.html().
|
| 4 |
Resizes image to keep data URL small so it loads reliably in iframes.
|
| 5 |
Fetches Pannellum script server-side and inlines it so the client does not load from CDN (avoids CSP/network block).
|
| 6 |
"""
|