sysu111 commited on
Commit
0640f41
·
1 Parent(s): 87608ea

Add PXDepth demo

Browse files
Files changed (2) hide show
  1. app.py +184 -24
  2. requirements.txt +0 -1
app.py CHANGED
@@ -39,10 +39,41 @@ OUTPUT_MAX_AGE = 60 * 60
39
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
40
 
41
  CSS = """
 
 
 
 
 
 
 
 
 
 
 
42
  #pxdepth-demo { max-width: 1280px; margin: 0 auto; }
43
  #img-display-input, #img-display-output { max-height: 72vh; }
44
  #img-display-output img { object-fit: contain !important; }
45
- #model-3d { min-height: 60vh; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  """
47
 
48
 
@@ -125,6 +156,103 @@ def sample_points(
125
  return points[indices], colors[indices]
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  @gpu
129
  @torch.inference_mode()
130
  def predict_gpu(image: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
@@ -163,8 +291,10 @@ def predict_gpu(image: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
163
 
164
  def on_submit(
165
  image: Optional[np.ndarray],
166
- max_points: int,
167
  apply_mask: bool,
 
 
 
168
  request: gr.Request,
169
  ):
170
  """Run inference, build visualizations, and export downloadable files."""
@@ -199,11 +329,11 @@ def on_submit(
199
  depth_vis = colorize_depth(np.where(mask_np, depth_np, np.inf), mask=None)
200
 
201
  output = session_dir(request)
202
- depth_npy = output / "metric_depth.npy"
203
  depth_png = output / "depth.png"
204
  mask_png = output / "mask.png"
205
  ply_path = output / "pointcloud.ply"
206
- glb_path = output / "pointcloud_viewer.glb"
207
 
208
  np.save(depth_npy, depth_np)
209
  Image.fromarray(depth_vis).save(depth_png)
@@ -216,20 +346,17 @@ def on_submit(
216
  if points_full.shape[0] == 0:
217
  raise gr.Error("No valid 3D points were produced for this image.")
218
  write_point_cloud_ply(ply_path, points_full, colors_full)
219
-
220
- import trimesh
221
-
222
- viewer_points, viewer_colors = sample_points(
223
- points_full,
224
- colors_full,
225
- int(max_points),
226
  )
227
- viewer_points = viewer_points * np.array([1.0, -1.0, -1.0], np.float32)
228
- viewer_colors = np.clip(viewer_colors * 255.0, 0, 255).astype(np.uint8)
229
- trimesh.PointCloud(viewer_points, colors=viewer_colors).export(glb_path)
230
 
231
  files = [str(depth_png), str(depth_npy), str(mask_png), str(ply_path)]
232
- return (image, depth_vis), str(glb_path), files
233
 
234
 
235
  def build_demo() -> gr.Blocks:
@@ -240,7 +367,8 @@ See the [paper](https://arxiv.org/abs/2608.16984),
240
  [project page](https://yuanzhy29.github.io/PXDepth-Page/), and
241
  [GitHub repository](https://github.com/yuanzhy29/PXDepth).
242
  """
243
- with gr.Blocks(theme=gr.themes.Soft(), css=CSS) as demo:
 
244
  with gr.Column(elem_id="pxdepth-demo"):
245
  gr.Markdown("# PXDepth")
246
  gr.Markdown(description)
@@ -255,17 +383,30 @@ See the [paper](https://arxiv.org/abs/2608.16984),
255
  elem_id="img-display-input",
256
  )
257
  with gr.Accordion(label="Settings", open=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  max_points = gr.Slider(
259
  50_000,
260
  500_000,
261
  value=200_000,
262
  step=50_000,
263
  label="3D Viewer Max Points",
264
- info="The downloaded PLY retains all valid points.",
265
- )
266
- apply_mask = gr.Checkbox(
267
- label="Apply valid-depth mask to point cloud",
268
- value=True,
269
  )
270
  submit = gr.Button("Predict", variant="primary")
271
 
@@ -275,7 +416,7 @@ See the [paper](https://arxiv.org/abs/2608.16984),
275
  model_3d = gr.Model3D(
276
  label="3D Point Map",
277
  clear_color=(1.0, 1.0, 1.0, 1.0),
278
- height="60vh",
279
  elem_id="model-3d",
280
  )
281
  with gr.Tab("Depth"):
@@ -308,11 +449,30 @@ See the [paper](https://arxiv.org/abs/2608.16984),
308
 
309
  submit.click(
310
  on_submit,
311
- [input_image, max_points, apply_mask],
312
- [depth_map, model_3d, downloads],
313
  show_progress="full",
314
  concurrency_limit=1,
315
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  return demo
317
 
318
 
 
39
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
40
 
41
  CSS = """
42
+ html, body {
43
+ height: auto !important;
44
+ min-height: 100% !important;
45
+ overflow-y: auto !important;
46
+ overscroll-behavior-y: auto !important;
47
+ }
48
+ .gradio-container {
49
+ height: auto !important;
50
+ min-height: 100vh !important;
51
+ overflow: visible !important;
52
+ }
53
  #pxdepth-demo { max-width: 1280px; margin: 0 auto; }
54
  #img-display-input, #img-display-output { max-height: 72vh; }
55
  #img-display-output img { object-fit: contain !important; }
56
+ #model-3d { min-height: 55vh; }
57
+ """
58
+
59
+ PAGE_JS = """
60
+ () => {
61
+ document.documentElement.style.overflowY = "auto";
62
+ document.body.style.overflowY = "auto";
63
+ const install = () => {
64
+ const viewer = document.querySelector("#model-3d");
65
+ if (!viewer || viewer.dataset.pageWheel === "true") return;
66
+ viewer.dataset.pageWheel = "true";
67
+ viewer.addEventListener("wheel", (event) => {
68
+ if (event.ctrlKey || event.metaKey) return;
69
+ event.preventDefault();
70
+ event.stopImmediatePropagation();
71
+ window.scrollBy({ top: event.deltaY, left: 0, behavior: "auto" });
72
+ }, { passive: false, capture: true });
73
+ };
74
+ install();
75
+ new MutationObserver(install).observe(document.body, { childList: true, subtree: true });
76
+ }
77
  """
78
 
79
 
 
156
  return points[indices], colors[indices]
157
 
158
 
159
+ def filter_flying_points(
160
+ points: np.ndarray,
161
+ colors: np.ndarray,
162
+ neighbors: int = 30,
163
+ std_ratio: float = 2.0,
164
+ ) -> tuple[np.ndarray, np.ndarray]:
165
+ """Remove sparse statistical outliers from an already sampled cloud."""
166
+ if points.shape[0] <= neighbors + 1:
167
+ return points, colors
168
+
169
+ from scipy.spatial import cKDTree
170
+
171
+ tree = cKDTree(points.astype(np.float64, copy=False))
172
+ mean_distance = np.empty(points.shape[0], dtype=np.float32)
173
+ for start in range(0, points.shape[0], 100_000):
174
+ stop = min(start + 100_000, points.shape[0])
175
+ try:
176
+ distances, _ = tree.query(
177
+ points[start:stop],
178
+ k=neighbors + 1,
179
+ workers=-1,
180
+ )
181
+ except TypeError:
182
+ distances, _ = tree.query(points[start:stop], k=neighbors + 1)
183
+ mean_distance[start:stop] = np.asarray(
184
+ distances[:, 1:],
185
+ dtype=np.float32,
186
+ ).mean(axis=1)
187
+ finite = np.isfinite(mean_distance)
188
+ if not finite.any():
189
+ return points, colors
190
+ values = mean_distance[finite]
191
+ threshold = float(values.mean() + std_ratio * values.std())
192
+ keep = finite & (mean_distance <= threshold)
193
+ return (points[keep], colors[keep]) if keep.any() else (points, colors)
194
+
195
+
196
+ def write_splat(
197
+ path: Path,
198
+ points: np.ndarray,
199
+ colors: np.ndarray,
200
+ point_size: float,
201
+ ) -> None:
202
+ """Write a compact Gaussian-splat viewer file with adjustable point size."""
203
+ display_points = points * np.array([1.0, -1.0, -1.0], dtype=np.float32)
204
+ low, high = np.percentile(display_points, [1.0, 99.0], axis=0)
205
+ extent = max(float(np.max(high - low)), 1e-6)
206
+ spacing = extent / max(float(np.sqrt(points.shape[0])), 1.0)
207
+ scale = max(spacing * float(point_size) * 0.65, 1e-7)
208
+
209
+ dtype = np.dtype(
210
+ [
211
+ ("position", "<f4", (3,)),
212
+ ("scale", "<f4", (3,)),
213
+ ("color", "u1", (4,)),
214
+ ("rotation", "u1", (4,)),
215
+ ],
216
+ align=False,
217
+ )
218
+ splats = np.empty(points.shape[0], dtype=dtype)
219
+ splats["position"] = display_points
220
+ splats["scale"] = scale
221
+ splats["color"][:, :3] = colors
222
+ splats["color"][:, 3] = 255
223
+ splats["rotation"] = np.array([255, 128, 128, 128], dtype=np.uint8)
224
+ splats.tofile(path)
225
+
226
+
227
+ def update_viewer(
228
+ cache_path: Optional[str],
229
+ filter_points: bool,
230
+ point_size: float,
231
+ max_points: int,
232
+ ) -> Optional[str]:
233
+ """Rebuild the viewer from cached points without running either model."""
234
+ if not cache_path or not Path(cache_path).is_file():
235
+ return None
236
+
237
+ with np.load(cache_path) as cache:
238
+ points = cache["points"]
239
+ colors = cache["colors"]
240
+ points, colors = sample_points(points, colors, int(max_points))
241
+ if filter_points:
242
+ points, colors = filter_flying_points(points, colors)
243
+ if points.shape[0] == 0:
244
+ raise gr.Error("No points remain after filtering.")
245
+
246
+ cache_file = Path(cache_path)
247
+ tag = f"{int(max_points)}_{int(filter_points)}_{float(point_size):.2f}"
248
+ viewer_path = cache_file.with_name(f"pointcloud_viewer_{tag}.splat")
249
+ write_splat(viewer_path, points, colors, point_size)
250
+ for old_path in cache_file.parent.glob("pointcloud_viewer_*.splat"):
251
+ if old_path != viewer_path:
252
+ old_path.unlink(missing_ok=True)
253
+ return str(viewer_path)
254
+
255
+
256
  @gpu
257
  @torch.inference_mode()
258
  def predict_gpu(image: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
 
291
 
292
  def on_submit(
293
  image: Optional[np.ndarray],
 
294
  apply_mask: bool,
295
+ filter_points: bool,
296
+ point_size: float,
297
+ max_points: int,
298
  request: gr.Request,
299
  ):
300
  """Run inference, build visualizations, and export downloadable files."""
 
329
  depth_vis = colorize_depth(np.where(mask_np, depth_np, np.inf), mask=None)
330
 
331
  output = session_dir(request)
332
+ depth_npy = output / "depth.npy"
333
  depth_png = output / "depth.png"
334
  mask_png = output / "mask.png"
335
  ply_path = output / "pointcloud.ply"
336
+ cache_path = output / "viewer_data.npz"
337
 
338
  np.save(depth_npy, depth_np)
339
  Image.fromarray(depth_vis).save(depth_png)
 
346
  if points_full.shape[0] == 0:
347
  raise gr.Error("No valid 3D points were produced for this image.")
348
  write_point_cloud_ply(ply_path, points_full, colors_full)
349
+ colors_uint8 = np.clip(colors_full * 255.0, 0, 255).astype(np.uint8)
350
+ np.savez(cache_path, points=points_full.astype(np.float32), colors=colors_uint8)
351
+ viewer_path = update_viewer(
352
+ str(cache_path),
353
+ filter_points,
354
+ point_size,
355
+ max_points,
356
  )
 
 
 
357
 
358
  files = [str(depth_png), str(depth_npy), str(mask_png), str(ply_path)]
359
+ return (image, depth_vis), viewer_path, files, str(cache_path)
360
 
361
 
362
  def build_demo() -> gr.Blocks:
 
367
  [project page](https://yuanzhy29.github.io/PXDepth-Page/), and
368
  [GitHub repository](https://github.com/yuanzhy29/PXDepth).
369
  """
370
+ with gr.Blocks(theme=gr.themes.Soft(), css=CSS, js=PAGE_JS) as demo:
371
+ viewer_cache = gr.State(value=None)
372
  with gr.Column(elem_id="pxdepth-demo"):
373
  gr.Markdown("# PXDepth")
374
  gr.Markdown(description)
 
383
  elem_id="img-display-input",
384
  )
385
  with gr.Accordion(label="Settings", open=False):
386
+ apply_mask = gr.Checkbox(
387
+ label="Apply valid-depth mask to point cloud",
388
+ value=True,
389
+ )
390
+ filter_points = gr.Checkbox(
391
+ label="Filter Flying Points",
392
+ info="Statistical outlier filtering; does not rerun the model.",
393
+ value=False,
394
+ )
395
+ point_size = gr.Slider(
396
+ 0.25,
397
+ 3.0,
398
+ value=1.0,
399
+ step=0.05,
400
+ label="Point Size",
401
+ info="Updates only the 3D viewer.",
402
+ )
403
  max_points = gr.Slider(
404
  50_000,
405
  500_000,
406
  value=200_000,
407
  step=50_000,
408
  label="3D Viewer Max Points",
409
+ info="Updates only the viewer; the downloaded PLY retains all valid points.",
 
 
 
 
410
  )
411
  submit = gr.Button("Predict", variant="primary")
412
 
 
416
  model_3d = gr.Model3D(
417
  label="3D Point Map",
418
  clear_color=(1.0, 1.0, 1.0, 1.0),
419
+ height="55vh",
420
  elem_id="model-3d",
421
  )
422
  with gr.Tab("Depth"):
 
449
 
450
  submit.click(
451
  on_submit,
452
+ [input_image, apply_mask, filter_points, point_size, max_points],
453
+ [depth_map, model_3d, downloads, viewer_cache],
454
  show_progress="full",
455
  concurrency_limit=1,
456
  )
457
+ viewer_inputs = [viewer_cache, filter_points, point_size, max_points]
458
+ filter_points.change(
459
+ update_viewer,
460
+ viewer_inputs,
461
+ model_3d,
462
+ show_progress="minimal",
463
+ )
464
+ point_size.release(
465
+ update_viewer,
466
+ viewer_inputs,
467
+ model_3d,
468
+ show_progress="minimal",
469
+ )
470
+ max_points.release(
471
+ update_viewer,
472
+ viewer_inputs,
473
+ model_3d,
474
+ show_progress="minimal",
475
+ )
476
  return demo
477
 
478
 
requirements.txt CHANGED
@@ -8,6 +8,5 @@ huggingface_hub>=0.28
8
  matplotlib>=3.8
9
  pillow>=10.0
10
  scipy>=1.12
11
- trimesh>=4.5
12
  git+https://github.com/EasternJournalist/utils3d.git@3fab839f0be9931dac7c8488eb0e1600c236e183
13
  git+https://github.com/microsoft/MoGe.git@42acd8f46e974f5e0548ecd72315d1d7df2cb6f4
 
8
  matplotlib>=3.8
9
  pillow>=10.0
10
  scipy>=1.12
 
11
  git+https://github.com/EasternJournalist/utils3d.git@3fab839f0be9931dac7c8488eb0e1600c236e183
12
  git+https://github.com/microsoft/MoGe.git@42acd8f46e974f5e0548ecd72315d1d7df2cb6f4