Buckets:
| #!/usr/bin/env python | |
| """Resample a planner trajectory onto a different frame count / frame rate. | |
| Written for one specific downstream constraint: Cosmos-Transfer2.5-2B consumes | |
| control videos at **16 FPS** and states that lengths which are a multiple of | |
| **93 frames** perform best. Our planner emits at DROID's 15 Hz, so a | |
| pick-and-place that runs 10.27 s comes out as 155 frames -- neither the right | |
| rate nor a multiple of 93. Rather than re-plan (which would change the motion), | |
| this resamples the existing, already-verified trajectory onto the target grid. | |
| The motion is *stretched*, not cropped: all ``T_src`` source samples are mapped | |
| uniformly onto ``T_dst`` output samples, so every phase of the pick-and-place | |
| survives and only the wall-clock duration changes. For 155 -> 186 that is | |
| 10.27 s -> 11.63 s, i.e. the action plays 1.13x slower than recorded. That is | |
| the closest a 93-multiple gets to real time here; 93 frames would be 1.77x | |
| *faster*, which reads as a hurried, unnatural arm. | |
| Interpolation is deliberately linear for joints/gripper and SLERP for object | |
| rotations: | |
| * Joints come from a trapezoidal-profile plan, which has curvature | |
| discontinuities at the accel/decel corners. A cubic spline would overshoot | |
| exactly there; linear cannot. The cost is chord error, which ``--validate`` | |
| measures directly (decimate-and-restore) rather than assuming. | |
| * The gripper has a genuine step (release). Linear turns it into a one-frame | |
| ramp; a spline would ring around it. | |
| * Rotations must stay in SO(3). Element-wise interpolation of a 4x4 does not | |
| preserve orthonormality, so rotations go through SLERP and the result is | |
| re-checked, not trusted. | |
| Usage: | |
| PYTHONPATH=src python scripts/resample_trajectory.py \\ | |
| --npz outputs/pick_place_books/pick_place_books_settled.npz \\ | |
| --frames 186 --fps 16 \\ | |
| --out outputs/pick_place_books/pick_place_books_cosmos186.npz | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import sys | |
| from pathlib import Path | |
| import numpy as np | |
| from scipy.spatial.transform import Rotation, Slerp | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(REPO_ROOT / "src")) | |
| from fpgm.utils.logging import get_logger, setup_logging # noqa: E402 | |
| logger = get_logger("resample_trajectory") | |
| def parse_args() -> argparse.Namespace: | |
| p = argparse.ArgumentParser( | |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter | |
| ) | |
| p.add_argument("--npz", type=Path, required=True, help="planner trajectory .npz") | |
| p.add_argument("--out", type=Path, required=True) | |
| p.add_argument( | |
| "--frames", type=int, default=186, | |
| help="output frame count (Cosmos-Transfer2.5 prefers multiples of 93)", | |
| ) | |
| p.add_argument("--fps", type=float, default=16.0, help="output frame rate") | |
| p.add_argument( | |
| "--validate", action="store_true", default=True, | |
| help="measure the interpolation's own chord error before writing", | |
| ) | |
| return p.parse_args() | |
| def _interp_linear_blocks(M: np.ndarray, s_src: np.ndarray, s_dst: np.ndarray) -> np.ndarray: | |
| """Element-wise linear interpolation of a (T, 3, 3) stack.""" | |
| return np.stack( | |
| [np.stack([np.interp(s_dst, s_src, M[:, r, c]) for c in range(3)], axis=1) | |
| for r in range(3)], axis=1, | |
| ) | |
| def _resample_poses(poses: np.ndarray, s_src: np.ndarray, s_dst: np.ndarray) -> np.ndarray: | |
| """(T, n_obj, 4, 4) object transforms -> (T_dst, n_obj, 4, 4). | |
| The 3x3 block of these matrices is **not** a bare rotation: the planner | |
| bakes the object mesh's own scale into it (this project's brick carries a | |
| uniform factor of ~0.05, i.e. the mesh is authored in units 20x the metre | |
| scale it is placed at). Feeding that straight to ``Rotation.from_matrix`` | |
| silently orthonormalises the scale away and the object renders ~20x too | |
| big -- which is what happened the first time this ran, and is invisible in | |
| any check phrased as "is the result a valid rotation", because destroying | |
| the scale is exactly what makes it one. | |
| So decompose ``M = R diag(s)`` per frame, SLERP ``R``, interpolate ``s`` | |
| linearly, and recombine. Scale is per-axis rather than assumed uniform, so | |
| a non-uniformly scaled asset survives too. | |
| """ | |
| T_dst, n_obj = s_dst.size, poses.shape[1] | |
| out = np.zeros((T_dst, n_obj, 4, 4)) | |
| out[:, :, 3, 3] = 1.0 | |
| for j in range(n_obj): | |
| M = poses[:, j, :3, :3] | |
| scale = np.linalg.norm(M, axis=1) # (T, 3) per-column norms | |
| if not np.all(scale > 1e-12): | |
| raise SystemExit(f"object {j}: degenerate (zero-scale) transform, cannot resample") | |
| R_src = M / scale[:, None, :] | |
| R_dst = Slerp(s_src, Rotation.from_matrix(R_src))(s_dst).as_matrix() | |
| s_dst_scale = np.stack([np.interp(s_dst, s_src, scale[:, k]) for k in range(3)], axis=1) | |
| out[:, j, :3, :3] = R_dst * s_dst_scale[:, None, :] | |
| out[:, j, :3, 3] = np.stack( | |
| [np.interp(s_dst, s_src, poses[:, j, k, 3]) for k in range(3)], axis=1 | |
| ) | |
| return out | |
| def _chord_error(values: np.ndarray, s_src: np.ndarray) -> float: | |
| """Bound the resample's own interpolation error, measured not assumed. | |
| Drop every other source sample, linearly restore the full grid from what is | |
| left, and report the worst discrepancy against the samples that were | |
| actually dropped. That is a *2x-coarser* resample than the one being | |
| performed, so it over-estimates the real error -- an honest upper bound. | |
| """ | |
| keep = s_src[::2] | |
| err = 0.0 | |
| for k in range(values.shape[1]): | |
| restored = np.interp(s_src, keep, values[::2, k]) | |
| err = max(err, float(np.abs(restored - values[:, k]).max())) | |
| return err | |
| def main() -> int: | |
| args = parse_args() | |
| setup_logging() | |
| if args.frames % 93 != 0: | |
| logger.warning( | |
| "--frames %d is not a multiple of 93; Cosmos-Transfer2.5's model card " | |
| "reports multiples of 93 perform best", args.frames, | |
| ) | |
| with np.load(args.npz, allow_pickle=True) as npz: | |
| data = {k: np.asarray(npz[k]) for k in npz.files} | |
| t_src = data["timestamps"].astype(np.float64) | |
| T_src = t_src.size | |
| if T_src < 2: | |
| raise SystemExit(f"need at least 2 source samples, got {T_src}") | |
| # Uniform index-space mapping: source sample i <-> s = i, output sample k | |
| # <-> s = k * (T_src - 1) / (T_dst - 1). Endpoints land exactly on the | |
| # first and last source samples, so the start and end poses are preserved | |
| # bit-for-bit rather than interpolated. | |
| s_src = np.arange(T_src, dtype=np.float64) | |
| s_dst = np.linspace(0.0, T_src - 1.0, args.frames) | |
| src_duration = float(t_src[-1] - t_src[0]) | |
| dst_duration = (args.frames - 1) / args.fps | |
| stretch = dst_duration / src_duration if src_duration > 0 else float("nan") | |
| out = { | |
| "timestamps": np.arange(args.frames, dtype=np.float64) / args.fps, | |
| } | |
| joints = data["joint_positions"].astype(np.float64) | |
| out["joint_positions"] = np.stack( | |
| [np.interp(s_dst, s_src, joints[:, j]) for j in range(joints.shape[1])], axis=1 | |
| ) | |
| out["gripper"] = np.interp(s_dst, s_src, data["gripper"].astype(np.float64)) | |
| out["object_poses"] = _resample_poses(data["object_poses"].astype(np.float64), s_src, s_dst) | |
| out["object_names"] = data["object_names"] | |
| # --- validation ------------------------------------------------------- | |
| joint_err = _chord_error(joints, s_src) | |
| logger.info( | |
| "resampled %d @ %.4g Hz -> %d @ %.4g fps (%.3f s -> %.3f s, %.3fx %s)", | |
| T_src, (T_src - 1) / src_duration, args.frames, args.fps, | |
| src_duration, dst_duration, stretch, "slower" if stretch > 1 else "faster", | |
| ) | |
| logger.info( | |
| "joint interpolation error (2x-coarse upper bound): %.3e rad = %.4f deg", | |
| joint_err, np.degrees(joint_err), | |
| ) | |
| # Object-transform scale is checked explicitly, and checked against the | |
| # *source*. The obvious formulation -- "assert the 3x3 block is a valid | |
| # rotation" -- is worse than useless here: it is satisfied precisely by the | |
| # bug of orthonormalising the asset's baked-in scale away, which renders | |
| # the object at the wrong size while every number looks perfect. | |
| src_scale = np.linalg.norm(data["object_poses"][:, :, :3, :3], axis=2) | |
| dst_scale = np.linalg.norm(out["object_poses"][:, :, :3, :3], axis=2) | |
| scale_err = float( | |
| np.abs(dst_scale - np.stack( | |
| [np.stack([np.interp(s_dst, s_src, src_scale[:, j, k]) for k in range(3)], axis=1) | |
| for j in range(src_scale.shape[1])], axis=1)).max() | |
| ) | |
| logger.info( | |
| "object transform scale: source %s, resampled %s, max error %.2e", | |
| np.round(src_scale.reshape(-1, 3).min(axis=0), 6), | |
| np.round(dst_scale.reshape(-1, 3).min(axis=0), 6), scale_err, | |
| ) | |
| if scale_err > 1e-12: | |
| raise SystemExit("object transform scale not preserved -- refusing to write") | |
| # Endpoints must survive exactly, or the grasp/release poses shift. | |
| for key, src in (("joint_positions", joints), ("gripper", data["gripper"]), | |
| ("object_poses", data["object_poses"])): | |
| for idx in (0, -1): | |
| if not np.allclose(out[key][idx], src[idx], atol=1e-12): | |
| raise SystemExit(f"{key} endpoint {idx} not preserved") | |
| args.out.parent.mkdir(parents=True, exist_ok=True) | |
| np.savez(args.out, **out) | |
| sidecar = args.out.with_suffix(".resample.json") | |
| sidecar.write_text(json.dumps({ | |
| "source_npz": str(args.npz), | |
| "source_frames": int(T_src), | |
| "source_fps": float((T_src - 1) / src_duration), | |
| "source_duration_s": src_duration, | |
| "output_frames": int(args.frames), | |
| "output_fps": float(args.fps), | |
| "output_duration_s": dst_duration, | |
| "time_stretch_factor": stretch, | |
| "stretch_note": ( | |
| "The motion is stretched uniformly onto the new frame count, not cropped or " | |
| "re-planned. The arm therefore moves %.1f%% %s than it does in the source " | |
| "trajectory; nothing about the path changes." % ( | |
| abs(stretch - 1) * 100, "slower" if stretch > 1 else "faster") | |
| ), | |
| "interpolation": { | |
| "joint_positions": "linear", | |
| "gripper": "linear", | |
| "object_translation": "linear", | |
| "object_rotation": "SLERP on the rotation factor of M = R diag(s)", | |
| "object_scale": "linear on the per-axis scale factor s, then recombined", | |
| "why_not_spline": ( | |
| "The joint trajectory comes from a trapezoidal velocity profile and has " | |
| "curvature discontinuities at the accel/decel corners; the gripper has a " | |
| "genuine step at release. A cubic spline overshoots at exactly those points. " | |
| "Linear cannot overshoot, and the measured chord error below is far under " | |
| "any tolerance that matters here." | |
| ), | |
| }, | |
| "joint_interp_error_rad_upper_bound": joint_err, | |
| "joint_interp_error_deg_upper_bound": float(np.degrees(joint_err)), | |
| "object_transform_scale_max_err": scale_err, | |
| "object_transform_scale_source": src_scale.reshape(-1, 3).min(axis=0).tolist(), | |
| }, indent=2)) | |
| print("=" * 78) | |
| print(f"wrote {args.out} ({args.frames} frames @ {args.fps:g} fps = {dst_duration:.3f} s)") | |
| print(f" {sidecar.name}") | |
| print("=" * 78) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 11.6 kB
- Xet hash:
- 5e984733efa98e4abd8af056f51b58b209f77036bb74aa08273bc168ae5b114a
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.