splatweb / app.py
VISHAL18for4's picture
Upload app.py
0f2f4d9 verified
Raw History Blame Contribute Delete
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:
@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"""<!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)