twanghcmut's picture
download
raw
10.3 kB
#!/usr/bin/env python
"""Measure a real push from the demo: hand velocity in, object displacement out.
This is the calibration half of the counterfactual-push demo. It answers, from
the recorded episode alone: *when the arm pushed the drawer at speed v, how far
did the drawer actually slide?* -- which is the only honest way to fix the
transfer coefficient that `render_counterfactual_push.py` then extrapolates to
push speeds that were never recorded.
Why the displacement is measured in 2D and lifted, rather than read from depth:
PointWorld ships dense depth for exactly one frame per clip (`initial_depth`),
and its clips for this episode stop well before the drawer push happens. So the
drawer's *initial* 3D pose comes from depth (frame 0, where it is available),
and its *motion* comes from SAM 3.1 tracking the drawer's 2D mask across the
push, lifted back to metres by solving for displacement along a single
slide axis:
u_t = project(X_0 + s_t * axis) solve each s_t (metres) in 1D
That one-axis constraint is what makes a 2D measurement metric: a drawer is a
prismatic joint, so its motion has exactly one degree of freedom, and the axis
is recovered from the drawer's own frame-0 point cloud (PCA -- a drawer front
is a thin plate, so its thinnest direction is the slide direction).
Usage:
PYTHONPATH=src python scripts/measure_push_demo.py \\
--episode <uuid> --camera ext1 --mask-outputs outputs/<uuid>/objects_shelf2/0_11 \\
--traj-start 90 --traj-end 126 --out outputs/push_calibration.json
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import sys
from pathlib import Path
import h5py
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
from fpgm.data.droid_raw import cam2world_vector_to_world2cam # noqa: E402
from fpgm.geometry.camera import Camera # noqa: E402
from fpgm.pipeline.frames import ClipFrameSource # noqa: E402
from fpgm.robot.urdf import RobotModel # noqa: E402
from fpgm.utils.logging import get_logger, setup_logging # noqa: E402
_spec = importlib.util.spec_from_file_location(
"run_object_pipeline", REPO_ROOT / "scripts" / "run_object_pipeline.py"
)
rop = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = rop
_spec.loader.exec_module(rop)
logger = get_logger("measure_push_demo")
#: DROID's `trajectory.h5` rows are logged at 15 Hz.
_TRAJ_FPS = 15.0
#: Video frames and trajectory rows are 1:1 for this episode (127 video frames
#: vs 128 trajectory rows). PointWorld's *annotation* clock is the half-rate one
#: (its `ClipTiming.annotation_stride == 2`, i.e. clip index 50 -> video frame
#: 100) -- that stride applies to clip keys, never to trajectory rows.
_VIDEO_STRIDE = 1
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--episode", required=True)
p.add_argument("--camera", default="ext1", choices=["ext1", "ext2"])
p.add_argument("--mask-outputs", type=Path, required=True,
help="stage root whose 1_mask/ holds the object's frame-0 mask (from mask_object_manual.py)")
p.add_argument("--clip", default="0:11", help="clip the frame-0 mask/depth came from")
p.add_argument("--traj-start", type=int, required=True, help="first trajectory row of the push window")
p.add_argument("--traj-end", type=int, required=True, help="last trajectory row of the push window (inclusive)")
p.add_argument("--prompt", default="open drawer", help="SAM 3.1 prompt used to re-find the object in the push window")
p.add_argument("--device", default="cuda")
p.add_argument("--out", type=Path, required=True)
return p.parse_args()
def main() -> int:
args = parse_args()
setup_logging()
from fpgm.config import SegmentationConfig
from fpgm.objects.align import object_point_cloud
from fpgm.segmentation.sam3 import Sam3VideoSegmenter
meta = json.loads((rop.stage_dir(args.mask_outputs, "mask") / "meta.json").read_text())
with np.load(rop.stage_dir(args.mask_outputs, "mask") / "mask_frame0.npz") as npz:
mask_full = npz["mask"].astype(bool)
video_w, video_h = meta["video_resolution"]
ctx = rop._load_clip_context(args.episode, args.camera, args.clip)
# --- object's frame-0 3D pose + slide axis, from depth -------------------
depth = rop._load_initial_depth(args.episode, args.clip, meta["camera_serial"])
adapter = rop._DepthRgbAdapter(depth, ctx.clip.initial_rgb)
points_world, _colors = object_point_cloud(adapter, ctx.camera_annot, mask_full, (video_h, video_w))
X0 = points_world.mean(axis=0)
logger.info("object frame-0 centroid=%s (n=%d points)", X0.round(3), points_world.shape[0])
# --- robot hand kinematics over the push window -------------------------
robot = RobotModel(str(rop._DEFAULT_URDF), load_meshes=False)
with h5py.File(rop._load_trajectory_h5(args.episode), "r") as f:
J = np.asarray(f["observation/robot_state/joint_positions"])
G = np.asarray(f["observation/robot_state/gripper_position"])
extrinsics_6d = np.asarray(f[f"observation/camera_extrinsics/{meta['camera_serial']}_left"])
rows = np.arange(args.traj_start, args.traj_end + 1)
hand = np.array([
(robot.link_poses(J[t], float(G[t]))["left_inner_finger"][:3, 3]
+ robot.link_poses(J[t], float(G[t]))["right_inner_finger"][:3, 3]) / 2.0
for t in rows
])
hand_vel = np.gradient(hand, 1.0 / _TRAJ_FPS, axis=0)
hand_speed = np.linalg.norm(hand_vel, axis=1)
# --- track the object across the push window ----------------------------
video_start = int(rows[0]) * _VIDEO_STRIDE
video_end = (int(rows[-1]) + 1) * _VIDEO_STRIDE
seg_cfg = SegmentationConfig()
segmenter = Sam3VideoSegmenter(seg_cfg, device=args.device)
with ClipFrameSource(str(ctx.mp4_path), video_start, video_end, stride=_VIDEO_STRIDE) as frames:
segmenter.start_session(str(frames.frame_dir))
try:
segmenter.add_text_prompt(0, args.prompt)
found = segmenter.collect_masklets()
if not found:
raise SystemExit(f"SAM 3.1 found nothing for {args.prompt!r} in the push window")
obj_id = segmenter.select_object(found)
masklet = found[obj_id]
finally:
segmenter.close_session()
# --- lift 2D centroid motion to metres -----------------------------------
# Solved as a horizontal (dx, dy) displacement with dz fixed to 0, rather
# than along an assumed slide axis: a drawer runs level, so constraining
# dz=0 is the physical fact, whereas the in-plane *direction* is exactly
# what the measurement should recover rather than presuppose. Two unknowns
# from two observations (u, v) per frame -- exactly determined.
grid = np.arange(-0.40, 0.40 + 1e-9, 0.005)
dx, dy = np.meshgrid(grid, grid, indexing="ij")
offsets = np.stack([dx.ravel(), dy.ravel(), np.zeros(dx.size)], axis=1)
disp_xy: list[np.ndarray] = []
valid_rows: list[int] = []
residuals: list[float] = []
for i, row in enumerate(rows):
m = masklet.frames.get(i)
if m is None or not m.any():
continue
ys, xs = np.nonzero(m)
uv_obs = np.array([xs.mean(), ys.mean()])
camera = Camera(
ctx.camera_annot.rescaled(video_w, video_h).K,
cam2world_vector_to_world2cam(extrinsics_6d[row]),
)
uv_cand, depth_cand = camera.project(X0[None, :] + offsets)
ok = depth_cand > 0
if not ok.any():
continue
err = np.full(offsets.shape[0], np.inf)
err[ok] = np.linalg.norm(uv_cand[ok] - uv_obs[None, :], axis=1)
best = int(np.argmin(err))
disp_xy.append(offsets[best, :2].copy())
residuals.append(float(err[best]))
valid_rows.append(int(row))
if len(disp_xy) < 3:
raise SystemExit(f"only {len(disp_xy)} trackable frames -- cannot measure a push")
disp_xy = np.asarray(disp_xy)
disp_xy = disp_xy - disp_xy[0] # relative to the window's first tracked frame
vr = np.asarray(valid_rows)
# Slide direction = principal direction of the measured horizontal motion.
net_vec = disp_xy[-1]
axis2 = net_vec / (np.linalg.norm(net_vec) + 1e-12)
axis = np.array([axis2[0], axis2[1], 0.0])
disp = disp_xy @ axis2 # signed travel along the recovered slide direction
idx_hand_all = np.searchsorted(rows, vr)
hand_along = (hand - hand[0]) @ axis
hand_vel_along = hand_vel @ axis
# --- summarise the demo push --------------------------------------------
total_travel = float(disp[-1] - disp.min()) if abs(disp[-1]) < abs(disp.min()) else float(disp[-1])
net_disp = float(disp[-1])
peak_disp = float(disp[np.argmax(np.abs(disp))])
# Contact = the hand's fastest motion along the slide axis while the drawer
# is actually moving; that is the "push speed" the counterfactual scales.
idx_hand = np.searchsorted(rows, vr)
push_speed = float(np.max(np.abs(hand_vel_along[idx_hand])))
drawer_speed = float(np.max(np.abs(np.gradient(disp, 1.0 / _TRAJ_FPS))))
result = {
"episode": args.episode,
"prompt": args.prompt,
"traj_window": [int(args.traj_start), int(args.traj_end)],
"object_centroid_m": X0.tolist(),
"slide_axis": axis.tolist(),
"n_tracked_frames": int(len(disp)),
"tracked_rows": vr.tolist(),
"displacement_series_m": disp.tolist(),
"displacement_xy_m": disp_xy.tolist(),
"reprojection_residual_px": residuals,
"hand_along_axis_m": hand_along[idx_hand].tolist(),
"hand_speed_max_mps": float(np.max(hand_speed)),
"push_speed_along_axis_mps": push_speed,
"drawer_peak_speed_mps": drawer_speed,
"drawer_net_displacement_m": net_disp,
"drawer_peak_displacement_m": peak_disp,
"total_travel_m": total_travel,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, indent=2))
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
10.3 kB
·
Xet hash:
9014a45ed0f4c19bb768385e869094e2aa105355d5527cb2593335ba06ebdedd

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