twanghcmut's picture
download
raw
11 kB
#!/usr/bin/env python
"""Stage 1 of the appearance-pair pipeline: MoGe depth for the occlusion test.
This is deliberately the *smallest* thing stage 2 cannot compute for itself, and
every choice below is a measured one on an H200 at 1280x720:
* **uint8 -> GPU -> float32**, not ``torch.tensor(rgb / 255.0)``. That innocent
division promotes a uint8 frame to a **float64** array -- 22 MB per frame,
built and copied on the CPU -- before torch ever sees it. Removing it takes
the model from 13.5 to **24.1 fps**, a 1.8x speedup for a one-line change and
no change in output.
* **Batched inference** (``--batch 4``): 24.1 -> **31.4 fps**. Beyond 4 the
curve is flat (32.1 at batch 8), so 4 is where the VRAM stops buying speed.
* **Cover-cropped to the training resolution first.** MoGe itself does not care
-- it resizes to a token budget internally, so 832x480 scores 31.5 fps against
31.4 at 1280x720 -- but everything downstream of it does: stage 2's composite
runs 46 fps at 720p against 159 at 832x480. Cropping here means the crop is
applied exactly once, by one piece of code, to the frame that both stages and
the trainer see.
* **fp16 depth at half the output resolution**, and nothing else. This is not
the ``moge_run_*`` cache: those write a 5.6 MB/frame ``.npz`` (depth f32 +
mask + normal + intrinsics), and that compression -- not the network -- is
what caps them at 2.45 fps. The only consumer here is a depth *comparison*
against a URDF render with a 150 mm tolerance, which 416x240 fp16 serves at
200 KB/frame.
Decoding, at 61-74 fps, is now the co-bottleneck with the model. That is why
stage 1 and stage 2 stay separate processes rather than merging: run
sequentially in one process the whole chain would be ~14 fps, whereas two
processes overlap decode/GPU/encode and both settle near 21 fps.
MoGe's own estimated intrinsics are recorded but NOT used downstream -- stage 2
uses the camera's real calibration (``configs/droid_camera_intrinsics.json``),
because the extrinsics in ``cameras.json`` were optimised against that. MoGe's
estimate is kept as a cheap independent check: on the RAIL demo clip it lands at
fx/w 0.4151 against the true 0.4107.
Env: ``moge``. Usage:
CUDA_VISIBLE_DEVICES=1 /home/quang/miniconda3/envs/moge/bin/python -u \\
scripts/appearance_depth.py --work <dir> [--shard 0 3]
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import cv2
import numpy as np
import torch
REPO_ROOT = Path(__file__).resolve().parents[1]
#: VACE's training resolution. Both stages and the trainer work here.
DEFAULT_RESOLUTION = (832, 480)
#: Depth is stored at half the output resolution. The occlusion test tolerates a
#: 150 mm margin, so a depth pixel covering a 2x2 output block costs nothing it
#: can measure, and it turns 800 KB/frame of intermediate into 200 KB.
_DEPTH_SCALE = 0.5
def cover_crop_params(in_w: int, in_h: int, out_w: int, out_h: int):
"""``(resized_w, resized_h, off_x, off_y)`` for a scale-to-cover + centre-crop.
Same arithmetic as ``fpgm.datagen.export_vace._CoverCrop``, kept here so
stage 1 (which runs in the ``moge`` env, without ``fpgm`` importable) and
stage 2 cannot drift apart on the framing.
"""
scale = max(out_w / in_w, out_h / in_h)
rw, rh = int(round(in_w * scale)), int(round(in_h * scale))
return rw, rh, (rw - out_w) / 2.0, (rh - out_h) / 2.0
def cover_crop(image: np.ndarray, out_w: int, out_h: int) -> np.ndarray:
in_h, in_w = image.shape[:2]
if (in_w, in_h) == (out_w, out_h):
return image
rw, rh, ox, oy = cover_crop_params(in_w, in_h, out_w, out_h)
interp = cv2.INTER_AREA if rw < in_w else cv2.INTER_LINEAR
resized = cv2.resize(image, (rw, rh), interpolation=interp)
x0, y0 = int(round(ox)), int(round(oy))
return resized[y0:y0 + out_h, x0:x0 + out_w]
def episode_jobs(episodes_root: Path, uuids: list[str] | None) -> list[tuple[str, str, Path]]:
"""``(uuid, camera_serial, mp4)`` for every exterior camera of every episode.
Both exterior cameras are used: two genuinely different viewpoints of the
same trajectory, so an episode yields two training clips at no extra download
cost. The wrist camera is excluded -- the robot fills the frame and the URDF
render has nothing to composite against.
"""
jobs: list[tuple[str, str, Path]] = []
for ep in sorted(p for p in episodes_root.iterdir() if p.is_dir()):
if uuids and ep.name not in uuids:
continue
meta_path = ep / "metadata.json"
if not meta_path.exists():
continue
meta = json.loads(meta_path.read_text())
for key in ("ext1_cam_serial", "ext2_cam_serial"):
serial = meta.get(key)
if not serial:
continue
mp4 = ep / "recordings/MP4" / f"{serial}.mp4"
if mp4.exists():
jobs.append((ep.name, str(serial), mp4))
return jobs
def run_clip(model, uuid: str, serial: str, mp4: Path, work: Path,
device: torch.device, resolution: tuple[int, int],
resolution_level: int, batch: int) -> dict:
stem = work / f"{uuid}__{serial}"
meta_path = stem.with_suffix(".depth.json")
if meta_path.exists() and stem.with_suffix(".depth.npy").exists():
return json.loads(meta_path.read_text())
out_w, out_h = resolution
cap = cv2.VideoCapture(str(mp4))
src_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
src_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
dh, dw = int(out_h * _DEPTH_SCALE), int(out_w * _DEPTH_SCALE)
buf = np.empty((n, dh, dw), np.float16)
fx_over_w: list[float] = []
t = 0
t0 = time.time()
pending: list[np.ndarray] = []
def flush() -> None:
"""Run one batch and store its depth. Empties ``pending``."""
nonlocal t
if not pending:
return
# uint8 -> GPU -> float32 in one hop. Going through `rgb / 255.0` first
# would materialise a float64 array per frame on the CPU (1.8x slower).
arr = torch.from_numpy(np.stack(pending)).to(device, non_blocking=True)
arr = arr.permute(0, 3, 1, 2).float().div_(255.0)
out = model.infer(arr, resolution_level=resolution_level)
depth = out["depth"].float().cpu().numpy()
mask = out["mask"].cpu().numpy().astype(bool)
k = out["intrinsics"].float().cpu().numpy()
for i in range(len(pending)):
# Invalid depth becomes +inf, not NaN: stage 2 asks "is the scene in
# front of the robot", and inf is the honest answer for "unknown"
# (it never occludes) whereas NaN would need a separate mask array.
d = np.where(mask[i], depth[i], np.inf)
buf[t] = cv2.resize(d, (dw, dh), interpolation=cv2.INTER_NEAREST)
fx_over_w.append(float(k[i, 0, 0]))
t += 1
pending.clear()
with torch.inference_mode():
while t + len(pending) < n:
ok, frame_bgr = cap.read()
if not ok:
break
pending.append(cover_crop(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB),
out_w, out_h))
if len(pending) == batch:
flush()
flush()
cap.release()
if t == 0:
raise RuntimeError(f"{uuid}/{serial}: decoded 0 frames from {mp4}")
dt = time.time() - t0
np.save(stem.with_suffix(".depth.npy"), buf[:t])
meta = {"uuid": uuid, "camera_serial": serial, "mp4": str(mp4.relative_to(REPO_ROOT)),
"n_frames": t, "source_wh": [src_w, src_h], "video_wh": [out_w, out_h],
"depth_wh": [dw, dh],
"moge_fx_over_w_median": round(float(np.median(fx_over_w)), 5),
"seconds": round(dt, 1), "fps": round(t / dt, 2)}
meta_path.write_text(json.dumps(meta))
print(f" {uuid}/{serial}: {t} frames {dt:.1f}s ({t/dt:.2f} fps) "
f"moge fx/w={meta['moge_fx_over_w_median']:.4f}", flush=True)
return meta
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--episodes-root", type=Path, default=REPO_ROOT / "data/droid_raw")
ap.add_argument("--work", type=Path, required=True, help="scratch dir for depth npy")
ap.add_argument("--model", default="Ruicheng/moge-2-vitl-normal")
ap.add_argument("--resolution", type=int, nargs=2, default=DEFAULT_RESOLUTION,
metavar=("W", "H"))
ap.add_argument("--resolution-level", type=int, default=9)
ap.add_argument("--batch", type=int, default=4,
help="frames per forward pass; 4 captures the whole speedup "
"(31.4 fps vs 32.1 at 8, against 24.1 at 1)")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--uuid", nargs="*", default=None)
ap.add_argument("--uuid-file", type=Path, default=None,
help="newline-separated uuids, e.g. from sample_appearance_episodes.py")
ap.add_argument("--shard", type=int, nargs=2, default=(0, 1), metavar=("I", "N"),
help="process clips i, i+N, i+2N... Round-robin, so long clips "
"spread evenly when running one shard per GPU.")
ap.add_argument("--max-pending", type=int, default=16,
help="pause while this many un-consumed depth files are queued, "
"so the scratch dir cannot outrun stage 2 and fill the disk")
args = ap.parse_args()
uuids = list(args.uuid or [])
if args.uuid_file:
uuids += [u for u in args.uuid_file.read_text().split() if u]
jobs = episode_jobs(args.episodes_root, uuids or None)
i, nsh = args.shard
jobs = jobs[i::nsh]
if args.limit:
jobs = jobs[: args.limit]
args.work.mkdir(parents=True, exist_ok=True)
print(f"shard {i}/{nsh}: {len(jobs)} clips from {args.episodes_root} "
f"@ {args.resolution[0]}x{args.resolution[1]} batch={args.batch}", flush=True)
from moge.model.v2 import MoGeModel
device = torch.device("cuda")
model = MoGeModel.from_pretrained(args.model).to(device).eval()
for j, (uuid, serial, mp4) in enumerate(jobs):
while len(list(args.work.glob("*.depth.npy"))) >= args.max_pending:
time.sleep(5)
try:
run_clip(model, uuid, serial, mp4, args.work, device,
tuple(args.resolution), args.resolution_level, args.batch)
except Exception as exc: # one bad clip must not end a multi-hour run
print(f" FAILED {uuid}/{serial}: {type(exc).__name__}: {exc}", flush=True)
if (j + 1) % 20 == 0:
print(f"[{j+1}/{len(jobs)}]", flush=True)
(args.work / f"STAGE1_DONE_{i}of{nsh}").write_text("")
print("stage 1 done", flush=True)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
11 kB
·
Xet hash:
130e9539b9b2550533f001e681d0e6294c36b6aa00ca74eacc6c92ae65ac5f3b

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