Skip to content

feat(datasets): browse a local dataset's episodes in-app#68

Draft
JeremyHX wants to merge 1 commit into
huggingface:mainfrom
VibeCuisine:feat/local-episode-browser
Draft

feat(datasets): browse a local dataset's episodes in-app#68
JeremyHX wants to merge 1 commit into
huggingface:mainfrom
VibeCuisine:feat/local-episode-browser

Conversation

@JeremyHX

Copy link
Copy Markdown

The gap

A dataset that exists on disk can't currently be viewed in LeLab.

The viewer is a window.open deep-link to the hosted lerobot/visualize_dataset Space, which renders from a Hub repo path. Landing.tsx encodes the consequence: handlePickExisting routes source: "local" | "both" datasets to /upload instead. So the dataset you just recorded has to be pushed to the Hub before you can look at it — which is backwards, since checking it is usually why you'd decide whether to keep it.

/edit-dataset has been a routed 15-line placeholder ("This page is under construction") with no backend. This fills it in.

What this adds

Pick a dataset, page through its episodes, and watch any camera frame by frame — with a film strip and an aggregate joint-motion trace, entirely against local files.

GET /dataset-episodes episode list: length, duration, tasks
GET /dataset-episode one episode: per-camera video windows, neighbours
GET /dataset-frame one decoded frame as PNG
GET /dataset-thumbnails film-strip thumbnails, decoded in a single pass
GET /dataset-motion per-frame aggregate joint motion
GET /dataset-video the camera's mp4, inline + Range-capable

The Landing picker now opens local datasets in the browser instead of diverting to /upload. Hub-only datasets still hand off to the Space — there's nothing local to decode. This changes existing behaviour, so it's the piece most worth your opinion; Upload stays reachable from the browse page.

No new dependencies

PyAV, numpy, pyarrow and Pillow all arrive via lerobot[core_scripts,feetech,training], which pyproject.toml already requires (core_scriptsdatasetav-dep). Nothing was added.

episode_media.py reads the v3.0 layout (meta/episodes/*.parquet + videos/) directly and decodes in-process with PyAV. It deliberately avoids LeRobotDataset, so browsing a dataset never constructs a robot config or imports torch — listing and previewing stay cheap.

Two details that are easy to get wrong

Episodes share an mp4. Locating one means resolving the file and its from_/to_timestamp window inside it. /dataset-video is therefore addressed by chunk/file rather than by episode, so the URL stays byte-identical as you page between episodes: the player seeks instead of reloading, and playback survives the switch. An episode-addressed URL hands the browser a new identifier for the same bytes, forcing a reload and silently dropping playback on every click. A test pins this.

duration_s is length / fps, not to_timestamp - from_timestamp. An episode accepted early stops its parquet at accept while the camera ran to the configured limit, so the window over-reports the demonstration (a ~2.6s episode reads as the configured 10s). The window is still exposed for the transport.

Testing

223 passed (187 existing + 36 new). ruff check, ruff format, tsc and eslint are clean, and npm run build passes.

The new tests build a real (tiny) v3.0 dataset on disk — a genuine PyAV-encoded mp4 plus parquet metadata — rather than mocking the readers. The whole point of the module is that it agrees with the on-disk format, which a mock can't check. Covered: path traversal, camera/episode resolution, the packed and pre-packing layouts, the duration_s rule above, decode-lands-in-the-right-window, inline + Range on the video route, and the shared-URL contract.

Verified by hand against two datasets: lerobot/svla_so101_pickplace (50 episodes, 30fps, cameras side/up) and an unrelated in-house one (20fps, three cameras, 7-DoF), so the format assumptions aren't tuned to one producer.

frontend/dist is intentionally not included — build_frontend.yml rebuilds it on merge to main.

Documentation

README.md gains a Browse row; CLAUDE.md gains datasets.py and episode_media.py in the feature-module list, including the chunk/file addressing rationale. A stale comment in server.py ("Replay is rendered by the embedded visualize_dataset Space") is corrected — it's a link-out, not an embed, and local datasets no longer go there.

Unrelated and not touched: the README's ⏪ Replay — "Re-run any recorded episode" row looks stale. ba0ddfb and 8a72410 merged record/replay and dropped the /replay-dataset route, so nothing re-runs an episode on hardware any more. Happy to fix in a separate PR if you agree.

Open question

lelab --dev hardcodes :8000/:8080 with no override, which collides with anything already on :8000. I worked around it locally with a --port uvicorn and the ?api= override. Worth a flag? Separate PR if so.


Contributed by VibeCuisine. Draft — happy to adjust the Landing behaviour change, the route naming, or the scope before this goes green.

A dataset on disk currently can't be viewed at all. The viewer is a
deep-link to the hosted lerobot/visualize_dataset Space, which renders
from a Hub repo path, so Landing sends local datasets to /upload
instead — you have to push a dataset to the Hub before you can look at
what you just recorded. /edit-dataset has been a routed placeholder.

Fill it in: pick a dataset, page its episodes, watch every camera frame
by frame, with a film strip and an aggregate joint-motion trace.

Backend — episode_media.py reads the v3.0 layout (meta/episodes/*.parquet
+ videos/) directly and decodes with PyAV. It deliberately avoids
LeRobotDataset so browsing never builds a robot config or imports torch.
No new dependencies: PyAV, numpy, pyarrow and Pillow all arrive with
lerobot[core_scripts], which is already required.

Notes on two things that are easy to get wrong:

- Episodes share an mp4. Locating one resolves the file *and* its
  from_/to_timestamp window inside it. /dataset-video is therefore
  addressed by chunk/file, not by episode, so the URL stays stable as
  you page between episodes: the player seeks instead of reloading, and
  playback survives the switch. An episode-addressed URL hands the
  browser a new identifier for the same bytes and silently drops
  playback on every click.

- duration_s is length/fps, not to_timestamp - from_timestamp. An
  episode accepted early stops its parquet at accept while the camera
  ran to the configured limit, so the window over-reports the
  demonstration. The window is still exposed for the transport.

Frontend — the Landing picker now opens local/both datasets in the
browser rather than diverting to /upload; Hub-only datasets still go to
the Space, since there's nothing here to decode. Upload stays reachable
from the browse page.

Tests build a real (tiny) v3.0 dataset on disk — a genuine PyAV-encoded
mp4 plus parquet metadata — rather than mocking the readers: the point
of the module is that it agrees with the on-disk format, which a mock
can't check.
@nicolas-rabault

Copy link
Copy Markdown
Member

Nice work!
This is thorough and the tests-against-real-mp4 approach is the right call. One thing to fix before merge, a copyright tweak, plus a note on your two follow-up offers.

Thumbnail index/png misalignment

In extract_thumbnails, a frame that decodes to None is skipped with continue, so pngs can come back shorter than the requested indices. But handle_episode_thumbnails pairs them positionally with zip(indices, pngs, strict=False). If a frame in the middle of the strip fails to decode, every thumbnail after it shifts up one slot, so a tile labeled "frame 8" shows a later frame's image, clicking it seeks the player somewhere the picture never showed, and the strip silently ends short of the last frame. No error, success:true, valid PNGs, purely a wrong-image-under-label bug, and it surfaces exactly on a corrupt/truncated recording, which is when someone's inspecting the strip most carefully.

Suggested fix: keep the index bound to its frame instead of relying on positional alignment. e.g. have extract_thumbnails return (index, png) pairs (or None placeholders for misses) and build the response from those, so a dropped frame can't shift the rest.

Copyright header

episode_media.py carries Copyright 2026 VibeCuisine while the rest of the repo is The HuggingFace Inc. team. Let's merge the two rather than pick one:

# Copyright 2026 VibeCuisine and The HuggingFace Inc. team. All rights reserved.

Your two follow-up PR offers — both agreed

  • README ⏪ Replay row being stale (record/replay merged, /replay-dataset gone): yes, it's stale, go ahead and drop/fix it in its own PR.
  • lelab --dev hardcoding :8000/:8080: a --port override is worth having. A separate PR is the right scope.

Everything else, path-traversal guard, chunk/file video addressing, the duration_s = length/fps rule, sync def routes keeping PyAV decode off the event loop, looks very good.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants