File size: 11,186 Bytes
5ddd413
0de805c
 
 
5ddd413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0de805c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ddd413
 
 
 
 
0796c7e
0de805c
 
 
 
 
5ddd413
 
0de805c
 
 
5ddd413
 
0de805c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ddd413
 
 
 
 
 
 
 
0796c7e
5ddd413
0de805c
 
 
5ddd413
 
 
 
 
 
 
 
0de805c
5ddd413
 
0de805c
 
5ddd413
0de805c
 
 
 
 
 
c179ef4
 
 
 
 
5ddd413
 
 
 
 
 
0de805c
5ddd413
 
0de805c
5ddd413
 
 
c179ef4
 
 
 
 
 
 
 
 
5ddd413
0de805c
 
5ddd413
 
0de805c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ddd413
 
 
0de805c
5ddd413
 
 
 
 
0de805c
 
 
 
 
 
 
 
 
0796c7e
 
 
 
 
 
 
 
 
5ddd413
 
 
 
 
0de805c
 
 
 
 
0796c7e
5ddd413
 
0de805c
 
5ddd413
 
 
 
 
0de805c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5ddd413
 
 
 
 
0796c7e
5ddd413
 
 
 
0de805c
 
 
 
0796c7e
5ddd413
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
"""
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)