import gradio as gr import subprocess import sys import os import base64 import tempfile import time import json import urllib.parse import numpy as np from PIL import Image # ── ZeroGPU optional ── try: import spaces HAS_GPU = True except Exception: HAS_GPU = False class spaces: @staticmethod def GPU(duration=60): return lambda fn: fn # ── gradio_client optional (for the TripoSplat alt-model call) ── try: from gradio_client import Client as GradioClient, handle_file as gradio_handle_file HAS_GRADIO_CLIENT = True except Exception: HAS_GRADIO_CLIENT = False # ── plyfile optional (for the auto-clean / noise-pruning step) ── try: from plyfile import PlyData, PlyElement HAS_PLYFILE = True except Exception: HAS_PLYFILE = False # ── Install SHARP ── def install_sharp(): try: r = subprocess.run(["sharp", "--help"], capture_output=True, timeout=10) if r.returncode == 0: return except Exception: pass print("Installing Apple SHARP...") subprocess.check_call([ sys.executable, "-m", "pip", "install", "git+https://github.com/apple/ml-sharp.git", "--quiet" ]) install_sharp() # ── Resize before SHARP ── def resize_image(path, max_size=512): img = Image.open(path).convert("RGB") w, h = img.size if max(w, h) > max_size: r = max_size / max(w, h) img = img.resize((int(w*r), int(h*r)), Image.LANCZOS) tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False, prefix="sharp_in_") img.save(tmp.name, "JPEG", quality=90) return tmp.name # ──────────────────────────────────────────────────────────────── # Core SHARP runner — shared by the GPU and CPU code paths. # # Progress: SHARP's CLI doesn't stream fine-grained step-by-step progress, # so we can't show a "true" per-layer progress bar. What we *can* do # honestly: launch it with Popen (not the blocking .run()) and poll it in # a loop from this same function, calling gr.Progress() each tick. Gradio # streams those calls live to the frontend as the function runs — no # threading needed, and no risk of interfering with ZeroGPU's execution # context. The percentage is paced against historically observed timing # (we logged ~49s for a typical run), so it's a real, live-updating bar — # just not literally tied to internal model layers, which SHARP doesn't # expose to us. # ──────────────────────────────────────────────────────────────── def _sharp_core(image_path, timeout_s, env_overrides=None, progress=None, label="", desc_prefix="", est_total=45.0): t0 = time.time() print(f"[{label}] START image_path={image_path}", flush=True) if image_path is None: return None, "⚠ Please upload a photo first." out_dir = tempfile.mkdtemp(prefix="splat_") resized = None def _p(frac, desc): if progress is not None: try: progress(frac, desc=desc_prefix + desc) except Exception: pass try: _p(0.05, "Resizing photo…") resized = resize_image(image_path, 512) print(f"[{label}] resized -> {resized} (+{time.time()-t0:.1f}s)", flush=True) env = os.environ.copy() if env_overrides: env.update(env_overrides) _p(0.12, "Starting SHARP…") proc = subprocess.Popen( ["sharp", "predict", "-i", resized, "-o", out_dir], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, env=env ) while proc.poll() is None: elapsed = time.time() - t0 if elapsed > timeout_s: proc.kill() proc.wait(timeout=5) raise subprocess.TimeoutExpired(cmd="sharp", timeout=timeout_s) frac = 0.12 + 0.78 * min(elapsed / est_total, 1.0) _p(frac, f"Generating gaussians… {elapsed:.0f}s") time.sleep(0.4) stdout, _ = proc.communicate() print(f"[{label}] sharp exited code={proc.returncode} (+{time.time()-t0:.1f}s)", flush=True) _p(0.93, "Reading result…") ply_files = [f for f in os.listdir(out_dir) if f.endswith(".ply")] if not ply_files: err = (stdout or "No .ply produced.")[-800:] print(f"[{label}] FAILED, no .ply produced:\n{err}", flush=True) return None, f"SHARP failed:\n{err}" ply_path = os.path.join(out_dir, ply_files[0]) size_mb = os.path.getsize(ply_path) / 1024 / 1024 print(f"[{label}] DONE ply={ply_path} size={size_mb:.2f}MB total={time.time()-t0:.1f}s", flush=True) _p(1.0, "Done") return ply_path, "✓ Done — 3D scene loading below ↓" except subprocess.TimeoutExpired: print(f"[{label}] TIMEOUT after {time.time()-t0:.1f}s", flush=True) return None, "⚠ Timed out. Try a smaller/simpler photo, or (if on GPU) increase GPU_DURATION near the top of app.py if you have more quota." except FileNotFoundError: print(f"[{label}] SHARP binary not found on PATH", flush=True) return None, "⚠ SHARP not found yet — wait 1 minute and try again." except Exception as e: print(f"[{label}] EXCEPTION: {e}", flush=True) return None, f"⚠ Error: {str(e)}" finally: if resized: try: os.unlink(resized) except: pass # NOTE on GPU_DURATION: HuggingFace's free-tier ZeroGPU accounts get a # 5-minute (300s) TOTAL daily quota. Requesting a duration at or above that # cap makes the platform reject the call outright ("illegal duration") — # instantly, before your function even runs. Keeping this comfortably # under the cap lets a free/unauthenticated user get a few conversions in # per day instead of burning the whole quota on one call. GPU_DURATION = 90 # CPU has no ZeroGPU quota at all, but is much slower — this is just a # generous soft cap for our own subprocess, not a platform guarantee. CPU_TIMEOUT = 300 @spaces.GPU(duration=GPU_DURATION) def run_sharp_gpu(image_path, progress=None, desc_prefix=""): return _sharp_core(image_path, GPU_DURATION - 15, env_overrides=None, progress=progress, label="run_sharp_gpu", desc_prefix=desc_prefix, est_total=45.0) def run_sharp_cpu(image_path, progress=None, desc_prefix=""): # Forcing CUDA_VISIBLE_DEVICES empty makes SHARP (and torch under it) # fall back to CPU even if a GPU happens to be visible in this # container, regardless of whether SHARP itself exposes a --device flag. return _sharp_core(image_path, CPU_TIMEOUT, env_overrides={"CUDA_VISIBLE_DEVICES": ""}, progress=progress, label="run_sharp_cpu", desc_prefix=desc_prefix, est_total=150.0) # ──────────────────────────────────────────────────────────────── # Alt model: TripoSplat, called via its own live public HF Space # (VAST-AI/TripoSplat) instead of vendored locally. # # Why not run it in this Space directly? TripoSplat's own pipeline needs # several GB of diffusion/VAE/background-removal checkpoints and a # heavier runtime than SHARP's single CLI call — that would blow past # both our GPU_DURATION budget and this Space's disk allowance. Calling # their existing Space offloads the heavy compute to VAST-AI's own # ZeroGPU quota instead of ours. # # Honesty note: the exact positional argument order below is my best # reading of their public UI (image, seed, steps, guidance scale, gaussian # count, format) — I couldn't verify the literal function signature # without live-testing it. If it's wrong, client.view_api()'s output # (logged below) will show the real one, and it's a one-line fix. # ──────────────────────────────────────────────────────────────── _tripo_client = None def get_tripo_client(): global _tripo_client if _tripo_client is None: _tripo_client = GradioClient("VAST-AI/TripoSplat") try: print(f"[tripo] API schema:\n{_tripo_client.view_api(print_info=False)}", flush=True) except Exception as e: print(f"[tripo] view_api() failed (non-fatal): {e}", flush=True) return _tripo_client def _extract_ply_path(result): """gradio_client results can be nested tuples/dicts/strings depending on the target Space's output components — walk them looking for anything that looks like a .ply (or .splat) file path/url.""" def _walk(x): if isinstance(x, str) and x.lower().endswith((".ply", ".splat")): return x if isinstance(x, dict): for k in ("path", "url", "name"): v = x.get(k) if isinstance(v, str) and v.lower().endswith((".ply", ".splat")): return v for v in x.values(): found = _walk(v) if found: return found if isinstance(x, (list, tuple)): for v in x: found = _walk(v) if found: return found return None return _walk(result) def run_tripo(image_path, progress=None, desc_prefix="", num_gaussians=131072): t0 = time.time() print(f"[run_tripo] START image_path={image_path}", flush=True) def _p(frac, desc): if progress is not None: try: progress(frac, desc=desc_prefix + desc) except Exception: pass if not HAS_GRADIO_CLIENT: return None, "⚠ gradio_client isn't installed on this Space yet — add `gradio_client` to requirements.txt and redeploy." try: _p(0.1, "Connecting to TripoSplat's live demo…") client = get_tripo_client() _p(0.25, "Generating on TripoSplat (may take 20–90s, longer if their Space is asleep)…") # Verified against TripoSplat's own app.py source (they use a custom # gradio.Server + @app.api() endpoint, not a plain gr.Blocks demo — # their real signature is: # generate(image, seed=42, steps=20, guidance_scale=3.0, # num_gaussians=262144, output_format="ply") # -> (preprocessed_image, ply_file, download_file, info_string) # Calling with keyword args + their real api_name, not guessed # positional args against an assumed "/predict" name. result = client.predict( image=gradio_handle_file(image_path), seed=42, steps=20, guidance_scale=3.0, num_gaussians=int(num_gaussians), output_format="ply", api_name="/generate" ) print(f"[run_tripo] raw result (+{time.time()-t0:.1f}s): {str(result)[:500]}", flush=True) _p(0.9, "Finalizing…") ply_path = _extract_ply_path(result) if not ply_path or not os.path.exists(ply_path): return None, (f"⚠ TripoSplat responded but I couldn't find a .ply in the result. " f"Raw response logged to the HF Space logs for debugging.") _p(1.0, "Done") return ply_path, "✓ Done (via TripoSplat) — 3D scene loading below ↓" except Exception as e: print(f"[run_tripo] EXCEPTION: {e}", flush=True) return None, (f"⚠ TripoSplat call failed: {str(e)}\n" f"(This calls VAST-AI's public demo directly — it may be asleep, " f"rate-limited, or have changed its API since. Check the HF " f"Space logs for the exact error and the real API schema.)") # ──────────────────────────────────────────────────────────────── # Auto-clean: prune near-invisible / statistically-stray gaussians. # This is a pure-CPU numpy pass over the vertex data — runs outside the # GPU-decorated functions on purpose, so it never eats into ZeroGPU quota. # ──────────────────────────────────────────────────────────────── def clean_ply(ply_path, opacity_thresh=0.04, mad_k=6.0): if not HAS_PLYFILE: return ply_path, "skipped (plyfile not installed)" plydata = PlyData.read(ply_path) if "vertex" not in plydata: return ply_path, "skipped (no vertex element)" v = plydata["vertex"] n0 = len(v.data) if n0 == 0: return ply_path, "skipped (empty)" names = v.data.dtype.names if not all(k in names for k in ("x", "y", "z")): return ply_path, "skipped (unexpected format)" xyz = np.stack([v["x"], v["y"], v["z"]], axis=1).astype(np.float64) if "opacity" in names: op = np.asarray(v["opacity"], dtype=np.float64) # 3DGS often stores opacity as a raw pre-sigmoid logit rather than # a 0-1 probability — detect that and convert if so. if op.min() < -0.01 or op.max() > 1.01: op_prob = 1.0 / (1.0 + np.exp(-op)) else: op_prob = op keep_opacity = op_prob >= opacity_thresh else: keep_opacity = np.ones(n0, dtype=bool) # Robust outlier rejection via median absolute deviation (MAD) — more # resistant to the very outliers we're trying to remove than mean/std. med = np.median(xyz, axis=0) mad = np.median(np.abs(xyz - med), axis=0) + 1e-6 dist = np.abs(xyz - med) / mad keep_pos = np.all(dist <= mad_k, axis=1) keep = keep_opacity & keep_pos n1 = int(keep.sum()) if n1 == 0 or n1 == n0: return ply_path, f"{n0:,} gaussians (no change)" new_data = v.data[keep] new_el = PlyElement.describe(new_data, "vertex") out_path = ply_path[:-4] + "_clean.ply" if ply_path.endswith(".ply") else ply_path + "_clean.ply" PlyData([new_el], text=plydata.text, byte_order=plydata.byte_order).write(out_path) size_before = os.path.getsize(ply_path) / 1024 / 1024 size_after = os.path.getsize(out_path) / 1024 / 1024 pct = (1 - n1 / n0) * 100 return out_path, f"{n0:,} → {n1:,} gaussians ({pct:.0f}% pruned) · {size_before:.1f}MB → {size_after:.1f}MB" # ── Animation HTML (two angles, camera flies between them) ── def generate_animation_html(ply1, ply2): def enc(p): with open(p, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") b1, b2 = enc(ply1), enc(ply2) return f""" SplatWeb Animation
✦ 3D KEYFRAME ANIMATION
LOADING 3D SCENES…
ANGLE 1
Drag → Orbit
Scroll → Zoom
Camera auto-animates
""" # ──────────────────────────────────────────────────────────────── # CSS — back in gr.Blocks() where it belongs in Gradio 6 # (shows a harmless warning in logs but does NOT crash) # ──────────────────────────────────────────────────────────────── CSS = """ @import url('https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=JetBrains+Mono:wght@300;400;500&display=swap'); /* ── Theme via Gradio's own CSS variables — the stable, documented way to reskin native components (radios, sliders, buttons, tabs, inputs) without depending on internal class names that change between Gradio versions. ── */ :root, .dark { --body-background-fill: #050810; --background-fill-primary: #050810; --background-fill-secondary: #0a0f1c; --block-background-fill: #0a0f1c; --block-border-color: rgba(77,138,255,0.16); --block-label-text-color: #8b9ac4; --block-title-text-color: #dce8ff; --body-text-color: #dce8ff; --body-text-color-subdued: #8b9ac4; --border-color-primary: rgba(77,138,255,0.18); --border-color-accent: #4d8aff; --input-background-fill: #0d1220; --input-border-color: rgba(77,138,255,0.22); --input-border-color-focus: #4d8aff; --checkbox-background-color: #0d1220; --checkbox-background-color-selected: #4d8aff; --checkbox-border-color: rgba(77,138,255,0.3); --checkbox-border-color-selected: #4d8aff; --checkbox-label-background-fill: #0d1220; --checkbox-label-background-fill-selected: linear-gradient(135deg,#4d8aff,#8b5cf6); --checkbox-label-border-color: rgba(77,138,255,0.22); --checkbox-label-border-color-selected: #4d8aff; --checkbox-label-text-color: #8b9ac4; --checkbox-label-text-color-selected: #fff; --slider-color: #4d8aff; --button-primary-background-fill: linear-gradient(135deg,#4d8aff,#8b5cf6); --button-primary-background-fill-hover: linear-gradient(135deg,#5f97ff,#9a6cf7); --button-primary-text-color: #fff; --button-secondary-background-fill: #0d1220; --button-secondary-border-color: rgba(77,138,255,0.25); --button-secondary-text-color: #dce8ff; --panel-background-fill: #0a0f1c; --shadow-drop: 0 4px 20px rgba(77,138,255,0.08); } body, .gradio-container { background: #050810 !important; font-family: 'JetBrains Mono', monospace !important; } .gradio-container { max-width: 820px !important; margin: 0 auto !important; } h1 { font-family: 'Syne', sans-serif !important; font-weight: 800 !important; font-size: 2.6rem !important; background: linear-gradient(135deg, #4d8aff, #8b5cf6, #22d3a0) !important; -webkit-background-clip: text !important; -webkit-text-fill-color: transparent !important; letter-spacing: -0.02em !important; line-height: 1.1 !important; margin-bottom: 0.5rem !important; } /* Buttons */ button.lg { font-family: 'Syne', sans-serif !important; font-weight: 700 !important; letter-spacing: 0.02em; } button.primary, .gradio-container button[variant="primary"] { border: none !important; box-shadow: 0 4px 18px rgba(77,138,255,0.35) !important; transition: transform 0.15s ease, box-shadow 0.15s ease !important; } button.primary:hover { transform: translateY(-1px) !important; box-shadow: 0 6px 22px rgba(139,92,246,0.4) !important; } /* Panels / blocks (image uploader, textboxes, tips accordion, etc.) */ .block, .form { border-radius: 14px !important; border: 1px solid rgba(77,138,255,0.14) !important; } /* Tab navigation */ .tab-nav { border-bottom: 1px solid rgba(77,138,255,0.14) !important; gap: 0.4rem !important; } .tab-nav button { font-family: 'JetBrains Mono', monospace !important; color: #8b9ac4 !important; border-radius: 10px 10px 0 0 !important; } .tab-nav button.selected { color: #4d8aff !important; font-weight: 600 !important; border-bottom: 2px solid #4d8aff !important; background: rgba(77,138,255,0.06) !important; } /* Radio / checkbox pill groups */ label.selected, .selected > label { box-shadow: 0 2px 12px rgba(77,138,255,0.25) !important; } input[type="radio"], input[type="checkbox"] { accent-color: #4d8aff !important; } /* Sliders */ input[type="range"] { accent-color: #4d8aff !important; } /* Accordion (Tips section) */ .label-wrap { border-radius: 10px !important; } /* Textboxes / status output */ textarea, input[type="text"] { font-family: 'JetBrains Mono', monospace !important; border-radius: 10px !important; } @keyframes sw_spin { to { transform: rotate(360deg); } } """ # ──────────────────────────────────────────────────────────────── # 3D viewer — parameterized so multiple independent instances can exist # on the page (Single Photo tab + the standalone .ply viewer tab) without # colliding on element IDs. Each instance is looked up by its instance_id # via data-role attributes scoped inside its own container div. # ──────────────────────────────────────────────────────────────── def make_viewer_shell(instance_id: str) -> str: return f"""
✦ 3D SCENE VIEWER
✦
3D SCENE WILL APPEAR HERE
Upload a photo and press Build →
BUILDING 3D SCENE…
Drag → Orbit Scroll → Zoom Shift+Drag → Pan
// debug log (tap to expand)

  
""" # One shared script block driving every viewer instance on the page, # keyed by instance_id via data-role lookups scoped to each container. VIEWER_SCRIPT = """ """ # Gradio serves any file under its allowed paths (which includes the # system temp dir — where our .ply files live) at this documented route: # /gradio_api/file= def make_ply_url(ply_path: str) -> str: return "/gradio_api/file=" + urllib.parse.quote(ply_path, safe="/") # ──────────────────────────────────────────────────────────────── # UI — css in gr.Blocks() (correct for all Gradio versions) # ──────────────────────────────────────────────────────────────── with gr.Blocks(css=CSS, title="SplatWeb") as demo: gr.HTML("""

SPLATWEB

PHOTO → 3D GAUSSIAN SPLAT · MULTI-MODEL · FREE

✓ 100% FREE ⚡ AUTO 3D PREVIEW ✦ CAMERA ANIMATION
""") with gr.Tabs(): # ── Tab 1: Single photo ────────────────────────────── with gr.TabItem("📷 Single Photo → 3D"): img1 = gr.Image( type="filepath", label="// upload photo", sources=["upload", "webcam"], height=260 ) with gr.Accordion("📸 Tips for best results", open=False): gr.Markdown(""" - Any size photo — app auto-resizes to 512px before processing - Clear, well-lit subject gives the best 3D quality - Works on: objects, rooms, food, people, landscapes - **GPU:** fast (~45s), but free HF accounts get 5 min/day total ZeroGPU quota - **CPU:** slow (~2-4 min), but no daily quota limit - **ZeroGPU hardware must be selected in your Space's Settings → Hardware** - **TripoSplat (alt model):** experimental — calls a different team's live public demo, so it can be slower/less reliable, but lets you control the Gaussian count - **Auto-clean:** prunes near-invisible and statistically stray gaussians — usually shrinks the file and reduces noise with no visible quality loss """) with gr.Row(): device_choice = gr.Radio( ["⚡ GPU (fast, uses daily quota)", "🐢 CPU (slower, no quota limit)"], value="⚡ GPU (fast, uses daily quota)", label="Processing device" ) model_choice = gr.Radio( ["Apple SHARP (built-in)", "TripoSplat (experimental, via live demo)"], value="Apple SHARP (built-in)", label="Model" ) tripo_gaussians = gr.Slider( minimum=32768, maximum=262144, step=32768, value=131072, label="Gaussian count (TripoSplat only)", info="Higher = more detail + bigger file. Ignored when using SHARP." ) clean_toggle = gr.Checkbox( value=True, label="✂ Auto-clean stray noise gaussians (recommended)" ) btn1 = gr.Button("✦ Build My 3D Scene", variant="primary", size="lg") st1 = gr.Textbox( label="// status", interactive=False, lines=3, placeholder="Upload a photo and press the button…" ) # 3D viewer — always visible, waits for content gr.HTML(make_viewer_shell("v1")) # Real, native Gradio download — works regardless of whether # the custom WebGL preview above loads, so you always have a # reliable way to get the file and open it in another app. dl1 = gr.DownloadButton("⬇ Download .ply", visible=False) # Hidden trigger — a plain Textbox (not gr.HTML). Browsers (and # Gradio's Svelte renderer) never execute