Spaces:
Sleeping
Sleeping
Download app.py from VISHAL18for4/splatweb: direct link, hf CLI and curl.
- Browser
- Download file 51.1 kB
-
https://huggingface.co/spaces/VISHAL18for4/splatweb/resolve/main/app.py
- Command line
-
hf download hf://spaces/VISHAL18for4/splatweb/app.py
-
curl -L -o app.py https://huggingface.co/spaces/VISHAL18for4/splatweb/resolve/main/app.py
51.1 kB
| 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: | |
| 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 | |
| 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"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"/> | |
| <meta name="viewport" content="width=device-width,initial-scale=1.0"/> | |
| <title>SplatWeb Animation</title> | |
| <style> | |
| *{{box-sizing:border-box;margin:0;padding:0}} | |
| body{{background:#050810;color:#dce8ff;font-family:monospace;height:100vh;display:flex;flex-direction:column;overflow:hidden}} | |
| #hdr{{display:flex;align-items:center;justify-content:space-between;padding:.8rem 1.4rem; | |
| border-bottom:1px solid rgba(77,138,255,.15);background:rgba(5,8,16,.9);backdrop-filter:blur(10px);flex-shrink:0;gap:.8rem}} | |
| .logo{{font-size:1.1rem;font-weight:bold;letter-spacing:.06em;background:linear-gradient(90deg,#4d8aff,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent}} | |
| .hr{{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;justify-content:flex-end}} | |
| .badge{{font-size:.58rem;color:#22d3a0;border:1px solid rgba(34,211,160,.3);padding:.22rem .65rem;border-radius:100px;white-space:nowrap}} | |
| #btndl{{background:linear-gradient(135deg,#4d8aff,#8b5cf6);border:none;border-radius:8px;padding:.32rem .85rem; | |
| color:#fff;font-family:monospace;font-size:.6rem;cursor:pointer;white-space:nowrap;transition:all .2s}} | |
| #btndl:hover{{transform:translateY(-1px)}} | |
| #wrap{{flex:1;position:relative;overflow:hidden}} | |
| canvas{{width:100%!important;height:100%!important;display:block}} | |
| #ov{{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;background:rgba(5,8,16,.78);backdrop-filter:blur(4px)}} | |
| #ov.hidden{{display:none}} | |
| .sp{{width:42px;height:42px;border:2px solid rgba(77,138,255,.18);border-top-color:#4d8aff;border-radius:50%;animation:spin 1s linear infinite}} | |
| @keyframes spin{{to{{transform:rotate(360deg)}}}} | |
| #lm{{font-size:.7rem;color:rgba(180,200,255,.5);letter-spacing:.1em}} | |
| #sl{{position:absolute;top:1rem;left:50%;transform:translateX(-50%);font-size:.6rem;color:rgba(180,200,255,.4); | |
| background:rgba(0,0,0,.55);padding:.25rem .9rem;border-radius:100px;border:1px solid rgba(77,138,255,.12);display:none;white-space:nowrap}} | |
| #ctrls{{position:absolute;bottom:1.2rem;left:50%;transform:translateX(-50%);display:flex;gap:.5rem;flex-wrap:wrap;justify-content:center}} | |
| .pill{{background:rgba(0,0,0,.62);border:1px solid rgba(255,255,255,.07);border-radius:100px;padding:.28rem .72rem;font-size:.57rem;color:rgba(180,200,255,.42)}} | |
| #pbw{{position:absolute;bottom:0;left:0;right:0;height:3px;background:rgba(77,138,255,.1)}} | |
| #pb{{height:100%;background:linear-gradient(90deg,#4d8aff,#8b5cf6,#22d3a0);width:0%;transition:width .12s linear}} | |
| </style> | |
| </head> | |
| <body> | |
| <div id="hdr"> | |
| <div class="logo">SPLATWEB</div> | |
| <div class="hr"> | |
| <div class="badge">β¦ 3D KEYFRAME ANIMATION</div> | |
| <button id="btndl" onclick="saveme()">β¬ SAVE HTML</button> | |
| </div> | |
| </div> | |
| <div id="wrap"> | |
| <canvas id="c"></canvas> | |
| <div id="ov"><div class="sp"></div><div id="lm">LOADING 3D SCENESβ¦</div></div> | |
| <div id="sl">ANGLE 1</div> | |
| <div id="ctrls"> | |
| <div class="pill">Drag β Orbit</div> | |
| <div class="pill">Scroll β Zoom</div> | |
| <div class="pill">Camera auto-animates</div> | |
| </div> | |
| <div id="pbw"><div id="pb"></div></div> | |
| </div> | |
| <script type="importmap">{{"imports":{{"@mkkellogg/gaussian-splats-3d":"https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.2/build/gaussian-splats-3d.module.js"}}}}</script> | |
| <script type="module"> | |
| import * as G from '@mkkellogg/gaussian-splats-3d'; | |
| function b2u(b){{const bin=atob(b),buf=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)buf[i]=bin.charCodeAt(i);return URL.createObjectURL(new Blob([buf],{{type:'application/octet-stream'}}));}} | |
| const u1=b2u(`{b1}`),u2=b2u(`{b2}`); | |
| const canvas=document.getElementById('c'),ov=document.getElementById('ov'),lm=document.getElementById('lm'),pb=document.getElementById('pb'),sl=document.getElementById('sl'); | |
| const A={{x:-2.5,y:-1.2,z:5}},B={{x:2.5,y:-.4,z:5}},TRAVEL=4000,HOLD=1500; | |
| let viewer=null,phase='hold_a',ps=null; | |
| const ease=t=>t<.5?2*t*t:-1+(4-2*t)*t,lerp=(a,b,t)=>a+(b-a)*t; | |
| function tick(now){{ | |
| if(!viewer||!viewer.camera){{requestAnimationFrame(tick);return;}} | |
| if(!ps)ps=now;const e=now-ps,cam=viewer.camera; | |
| if(phase==='hold_a'){{cam.position.set(A.x,A.y,A.z);pb.style.width='0%';sl.textContent='ANGLE 1';if(e>=HOLD){{phase='a_to_b';ps=now;}}}} | |
| else if(phase==='a_to_b'){{const t=ease(Math.min(e/TRAVEL,1));cam.position.set(lerp(A.x,B.x,t),lerp(A.y,B.y,t),lerp(A.z,B.z,t));pb.style.width=(t*100)+'%';sl.textContent='ANGLE 1 β ANGLE 2';if(e>=TRAVEL){{phase='hold_b';ps=now;}}}} | |
| else if(phase==='hold_b'){{cam.position.set(B.x,B.y,B.z);pb.style.width='100%';sl.textContent='ANGLE 2';if(e>=HOLD){{phase='b_to_a';ps=now;}}}} | |
| else if(phase==='b_to_a'){{const t=ease(Math.min(e/TRAVEL,1));cam.position.set(lerp(B.x,A.x,t),lerp(B.y,A.y,t),lerp(B.z,A.z,t));pb.style.width=((1-t)*100)+'%';sl.textContent='ANGLE 2 β ANGLE 1';if(e>=TRAVEL){{phase='hold_a';ps=now;}}}} | |
| cam.lookAt(0,0,0);requestAnimationFrame(tick); | |
| }} | |
| async function init(){{ | |
| viewer=new G.Viewer({{canvas,cameraUp:[0,-1,0],initialCameraPosition:[A.x,A.y,A.z],initialCameraLookAt:[0,0,0],selfDrivenMode:true,dynamicScene:true}}); | |
| try{{ | |
| lm.textContent='LOADING ANGLE 1β¦';await viewer.addSplatScene(u1,{{progressiveLoad:false}}); | |
| lm.textContent='LOADING ANGLE 2β¦';await viewer.addSplatScene(u2,{{progressiveLoad:false}}); | |
| ov.classList.add('hidden');sl.style.display='block';viewer.start();requestAnimationFrame(tick); | |
| }}catch(err){{lm.textContent='β Load error.';console.error(err);}} | |
| }} | |
| init(); | |
| </script> | |
| <script>function saveme(){{const blob=new Blob([document.documentElement.outerHTML],{{type:'text/html'}});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='splatweb-animation.html';document.body.appendChild(a);a.click();document.body.removeChild(a);}}</script> | |
| </body></html>""" | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # 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""" | |
| <div id="splatweb-viewer-{instance_id}" | |
| style="background:#050810;border:1px solid rgba(77,138,255,0.18); | |
| border-radius:18px;overflow:hidden;"> | |
| <div style="display:flex;align-items:center;justify-content:space-between; | |
| padding:0.7rem 1.2rem;border-bottom:1px solid rgba(77,138,255,0.1); | |
| background:rgba(5,8,16,0.95);gap:0.6rem;flex-wrap:wrap;"> | |
| <div> | |
| <span style="font-family:monospace;font-size:0.68rem;font-weight:bold; | |
| background:linear-gradient(90deg,#4d8aff,#8b5cf6); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent; | |
| letter-spacing:0.06em;">β¦ 3D SCENE VIEWER</span> | |
| <span data-role="size" style="font-family:monospace;font-size:0.55rem; | |
| color:rgba(180,200,255,0.3);margin-left:0.5rem;"></span> | |
| </div> | |
| <button data-role="dlbtn" onclick="swDownload('{instance_id}')" | |
| style="display:none;background:linear-gradient(135deg,#4d8aff,#8b5cf6); | |
| border:none;border-radius:8px;padding:0.3rem 0.9rem;color:#fff; | |
| font-family:monospace;font-size:0.6rem;letter-spacing:0.07em; | |
| cursor:pointer;box-shadow:0 2px 10px rgba(77,138,255,0.3);"> | |
| β¬ DOWNLOAD .PLY | |
| </button> | |
| </div> | |
| <div style="position:relative;width:100%;height:460px;"> | |
| <canvas data-role="canvas" | |
| style="width:100%;height:100%;display:block; | |
| background:radial-gradient(ellipse at center,#071020,#020408);"></canvas> | |
| <div data-role="idle" | |
| style="position:absolute;inset:0;display:flex;flex-direction:column; | |
| align-items:center;justify-content:center;gap:0.8rem; | |
| background:rgba(5,8,16,0.85);"> | |
| <div style="font-size:2.5rem;opacity:0.3;">β¦</div> | |
| <div style="font-family:monospace;font-size:0.7rem;color:rgba(180,200,255,0.35); | |
| letter-spacing:0.12em;text-align:center;line-height:1.7;"> | |
| 3D SCENE WILL APPEAR HERE<br/> | |
| <span style="font-size:0.58rem;opacity:0.6;">Upload a photo and press Build β</span> | |
| </div> | |
| </div> | |
| <div data-role="loading" | |
| style="position:absolute;inset:0;display:none;flex-direction:column; | |
| align-items:center;justify-content:center;gap:1rem; | |
| background:rgba(5,8,16,0.82);backdrop-filter:blur(4px);"> | |
| <div style="width:40px;height:40px; | |
| border:2px solid rgba(77,138,255,0.2); | |
| border-top-color:#4d8aff;border-radius:50%; | |
| animation:sw_spin 1s linear infinite;"></div> | |
| <div data-role="loadmsg" | |
| style="font-family:monospace;font-size:0.68rem; | |
| color:rgba(180,200,255,0.55);letter-spacing:0.1em;"> | |
| BUILDING 3D SCENE⦠| |
| </div> | |
| </div> | |
| <div data-role="controls" | |
| style="position:absolute;bottom:0.9rem;left:50%;transform:translateX(-50%); | |
| display:none;gap:0.4rem;flex-wrap:wrap;justify-content:center;pointer-events:none;"> | |
| <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px); | |
| border:1px solid rgba(255,255,255,0.07);border-radius:100px; | |
| padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem; | |
| color:rgba(180,200,255,0.4);">Drag β Orbit</span> | |
| <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px); | |
| border:1px solid rgba(255,255,255,0.07);border-radius:100px; | |
| padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem; | |
| color:rgba(180,200,255,0.4);">Scroll β Zoom</span> | |
| <span style="background:rgba(0,0,0,0.65);backdrop-filter:blur(6px); | |
| border:1px solid rgba(255,255,255,0.07);border-radius:100px; | |
| padding:0.25rem 0.65rem;font-family:monospace;font-size:0.55rem; | |
| color:rgba(180,200,255,0.4);">Shift+Drag β Pan</span> | |
| </div> | |
| </div> | |
| <details style="border-top:1px solid rgba(77,138,255,0.1);"> | |
| <summary style="cursor:pointer;padding:0.5rem 0.8rem;font-family:monospace;font-size:0.6rem; | |
| color:rgba(180,200,255,0.45);letter-spacing:0.08em;"> | |
| // debug log (tap to expand) | |
| </summary> | |
| <pre data-role="debug" style="font-family:monospace;font-size:0.58rem;line-height:1.5; | |
| color:rgba(180,200,255,0.55);padding:0 0.8rem 0.7rem;margin:0; | |
| max-height:180px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;"></pre> | |
| </details> | |
| </div> | |
| """ | |
| # 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 = """ | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "@mkkellogg/gaussian-splats-3d": | |
| "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.2/build/gaussian-splats-3d.module.js" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d'; | |
| window._swState = window._swState || {}; | |
| function _swEls(id) { | |
| const root = document.getElementById('splatweb-viewer-' + id); | |
| if (!root) return null; | |
| return { | |
| idle: root.querySelector('[data-role="idle"]'), | |
| loading: root.querySelector('[data-role="loading"]'), | |
| controls: root.querySelector('[data-role="controls"]'), | |
| dlBtn: root.querySelector('[data-role="dlbtn"]'), | |
| sizeEl: root.querySelector('[data-role="size"]'), | |
| canvas: root.querySelector('[data-role="canvas"]'), | |
| msg: root.querySelector('[data-role="loadmsg"]'), | |
| debug: root.querySelector('[data-role="debug"]'), | |
| }; | |
| } | |
| window.swLog = function(id, msg) { | |
| const t = new Date().toTimeString().slice(0, 8); | |
| console.log('[SplatWeb:' + id + ']', msg); | |
| const els = _swEls(id); | |
| if (els && els.debug) { | |
| els.debug.textContent += `[${t}] ${msg}\\n`; | |
| els.debug.scrollTop = els.debug.scrollHeight; | |
| } | |
| }; | |
| window.swLoadUrl = async function(id, fileUrl, sizeMb) { | |
| const els = _swEls(id); | |
| if (!els) { console.error('SplatWeb: no viewer found for instance', id); return; } | |
| const { idle, loading, controls, dlBtn, sizeEl, canvas, msg } = els; | |
| window.swLog(id, 'trigger fired β ' + fileUrl); | |
| idle.style.display = 'none'; | |
| loading.style.display = 'flex'; | |
| controls.style.display = 'none'; | |
| if (sizeEl) sizeEl.textContent = (sizeMb ? sizeMb + ' MB Β· ' : '') + 'WebGL Β· your GPU'; | |
| try { | |
| msg.textContent = 'DOWNLOADING SCENEβ¦'; | |
| window.swLog(id, 'fetching fileβ¦'); | |
| const resp = await fetch(fileUrl); | |
| window.swLog(id, 'fetch responded: HTTP ' + resp.status); | |
| if (!resp.ok) throw new Error('server returned HTTP ' + resp.status + ' for the file URL'); | |
| const buf = await resp.arrayBuffer(); | |
| window.swLog(id, 'downloaded ' + (buf.byteLength / 1024 / 1024).toFixed(2) + ' MB'); | |
| const blobUrl = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' })); | |
| const prev = window._swState[id]; | |
| if (prev) { | |
| if (prev.blobUrl) { try { URL.revokeObjectURL(prev.blobUrl); } catch(e) {} } | |
| if (prev.viewer) { try { prev.viewer.dispose(); } catch(e) {} } | |
| } | |
| window.swLog(id, 'initializing WebGL viewerβ¦'); | |
| const viewer = new GaussianSplats3D.Viewer({ | |
| canvas, | |
| cameraUp: [0, -1, 0], | |
| initialCameraPosition: [-1, -4, 6], | |
| initialCameraLookAt: [0, 0, 0], | |
| selfDrivenMode: true, | |
| }); | |
| msg.textContent = 'RENDERING GAUSSIANSβ¦'; | |
| window.swLog(id, 'parsing splat data + uploading to GPUβ¦'); | |
| await viewer.addSplatScene(blobUrl, { progressiveLoad: true }); | |
| window.swLog(id, 'scene loaded β'); | |
| window._swState[id] = { viewer, blobUrl }; | |
| loading.style.display = 'none'; | |
| controls.style.display = 'flex'; | |
| if (dlBtn) dlBtn.style.display = 'block'; | |
| viewer.start(); | |
| } catch(err) { | |
| const emsg = (err && err.message) ? err.message : String(err); | |
| window.swLog(id, 'ERROR: ' + emsg); | |
| msg.textContent = 'β ' + emsg; | |
| console.error(err); | |
| } | |
| }; | |
| window.swDownload = function(id) { | |
| const state = window._swState[id]; | |
| if (!state || !state.blobUrl) return; | |
| const a = document.createElement('a'); | |
| a.href = state.blobUrl; | |
| a.download = 'scene.ply'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| }; | |
| </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=<path> | |
| 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(""" | |
| <div style="text-align:center;padding:2.5rem 1rem 1.5rem; | |
| border-bottom:1px solid rgba(80,130,255,0.1);margin-bottom:1.5rem;"> | |
| <h1>SPLATWEB</h1> | |
| <p style="color:rgba(180,200,255,0.4);font-size:0.72rem; | |
| letter-spacing:0.1em;margin-top:0.3rem;"> | |
| PHOTO β 3D GAUSSIAN SPLAT Β· MULTI-MODEL Β· FREE | |
| </p> | |
| <div style="display:inline-flex;gap:0.6rem;margin-top:0.8rem; | |
| flex-wrap:wrap;justify-content:center;"> | |
| <span style="background:rgba(34,211,160,0.08);border:1px solid rgba(34,211,160,0.2); | |
| color:#22d3a0;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;"> | |
| β 100% FREE</span> | |
| <span style="background:rgba(77,138,255,0.08);border:1px solid rgba(77,138,255,0.2); | |
| color:#4d8aff;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;"> | |
| β‘ AUTO 3D PREVIEW</span> | |
| <span style="background:rgba(139,92,246,0.08);border:1px solid rgba(139,92,246,0.2); | |
| color:#8b5cf6;font-size:0.6rem;padding:0.2rem 0.7rem;border-radius:100px;"> | |
| β¦ CAMERA ANIMATION</span> | |
| </div> | |
| </div> | |
| """) | |
| 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 <script> tags inserted | |
| # via innerHTML on a dynamic update β only on the page's | |
| # *initial* load. A Textbox's .change(js=...) event is Gradio's | |
| # actual supported mechanism for running JS off a Python return | |
| # value, and reliably passes the exact string we set. | |
| url_holder = gr.Textbox(value="", visible=False, elem_id="sw-url-holder") | |
| # ββ Tab 2: Two angles + animation βββββββββββββββββββ | |
| with gr.TabItem("π¬ Two Angles β Animation"): | |
| gr.HTML(""" | |
| <div style="background:rgba(139,92,246,0.06); | |
| border:1px solid rgba(139,92,246,0.18); | |
| border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;"> | |
| <div style="font-size:0.62rem;color:#8b5cf6; | |
| letter-spacing:0.1em;margin-bottom:0.4rem;">β¦ HOW THIS WORKS</div> | |
| <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;"> | |
| Upload 2 photos of the same object from different angles. | |
| Both convert to 3D. Enable animation to get a downloadable HTML file β | |
| open it on any phone, camera flies between both angles in a loop. | |
| The HTML has a <strong style="color:#dce8ff;">β¬ SAVE HTML</strong> button inside. | |
| </div> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| imgA = gr.Image( | |
| type="filepath", label="// angle 1 β front / left", | |
| sources=["upload"], height=220 | |
| ) | |
| imgB = gr.Image( | |
| type="filepath", label="// angle 2 β back / right", | |
| sources=["upload"], height=220 | |
| ) | |
| anim_toggle = gr.Checkbox( | |
| label="β¦ Generate camera animation HTML", | |
| value=True, | |
| info="Downloadable .html β camera flies between angles in a loop, has β¬ SAVE HTML inside" | |
| ) | |
| btn2 = gr.Button("β¦ Build 3D + Animation", variant="primary", size="lg") | |
| st2 = gr.Textbox( | |
| label="// status", interactive=False, lines=3, | |
| placeholder="Upload both photos and press the buttonβ¦" | |
| ) | |
| anim_file = gr.File( | |
| label="// animation .html β download & open on any phone", | |
| visible=False, file_types=[".html"] | |
| ) | |
| gr.HTML(""" | |
| <div style="margin-top:0.8rem;padding:0.9rem 1rem; | |
| background:rgba(34,211,160,0.04); | |
| border:1px solid rgba(34,211,160,0.1); | |
| border-radius:10px;font-size:0.65rem; | |
| color:rgba(180,200,255,0.38);line-height:1.8;"> | |
| π± Download animation .html β open in Chrome or Safari on any phone β 3D plays.<br/> | |
| π€ Share via WhatsApp / email β recipient just opens the file, no app needed.<br/> | |
| πΎ Tap <strong style="color:#dce8ff;">β¬ SAVE HTML</strong> inside to re-download anytime. | |
| </div> | |
| """) | |
| # ββ Tab 3: standalone .ply viewer (NEW) ββββββββββββββ | |
| with gr.TabItem("π View a .PLY File"): | |
| gr.HTML(""" | |
| <div style="background:rgba(34,211,160,0.06); | |
| border:1px solid rgba(34,211,160,0.18); | |
| border-radius:12px;padding:1rem 1.2rem;margin-bottom:1rem;"> | |
| <div style="font-size:0.72rem;color:rgba(180,200,255,0.55);line-height:1.65;"> | |
| Already have a Gaussian Splat <strong style="color:#dce8ff;">.ply</strong> file | |
| (from this app, SuperSplat, Luma, Polycam, etc.)? Drop it here to preview it | |
| directly β no GPU processing needed, this just loads it into the same viewer. | |
| </div> | |
| </div> | |
| """) | |
| ply_upload = gr.File(label="// upload a .ply file", file_types=[".ply"], type="filepath") | |
| btn3 = gr.Button("π Load in Viewer", variant="primary", size="lg") | |
| st3 = gr.Textbox(label="// status", interactive=False, lines=1) | |
| gr.HTML(make_viewer_shell("v3")) | |
| url_holder3 = gr.Textbox(value="", visible=False, elem_id="sw-url-holder-v3") | |
| # ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββ | |
| def handle_single(img, device_choice, model_choice, clean_toggle, tripo_gaussians, progress=gr.Progress()): | |
| print(f"[handle_single] click received img={img} device={device_choice!r} model={model_choice!r} clean={clean_toggle}", flush=True) | |
| if img is None: | |
| return "β Please upload a photo first.", gr.update(visible=False), "" | |
| use_tripo = bool(model_choice and "TripoSplat" in model_choice) | |
| use_cpu = bool(device_choice and "CPU" in device_choice) | |
| try: | |
| if use_tripo: | |
| ply, status = run_tripo(img, progress=progress, num_gaussians=tripo_gaussians) | |
| elif use_cpu: | |
| ply, status = run_sharp_cpu(img, progress=progress) | |
| else: | |
| ply, status = run_sharp_gpu(img, progress=progress) | |
| except Exception as e: | |
| print(f"[handle_single] backend call raised: {e}", flush=True) | |
| return (f"β Request failed: {str(e)}\n(If this mentions quota/duration, you've hit your daily free ZeroGPU limit β wait for it to reset, switch to CPU, or sign in with a HF account for a bigger quota.)", | |
| gr.update(visible=False), "") | |
| print(f"[handle_single] backend returned ply={ply!r} status={status!r}", flush=True) | |
| if ply and clean_toggle: | |
| try: | |
| cleaned_path, stats = clean_ply(ply) | |
| print(f"[handle_single] clean_ply: {stats}", flush=True) | |
| if cleaned_path != ply: | |
| status += f"\nβ Cleaned: {stats}" | |
| ply = cleaned_path | |
| except Exception as e: | |
| print(f"[handle_single] cleanup skipped: {e}", flush=True) | |
| status += "\n(cleanup skipped β using raw output)" | |
| if ply: | |
| size_mb = round(os.path.getsize(ply) / 1024 / 1024, 2) | |
| dl_update = gr.update(value=ply, visible=True, label=f"β¬ Download .ply ({size_mb} MB)") | |
| payload = json.dumps({"url": make_ply_url(ply), "size_mb": size_mb, "ts": time.time()}) | |
| return status, dl_update, payload | |
| return status, gr.update(visible=False), "" | |
| def handle_dual(a, b, do_anim, progress=gr.Progress()): | |
| if a is None or b is None: | |
| return "β Please upload BOTH photos.", gr.update(visible=False) | |
| try: | |
| ply1, s1 = run_sharp_gpu(a, progress=progress, desc_prefix="Angle 1: ") | |
| except Exception as e: | |
| return f"β GPU rejected angle 1: {str(e)}", gr.update(visible=False) | |
| if not ply1: | |
| return f"β Angle 1 failed:\n{s1}", gr.update(visible=False) | |
| try: | |
| ply2, s2 = run_sharp_gpu(b, progress=progress, desc_prefix="Angle 2: ") | |
| except Exception as e: | |
| return f"β Angle 1 done.\nβ GPU rejected angle 2: {str(e)}", gr.update(visible=False) | |
| if not ply2: | |
| return f"β Angle 1 done.\nβ Angle 2 failed:\n{s2}", gr.update(visible=False) | |
| msg = "β Both angles done!" | |
| html_out = gr.update(visible=False) | |
| if do_anim: | |
| try: | |
| content = generate_animation_html(ply1, ply2) | |
| tmp = tempfile.NamedTemporaryFile( | |
| suffix=".html", prefix="splatweb_anim_", | |
| delete=False, mode="w", encoding="utf-8" | |
| ) | |
| tmp.write(content) | |
| tmp.close() | |
| html_out = gr.update(value=tmp.name, visible=True) | |
| msg += "\nβ¦ Animation HTML ready β download below and open on your phone!" | |
| except Exception as e: | |
| msg += f"\nβ Animation failed: {str(e)}" | |
| return msg, html_out | |
| def handle_view_ply(f): | |
| if f is None: | |
| return "β Please upload a .ply file first.", "" | |
| size_mb = round(os.path.getsize(f) / 1024 / 1024, 2) | |
| payload = json.dumps({"url": make_ply_url(f), "size_mb": size_mb, "ts": time.time()}) | |
| return f"β Loaded {os.path.basename(f)} ({size_mb} MB)", payload | |
| btn1.click(fn=handle_single, inputs=[img1, device_choice, model_choice, clean_toggle, tripo_gaussians], | |
| outputs=[st1, dl1, url_holder]) | |
| btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file]) | |
| btn3.click(fn=handle_view_ply, inputs=[ply_upload], outputs=[st3, url_holder3]) | |
| # This is the load-bearing wire for both viewer instances: whenever | |
| # url_holder's value changes (including programmatically, from a | |
| # handler's return), Gradio calls this JS function with that exact | |
| # string β the documented, reliable way to run custom JS off a Python | |
| # result. | |
| url_holder.change( | |
| fn=None, inputs=[url_holder], outputs=[], | |
| js=""" | |
| (payload) => { | |
| const log = (m) => { if (window.swLog) window.swLog('v1', m); else console.log('[SplatWeb]', m); }; | |
| if (!payload) { log('trigger cleared, nothing to load'); return; } | |
| try { | |
| const data = JSON.parse(payload); | |
| if (window.swLoadUrl) { window.swLoadUrl('v1', data.url, data.size_mb); } | |
| else { log('ERROR: viewer script not ready yet β reload the page and try again'); } | |
| } catch (e) { | |
| log('ERROR parsing trigger payload: ' + e.message); | |
| } | |
| } | |
| """ | |
| ) | |
| url_holder3.change( | |
| fn=None, inputs=[url_holder3], outputs=[], | |
| js=""" | |
| (payload) => { | |
| const log = (m) => { if (window.swLog) window.swLog('v3', m); else console.log('[SplatWeb]', m); }; | |
| if (!payload) { log('trigger cleared, nothing to load'); return; } | |
| try { | |
| const data = JSON.parse(payload); | |
| if (window.swLoadUrl) { window.swLoadUrl('v3', data.url, data.size_mb); } | |
| else { log('ERROR: viewer script not ready yet β reload the page and try again'); } | |
| } catch (e) { | |
| log('ERROR parsing trigger payload: ' + e.message); | |
| } | |
| } | |
| """ | |
| ) | |
| # Shared viewer script β placed once, after every viewer container it | |
| # controls already exists in the initial page HTML, so its <script> | |
| # tags execute normally (this is the page's *first* render, not a | |
| # dynamic update, so the innerHTML-script caveat above doesn't apply). | |
| gr.HTML(VIEWER_SCRIPT) | |
| gr.HTML(""" | |
| <div style="text-align:center;padding:1.5rem 1rem; | |
| border-top:1px solid rgba(80,130,255,0.07);margin-top:1.5rem;"> | |
| <p style="font-size:0.6rem;color:rgba(180,200,255,0.22);line-height:1.8;"> | |
| SplatWeb Β· Apple SHARP + TripoSplat Β· HuggingFace Β· 3D renders on your device GPU via WebGL | |
| </p> | |
| </div> | |
| """) | |
| if __name__ == "__main__": | |
| demo.launch(server_name="0.0.0.0", server_port=7860) | |