Spaces:
Running on Zero
Running on Zero
Add PXDepth demo
Browse files- app.py +184 -24
- 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 / "
|
| 203 |
depth_png = output / "depth.png"
|
| 204 |
mask_png = output / "mask.png"
|
| 205 |
ply_path = output / "pointcloud.ply"
|
| 206 |
-
|
| 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 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 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),
|
| 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="
|
| 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="
|
| 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,
|
| 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
|