manav0506 commited on
Commit
0de805c
·
1 Parent(s): a0e2b41

Sync with main evoneural: quality options, mesh viewer, GLB fix, TripoSR in Dockerfile

Browse files
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 uploaded image instead of text", value=False, key="use_img")
80
- uploaded = st.file_uploader("Upload image for mesh", type=["png", "jpg"], key="mesh_upload") if use_image else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  if st.button("Generate mesh", key="btn_mesh"):
83
- if not prompt_mesh.strip() and not use_image:
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 use_image and uploaded:
100
- path = os.path.join(OUTPUTS, "uploaded_mesh_input.png")
101
- with open(path, "wb") as f:
102
- f.write(uploaded.getvalue())
103
- mesh_path, elapsed, msg = generate_mesh_from_image(
104
- path,
105
- output_dir=str(OUTPUTS / "mesh_run"),
106
- mesh_format=mesh_format,
107
- )
108
- if mesh_path:
109
- st.success(f"Done in {elapsed:.1f}s. {msg}")
110
- with open(mesh_path, "rb") as f:
111
- st.download_button("Download mesh", f, file_name=os.path.basename(mesh_path), key="dl_mesh_upload")
112
- else:
113
- st.error(msg)
 
 
 
 
 
 
 
 
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 (install after cloning TripoSR, or use our subprocess runner)
30
- # omegaconf==2.3.0
31
- # einops==0.7.0
32
- # trimesh>=4.0.0
33
- # xatlas==0.0.9
 
 
 
 
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 via TripoSR (local).
3
- Expects TripoSR repo cloned at project_root/TripoSR. Uses subprocess to run run.py.
4
- For text→mesh: first generate image with text_to_image(), then call this.
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
- Run TripoSR on an image. Returns (path_to_mesh, inference_time_sec, message).
38
- If TripoSR is not found, returns (None, 0, error_message).
 
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
- # TripoSR writes to output_dir/0/mesh.obj (or mesh.glb)
 
 
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
- mesh_format,
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=120,
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
- mesh_path = os.path.join(output_dir, "0", f"mesh.{mesh_format}")
 
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 (120s)")
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 → image (SD) mesh (TripoSR). Returns (path_to_mesh, total_time_sec, message).
 
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 string 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
  """
 
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
  """