Recording & datasets¶
from strands_robots import Robot
sim = Robot("so100")
sim.start_recording(repo_id="user/my_dataset", task="pick up the cube", fps=50)
sim.run_policy(robot_name="so100", instruction="pick up the cube",
policy_provider="mock", duration=10.0)
sim.stop_recording()
# LeRobot v3 dataset written to ~/.strands_robots/datasets/
start_recording requires [lerobot]. Without it, use start_cameras_recording for plain MP4.
fps must equal the rollout's control_frequency¶
The recorder captures one frame per control step and never decimates, so the
rate frames arrive at is the rollout's control_frequency - and LeRobot derives
every timestamp from the dataset's declared fps positionally
(timestamp = frame_index / fps). A differing fps therefore cannot be
honored, only mislabelled, so it is refused:
sim.start_recording(repo_id="user/my_dataset", task="t", fps=30)
sim.run_policy(robot_name="so100", policy_provider="mock") # default 50.0 Hz
# -> "run_policy: the active recording declares 30 fps but this rollout captures
# at control_frequency=50 Hz. [...] a 1.667x distortion of the episode
# duration [...] Align the two rates: pass control_frequency=30 to
# run_policy(), or record at the rollout's rate"
The refusal lands before any frame is written, so nothing is lost - pass either
rate. It matters beyond the label: that per-frame interval is the control period
a policy trains on, and replay_episode derives its per-frame physics budget
from the dataset rate, so a mislabelled episode also replays at the wrong speed.
To record at a lower rate than you control at, run the rollout at that rate -
there is no decimating recorder.
PolicyRunner is drivable directly, and the engine's check is not on that path,
so PolicyRunner.run / PolicyRunner.evaluate apply the same rule themselves.
They raise ValueError rather than returning an error dict, because a direct
caller has no tool envelope to read:
runner = PolicyRunner(sim)
runner.run("so100", policy, control_frequency=50.0, on_frame=hook)
# -> ValueError: PolicyRunner.run: the active recording declares 30 fps but this
# rollout captures at control_frequency=50 Hz. [...] pass control_frequency=30
# to PolicyRunner.run()
The rule holds whichever call comes first. start_policy returns while its
rollout keeps running, so a recording can be opened against a rollout already in
flight - and on the defaults (fps=30 against control_frequency=50.0) that
recorded a 1.667x mislabelled episode with every call reporting success.
start_recording refuses the same disagreement, before creating the dataset:
sim.start_policy(robot_name="so100", policy_provider="mock") # default 50.0 Hz
sim.start_recording(repo_id="user/my_dataset", task="t", fps=30)
# -> "start_recording: this recording would declare 30 fps but a rollout is
# already running ('so100' at 50 Hz). [...] Align the two rates: record at
# the rollout's rate (start_recording(fps=50)), or restart the rollout at the
# recording's rate (stop_policy(robot_name='so100'), then
# start_policy(..., control_frequency=30))"
Rollouts running at different rates are refused outright, even when fps
matches one of them: their frames interleave into one episode whose single
declared rate cannot describe both, so there is no fps to pass instead. Stop
all but one, or restart them at a common control_frequency.
Selecting which cameras to record¶
By default every camera in the scene is recorded into the dataset - including
the implicit default free camera that exists even before you call
add_camera. A policy that declares a fixed set of image features (e.g. SmolVLA
expects exactly observation.images.camera1/camera2/camera3) then trains
against a dataset that carries a stray observation.images.default view it
never asked for, and the extra MP4 stream bloats every episode.
Pass cameras= to record exactly the views the policy expects:
sim.add_camera(name="camera1", ...)
sim.add_camera(name="camera2", ...)
sim.add_camera(name="camera3", ...)
sim.start_recording(
repo_id="user/my_dataset", task="pick up the cube", fps=50,
cameras=["camera1", "camera2", "camera3"], # drops the implicit 'default'
)
The dataset schema then declares only those three image features. Names may be
given in raw MuJoCo form (arm0/wrist_cam) or schema-safe form
(arm0__wrist_cam); an unknown name fails loudly and lists the available
cameras rather than silently recording the wrong set. Omit cameras= to keep
the legacy behavior of recording every camera - in that case a one-time
warning is logged when the implicit default overview camera is swept in
alongside your real sensor cameras, so the stray view is never recorded
silently.
cameras= is a list of distinct camera names, and every surface that accepts
one - start_recording, render_all, and the plain-MP4
start_cameras_recording / start_cameras_recording_synchronous - enforces that
shape up front. Two mistakes are refused rather than guessed at, because neither
can be honored as written:
- A single name passed as a bare string.
cameras="wrist"is iterable per character, so it would be read as five cameras, one per letter. Wrap it in a list:cameras=["wrist"]. - A repeated name.
cameras=["wrist", "wrist"]cannot mean one camera and cannot mean two. In a dataset schema it collapses to a single column, so the recording declares fewer views than were asked for; inrender_allit renders the same view twice; and in a plain-MP4 recording it opens a second encoder on the one output path, so the camera is rendered and appended twice per capture tick and the artifact ledger reports two files where one exists.
A Mapping is refused for the same reason - it is iterable over its keys, so its
values would be silently discarded. cameras=None keeps its "every camera"
meaning.
Where the dataset is written (root / overwrite)¶
root is the on-disk directory for the dataset (defaults to the LeRobot cache
under repo_id when omitted). Passing an existing empty directory - for
example one returned by tempfile.mkdtemp() - is accepted and recorded into:
import tempfile
root = tempfile.mkdtemp() # existing empty dir
sim.start_recording(repo_id="user/my_dataset", root=root, fps=30) # records here
When root already contains a LeRobotDataset (a meta/ directory),
start_recording resumes it and appends new episodes unless
overwrite=True, which wipes and recreates it. A root that exists, is not a
LeRobotDataset, and is not empty is left untouched and reported as an error
rather than clobbered - pass overwrite=True or choose a new/empty root.
A resume inherits the existing dataset's schema, so fps must match the rate it
was created at - a resume cannot change it. Requesting a different rate is
refused, naming the on-disk value, rather than appending frames timestamped on
the dataset's timebase instead of the one they were captured at:
sim.start_recording(repo_id="user/my_dataset", root=root, fps=60) # dataset is 30 fps
# -> error: "dataset fps differs: on-disk=30 vs requested=60
# (a resumed dataset keeps its on-disk rate; pass fps=30 to append at it)"
Pass fps=30 to append at the dataset's rate, or overwrite=True to record a
fresh dataset at 60.
When you drive recording through the run_policy tool (which owns the
start_recording -> rollout -> stop_recording cycle), forward the same
subset with dataset_cameras=:
from strands_robots.tools.run_policy import run_policy
run_policy(
simulation=sim,
robot_name="so101",
policy_provider="lerobot_local",
instruction="pick up the cube",
n_episodes=1,
dataset_root="/tmp/my_dataset",
dataset_cameras=["camera1", "camera2", "camera3"], # drops the implicit 'default'
)
When set, dataset_cameras is forwarded as start_recording(cameras=...),
which both the MuJoCo and Newton backends support, so the subset is applied
identically on either engine. Omit it (the default None) to record every
scene camera - the default path forwards no cameras kwarg at all.
To also capture a rollout MP4 (the visual artifact for review or VLM
defect-checking), pass the same video={...} config the
Simulation.run_policy facade accepts - the tool forwards it per episode:
run_policy(
simulation=sim,
robot_name="so101",
policy_provider="lerobot_local",
instruction="pick up the cube",
n_episodes=3,
dataset_root="/tmp/my_dataset",
video={"path": "/tmp/rollout.mp4", "fps": 30, "camera": "camera1",
"width": 640, "height": 480},
)
video["path"] is required to enable recording; a falsy/absent path disables
it. Only the keys above are accepted: any other key (filename, resolution,
...) is rejected with an error listing the accepted set, so a mistyped option
cannot silently produce a rollout with no video or a video at the wrong
resolution. For n_episodes > 1 an _ep{i} suffix is inserted before the extension
(/tmp/rollout_ep0.mp4, _ep1, ...) so episodes do not overwrite one another -
matching the facade's own multi-episode naming. The returned {"json": {...}}
block carries video_paths, the list of MP4s that actually landed on disk
(only existing files are reported, never the requested paths).

A SmolVLA-on-SO-101 rollout recorded through the run_policy tool's video=
config (MuJoCo headless, MUJOCO_GL=egl).
Multi-episode recording¶
A recording session is one dataset. The simplest way to collect N episodes in
one session is run_policy(n_episodes=N) - it runs N rollouts back-to-back,
flushes a dataset episode boundary after each, and resets the sim between
episodes for you:
sim.start_recording(repo_id="user/my_dataset", task="pick up the cube", fps=50)
sim.run_policy(robot_name="so100", instruction="pick up the cube",
policy_provider="mock", n_steps=60, n_episodes=20)
sim.stop_recording()
# -> 20 episodes, each with its own episode_index / length / from_index / to_index
n_steps (or duration) is the per-episode horizon. reset_between=False
chains episodes from the previous end state instead of resetting. When a seed
is given it is offset per episode (seed + i) for reproducible-yet-distinct
rollouts, and a video={...} config is written per episode to a path with
_ep{i} inserted before the extension so episodes do not overwrite one another.
The aggregate result carries n_episodes_completed, episodes_saved,
total_steps, and a per-episode list in its {"json": {...}} block.
If you need full control over each rollout (different instructions, custom
randomization, conditional logic between episodes), drive the loop yourself and
call save_episode() after each rollout to flush it as its own episode:
sim.start_recording(repo_id="user/my_dataset", task="pick up the cube", fps=50)
for _ in range(20):
sim.reset()
sim.run_policy(robot_name="so100", instruction="pick up the cube",
policy_provider="mock", n_steps=60)
sim.save_episode() # flush this rollout as one episode
sim.stop_recording() # flushes any trailing rollout automatically
save_episode is idempotent on an empty buffer, so it is safe to call
unconditionally inside a loop. LeRobot computes stats.json per episode and then
aggregates, so per-rollout boundaries keep dataset statistics correct across the
reset() teleport between rollouts.
reset() is itself an episode boundary during a recording: if frames are
buffered when you call it, reset() flushes them as their own episode before
re-initializing the world (it reports the saved episode in its result text).
This means a bare run_policy + reset collection loop already produces one
episode per rollout - the explicit save_episode() is optional when you reset
between rollouts:
sim.start_recording(repo_id="user/my_dataset", task="pick up the cube", fps=50)
for _ in range(20):
sim.run_policy(robot_name="so100", instruction="pick up the cube",
policy_provider="mock", n_steps=60)
sim.reset() # flushes this rollout as one episode, then resets
sim.stop_recording() # flushes any trailing rollout automatically
# -> 20 episodes
Without n_episodes, an explicit save_episode(), or a reset() between rollouts, all
20 rollouts append to the same buffer and stop_recording flushes them as a
single episode_index=0 (1200 steps in one episode). To DISCARD a partial
rollout instead of flushing it on the next reset(), call
clear_episode_buffer() first.
Verifying episode count¶
An LLM agent narrating "20 episodes recorded" is not proof: a single
run_policy(n_episodes=1) (or 20 looped tool calls into one open buffer)
produces one merged episode_index=0 mega-episode while the agent believes it
recorded 20. Never trust agent narration for dataset structure - verify against
the on-disk metadata. After stop_recording, call verify_dataset_episodes:
sim.stop_recording()
result = sim.verify_dataset_episodes(expected=20)
assert result["status"] == "success" # else MISMATCH, fail loud
It checks two independent sources of truth and requires them to AGREE:
- the parquet under
meta/episodes/**/*.parquet(the distinctepisode_indexset - the ground truth), and - the
total_episodesheader inmeta/info.json.
status is "error" when the parquet count differs from expected OR when the
parquet disagrees with info.json (an internally inconsistent dataset, e.g. an
interrupted finalize - sources_agree is then False), so a dataset that
happens to match expected on one source but not the other still fails. The
{"json": {...}} block carries expected, actual, info_total_episodes,
sources_agree, episode_indices, and total_frames for programmatic CI
gating. The pure-pyarrow read_dataset_episode_indices(root) exposes the same
facts without instantiating a LeRobotDataset.
The same check runs from the shell against any LeRobot dataset on disk, with an exit code suitable for CI:
strands-robots verify-dataset /path/to/dataset --expected 20 # exit 0 pass, 1 fail
strands-robots verify-dataset /path/to/dataset --json # machine-readable report
strands-robots verify-dataset /path/to/dataset --no-check-videos # skip the per-episode MP4 checks
verify-dataset reuses the same pure-pyarrow read_dataset_episode_indices
helper (no lerobot import) and flags four failure modes: the mega-episode
(fewer distinct episodes than --expected), meta/info.json total_episodes /
total_frames drifting from the parquet ground truth (caught even without
--expected), any episode below --min-frames (default 1), and - unless
--no-check-videos is passed - any per-episode video file that is missing or
empty on disk. The last check is the video-modality sibling of the
mega-episode class: a dataset can carry the right episode count yet have no
pixels because the recorder's video encoder failed or the MP4 streams were
never written. It resolves each camera's MP4 from meta/info.json's
video_path template and the episode parquet's videos/<key>/chunk_index /
file_index columns, and reports the count it checked in
video_files_checked. The programmatic form is
strands_robots.verify_dataset.verify_dataset(root, expected=None, min_frames=1, check_videos=True),
which returns the same report dict.
verify-dataset always produces a report - it never crashes on the corruption
it exists to flag. A corrupt or foreign meta/episodes parquet, a non-v3
video_path template, or a truncated / unreadable MP4 is reported as a problem
string in the report (and a non-zero exit code), not surfaced as a raw
traceback.
Corruption confined to SOME episode parquet shards (the usual outcome of an
interrupted rsync or hub download) is localised rather than fatal: each
unreadable shard is named as a problem, the readable shards still supply
total_episodes / frames_per_episode, and the info.json, video, and
dead-column checks still run against them. Only a meta/episodes tree with no
readable shard at all reports zero episodes. The same holds for
verify_dataset_episodes, which additionally refuses to certify a dataset with
unreadable shards even when the readable count matches expected - the count is
then only a lower bound, reported in the unreadable_files diagnostics.
Recording paths¶
| Method | Extra needed | Output |
|---|---|---|
start_recording / stop_recording |
[lerobot] |
LeRobot v3 (parquet + MP4) |
save_episode |
[lerobot] |
Close current rollout as one episode (call once per run_policy for N episodes) |
start_cameras_recording / stop_cameras_recording |
[sim-mujoco] alone |
Plain MP4, no parquet |
fps, width, height and max_frames_per_camera on the plain-MP4 recorders
must be positive whole numbers - the same domain run_policy(video={...}),
start_recording(fps=...) and the shared encoder
strands_robots.rendering.encode_clip enforce. An unusable value (fps=0, a
negative frame cap) is a structured error naming the parameter rather than a
recording that reports success and writes no file. Omit width/height to use
each camera's configured resolution.
Encoding frames directly through encode_clip(frames, path, fps=...) follows the
same rule and raises ValueError for a rate it cannot honor - a fractional or
non-positive fps previously produced a clip at some other rate (the GIF writer
clamped it to 1 fps, ffmpeg substituted its own default) or no file at all, with
the output path still returned. encode_clip also raises RuntimeError when the
encoder wrote no clip despite accepting the frames, so a returned path always
names a clip that exists.
Video codec (H.264 default, AV1 opt-in)¶
start_recording (and DatasetRecorder.create/resume) default to
vcodec="h264". H.264 is universally decodable - including by OpenCV's
cv2.VideoCapture, which is what many downstream VLM video readers use to
watch a recorded episode. The recorded per-camera MP4s therefore reopen and
decode everywhere without a transcode step:
import cv2
cap = cv2.VideoCapture(".../videos/observation.images.base/chunk-000/file-000.mp4")
ok, frame = cap.read() # H.264: ok is True, frame is a real (H, W, 3) array
Pass vcodec="libsvtav1" to opt into AV1 for smaller files in
storage-constrained training pipelines. AV1 reads back fine through LeRobot's
own loader (torchcodec/pyav), but OpenCV wheels commonly lack an AV1 decoder
and silently yield 0 frames (VideoCapture.read() returns False
immediately even though the stream has frames) - so avoid AV1 if anything in
your pipeline reads the videos through OpenCV.
The flat vcodec value accepts either codec names (h264, hevc,
libsvtav1) or ffmpeg encoder names (libx264, libx265); it is normalized
to whatever the installed LeRobot version's encoder config expects. An
unsupported codec fails loudly rather than silently reverting to the default.
DatasetRecorder direct API¶
from strands_robots.dataset_recorder import DatasetRecorder
recorder = DatasetRecorder.create(
repo_id="user/my_dataset",
fps=30,
robot_type="so100",
# When recording from a real LeRobot hardware robot pass the schema dicts
# straight through:
# robot_features=robot.observation_features,
# action_features=robot.action_features,
# When recording from a sim Robot (no `observation_features` attr), pass
# `joint_names=[...]` instead - the recorder builds the schema for you.
camera_keys=["default"],
joint_names=["joint_1", "joint_2", "joint_3", "joint_4", "joint_5", "joint_6"],
task="pick up the red cube",
# root=None → ~/.strands_robots/datasets/
# vcodec="h264", streaming_encoding=True, image_writer_threads=4
)
for step in control_loop:
recorder.add_frame(observation, action, task="pick up the red cube")
recorder.save_episode()
recorder.finalize()
recorder.push_to_hub(tags=["so100", "sim"], private=False)
Append to existing dataset (requires lerobot>=0.5.2):
recorder = DatasetRecorder.resume(repo_id="user/my_dataset", task="pick up the blue cube")
recorder.add_frame(observation, action)
recorder.save_episode()
recorder.finalize()
Schema column names must be distinct¶
camera_keys, joint_names and action_names each declare the recorded
dataset's column names, so each must be a list of distinct, non-blank names.
create() refuses anything else before it touches the on-disk target, so a
refused overwrite=True call leaves an existing dataset intact:
DatasetRecorder.create(repo_id="user/d", joint_names="gripper") # ValueError
DatasetRecorder.create(repo_id="user/d", camera_keys=["front", "front"]) # ValueError
Both mistakes used to be accepted and only surfaced in the recorded data:
- A single name passed as a bare string is iterable per character, so
joint_names="gripper"declared seven columns (g,r,i,p,p,e,r).add_framereads each declared name out of the observation, and none of those names is in it, so every column recorded0.0for the whole episode -create(),add_frame(),save_episode()andfinalize()all succeeded. Wrap a single name in a list:joint_names=["gripper"]. - A repeated name collapses where it keys a dict and doubles where it indexes
a position.
camera_keys=["front", "front"]declared one camera column for the two the caller asked for;joint_names=["j1", "j2", "j2"]recordedj2twice and the joint the caller meant not at all.
None and [] still mean "not supplied" - the schema is then derived from
robot_features / action_features, or from joint_names for the action
columns.
Re-recording into an existing repo_id¶
DatasetRecorder.create() builds a fresh dataset. If the resolved dataset
directory already exists, LeRobot's create() would raise a bare
FileExistsError (its mkdir uses exist_ok=False). create() resolves this
up front, matching the start_recording facade:
overwrite=Truewipes the existing directory and creates a fresh dataset.overwrite=False(default) on an existing dataset (a dir containingmeta/) raises a clearFileExistsErrornamingoverwrite=True(fresh) andresume()(append) - not a cryptic LeRobot error, and never a silent clobber.- An existing empty directory (e.g. from
tempfile.mkdtemp()) is cleared socreate()does not dead-end on its own existence guard. - A non-empty non-dataset directory raises
ValueErrorrather than deleting unrelated files.
# Re-run a capture script into the same repo_id, replacing the old dataset:
recorder = DatasetRecorder.create(
repo_id="user/my_dataset", fps=30, joint_names=[...], overwrite=True,
)
Use resume() (not create(overwrite=True)) when you want to append
episodes to the existing dataset instead of replacing it.
A frame the recorder cannot write fails the rollout¶
DatasetRecorder is fail-fast by default (strict=True): if the underlying
LeRobotDataset write fails, it raises
strands_robots.dataset_recorder.RecordingFrameError instead of continuing. When
the recorder is driven by run_policy, that ends the rollout with
status="error" naming the frame the recording stopped being complete at:
Policy failed: dataset add_frame failed after 7 frame(s) written; the recording
is incomplete from this frame on (strict=True, so it is not dropped silently):
<the underlying error>
Continuing past a lost frame is not a smaller failure. Timestamps are derived
positionally from the declared fps, so the surviving frames are re-stamped into
a shorter span than they were captured over - a rollout that loses every other
frame at 50 Hz produces an episode labelled at 2x speed, with no gap to detect.
Construct the recorder with strict=False to trade that for best-effort
recording: a failed write is counted in dropped_frame_count, warned about at
WARNING (on the 1st, 2nd, 4th, 8th ... failure so a 50 Hz loop cannot flood the
log), and the rollout continues.
Instance methods¶
| Method | What |
|---|---|
add_frame(observation, action, task=None, camera_keys=None) |
Append one timestep |
save_episode() |
Flush buffer as a new episode |
clear_episode_buffer() |
Discard current episode |
finalize() |
Write metadata, stats, close writers |
push_to_hub(tags=None, private=False) |
Upload to a versioned HF dataset repo |
sync_to_bucket(bucket, run_id=None, private=True) |
Sync to a mutable HF Storage Bucket (hf://buckets/...) — Xet-deduped collection target; needs the hf CLI. bucket (name or org/name) and run_id (single segment) are allowlist-validated ([A-Za-z0-9._-], no traversal) before the sync |
Read back¶
Fully materialized (downloads everything):
from lerobot.datasets.lerobot_dataset import LeRobotDataset
ds = LeRobotDataset(repo_id="user/my_dataset", root="/tmp/my_dataset")
print(len(ds), ds[0].keys())
Replay an episode¶
sim.replay_episode(repo_id, robot_name=..., episode=0, root=None, speed=1.0)
plays a recorded episode back through the sim: each recorded frame is one
control step, applied via send_action and integrated for a full control period
derived from the dataset fps, so a position-servo robot reproduces the recorded
trajectory. speed scales only the wall-clock playback rate.
Each recorded action index is bound to an action key. By default those are
robot_action_keys(robot_name) — the robot's actuator keys, which is the
ordering the recorder writes the action column in. Pass action_key_map only
when the dataset's action ordering differs:
sim.replay_episode(
"user/my_dataset",
robot_name="so101",
root="/tmp/my_dataset",
action_key_map=["1", "2", "3", "4", "5", "6"], # one key per action index
)
action_key_map must be a non-empty list/tuple of unique strings whose length
equals the recorded action vector's width. A bare string (consumed one key per
character), a non-string entry, a duplicate key, or a width mismatch is rejected
with an actionable error before the dataset is fetched — never truncated to fit.
A "success" status means every frame reached the actuators. If a recorded
action cannot be applied — e.g. the mapped keys resolve to no actuator on this
robot — the replay aborts at that frame and returns status="error" with the
frame index, how many frames were applied, and the unresolved keys:
result = sim.replay_episode("user/my_dataset", robot_name="so101", action_key_map=["wrong"] * 6)
result["status"] # "error"
result["content"][1]["json"]["unresolved_keys"] # ['wrong', ...]
result["content"][1]["json"]["frames_applied"] # 0
Stream back (no full download)¶
sim.stream_dataset() is the in-process counterpart to start_recording /
stop_recording — it reads frames lazily from the Hub (or a local root) via
LeRobot's StreamingLeRobotDataset. Camera frames are decoded on the fly from
the MP4 shards; state/action come from the parquet shards.
from strands_robots import Robot
sim = Robot("so100")
reader = sim.stream_dataset(
"user/my_dataset", # or a local repo_id + root=
root="/tmp/my_dataset",
delta_timestamps={ # optional: stacked time windows + *_is_pad masks
"observation.state": [-0.0667, -0.0333, 0.0],
"action": [0.0, 0.0333, 0.0667],
},
shuffle=False, # chronological for replay/eval
)
print(reader.num_episodes, reader.num_frames, reader.fps)
for frame in reader:
...
# torch DataLoader (shuffles INTERNALLY — do not pass shuffle=True):
for batch in reader.dataloader(batch_size=64, num_workers=4):
...
Equivalently, the standalone reader: from strands_robots import StreamingDatasetReader.
Useful kwargs (forwarded to StreamingLeRobotDataset, version-tolerant):
episodes=[...] (subset without download), buffer_size, max_num_shards,
return_uint8=True (default; halves frame bandwidth), and
drop_videos=True (proprio-only — skips video decode entirely, so it works on
edge devices without a torchcodec wheel; requires delta_timestamps with at
least one non-video key, otherwise open() raises ValueError rather than
silently streaming video anyway).
One kwarg is not tolerant-forwarded because its absence changes semantics:
repo_type="bucket" requires lerobot>=0.6.1 — on older versions open()
raises RuntimeError instead of silently streaming from the versioned dataset
namespace (a different storage system).
For training, the upstream trainer uses the same engine:
python -m lerobot.scripts.lerobot_train --policy.type=act \
--dataset.repo_id=user/my_dataset --dataset.streaming=true --num_workers=4
macOS: video streaming needs Homebrew ffmpeg on the dyld path.
import strands_robotsauto-fixes this (zero-touch); disable withSTRANDS_ROBOTS_NO_DYLD_SHIM=1. See the README "Recording & streaming datasets" section.
See also¶
- Training - what to do with the data.
- Steerable annotation - add language conditioning columns to a recorded dataset.
- LeRobot dataset docs - upstream spec.