Buckets:
| """Validate lifted velocity against scene_flows ground truth. | |
| The trick that makes this a fair test rather than tautological: the ground-truth | |
| velocity is produced by feeding scene_flows positions through the exact same | |
| :class:`~fpgm.geometry.velocity.VelocityEstimator` used for our lifted track (same | |
| gap-bridging, same differentiation, same MAD-trimmed aggregation). Any discrepancy is | |
| therefore attributable to the lifting stage (camera convention, depth interpolation), | |
| not to a difference in the velocity algorithm itself. | |
| Magnitude error and direction (cosine) error are reported *separately* because they | |
| point at different bugs: a magnitude-only error implies a depth-scale problem, while a | |
| direction error implies a convention/axis-sign problem -- collapsing them into one | |
| number would hide which stage to go fix. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| import numpy as np | |
| from fpgm.config import VelocityConfig | |
| from fpgm.geometry.camera import Camera | |
| from fpgm.geometry.velocity import VelocityEstimator | |
| from fpgm.types import ClipTiming, Track2D, Track3D, VelocityEstimate | |
| _EPS = 1e-9 | |
| class ValidationReport: | |
| """Per-frame and aggregate agreement between our lifted velocity and ground truth.""" | |
| n_frames: int | |
| n_gt_points: int | |
| n_compared_frames: int | |
| magnitude_rel_error: np.ndarray # (T,) float, NaN where not comparable | |
| direction_cosine_error: np.ndarray # (T,) float in [0, 2], NaN where not comparable | |
| per_axis_bias: np.ndarray # (3,) mean signed (ours - gt) velocity error, m/s | |
| per_axis_noise_std: np.ndarray # (3,) std of the signed error, m/s | |
| median_magnitude_rel_error: float | |
| median_direction_cosine_error: float | |
| frac_frames_under_threshold: float | |
| rel_error_threshold: float | |
| reprojection_error_px: np.ndarray # (T,) float, NaN where not comparable | |
| median_reprojection_error_px: float | |
| def summary(self) -> str: | |
| """Render a readable table of the validation results.""" | |
| lines = [ | |
| "Scene-flow velocity validation", | |
| "===============================", | |
| f"frames total / compared : {self.n_frames} / {self.n_compared_frames}", | |
| f"ground-truth points : {self.n_gt_points}", | |
| "", | |
| "metric value", | |
| "------------------------------- --------", | |
| f"{'median magnitude rel. error':<32} {self.median_magnitude_rel_error:.4f}", | |
| f"{'median direction cosine error':<32} {self.median_direction_cosine_error:.4f}", | |
| f"{'frac frames < ' + f'{self.rel_error_threshold:g}':<32} " | |
| f"{self.frac_frames_under_threshold:.4f}", | |
| f"{'median reprojection error (px)':<32} {self.median_reprojection_error_px:.4f}", | |
| "", | |
| "per-axis bias (ours - gt, m/s) x y z", | |
| "------------------------------- -------- -------- --------", | |
| ( | |
| f"{'systematic (mean)':<32} " | |
| f"{self.per_axis_bias[0]:8.4f} {self.per_axis_bias[1]:8.4f} " | |
| f"{self.per_axis_bias[2]:8.4f}" | |
| ), | |
| ( | |
| f"{'noise (std)':<32} " | |
| f"{self.per_axis_noise_std[0]:8.4f} {self.per_axis_noise_std[1]:8.4f} " | |
| f"{self.per_axis_noise_std[2]:8.4f}" | |
| ), | |
| ] | |
| return "\n".join(lines) | |
| def _cosine_error(a: np.ndarray, b: np.ndarray) -> np.ndarray: | |
| """1 - cosine-similarity between rows of ``a`` and ``b`` (both (T, 3)); NaN for zero vectors.""" | |
| na = np.linalg.norm(a, axis=-1) | |
| nb = np.linalg.norm(b, axis=-1) | |
| denom = na * nb | |
| cos_sim = np.full(a.shape[0], np.nan, dtype=np.float64) | |
| ok = denom > _EPS | |
| cos_sim[ok] = np.sum(a[ok] * b[ok], axis=-1) / denom[ok] | |
| return 1.0 - np.clip(cos_sim, -1.0, 1.0) | |
| def validate_against_scene_flow( | |
| scene_flows: np.ndarray, | |
| scene_visibility: np.ndarray, | |
| scene_depth_valid: np.ndarray, | |
| object_point_indices: np.ndarray, | |
| camera: Camera, | |
| frame_is_world: bool, | |
| timing: ClipTiming, | |
| track2d: Track2D, | |
| lifted_velocity: VelocityEstimate, | |
| velocity_cfg: VelocityConfig, | |
| rel_error_threshold: float = 0.2, | |
| ) -> ValidationReport: | |
| """Compare our lifted-track velocity against a ground-truth velocity from scene_flows. | |
| Args: | |
| scene_flows: ``(T, N, 3)`` raw tracked 3D points from the h5. | |
| scene_visibility: ``(T, N)`` bool. | |
| scene_depth_valid: ``(T, N)`` bool. | |
| object_point_indices: ``(K,)`` indices into ``N`` selecting the object's own | |
| annotated scene-flow points (e.g. those inside the object mask). | |
| camera: Camera at the scene-flow annotation resolution. | |
| frame_is_world: Resolved scene-flow frame convention (see | |
| :mod:`fpgm.geometry.convention`). | |
| timing: Clip timing, used to build ground-truth timestamps. | |
| track2d: Our tracked 2D query points (clip-frame indexed, e.g. via | |
| :func:`fpgm.geometry.temporal.resample_track_to_clip_frames`), used only | |
| for the reprojection sanity check. | |
| lifted_velocity: The velocity estimate produced by our lifting pipeline | |
| (:func:`fpgm.geometry.lifting.lift_track_to_3d` followed by | |
| :class:`~fpgm.geometry.velocity.VelocityEstimator`). | |
| velocity_cfg: Passed to the *same* :class:`VelocityEstimator` used to build | |
| ``lifted_velocity``, so the ground-truth pass is an apples-to-apples | |
| comparison. | |
| rel_error_threshold: Relative magnitude-error threshold for the "fraction of | |
| good frames" summary statistic. | |
| Returns: | |
| A :class:`ValidationReport`. | |
| """ | |
| object_point_indices = np.asarray(object_point_indices, dtype=np.int64) | |
| n_frames = scene_flows.shape[0] | |
| gt_points = scene_flows[:, object_point_indices, :].astype(np.float64) | |
| if frame_is_world: | |
| gt_world_xyz = gt_points | |
| else: | |
| gt_world_xyz = camera.cam_to_world(gt_points) | |
| gt_valid = scene_visibility[:, object_point_indices] & scene_depth_valid[:, object_point_indices] | |
| gt_confidence = gt_valid.astype(np.float32) | |
| frame_idx = np.arange(n_frames, dtype=np.int64) | |
| gt_timestamps = np.asarray(timing.clip_frame_to_seconds(frame_idx), dtype=np.float64) | |
| gt_track3d = Track3D( | |
| point_id=object_point_indices.astype(np.int32), | |
| timestamps=gt_timestamps, | |
| xyz_world=gt_world_xyz, | |
| valid=gt_valid, | |
| confidence=gt_confidence, | |
| ) | |
| v_gt = VelocityEstimator().estimate(gt_track3d, velocity_cfg) | |
| magnitude_rel_error = np.full(n_frames, np.nan, dtype=np.float64) | |
| direction_cosine_error = np.full(n_frames, np.nan, dtype=np.float64) | |
| signed_error = np.full((n_frames, 3), np.nan, dtype=np.float64) | |
| t_common = min(n_frames, lifted_velocity.object_speed.shape[0]) | |
| both_valid = np.zeros(n_frames, dtype=bool) | |
| both_valid[:t_common] = ~np.isnan(v_gt.object_speed[:t_common]) & ~np.isnan( | |
| lifted_velocity.object_speed[:t_common] | |
| ) | |
| ours_speed = np.full(n_frames, np.nan) | |
| ours_speed[:t_common] = lifted_velocity.object_speed[:t_common] | |
| ours_vel = np.full((n_frames, 3), np.nan) | |
| ours_vel[:t_common] = lifted_velocity.object_linear_velocity[:t_common] | |
| gt_speed_safe = np.where(v_gt.object_speed > _EPS, v_gt.object_speed, _EPS) | |
| magnitude_rel_error[both_valid] = ( | |
| np.abs(ours_speed[both_valid] - v_gt.object_speed[both_valid]) / gt_speed_safe[both_valid] | |
| ) | |
| direction_cosine_error[both_valid] = _cosine_error( | |
| ours_vel[both_valid], v_gt.object_linear_velocity[both_valid] | |
| ) | |
| signed_error[both_valid] = ours_vel[both_valid] - v_gt.object_linear_velocity[both_valid] | |
| n_compared = int(np.count_nonzero(both_valid)) | |
| if n_compared > 0: | |
| per_axis_bias = np.nanmean(signed_error, axis=0) | |
| per_axis_noise_std = np.nanstd(signed_error, axis=0) | |
| median_mag = float(np.nanmedian(magnitude_rel_error)) | |
| median_dir = float(np.nanmedian(direction_cosine_error)) | |
| frac_under = float(np.mean(magnitude_rel_error[both_valid] < rel_error_threshold)) | |
| else: | |
| per_axis_bias = np.full(3, np.nan) | |
| per_axis_noise_std = np.full(3, np.nan) | |
| median_mag = float("nan") | |
| median_dir = float("nan") | |
| frac_under = float("nan") | |
| reprojection_error_px = _reprojection_check( | |
| gt_world_xyz, gt_valid, camera, track2d, n_frames | |
| ) | |
| finite_reproj = reprojection_error_px[np.isfinite(reprojection_error_px)] | |
| median_reproj = float(np.median(finite_reproj)) if finite_reproj.size else float("nan") | |
| return ValidationReport( | |
| n_frames=n_frames, | |
| n_gt_points=int(object_point_indices.size), | |
| n_compared_frames=n_compared, | |
| magnitude_rel_error=magnitude_rel_error, | |
| direction_cosine_error=direction_cosine_error, | |
| per_axis_bias=per_axis_bias, | |
| per_axis_noise_std=per_axis_noise_std, | |
| median_magnitude_rel_error=median_mag, | |
| median_direction_cosine_error=median_dir, | |
| frac_frames_under_threshold=frac_under, | |
| rel_error_threshold=rel_error_threshold, | |
| reprojection_error_px=reprojection_error_px, | |
| median_reprojection_error_px=median_reproj, | |
| ) | |
| def _reprojection_check( | |
| gt_world_xyz: np.ndarray, | |
| gt_valid: np.ndarray, | |
| camera: Camera, | |
| track2d: Track2D, | |
| n_frames: int, | |
| ) -> np.ndarray: | |
| """Differentiation-free check: does reprojected GT land near our tracked query points? | |
| Compares, per frame, the centroid of the reprojected ground-truth object points | |
| against the centroid of our own visible tracked query points. A large centroid | |
| offset means the camera/convention is wrong at the projection level, independent | |
| of anything to do with differentiation or depth interpolation -- so this check | |
| should be examined *before* trusting any velocity numbers at all. | |
| """ | |
| cam = camera.rescaled(*track2d.resolution) | |
| frame_to_row = {int(f): i for i, f in enumerate(track2d.frames)} | |
| out = np.full(n_frames, np.nan, dtype=np.float64) | |
| for t in range(n_frames): | |
| valid_t = gt_valid[t] | |
| if not np.any(valid_t): | |
| continue | |
| row = frame_to_row.get(t) | |
| if row is None: | |
| continue | |
| query_visible = track2d.visible[row] | |
| if not np.any(query_visible): | |
| continue | |
| uv_gt, depth_gt = cam.project(gt_world_xyz[t, valid_t]) | |
| in_front = depth_gt > 0 | |
| if not np.any(in_front): | |
| continue | |
| gt_centroid = np.mean(uv_gt[in_front], axis=0) | |
| query_centroid = np.mean(track2d.uv[row, query_visible], axis=0) | |
| out[t] = float(np.linalg.norm(gt_centroid - query_centroid)) | |
| return out | |
| class GripperValidationReport: | |
| """Comparison of estimated object velocity against robot proprioception. | |
| While the object is grasped, it moves rigidly with the end-effector, so the | |
| gripper's own velocity is a strong reference for the object's. Crucially it is | |
| *independent* of everything the pipeline does: it comes from joint encoders, | |
| not from the camera, the masks, the tracks or the scene-flow annotations. | |
| """ | |
| n_frames: int | |
| n_compared: int | |
| grasped_only: bool | |
| median_abs_error_mps: float | |
| median_rel_error: float | |
| frac_within_50pct: float | |
| ours_peak_mps: float | |
| gripper_peak_mps: float | |
| ours_mean_mps: float | |
| gripper_mean_mps: float | |
| def summary(self) -> str: | |
| return ( | |
| "Gripper-proprioception validation\n" | |
| "=================================\n" | |
| f"frames compared : {self.n_compared}/{self.n_frames}" | |
| f"{' (grasped only)' if self.grasped_only else ''}\n" | |
| f"peak speed ours / gripper : {self.ours_peak_mps:.3f} / " | |
| f"{self.gripper_peak_mps:.3f} m/s\n" | |
| f"mean speed ours / gripper : {self.ours_mean_mps:.3f} / " | |
| f"{self.gripper_mean_mps:.3f} m/s\n" | |
| f"median absolute error : {self.median_abs_error_mps:.3f} m/s\n" | |
| f"median relative error : {self.median_rel_error:.2f}\n" | |
| f"frames within 50% of truth : {self.frac_within_50pct:.0%}" | |
| ) | |
| def gripper_speed(gripper_pose: np.ndarray, fps: float) -> np.ndarray: | |
| """End-effector speed per frame (m/s) from ``gripper_pose`` positions. | |
| Returns an array of length ``T`` (the first sample repeats the second) so it | |
| aligns with the clip's frame axis. | |
| """ | |
| pos = np.asarray(gripper_pose, dtype=np.float64)[:, :3] | |
| step = np.linalg.norm(np.diff(pos, axis=0), axis=1) * float(fps) | |
| if step.size == 0: | |
| return np.zeros(len(pos)) | |
| return np.concatenate([step[:1], step]) | |
| def validate_against_gripper( | |
| velocity: VelocityEstimate, | |
| gripper_pose: np.ndarray, | |
| fps: float, | |
| gripper_open: np.ndarray | None = None, | |
| grasped_only: bool = True, | |
| object_xyz_world: np.ndarray | None = None, | |
| max_grasp_distance_m: float = 0.12, | |
| ) -> GripperValidationReport: | |
| """Score an estimated object velocity against the end-effector's own velocity. | |
| This is deliberately offered alongside the scene-flow check rather than instead | |
| of it, because the two fail differently: scene-flow validation is unavailable | |
| exactly where annotations are missing (which, on this dataset, is often the | |
| manipulated object itself), while proprioception is always present but is only | |
| a valid reference while the object is actually held. | |
| Args: | |
| velocity: pipeline output for the clip. | |
| gripper_pose: ``(T, 7)`` end-effector pose; only the position is used. | |
| fps: trajectory (control) rate, used to differentiate the pose. | |
| gripper_open: ``(T, 1)`` or ``(T,)`` boolean; frames where the gripper is | |
| open are excluded when ``grasped_only`` is set, since a released object | |
| no longer follows the end-effector. | |
| grasped_only: restrict the comparison to grasped frames. | |
| """ | |
| ours = np.asarray(velocity.object_speed, dtype=np.float64) | |
| truth = gripper_speed(gripper_pose, fps) | |
| n = min(len(ours), len(truth)) | |
| ours, truth = ours[:n], truth[:n] | |
| usable = np.isfinite(ours) & np.isfinite(truth) | |
| if grasped_only: | |
| # A closed gripper does NOT imply it is holding *this* object -- on real | |
| # episodes the hand closes and travels while the object still sits on the | |
| # table, and comparing those frames manufactures enormous fake errors. | |
| # Require the gripper to be closed AND co-located with the object. | |
| if gripper_open is not None: | |
| closed = ~np.asarray(gripper_open).reshape(-1)[:n].astype(bool) | |
| usable &= closed | |
| if object_xyz_world is not None: | |
| obj = np.asarray(object_xyz_world, dtype=np.float64)[:n] | |
| hand = np.asarray(gripper_pose, dtype=np.float64)[:n, :3] | |
| with np.errstate(invalid="ignore"): | |
| near = np.linalg.norm(obj - hand, axis=1) <= max_grasp_distance_m | |
| usable &= np.nan_to_num(near, nan=False).astype(bool) | |
| if not usable.any(): | |
| return GripperValidationReport( | |
| n_frames=n, | |
| n_compared=0, | |
| grasped_only=grasped_only, | |
| median_abs_error_mps=float("nan"), | |
| median_rel_error=float("nan"), | |
| frac_within_50pct=float("nan"), | |
| ours_peak_mps=float("nan"), | |
| gripper_peak_mps=float(truth.max()) if len(truth) else float("nan"), | |
| ours_mean_mps=float("nan"), | |
| gripper_mean_mps=float(truth.mean()) if len(truth) else float("nan"), | |
| ) | |
| a, b = ours[usable], truth[usable] | |
| abs_err = np.abs(a - b) | |
| rel_err = abs_err / np.maximum(b, 1e-3) | |
| return GripperValidationReport( | |
| n_frames=n, | |
| n_compared=int(usable.sum()), | |
| grasped_only=grasped_only, | |
| median_abs_error_mps=float(np.median(abs_err)), | |
| median_rel_error=float(np.median(rel_err)), | |
| frac_within_50pct=float(np.mean(rel_err < 0.5)), | |
| ours_peak_mps=float(a.max()), | |
| gripper_peak_mps=float(b.max()), | |
| ours_mean_mps=float(a.mean()), | |
| gripper_mean_mps=float(b.mean()), | |
| ) | |
Xet Storage Details
- Size:
- 16.3 kB
- Xet hash:
- ce902a0c258f4e3caf1d9b9e7c1e91e7871b6c1ca906053df12047ef79fc18de
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.