| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| from collections import deque |
| from collections.abc import Callable, Generator, Iterable, Iterator |
| from pathlib import Path |
|
|
| import datasets |
| import numpy as np |
| import torch |
| from datasets import load_dataset |
|
|
| from lerobot.configs import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DepthEncoderConfig |
| from lerobot.utils.constants import HF_LEROBOT_HOME, LOOKAHEAD_BACKTRACKTABLE, LOOKBACK_BACKTRACKTABLE |
|
|
| from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata |
| from .depth_utils import MM_PER_METRE, dequantize_depth |
| from .feature_utils import get_delta_indices |
| from .io_utils import item_to_torch |
| from .utils import ( |
| check_version_compatibility, |
| find_float_index, |
| is_float_in_list, |
| safe_shard, |
| ) |
| from .video_utils import ( |
| VideoDecoderCache, |
| decode_video_frames, |
| decode_video_frames_torchcodec, |
| ) |
|
|
|
|
| class LookBackError(Exception): |
| """ |
| Exception raised when trying to look back in the history of a Backtrackable object. |
| """ |
|
|
| pass |
|
|
|
|
| class LookAheadError(Exception): |
| """ |
| Exception raised when trying to look ahead in the future of a Backtrackable object. |
| """ |
|
|
| pass |
|
|
|
|
| class Backtrackable[T]: |
| """ |
| Wrap any iterator/iterable so you can step back up to `history` items |
| and look ahead up to `lookahead` items. |
| |
| This is useful for streaming datasets where you need to access previous and future items |
| but can't load the entire dataset into memory. |
| |
| Example: |
| ------- |
| ```python |
| ds = load_dataset("c4", "en", streaming=True, split="train") |
| rev = Backtrackable(ds, history=3, lookahead=2) |
| |
| x0 = next(rev) # forward |
| x1 = next(rev) |
| x2 = next(rev) |
| |
| # Look ahead |
| x3_peek = rev.peek_ahead(1) # next item without moving cursor |
| x4_peek = rev.peek_ahead(2) # two items ahead |
| |
| # Look back |
| x1_again = rev.peek_back(1) # previous item without moving cursor |
| x0_again = rev.peek_back(2) # two items back |
| |
| # Move backward |
| x1_back = rev.prev() # back one step |
| next(rev) # returns x2, continues forward from where we were |
| ``` |
| """ |
|
|
| __slots__ = ("_source", "_back_buf", "_ahead_buf", "_cursor", "_history", "_lookahead") |
|
|
| def __init__(self, iterable: Iterable[T], *, history: int = 1, lookahead: int = 0): |
| if history < 1: |
| raise ValueError("history must be >= 1") |
| if lookahead <= 0: |
| raise ValueError("lookahead must be > 0") |
|
|
| self._source: Iterator[T] = iter(iterable) |
| self._back_buf: deque[T] = deque(maxlen=history) |
| self._ahead_buf: deque[T] = deque(maxlen=lookahead) if lookahead > 0 else deque() |
| self._cursor: int = 0 |
| self._history = history |
| self._lookahead = lookahead |
|
|
| def __iter__(self) -> "Backtrackable[T]": |
| return self |
|
|
| def __next__(self) -> T: |
| |
| if self._cursor < 0: |
| self._cursor += 1 |
| return self._back_buf[self._cursor] |
|
|
| |
| item = self._ahead_buf.popleft() if self._ahead_buf else next(self._source) |
|
|
| |
| self._back_buf.append(item) |
| self._cursor = 0 |
| return item |
|
|
| def prev(self) -> T: |
| """ |
| Step one item back in history and return it. |
| Raises IndexError if already at the oldest buffered item. |
| """ |
| if len(self._back_buf) + self._cursor <= 1: |
| raise LookBackError("At start of history") |
|
|
| self._cursor -= 1 |
| return self._back_buf[self._cursor] |
|
|
| def peek_back(self, n: int = 1) -> T: |
| """ |
| Look `n` items back (n=1 == previous item) without moving the cursor. |
| """ |
| if n < 0 or n + 1 > len(self._back_buf) + self._cursor: |
| raise LookBackError("peek_back distance out of range") |
|
|
| return self._back_buf[self._cursor - (n + 1)] |
|
|
| def peek_ahead(self, n: int = 1) -> T: |
| """ |
| Look `n` items ahead (n=1 == next item) without moving the cursor. |
| Fills the ahead buffer if necessary. |
| """ |
| if n < 1: |
| raise LookAheadError("peek_ahead distance must be 1 or more") |
| elif n > self._lookahead: |
| raise LookAheadError("peek_ahead distance exceeds lookahead limit") |
|
|
| |
| while len(self._ahead_buf) < n: |
| try: |
| item = next(self._source) |
| self._ahead_buf.append(item) |
|
|
| except StopIteration as err: |
| raise LookAheadError("peek_ahead: not enough items in source") from err |
|
|
| return self._ahead_buf[n - 1] |
|
|
| def history(self) -> list[T]: |
| """ |
| Return a copy of the buffered history (most recent last). |
| The list length ≤ `history` argument passed at construction. |
| """ |
| if self._cursor == 0: |
| return list(self._back_buf) |
|
|
| |
| return list(self._back_buf)[: self._cursor or None] |
|
|
| def can_peek_back(self, steps: int = 1) -> bool: |
| """ |
| Check if we can go back `steps` items without raising an IndexError. |
| """ |
| return steps <= len(self._back_buf) + self._cursor |
|
|
| def can_peek_ahead(self, steps: int = 1) -> bool: |
| """ |
| Check if we can peek ahead `steps` items. |
| This may involve trying to fill the ahead buffer. |
| """ |
| if self._lookahead > 0 and steps > self._lookahead: |
| return False |
|
|
| |
| try: |
| while len(self._ahead_buf) < steps: |
| if self._lookahead > 0 and len(self._ahead_buf) >= self._lookahead: |
| return False |
| item = next(self._source) |
| self._ahead_buf.append(item) |
| return True |
| except StopIteration: |
| return False |
|
|
|
|
| class StreamingLeRobotDataset(torch.utils.data.IterableDataset): |
| """LeRobotDataset with streaming capabilities. |
| |
| This class extends LeRobotDataset to add streaming functionality, allowing data to be streamed |
| rather than loaded entirely into memory. This is especially useful for large datasets that may |
| not fit in memory or when you want to quickly explore a dataset without downloading it completely. |
| |
| The key innovation is using a Backtrackable iterator that maintains a bounded buffer of recent |
| items, allowing us to access previous frames for delta timestamps without loading the entire |
| dataset into memory. |
| |
| Example: |
| Basic usage: |
| ```python |
| from lerobot.common.datasets.streaming_dataset import StreamingLeRobotDataset |
| |
| # Create a streaming dataset with delta timestamps |
| delta_timestamps = { |
| "observation.image": [-1.0, -0.5, 0.0], # 1 sec ago, 0.5 sec ago, current |
| "action": [0.0, 0.1, 0.2], # current, 0.1 sec future, 0.2 sec future |
| } |
| |
| dataset = StreamingLeRobotDataset( |
| repo_id="your-dataset-repo-id", |
| delta_timestamps=delta_timestamps, |
| streaming=True, |
| buffer_size=1000, |
| ) |
| |
| # Iterate over the dataset |
| for i, item in enumerate(dataset): |
| print(f"Sample {i}: Episode {item['episode_index']} Frame {item['frame_index']}") |
| # item will contain stacked frames according to delta_timestamps |
| if i >= 10: |
| break |
| ``` |
| """ |
|
|
| def __init__( |
| self, |
| repo_id: str, |
| root: str | Path | None = None, |
| episodes: list[int] | None = None, |
| image_transforms: Callable | None = None, |
| delta_timestamps: dict[list[float]] | None = None, |
| tolerance_s: float = 1e-4, |
| revision: str | None = None, |
| force_cache_sync: bool = False, |
| streaming: bool = True, |
| buffer_size: int = 1000, |
| max_num_shards: int = 16, |
| seed: int = 42, |
| rng: np.random.Generator | None = None, |
| shuffle: bool = True, |
| return_uint8: bool = False, |
| depth_output_unit: str = DEFAULT_DEPTH_UNIT, |
| ): |
| """Initialize a StreamingLeRobotDataset. |
| |
| Args: |
| repo_id (str): This is the repo id that will be used to fetch the dataset. |
| root (Path | None, optional): Local directory to use for local datasets. When omitted, Hub |
| metadata is resolved through a revision-safe snapshot cache under |
| ``$HF_LEROBOT_HOME/hub``. |
| episodes (list[int] | None, optional): If specified, this will only load episodes specified by |
| their episode_index in this list. |
| image_transforms (Callable | None, optional): Transform to apply to image data. |
| tolerance_s (float, optional): Tolerance in seconds for timestamp matching. |
| revision (str, optional): Git revision id (branch name, tag, or commit hash). |
| force_cache_sync (bool, optional): Flag to sync and refresh local files first. |
| streaming (bool, optional): Whether to stream the dataset or load it all. Defaults to True. |
| buffer_size (int, optional): Buffer size for shuffling when streaming. Defaults to 1000. |
| max_num_shards (int, optional): Number of shards to re-shard the input dataset into. Defaults to 16. |
| seed (int, optional): Reproducibility random seed. |
| rng (np.random.Generator | None, optional): Random number generator. |
| shuffle (bool, optional): Whether to shuffle the dataset across exhaustions. Defaults to True. |
| depth_output_unit (str, optional): Physical unit depth maps are dequantized to ("m" or "mm"). |
| Defaults to "mm". |
| """ |
| super().__init__() |
| self.repo_id = repo_id |
| self._requested_root = Path(root) if root else None |
| self.root = self._requested_root if self._requested_root is not None else HF_LEROBOT_HOME / repo_id |
| self.streaming_from_local = root is not None |
|
|
| self.image_transforms = image_transforms |
| self.episodes = episodes |
| self.tolerance_s = tolerance_s |
| self.revision = revision if revision else CODEBASE_VERSION |
| self.seed = seed |
| self.rng = rng if rng is not None else np.random.default_rng(seed) |
| self.shuffle = shuffle |
|
|
| self.streaming = streaming |
| self.buffer_size = buffer_size |
| self._return_uint8 = return_uint8 |
| self._depth_output_unit = depth_output_unit |
|
|
| |
| self.video_decoder_cache = None |
|
|
| if self._requested_root is not None: |
| self.root.mkdir(exist_ok=True, parents=True) |
|
|
| |
| self.meta = LeRobotDatasetMetadata( |
| self.repo_id, self._requested_root, self.revision, force_cache_sync=force_cache_sync |
| ) |
| self.root = self.meta.root |
| self.revision = self.meta.revision |
| self.meta.rescale_depth_stats(self._depth_output_unit) |
| |
| check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_VERSION) |
|
|
| self._depth_encoder_configs: dict[str, DepthEncoderConfig] = { |
| vid_key: DepthEncoderConfig.from_video_info(self.meta.features[vid_key].get("info")) |
| for vid_key in self.meta.depth_keys |
| } |
|
|
| |
| self._image_depth_units: dict[str, str | None] = { |
| key: (self.meta.features[key].get("info") or {}).get("depth_unit") |
| for key in self.meta.depth_keys |
| if key in self.meta.image_keys |
| } |
|
|
| self.delta_timestamps = None |
| self.delta_indices = None |
|
|
| if delta_timestamps is not None: |
| self._validate_delta_timestamp_keys(delta_timestamps) |
| self.delta_timestamps = delta_timestamps |
| self.delta_indices = get_delta_indices(self.delta_timestamps, self.fps) |
|
|
| self.hf_dataset: datasets.IterableDataset = load_dataset( |
| self.repo_id if not self.streaming_from_local else str(self.root), |
| split="train", |
| streaming=self.streaming, |
| data_files="data/*/*.parquet", |
| revision=self.revision, |
| ) |
|
|
| self.num_shards = min(self.hf_dataset.num_shards, max_num_shards) |
|
|
| @property |
| def num_frames(self): |
| return self.meta.total_frames |
|
|
| @property |
| def num_episodes(self): |
| return self.meta.total_episodes |
|
|
| @property |
| def fps(self): |
| return self.meta.fps |
|
|
| @property |
| def depth_output_unit(self) -> str: |
| """Physical unit (``"m"`` or ``"mm"``) depth maps are returned in on read.""" |
| return self._depth_output_unit |
|
|
| @staticmethod |
| def _iter_random_indices( |
| rng: np.random.Generator, buffer_size: int, random_batch_size=100 |
| ) -> Iterator[int]: |
| while True: |
| yield from (int(i) for i in rng.integers(0, buffer_size, size=random_batch_size)) |
|
|
| @staticmethod |
| def _infinite_generator_over_elements(rng: np.random.Generator, elements: list[int]) -> Iterator[int]: |
| while True: |
| yield rng.choice(elements) |
|
|
| |
| |
| |
| |
| def __iter__(self) -> Iterator[dict[str, torch.Tensor]]: |
| if self.video_decoder_cache is None: |
| self.video_decoder_cache = VideoDecoderCache() |
|
|
| |
| rng = np.random.default_rng(self.seed) if not self.shuffle else self.rng |
|
|
| buffer_indices_generator = self._iter_random_indices(rng, self.buffer_size) |
|
|
| idx_to_backtrack_dataset = { |
| idx: self._make_backtrackable_dataset(safe_shard(self.hf_dataset, idx, self.num_shards)) |
| for idx in range(self.num_shards) |
| } |
|
|
| |
| |
| |
| |
| frames_buffer = [] |
| while available_shards := list(idx_to_backtrack_dataset.keys()): |
| shard_key = next(self._infinite_generator_over_elements(rng, available_shards)) |
| backtrack_dataset = idx_to_backtrack_dataset[shard_key] |
|
|
| try: |
| for frame in self.make_frame(backtrack_dataset): |
| if len(frames_buffer) == self.buffer_size: |
| i = next(buffer_indices_generator) |
| yield frames_buffer[i] |
| frames_buffer[i] = frame |
| else: |
| frames_buffer.append(frame) |
| break |
| except ( |
| RuntimeError, |
| StopIteration, |
| ): |
| del idx_to_backtrack_dataset[shard_key] |
|
|
| |
| rng.shuffle(frames_buffer) |
| yield from frames_buffer |
|
|
| def _get_window_steps( |
| self, delta_timestamps: dict[str, list[float]] | None = None, dynamic_bounds: bool = False |
| ) -> tuple[int, int]: |
| if delta_timestamps is None: |
| return 1, 1 |
|
|
| if not dynamic_bounds: |
| |
| lookback = LOOKBACK_BACKTRACKTABLE |
| lookahead = LOOKAHEAD_BACKTRACKTABLE |
| else: |
| |
| all_timestamps = sum(delta_timestamps.values(), []) |
| lookback = min(all_timestamps) * self.fps |
| lookahead = max(all_timestamps) * self.fps |
|
|
| |
| lookback = 0 if lookback >= 0 else (lookback * -1) |
|
|
| return lookback, lookahead |
|
|
| def _make_backtrackable_dataset(self, dataset: datasets.IterableDataset) -> Backtrackable: |
| lookback, lookahead = self._get_window_steps(self.delta_timestamps) |
| return Backtrackable(dataset, history=lookback, lookahead=lookahead) |
|
|
| def _make_timestamps_from_indices( |
| self, start_ts: float, indices: dict[str, list[int]] | None = None |
| ) -> dict[str, list[float]]: |
| if indices is not None: |
| return { |
| key: ( |
| start_ts + torch.tensor(indices[key]) / self.fps |
| ).tolist() |
| for key in self.delta_timestamps |
| } |
| else: |
| return dict.fromkeys(self.meta.video_keys, [start_ts]) |
|
|
| def _make_padding_camera_frame(self, camera_key: str): |
| """Variable-shape padding frame for given camera keys, given in (H, W, C)""" |
| return torch.zeros(self.meta.info.features[camera_key]["shape"]).permute(-1, 0, 1) |
|
|
| def _get_video_frame_padding_mask( |
| self, |
| video_frames: dict[str, torch.Tensor], |
| query_timestamps: dict[str, list[float]], |
| original_timestamps: dict[str, list[float]], |
| ) -> dict[str, torch.BoolTensor]: |
| padding_mask = {} |
|
|
| for video_key, timestamps in original_timestamps.items(): |
| if video_key not in video_frames: |
| continue |
| frames = [] |
| mask = [] |
| padding_frame = self._make_padding_camera_frame(video_key) |
| for ts in timestamps: |
| if is_float_in_list(ts, query_timestamps[video_key]): |
| idx = find_float_index(ts, query_timestamps[video_key]) |
| frames.append(video_frames[video_key][idx, :]) |
| mask.append(False) |
| else: |
| frames.append(padding_frame) |
| mask.append(True) |
|
|
| padding_mask[f"{video_key}_is_pad"] = torch.BoolTensor(mask) |
|
|
| return padding_mask |
|
|
| def make_frame(self, dataset_iterator: Backtrackable) -> Generator: |
| """Makes a frame starting from a dataset iterator""" |
| item = next(dataset_iterator) |
| item = item_to_torch(item) |
|
|
| updates = [] |
|
|
| |
| ep_idx = item["episode_index"] |
|
|
| |
| current_ts = item["index"] / self.fps |
|
|
| episode_boundaries_ts = { |
| key: ( |
| self.meta.episodes[ep_idx][f"videos/{key}/from_timestamp"], |
| self.meta.episodes[ep_idx][f"videos/{key}/to_timestamp"], |
| ) |
| for key in self.meta.video_keys |
| } |
|
|
| |
| if self.delta_indices is not None: |
| query_result, padding = self._get_delta_frames(dataset_iterator, item) |
| updates.append(query_result) |
| updates.append(padding) |
|
|
| |
| if len(self.meta.video_keys) > 0: |
| original_timestamps = self._make_timestamps_from_indices(current_ts, self.delta_indices) |
|
|
| |
| query_timestamps = self._get_query_timestamps( |
| current_ts, self.delta_indices, episode_boundaries_ts |
| ) |
| video_frames = self._query_videos(query_timestamps, ep_idx) |
|
|
| if self.image_transforms is not None: |
| image_keys = self.meta.camera_keys |
| for cam in image_keys: |
| video_frames[cam] = self.image_transforms(video_frames[cam]) |
|
|
| updates.append(video_frames) |
|
|
| if self.delta_indices is not None: |
| |
| padding_mask = self._get_video_frame_padding_mask( |
| video_frames, query_timestamps, original_timestamps |
| ) |
| updates.append(padding_mask) |
|
|
| result = item.copy() |
| for update in updates: |
| result.update(update) |
|
|
| |
| for key, stored_unit in self._image_depth_units.items(): |
| if key in result and stored_unit is not None and stored_unit != self._depth_output_unit: |
| result[key] = ( |
| result[key] * MM_PER_METRE |
| if stored_unit == DEPTH_METER_UNIT |
| else result[key] / MM_PER_METRE |
| ) |
|
|
| result["task"] = self.meta.tasks.iloc[item["task_index"]].name |
|
|
| yield result |
|
|
| def _get_query_timestamps( |
| self, |
| current_ts: float, |
| query_indices: dict[str, list[int]] | None = None, |
| episode_boundaries_ts: dict[str, tuple[float, float]] | None = None, |
| ) -> dict[str, list[float]]: |
| query_timestamps = {} |
| keys_to_timestamps = self._make_timestamps_from_indices(current_ts, query_indices) |
| for key in self.meta.video_keys: |
| if query_indices is not None and key in query_indices: |
| timestamps = keys_to_timestamps[key] |
| |
| query_timestamps[key] = torch.clamp( |
| torch.tensor(timestamps), *episode_boundaries_ts[key] |
| ).tolist() |
|
|
| else: |
| query_timestamps[key] = [current_ts] |
|
|
| return query_timestamps |
|
|
| def _query_videos(self, query_timestamps: dict[str, list[float]], ep_idx: int) -> dict: |
| """Note: When using data workers (e.g. DataLoader with num_workers>0), do not call this function |
| in the main process (e.g. by using a second Dataloader with num_workers=0). It will result in a |
| Segmentation Fault. This probably happens because a memory reference to the video loader is created in |
| the main process and a subprocess fails to access it. |
| """ |
|
|
| item = {} |
| for video_key, query_ts in query_timestamps.items(): |
| root = self.meta.url_root if self.streaming and not self.streaming_from_local else self.root |
| video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}" |
| if video_key in self.meta.depth_keys: |
| |
| |
| frames = decode_video_frames( |
| video_path, |
| query_ts, |
| self.tolerance_s, |
| backend="pyav", |
| return_uint8=False, |
| is_depth=True, |
| ) |
| depth_encoder = self._depth_encoder_configs[video_key] |
| frames = dequantize_depth( |
| frames, |
| depth_min=depth_encoder.depth_min, |
| depth_max=depth_encoder.depth_max, |
| shift=depth_encoder.shift, |
| use_log=depth_encoder.use_log, |
| output_unit=self._depth_output_unit, |
| ) |
| else: |
| frames = decode_video_frames_torchcodec( |
| video_path, |
| query_ts, |
| self.tolerance_s, |
| decoder_cache=self.video_decoder_cache, |
| return_uint8=self._return_uint8, |
| ) |
|
|
| item[video_key] = frames.squeeze(0) if len(query_ts) == 1 else frames |
|
|
| return item |
|
|
| def _get_delta_frames(self, dataset_iterator: Backtrackable, current_item: dict): |
| |
| """Get frames with delta offsets using the backtrackable iterator. |
| |
| Args: |
| current_item (dict): Current item from the iterator. |
| ep_idx (int): Episode index. |
| |
| Returns: |
| tuple: (query_result, padding) - frames at delta offsets and padding info. |
| """ |
| current_episode_idx = current_item["episode_index"] |
|
|
| |
| query_result = {} |
| padding = {} |
|
|
| for key, delta_indices in self.delta_indices.items(): |
| if key in self.meta.video_keys: |
| continue |
|
|
| target_frames = [] |
| is_pad = [] |
|
|
| |
| delta_results = {} |
|
|
| |
| negative_deltas = sorted([d for d in delta_indices if d < 0], reverse=True) |
| positive_deltas = sorted([d for d in delta_indices if d > 0]) |
| zero_deltas = [d for d in delta_indices if d == 0] |
|
|
| |
| for delta in zero_deltas: |
| delta_results[delta] = ( |
| current_item[key], |
| False, |
| ) |
|
|
| |
| lookback_failed = False |
|
|
| last_successful_frame = current_item[key] |
|
|
| for delta in negative_deltas: |
| if lookback_failed: |
| delta_results[delta] = (last_successful_frame, True) |
| continue |
|
|
| try: |
| steps_back = abs(delta) |
| if dataset_iterator.can_peek_back(steps_back): |
| past_item = dataset_iterator.peek_back(steps_back) |
| past_item = item_to_torch(past_item) |
|
|
| if past_item["episode_index"] == current_episode_idx: |
| delta_results[delta] = (past_item[key], False) |
| last_successful_frame = past_item[key] |
|
|
| else: |
| raise LookBackError("Retrieved frame is from different episode!") |
| else: |
| raise LookBackError("Cannot go back further than the history buffer!") |
|
|
| except LookBackError: |
| delta_results[delta] = (last_successful_frame, True) |
| lookback_failed = True |
|
|
| |
| lookahead_failed = False |
| last_successful_frame = current_item[key] |
|
|
| for delta in positive_deltas: |
| if lookahead_failed: |
| delta_results[delta] = (last_successful_frame, True) |
| continue |
|
|
| try: |
| if dataset_iterator.can_peek_ahead(delta): |
| future_item = dataset_iterator.peek_ahead(delta) |
| future_item = item_to_torch(future_item) |
|
|
| if future_item["episode_index"] == current_episode_idx: |
| delta_results[delta] = (future_item[key], False) |
| last_successful_frame = future_item[key] |
|
|
| else: |
| raise LookAheadError("Retrieved frame is from different episode!") |
| else: |
| raise LookAheadError("Cannot go ahead further than the lookahead buffer!") |
|
|
| except LookAheadError: |
| delta_results[delta] = (last_successful_frame, True) |
| lookahead_failed = True |
|
|
| |
| for delta in delta_indices: |
| frame, is_padded = delta_results[delta] |
|
|
| |
| target_frames.append(frame) |
| is_pad.append(is_padded) |
|
|
| |
| if target_frames: |
| query_result[key] = torch.stack(target_frames) |
| padding[f"{key}_is_pad"] = torch.BoolTensor(is_pad) |
|
|
| return query_result, padding |
|
|
| def _validate_delta_timestamp_keys(self, delta_timestamps: dict[list[float]]) -> None: |
| """ |
| Validate that all keys in delta_timestamps correspond to actual features in the dataset. |
| |
| Raises: |
| ValueError: If any delta timestamp key doesn't correspond to a dataset feature. |
| """ |
| if delta_timestamps is None: |
| return |
|
|
| |
| available_features = set(self.meta.features.keys()) |
|
|
| |
| delta_keys = set(delta_timestamps.keys()) |
|
|
| |
| invalid_keys = delta_keys - available_features |
|
|
| if invalid_keys: |
| raise ValueError( |
| f"The following delta_timestamp keys do not correspond to dataset features: {invalid_keys}. " |
| f"Available features are: {sorted(available_features)}" |
| ) |
|
|