Source: docs/experiments/EXP-3D-RGBD-DEPTHSPLAT-JEPA-NORM-SCALE-ARKIT-V1/code/ds_predict.py · Plain text

docs/experiments/EXP-3D-RGBD-DEPTHSPLAT-JEPA-NORM-SCALE-ARKIT-V1/code/ds_predict.py

"""DepthSplat predictions for project records (exploratory; reads no query depth).

For every record and role of a manifest, the three context frames (RGB only, the prepared
128x160 frames upsampled to --size) and their cameras go into the pretrained DepthSplat
(re10k+dl3dv base); its Gaussians are rendered at every primary query camera at 128x160
(planar z, the model's depth mode) and saved, with the model's own context-view depths.
The depth render is DepthSplat's sum of alpha * transmittance * z over Gaussians on a zero
background, so it is not normalized; the accumulated opacity (alpha) of the same blend is saved
too, and depth_z / alpha is the normalized depth wherever alpha is not small.
Intrinsics are normalized as DepthSplat expects (pixel-corner principal point / image size);
poses are the records' OpenCV camera-to-world matrices in metres.
Run with the DepthSplat environment from the DepthSplat repository root:
  python ds_predict.py MANIFEST SPLIT OUT [--size 256 320] [--near 0.5] [--far 200]
"""

import argparse
import json
import subprocess
import time
from pathlib import Path

import numpy as np
import torch
import torch.nn.functional as F
from einops import rearrange, repeat
from hydra import compose, initialize_config_dir
from PIL import Image

from src.config import load_typed_root_config
from src.global_cfg import set_cfg
from src.model.decoder import get_decoder
from src.model.decoder.cuda_splatting import render_cuda
from src.model.encoder import get_encoder

CHECKPOINT = Path(
    "/home/xmum26_zonghan/zonghan_o_mirror/_models/depthsplat/"
    "depthsplat-gs-base-re10kdl3dv-448x768-randview2-6-f8ddd845.pth"
)
OVERRIDES = [
    "+experiment=dl3dv",
    "mode=test",
    "model.encoder.num_scales=2",
    "model.encoder.upsample_factor=4",
    "model.encoder.lowest_feature_resolution=8",
    "model.encoder.monodepth_vit_type=vitb",
    "model.encoder.gaussian_adapter.gaussian_scale_max=0.1",
    # Inference only: the final depth alone (the intermediate one is a training target).
    "model.encoder.supervise_intermediate_depth=false",
    "wandb.mode=disabled",
]
MIRROR = ("/home/zonghan/", "/home/xmum26_zonghan/zonghan_o_mirror/")
DINOV2_CODE = Path.home() / ".cache/torch/hub/facebookresearch_dinov2_main"
_hub_load = torch.hub.load


def hub_load_without_weights(repo, model, *args, **kwargs):
    """DepthSplat builds its monocular ViT with torch.hub.load("facebookresearch/dinov2", ...), which looks up the
    default branch on GitHub and downloads the ImageNet DINOv2 weights. The DepthSplat checkpoint holds all of those
    weights (build() fails on any missing encoder key), so the module is built without them, from the local code."""
    kwargs.setdefault("pretrained", False)
    if repo == "facebookresearch/dinov2":
        return _hub_load(str(DINOV2_CODE), model, *args, source="local", **kwargs)
    return _hub_load(repo, model, *args, **kwargs)


torch.hub.load = hub_load_without_weights


def local(path):
    return path.replace(*MIRROR) if path.startswith(MIRROR[0]) else path


def build(device, checkpoint=CHECKPOINT):
    with initialize_config_dir(config_dir=str(Path("config").resolve()), version_base=None):
        cfg_dict = compose(config_name="main", overrides=OVERRIDES)
    set_cfg(cfg_dict)
    cfg = load_typed_root_config(cfg_dict)
    encoder, _ = get_encoder(cfg.model.encoder)
    decoder = get_decoder(cfg.model.decoder, cfg.dataset)
    state = torch.load(checkpoint, map_location="cpu")
    state = state.get("state_dict", state)
    for prefix, module in (("encoder.", encoder), ("decoder.", decoder)):
        part = {k[len(prefix):]: v for k, v in state.items() if k.startswith(prefix)}
        missing, unexpected = module.load_state_dict(part, strict=False)
        if unexpected or (missing and prefix == "encoder."):
            raise RuntimeError(f"{prefix} missing {missing[:5]} unexpected {unexpected[:5]}")
    return encoder.to(device).eval(), decoder.to(device).eval()


ORTHO = {"max_correction": 0.0}


def orthonormal(c2w):
    """The nearest proper rotation (SVD) replaces the pose's rotation: DepthSplat's SH rotation asserts det(R) == 1
    to 1e-5, which a few recorded poses miss (e.g. EVAL-V3 ai_045_001 frame 0: |det - 1| = 2e-5, R^T R off by 0.006).
    The largest entry change is kept in ORTHO and reported in run_info.json."""
    u, _, vt = np.linalg.svd(c2w[:3, :3])
    rotation = u @ vt
    if np.linalg.det(rotation) < 0:
        raise ValueError("c2w rotation is a reflection")
    ORTHO["max_correction"] = max(ORTHO["max_correction"], float(np.abs(rotation - c2w[:3, :3]).max()))
    out = c2w.copy()
    out[:3, :3] = rotation
    return out


def views(frames, ids, size, near, far, device, image_size=(128, 160)):
    rows = {f["frame_id"]: f for f in frames}
    images, intrinsics, extrinsics = [], [], []
    h0, w0 = image_size
    for i in ids:
        f = rows[i]
        rgb = np.asarray(Image.open(local(f["rgb"])).convert("RGB"), dtype=np.float32) / 255.0
        image = torch.from_numpy(rgb).permute(2, 0, 1)[None]
        if size is not None:
            image = F.interpolate(image, size=size, mode="bilinear", align_corners=False)
        images.append(image[0])
        k = np.array(f["intrinsics"], dtype=np.float64)
        intrinsics.append(
            [[k[0, 0] / w0, 0.0, (k[0, 2] + 0.5) / w0], [0.0, k[1, 1] / h0, (k[1, 2] + 0.5) / h0], [0.0, 0.0, 1.0]]
        )
        extrinsics.append(orthonormal(np.array(f["c2w"], dtype=np.float64)))
    n = len(ids)
    return {
        "image": torch.stack(images)[None].to(device),
        "intrinsics": torch.tensor(np.array(intrinsics), dtype=torch.float32)[None].to(device),
        "extrinsics": torch.tensor(np.array(extrinsics), dtype=torch.float32)[None].to(device),
        "near": torch.full((1, n), near, device=device),
        "far": torch.full((1, n), far, device=device),
    }


def spare_frames(record, n, min_query_gap=0):
    """Up to n frames that no role of the record uses (neither context nor query), evenly spaced in frame order;
    with min_query_gap, frames whose index is closer than that to any query frame are left out."""
    roles = record["roles"]
    used = set().union(*(set(roles.get(k, [])) for k in
                         ("context_a", "context_b", "query", "primary_query", "secondary_query", "stream")))
    queries = set().union(*(set(roles.get(k, [])) for k in ("query", "primary_query", "secondary_query")))
    spare = [f["frame_id"] for f in record["frames"] if f["frame_id"] not in used
             and all(abs(f["frame_id"] - q) >= min_query_gap for q in queries)]
    if n >= len(spare):
        return spare
    return [spare[round(i * (len(spare) - 1) / max(n - 1, 1))] for i in range(n)]


def measured_planar(frames, ids, size, device, keep=None):
    """Measured depth as planar z [1, V, H, W] (the records store ray distance), nearest-resized; 0 = invalid.
    Views outside `keep` (when given) get 0, so DepthSplat keeps its own depth there."""
    rows = {f["frame_id"]: f for f in frames}
    planes = []
    for i in ids:
        if keep is not None and i not in keep:
            planes.append(torch.zeros(np.load(local(rows[i]["depth"])).squeeze().shape))
            continue
        depth = np.load(local(rows[i]["depth"])).squeeze().astype(np.float64)
        k = np.array(rows[i]["intrinsics"], dtype=np.float64)
        y, x = np.indices(depth.shape)
        ray = np.sqrt(((x - k[0, 2]) / k[0, 0]) ** 2 + ((y - k[1, 2]) / k[1, 1]) ** 2 + 1.0)
        planes.append(torch.from_numpy(np.where(np.isfinite(depth) & (depth > 0), depth / ray, 0.0)).float())
    return F.interpolate(torch.stack(planes)[None], size=size, mode="nearest").to(device)


INJECTED = {}


def inject_measured_depth(encoder):
    """Make DepthSplat use INJECTED["depth"] (where valid) as its final context depth: the depth that feeds the
    Gaussian regressor and places the Gaussians. Its matching probabilities and features stay its own.
    Records how DepthSplat's own depth compares with the measured depth of the first INJECTED["measured_views"]
    views (median ratio and AbsRel); with INJECTED["align"], DepthSplat's depth of the remaining views is first
    multiplied by that median ratio (a test-time scale alignment to the measured views)."""
    original = encoder.depth_predictor.forward

    def forward(*args, **kwargs):
        result = original(*args, **kwargs)
        predicted = result["depth_preds"][-1]
        measured = INJECTED["depth"].to(predicted)
        n = INJECTED.get("measured_views", measured.shape[1])
        m, p = measured[:, :n], predicted[:, :n]
        valid = m > 0
        ratio = float(torch.median(m[valid] / p[valid].clamp_min(1e-6)))
        INJECTED["stats"] = {"median_ratio": ratio,
                             "context_absrel": float(((p - m).abs() / m.clamp_min(1e-6))[valid].mean())}
        if INJECTED.get("align"):
            predicted = predicted * ratio
        result["depth_preds"][-1] = torch.where(measured > 0, measured, predicted)
        return result

    encoder.depth_predictor.forward = forward


def render_alpha(gaussians, target, shape):
    """Accumulated opacity per target pixel: the total weight of render_depth_cuda's blend (zero background)."""
    b, v = target["extrinsics"].shape[:2]
    means = gaussians.means
    alpha = render_cuda(
        rearrange(target["extrinsics"], "b v i j -> (b v) i j"),
        rearrange(target["intrinsics"], "b v i j -> (b v) i j"),
        rearrange(target["near"], "b v -> (b v)"),
        rearrange(target["far"], "b v -> (b v)"),
        shape,
        torch.zeros((b * v, 3), device=means.device),
        repeat(means, "b g xyz -> (b v) g xyz", v=v),
        repeat(gaussians.covariances, "b g i j -> (b v) g i j", v=v),
        torch.ones((b * v, means.shape[1], 3, 1), device=means.device),
        repeat(gaussians.opacities, "b g -> (b v) g", v=v),
        use_sh=False,
    )
    return rearrange(alpha.mean(dim=1), "(b v) h w -> b v h w", b=b, v=v)


def git_head(path):
    try:
        return subprocess.run(["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
    except OSError:
        return None


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("manifest")
    parser.add_argument("split")
    parser.add_argument("out")
    parser.add_argument("--size", type=int, nargs=2, default=[256, 320])
    parser.add_argument("--near", type=float, default=0.5)
    parser.add_argument("--far", type=float, default=200.0)
    parser.add_argument("--inject-measured-depth", action="store_true",
                        help="replace DepthSplat's final context depth by the measured context depth where valid")
    parser.add_argument("--extra-rgb", type=int, default=0,
                        help="add up to N frames no role uses as RGB-only views after the three context views")
    parser.add_argument("--min-query-gap", type=int, default=0,
                        help="leave out extra frames whose frame index is closer than this to any query frame")
    parser.add_argument("--inject-all", action="store_true",
                        help="with --inject-measured-depth, inject measured depth for the extra views too (RGB-D bound)")
    parser.add_argument("--checkpoint", default=str(CHECKPOINT), help="DepthSplat weights (default: the released base)")
    parser.add_argument("--align-extra-scale", action="store_true",
                        help="with --inject-measured-depth: scale DepthSplat's depth of the RGB-only views by the median "
                             "measured/predicted ratio on the measured views")
    args = parser.parse_args()
    if args.align_extra_scale and not args.inject_measured_depth:
        parser.error("--align-extra-scale needs --inject-measured-depth")
    INJECTED["align"] = args.align_extra_scale
    out = Path(args.out)
    out.mkdir(parents=True, exist_ok=False)
    device = "cuda"
    torch.backends.cuda.matmul.allow_tf32 = False
    torch.backends.cudnn.allow_tf32 = False
    started = time.time()
    encoder, decoder = build(device, Path(args.checkpoint))
    if args.inject_measured_depth:
        inject_measured_depth(encoder)
    manifest = json.loads(Path(args.manifest).read_text())
    records = [r for r in manifest["scenes"] if r["split"] == args.split]
    done, log = 0, []
    for record in records:
        for role, key in (("A", "context_a"), ("B", "context_b")):
            ctx_ids, query_ids = record["roles"][key], record["roles"]["primary_query"]
            extra_ids = spare_frames(record, args.extra_rgb, args.min_query_gap) if args.extra_rgb else []
            view_ids = list(ctx_ids) + extra_ids  # context views first: ds_score reads their depths by position
            context = views(record["frames"], view_ids, tuple(args.size), args.near, args.far, device)
            target = views(record["frames"], query_ids, None, args.near, args.far, device)
            if args.inject_measured_depth:
                keep = set(view_ids) if args.inject_all else set(ctx_ids)
                INJECTED["depth"] = measured_planar(record["frames"], view_ids, tuple(args.size), device, keep)
                INJECTED["measured_views"] = len(keep)
            with torch.no_grad():
                encoded = encoder(context, global_step=0, deterministic=True)
                gaussians = encoded["gaussians"] if isinstance(encoded, dict) else encoded
                rendered = decoder.forward(
                    gaussians, target["extrinsics"], target["intrinsics"], target["near"],
                    target["far"], (128, 160), depth_mode="depth",
                )
                alpha = render_alpha(gaussians, target, (128, 160))
            ctx_depth = encoded["depths"] if isinstance(encoded, dict) and "depths" in encoded else None
            while ctx_depth is not None and ctx_depth.dim() > 4:  # [B, V, H, W, srf, s] -> [B, V, H, W]
                ctx_depth = ctx_depth.squeeze(-1)
            entry = {"scene_id": record["scene_id"], "role": role, "views": view_ids,
                     "gaussians": int(gaussians.means.shape[1]),
                     "alpha_mean": float(alpha.mean()), "context_depth_shape": None if ctx_depth is None
                     else list(ctx_depth.shape), "injection": INJECTED.get("stats") if args.inject_measured_depth else None}
            log.append(entry)
            if len(log) == 1:
                print("FIRST", entry, flush=True)
            for j, qid in enumerate(query_ids):
                arrays = {
                    "depth_z": rendered.depth[0, j].float().cpu().numpy(),
                    "alpha": alpha[0, j].float().cpu().numpy(),
                    "color": rendered.color[0, j].float().cpu().numpy(),
                }
                if ctx_depth is not None:
                    arrays["context_depth_z"] = ctx_depth[0].float().cpu().numpy()
                np.savez_compressed(out / f"{record['scene_id']}_{role}_{qid}.npz", **arrays)
                done += 1
    info = {
        "manifest": str(Path(args.manifest).resolve()), "split": args.split, "size": args.size,
        "near": args.near, "far": args.far, "inject_measured_depth": args.inject_measured_depth,
        "extra_rgb": args.extra_rgb, "min_query_gap": args.min_query_gap, "inject_all": args.inject_all,
        "align_extra_scale": args.align_extra_scale,
        "checkpoint": str(args.checkpoint), "dinov2_code": str(DINOV2_CODE), "overrides": OVERRIDES,
        "depthsplat_commit": git_head(Path.cwd()), "torch": torch.__version__,
        "gpu": torch.cuda.get_device_name(0), "queries": done, "seconds": round(time.time() - started, 1),
        "pose_orthonormalization_max_correction": ORTHO["max_correction"],
        "records": log,
    }
    (out / "run_info.json").write_text(json.dumps(info, indent=2))
    print("DS_PREDICTED", done, "queries", "size", args.size, "seconds", info["seconds"], flush=True)


if __name__ == "__main__":
    main()