pager-raw / app.py
dufals's picture
Upload folder using huggingface_hub
b19b681 verified
Raw
History Blame Contribute Delete
8.23 kB
"""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
# PaGeR imports live under a top-level `src` package, so the repo root has to be
# importable. requirements.txt installs it; the clone is a fallback because that
# layout does not always survive a wheel build.
try:
from src.pager import Pager
except ModuleNotFoundError: # pragma: no cover
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 # our panoramas are 2K native; PaGeR accepts up to 3K
SCENE_TO_SCALE_HEAD = {"Indoor": "scale_indoor", "Outdoor": "scale_outdoor"}
CMAP = matplotlib.colormaps["Spectral"]
# A torch.device, not the string "cuda". ZeroGPU also wants the model placed at
# module import rather than lazily inside the GPU function.
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))
# Only the unified checkpoint ships both scale heads. With a single-head
# checkpoint `skip_heads` has nothing to choose between, so it is omitted rather
# than passed empty — and the scene selector is disabled to say so honestly.
_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 # centred input for the backbone
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:
# Surfaced to the caller rather than buried in the Space log, so a
# remote client can diagnose without log access.
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)
# A stable api_name keeps scripts/fetch_pager_depth.py working when the UI
# is rearranged.
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)