Compare commits

...

4 Commits

Author SHA1 Message Date
CarolinePascal 7fb512f507 chore(comment): adding clarification comment 2026-08-04 18:51:50 +02:00
CarolinePascal d98847f5ad test(datasets): consolidate episode-selection error tests
Merge the separate episode_filter no-match and unknown-key tests into a
single parametrized test, and add coverage for an out-of-range episodes
list raising rather than silently resolving to empty.
2026-08-04 16:55:03 +02:00
CarolinePascal 30329f6c80 refactor(datasets): resolve episode indices consistently
Make LeRobotDataset own episode selection policy: resolve the allowlist
via resolve_episode_indices (replacing the old warn-only out-of-range
handling, which now raises on an empty selection) and apply episode_filter,
then hand the finalized set to DatasetReader. The reader no longer
re-resolves, removing the duplicate resolution, and computes num_frames
from episode metadata so it stays consistent with the streaming path.
2026-08-04 16:54:55 +02:00
CarolinePascal dc9906d072 feat(datasets): support episode selection and filtering in streaming
StreamingLeRobotDataset previously accepted an `episodes` argument but
never applied it, so the whole stream was returned regardless. Resolve
the selection against the dataset range, apply it as a lazy filter over
the streamed shards, and add an `episode_filter` predicate (mirroring
LeRobotDataset). Empty selections and non-matching filters now raise
instead of silently streaming everything, and num_frames/num_episodes
reflect the selection.
2026-08-04 16:54:49 +02:00
6 changed files with 142 additions and 37 deletions
+7 -7
View File
@@ -39,7 +39,6 @@ from .io_utils import (
hf_transform_to_torch,
load_nested_dataset,
)
from .utils import resolve_episode_indices
from .video_utils import decode_video_frames
@@ -69,8 +68,9 @@ class DatasetReader:
Args:
meta: Dataset metadata instance.
root: Local dataset root directory.
episodes: Optional list of episode indices to select. ``None``
means all episodes.
episodes: Optional list of episode indices to select, assumed
already validated by the caller. ``None`` means
all episodes.
tolerance_s: Timestamp synchronization tolerance in seconds.
video_backend: Video decoding backend identifier.
delta_timestamps: Optional dict mapping feature keys to lists of
@@ -84,7 +84,7 @@ class DatasetReader:
"""
self._meta = meta
self.root = root
self.episodes = resolve_episode_indices(episodes, meta.total_episodes)
self.episodes = episodes
self._tolerance_s = tolerance_s
self._video_backend = video_backend
if image_transforms is not None and not callable(image_transforms):
@@ -152,9 +152,9 @@ class DatasetReader:
@property
def num_frames(self) -> int:
"""Number of frames in selected episodes."""
if self.episodes is not None and self.hf_dataset is not None:
return len(self.hf_dataset)
return self._meta.total_frames
if self.episodes is None:
return self._meta.total_frames
return sum(self._meta.episodes[ep]["length"] for ep in self.episodes)
@property
def num_episodes(self) -> int:
+1
View File
@@ -91,6 +91,7 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
repo_type=cfg.dataset.repo_type,
)
delta_timestamps = resolve_delta_timestamps(cfg.trainable_config, ds_meta)
# Resolved here because dataset reader does not apply exclude_episodes.
episodes = resolve_episode_indices(
cfg.dataset.episodes, ds_meta.total_episodes, cfg.dataset.exclude_episodes
)
+8 -6
View File
@@ -34,6 +34,7 @@ from .utils import (
create_lerobot_dataset_card,
get_safe_version,
is_valid_version,
resolve_episode_indices,
)
from .video_utils import (
StreamingVideoEncoder,
@@ -237,13 +238,14 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.revision = self.meta.revision
self.meta.rescale_depth_stats(self._depth_output_unit)
if episodes is not None and any(
episode >= self.meta.total_episodes or episode < 0 for episode in episodes
):
logger.warning(
f"Some episodes in the provided episodes list are out of range for this dataset ({self.meta.total_episodes})."
# Selection policy (allowlist resolution + predicate filter) is owned here;
# the reader just consumes the finalized episode index set.
episodes = resolve_episode_indices(episodes, self.meta.total_episodes)
if episodes is not None and not episodes:
raise ValueError(
"No valid episodes: the requested episode selection is empty after resolving "
f"against the dataset range [0, {self.meta.total_episodes})."
)
if episode_filter is not None:
resolved = self.meta.filter_episodes(episode_filter, candidates=episodes)
if not resolved:
+32 -3
View File
@@ -13,6 +13,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from collections import deque
from collections.abc import Callable, Generator, Iterable, Iterator
from pathlib import Path
@@ -34,6 +35,7 @@ from .utils import (
check_version_compatibility,
find_float_index,
is_float_in_list,
resolve_episode_indices,
safe_shard,
)
from .video_utils import (
@@ -42,6 +44,8 @@ from .video_utils import (
decode_video_frames_torchcodec,
)
logger = logging.getLogger(__name__)
class LookBackError(Exception):
"""
@@ -248,6 +252,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
repo_id: str,
root: str | Path | None = None,
episodes: list[int] | None = None,
episode_filter: Callable[[dict], bool] | None = None,
image_transforms: Callable | None = None,
delta_timestamps: dict[list[float]] | None = None,
tolerance_s: float = 1e-4,
@@ -274,6 +279,9 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
When omitted, Hub metadata is resolved through the 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.
episode_filter (Callable[[dict], bool] | None, optional): Predicate over per-episode
metadata rows used to select episodes (e.g. ``lambda ep: ep["length"] >= 100``).
Intersected with ``episodes`` when both are set. Defaults to None.
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).
@@ -305,7 +313,6 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
self.streaming_from_local = root is not None and self.repo_type == "dataset"
self.image_transforms = image_transforms
self.episodes = episodes
self.tolerance_s = tolerance_s
self.revision = revision if revision else CODEBASE_VERSION
self.seed = seed
@@ -338,6 +345,21 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
# Check version
check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_VERSION)
self.episodes = resolve_episode_indices(episodes, self.meta.total_episodes)
if self.episodes is not None and not self.episodes:
raise ValueError(
"No valid episodes: the requested episode selection is empty after resolving "
f"against the dataset range [0, {self.meta.total_episodes})."
)
if episode_filter is not None:
resolved = self.meta.filter_episodes(episode_filter, candidates=self.episodes)
if not resolved:
raise ValueError(
"The episode filter did not match any episode. Make sure the filter and episodes list are valid and compatible."
)
logger.info(f"The episode filter matched {len(resolved)} episode(s).")
self.episodes = resolved
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
@@ -379,15 +401,22 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
**token_kwargs,
)
# Streaming loads all shards, so episode selection is applied as a lazy stream filter.
if self.episodes is not None:
selected = set(self.episodes)
self.hf_dataset = self.hf_dataset.filter(lambda x: x["episode_index"] in selected)
self.num_shards = min(self.hf_dataset.num_shards, max_num_shards)
@property
def num_frames(self):
return self.meta.total_frames
if self.episodes is None:
return self.meta.total_frames
return sum(self.meta.episodes[ep]["length"] for ep in self.episodes)
@property
def num_episodes(self):
return self.meta.total_episodes
return len(self.episodes) if self.episodes is not None else self.meta.total_episodes
@property
def fps(self):
+16 -20
View File
@@ -1828,25 +1828,21 @@ def test_episode_filter_intersects_with_episodes(tmp_path, lerobot_dataset_facto
assert seen_eps == set(expected_eps)
def test_episode_filter_no_match_raises(tmp_path, lerobot_dataset_factory):
"""An empty match in LeRobotDataset's episode_filter raises a ValueError rather than silently returning an empty dataset."""
@pytest.mark.parametrize(
"kwargs, exc, match",
[
(
{"episode_filter": lambda ep: ep["length"] < 0},
ValueError,
r"The episode filter did not match any episode",
),
({"episode_filter": lambda ep: ep["not_a_real_field"] > 0}, KeyError, "not_a_real_field"),
({"episodes": [99]}, ValueError, "No valid episodes"),
],
)
def test_episode_selection_invalid_raises(tmp_path, lerobot_dataset_factory, kwargs, exc, match):
"""Invalid selections fail loudly: non-matching filter, unknown filter key, or out-of-range episodes."""
dataset = lerobot_dataset_factory(root=tmp_path / "test", total_episodes=4, total_frames=100)
with pytest.raises(ValueError, match=r"The episode filter did not match any episode"):
LeRobotDataset(
dataset.repo_id,
root=dataset.root,
episode_filter=lambda ep: ep["length"] < 0,
)
def test_episode_filter_unknown_key_raises(tmp_path, lerobot_dataset_factory):
"""A predicate referencing a column absent from meta.episodes surfaces a clear KeyError."""
dataset = lerobot_dataset_factory(root=tmp_path / "test", total_episodes=4, total_frames=100)
with pytest.raises(KeyError, match="not_a_real_field"):
LeRobotDataset(
dataset.repo_id,
root=dataset.root,
episode_filter=lambda ep: ep["not_a_real_field"] > 0,
)
with pytest.raises(exc, match=match):
LeRobotDataset(dataset.repo_id, root=dataset.root, **kwargs)
+78 -1
View File
@@ -84,6 +84,7 @@ def test_streaming_dataset_forwards_hub_token_only_for_remote_data(tmp_path, mon
root=requested_root or tmp_path / "snapshot",
revision=streaming_dataset_module.CODEBASE_VERSION,
_version=streaming_dataset_module.CODEBASE_VERSION,
total_episodes=10,
features={},
depth_keys=[],
image_keys=[],
@@ -155,6 +156,81 @@ def test_single_frame_consistency(tmp_path, lerobot_dataset_factory):
)
def test_streaming_episode_selection(tmp_path, lerobot_dataset_factory):
"""episodes=[...] restricts both the streamed frames and the count properties to the selection."""
ds_num_frames = 200
ds_num_episodes = 10
selected = [2, 5, 7]
local_path = tmp_path / "test"
repo_id = DUMMY_REPO_ID
ds = lerobot_dataset_factory(
root=local_path,
repo_id=repo_id,
total_episodes=ds_num_episodes,
total_frames=ds_num_frames,
)
streaming_ds = StreamingLeRobotDataset(
repo_id=repo_id, root=local_path, episodes=selected, buffer_size=50, shuffle=False
)
assert streaming_ds.num_episodes == len(selected)
assert streaming_ds.num_frames == sum(ds.meta.episodes[ep]["length"] for ep in selected)
episode_indices = [int(frame["episode_index"]) for frame in streaming_ds]
assert set(episode_indices) == set(selected)
assert len(episode_indices) == streaming_ds.num_frames
def test_streaming_episode_filter(tmp_path, lerobot_dataset_factory):
"""episode_filter restricts the stream to episodes whose metadata matches the predicate."""
ds_num_episodes = 6
ds_num_frames = 120 # 20 frames per episode
local_path = tmp_path / "test"
repo_id = DUMMY_REPO_ID
ds = lerobot_dataset_factory(
root=local_path,
repo_id=repo_id,
total_episodes=ds_num_episodes,
total_frames=ds_num_frames,
)
keep = {1, 4}
streaming_ds = StreamingLeRobotDataset(
repo_id=repo_id,
root=local_path,
episode_filter=lambda ep: ep["episode_index"] in keep,
buffer_size=50,
shuffle=False,
)
assert set(streaming_ds.episodes) == keep
assert streaming_ds.num_episodes == len(keep)
assert {int(frame["episode_index"]) for frame in streaming_ds} == keep
@pytest.mark.parametrize(
"kwargs, match",
[
({"episodes": [99]}, "No valid episodes"),
({"episode_filter": lambda ep: False}, "episode filter did not match"),
],
)
def test_streaming_empty_selection_raises(tmp_path, lerobot_dataset_factory, kwargs, match):
"""An empty selection (out-of-range episodes or a non-matching filter) must fail loudly."""
local_path = tmp_path / "test"
repo_id = DUMMY_REPO_ID
lerobot_dataset_factory(root=local_path, repo_id=repo_id, total_episodes=4, total_frames=40)
with pytest.raises(ValueError, match=match):
StreamingLeRobotDataset(repo_id=repo_id, root=local_path, **kwargs)
@pytest.mark.parametrize(
"shuffle",
[False, True],
@@ -494,6 +570,7 @@ def _fake_meta(*args, **kwargs):
meta.root = root or "/tmp/_streaming_meta"
meta.revision = revision or "v0"
meta._version = "v3.0"
meta.total_episodes = 10
meta.depth_keys = []
meta.image_keys = []
meta.rescale_depth_stats = lambda *_a, **_k: None
@@ -616,7 +693,7 @@ def test_repo_type_is_keyword_only_and_preserves_positional_episodes():
patch("lerobot.datasets.streaming_dataset.check_version_compatibility"),
patch(
"lerobot.datasets.streaming_dataset.load_dataset",
return_value=SimpleNamespace(num_shards=1),
return_value=SimpleNamespace(num_shards=1, filter=lambda *a, **k: SimpleNamespace(num_shards=1)),
),
):
dataset = StreamingLeRobotDataset(DUMMY_REPO_ID, None, episodes)