twanghcmut/backup-foundation-physics / scripts /measure_push_tracked.py
twanghcmut's picture
download
raw
10.9 kB
#!/usr/bin/env python
"""Measure a real push using TAPNext++ point tracks (not a mask centroid).
Supersedes `scripts/measure_push_demo.py`'s centroid method, which failed on
this episode for a concrete, measured reason: the arm occludes part of the
drawer while pushing it, so the visible mask's centroid drifts with the
*occlusion pattern* rather than with the drawer. That produced a ~40-100 px
systematic reprojection residual (~4-11 cm at this scene's ~9 px/cm) and a
displacement profile that rose and then returned to zero -- physically
impossible for a drawer being closed. Occlusion-robust point tracks fix it.
Method:
1. Seed query points inside the drawer's mask on the depth frame (frame 0),
where each point's metric 3D position is directly available.
2. Track them with TAPNext++ across the whole window up to and through the
push. Points the arm covers are reported not-visible and simply drop out
of that frame's fit rather than corrupting it.
3. Per frame, solve one rigid horizontal translation (dx, dy) shared by all
visible points, by least squares over their reprojections. A drawer runs
level, so dz = 0 is a physical constraint, not an approximation; using
many points instead of one centroid makes the fit over-determined and lets
the residual actually report whether the rigid-slide model holds.
Usage:
PYTHONPATH=src python scripts/measure_push_tracked.py \\
--episode <uuid> --mask-outputs outputs/<uuid>/objects_shelf2/0_11 \\
--traj-end 124 --out outputs/push_calibration_tracked.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.config import PipelineConfig # noqa: E402
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.tracking.sampling import sample_points_in_mask # noqa: E402
from fpgm.tracking.tapnext import TapNextPointTracker # 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_tracked")
_TRAJ_FPS = 15.0
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)
p.add_argument("--clip", default="0:11")
p.add_argument("--traj-end", type=int, default=124)
p.add_argument("--n-points", type=int, default=96)
p.add_argument("--device", default="cuda")
p.add_argument("--out", type=Path, required=True)
return p.parse_args()
def _lift_points_to_3d(mask, uv, adapter, camera_annot, frame_shape):
"""Metric 3D position for each query pixel, from the depth frame."""
depth_m = adapter.initial_depth.astype(np.float64) / 1000.0
dh, dw = depth_m.shape
fh, fw = frame_shape
# Query points are in video pixels; depth is at annotation resolution.
su, sv = dw / fw, dh / fh
du = np.clip(np.round(uv[:, 0] * su).astype(int), 0, dw - 1)
dv = np.clip(np.round(uv[:, 1] * sv).astype(int), 0, dh - 1)
z = depth_m[dv, du]
ok = z > 0
cam = camera_annot
uv_annot = np.stack([uv[:, 0] * su, uv[:, 1] * sv], axis=1)
pts = cam.unproject(uv_annot[ok], z[ok])
return pts, ok
def main() -> int:
args = parse_args()
setup_logging()
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 = npz["mask"].astype(bool)
video_w, video_h = meta["video_resolution"]
ctx = rop._load_clip_context(args.episode, args.camera, args.clip)
depth = rop._load_initial_depth(args.episode, args.clip, meta["camera_serial"])
adapter = rop._DepthRgbAdapter(depth, ctx.clip.initial_rgb)
uv_seed = sample_points_in_mask(mask, args.n_points, method="farthest", boundary_erosion_px=6)
X_seed, ok = _lift_points_to_3d(mask, uv_seed, adapter, ctx.camera_annot, (video_h, video_w))
uv_seed = uv_seed[ok]
logger.info("seeded %d points with valid depth (of %d sampled)", X_seed.shape[0], ok.size)
if X_seed.shape[0] < 8:
raise SystemExit("too few query points survived the depth check")
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"])
n_frames = args.traj_end + 1
cfg = PipelineConfig()
tracker = TapNextPointTracker(
cfg.tracking, checkpoint_path=str(cfg.paths.checkpoints_dir / cfg.tracking.checkpoint),
device=args.device,
)
with ClipFrameSource(str(ctx.mp4_path), 0, n_frames, stride=1) as frames:
rgb = (f[:, :, ::-1] for f in (frames.read(i) for i in range(frames.n_frames)))
track = tracker.track(rgb, uv_seed.astype(np.float32), query_frame_idx=0)
uv_t = np.asarray(track.uv) # (T, Q, 2)
vis_t = np.asarray(track.visible) # (T, Q)
n_t = uv_t.shape[0]
logger.info("tracked %d frames x %d points", n_t, uv_t.shape[1])
K = ctx.camera_annot.rescaled(video_w, video_h).K
grid = np.arange(-0.30, 0.30 + 1e-9, 0.0025)
dx, dy = np.meshgrid(grid, grid, indexing="ij")
offsets = np.stack([dx.ravel(), dy.ravel(), np.zeros(dx.size)], axis=1)
# --- keep only points that actually ride the drawer ----------------------
# The SAM mask of an *open* drawer necessarily also covers its contents and
# slivers of the static cabinet behind it. Those points do not translate
# with the drawer, so including them biases the shared-translation fit and
# is exactly what drives the reprojection residual up as the drawer moves.
# Each point is given its own 2-DOF solve on the last frame (2 unknowns, 2
# observations -- exactly determined); the drawer's points agree on one
# displacement while static ones cluster at zero, so the majority cluster
# around the median identifies the drawer.
last = n_t - 1
cam_last = Camera(K, cam2world_vector_to_world2cam(extrinsics_6d[last]))
per_point = np.full((X_seed.shape[0], 2), np.nan)
vis_last = vis_t[last] & np.isfinite(uv_t[last]).all(axis=1)
for q in np.nonzero(vis_last)[0]:
cand = X_seed[q][None, :] + offsets
uvp, dep = cam_last.project(cand)
err = np.linalg.norm(uvp - uv_t[last][q][None, :], axis=1)
err[dep <= 0] = 1e6
per_point[q] = offsets[int(np.argmin(err)), :2]
finite = np.isfinite(per_point[:, 0])
med = np.median(per_point[finite], axis=0)
dev = np.linalg.norm(per_point - med[None, :], axis=1)
mad = np.median(dev[finite])
inlier = finite & (dev <= max(0.02, 2.0 * mad))
logger.info(
"drawer-point selection: %d/%d inliers (median displacement %s m, MAD %.3f m)",
int(inlier.sum()), int(finite.sum()), med.round(3), float(mad),
)
if inlier.sum() < 8:
raise SystemExit("too few consistent drawer points -- rigid-slide model does not hold")
X_seed = X_seed[inlier]
uv_t = uv_t[:, inlier, :]
vis_t = vis_t[:, inlier]
disp_xy = np.full((n_t, 2), np.nan)
resid = np.full(n_t, np.nan)
n_vis = np.zeros(n_t, dtype=int)
for t in range(n_t):
vis = vis_t[t] & np.isfinite(uv_t[t]).all(axis=1)
n_vis[t] = int(vis.sum())
if n_vis[t] < 8:
continue
camera = Camera(K, cam2world_vector_to_world2cam(extrinsics_6d[t]))
# Shared rigid horizontal translation: evaluate all candidate offsets
# against every visible point at once and keep the least-squares best.
best_err, best_off = np.inf, None
Xv = X_seed[vis]
obs = uv_t[t][vis]
for chunk in np.array_split(offsets, 8):
cand = (Xv[None, :, :] + chunk[:, None, :]).reshape(-1, 3)
uvp, dep = camera.project(cand)
uvp = uvp.reshape(chunk.shape[0], Xv.shape[0], 2)
dep = dep.reshape(chunk.shape[0], Xv.shape[0])
err = np.linalg.norm(uvp - obs[None, :, :], axis=2)
err[dep <= 0] = 1e6
rms = np.sqrt((err ** 2).mean(axis=1))
i = int(np.argmin(rms))
if rms[i] < best_err:
best_err, best_off = float(rms[i]), chunk[i, :2].copy()
disp_xy[t] = best_off
resid[t] = best_err
valid = np.isfinite(disp_xy[:, 0])
ref = np.nanmedian(disp_xy[:20][np.isfinite(disp_xy[:20, 0])], axis=0)
disp_xy = disp_xy - ref[None, :]
robot = RobotModel(str(rop._DEFAULT_URDF), load_meshes=False)
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 range(n_t)
])
result = {
"episode": args.episode,
# The inlier seeds' 3D positions ARE the drawer, established by the data
# rather than assumed: they are exactly the points that translated
# rigidly together throughout the push. Downstream geometry should be
# fitted to these, not to the raw SAM mask's point cloud (which also
# covers the drawer's contents and the static cabinet behind it).
"drawer_inlier_points_world": X_seed.tolist(),
"n_query_points": int(X_seed.shape[0]),
"n_frames": int(n_t),
"displacement_xy_m": np.where(np.isfinite(disp_xy), disp_xy, None).tolist(),
"residual_px": np.where(np.isfinite(resid), resid, None).tolist(),
"n_visible_per_frame": n_vis.tolist(),
"hand_xyz_m": hand.tolist(),
"traj_fps": _TRAJ_FPS,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, indent=2))
mag = np.linalg.norm(disp_xy, axis=1)
print(f"{'row':>4} {'dx':>8} {'dy':>8} {'|d|':>8} {'resid_px':>9} {'n_vis':>6}")
for t in range(80, n_t):
if not valid[t]:
continue
print(f"{t:4d} {disp_xy[t,0]:8.3f} {disp_xy[t,1]:8.3f} {mag[t]:8.3f} {resid[t]:9.1f} {n_vis[t]:6d}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
10.9 kB
·
Xet hash:
a9c38efa76114b62a917e17c3a2a67a337b11efba374216fcd05d9f0c860c683

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