twanghcmut's picture
download
raw
13.3 kB
#!/usr/bin/env python
"""Run MoGe-2 over the FULL length of several DROID clips and render what it sees.
Purpose is qualitative: "how fine-grained is the monocular depth model on our own
published clips?". So the primary output is a three-panel video per clip
RGB | depth (turbo, ONE global normalisation for the whole clip) | normal
rather than a metric table -- normals are included because they are the panel that
actually exposes fine detail: depth is dominated by the metre-scale front-to-back
ramp of the table, while the normal map shows the millimetre-scale surface relief
(the drawer lip, the brick's edges, the cloth folds) on the same colour budget.
Two choices worth stating, because both are the difference between judging the
model and judging the visualisation:
* **Global depth normalisation, never per frame.** Per-frame percentiles make the
panel breathe as the near/far extremes move, which by eye is indistinguishable
from the model's own temporal instability -- the exact thing this video exists to
let someone judge. Percentiles are robust (2/98) over every valid pixel of every
frame, so one stray pixel cannot crush the range.
* **Out-of-the-box path: no ``fov_x`` is passed**, so MoGe estimates its own FOV
per frame even though this project knows the true DROID intrinsics. Priming it
with the calibration would measure a best case we would not get on arbitrary
video. ``resolution_level=9`` is the default and already maps to the top of the
model's ``num_tokens_range`` (3600), i.e. the finest setting it offers.
Env: ``moge`` (``/home/quang/miniconda3/envs/moge/bin/python``), NOT ``fpgm``.
Deliberately imports nothing from ``fpgm`` so it needs only torch/cv2/numpy.
Usage:
CUDA_VISIBLE_DEVICES=1 /home/quang/miniconda3/envs/moge/bin/python \\
scripts/moge_run_clips.py --manifest outputs/datagen/_publish_staging/dataset_manifest.json \\
--limit 5 --out-root outputs/moge/published_clips
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import time
from pathlib import Path
import cv2
import numpy as np
import torch
REPO_ROOT = Path(__file__).resolve().parents[1]
_FFMPEG_CANDIDATE = Path("/home/quang/miniconda3/envs/ffmpeg_libs/bin/ffmpeg")
_PERCENTILES = (2.0, 98.0)
def resolve_ffmpeg() -> str:
if _FFMPEG_CANDIDATE.exists():
return str(_FFMPEG_CANDIDATE)
on_path = shutil.which("ffmpeg")
if on_path:
return on_path
raise RuntimeError(f"no ffmpeg: {_FFMPEG_CANDIDATE} missing and none on PATH")
def write_h264(frames_bgr, out: Path, fps: float, crf: int = 16) -> None:
"""Pipe BGR frames to libx264. `frames_bgr` is any iterable of (H, W, 3) uint8."""
it = iter(frames_bgr)
first = next(it)
h, w = first.shape[:2]
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
resolve_ffmpeg(), "-y", "-hide_banner", "-loglevel", "error",
"-f", "rawvideo", "-pix_fmt", "bgr24", "-s", f"{w}x{h}", "-r", f"{fps:.4f}",
"-i", "pipe:0", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", str(crf),
str(out),
]
p = subprocess.Popen(cmd, stdin=subprocess.PIPE)
assert p.stdin is not None
try:
p.stdin.write(np.ascontiguousarray(first).tobytes())
for f in it:
p.stdin.write(np.ascontiguousarray(f).tobytes())
finally:
p.stdin.close()
if p.wait() != 0:
raise RuntimeError(f"ffmpeg failed writing {out}")
def label(img_bgr: np.ndarray, text: str) -> np.ndarray:
out = img_bgr.copy()
cv2.putText(out, text, (14, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 0), 5, cv2.LINE_AA)
cv2.putText(out, text, (14, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (255, 255, 255), 2, cv2.LINE_AA)
return out
def colorize_depth(depth: np.ndarray, lo: float, hi: float) -> np.ndarray:
"""(H, W) metres -> BGR turbo. Invalid (inf/nan) pixels stay pure black."""
valid = np.isfinite(depth)
norm = np.zeros_like(depth, dtype=np.float32)
norm[valid] = np.clip((depth[valid] - lo) / max(hi - lo, 1e-6), 0.0, 1.0)
# near = warm: invert so the closest surfaces are the brightest, matching how the
# RGB panel reads (the manipulated object is always the near thing).
u8 = ((1.0 - norm) * 255.0).astype(np.uint8)
col = cv2.applyColorMap(u8, cv2.COLORMAP_TURBO)
col[~valid] = 0
return col
def colorize_normal(normal: np.ndarray, valid: np.ndarray) -> np.ndarray:
"""(H, W, 3) unit normals in camera frame -> BGR. Invalid pixels black."""
rgb = ((normal * 0.5 + 0.5) * 255.0).clip(0, 255).astype(np.uint8)
bgr = rgb[..., ::-1].copy()
bgr[~valid] = 0
return bgr
def select_clips(manifest: Path, limit: int, explicit: list[str] | None) -> list[dict]:
data = json.loads(manifest.read_text())
eps = data["episodes"]
if explicit:
wanted = set(explicit)
eps = [e for e in eps if e["uuid"] in wanted or f"{e['uuid']}/{e['camera_serial']}" in wanted]
# Prefer distinct viewpoints: keep at most one entry per (uuid, serial), and
# order so the alternate camera of a repeated uuid is not dropped by --limit.
seen: set[tuple[str, str]] = set()
uniq = []
for e in eps:
key = (e["uuid"], e["camera_serial"])
if key in seen:
continue
seen.add(key)
uniq.append(e)
return uniq[:limit] if limit else uniq
#: DROID mp4 containers advertise 60/1, but their frames are the 15 Hz trajectory
#: samples -- the whole repo indexes poses by video frame at
#: ``fpgm.data.episode.DEFAULT_TRAJECTORY_FPS = 15.0`` (e.g. this episode's 178
#: frames carry 178 pose-provenance entries). Muxing the panel video at the
#: container's 60 would play it 4x fast, which reads as temporal jitter that isn't
#: there. Hardcoding the repo constant instead, and recording both in stats.json.
TRAJECTORY_FPS = 15.0
def run_clip(model, ep: dict, out_root: Path, device: torch.device,
save_npz: bool, resolution_level: int, out_fps: float,
skip_existing: bool) -> dict:
uuid, serial = ep["uuid"], ep["camera_serial"]
video = REPO_ROOT / "data" / "droid_raw" / uuid / "recordings" / "MP4" / f"{serial}.mp4"
if not video.exists():
raise FileNotFoundError(video)
out_dir = out_root / f"{uuid}__{serial}"
npz_dir = out_dir / "per_frame"
done_marker = out_dir / "stats.json"
if skip_existing and done_marker.exists() and (out_dir / "rgb_depth_normal.mp4").exists():
print(f"\n=== {uuid} / {serial} :: already done, skipping (delete {done_marker} to redo)")
return json.loads(done_marker.read_text())
if save_npz:
npz_dir.mkdir(parents=True, exist_ok=True)
out_dir.mkdir(parents=True, exist_ok=True)
cap = cv2.VideoCapture(str(video))
n_reported = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
container_fps = float(cap.get(cv2.CAP_PROP_FPS)) or out_fps
w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
print(f"\n=== {uuid} / {serial} :: {w}x{h}, {n_reported} frames reported, "
f"container fps {container_fps:.2f} -> playing at {out_fps:.2f}")
rgbs: list[np.ndarray] = [] # uint8 BGR, for the left panel
depths: list[np.ndarray] = [] # float16 metres, inf where invalid
normals: list[np.ndarray] = [] # uint8 BGR, already colourised (keeps RAM flat)
t0 = time.time()
with torch.inference_mode():
for t in range(n_reported):
ok, frame_bgr = cap.read()
if not ok:
print(f" decode stopped at t={t} (container reported {n_reported})")
break
frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
image = torch.tensor(frame_rgb / 255.0, dtype=torch.float32,
device=device).permute(2, 0, 1)
out = model.infer(image, resolution_level=resolution_level) # no fov_x on purpose
depth = out["depth"].float().cpu().numpy()
mask = out["mask"].cpu().numpy().astype(bool)
normal = out["normal"].float().cpu().numpy() if "normal" in out else None
intr = out["intrinsics"].float().cpu().numpy()
if save_npz:
payload = {"depth": depth.astype(np.float32), "mask": mask,
"intrinsics": intr.astype(np.float32)}
if normal is not None:
payload["normal"] = normal.astype(np.float16)
np.savez_compressed(npz_dir / f"frame_{t:04d}.npz", **payload)
rgbs.append(frame_bgr)
depths.append(depth.astype(np.float16))
normals.append(colorize_normal(normal, mask) if normal is not None
else np.zeros_like(frame_bgr))
cap.release()
n = len(rgbs)
dt = time.time() - t0
print(f" {n} frames in {dt:.1f}s ({n / max(dt, 1e-6):.2f} fps)")
# --- one global depth range for the whole clip -------------------------------
stacked = np.concatenate([d[np.isfinite(d)].astype(np.float32).ravel() for d in depths])
lo, hi = np.percentile(stacked, _PERCENTILES)
valid_frac = float(stacked.size) / max(n * h * w, 1)
print(f" depth p2={lo:.3f} m p98={hi:.3f} m valid_pixels={valid_frac * 100:.1f}%")
def panels():
for i in range(n):
d = colorize_depth(depths[i].astype(np.float32), lo, hi)
yield np.hstack([
label(rgbs[i], "RGB"),
label(d, f"MoGe-2 depth [{lo:.2f}-{hi:.2f} m, global]"),
label(normals[i], "MoGe-2 normal"),
])
vid_out = out_dir / "rgb_depth_normal.mp4"
write_h264(panels(), vid_out, out_fps)
print(f" wrote {vid_out}")
stats = {
"uuid": uuid, "camera_serial": serial, "camera_role": ep.get("camera_role"),
"task": ep.get("task"), "video": str(video.relative_to(REPO_ROOT)),
"width": w, "height": h,
"container_fps": container_fps, "output_fps": out_fps,
"n_frames_reported": n_reported, "n_frames_processed": n,
"seconds": round(dt, 2), "fps_inference": round(n / max(dt, 1e-6), 3),
"depth_p2_m": float(lo), "depth_p98_m": float(hi),
"valid_pixel_fraction": round(valid_frac, 5),
"resolution_level": resolution_level,
"video_out": str(vid_out.relative_to(REPO_ROOT)),
}
(out_dir / "stats.json").write_text(json.dumps(stats, indent=2))
return stats
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--manifest", type=Path,
default=REPO_ROOT / "outputs/datagen/_publish_staging/dataset_manifest.json")
ap.add_argument("--uuid", nargs="*", default=None,
help="explicit uuid(s) or 'uuid/serial'; default: first --limit of the manifest")
ap.add_argument("--limit", type=int, default=5)
ap.add_argument("--out-root", type=Path, default=REPO_ROOT / "outputs/moge/published_clips")
ap.add_argument("--model", default="Ruicheng/moge-2-vitl-normal")
ap.add_argument("--resolution-level", type=int, default=9,
help="9 = default = top of num_tokens_range (3600), the finest MoGe-2 offers")
ap.add_argument("--no-npz", action="store_true", help="video only, skip the per-frame arrays")
ap.add_argument("--skip-existing", action=argparse.BooleanOptionalAction, default=True,
help="skip clips that already have stats.json + video (default: on)")
ap.add_argument("--fps", type=float, default=TRAJECTORY_FPS,
help="playback fps of the panel video; NOT the mp4 container's 60 (see TRAJECTORY_FPS)")
args = ap.parse_args()
clips = select_clips(args.manifest, args.limit, args.uuid)
print(f"{len(clips)} clip(s) selected:")
for c in clips:
print(f" {c['uuid']} / {c['camera_serial']} ({c.get('camera_role')})")
from moge.model.v2 import MoGeModel
device = torch.device("cuda")
print(f"\nloading {args.model} ...")
model = MoGeModel.from_pretrained(args.model).to(device).eval()
all_stats = []
for ep in clips:
try:
all_stats.append(run_clip(model, ep, args.out_root, device,
save_npz=not args.no_npz,
resolution_level=args.resolution_level,
out_fps=args.fps,
skip_existing=args.skip_existing))
except Exception as exc: # keep going: one bad clip must not lose the batch
print(f" FAILED {ep['uuid']}/{ep['camera_serial']}: {type(exc).__name__}: {exc}")
all_stats.append({"uuid": ep["uuid"], "camera_serial": ep["camera_serial"],
"error": f"{type(exc).__name__}: {exc}"})
args.out_root.mkdir(parents=True, exist_ok=True)
summary = args.out_root / "summary.json"
summary.write_text(json.dumps({"model": args.model,
"resolution_level": args.resolution_level,
"clips": all_stats}, indent=2))
print(f"\nwrote {summary}")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
13.3 kB
·
Xet hash:
6186be1bd0712043964b2134eb8fe836f4d5675988c227c536f46c51306a544d

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