Skip to content

Commit 1409829

Browse files
authored
Merge pull request #38 from Eventual-Inc/rewards-demo-read-episodes
feat(rewards): rebuild the demo on lerobot.read_episodes; accept Daft file handles
2 parents fa193e1 + e82d819 commit 1409829

12 files changed

Lines changed: 144 additions & 136 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,12 @@ for RL post-training, or to catch mislabeled tasks.
8888
from daft_physical_ai.rewards import score_rewards
8989

9090
# one row per episode: task text, length, and where its frames live in the video
91+
# (e.g. from daft.datasets.lerobot.read_episodes - the video column can be a
92+
# Daft file handle or a local path string)
9193
df = df.with_column(
9294
"rewards",
9395
score_rewards(
94-
df["task"], df["length"], df["from_ts"], df["to_ts"], df["video_path"],
96+
df["task"], df["length"], df["from_ts"], df["to_ts"], df["video"],
9597
url="http://localhost:8001", # any running Robometer eval server
9698
max_frames=8, # frames sampled per episode
9799
),

TESTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ exit codes (0/1/2) and `--force`.
146146
example's server script, the committed example regenerated against it
147147
(scores identical to the 07-14 run), and a fresh scaffold's `demo.py` run
148148
end to end (3 episodes, values identical, the closing filter flags ep1).
149+
- **read_episodes rewrite (2026-07-16)** - the demo rebuilt on
150+
`daft.datasets.lerobot.read_episodes` (episode rows + video file handles
151+
streamed from the Hub; no `hf_hub_download`, no hardcoded chunk paths) and
152+
`score_rewards` extended to accept a Daft file handle: fresh `modal deploy`,
153+
the committed example regenerated against it - all 5 episodes' per-frame
154+
progress and success identical to the 07-15 run, closing filter still flags
155+
ep1 + ep3.
149156

150157
**Known limitation (not a bug):** executing the rewards demo or its regen
151158
needs a live Robometer eval server (`ROBOMETER_URL`); CI exercises the

daft_physical_ai/_render_rewards.py

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,11 @@ def _demo_cells(config: RewardsDemoConfig) -> list[tuple[str, str]]:
114114
("markdown", intro),
115115
(
116116
"markdown",
117-
"## Setup\n\nInstall with `pip install daft-physical-ai huggingface_hub matplotlib`, then import.",
117+
"## Setup\n\nInstall with `pip install daft-physical-ai matplotlib`, then import.",
118118
),
119119
(
120120
"code",
121-
"import daft\nfrom daft import col, lit\n\nfrom daft_physical_ai.rewards import score_rewards",
121+
"from daft import col\nfrom daft.datasets import lerobot\n\nfrom daft_physical_ai.rewards import score_rewards",
122122
),
123123
(
124124
"markdown",
@@ -136,29 +136,16 @@ def _demo_cells(config: RewardsDemoConfig) -> list[tuple[str, str]]:
136136
("code", _SERVER_CELL),
137137
(
138138
"markdown",
139-
"## Fetch the episode metadata and video\n\nLeRobot v3 stores episode metadata as "
140-
"parquet and concatenates episodes into shared mp4 files. The first metadata and "
141-
"video files cover the first episodes, which is all this demo scores.",
142-
),
143-
(
144-
"code",
145-
"from huggingface_hub import hf_hub_download\n"
146-
"\n"
147-
'meta_path = hf_hub_download(DATASET, f"{SPLIT}/meta/episodes/chunk-000/file-000.parquet", '
148-
'repo_type="dataset")\n'
149-
'video_path = hf_hub_download(DATASET, f"{SPLIT}/videos/{VIDEO_KEY}/chunk-000/file-000.mp4", '
150-
'repo_type="dataset")',
151-
),
152-
(
153-
"markdown",
154-
"## Build the episode DataFrame\n\nOne row per episode: the task text (from the "
155-
"episode's own LeRobot metadata), its length, and where its "
156-
"frames live in the video.",
139+
"## Build the episode DataFrame\n\nOne row per episode, straight from Daft's LeRobot "
140+
"reader: `read_episodes` reads the episode metadata and resolves which shared mp4 "
141+
"holds each episode's footage; `include_video_metadata=True` keeps where in that "
142+
"file the episode lives (`from_timestamp`/`to_timestamp`). Everything streams from "
143+
"the Hub - nothing to download first.",
157144
),
158145
(
159146
"code",
160147
"df = (\n"
161-
" daft.read_parquet(meta_path)\n"
148+
' lerobot.read_episodes(f"hf://datasets/{DATASET}/{SPLIT}", include_video_metadata=True)\n'
162149
' .sort("episode_index")\n'
163150
" .limit(EPISODES)\n"
164151
" .select(\n"
@@ -167,24 +154,24 @@ def _demo_cells(config: RewardsDemoConfig) -> list[tuple[str, str]]:
167154
' "length",\n'
168155
' col(f"videos/{VIDEO_KEY}/from_timestamp").alias("from_ts"),\n'
169156
' col(f"videos/{VIDEO_KEY}/to_timestamp").alias("to_ts"),\n'
170-
' lit(video_path).alias("video_path"),\n'
157+
' col(f"videos/{VIDEO_KEY}/video").alias("video"),\n'
171158
" )\n"
172159
")",
173160
),
174161
(
175162
"markdown",
176163
"## Score the episodes\n\n`score_rewards` returns a reward column: it samples "
177164
"`MAX_FRAMES` frames per episode, decodes them from the episode's segment of the "
178-
"video, and asks the server for per-frame progress + success. It's a lazy async "
179-
"Daft UDF, so nothing runs until we materialize below - and episodes score "
180-
"concurrently when they do.",
165+
"video (streamed through the file handle), and asks the server for per-frame "
166+
"progress + success. It's a lazy async Daft UDF, so nothing runs until we "
167+
"materialize below - and episodes score concurrently when they do.",
181168
),
182169
(
183170
"code",
184171
"df = df.with_column(\n"
185172
' "rewards",\n'
186173
" score_rewards(\n"
187-
' df["task"], df["length"], df["from_ts"], df["to_ts"], df["video_path"],\n'
174+
' df["task"], df["length"], df["from_ts"], df["to_ts"], df["video"],\n'
188175
" url=ROBOMETER_URL, max_frames=MAX_FRAMES, headers=HEADERS,\n"
189176
" ),\n"
190177
")",

daft_physical_ai/cli/rewards.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def run(args: argparse.Namespace) -> int:
171171
print(f" python {out_dir / 'run_robometer_server.py'} # any NVIDIA GPU (A10G/L4 fits the 4B bf16)")
172172
print(f" uvx modal deploy {out_dir / 'modal_eval_server.py'} # Modal (uvx modal setup first)")
173173
print("\nThen run the demo against it (deps fetched on the fly, nothing to install):")
174-
withs = "--with daft-physical-ai --with huggingface_hub --with matplotlib"
174+
withs = "--with daft-physical-ai --with matplotlib"
175175
if have_script:
176176
print(f" ROBOMETER_URL=http://... uv run {withs} {script_path}")
177177
if have_nb:

daft_physical_ai/rewards/__init__.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def score_rewards(
2525
length: Expression,
2626
from_ts: Expression,
2727
to_ts: Expression,
28-
video_path: Expression,
28+
video: Expression,
2929
*,
3030
url: str,
3131
max_frames: int = 8,
@@ -46,7 +46,11 @@ def score_rewards(
4646
length: episode length column (frame count).
4747
from_ts: episode start timestamp column (seconds, in the video).
4848
to_ts: episode end timestamp column (seconds, in the video).
49-
video_path: path column for the video file holding the episode.
49+
video: video column for the file holding the episode - a local path
50+
string or a Daft file handle (e.g. the ``videos/{key}/video``
51+
column from ``daft.datasets.lerobot.read_episodes``; handles are
52+
streamed through Daft's IO layer, so remote ``hf://`` datasets
53+
work without downloading).
5054
url: base URL of a running Robometer eval server (local or remote);
5155
the pipeline doesn't care what's behind it.
5256
max_frames: how many frames to sample per episode (default 8, matching
@@ -62,13 +66,13 @@ def score_rewards(
6266
"""
6367

6468
@daft.func(return_dtype=REWARD_DTYPE)
65-
async def _score(task: str, length: int, from_ts: float, to_ts: float, video_path: str) -> dict:
69+
async def _score(task: str, length: int, from_ts: float, to_ts: float, video) -> dict:
6670
idxs = sample_indexes(int(length), max_frames)
6771
# av decode is blocking; keep it off the event loop so requests overlap.
68-
frames, refs = await asyncio.to_thread(decode_frames, video_path, float(from_ts), float(to_ts), idxs)
72+
frames, refs = await asyncio.to_thread(decode_frames, video, float(from_ts), float(to_ts), idxs)
6973
npy, sample_json = build_request(frames, task)
7074
out = await post_request(url, npy, sample_json, headers=headers, timeout_s=timeout_s)
7175
progress, success = parse_response(out)
7276
return {"reward_score": progress, "robometer_success": success, "reward_frames": refs}
7377

74-
return _score(task, length, from_ts, to_ts, video_path)
78+
return _score(task, length, from_ts, to_ts, video)

daft_physical_ai/rewards/_robometer.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212

1313
import io
1414
import json
15+
from contextlib import contextmanager
16+
from typing import TYPE_CHECKING, Any
1517

1618
import numpy as np
1719

20+
if TYPE_CHECKING:
21+
from collections.abc import Iterator
22+
1823

1924
def sample_indexes(length: int, max_frames: int = 8) -> list[int]:
2025
"""Uniformly sample frame indexes across an episode, first + last always included.
@@ -32,22 +37,39 @@ def sample_indexes(length: int, max_frames: int = 8) -> list[int]:
3237
return sorted({round(i * float(length - 1) / float(max_frames - 1)) for i in range(max_frames)})
3338

3439

35-
def decode_frames(video_path: str, from_ts: float, to_ts: float, want: list[int]) -> tuple[np.ndarray, list[dict]]:
40+
@contextmanager
41+
def _open_container(video: Any) -> Iterator[Any]:
42+
"""Yield an av container for a local path string or a Daft file handle.
43+
44+
A handle (e.g. ``VideoFile`` from ``lerobot.read_episodes``) is streamed
45+
through Daft's IO layer via ``.open()``, so remote schemes like ``hf://``
46+
work; PyAV alone can only open local paths and its own protocols.
47+
"""
48+
import av
49+
50+
if isinstance(video, str):
51+
with av.open(video) as container:
52+
yield container
53+
else:
54+
with video.open() as f, av.open(f) as container:
55+
yield container
56+
57+
58+
def decode_frames(video: Any, from_ts: float, to_ts: float, want: list[int]) -> tuple[np.ndarray, list[dict]]:
3659
"""Decode an episode's segment of a concatenated LeRobot mp4 and pick the wanted frames.
3760
38-
``want`` holds frame indexes relative to the episode start (``from_ts``).
61+
``video`` is a local path string or a Daft file handle. ``want`` holds
62+
frame indexes relative to the episode start (``from_ts``).
3963
Returns ``(frames [N, H, W, 3] uint8, refs)`` where each ref records the
4064
frame's relative index and absolute timestamp in seconds.
4165
"""
42-
import av
43-
4466
want_set = set(want)
4567
frames, refs = [], []
46-
with av.open(video_path) as container:
68+
with _open_container(video) as container:
4769
stream = container.streams.video[0]
4870
time_base = stream.time_base
4971
if time_base is None:
50-
raise ValueError(f"video stream in {video_path} has no time base")
72+
raise ValueError(f"video stream in {getattr(video, 'path', video)} has no time base")
5173
container.seek(max(0, int((from_ts - 1.0) / time_base)), stream=stream)
5274
rel = None
5375
for frame in container.decode(stream):

examples/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ talks to a Robometer eval server you run - the committed
4848
```bash
4949
# serve (pick one), then run against it:
5050
ROBOMETER_URL=http://localhost:8001 \
51-
uv run --with daft-physical-ai --with huggingface_hub --with matplotlib examples/rewards/demo.py
51+
uv run --with daft-physical-ai --with matplotlib examples/rewards/demo.py
5252
```
5353

5454
Generate your own (different dataset, episode count, frame budget):

0 commit comments

Comments
 (0)