twanghcmut/backup-foundation-physics / scripts /render_robot_over_moge.py
twanghcmut's picture
download
raw
10.3 kB
#!/usr/bin/env python
"""VACE control = MoGe point cloud (env + objects) + URDF-rendered Franka (robot).
The split is deliberate and follows a measured failure. Feeding a MoGe-only control
(``export_moge_pointcloud_control.py``) into VACE-1.3B preserved the whole scene --
bench, towel, plate, pot, marker, all photoreal and in place -- but **replaced the
robot with a yellow toy arm** from about frame 50. Same family as this project's
earlier finding that a shaded-grey robot probe drew a *blue* toy arm: the control
carries no evidence of the robot's material, so the model invents one. MoGe cannot
supply that evidence -- it does not know a Franka is white and a Robotiq is black.
The URDF does, via its COLLADA visuals. So: geometry and appearance of everything
that is not the robot come from MoGe; the robot comes from the URDF render.
**No SAM3 anywhere.** The datagen S2 stage runs SAM3 only to *verify* the extrinsics
choice against a text-prompted mask, and on this project's own demo episode that gate
FAILED and marked every buffer UNVERIFIED anyway. This script skips it and uses
PointWorld's optimised extrinsics directly (for this episode: ``optimization_success:
true``, final loss 0.0625 on serial 20521388).
**Scale alignment, and why the robot is the right anchor.** MoGe is run without
``fov_x``, so it estimates its own FOV and its own metric scale per frame (drift CV
~9.6% on this project's demo episode) -- its depth is NOT in the same metric frame as
the URDF render's. A per-frame robust affine fit ``a*moge + b -> robot_depth`` is
solved on exactly the pixels the URDF render covers, then applied to the whole frame.
The robot is the only object that appears, metrically known, in both sources, which
makes it the one available anchor. The fit's residual is reported per frame: if it is
large, the composite's occlusion reasoning is not to be trusted and the number says so
rather than the failure being silent.
Usage:
PYTHONPATH=src /home/quang/miniconda3/envs/fpgm/bin/python scripts/render_robot_over_moge.py \\
--uuid RAIL+80edfcb1+2023-04-26-15h-45m-39s --camera-serial 20521388 \\
--moge-dir outputs/moge/sample_bucket/RAIL+80edfcb1+2023-04-26-15h-45m-39s \\
--out outputs/zeroshot_moge/RAIL+80edfcb1+2023-04-26-15h-45m-39s_urdf
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
import cv2
import h5py
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
_FFMPEG = Path("/home/quang/miniconda3/envs/ffmpeg_libs/bin/ffmpeg")
_ERODE = np.ones((9, 9), np.uint8)
def write_h264(frames_bgr, out: Path, fps: float, crf: int = 14) -> None:
it = iter(frames_bgr)
first = next(it)
h, w = first.shape[:2]
out.parent.mkdir(parents=True, exist_ok=True)
p = subprocess.Popen(
[str(_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)],
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 robust_scale(src: np.ndarray, dst: np.ndarray) -> tuple[float, float]:
"""Median of ``dst/src`` -- a SCALE-ONLY fit. Returns ``(a, residual_m)``.
A scale+shift (affine) fit was tried first and is wrong here, measured: on the
robot mask alone the depth range is narrow, so ``a`` and ``b`` trade off freely
and ``a`` wandered between 0.037 and 1.08 across frames (CV 0.84) -- the fit was
ill-conditioned, not the extrinsics. Dropping the shift makes it stable at
CV 0.017. That is also the physically right model: MoGe-2 predicts *metric*
depth (unlike affine-invariant MiDaS-family models), so the only free parameter
its per-frame FOV guess can introduce is a scale.
"""
r = dst / np.maximum(src, 1e-6)
a = float(np.median(r))
return a, float(np.median(np.abs(a * src - dst)))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--uuid", required=True)
ap.add_argument("--camera-serial", required=True)
ap.add_argument("--moge-dir", type=Path, required=True)
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--config", type=Path, default=REPO_ROOT / "configs/datagen_droid.yaml")
ap.add_argument("--fps", type=float, default=15.0)
ap.add_argument("--depth-tol-m", type=float, default=0.02,
help="robot wins where robot_depth < aligned_moge + tol")
args = ap.parse_args()
from fpgm.config_datagen import DatagenProfile
from fpgm.datagen.frame_index import EpisodeFrameIndex
from fpgm.data.pointworld import FlowsReader
from fpgm.datagen.robot_buffers import (
load_extrinsics_candidates, read_native_camera_intrinsics,
)
from fpgm.geometry.camera import Camera
from fpgm.robot.render import RobotRenderer
from fpgm.robot.urdf import RobotModel
from fpgm.viz.overlays import read_frames_bgr
profile = DatagenProfile.from_yaml(str(args.config))
traj = profile.paths.droid_episode_dir(args.uuid) / "trajectory.h5" \
if hasattr(profile.paths, "droid_episode_dir") else \
REPO_ROOT / "data/droid_raw" / args.uuid / "trajectory.h5"
mp4 = next((REPO_ROOT / "data/droid_raw" / args.uuid / "recordings/MP4").glob(
f"{args.camera_serial}.mp4"))
cap = cv2.VideoCapture(str(mp4))
video_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
video_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
n_video_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
mp4_fps = float(cap.get(cv2.CAP_PROP_FPS))
cap.release()
flows = profile.paths.flows_h5(args.uuid)
with FlowsReader(flows, episode_uuid=args.uuid) as reader:
frame_index = EpisodeFrameIndex.build(
args.uuid, reader, traj,
mp4_properties=(mp4_fps, n_video_frames, (video_w, video_h)),
camera_serial=args.camera_serial)
intr = read_native_camera_intrinsics(flows, args.camera_serial).scaled(video_w, video_h)
cands = load_extrinsics_candidates(
profile.paths.cameras_json(args.uuid), traj, args.camera_serial)
chosen = next(c for c in cands if c.name == "optimized_cameras_json")
camera = Camera(intr, chosen.world_to_cam)
print(f"extrinsics: {chosen.name} | intrinsics {intr.width}x{intr.height} "
f"fx={intr.fx:.1f} fy={intr.fy:.1f}")
robot = RobotModel(str(profile.paths.urdf), load_meshes=True)
with h5py.File(traj, "r") as f:
from fpgm.datagen.robot_buffers import (
TRAJECTORY_GRIPPER_POSITION_KEY, TRAJECTORY_JOINT_POSITIONS_KEY,
)
joint_positions = np.asarray(f[TRAJECTORY_JOINT_POSITIONS_KEY])
gripper = np.asarray(f[TRAJECTORY_GRIPPER_POSITION_KEY])
renderer = RobotRenderer(robot.visual_meshes(), intr.width, intr.height)
npz_files = sorted((args.moge_dir / "per_frame").glob("frame_*.npz"))
n = min(n_video_frames, len(npz_files))
print(f"{args.uuid}: {n} frames, {video_w}x{video_h}")
args.out.mkdir(parents=True, exist_ok=True)
stats = {"scale_a": [], "resid_m": [], "robot_px": []}
last_a = [1.0]
def frames():
for t, frame_bgr in enumerate(read_frames_bgr(mp4)):
if t >= n:
break
row = frame_index.trajectory_row(t)
rr = renderer.render(
robot.link_poses(joint_positions[row], float(gripper[row])), camera)
d = np.load(npz_files[t])
moge = d["depth"].astype(np.float32)
moge = np.where(d["mask"].astype(bool), moge, np.nan)
# Erode the mask before fitting: silhouette pixels have the render's
# depth on the robot but MoGe's on whatever is behind it, and they are
# pure outliers to a depth ratio.
core = cv2.erode(rr.mask.astype(np.uint8), _ERODE).astype(bool)
fit_px = core & np.isfinite(moge) & (rr.depth > 0)
if fit_px.sum() >= 200:
a, resid = robust_scale(moge[fit_px], rr.depth[fit_px])
last_a[0] = a
else:
a, resid = last_a[0], float("nan") # hold the last good scale
stats["scale_a"].append(a)
stats["resid_m"].append(resid)
stats["robot_px"].append(int(rr.mask.sum()))
moge_aligned = a * moge
# Robot wins only where it is actually in front of the aligned MoGe
# surface -- so an arm passing behind a foreground object stays occluded
# instead of being pasted over it.
win = rr.mask & (rr.depth > 0) & (
~np.isfinite(moge_aligned) | (rr.depth < moge_aligned + args.depth_tol_m))
out = frame_bgr.copy()
out[win] = rr.color[win][:, ::-1] # RenderResult.color is RGB
yield out
out_video = args.out / "control_moge_urdf.mp4"
write_h264(frames(), out_video, args.fps)
renderer.close()
ref = args.out / "ref_frame0.png"
cap = cv2.VideoCapture(str(mp4))
ok, f0 = cap.read()
cap.release()
cv2.imwrite(str(ref), f0)
a = np.array(stats["scale_a"]); r = np.array(stats["resid_m"])
meta = {"uuid": args.uuid, "camera_serial": args.camera_serial,
"extrinsics": chosen.name, "n_frames": n,
"scale_a_median": round(float(np.median(a)), 4),
"scale_a_p10_p90": [round(float(np.percentile(a, 10)), 4),
round(float(np.percentile(a, 90)), 4)],
"align_residual_m_median": round(float(np.nanmedian(r)), 4),
"robot_px_median": int(np.median(stats["robot_px"])),
"control": str(out_video), "ref_image": str(ref)}
(args.out / "control_meta.json").write_text(json.dumps(meta, indent=2))
print(json.dumps(meta, indent=2))
if __name__ == "__main__":
main()

Xet Storage Details

Size:
10.3 kB
·
Xet hash:
6a11534e98952960b92d01f434f600ba1c02fb12798a4c2871fe35538e815196

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