| """A Hugging Face Space that returns PaGeR's *raw metric depth*. |
| |
| The official demo (`prs-eth/PaGeR`) renders colour-mapped previews and GLB point |
| clouds, and never exposes the underlying array. Placement needs metres, so this |
| Space does the minimum: one forward pass, one `.npy` of float32 metric depth at |
| panoramic resolution, plus a preview and a few statistics for sanity. |
| |
| It also deliberately skips the point-cloud path, which is most of the original |
| demo's GPU time. On a free account ZeroGPU allows 5 minutes of GPU per day, so |
| the difference decides whether a run of four panoramas fits in the budget. |
| |
| The call sequence below mirrors the official Space's `_run_inference`, because |
| the model card's abbreviated snippet is wrong in ways that fail loudly (numpy |
| where a tensor is wanted) and, worse, in one way that would not have: |
| |
| * the backbone wants input centred to **[-1, 1]**, not [0, 1]; |
| * `Pager` stores whatever `device` it is given and later reads |
| `self.device.type`, so it needs a `torch.device`, not the string "cuda"; |
| * the model must be cast to `pager.weight_dtype`, not merely moved; |
| * `prepare_depth_for_logging` *calls* its cmap argument — it takes a colormap |
| object — and needs `log_scale=pred["scale"]` to produce metric output; |
| * the metric scale comes from one of two heads, indoor or outdoor, selected |
| per scene via `skip_heads`. |
| |
| DEPLOYING |
| 1. Create a Space, SDK "Gradio", hardware "ZeroGPU". A free account in good |
| standing may host two. |
| 2. Upload this file, `requirements.txt` and `README.md`. |
| 3. `python scripts/push_pager_space.py --name pager-raw --yes` does all of |
| that except the hardware switch, which needs the *Manage Spaces* scope. |
| |
| Weights are CC BY-NC 4.0, inherited from the Depth Anything 3 backbone: |
| academic and non-commercial use only. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import subprocess |
| import sys |
| import tempfile |
| import traceback |
|
|
| import matplotlib |
| import numpy as np |
| import spaces |
| import torch |
| import gradio as gr |
| from PIL import Image |
|
|
| |
| |
| |
| try: |
| from src.pager import Pager |
| except ModuleNotFoundError: |
| REPO = os.path.join(os.path.dirname(__file__), "PaGeR") |
| if not os.path.isdir(REPO): |
| subprocess.run( |
| ["git", "clone", "--depth", "1", "https://github.com/prs-eth/PaGeR", REPO], |
| check=True, |
| ) |
| sys.path.insert(0, REPO) |
| from src.pager import Pager |
|
|
| from huggingface_hub import hf_hub_download |
| from omegaconf import OmegaConf |
|
|
| from src.utils.geometry_utils import erp_to_cubemap |
| from src.utils.utils import prepare_depth_for_logging |
|
|
| CHECKPOINT = os.environ.get("PAGER_CHECKPOINT", "prs-eth/PaGeR-metric-depth") |
| MAX_WIDTH = 2048 |
| SCENE_TO_SCALE_HEAD = {"Indoor": "scale_indoor", "Outdoor": "scale_outdoor"} |
| CMAP = matplotlib.colormaps["Spectral"] |
|
|
| |
| |
| device = torch.device("cuda") |
| _cfg = OmegaConf.load(hf_hub_download(repo_id=CHECKPOINT, filename="config.yaml")) |
| _face = int(_cfg.face_size) |
| _fov = float(getattr(_cfg, "cube_fov", 90.0)) |
|
|
| |
| |
| |
| _modalities = set(getattr(_cfg, "modalities", []) or []) |
| HAS_BOTH_HEADS = {"scale_indoor", "scale_outdoor"} <= _modalities |
|
|
| pager = Pager(CHECKPOINT, cfg=_cfg, device=device) |
| pager.get_intrinsics_extrinsics(image_size=_face, fov=_fov) |
| pager.model.to(device, dtype=getattr(pager, "weight_dtype", torch.float32)) |
| pager.model.eval() |
|
|
| print(f"[pager-raw] checkpoint={CHECKPOINT} face={_face} fov={_fov} " |
| f"modalities={sorted(_modalities)} dual_heads={HAS_BOTH_HEADS}") |
|
|
|
|
| @spaces.GPU(duration=90) |
| def predict(image_path: str, scene: str = "Indoor"): |
| """Equirectangular RGB in, raw metric depth out.""" |
| if image_path is None: |
| raise gr.Error("Upload an equirectangular panorama first.") |
|
|
| img = Image.open(image_path).convert("RGB") |
| if img.width > MAX_WIDTH: |
| img = img.resize((MAX_WIDTH, MAX_WIDTH // 2), Image.LANCZOS) |
| if img.width != 2 * img.height: |
| raise gr.Error( |
| f"Expected a 2:1 equirectangular panorama, got {img.width}x{img.height}." |
| ) |
|
|
| rgb = np.asarray(img, dtype=np.uint8) |
| height, width = rgb.shape[:2] |
|
|
| try: |
| erp = torch.from_numpy(rgb).permute(2, 0, 1).to(torch.float32) / 255.0 |
| erp = erp * 2.0 - 1.0 |
| cubemap = erp_to_cubemap(erp, face_w=_face, fov=_fov).unsqueeze(0).to(device) |
|
|
| kwargs = {} |
| if HAS_BOTH_HEADS: |
| active = SCENE_TO_SCALE_HEAD[scene] |
| kwargs["skip_heads"] = {h for h in SCENE_TO_SCALE_HEAD.values() |
| if h != active} |
|
|
| with torch.inference_mode(): |
| pred = pager(cubemap, dtype=torch.float16, **kwargs) |
|
|
| depth, _viz = prepare_depth_for_logging( |
| pager, |
| pred["depth"][0], |
| pred["sky"][0] if "sky" in pred else None, |
| (height, width), |
| CMAP, |
| log_scale=pred.get("scale", None), |
| ) |
| except Exception: |
| |
| |
| raise gr.Error("PaGeR inference failed:\n" + traceback.format_exc()[-1500:]) |
|
|
| depth = np.squeeze(np.asarray(depth, dtype=np.float32)) |
| if depth.shape != (height, width): |
| raise gr.Error(f"depth is {depth.shape}, expected {(height, width)}") |
|
|
| path = os.path.join(tempfile.mkdtemp(), "pager_depth.npy") |
| np.save(path, depth) |
|
|
| finite = depth[np.isfinite(depth) & (depth > 0)] |
| head = SCENE_TO_SCALE_HEAD[scene] if HAS_BOTH_HEADS else "single-head checkpoint" |
| stats = ( |
| f"shape {depth.shape[0]}x{depth.shape[1]} · metres · {head}\n" |
| f"min {finite.min():.3f} p05 {np.percentile(finite, 5):.3f} " |
| f"median {np.median(finite):.3f} " |
| f"p95 {np.percentile(finite, 95):.3f} max {finite.max():.3f}\n" |
| f"non-finite or non-positive: {depth.size - finite.size} px" |
| ) |
|
|
| lo, hi = np.percentile(finite, [2, 98]) if finite.size else (0.0, 1.0) |
| norm = np.clip((depth - lo) / max(hi - lo, 1e-6), 0, 1) |
| preview = (CMAP(1.0 - norm)[..., :3] * 255).astype(np.uint8) |
| return path, preview, stats |
|
|
|
|
| with gr.Blocks(title="PaGeR raw metric depth") as demo: |
| gr.Markdown( |
| "## PaGeR — raw metric depth\n" |
| "Returns the float32 metric depth array as `.npy`, not a colour map. " |
| "Upload a 2:1 equirectangular panorama.\n\n" |
| f"Model: `{CHECKPOINT}` (CC BY-NC 4.0, non-commercial)." |
| ) |
| with gr.Row(): |
| with gr.Column(): |
| inp = gr.Image(type="filepath", label="Equirectangular panorama") |
| mode = gr.Radio( |
| list(SCENE_TO_SCALE_HEAD), value="Indoor", label="Metric scale head", |
| interactive=HAS_BOTH_HEADS, |
| info=("Indoor and outdoor scales come from different heads." |
| if HAS_BOTH_HEADS else |
| "This checkpoint ships a single scale head; ignored."), |
| ) |
| run = gr.Button("Estimate depth", variant="primary") |
| with gr.Column(): |
| out_file = gr.File(label="Metric depth (.npy, float32, metres)") |
| out_prev = gr.Image(label="Preview") |
| out_txt = gr.Textbox(label="Statistics", lines=3) |
|
|
| |
| |
| run.click(predict, inputs=[inp, mode], outputs=[out_file, out_prev, out_txt], |
| api_name="predict") |
|
|
| demo.queue(max_size=8).launch(show_error=True) |
|
|