-
Notifications
You must be signed in to change notification settings - Fork 2
feat(examples): add Daft-native LeRobot and DROID reading scripts #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8b335ed
293b973
f8f1eb8
48ccb5d
7d0d34c
3c4ae8d
a79b783
e264aba
140c175
49e06db
b39642d
45e1edf
3cbb0d8
6b427b0
fbb5a0e
6f33c34
37944ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| """Build a lazy DROID episode index with Daft's native reader. | ||
|
|
||
| `daft.datasets.droid.raw()` catalogs episodes without decoding video. Filter | ||
| and project first; materialize later. This script only prints the plan. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import daft | ||
| from daft.datasets import droid | ||
|
|
||
|
|
||
| def build_episode_index() -> daft.DataFrame: | ||
| episodes = droid.raw() | ||
| successful = episodes.where(daft.col("success") == daft.lit(True)) | ||
| return successful.select( | ||
| "uuid", | ||
| "scene_id", | ||
| "building", | ||
| "current_task", | ||
| "success", | ||
| "trajectory_length", | ||
| "wrist_cam_video", | ||
| "ext1_cam_video", | ||
| "ext2_cam_video", | ||
| ) | ||
|
|
||
|
|
||
| def main() -> None: | ||
| build_episode_index().explain(show_all=True) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Index a LeRobot v3 dataset without decoding a single video frame. | ||
|
|
||
| `daft.datasets.lerobot` reads a LeRobot dataset lazily: `read_episodes` gives | ||
| one row per episode from the metadata, `read_tasks` the task table, and `read` | ||
| one row per frame with episode metadata broadcast on. Video stays undecoded | ||
| until you ask for it with `load_video_frames`. | ||
|
|
||
| The default dataset is a tiny EgoDex sample (3 episodes / 632 frames) in | ||
| LeRobot v3 format. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
|
|
||
| from daft import col | ||
| from daft.datasets import lerobot | ||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser(description="Episode/task/frame views of a LeRobot dataset.") | ||
| parser.add_argument("--dataset", default="pepijn223/egodex-test", help="HF repo id or path") | ||
| parser.add_argument("--min-length", type=int, default=100, help="episode-length filter to demo") | ||
| args = parser.parse_args() | ||
|
|
||
| episodes = lerobot.read_episodes(args.dataset) | ||
| index = episodes.select("episode_index", "tasks", "length").sort("episode_index").to_pydict() | ||
| print(f"{args.dataset}: {len(index['episode_index'])} episodes") | ||
| for episode_index, tasks, length in zip(index["episode_index"], index["tasks"], index["length"]): | ||
| print(f" episode {episode_index}: {length:4d} frames {tasks[0][:70]}") | ||
|
|
||
| tasks = lerobot.read_tasks(args.dataset).to_pydict() | ||
| print(f"\n{len(next(iter(tasks.values()), []))} distinct tasks in the task table") | ||
|
|
||
| long_episodes = episodes.where(col("length") >= args.min_length) | ||
| frames = lerobot.read(args.dataset).join(long_episodes.select("episode_index"), on="episode_index", how="semi") | ||
| print( | ||
| f"frames in episodes with >= {args.min_length} steps: {frames.count_rows()} " | ||
| f"(selected without touching any video)" | ||
| ) | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| """Merge two LeRobot recording sessions into one table. | ||
|
|
||
| Yesterday's teleop and today's land as two LeRobot datasets, both numbering | ||
| episodes from zero. `episode_index` and the global frame `index` collide, so | ||
| the second session is re-indexed before concat. | ||
|
|
||
| This demo reads the same tiny v3 dataset twice as session A and session B | ||
| (a second public v3 dataset is not available yet). The mechanics are those of | ||
| merging distinct recordings from one rig. Task strings ride on every frame, so | ||
| task identity survives without a `task_index` remap. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
|
|
||
| from daft import col, lit | ||
| from daft.datasets import lerobot | ||
|
|
||
|
|
||
| def main() -> int: | ||
| parser = argparse.ArgumentParser(description="Merge two LeRobot sessions with re-indexed episodes.") | ||
| parser.add_argument("--session-a", default="pepijn223/egodex-test") | ||
| parser.add_argument("--session-b", default="pepijn223/egodex-test") | ||
| args = parser.parse_args() | ||
|
|
||
| frames_a = lerobot.read(args.session_a) | ||
| frames_b = lerobot.read(args.session_b) | ||
|
|
||
| extent = frames_a.agg( | ||
| (col("episode_index").max() + lit(1)).alias("n_episodes"), | ||
| (col("index").max() + lit(1)).alias("n_frames"), | ||
| ).to_pydict() | ||
| episode_offset, frame_offset = extent["n_episodes"][0], extent["n_frames"][0] | ||
|
|
||
| merged = frames_a.concat( | ||
| frames_b.with_column("episode_index", col("episode_index") + lit(episode_offset)).with_column( | ||
| "index", col("index") + lit(frame_offset) | ||
| ) | ||
|
Comment on lines
+37
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For session B, Useful? React with 👍 / 👎. |
||
| ) | ||
|
|
||
| lengths = ( | ||
| merged.groupby("episode_index") | ||
| .agg(col("frame_index").count().alias("frames")) | ||
| .sort("episode_index") | ||
| .to_pydict() | ||
| ) | ||
| print(f"merged: {len(lengths['episode_index'])} episodes / {sum(lengths['frames'])} frames") | ||
| for episode_index, frames in zip(lengths["episode_index"], lengths["frames"]): | ||
| source = "A" if episode_index < episode_offset else "B" | ||
| print(f" episode {episode_index} (session {source}): {frames} frames") | ||
|
|
||
| n_frames = merged.count_rows() | ||
| n_distinct = merged.select("index").distinct().count_rows() | ||
| assert n_frames == n_distinct, "global frame index must stay unique after the merge" | ||
| print(f"\nglobal frame index unique after re-indexing: {n_distinct}/{n_frames}") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When independently recorded sessions assign the same local
task_indexto different task strings—commonly both start at zero—this concatenation leaves those indices unchanged while making the other identifiers global. The merged table then maps one task index to multiple tasks, so downstream grouping, training, or export through the LeRobot task table can silently conflate supervision; retaining the strings does not make the conflicting integer column safe. Build a unified task mapping and rewritetask_index, or drop that column.Useful? React with 👍 / 👎.