twanghcmut's picture
download
raw
11.8 kB
#!/usr/bin/env python
"""Evaluate MoGe-2 per-frame depth against real PointWorld ``scene_flows`` ground truth.
Run with the ``fpgm`` conda env (this needs ``fpgm.geometry`` + ``h5py``). MoGe
inference is a *separate* prior step (``scripts/moge_infer_episode.py``, run
under the ``moge`` env) whose ``.npz`` outputs this script only reads.
GT frame-convention detection is reused verbatim from
``fpgm.geometry.convention.detect_scene_flow_convention`` -- the same detector
``scripts/export_real_depth_video.py`` uses -- rather than re-derived here.
Only pixels covered by BOTH ground truth (visible & depth-valid scene_flows,
z-buffered into the frame) AND MoGe's own predicted mask are scored. GT
coverage itself is sparse and partial (measured previously at 58/179 frames,
34.4% of pixels on those frames) -- this script recomputes and reports that
coverage number again as a sanity check that it agrees with the earlier claim.
Metrics, computed pooled over all scored pixels across all measured frames:
- AbsRel = mean(|pred - gt| / gt)
- RMSE (metres)
- delta1.25 = fraction with max(pred/gt, gt/pred) < 1.25
Reported twice per frame set:
(a) "aligned" -- after a per-frame least-squares scale+shift fit
(gt ~= a * pred + b) solved on that frame's scored pixels.
(b) "raw" -- no alignment, using MoGe's metric-scale output directly.
Temporal consistency: std of the per-frame fitted scale `a` across measured
frames (aligned case only) -- large std means a flickery, not video-consistent,
depth model.
Usage:
PYTHONPATH=src /home/quang/miniconda3/envs/fpgm/bin/python scripts/moge_eval_gt.py \\
--flows data/pointworld/droid/flows-fs-optimized/<uuid>_flows.h5 \\
--camera-serial 22008760 \\
--moge-dir outputs/moge/per_frame \\
--video data/droid_raw/<uuid>/recordings/MP4/22008760.mp4 \\
--out-dir outputs/moge
"""
from __future__ import annotations
import argparse
import io
import json
import re
from pathlib import Path
import cv2
import h5py
import numpy as np
from PIL import Image
from fpgm.config import ConventionConfig
from fpgm.geometry.camera import Camera, CameraIntrinsics
from fpgm.geometry.convention import detect_scene_flow_convention
from fpgm.types import FrameConvention
_CLIP_RE = re.compile(r"^(\d+):(\d+)$")
def _decode_initial_rgb(raw: object) -> np.ndarray:
"""Identical helper to ``export_real_depth_video.py`` -- generic h5 parsing, not GT logic."""
data = raw[0] if isinstance(raw, np.ndarray) and raw.dtype == object else raw
if isinstance(data, bytes | bytearray | np.bytes_):
return np.asarray(Image.open(io.BytesIO(bytes(data))).convert("RGB"))
arr = np.asarray(data)
if arr.ndim == 1:
return np.asarray(Image.open(io.BytesIO(arr.tobytes())).convert("RGB"))
return arr
def _camera_from(intrinsic: np.ndarray, extrinsic: np.ndarray, w: int, h: int) -> Camera:
K = CameraIntrinsics(
fx=float(intrinsic[0, 0]), fy=float(intrinsic[1, 1]),
cx=float(intrinsic[0, 2]), cy=float(intrinsic[1, 2]), width=w, height=h,
)
return Camera(K, np.asarray(extrinsic, dtype=np.float64))
def _zbuffer_depth(uv: np.ndarray, z: np.ndarray, h: int, w: int) -> np.ndarray:
"""Nearest-wins splat of projected points into an (H, W) float32 GT depth map (0 = unmeasured)."""
out = np.zeros((h, w), dtype=np.float32)
u = np.round(uv[:, 0]).astype(np.int64)
v = np.round(uv[:, 1]).astype(np.int64)
keep = (u >= 0) & (u < w) & (v >= 0) & (v < h) & (z > 0)
u, v, z = u[keep], v[keep], z[keep]
order = np.argsort(-z) # far first, so nearer points overwrite -> nearest-wins
out[v[order], u[order]] = z[order]
return out
def _fit_scale_shift(pred: np.ndarray, gt: np.ndarray) -> tuple[float, float]:
"""Closed-form least-squares (a, b) minimizing sum((a*pred + b - gt)^2)."""
A = np.stack([pred, np.ones_like(pred)], axis=1)
sol, *_ = np.linalg.lstsq(A, gt, rcond=None)
return float(sol[0]), float(sol[1])
def _depth_metrics(pred: np.ndarray, gt: np.ndarray) -> dict:
absrel = float(np.mean(np.abs(pred - gt) / gt))
rmse = float(np.sqrt(np.mean((pred - gt) ** 2)))
ratio = np.maximum(pred / gt, gt / pred)
delta125 = float(np.mean(ratio < 1.25))
return {"AbsRel": absrel, "RMSE_m": rmse, "delta1.25": delta125, "n_pixels": int(pred.size)}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--flows", required=True, type=Path)
ap.add_argument("--camera-serial", required=True)
ap.add_argument("--moge-dir", required=True, type=Path)
ap.add_argument("--video", required=True, type=Path)
ap.add_argument("--out-dir", required=True, type=Path)
ap.add_argument("--min-pixels-per-frame", type=int, default=50)
args = ap.parse_args()
cap = cv2.VideoCapture(str(args.video))
vid_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
vid_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
cap.release()
with h5py.File(args.flows, "r") as f:
clips = sorted(
(k for k in f if _CLIP_RE.fullmatch(k)),
key=lambda s: int(s.split(":")[0]),
)
n_frames = int(f.attrs.get("trajectory_length", 0)) or (
max(int(c.split(":")[1]) for c in clips)
)
cam_key = f"camera_{args.camera_serial}_ext"
first = f[clips[0]][cam_key]
ann_h, ann_w = np.asarray(first["initial_depth"]).shape
rgb0 = _decode_initial_rgb(first["initial_rgb"][()])
cam_native = _camera_from(first["intrinsic"], first["extrinsic"], ann_w, ann_h)
det = detect_scene_flow_convention(
cam_native.rescaled(rgb0.shape[1], rgb0.shape[0]),
np.asarray(first["scene_flows"][0], dtype=np.float64),
np.asarray(first["scene_colors"][0]),
rgb0,
ConventionConfig(),
)
is_world = det.convention is FrameConvention.WORLD
print(f"frame convention: {det.convention.name} (margin={det.margin:.4f}, "
f"frac_in_bounds={det.frac_in_bounds:.4f})")
# GT depth is z-buffered at MoGe's native video resolution so pixels line up 1:1
# with MoGe's per-frame depth maps (no resizing/interpolation on either side).
gt_depth = np.zeros((n_frames, vid_h, vid_w), dtype=np.float32)
measured = np.zeros(n_frames, dtype=bool)
for clip in clips:
a, b = (int(x) for x in clip.split(":"))
g = f[clip][cam_key]
cam = _camera_from(g["intrinsic"], g["extrinsic"], ann_w, ann_h).rescaled(vid_w, vid_h)
pts = np.asarray(g["scene_flows"], dtype=np.float64)
vis = np.asarray(g["scene_visibility"])
ok = np.asarray(g["scene_depth_valid_mask"])
for i, t in enumerate(range(a, min(b, n_frames))):
sel = vis[i] & ok[i]
if not sel.any():
continue
p = pts[i][sel]
uv, z = (cam.project(p) if is_world else cam.project_cam(p))
gt_depth[t] = _zbuffer_depth(np.asarray(uv), np.asarray(z), vid_h, vid_w)
measured[t] = True
measured_frames = [t for t in range(n_frames) if measured[t]]
print(f"GT measured frames: {len(measured_frames)}/{n_frames} = {len(measured_frames) / n_frames:.1%}")
per_frame_records = []
pooled_pred_aligned, pooled_gt_aligned = [], []
pooled_pred_raw, pooled_gt_raw = [], []
for t in measured_frames:
npz_path = args.moge_dir / f"frame_{t:03d}.npz"
if not npz_path.exists():
print(f" WARNING: missing MoGe output for frame {t}, skipping")
continue
d = np.load(npz_path)
moge_depth = d["depth"]
moge_mask = d["mask"]
gt_valid = gt_depth[t] > 0
moge_valid = moge_mask & np.isfinite(moge_depth) & (moge_depth > 0)
both = gt_valid & moge_valid
n_both = int(both.sum())
if n_both < args.min_pixels_per_frame:
print(f" frame {t}: only {n_both} overlapping pixels, skipping (< {args.min_pixels_per_frame})")
continue
pred = moge_depth[both].astype(np.float64)
gt = gt_depth[t][both].astype(np.float64)
a, b = _fit_scale_shift(pred, gt)
pred_aligned = a * pred + b
keep = pred_aligned > 0 # guard against a pathological negative-depth fit
m_aligned = _depth_metrics(pred_aligned[keep], gt[keep])
m_raw = _depth_metrics(pred, gt)
per_frame_records.append({
"frame": t,
"n_gt_pixels": int(gt_valid.sum()),
"n_overlap_pixels": n_both,
"pixel_coverage_frac_gt_frame": float(gt_valid.mean()),
"fit_scale_a": a,
"fit_shift_b": b,
"aligned": m_aligned,
"raw": m_raw,
})
pooled_pred_aligned.append(pred_aligned[keep])
pooled_gt_aligned.append(gt[keep])
pooled_pred_raw.append(pred)
pooled_gt_raw.append(gt)
pooled_pred_aligned = np.concatenate(pooled_pred_aligned)
pooled_gt_aligned = np.concatenate(pooled_gt_aligned)
pooled_pred_raw = np.concatenate(pooled_pred_raw)
pooled_gt_raw = np.concatenate(pooled_gt_raw)
pooled_aligned_metrics = _depth_metrics(pooled_pred_aligned, pooled_gt_aligned)
pooled_raw_metrics = _depth_metrics(pooled_pred_raw, pooled_gt_raw)
scales = np.array([r["fit_scale_a"] for r in per_frame_records])
temporal = {
"n_frames_scored": len(per_frame_records),
"fit_scale_mean": float(scales.mean()),
"fit_scale_std": float(scales.std()),
"fit_scale_cv": float(scales.std() / scales.mean()) if scales.mean() != 0 else float("nan"),
"fit_scale_min": float(scales.min()),
"fit_scale_max": float(scales.max()),
}
pixel_cov = np.mean([r["pixel_coverage_frac_gt_frame"] for r in per_frame_records])
summary = {
"video": str(args.video),
"flows": str(args.flows),
"camera_serial": args.camera_serial,
"convention": det.convention.name,
"n_frames_total": n_frames,
"n_frames_gt_measured": len(measured_frames),
"frame_coverage_frac": len(measured_frames) / n_frames,
"mean_pixel_coverage_on_measured_frames": float(pixel_cov),
"n_frames_scored_moge_overlap": len(per_frame_records),
"pooled_aligned": pooled_aligned_metrics,
"pooled_raw": pooled_raw_metrics,
"temporal_scale_consistency": temporal,
"per_frame": per_frame_records,
}
args.out_dir.mkdir(parents=True, exist_ok=True)
(args.out_dir / "eval_metrics.json").write_text(json.dumps(summary, indent=2))
print("\n=== POOLED METRICS (all scored pixels, all measured frames) ===")
print(f"frames scored: {len(per_frame_records)}/{len(measured_frames)} GT-measured frames "
f"(rest had <{args.min_pixels_per_frame} overlap pixels with MoGe's mask)")
print(f"mean pixel coverage on measured frames (GT only): {pixel_cov:.1%}")
print(f"[aligned, per-frame LS scale+shift] AbsRel={pooled_aligned_metrics['AbsRel']:.4f} "
f"RMSE={pooled_aligned_metrics['RMSE_m']:.4f} m delta1.25={pooled_aligned_metrics['delta1.25']:.4f}")
print(f"[raw, metric, no alignment] AbsRel={pooled_raw_metrics['AbsRel']:.4f} "
f"RMSE={pooled_raw_metrics['RMSE_m']:.4f} m delta1.25={pooled_raw_metrics['delta1.25']:.4f}")
print(f"temporal scale consistency: mean(a)={temporal['fit_scale_mean']:.4f} "
f"std(a)={temporal['fit_scale_std']:.4f} CV={temporal['fit_scale_cv']:.4f} "
f"range=[{temporal['fit_scale_min']:.4f}, {temporal['fit_scale_max']:.4f}]")
print(f"\nwrote {args.out_dir}/eval_metrics.json")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
11.8 kB
·
Xet hash:
db7340a0d5015a802b7dd0c661432444f02d1dacb07435d42e28c992a9eb9048

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.