Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -4,6 +4,7 @@ import sys
|
|
| 4 |
import os
|
| 5 |
import base64
|
| 6 |
import tempfile
|
|
|
|
| 7 |
from PIL import Image
|
| 8 |
|
| 9 |
# ββ ZeroGPU optional ββ
|
|
@@ -58,26 +59,37 @@ GPU_DURATION = 90
|
|
| 58 |
|
| 59 |
@spaces.GPU(duration=GPU_DURATION)
|
| 60 |
def run_sharp(image_path):
|
|
|
|
|
|
|
| 61 |
if image_path is None:
|
| 62 |
return None, "β Please upload a photo first."
|
| 63 |
out_dir = tempfile.mkdtemp(prefix="splat_")
|
| 64 |
resized = None
|
| 65 |
try:
|
| 66 |
resized = resize_image(image_path, 512)
|
|
|
|
| 67 |
result = subprocess.run(
|
| 68 |
["sharp", "predict", "-i", resized, "-o", out_dir],
|
| 69 |
capture_output=True, text=True, timeout=GPU_DURATION - 15
|
| 70 |
)
|
|
|
|
| 71 |
ply_files = [f for f in os.listdir(out_dir) if f.endswith(".ply")]
|
| 72 |
if not ply_files:
|
| 73 |
err = (result.stderr or result.stdout or "No .ply produced.")[-800:]
|
|
|
|
| 74 |
return None, f"SHARP failed:\n{err}"
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
| 76 |
except subprocess.TimeoutExpired:
|
|
|
|
| 77 |
return None, "β Timed out inside the GPU window. Try a smaller/simpler photo, or increase GPU_DURATION near the top of app.py if you have more quota (PRO/Team/Enterprise)."
|
| 78 |
except FileNotFoundError:
|
|
|
|
| 79 |
return None, "β SHARP not found yet β wait 1 minute and try again."
|
| 80 |
except Exception as e:
|
|
|
|
| 81 |
return None, f"β Error: {str(e)}"
|
| 82 |
finally:
|
| 83 |
if resized:
|
|
@@ -286,6 +298,17 @@ VIEWER_SHELL = """
|
|
| 286 |
</div>
|
| 287 |
</div>
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
<style>@keyframes sw_spin { to { transform: rotate(360deg); } }</style>
|
| 290 |
|
| 291 |
<script type="importmap">
|
|
@@ -300,13 +323,21 @@ VIEWER_SHELL = """
|
|
| 300 |
<script type="module">
|
| 301 |
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
|
| 302 |
|
| 303 |
-
// Expose loader globally so Gradio's
|
| 304 |
-
// This version fetches the file as raw binary (no base64) β much faster
|
| 305 |
-
// on mobile, since base64 inflates size ~33% AND forces a slow manual
|
| 306 |
-
// byte-by-byte decode loop that can freeze the page for large models.
|
| 307 |
window._swViewer = null;
|
| 308 |
window._swBlobUrl = null;
|
| 309 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
window.swLoadUrl = async function(fileUrl, sizeMb) {
|
| 311 |
const idle = document.getElementById('sw-idle');
|
| 312 |
const loading = document.getElementById('sw-loading');
|
|
@@ -316,6 +347,8 @@ window.swLoadUrl = async function(fileUrl, sizeMb) {
|
|
| 316 |
const canvas = document.getElementById('sw-canvas');
|
| 317 |
const msg = document.getElementById('sw-load-msg');
|
| 318 |
|
|
|
|
|
|
|
| 319 |
idle.style.display = 'none';
|
| 320 |
loading.style.display = 'flex';
|
| 321 |
controls.style.display = 'none';
|
|
@@ -323,9 +356,13 @@ window.swLoadUrl = async function(fileUrl, sizeMb) {
|
|
| 323 |
|
| 324 |
try {
|
| 325 |
msg.textContent = 'DOWNLOADING SCENEβ¦';
|
|
|
|
| 326 |
const resp = await fetch(fileUrl);
|
| 327 |
-
|
|
|
|
|
|
|
| 328 |
const buf = await resp.arrayBuffer();
|
|
|
|
| 329 |
const blobUrl = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' }));
|
| 330 |
|
| 331 |
if (window._swBlobUrl) { try { URL.revokeObjectURL(window._swBlobUrl); } catch(e) {} }
|
|
@@ -336,6 +373,7 @@ window.swLoadUrl = async function(fileUrl, sizeMb) {
|
|
| 336 |
window._swViewer = null;
|
| 337 |
}
|
| 338 |
|
|
|
|
| 339 |
const viewer = new GaussianSplats3D.Viewer({
|
| 340 |
canvas,
|
| 341 |
cameraUp: [0, -1, 0],
|
|
@@ -346,7 +384,9 @@ window.swLoadUrl = async function(fileUrl, sizeMb) {
|
|
| 346 |
window._swViewer = viewer;
|
| 347 |
|
| 348 |
msg.textContent = 'RENDERING GAUSSIANSβ¦';
|
|
|
|
| 349 |
await viewer.addSplatScene(blobUrl, { progressiveLoad: true });
|
|
|
|
| 350 |
|
| 351 |
loading.style.display = 'none';
|
| 352 |
controls.style.display = 'flex';
|
|
@@ -354,7 +394,9 @@ window.swLoadUrl = async function(fileUrl, sizeMb) {
|
|
| 354 |
viewer.start();
|
| 355 |
|
| 356 |
} catch(err) {
|
| 357 |
-
|
|
|
|
|
|
|
| 358 |
console.error(err);
|
| 359 |
}
|
| 360 |
};
|
|
@@ -375,34 +417,6 @@ function swDownload() {
|
|
| 375 |
"""
|
| 376 |
|
| 377 |
|
| 378 |
-
# ββ Tiny trigger: waits for the hidden File component's link to appear/
|
| 379 |
-
# update in the DOM, then hands its URL to the viewer. No file bytes ever
|
| 380 |
-
# pass through Python string formatting or the Gradio websocket payload β
|
| 381 |
-
# only a short bit of JS does, so this stays fast no matter how big the
|
| 382 |
-
# .ply is.
|
| 383 |
-
def make_load_trigger(size_mb) -> str:
|
| 384 |
-
return f"""
|
| 385 |
-
<script>
|
| 386 |
-
(function() {{
|
| 387 |
-
const host = document.getElementById('sw-ply-file');
|
| 388 |
-
if (!host) return;
|
| 389 |
-
function tryFire() {{
|
| 390 |
-
const a = host.querySelector('a[href]');
|
| 391 |
-
if (a && a.href && typeof window.swLoadUrl === 'function') {{
|
| 392 |
-
window.swLoadUrl(a.href, '{size_mb}');
|
| 393 |
-
return true;
|
| 394 |
-
}}
|
| 395 |
-
return false;
|
| 396 |
-
}}
|
| 397 |
-
if (tryFire()) return;
|
| 398 |
-
const obs = new MutationObserver(function() {{ if (tryFire()) obs.disconnect(); }});
|
| 399 |
-
obs.observe(host, {{ childList: true, subtree: true, attributes: true }});
|
| 400 |
-
setTimeout(function() {{ obs.disconnect(); }}, 15000);
|
| 401 |
-
}})();
|
| 402 |
-
</script>
|
| 403 |
-
"""
|
| 404 |
-
|
| 405 |
-
|
| 406 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 407 |
# UI β css in gr.Blocks() (correct for all Gradio versions)
|
| 408 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -461,14 +475,12 @@ with gr.Blocks(css=CSS, title="SplatWeb") as demo:
|
|
| 461 |
gr.HTML(VIEWER_SHELL)
|
| 462 |
|
| 463 |
# Hidden File component β Gradio serves this over its normal
|
| 464 |
-
# file route
|
| 465 |
-
#
|
|
|
|
|
|
|
| 466 |
ply_file = gr.File(visible=False, elem_id="sw-ply-file", label="ply")
|
| 467 |
|
| 468 |
-
# Hidden trigger β tiny script (no file data) that tells the
|
| 469 |
-
# viewer where to fetch the model from once ply_file updates
|
| 470 |
-
trigger1 = gr.HTML(value="", visible=False)
|
| 471 |
-
|
| 472 |
# ββ Tab 2: Two angles + animation βββββββββββββββββββ
|
| 473 |
with gr.TabItem("π¬ Two Angles β Animation"):
|
| 474 |
|
|
@@ -527,17 +539,19 @@ with gr.Blocks(css=CSS, title="SplatWeb") as demo:
|
|
| 527 |
|
| 528 |
# ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 529 |
def handle_single(img):
|
|
|
|
| 530 |
if img is None:
|
| 531 |
-
return "β Please upload a photo first.", gr.update(value=None, visible=False)
|
| 532 |
try:
|
| 533 |
ply, status = run_sharp(img)
|
| 534 |
except Exception as e:
|
|
|
|
| 535 |
return (f"β GPU rejected the request: {str(e)}\n(If this mentions quota/duration, you've hit your daily free ZeroGPU limit β wait for it to reset, or sign in with a HF account for a bigger quota.)",
|
| 536 |
-
gr.update(value=None, visible=False)
|
|
|
|
| 537 |
if ply:
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
return status, gr.update(value=None, visible=False), gr.update(value="", visible=False)
|
| 541 |
|
| 542 |
def handle_dual(a, b, do_anim):
|
| 543 |
if a is None or b is None:
|
|
@@ -576,9 +590,34 @@ with gr.Blocks(css=CSS, title="SplatWeb") as demo:
|
|
| 576 |
|
| 577 |
return msg, html_out
|
| 578 |
|
| 579 |
-
btn1.click(fn=handle_single, inputs=[img1], outputs=[st1, ply_file
|
| 580 |
btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file])
|
| 581 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
gr.HTML("""
|
| 583 |
<div style="text-align:center;padding:1.5rem 1rem;
|
| 584 |
border-top:1px solid rgba(80,130,255,0.07);margin-top:1.5rem;">
|
|
|
|
| 4 |
import os
|
| 5 |
import base64
|
| 6 |
import tempfile
|
| 7 |
+
import time
|
| 8 |
from PIL import Image
|
| 9 |
|
| 10 |
# ββ ZeroGPU optional ββ
|
|
|
|
| 59 |
|
| 60 |
@spaces.GPU(duration=GPU_DURATION)
|
| 61 |
def run_sharp(image_path):
|
| 62 |
+
t0 = time.time()
|
| 63 |
+
print(f"[run_sharp] START image_path={image_path}", flush=True)
|
| 64 |
if image_path is None:
|
| 65 |
return None, "β Please upload a photo first."
|
| 66 |
out_dir = tempfile.mkdtemp(prefix="splat_")
|
| 67 |
resized = None
|
| 68 |
try:
|
| 69 |
resized = resize_image(image_path, 512)
|
| 70 |
+
print(f"[run_sharp] resized -> {resized} (+{time.time()-t0:.1f}s)", flush=True)
|
| 71 |
result = subprocess.run(
|
| 72 |
["sharp", "predict", "-i", resized, "-o", out_dir],
|
| 73 |
capture_output=True, text=True, timeout=GPU_DURATION - 15
|
| 74 |
)
|
| 75 |
+
print(f"[run_sharp] sharp exited code={result.returncode} (+{time.time()-t0:.1f}s)", flush=True)
|
| 76 |
ply_files = [f for f in os.listdir(out_dir) if f.endswith(".ply")]
|
| 77 |
if not ply_files:
|
| 78 |
err = (result.stderr or result.stdout or "No .ply produced.")[-800:]
|
| 79 |
+
print(f"[run_sharp] FAILED, no .ply produced:\n{err}", flush=True)
|
| 80 |
return None, f"SHARP failed:\n{err}"
|
| 81 |
+
ply_path = os.path.join(out_dir, ply_files[0])
|
| 82 |
+
size_mb = os.path.getsize(ply_path) / 1024 / 1024
|
| 83 |
+
print(f"[run_sharp] DONE ply={ply_path} size={size_mb:.2f}MB total={time.time()-t0:.1f}s", flush=True)
|
| 84 |
+
return ply_path, "β Done β 3D scene loading below β"
|
| 85 |
except subprocess.TimeoutExpired:
|
| 86 |
+
print(f"[run_sharp] TIMEOUT after {time.time()-t0:.1f}s", flush=True)
|
| 87 |
return None, "β Timed out inside the GPU window. Try a smaller/simpler photo, or increase GPU_DURATION near the top of app.py if you have more quota (PRO/Team/Enterprise)."
|
| 88 |
except FileNotFoundError:
|
| 89 |
+
print("[run_sharp] SHARP binary not found on PATH", flush=True)
|
| 90 |
return None, "β SHARP not found yet β wait 1 minute and try again."
|
| 91 |
except Exception as e:
|
| 92 |
+
print(f"[run_sharp] EXCEPTION: {e}", flush=True)
|
| 93 |
return None, f"β Error: {str(e)}"
|
| 94 |
finally:
|
| 95 |
if resized:
|
|
|
|
| 298 |
</div>
|
| 299 |
</div>
|
| 300 |
|
| 301 |
+
<details style="margin-top:0.6rem;border:1px solid rgba(77,138,255,0.12);border-radius:10px;
|
| 302 |
+
background:rgba(5,8,16,0.6);">
|
| 303 |
+
<summary style="cursor:pointer;padding:0.5rem 0.8rem;font-family:monospace;font-size:0.6rem;
|
| 304 |
+
color:rgba(180,200,255,0.45);letter-spacing:0.08em;">
|
| 305 |
+
// debug log (tap to expand β shows what the browser is doing)
|
| 306 |
+
</summary>
|
| 307 |
+
<pre id="sw-debug" style="font-family:monospace;font-size:0.58rem;line-height:1.5;
|
| 308 |
+
color:rgba(180,200,255,0.55);padding:0 0.8rem 0.7rem;margin:0;
|
| 309 |
+
max-height:180px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;"></pre>
|
| 310 |
+
</details>
|
| 311 |
+
|
| 312 |
<style>@keyframes sw_spin { to { transform: rotate(360deg); } }</style>
|
| 313 |
|
| 314 |
<script type="importmap">
|
|
|
|
| 323 |
<script type="module">
|
| 324 |
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
|
| 325 |
|
| 326 |
+
// Expose loader + logger globally so Gradio's js= callback can call them.
|
|
|
|
|
|
|
|
|
|
| 327 |
window._swViewer = null;
|
| 328 |
window._swBlobUrl = null;
|
| 329 |
|
| 330 |
+
window.swLog = function(msg) {
|
| 331 |
+
const t = new Date().toTimeString().slice(0, 8);
|
| 332 |
+
console.log('[SplatWeb]', msg);
|
| 333 |
+
const dbg = document.getElementById('sw-debug');
|
| 334 |
+
if (dbg) {
|
| 335 |
+
dbg.textContent += `[${t}] ${msg}\n`;
|
| 336 |
+
dbg.scrollTop = dbg.scrollHeight;
|
| 337 |
+
}
|
| 338 |
+
};
|
| 339 |
+
window.swLog('viewer module loaded, waiting for a buildβ¦');
|
| 340 |
+
|
| 341 |
window.swLoadUrl = async function(fileUrl, sizeMb) {
|
| 342 |
const idle = document.getElementById('sw-idle');
|
| 343 |
const loading = document.getElementById('sw-loading');
|
|
|
|
| 347 |
const canvas = document.getElementById('sw-canvas');
|
| 348 |
const msg = document.getElementById('sw-load-msg');
|
| 349 |
|
| 350 |
+
swLog('trigger fired β ' + fileUrl);
|
| 351 |
+
|
| 352 |
idle.style.display = 'none';
|
| 353 |
loading.style.display = 'flex';
|
| 354 |
controls.style.display = 'none';
|
|
|
|
| 356 |
|
| 357 |
try {
|
| 358 |
msg.textContent = 'DOWNLOADING SCENEβ¦';
|
| 359 |
+
swLog('fetching fileβ¦');
|
| 360 |
const resp = await fetch(fileUrl);
|
| 361 |
+
swLog('fetch responded: HTTP ' + resp.status);
|
| 362 |
+
if (!resp.ok) throw new Error('server returned HTTP ' + resp.status + ' for the file URL');
|
| 363 |
+
|
| 364 |
const buf = await resp.arrayBuffer();
|
| 365 |
+
swLog('downloaded ' + (buf.byteLength / 1024 / 1024).toFixed(2) + ' MB');
|
| 366 |
const blobUrl = URL.createObjectURL(new Blob([buf], { type: 'application/octet-stream' }));
|
| 367 |
|
| 368 |
if (window._swBlobUrl) { try { URL.revokeObjectURL(window._swBlobUrl); } catch(e) {} }
|
|
|
|
| 373 |
window._swViewer = null;
|
| 374 |
}
|
| 375 |
|
| 376 |
+
swLog('initializing WebGL viewerβ¦');
|
| 377 |
const viewer = new GaussianSplats3D.Viewer({
|
| 378 |
canvas,
|
| 379 |
cameraUp: [0, -1, 0],
|
|
|
|
| 384 |
window._swViewer = viewer;
|
| 385 |
|
| 386 |
msg.textContent = 'RENDERING GAUSSIANSβ¦';
|
| 387 |
+
swLog('parsing splat data + uploading to GPUβ¦');
|
| 388 |
await viewer.addSplatScene(blobUrl, { progressiveLoad: true });
|
| 389 |
+
swLog('scene loaded β');
|
| 390 |
|
| 391 |
loading.style.display = 'none';
|
| 392 |
controls.style.display = 'flex';
|
|
|
|
| 394 |
viewer.start();
|
| 395 |
|
| 396 |
} catch(err) {
|
| 397 |
+
const emsg = (err && err.message) ? err.message : String(err);
|
| 398 |
+
swLog('ERROR: ' + emsg);
|
| 399 |
+
msg.textContent = 'β ' + emsg;
|
| 400 |
console.error(err);
|
| 401 |
}
|
| 402 |
};
|
|
|
|
| 417 |
"""
|
| 418 |
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 421 |
# UI β css in gr.Blocks() (correct for all Gradio versions)
|
| 422 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 475 |
gr.HTML(VIEWER_SHELL)
|
| 476 |
|
| 477 |
# Hidden File component β Gradio serves this over its normal
|
| 478 |
+
# file route and gives the frontend a real URL for it (via the
|
| 479 |
+
# .change(js=...) wiring below), so the browser can fetch() the
|
| 480 |
+
# raw bytes directly instead of us shipping a giant base64
|
| 481 |
+
# string through the page.
|
| 482 |
ply_file = gr.File(visible=False, elem_id="sw-ply-file", label="ply")
|
| 483 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
# ββ Tab 2: Two angles + animation βββββββββββββββββββ
|
| 485 |
with gr.TabItem("π¬ Two Angles β Animation"):
|
| 486 |
|
|
|
|
| 539 |
|
| 540 |
# ββ Handlers ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 541 |
def handle_single(img):
|
| 542 |
+
print(f"[handle_single] click received, img={img}", flush=True)
|
| 543 |
if img is None:
|
| 544 |
+
return "β Please upload a photo first.", gr.update(value=None, visible=False)
|
| 545 |
try:
|
| 546 |
ply, status = run_sharp(img)
|
| 547 |
except Exception as e:
|
| 548 |
+
print(f"[handle_single] GPU call raised: {e}", flush=True)
|
| 549 |
return (f"β GPU rejected the request: {str(e)}\n(If this mentions quota/duration, you've hit your daily free ZeroGPU limit β wait for it to reset, or sign in with a HF account for a bigger quota.)",
|
| 550 |
+
gr.update(value=None, visible=False))
|
| 551 |
+
print(f"[handle_single] run_sharp returned ply={ply!r} status={status!r}", flush=True)
|
| 552 |
if ply:
|
| 553 |
+
return status, gr.update(value=ply, visible=False)
|
| 554 |
+
return status, gr.update(value=None, visible=False)
|
|
|
|
| 555 |
|
| 556 |
def handle_dual(a, b, do_anim):
|
| 557 |
if a is None or b is None:
|
|
|
|
| 590 |
|
| 591 |
return msg, html_out
|
| 592 |
|
| 593 |
+
btn1.click(fn=handle_single, inputs=[img1], outputs=[st1, ply_file])
|
| 594 |
btn2.click(fn=handle_dual, inputs=[imgA, imgB, anim_toggle], outputs=[st2, anim_file])
|
| 595 |
|
| 596 |
+
# This is the load-bearing wire: whenever ply_file's value changes
|
| 597 |
+
# (including programmatically, from handle_single's return), Gradio
|
| 598 |
+
# hands this JS function the component's real FileData β the same
|
| 599 |
+
# {path, url, size, ...} object it uses internally β no DOM-scraping,
|
| 600 |
+
# no guessing routes. fn=None means this runs client-side only, no
|
| 601 |
+
# extra server round-trip.
|
| 602 |
+
ply_file.change(
|
| 603 |
+
fn=None, inputs=[ply_file], outputs=[],
|
| 604 |
+
js="""
|
| 605 |
+
(file) => {
|
| 606 |
+
const log = (m) => { if (window.swLog) window.swLog(m); else console.log('[SplatWeb]', m); };
|
| 607 |
+
log('ply_file component changed: ' + JSON.stringify(file));
|
| 608 |
+
if (file && file.url) {
|
| 609 |
+
const mb = file.size ? (file.size / 1024 / 1024).toFixed(2) : '';
|
| 610 |
+
if (window.swLoadUrl) { window.swLoadUrl(file.url, mb); }
|
| 611 |
+
else { log('ERROR: viewer script not ready yet β reload the page and try again'); }
|
| 612 |
+
} else if (file) {
|
| 613 |
+
log('ERROR: file has no .url field (got: ' + Object.keys(file).join(',') + ') β Gradio version mismatch?');
|
| 614 |
+
} else {
|
| 615 |
+
log('cleared / no file');
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
"""
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
gr.HTML("""
|
| 622 |
<div style="text-align:center;padding:1.5rem 1rem;
|
| 623 |
border-top:1px solid rgba(80,130,255,0.07);margin-top:1.5rem;">
|