Spaces:
Running on Zero
Add live sampling progress bar to the web UI
Browse filesStream per-step sampling progress from the generate endpoint to the
custom frontend so users see a live progress bar during generation.
- app.py: report progress via a gr.Progress() tracker created inside
generate(). It is created in the function body rather than as a
parameter because @app .api() derives its input schema from the
signature and would otherwise treat `progress` as a required input.
The bar tracks the sampling loop only: pre-sampling stages report 0%
and post-sampling (decode/save) report 100%.
- index.html: switch generate() from client.predict() to client.submit()
and connect with events:["data","status"] so progress (status) events
actually reach the stream (the client default is ["data"] only); render
a progress overlay inside the viewer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +38 -3
- index.html +131 -2
|
@@ -13,6 +13,7 @@ import spaces
|
|
| 13 |
import torch
|
| 14 |
from PIL import Image
|
| 15 |
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|
|
|
| 16 |
from gradio import Server
|
| 17 |
from gradio.data_classes import FileData
|
| 18 |
|
|
@@ -119,27 +120,51 @@ async def get_example(idx: int):
|
|
| 119 |
|
| 120 |
@spaces.GPU
|
| 121 |
def _run_pipeline(pil_image, seed, steps, guidance_scale, num_gaussians,
|
| 122 |
-
out_dir, output_format):
|
| 123 |
"""Run the full pipeline (preprocess → encode → sample → decode → save)
|
| 124 |
in a single GPU acquisition.
|
| 125 |
|
| 126 |
All file I/O happens here so the unpicklable Gaussian object never
|
| 127 |
crosses the ZeroGPU multiprocessing boundary.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
t0 = time.time()
|
|
|
|
|
|
|
| 130 |
prepared = PIPE.preprocess_image(pil_image)
|
|
|
|
|
|
|
| 131 |
gen = torch.Generator(device=PIPE._device).manual_seed(int(seed))
|
| 132 |
cond = PIPE.encode_image(prepared, generator=gen)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
out = PIPE.sample_latent(
|
| 134 |
cond,
|
| 135 |
-
steps=
|
| 136 |
guidance_scale=float(guidance_scale),
|
| 137 |
generator=gen,
|
| 138 |
show_progress=True,
|
|
|
|
| 139 |
)
|
|
|
|
|
|
|
| 140 |
gaussian = PIPE.decode_latent(out["latent"], num_gaussians=int(num_gaussians))
|
| 141 |
gen_dt = time.time() - t0
|
| 142 |
|
|
|
|
|
|
|
| 143 |
# Save preprocessed image
|
| 144 |
prep_path = out_dir / "preprocessed.png"
|
| 145 |
prepared.save(str(prep_path))
|
|
@@ -158,6 +183,8 @@ def _run_pipeline(pil_image, seed, steps, guidance_scale, num_gaussians,
|
|
| 158 |
|
| 159 |
n_gaussians = gaussian.get_xyz.shape[0]
|
| 160 |
|
|
|
|
|
|
|
| 161 |
# Return only picklable primitives / paths
|
| 162 |
return str(prep_path), str(ply_path), str(download_path), n_gaussians, gen_dt
|
| 163 |
|
|
@@ -179,15 +206,23 @@ def generate(
|
|
| 179 |
|
| 180 |
Returns (preprocessed_image, ply_file, download_file, info_string).
|
| 181 |
The frontend receives these as result.data[0..3].
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
"""
|
| 183 |
pil_image = Image.open(image["path"]).convert("RGBA")
|
| 184 |
|
|
|
|
|
|
|
| 185 |
out_dir = OUT_ROOT / uuid4().hex[:12]
|
| 186 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 187 |
|
| 188 |
prep_path, ply_path, download_path, n_gaussians, gen_dt = _run_pipeline(
|
| 189 |
pil_image, seed, steps, guidance_scale, num_gaussians,
|
| 190 |
-
out_dir, output_format,
|
| 191 |
)
|
| 192 |
|
| 193 |
info = (
|
|
|
|
| 13 |
import torch
|
| 14 |
from PIL import Image
|
| 15 |
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
| 16 |
+
import gradio as gr
|
| 17 |
from gradio import Server
|
| 18 |
from gradio.data_classes import FileData
|
| 19 |
|
|
|
|
| 120 |
|
| 121 |
@spaces.GPU
|
| 122 |
def _run_pipeline(pil_image, seed, steps, guidance_scale, num_gaussians,
|
| 123 |
+
out_dir, output_format, progress=None):
|
| 124 |
"""Run the full pipeline (preprocess → encode → sample → decode → save)
|
| 125 |
in a single GPU acquisition.
|
| 126 |
|
| 127 |
All file I/O happens here so the unpicklable Gaussian object never
|
| 128 |
crosses the ZeroGPU multiprocessing boundary.
|
| 129 |
+
|
| 130 |
+
``progress`` is an optional ``gradio.Progress`` tracker. When supplied,
|
| 131 |
+
the bar tracks the sampling loop only: stages before sampling (preprocess,
|
| 132 |
+
encode) report 0% and stages after it (decode, save) report 100%, so the
|
| 133 |
+
bar fills 0% → 100% across the sampling steps via a per-step callback.
|
| 134 |
"""
|
| 135 |
+
def _report(frac, desc):
|
| 136 |
+
if progress is not None:
|
| 137 |
+
progress(frac, desc=desc)
|
| 138 |
+
|
| 139 |
t0 = time.time()
|
| 140 |
+
|
| 141 |
+
_report(0.0, "Preprocessing image")
|
| 142 |
prepared = PIPE.preprocess_image(pil_image)
|
| 143 |
+
|
| 144 |
+
_report(0.0, "Encoding image")
|
| 145 |
gen = torch.Generator(device=PIPE._device).manual_seed(int(seed))
|
| 146 |
cond = PIPE.encode_image(prepared, generator=gen)
|
| 147 |
+
|
| 148 |
+
total_steps = int(steps)
|
| 149 |
+
|
| 150 |
+
def _on_step(step, total):
|
| 151 |
+
_report(step / total, f"Sampling · step {step}/{total}")
|
| 152 |
+
|
| 153 |
out = PIPE.sample_latent(
|
| 154 |
cond,
|
| 155 |
+
steps=total_steps,
|
| 156 |
guidance_scale=float(guidance_scale),
|
| 157 |
generator=gen,
|
| 158 |
show_progress=True,
|
| 159 |
+
callback=_on_step,
|
| 160 |
)
|
| 161 |
+
|
| 162 |
+
_report(1.0, "Decoding gaussians")
|
| 163 |
gaussian = PIPE.decode_latent(out["latent"], num_gaussians=int(num_gaussians))
|
| 164 |
gen_dt = time.time() - t0
|
| 165 |
|
| 166 |
+
_report(1.0, "Saving output")
|
| 167 |
+
|
| 168 |
# Save preprocessed image
|
| 169 |
prep_path = out_dir / "preprocessed.png"
|
| 170 |
prepared.save(str(prep_path))
|
|
|
|
| 183 |
|
| 184 |
n_gaussians = gaussian.get_xyz.shape[0]
|
| 185 |
|
| 186 |
+
_report(1.0, "Done")
|
| 187 |
+
|
| 188 |
# Return only picklable primitives / paths
|
| 189 |
return str(prep_path), str(ply_path), str(download_path), n_gaussians, gen_dt
|
| 190 |
|
|
|
|
| 206 |
|
| 207 |
Returns (preprocessed_image, ply_file, download_file, info_string).
|
| 208 |
The frontend receives these as result.data[0..3].
|
| 209 |
+
|
| 210 |
+
Sampling progress is streamed to the client over Gradio's SSE queue via a
|
| 211 |
+
``gr.Progress`` tracker. The tracker is created inside the function body
|
| 212 |
+
(rather than declared as a parameter) because ``@app.api()`` derives the
|
| 213 |
+
endpoint's input schema from the signature and would otherwise treat a
|
| 214 |
+
``progress`` parameter as a required API input.
|
| 215 |
"""
|
| 216 |
pil_image = Image.open(image["path"]).convert("RGBA")
|
| 217 |
|
| 218 |
+
progress = gr.Progress()
|
| 219 |
+
|
| 220 |
out_dir = OUT_ROOT / uuid4().hex[:12]
|
| 221 |
out_dir.mkdir(parents=True, exist_ok=True)
|
| 222 |
|
| 223 |
prep_path, ply_path, download_path, n_gaussians, gen_dt = _run_pipeline(
|
| 224 |
pil_image, seed, steps, guidance_scale, num_gaussians,
|
| 225 |
+
out_dir, output_format, progress=progress,
|
| 226 |
)
|
| 227 |
|
| 228 |
info = (
|
|
@@ -588,6 +588,68 @@ input[type="range"]::-moz-range-thumb {
|
|
| 588 |
letter-spacing: 0.3px;
|
| 589 |
}
|
| 590 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
/* ======================================================================
|
| 592 |
OUTPUT PANEL
|
| 593 |
====================================================================== */
|
|
@@ -899,6 +961,13 @@ input[type="range"]::-moz-range-thumb {
|
|
| 899 |
<p class="hint">Upload an image and click Generate</p>
|
| 900 |
</div>
|
| 901 |
<iframe id="viewer-iframe" class="hidden" allowfullscreen></iframe>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 902 |
</div>
|
| 903 |
<div class="viewer-bar">
|
| 904 |
<span class="viewer-hint">drag to orbit · scroll to zoom · right-drag to pan</span>
|
|
@@ -987,7 +1056,9 @@ async function init() {
|
|
| 987 |
updateStatus("connecting");
|
| 988 |
|
| 989 |
try {
|
| 990 |
-
|
|
|
|
|
|
|
| 991 |
updateStatus("connected");
|
| 992 |
} catch (e) {
|
| 993 |
updateStatus("error", e.message);
|
|
@@ -1140,9 +1211,12 @@ async function generate() {
|
|
| 1140 |
$("generate-label").textContent = "Generating…";
|
| 1141 |
$("generate-icon").classList.add("hidden");
|
| 1142 |
$("generate-spinner").classList.remove("hidden");
|
|
|
|
| 1143 |
|
| 1144 |
try {
|
| 1145 |
-
|
|
|
|
|
|
|
| 1146 |
image: handle_file(currentFile),
|
| 1147 |
seed: parseInt($("seed").value) || 42,
|
| 1148 |
steps: parseInt($("steps").value) || 20,
|
|
@@ -1151,6 +1225,30 @@ async function generate() {
|
|
| 1151 |
output_format: $("format").value || "ply",
|
| 1152 |
});
|
| 1153 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
showResults(result);
|
| 1155 |
} catch (error) {
|
| 1156 |
console.error("Generation failed:", error);
|
|
@@ -1162,9 +1260,40 @@ async function generate() {
|
|
| 1162 |
$("generate-label").textContent = "Generate";
|
| 1163 |
$("generate-icon").classList.remove("hidden");
|
| 1164 |
$("generate-spinner").classList.add("hidden");
|
|
|
|
| 1165 |
}
|
| 1166 |
}
|
| 1167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
// --------------------------------------------------------------------------
|
| 1169 |
// Display results
|
| 1170 |
// --------------------------------------------------------------------------
|
|
|
|
| 588 |
letter-spacing: 0.3px;
|
| 589 |
}
|
| 590 |
|
| 591 |
+
/* ======================================================================
|
| 592 |
+
GENERATION PROGRESS OVERLAY (inside the viewer)
|
| 593 |
+
====================================================================== */
|
| 594 |
+
.gen-progress {
|
| 595 |
+
position: absolute;
|
| 596 |
+
inset: 0;
|
| 597 |
+
z-index: 6;
|
| 598 |
+
display: flex;
|
| 599 |
+
flex-direction: column;
|
| 600 |
+
align-items: center;
|
| 601 |
+
justify-content: center;
|
| 602 |
+
gap: 16px;
|
| 603 |
+
padding: 0 48px;
|
| 604 |
+
background: rgba(8, 9, 13, 0.62);
|
| 605 |
+
backdrop-filter: blur(6px);
|
| 606 |
+
-webkit-backdrop-filter: blur(6px);
|
| 607 |
+
}
|
| 608 |
+
.gen-progress-label {
|
| 609 |
+
font-size: 13px;
|
| 610 |
+
font-weight: 500;
|
| 611 |
+
color: var(--text-secondary);
|
| 612 |
+
letter-spacing: 0.2px;
|
| 613 |
+
text-align: center;
|
| 614 |
+
font-variant-numeric: tabular-nums;
|
| 615 |
+
}
|
| 616 |
+
.gen-progress-track {
|
| 617 |
+
width: 100%;
|
| 618 |
+
max-width: 340px;
|
| 619 |
+
height: 6px;
|
| 620 |
+
background: rgba(255, 255, 255, 0.08);
|
| 621 |
+
border-radius: 3px;
|
| 622 |
+
overflow: hidden;
|
| 623 |
+
position: relative;
|
| 624 |
+
}
|
| 625 |
+
.gen-progress-fill {
|
| 626 |
+
height: 100%;
|
| 627 |
+
width: 0%;
|
| 628 |
+
border-radius: 3px;
|
| 629 |
+
background: linear-gradient(90deg, #ff9c6e, #ff4d4f);
|
| 630 |
+
box-shadow: 0 0 12px var(--accent-glow);
|
| 631 |
+
transition: width 0.3s ease;
|
| 632 |
+
}
|
| 633 |
+
/* Indeterminate state: a sliding sliver while we await the first update. */
|
| 634 |
+
.gen-progress-fill.indeterminate {
|
| 635 |
+
width: 35% !important;
|
| 636 |
+
position: absolute;
|
| 637 |
+
left: 0;
|
| 638 |
+
animation: indeterminateSlide 1.1s ease-in-out infinite;
|
| 639 |
+
transition: none;
|
| 640 |
+
}
|
| 641 |
+
@keyframes indeterminateSlide {
|
| 642 |
+
0% { transform: translateX(-110%); }
|
| 643 |
+
100% { transform: translateX(310%); }
|
| 644 |
+
}
|
| 645 |
+
.gen-progress-pct {
|
| 646 |
+
font-size: 12px;
|
| 647 |
+
font-weight: 600;
|
| 648 |
+
color: var(--accent);
|
| 649 |
+
font-variant-numeric: tabular-nums;
|
| 650 |
+
min-height: 14px;
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
/* ======================================================================
|
| 654 |
OUTPUT PANEL
|
| 655 |
====================================================================== */
|
|
|
|
| 961 |
<p class="hint">Upload an image and click Generate</p>
|
| 962 |
</div>
|
| 963 |
<iframe id="viewer-iframe" class="hidden" allowfullscreen></iframe>
|
| 964 |
+
<div class="gen-progress hidden" id="gen-progress">
|
| 965 |
+
<div class="gen-progress-label" id="gen-progress-label">Starting…</div>
|
| 966 |
+
<div class="gen-progress-track">
|
| 967 |
+
<div class="gen-progress-fill" id="gen-progress-fill"></div>
|
| 968 |
+
</div>
|
| 969 |
+
<div class="gen-progress-pct" id="gen-progress-pct"></div>
|
| 970 |
+
</div>
|
| 971 |
</div>
|
| 972 |
<div class="viewer-bar">
|
| 973 |
<span class="viewer-hint">drag to orbit · scroll to zoom · right-drag to pan</span>
|
|
|
|
| 1056 |
updateStatus("connecting");
|
| 1057 |
|
| 1058 |
try {
|
| 1059 |
+
// Subscribe to "status" events too (default is ["data"] only) so the
|
| 1060 |
+
// submit() iterator delivers gr.Progress() updates, not just the result.
|
| 1061 |
+
client = await Client.connect(window.location.origin, { events: ["data", "status"] });
|
| 1062 |
updateStatus("connected");
|
| 1063 |
} catch (e) {
|
| 1064 |
updateStatus("error", e.message);
|
|
|
|
| 1211 |
$("generate-label").textContent = "Generating…";
|
| 1212 |
$("generate-icon").classList.add("hidden");
|
| 1213 |
$("generate-spinner").classList.remove("hidden");
|
| 1214 |
+
showProgress();
|
| 1215 |
|
| 1216 |
try {
|
| 1217 |
+
// Use submit() (not predict()) so we can stream the server-side
|
| 1218 |
+
// gr.Progress() updates that arrive as "status" events.
|
| 1219 |
+
const submission = client.submit("/generate", {
|
| 1220 |
image: handle_file(currentFile),
|
| 1221 |
seed: parseInt($("seed").value) || 42,
|
| 1222 |
steps: parseInt($("steps").value) || 20,
|
|
|
|
| 1225 |
output_format: $("format").value || "ply",
|
| 1226 |
});
|
| 1227 |
|
| 1228 |
+
let result = null;
|
| 1229 |
+
for await (const msg of submission) {
|
| 1230 |
+
if (msg.type === "status") {
|
| 1231 |
+
if (msg.stage === "error") {
|
| 1232 |
+
throw new Error(msg.message || "Generation failed.");
|
| 1233 |
+
}
|
| 1234 |
+
const pd = msg.progress_data || (msg.status && msg.status.progress_data);
|
| 1235 |
+
if (pd && pd.length) {
|
| 1236 |
+
const p = pd[pd.length - 1];
|
| 1237 |
+
let frac = null;
|
| 1238 |
+
if (typeof p.progress === "number") {
|
| 1239 |
+
frac = p.progress;
|
| 1240 |
+
} else if (typeof p.index === "number" && p.length) {
|
| 1241 |
+
frac = p.index / p.length;
|
| 1242 |
+
}
|
| 1243 |
+
updateProgress(frac, p.desc);
|
| 1244 |
+
}
|
| 1245 |
+
} else if (msg.type === "data") {
|
| 1246 |
+
result = msg;
|
| 1247 |
+
}
|
| 1248 |
+
}
|
| 1249 |
+
|
| 1250 |
+
if (!result) throw new Error("No result returned from server.");
|
| 1251 |
+
updateProgress(1, "Done");
|
| 1252 |
showResults(result);
|
| 1253 |
} catch (error) {
|
| 1254 |
console.error("Generation failed:", error);
|
|
|
|
| 1260 |
$("generate-label").textContent = "Generate";
|
| 1261 |
$("generate-icon").classList.remove("hidden");
|
| 1262 |
$("generate-spinner").classList.add("hidden");
|
| 1263 |
+
hideProgress();
|
| 1264 |
}
|
| 1265 |
}
|
| 1266 |
|
| 1267 |
+
// --------------------------------------------------------------------------
|
| 1268 |
+
// Sampling progress overlay
|
| 1269 |
+
// --------------------------------------------------------------------------
|
| 1270 |
+
function showProgress() {
|
| 1271 |
+
$("gen-progress").classList.remove("hidden");
|
| 1272 |
+
updateProgress(null, "Starting…");
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
function updateProgress(frac, desc) {
|
| 1276 |
+
const fill = $("gen-progress-fill");
|
| 1277 |
+
const pct = $("gen-progress-pct");
|
| 1278 |
+
if (desc) $("gen-progress-label").textContent = desc;
|
| 1279 |
+
|
| 1280 |
+
if (frac == null || isNaN(frac)) {
|
| 1281 |
+
// Unknown progress → indeterminate animation.
|
| 1282 |
+
fill.classList.add("indeterminate");
|
| 1283 |
+
fill.style.width = "";
|
| 1284 |
+
pct.textContent = "";
|
| 1285 |
+
} else {
|
| 1286 |
+
fill.classList.remove("indeterminate");
|
| 1287 |
+
const clamped = Math.max(0, Math.min(1, frac));
|
| 1288 |
+
fill.style.width = (clamped * 100).toFixed(0) + "%";
|
| 1289 |
+
pct.textContent = (clamped * 100).toFixed(0) + "%";
|
| 1290 |
+
}
|
| 1291 |
+
}
|
| 1292 |
+
|
| 1293 |
+
function hideProgress() {
|
| 1294 |
+
$("gen-progress").classList.add("hidden");
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
// --------------------------------------------------------------------------
|
| 1298 |
// Display results
|
| 1299 |
// --------------------------------------------------------------------------
|