bennyguo Claude Opus 4.8 commited on
Commit
7265209
·
1 Parent(s): a6e508b

Add live sampling progress bar to the web UI

Browse files

Stream 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>

Files changed (2) hide show
  1. app.py +38 -3
  2. index.html +131 -2
app.py CHANGED
@@ -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=int(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 = (
index.html CHANGED
@@ -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 &nbsp;·&nbsp; scroll to zoom &nbsp;·&nbsp; right-drag to pan</span>
@@ -987,7 +1056,9 @@ async function init() {
987
  updateStatus("connecting");
988
 
989
  try {
990
- client = await Client.connect(window.location.origin);
 
 
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
- const result = await client.predict("/generate", {
 
 
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 &nbsp;·&nbsp; scroll to zoom &nbsp;·&nbsp; 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
  // --------------------------------------------------------------------------