Buckets:
| """Shared data contracts for the whole pipeline. | |
| Every stage consumes and produces the dataclasses defined here. No stage reaches | |
| into another stage's internal state: the segmenter never sees a checkpoint path, | |
| the velocity estimator never sees an HDF5 handle. This keeps each stage | |
| independently unit-testable against synthetic data. | |
| Array shape conventions used throughout: | |
| T = frames in a clip, N = annotated scene-flow points, Q = tracked query points | |
| Pixel coordinates are always ``(u, v)`` = ``(x, y)`` and are always paired with the | |
| resolution they are valid at -- see :class:`CameraIntrinsics`. | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from datetime import datetime | |
| from enum import Enum | |
| from typing import Any | |
| import numpy as np | |
| # --------------------------------------------------------------------------- # | |
| # Exceptions | |
| # --------------------------------------------------------------------------- # | |
| class FpgmError(Exception): | |
| """Base class for every error raised by this package.""" | |
| class ConfigError(FpgmError): | |
| """A declared configuration is internally inconsistent or incomplete. | |
| Distinct from :class:`DataError`: nothing was wrong with the episode, the | |
| *configuration* names something that cannot be satisfied (a mesh role with | |
| no registered mesh, a parent label naming an object this episode does not | |
| have). Raised at resolve time so the failure lands next to the declaration | |
| rather than deep inside a stage. | |
| """ | |
| class GeometryError(FpgmError): | |
| """Base class for geometry/lifting failures.""" | |
| class AmbiguousConventionError(GeometryError): | |
| """Scene-flow frame convention could not be decided with a decisive margin. | |
| Raised rather than defaulting, because guessing wrong applies a systematic | |
| rigid transform to every 3D position while still looking plausible. | |
| """ | |
| class CameraCalibrationMismatchError(GeometryError): | |
| """Both frame hypotheses score badly -- intrinsic/extrinsic/serial mispairing.""" | |
| class NoValidDepthAnnotationsError(GeometryError): | |
| """A frame has no usable scene-flow support at all. | |
| Distinct from "this point was rejected" so downstream gap handling can tell | |
| "no data this frame" apart from "this individual point is an outlier". | |
| """ | |
| class InsufficientTrackHistoryError(GeometryError): | |
| """Not enough valid samples to differentiate.""" | |
| class DataError(FpgmError): | |
| """Base class for data acquisition / parsing failures.""" | |
| class EpisodeNotFoundError(DataError): | |
| """Episode could not be located in the DROID raw bucket.""" | |
| # --------------------------------------------------------------------------- # | |
| # Episode identity | |
| # --------------------------------------------------------------------------- # | |
| class EpisodeId: | |
| """A DROID episode uuid, e.g. ``AUTOLab+0d4edc83+2023-10-21-19h-07m-04s``. | |
| The uuid deterministically encodes the episode's location in the public | |
| DROID raw bucket; see :mod:`fpgm.data.ids` for the mapping. | |
| """ | |
| uuid: str | |
| def lab(self) -> str: | |
| return self.uuid.split("+")[0] | |
| def user_id(self) -> str: | |
| return self.uuid.split("+")[1] | |
| def timestamp(self) -> datetime: | |
| return datetime.strptime(self.uuid.split("+")[2], "%Y-%m-%d-%Hh-%Mm-%Ss") | |
| def __str__(self) -> str: # pragma: no cover - trivial | |
| return self.uuid | |
| # --------------------------------------------------------------------------- # | |
| # Camera | |
| # --------------------------------------------------------------------------- # | |
| class CameraIntrinsics: | |
| """Pinhole intrinsics that always carry the resolution they are valid at. | |
| Carrying ``(width, height)`` is what makes resolution mismatches impossible to | |
| introduce silently: the scene-flow ``intrinsic`` in the HDF5 is valid at the | |
| annotation resolution, while mp4 frames are typically a different size. | |
| """ | |
| fx: float | |
| fy: float | |
| cx: float | |
| cy: float | |
| width: int | |
| height: int | |
| def scaled(self, width: int, height: int) -> "CameraIntrinsics": | |
| """Return intrinsics rescaled to a different pixel resolution.""" | |
| sx = width / self.width | |
| sy = height / self.height | |
| return CameraIntrinsics( | |
| fx=self.fx * sx, | |
| fy=self.fy * sy, | |
| cx=self.cx * sx, | |
| cy=self.cy * sy, | |
| width=width, | |
| height=height, | |
| ) | |
| def matrix(self) -> np.ndarray: | |
| """3x3 K matrix (no skew).""" | |
| return np.array( | |
| [[self.fx, 0.0, self.cx], [0.0, self.fy, self.cy], [0.0, 0.0, 1.0]], | |
| dtype=np.float64, | |
| ) | |
| def from_matrix(cls, k: np.ndarray, width: int, height: int) -> "CameraIntrinsics": | |
| k = np.asarray(k, dtype=np.float64) | |
| return cls( | |
| fx=float(k[0, 0]), | |
| fy=float(k[1, 1]), | |
| cx=float(k[0, 2]), | |
| cy=float(k[1, 2]), | |
| width=int(width), | |
| height=int(height), | |
| ) | |
| class FrameConvention(Enum): | |
| """Which frame ``scene_flows`` positions are expressed in.""" | |
| WORLD = "world" | |
| CAMERA = "camera" | |
| class ConventionDetectionResult: | |
| convention: FrameConvention | |
| score_world: float | |
| score_camera: float | |
| margin: float | |
| n_points_scored: int | |
| frac_in_bounds: float | |
| # --------------------------------------------------------------------------- # | |
| # Depth | |
| # --------------------------------------------------------------------------- # | |
| class DepthMethod(Enum): | |
| """Why a depth value is (or is not) what it is. | |
| Carrying a reason code rather than a bare NaN is deliberate: debugging "why is | |
| this point's velocity NaN" three stages downstream without provenance is the | |
| single biggest time sink in a pipeline like this. | |
| """ | |
| DIRECT = "direct" | |
| INTERP = "interp" | |
| INTERP_UNMASKED_FALLBACK = "interp_unmasked_fallback" | |
| REJECTED_NO_SUPPORT = "rejected_no_support" | |
| REJECTED_DISCONTINUITY = "rejected_discontinuity" | |
| class DepthQuery: | |
| """Ask a :class:`~fpgm.depth.base.DepthSource` for depth at 2D locations.""" | |
| frame_idx: int | |
| uv: np.ndarray # (Q, 2) float, pixels at ``query_resolution`` | |
| query_resolution: tuple[int, int] # (width, height) | |
| object_mask: np.ndarray | None = None # (H, W) bool, restricts neighbour search | |
| class DepthResult: | |
| depth: np.ndarray # (Q,) float32, metres; NaN where invalid | |
| valid: np.ndarray # (Q,) bool | |
| confidence: np.ndarray # (Q,) float32 in [0, 1] | |
| method: np.ndarray # (Q,) object array of DepthMethod | |
| n_support: np.ndarray # (Q,) int32 | |
| # --------------------------------------------------------------------------- # | |
| # Segmentation | |
| # --------------------------------------------------------------------------- # | |
| class FrameMasks: | |
| """SAM 3.1 output for a single frame, at the video's original resolution.""" | |
| frame_idx: int | |
| obj_ids: np.ndarray # (M,) int64 | |
| masks: np.ndarray # (M, H, W) bool | |
| scores: np.ndarray # (M,) float32 | |
| boxes_xywh: np.ndarray # (M, 4) float32, normalised | |
| def mask_for(self, obj_id: int) -> np.ndarray | None: | |
| idx = np.flatnonzero(self.obj_ids == obj_id) | |
| return self.masks[int(idx[0])] if idx.size else None | |
| class Masklet: | |
| """One object's mask across a whole clip.""" | |
| obj_id: int | |
| frames: dict[int, np.ndarray] = field(default_factory=dict) # frame_idx -> (H,W) bool | |
| scores: dict[int, float] = field(default_factory=dict) | |
| def area(self, frame_idx: int) -> int: | |
| m = self.frames.get(frame_idx) | |
| return int(m.sum()) if m is not None else 0 | |
| # --------------------------------------------------------------------------- # | |
| # Tracking | |
| # --------------------------------------------------------------------------- # | |
| class Track2D: | |
| """TAPNext++ output: Q points followed across T frames, in display pixels.""" | |
| point_id: np.ndarray # (Q,) int32 | |
| frames: np.ndarray # (T,) int32, absolute video frame indices | |
| uv: np.ndarray # (T, Q, 2) float32, pixels | |
| visible: np.ndarray # (T, Q) bool | |
| resolution: tuple[int, int] # (width, height) the uv values are valid at | |
| class Track3D: | |
| """Lifted metric 3D positions in the world (robot-base) frame.""" | |
| point_id: np.ndarray # (Q,) int32 | |
| timestamps: np.ndarray # (T,) float64, seconds | |
| xyz_world: np.ndarray # (T, Q, 3) float64, metres; NaN where invalid | |
| valid: np.ndarray # (T, Q) bool | |
| confidence: np.ndarray # (T, Q) float32 | |
| class VelocityEstimate: | |
| """Per-point and aggregated object velocity, in the world frame, in m/s.""" | |
| point_id: np.ndarray # (Q,) | |
| timestamps: np.ndarray # (T,) float64, seconds | |
| linear_velocity: np.ndarray # (T, Q, 3) m/s, NaN where unavailable | |
| object_linear_velocity: np.ndarray # (T, 3) m/s | |
| object_speed: np.ndarray # (T,) m/s | |
| object_velocity_confidence: np.ndarray # (T,) | |
| inlier_mask: np.ndarray # (T, Q) bool | |
| frame: str = "world" # reference-frame provenance, carried explicitly | |
| # --------------------------------------------------------------------------- # | |
| # Timing | |
| # --------------------------------------------------------------------------- # | |
| class ClipTiming: | |
| """Reconciles the three clocks: clip index, video frame index, wall-clock seconds. | |
| ``scene_flows`` shares its T axis with ``joint_positions``/``gripper_pose``, so | |
| ``trajectory_fps`` (DROID's control rate, 15 Hz) is the canonical clock and is | |
| the only thing used to turn indices into seconds. | |
| Two independent time-base traps have been found here, and both matter: | |
| 1. **The mp4-fps trap.** Index alignment is derived from **frame counts**, never | |
| from ``mp4_fps``. On real DROID recordings the container header reports 60 fps | |
| while the file holds 127 frames for a 128-step trajectory -- i.e. the true | |
| correspondence is one video frame per trajectory step. Trusting the header | |
| would stretch the time axis by 4x and scale every velocity by the same | |
| factor, which is exactly the kind of error that produces plausible-looking | |
| but wrong numbers. ``mp4_fps`` is therefore kept for diagnostics and playback | |
| only; ``video_frames_per_step`` (measured from frame counts) is what is | |
| actually used. | |
| 2. **The annotation-stride trap.** A PointWorld scene-flow clip's own frame axis | |
| (what ``clip_start_frame``/``clip_end_frame``/clip-local ``t`` are expressed | |
| in) is *not* one-to-one with the trajectory/video frame axis either. Clip | |
| frame ``t`` of clip ``"50:61"`` is trajectory/video row ``2 * (50 + t)``, not | |
| ``50 + t`` -- the annotations (``scene_flows``, ``joint_positions``, | |
| ``gripper_pose``, all sharing the clip group's T axis) are recorded at half | |
| the trajectory/video rate. This was measured, not assumed: matching each | |
| clip's own ``joint_positions`` against ``trajectory.h5``'s | |
| ``observation/robot_state/joint_positions`` at candidate strides gives an | |
| exact (0.0 max abs error) match at stride 2 across all 42 clips checked, and | |
| a large mismatch at stride 1. See | |
| :func:`fpgm.data.pointworld.verify_annotation_stride`, which performs exactly | |
| this check on real data -- callers should measure ``annotation_stride`` | |
| from their own clip rather than trusting the default, for the same reason | |
| trap 1 is not read from the mp4 header: a wrong-but-plausible constant is | |
| how both of these traps happened in the first place. | |
| The two traps are independent and compose multiplicatively: a clip frame maps | |
| to a trajectory row via ``annotation_stride`` alone, and a trajectory row maps | |
| to a video frame via ``video_frames_per_step`` alone. | |
| """ | |
| clip_start_frame: int | |
| clip_end_frame: int | |
| trajectory_fps: float | |
| #: Container header value. Diagnostic/playback only -- never used for alignment. | |
| mp4_fps: float = 0.0 | |
| #: Video frames per trajectory step, measured from frame counts (normally 1.0). | |
| video_frames_per_step: float = 1.0 | |
| #: Annotated clip frames per trajectory/video frame. Measured, not assumed -- | |
| #: see :func:`fpgm.data.pointworld.verify_annotation_stride`. | |
| annotation_stride: int = 2 | |
| def from_counts( | |
| cls, | |
| clip_start: int, | |
| clip_end: int, | |
| trajectory_length: int, | |
| video_frame_count: int, | |
| trajectory_fps: float = 15.0, | |
| mp4_fps: float = 0.0, | |
| annotation_stride: int = 2, | |
| ) -> "ClipTiming": | |
| """Build timing by measuring the video/trajectory frame-count ratio. | |
| ``annotation_stride`` is not derived here -- it can only be measured against | |
| real joint-position data (see | |
| :func:`fpgm.data.pointworld.verify_annotation_stride`), which this | |
| classmethod does not have access to. Callers that have verified it should | |
| pass it explicitly; the default is the stride measured across every | |
| available clip at the time this trap was found, not a guess. | |
| """ | |
| ratio = 1.0 | |
| if trajectory_length > 0 and video_frame_count > 0: | |
| ratio = video_frame_count / trajectory_length | |
| return cls( | |
| clip_start_frame=int(clip_start), | |
| clip_end_frame=int(clip_end), | |
| trajectory_fps=float(trajectory_fps), | |
| mp4_fps=float(mp4_fps), | |
| video_frames_per_step=float(ratio), | |
| annotation_stride=int(annotation_stride), | |
| ) | |
| def n_frames(self) -> int: | |
| return self.clip_end_frame - self.clip_start_frame | |
| def dt(self) -> float: | |
| """Seconds between consecutive clip frames. | |
| ``annotation_stride`` trajectory steps elapse between consecutive | |
| *annotated* clip frames, so this is ``annotation_stride / trajectory_fps`` | |
| -- 2/15 s, not 1/15 s, once the stride has been measured as 2. | |
| """ | |
| return self.annotation_stride / self.trajectory_fps | |
| def clip_frame_to_trajectory_frame(self, t: int | np.ndarray): | |
| """Trajectory.h5 row (``joint_positions``/``gripper_pose`` index) for clip frame ``t``.""" | |
| return (self.clip_start_frame + t) * self.annotation_stride | |
| def clip_frame_to_video_frame(self, t: int | np.ndarray): | |
| """Video frame for clip frame ``t``, composing both time-base traps.""" | |
| return self.clip_frame_to_trajectory_frame(t) * self.video_frames_per_step | |
| def clip_frame_to_seconds(self, t: int | np.ndarray): | |
| return self.clip_frame_to_trajectory_frame(t) / self.trajectory_fps | |
| def video_frame_to_seconds(self, f: int | np.ndarray): | |
| return f / (self.trajectory_fps * self.video_frames_per_step) | |
| def video_frame_to_clip_frame(self, f: int | np.ndarray): | |
| return f / (self.video_frames_per_step * self.annotation_stride) - self.clip_start_frame | |
| def timestamps(self) -> np.ndarray: | |
| """Wall-clock seconds for each clip frame.""" | |
| return self.clip_frame_to_seconds(np.arange(self.n_frames, dtype=np.float64)) | |
| # --------------------------------------------------------------------------- # | |
| # Episode / clip | |
| # --------------------------------------------------------------------------- # | |
| class CameraAssets: | |
| """Everything known about one physical camera for one clip.""" | |
| serial: str | |
| role: str # "ext1" | "ext2" | "wrist" | |
| intrinsic: np.ndarray # (3, 3) float64, at annotation resolution | |
| extrinsic: np.ndarray # (4, 4) float64, world -> camera | |
| mp4_path: str | None = None | |
| class SceneFlowClip: | |
| """One ``"{start}:{end}"`` clip group of one camera in a ``*_flows.h5``.""" | |
| key: str | |
| start: int | |
| end: int | |
| camera_serial: str | |
| scene_flows: np.ndarray # (T, N, 3) float32 | |
| scene_visibility: np.ndarray # (T, N) bool | |
| scene_depth_valid: np.ndarray # (T, N) bool | |
| scene_colors: np.ndarray # (T, N, 3) uint8 | |
| intrinsic: np.ndarray # (3, 3) float64 | |
| extrinsic: np.ndarray # (4, 4) float64, world -> camera | |
| initial_rgb: np.ndarray | None = None # (H, W, 3) uint8, decoded | |
| gripper_pose: np.ndarray | None = None # (T, 7) float32 | |
| gripper_open: np.ndarray | None = None # (T, 1) bool | |
| #: (T, 7) float32, robot joint positions -- shares the clip group's T axis with | |
| #: gripper_pose/scene_flows. Exists mainly so the clip -> trajectory.h5 stride | |
| #: can be *measured* against it; see fpgm.data.pointworld.verify_annotation_stride. | |
| joint_positions: np.ndarray | None = None | |
| #: (180, 320) uint16 millimetres, dense depth for the clip's *first* frame only | |
| #: (there is no dense depth for the other T-1 frames -- that is the whole reason | |
| #: the datagen plan splats ``scene_flows`` for the rest and treats this as an | |
| #: anchor/scale reference rather than a full depth stream). Optional with a | |
| #: ``None`` default so existing callers/pickled instances built before this | |
| #: field existed keep working unchanged. | |
| initial_depth: np.ndarray | None = None | |
| #: (T, N, 3) int8, per-point surface normals sharing scene_flows' (T, N) axes. | |
| #: Signed unit-ish components stored as int8 (i.e. roughly [-127, 127] mapping | |
| #: to [-1, 1]) -- decoding to float is left to the caller since different | |
| #: consumers want different precision/normalization. Optional for the same | |
| #: backward-compatibility reason as initial_depth. | |
| scene_normals: np.ndarray | None = None | |
| def n_frames(self) -> int: | |
| return int(self.scene_flows.shape[0]) | |
| def n_points(self) -> int: | |
| return int(self.scene_flows.shape[1]) | |
| class Episode: | |
| """A DROID episode joined across both data sources.""" | |
| episode_id: EpisodeId | |
| task_instruction: str | |
| trajectory_length: int | |
| cameras: dict[str, CameraAssets] = field(default_factory=dict) # serial -> assets | |
| metadata: dict[str, Any] = field(default_factory=dict) | |
| clips: list[SceneFlowClip] = field(default_factory=list) | |
| def camera_by_role(self, role: str) -> CameraAssets | None: | |
| for cam in self.cameras.values(): | |
| if cam.role == role: | |
| return cam | |
| return None | |
Xet Storage Details
- Size:
- 18.4 kB
- Xet hash:
- b06f396bc16bdcb816f25e9cce42ec8ef031dd76f15fb1fe026c49422ce85da4
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.