sacruise commited on
Commit
8a554fb
·
verified ·
1 Parent(s): 440d3a1

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +705 -0
app.py ADDED
@@ -0,0 +1,705 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI 3D Generation Pipeline — Hugging Face Space (ZeroGPU)
3
+ ========================================================
4
+ A production-ready Gradio application that generates high-fidelity,
5
+ manifold 3D models from multi-angle images + text instructions.
6
+
7
+ Pipeline:
8
+ 1. Background removal (RMBG-1.4)
9
+ 2. Multi-image composite / best-view selection
10
+ 3. TripoSG 3D reconstruction
11
+ 4. Manifold repair + scale normalization
12
+ 5. GLB/OBJ/STL export
13
+
14
+ Designed for Hugging Face ZeroGPU (A100-40GB).
15
+ """
16
+
17
+ import os
18
+ import io
19
+ import re
20
+ import sys
21
+ import json
22
+ import uuid
23
+ import time
24
+ import shutil
25
+ import base64
26
+ import logging
27
+ import tempfile
28
+ import zipfile
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ import gradio as gr
33
+ import numpy as np
34
+ import torch
35
+ from PIL import Image
36
+
37
+ # HuggingFace ZeroGPU decorator
38
+ try:
39
+ import spaces
40
+ HAS_SPACES = True
41
+ except ImportError:
42
+ HAS_SPACES = False
43
+ # Fallback no-op decorator for local dev
44
+ class spaces:
45
+ @staticmethod
46
+ def GPU(fn=None, duration=None):
47
+ if fn is None:
48
+ return lambda f: f
49
+ return fn
50
+
51
+ # Mesh processing
52
+ import trimesh
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Configuration
56
+ # ---------------------------------------------------------------------------
57
+ MODEL_ID = "VAST-AI/TripoSG"
58
+ RMBG_MODEL_ID = "briaai/RMBG-1.4"
59
+ MAX_FACES = 90000
60
+ DEFAULT_FACES = 50000
61
+ OUTPUT_DIR = Path(tempfile.gettempdir()) / "triposg_output"
62
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
63
+
64
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
65
+ logger = logging.getLogger(__name__)
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Global model references (loaded once)
69
+ # ---------------------------------------------------------------------------
70
+ triposg_pipeline = None
71
+ rmbg_model = None
72
+ device = "cuda" if torch.cuda.is_available() else "cpu"
73
+
74
+
75
+ def load_models():
76
+ """Load TripoSG + RMBG models into GPU memory."""
77
+ global triposg_pipeline, rmbg_model
78
+
79
+ if triposg_pipeline is not None:
80
+ return # already loaded
81
+
82
+ logger.info("Loading TripoSG pipeline from %s ...", MODEL_ID)
83
+ try:
84
+ # TripoSG uses a custom pipeline; import from the repo
85
+ sys.path.insert(0, os.path.dirname(__file__))
86
+
87
+ # Method 1: Try to use the HuggingFace model directly
88
+ from huggingface_hub import snapshot_download
89
+ model_path = snapshot_download(MODEL_ID)
90
+ logger.info("Model downloaded to: %s", model_path)
91
+
92
+ # Import TripoSG inference utilities
93
+ # The model uses a custom pipeline - we import it dynamically
94
+ sys.path.insert(0, model_path)
95
+
96
+ try:
97
+ from triposg import TripoSGPipeline
98
+ triposg_pipeline = TripoSGPipeline.from_pretrained(model_path).to(device)
99
+ except ImportError:
100
+ # Alternative: use the inference script approach
101
+ logger.warning("Direct import failed, using CLI inference fallback")
102
+ triposg_pipeline = {"model_path": model_path, "type": "cli"}
103
+
104
+ except Exception as e:
105
+ logger.error("Failed to load TripoSG: %s", e)
106
+ triposg_pipeline = None
107
+
108
+ # Load background remover
109
+ logger.info("Loading RMBG background remover...")
110
+ try:
111
+ from transformers import pipeline as hf_pipeline
112
+ rmbg_model = hf_pipeline("image-segmentation", model=RMBG_MODEL_ID, device=device)
113
+ logger.info("RMBG loaded successfully")
114
+ except Exception as e:
115
+ logger.warning("RMBG not available, will skip background removal: %s", e)
116
+ rmbg_model = None
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Image Processing Utilities
121
+ # ---------------------------------------------------------------------------
122
+ def remove_background(image: Image.Image) -> Image.Image:
123
+ """Remove background from an image using RMBG-1.4."""
124
+ if rmbg_model is None:
125
+ return image
126
+ try:
127
+ results = rmbg_model(image)
128
+ # Find the mask and apply it
129
+ for result in results:
130
+ if result.get("label") in ["foreground", "person", "object"]:
131
+ mask = result["mask"]
132
+ image = image.convert("RGBA")
133
+ image.putalpha(mask)
134
+ return image
135
+ # If no specific label, use the first result
136
+ if results:
137
+ mask = results[0]["mask"]
138
+ image = image.convert("RGBA")
139
+ image.putalpha(mask)
140
+ return image
141
+ except Exception as e:
142
+ logger.warning("Background removal failed: %s", e)
143
+ return image
144
+
145
+
146
+ def select_best_view(images: list[Image.Image]) -> Image.Image:
147
+ """
148
+ From multiple input images, select the best single view for reconstruction.
149
+ Heuristic: pick the largest image (highest resolution = most detail).
150
+ In a production pipeline, you would use a multi-view reconstruction model.
151
+ """
152
+ if not images:
153
+ raise ValueError("No images provided")
154
+ if len(images) == 1:
155
+ return images[0]
156
+
157
+ # Score images by resolution and sharpness
158
+ scored = []
159
+ for img in images:
160
+ w, h = img.size
161
+ resolution_score = w * h
162
+ # Simple Laplacian variance for sharpness
163
+ gray = np.array(img.convert("L"), dtype=np.float32)
164
+ laplacian = np.abs(np.roll(gray, 1, 0) + np.roll(gray, -1, 0) +
165
+ np.roll(gray, 1, 1) + np.roll(gray, -1, 1) - 4 * gray)
166
+ sharpness = float(np.var(laplacian))
167
+ scored.append((img, resolution_score * 0.3 + sharpness * 0.7))
168
+
169
+ scored.sort(key=lambda x: x[1], reverse=True)
170
+ logger.info("Selected best view (score=%.1f) from %d images", scored[0][1], len(images))
171
+ return scored[0][0]
172
+
173
+
174
+ def create_composite_view(images: list[Image.Image], max_images: int = 4) -> Image.Image:
175
+ """
176
+ Create a multi-view composite image (2x2 grid) from multiple input images.
177
+ Some models work better with a single composite showing multiple angles.
178
+ """
179
+ images = images[:max_images]
180
+ n = len(images)
181
+
182
+ if n == 1:
183
+ return images[0]
184
+
185
+ # Resize all to same size
186
+ target_size = 512
187
+ resized = [img.resize((target_size, target_size), Image.LANCZOS) for img in images]
188
+
189
+ if n == 2:
190
+ composite = Image.new("RGB", (target_size * 2, target_size))
191
+ composite.paste(resized[0], (0, 0))
192
+ composite.paste(resized[1], (target_size, 0))
193
+ elif n <= 4:
194
+ cols, rows = 2, 2
195
+ composite = Image.new("RGB", (target_size * cols, target_size * rows), (255, 255, 255))
196
+ for i, img in enumerate(resized):
197
+ x = (i % cols) * target_size
198
+ y = (i // cols) * target_size
199
+ composite.paste(img, (x, y))
200
+ else:
201
+ composite = resized[0]
202
+
203
+ return composite
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Text-to-Dimension Extraction
208
+ # ---------------------------------------------------------------------------
209
+ def extract_dimensions_from_prompt(prompt: str) -> dict:
210
+ """
211
+ Parse dimensional info from text prompt.
212
+ Supports patterns like:
213
+ - "10cm x 5cm x 3cm"
214
+ - "base is 10cm"
215
+ - "height: 200mm"
216
+ - "scale 1:10"
217
+ Returns dict with extracted measurements.
218
+ """
219
+ dims = {}
220
+
221
+ # Match "NxMxP" patterns (cm, mm, m, inches)
222
+ pattern_3d = r'(\d+(?:\.\d+)?)\s*(?:x|×|by)\s*(\d+(?:\.\d+)?)\s*(?:x|×|by)\s*(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches)?'
223
+ match = re.search(pattern_3d, prompt, re.IGNORECASE)
224
+ if match:
225
+ dims["width"] = float(match.group(1))
226
+ dims["depth"] = float(match.group(2))
227
+ dims["height"] = float(match.group(3))
228
+ dims["unit"] = (match.group(4) or "cm").lower().rstrip("es").rstrip("ch")
229
+ return dims
230
+
231
+ # Match individual dimension patterns
232
+ for dim_name in ["width", "height", "depth", "length", "base", "diameter", "radius"]:
233
+ pattern = rf'{dim_name}\s*(?:is|:|\=)?\s*(\d+(?:\.\d+)?)\s*(cm|mm|m|in|inch|inches)?'
234
+ match = re.search(pattern, prompt, re.IGNORECASE)
235
+ if match:
236
+ dims[dim_name] = float(match.group(1))
237
+ dims["unit"] = (match.group(2) or "cm").lower().rstrip("es").rstrip("ch")
238
+
239
+ # Match scale ratios
240
+ scale_pattern = r'(?:scale|ratio)\s*(?:is|:|\=)?\s*1\s*:\s*(\d+(?:\.\d+)?)'
241
+ match = re.search(scale_pattern, prompt, re.IGNORECASE)
242
+ if match:
243
+ dims["scale_ratio"] = float(match.group(1))
244
+
245
+ return dims
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Mesh Post-Processing (Manifold + Scale)
250
+ # ---------------------------------------------------------------------------
251
+ def repair_mesh(mesh: trimesh.Trimesh) -> trimesh.Trimesh:
252
+ """Make a mesh manifold (watertight) for 3D printing."""
253
+ logger.info("Repairing mesh: %d vertices, %d faces", len(mesh.vertices), len(mesh.faces))
254
+
255
+ # Fix normals
256
+ trimesh.repair.fix_normals(mesh)
257
+ trimesh.repair.fix_inversion(mesh)
258
+ trimesh.repair.fix_winding(mesh)
259
+
260
+ # Fill holes
261
+ if not mesh.is_watertight:
262
+ mesh.fill_holes()
263
+ logger.info("Filled holes → watertight: %s", mesh.is_watertight)
264
+
265
+ # Remove degenerate faces
266
+ mask = mesh.nondegenerate_faces()
267
+ if not mask.all():
268
+ mesh.update_faces(mask)
269
+ logger.info("Removed %d degenerate faces", (~mask).sum())
270
+
271
+ # Merge close vertices
272
+ mesh.merge_vertices(merge_tex=True, merge_norm=True)
273
+
274
+ return mesh
275
+
276
+
277
+ def scale_mesh(mesh: trimesh.Trimesh, dimensions: dict) -> trimesh.Trimesh:
278
+ """
279
+ Scale mesh based on extracted dimensions.
280
+ The model outputs are in arbitrary units — we map the largest extent
281
+ to the largest provided dimension.
282
+ """
283
+ if not dimensions:
284
+ return mesh
285
+
286
+ # Convert everything to millimeters for consistency
287
+ unit_to_mm = {"mm": 1.0, "cm": 10.0, "m": 1000.0, "in": 25.4}
288
+ unit = dimensions.get("unit", "cm")
289
+ multiplier = unit_to_mm.get(unit, 10.0)
290
+
291
+ # Find the target size
292
+ target_dims = []
293
+ for key in ["width", "height", "depth", "length", "base", "diameter"]:
294
+ if key in dimensions:
295
+ target_dims.append(dimensions[key] * multiplier) # convert to mm
296
+
297
+ if not target_dims:
298
+ if "scale_ratio" in dimensions:
299
+ # Apply scale ratio directly
300
+ scale_factor = 1.0 / dimensions["scale_ratio"]
301
+ mesh.apply_scale(scale_factor)
302
+ return mesh
303
+ return mesh
304
+
305
+ # Scale the mesh so its largest extent matches the largest target dimension
306
+ current_extents = mesh.extents
307
+ max_current = max(current_extents)
308
+ max_target = max(target_dims)
309
+
310
+ if max_current > 0:
311
+ scale_factor = max_target / max_current
312
+ mesh.apply_scale(scale_factor)
313
+ logger.info("Scaled mesh by %.3f (target: %.1fmm)", scale_factor, max_target)
314
+
315
+ return mesh
316
+
317
+
318
+ def segment_mesh_parts(mesh: trimesh.Trimesh) -> list[trimesh.Trimesh]:
319
+ """
320
+ Attempt to split a mesh into separate connected components (modular parts).
321
+ This is useful for complex objects where parts should be independently editable.
322
+ """
323
+ try:
324
+ components = mesh.split(only_watertight=False)
325
+ if len(components) > 1:
326
+ logger.info("Mesh split into %d modular parts", len(components))
327
+ return components
328
+ else:
329
+ logger.info("Mesh is a single connected component")
330
+ return [mesh]
331
+ except Exception as e:
332
+ logger.warning("Mesh segmentation failed: %s", e)
333
+ return [mesh]
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Export Utilities
338
+ # ---------------------------------------------------------------------------
339
+ def export_mesh(mesh: trimesh.Trimesh, fmt: str = "glb", parts: list = None) -> str:
340
+ """
341
+ Export mesh to file. If parts are provided, creates a ZIP with individual files.
342
+ """
343
+ job_id = str(uuid.uuid4())[:8]
344
+ output_base = OUTPUT_DIR / job_id
345
+ output_base.mkdir(parents=True, exist_ok=True)
346
+
347
+ if parts and len(parts) > 1:
348
+ # Export each part separately and zip them
349
+ part_files = []
350
+ for i, part in enumerate(parts):
351
+ fname = f"part_{i+1:02d}.{fmt}"
352
+ fpath = output_base / fname
353
+ part.export(str(fpath))
354
+ part_files.append(str(fpath))
355
+
356
+ # Also export the combined model
357
+ combined_path = output_base / f"combined.{fmt}"
358
+ mesh.export(str(combined_path))
359
+ part_files.append(str(combined_path))
360
+
361
+ # Create zip
362
+ zip_path = output_base / f"model_parts_{job_id}.zip"
363
+ with zipfile.ZipFile(str(zip_path), "w") as zf:
364
+ for pf in part_files:
365
+ zf.write(pf, os.path.basename(pf))
366
+
367
+ logger.info("Exported %d parts + combined to %s", len(parts), zip_path)
368
+ return str(combined_path), str(zip_path)
369
+ else:
370
+ fpath = output_base / f"model.{fmt}"
371
+ mesh.export(str(fpath))
372
+ logger.info("Exported single mesh to %s", fpath)
373
+ return str(fpath), str(fpath)
374
+
375
+
376
+ # ---------------------------------------------------------------------------
377
+ # Main Generation Pipeline
378
+ # ---------------------------------------------------------------------------
379
+ @spaces.GPU(duration=300)
380
+ def generate_3d_model(
381
+ input_images: list,
382
+ text_prompt: str,
383
+ num_faces: int = DEFAULT_FACES,
384
+ export_format: str = "glb",
385
+ enable_modular: bool = False,
386
+ progress=gr.Progress(track_tqdm=True),
387
+ ):
388
+ """
389
+ End-to-end 3D generation pipeline.
390
+
391
+ Args:
392
+ input_images: List of uploaded image file paths
393
+ text_prompt: Text instructions (dimensions, details, style)
394
+ num_faces: Target face count for the mesh
395
+ export_format: Output format (glb, obj, stl)
396
+ enable_modular: Whether to split into modular parts
397
+ """
398
+ if not input_images and not text_prompt:
399
+ raise gr.Error("Please provide at least one image or a text prompt.")
400
+
401
+ start_time = time.time()
402
+ status_log = []
403
+
404
+ def log(msg):
405
+ status_log.append(msg)
406
+ logger.info(msg)
407
+
408
+ # --- Step 1: Load models ---
409
+ log("🔄 Loading models...")
410
+ load_models()
411
+
412
+ if triposg_pipeline is None:
413
+ raise gr.Error("Model failed to load. Please try again or check the Space logs.")
414
+
415
+ # --- Step 2: Process images ---
416
+ log("🖼️ Processing input images...")
417
+ pil_images = []
418
+ if input_images:
419
+ for img_path in input_images:
420
+ if isinstance(img_path, str):
421
+ img = Image.open(img_path).convert("RGB")
422
+ elif hasattr(img_path, "name"):
423
+ img = Image.open(img_path.name).convert("RGB")
424
+ else:
425
+ img = img_path.convert("RGB") if isinstance(img_path, Image.Image) else None
426
+ if img:
427
+ pil_images.append(img)
428
+
429
+ # --- Step 3: Remove backgrounds ---
430
+ log("✂️ Removing backgrounds...")
431
+ clean_images = [remove_background(img) for img in pil_images]
432
+
433
+ # --- Step 4: Select best view / create composite ---
434
+ if clean_images:
435
+ if len(clean_images) == 1:
436
+ primary_image = clean_images[0]
437
+ log("📷 Using single image input")
438
+ else:
439
+ primary_image = select_best_view(clean_images)
440
+ log(f"📷 Selected best view from {len(clean_images)} images")
441
+ else:
442
+ primary_image = None
443
+
444
+ # --- Step 5: Extract dimensions from prompt ---
445
+ dimensions = {}
446
+ if text_prompt:
447
+ dimensions = extract_dimensions_from_prompt(text_prompt)
448
+ if dimensions:
449
+ log(f"📏 Extracted dimensions: {dimensions}")
450
+
451
+ # --- Step 6: Run TripoSG inference ---
452
+ log("🧠 Running 3D reconstruction (this may take 1-3 minutes)...")
453
+
454
+ try:
455
+ if isinstance(triposg_pipeline, dict) and triposg_pipeline.get("type") == "cli":
456
+ # CLI fallback: save image, run inference script
457
+ model_path = triposg_pipeline["model_path"]
458
+ tmp_img = OUTPUT_DIR / f"input_{uuid.uuid4().hex[:6]}.png"
459
+
460
+ if primary_image:
461
+ primary_image.save(str(tmp_img))
462
+ else:
463
+ # Create a blank image if only text prompt
464
+ blank = Image.new("RGB", (512, 512), (200, 200, 200))
465
+ blank.save(str(tmp_img))
466
+
467
+ tmp_out = OUTPUT_DIR / f"output_{uuid.uuid4().hex[:6]}.glb"
468
+
469
+ import subprocess
470
+ cmd = [
471
+ sys.executable, "-m", "scripts.inference_triposg",
472
+ "--image-input", str(tmp_img),
473
+ "--output-path", str(tmp_out),
474
+ "--faces", str(num_faces),
475
+ ]
476
+ env = os.environ.copy()
477
+ env["PYTHONPATH"] = model_path + ":" + env.get("PYTHONPATH", "")
478
+
479
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=600, cwd=model_path, env=env)
480
+ if result.returncode != 0:
481
+ logger.error("Inference stderr: %s", result.stderr)
482
+ raise RuntimeError(f"Inference failed: {result.stderr[-500:]}")
483
+
484
+ mesh = trimesh.load(str(tmp_out))
485
+
486
+ else:
487
+ # Direct pipeline call
488
+ if primary_image:
489
+ output = triposg_pipeline(
490
+ primary_image,
491
+ num_faces=num_faces,
492
+ )
493
+ # The output format depends on the pipeline version
494
+ if hasattr(output, "meshes"):
495
+ mesh = output.meshes[0]
496
+ elif isinstance(output, trimesh.Trimesh):
497
+ mesh = output
498
+ else:
499
+ # Try to load from file path
500
+ mesh = trimesh.load(output)
501
+ else:
502
+ raise gr.Error("Image input is required for 3D generation.")
503
+
504
+ except Exception as e:
505
+ raise gr.Error(f"3D reconstruction failed: {str(e)}")
506
+
507
+ # --- Step 7: Post-process mesh ---
508
+ log("🔧 Repairing mesh for manifold output...")
509
+ mesh = repair_mesh(mesh)
510
+
511
+ # --- Step 8: Apply dimensional scaling ---
512
+ if dimensions:
513
+ log("📐 Applying dimensional scaling...")
514
+ mesh = scale_mesh(mesh, dimensions)
515
+
516
+ # --- Step 9: Modular segmentation ---
517
+ parts = None
518
+ if enable_modular:
519
+ log("🧩 Segmenting into modular parts...")
520
+ parts = segment_mesh_parts(mesh)
521
+
522
+ # --- Step 10: Export ---
523
+ log(f"💾 Exporting as .{export_format}...")
524
+ preview_path, download_path = export_mesh(mesh, export_format, parts)
525
+
526
+ elapsed = time.time() - start_time
527
+ log(f"✅ Complete in {elapsed:.1f}s — manifold: {mesh.is_watertight}, "
528
+ f"vertices: {len(mesh.vertices)}, faces: {len(mesh.faces)}")
529
+
530
+ status_text = "\n".join(status_log)
531
+ return preview_path, download_path, status_text
532
+
533
+
534
+ # ---------------------------------------------------------------------------
535
+ # Gradio API Endpoint (for n8n / external calls)
536
+ # ---------------------------------------------------------------------------
537
+ @spaces.GPU(duration=300)
538
+ def api_generate(
539
+ image_b64_list: str,
540
+ prompt: str,
541
+ faces: int = DEFAULT_FACES,
542
+ fmt: str = "glb",
543
+ modular: bool = False,
544
+ ):
545
+ """
546
+ JSON API endpoint for programmatic access (n8n, curl, etc.).
547
+
548
+ Args:
549
+ image_b64_list: JSON array of base64-encoded images
550
+ prompt: Text instructions
551
+ faces: Target face count
552
+ fmt: Export format (glb/obj/stl)
553
+ modular: Split into parts
554
+ Returns:
555
+ Tuple of (model file path, download file path, status text)
556
+ """
557
+ # Decode images from base64
558
+ images_data = json.loads(image_b64_list) if image_b64_list else []
559
+ tmp_files = []
560
+
561
+ for i, b64_str in enumerate(images_data):
562
+ # Handle data URIs
563
+ if "," in b64_str:
564
+ b64_str = b64_str.split(",", 1)[1]
565
+ img_bytes = base64.b64decode(b64_str)
566
+ tmp_path = OUTPUT_DIR / f"api_input_{uuid.uuid4().hex[:6]}.png"
567
+ tmp_path.write_bytes(img_bytes)
568
+ tmp_files.append(str(tmp_path))
569
+
570
+ return generate_3d_model(
571
+ input_images=tmp_files,
572
+ text_prompt=prompt,
573
+ num_faces=faces,
574
+ export_format=fmt,
575
+ enable_modular=modular,
576
+ )
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # Gradio UI
581
+ # ---------------------------------------------------------------------------
582
+ HEADER_MD = """
583
+ # 🚀 AI 3D Model Generator — Print-Ready Pipeline
584
+
585
+ Generate **high-fidelity, manifold 3D models** from multiple images + text instructions.
586
+ Upload photos from different angles, add dimensional requirements, and get a
587
+ **watertight mesh** ready for 3D printing or game engines.
588
+
589
+ **Pipeline**: Background Removal → Multi-View Selection → TripoSG Reconstruction → Manifold Repair → Scale Calibration → Export
590
+
591
+ > 💡 **Tip**: For best results, provide 2-4 images of the object from different angles on a clean background.
592
+ """
593
+
594
+ with gr.Blocks(
595
+ title="AI 3D Model Generator",
596
+ theme=gr.themes.Soft(
597
+ primary_hue=gr.themes.colors.indigo,
598
+ secondary_hue=gr.themes.colors.purple,
599
+ ),
600
+ css="""
601
+ .gradio-container { max-width: 1200px !important; }
602
+ .status-box textarea { font-family: monospace; font-size: 12px; }
603
+ """,
604
+ ) as demo:
605
+
606
+ gr.Markdown(HEADER_MD)
607
+
608
+ with gr.Row():
609
+ # ----- Left Column: Inputs -----
610
+ with gr.Column(scale=1):
611
+ gr.Markdown("### 📥 Input")
612
+
613
+ input_images = gr.File(
614
+ label="Upload Images (multi-angle)",
615
+ file_count="multiple",
616
+ file_types=["image"],
617
+ type="filepath",
618
+ )
619
+
620
+ text_prompt = gr.Textbox(
621
+ label="Text Instructions",
622
+ placeholder=(
623
+ "Describe details, dimensions, materials...\n"
624
+ "e.g.: Industrial bracket, base is 10cm × 5cm, "
625
+ "matte steel finish, M6 bolt holes on each corner"
626
+ ),
627
+ lines=4,
628
+ )
629
+
630
+ with gr.Row():
631
+ num_faces = gr.Slider(
632
+ minimum=5000,
633
+ maximum=MAX_FACES,
634
+ value=DEFAULT_FACES,
635
+ step=5000,
636
+ label="Target Face Count",
637
+ )
638
+ export_format = gr.Dropdown(
639
+ choices=["glb", "obj", "stl"],
640
+ value="glb",
641
+ label="Export Format",
642
+ )
643
+
644
+ enable_modular = gr.Checkbox(
645
+ label="🧩 Split into modular parts (experimental)",
646
+ value=False,
647
+ )
648
+
649
+ generate_btn = gr.Button(
650
+ "🚀 Generate 3D Model",
651
+ variant="primary",
652
+ size="lg",
653
+ )
654
+
655
+ # ----- Right Column: Outputs -----
656
+ with gr.Column(scale=1):
657
+ gr.Markdown("### 📤 Output")
658
+
659
+ model_preview = gr.Model3D(
660
+ label="3D Model Preview",
661
+ clear_color=[0.1, 0.1, 0.15, 1.0],
662
+ )
663
+
664
+ download_file = gr.File(label="⬇️ Download Model")
665
+
666
+ status_output = gr.Textbox(
667
+ label="Pipeline Status",
668
+ lines=10,
669
+ interactive=False,
670
+ elem_classes=["status-box"],
671
+ )
672
+
673
+ # ----- Hidden API interface -----
674
+ with gr.Row(visible=False):
675
+ api_image_input = gr.Textbox(label="Base64 Images JSON")
676
+ api_prompt_input = gr.Textbox(label="Prompt")
677
+ api_faces_input = gr.Number(label="Faces", value=DEFAULT_FACES)
678
+ api_fmt_input = gr.Textbox(label="Format", value="glb")
679
+ api_modular_input = gr.Checkbox(label="Modular", value=False)
680
+ api_model_output = gr.File(label="Model")
681
+ api_download_output = gr.File(label="Download")
682
+ api_status_output = gr.Textbox(label="Status")
683
+
684
+ # ----- Event handlers -----
685
+ generate_btn.click(
686
+ fn=generate_3d_model,
687
+ inputs=[input_images, text_prompt, num_faces, export_format, enable_modular],
688
+ outputs=[model_preview, download_file, status_output],
689
+ )
690
+
691
+ # Named API endpoint for n8n
692
+ demo.load(fn=lambda: None, inputs=None, outputs=None)
693
+
694
+
695
+ # ---------------------------------------------------------------------------
696
+ # Launch
697
+ # ---------------------------------------------------------------------------
698
+ if __name__ == "__main__":
699
+ demo.queue(max_size=5)
700
+ demo.launch(
701
+ server_name="0.0.0.0",
702
+ server_port=7860,
703
+ share=False,
704
+ show_api=True, # Expose /api/ endpoints for n8n
705
+ )