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.
This commit is contained in:
CarolinePascal
2026-08-04 16:54:49 +02:00
parent 64b23178d5
commit dc9906d072
2 changed files with 110 additions and 4 deletions
+32 -3
View File
@@ -13,6 +13,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import logging
from collections import deque from collections import deque
from collections.abc import Callable, Generator, Iterable, Iterator from collections.abc import Callable, Generator, Iterable, Iterator
from pathlib import Path from pathlib import Path
@@ -34,6 +35,7 @@ from .utils import (
check_version_compatibility, check_version_compatibility,
find_float_index, find_float_index,
is_float_in_list, is_float_in_list,
resolve_episode_indices,
safe_shard, safe_shard,
) )
from .video_utils import ( from .video_utils import (
@@ -42,6 +44,8 @@ from .video_utils import (
decode_video_frames_torchcodec, decode_video_frames_torchcodec,
) )
logger = logging.getLogger(__name__)
class LookBackError(Exception): class LookBackError(Exception):
""" """
@@ -248,6 +252,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
repo_id: str, repo_id: str,
root: str | Path | None = None, root: str | Path | None = None,
episodes: list[int] | None = None, episodes: list[int] | None = None,
episode_filter: Callable[[dict], bool] | None = None,
image_transforms: Callable | None = None, image_transforms: Callable | None = None,
delta_timestamps: dict[list[float]] | None = None, delta_timestamps: dict[list[float]] | None = None,
tolerance_s: float = 1e-4, 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``. 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 episodes (list[int] | None, optional): If specified, this will only load episodes specified by
their episode_index in this list. 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. image_transforms (Callable | None, optional): Transform to apply to image data.
tolerance_s (float, optional): Tolerance in seconds for timestamp matching. tolerance_s (float, optional): Tolerance in seconds for timestamp matching.
revision (str, optional): Git revision id (branch name, tag, or commit hash). 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.streaming_from_local = root is not None and self.repo_type == "dataset"
self.image_transforms = image_transforms self.image_transforms = image_transforms
self.episodes = episodes
self.tolerance_s = tolerance_s self.tolerance_s = tolerance_s
self.revision = revision if revision else CODEBASE_VERSION self.revision = revision if revision else CODEBASE_VERSION
self.seed = seed self.seed = seed
@@ -338,6 +345,21 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
# Check version # Check version
check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_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] = { self._depth_encoder_configs: dict[str, DepthEncoderConfig] = {
vid_key: DepthEncoderConfig.from_video_info(self.meta.features[vid_key].get("info")) vid_key: DepthEncoderConfig.from_video_info(self.meta.features[vid_key].get("info"))
for vid_key in self.meta.depth_keys for vid_key in self.meta.depth_keys
@@ -379,15 +401,22 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
**token_kwargs, **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) self.num_shards = min(self.hf_dataset.num_shards, max_num_shards)
@property @property
def num_frames(self): 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 @property
def num_episodes(self): 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 @property
def fps(self): def fps(self):
+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", root=requested_root or tmp_path / "snapshot",
revision=streaming_dataset_module.CODEBASE_VERSION, revision=streaming_dataset_module.CODEBASE_VERSION,
_version=streaming_dataset_module.CODEBASE_VERSION, _version=streaming_dataset_module.CODEBASE_VERSION,
total_episodes=10,
features={}, features={},
depth_keys=[], depth_keys=[],
image_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( @pytest.mark.parametrize(
"shuffle", "shuffle",
[False, True], [False, True],
@@ -494,6 +570,7 @@ def _fake_meta(*args, **kwargs):
meta.root = root or "/tmp/_streaming_meta" meta.root = root or "/tmp/_streaming_meta"
meta.revision = revision or "v0" meta.revision = revision or "v0"
meta._version = "v3.0" meta._version = "v3.0"
meta.total_episodes = 10
meta.depth_keys = [] meta.depth_keys = []
meta.image_keys = [] meta.image_keys = []
meta.rescale_depth_stats = lambda *_a, **_k: None 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.check_version_compatibility"),
patch( patch(
"lerobot.datasets.streaming_dataset.load_dataset", "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) dataset = StreamingLeRobotDataset(DUMMY_REPO_ID, None, episodes)