Compare commits

...

41 Commits

Author SHA1 Message Date
Steven Palma a56fc0b174 refactor(rollout): integrate feedback -> api, recording, log mut and keyboard 2026-08-07 21:33:17 +02:00
Steven Palma 39c4e746f1 feat(rollout): add subtask command 2026-08-07 16:44:29 +02:00
Steven Palma d3ee0b820c feat(rollout): mute logs in interactive mode 2026-08-07 16:00:56 +02:00
Steven Palma 072c697c0e feat(rollout): interactive v1 2026-08-07 15:22:44 +02:00
alejodosr 266be2bd17 feat(train): add opt-in EMA of the policy weights (--ema.enable=true) (#4323)
* feat(train): add opt-in EMA of the policy weights (--ema.enable=true)

Maintain an EMA shadow via diffusers' EMAModel (lazy import, no new
dependency) with the reference Diffusion Policy schedule. Saves the
shadow for exact resume plus a loadable pretrained_model_ema/ per
checkpoint, evaluates the EMA weights during env eval, and pushes them
to a sibling <repo_id>-ema repo. Fixes huggingface/lerobot#4259.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(diffusion): document the --ema.enable training flag

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tests): skip EMA training tests when accelerate/diffusers are missing

* feat(train): support constant EMA decay (--ema.decay) for openpi-style policies

* fix(train): gate EMA step on sync_gradients; use parallel_dims.is_sharded guard

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 13:52:23 +02:00
Haoming Song ff7cc3de1d fix(train): narrow the accelerate env guard, and fix a device-bound assert (#4347)
Two post-merge CI failures on main, both from #4010.

Benchmark Integration Tests (Libero) — `accelerate launch` exports whole groups
of variables unconditionally (the five ACCELERATE_DYNAMO_* it writes default the
backend to "no"), so matching on prefixes refused launches that configure
nothing, contradicting the documented flow where accelerate is supported as a
plain launcher. The guard now watches only the three switches that hand a
subsystem to the environment.

GPU Tests — `test_metrics_tracker_reduce_across_ranks_invokes_all_reduce`
compared the captured reduction buffer against a CPU tensor, so the assert
raised "Expected all tensors to be on the same device" wherever CUDA is
available. The expected tensor is built on the buffer's device instead.
2026-08-06 18:39:24 +02:00
Hiroaki.Ishikawa 31fedfd9dd fix(dataset): use conservative bounds for quantile aggregation instead of incorrect weighted mean (#3804)
* fix(stats): use conservative bounds for quantile aggregation instead of incorrect weighted mean

* docs: add --overwrite/--skip-images/--root options to augment_dataset_quantile_stats usage

* fix(dataset): clarify quantile aggregation semantics

* fix(augment): handle quantile stats edge cases
2026-08-06 18:39:01 +02:00
Pepijn b1bf24f565 feat(train): split phase timing metrics (#4344) 2026-08-06 17:20:35 +02:00
Haoming Song ef88d4e52b feat(train): parallel training framework — FSDP2, HSDP, gradient accumulation, and DCP checkpoints (#4010)
* feat(train): parallel training engine with FSDP2, HSDP, and DCP checkpoints

Replace the FSDP1 training path with a config-owned parallel-training
engine:

- Topology and runtime configs (--parallelism.*, --accelerator.*):
  dp_replicate x dp_shard degrees select single-process, DDP (unchanged
  default), FSDP2, or HSDP; mixed precision, first-class gradient
  accumulation, and FSDP/DDP tuning knobs are mirrored as plain
  dataclasses that build the accelerate objects at runtime, so every
  run is reproducible from its train_config.json alone. Accelerate env
  vars are guarded against configuring the engine behind the config
  system's back.
- Declarative policy surface: policies declare FSDP2 wrap units
  (_fsdp_wrap_modules) and non-forward entry points
  (_fsdp_forward_methods); a shared engine resolves them around
  accelerator.prepare(). Context-parallel fields are reserved and
  validated to 1.
- Checkpoints: selectable --checkpoint_format (safetensors | dcp |
  safetensors_dcp); the sharded optimizer channel is always DCP;
  two-phase resume (step+RNG before prepare, DCP model/optimizer after)
  reshards across GPU-topology changes; lerobot-convert-dcp merges DCP
  shards into a distributable model.safetensors offline.
- Publishing: PreTrainedPolicy.push_model_to_hub is replaced by the
  free publish_trained_model (model + processors + card + train config,
  all-ranks gather with main-rank writes);
  PreTrainedPolicy._save_pretrained gathers state dicts internally,
  removing the state_dict= threading from save_pretrained.
- lerobot_train is restructured around the engine: optimizer built
  before the single prepare() call, deferred weight load on DCP
  resumes, collective save_checkpoint with no call-site rank branches,
  dp-world-size-based sample accounting.

Breaking changes: FSDP checkpoints from lerobot <= 0.6.x are not
resumable (weights stay loadable via from_pretrained; pin
lerobot==0.6.x to finish old runs); the `accelerate launch
--config_file` yaml flow is superseded by the config flags; training
autocast is owned exclusively by --accelerator.mixed_precision
(policy.dtype only casts parameters).

Also fixes: reward-model hub publishing crash (TypeError on extra
kwargs).

Verified by ~200 new CPU tests (config round-trips, checkpoint
round-trips per format, two-phase resume, publisher contracts,
converter equivalence, accelerate canaries), a 5-test 4-GPU suite
(FSDP2 save/resume bit-exactness, HSDP/DDP loss parity,
changed-topology resume, all-ranks save_pretrained, grad-accum
equivalence), and end-to-end ACT (1/4/8 GPUs) + FastWAM 6B
(FSDP2 + HSDP) training runs.
2026-08-06 19:16:41 +08:00
Pepijn 64b23178d5 feat(data): add recipe-driven language supervision (#4182)
* feat(data): add recipe-driven language supervision

* test(collate): expect preserved language columns

* Address PR review feedback

* Address Claude review feedback
2026-08-04 16:48:47 +02:00
Xingdong Zuo f66e5128ec fix(robots): clamp LeKiwi relative target without KeyError (#4281)
`send_action` keys `arm_goal_pos` as "<motor>.pos" but a Present_Position
read is keyed by bare motor name, so pairing the two raised KeyError as soon
as `max_relative_target` was set, making the safety cap unusable.
SOFollower strips the suffix before the same lookup.
2026-08-04 08:58:53 +02:00
Nikodem Bartnik 1e3a158e13 add third party hardware section (#4308)
* add third party hardware section

* fix links to main

* fix formatting
2026-08-03 18:33:18 +02:00
Steven Palma dc0cee9c75 chore(dependencies): Bump lerobot to 0.6.2 (#4313) 2026-08-03 16:30:05 +02:00
Steven Palma 7e241bd630 chore(dependencies): update uv.lock (#4284)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-03 16:05:01 +02:00
Steven Palma e867359d09 feat(Train): enable buckets with streaming dataset (#4312) 2026-08-03 16:00:48 +02:00
Khalil Meftah f1efa588b8 fix(sarm): fail fast on unusable episode annotations (#4306)
* fix(sarm): warn when dense/sparse targets silently collapse to all-zero

In dense_only/dual modes, if meta/episodes/*.parquet has no usable
subtask columns (column absent or NaN), _load_episode_annotations
returns None and find_stage_and_tau yields stage 0 / tau 0 for every
frame. Training "succeeds" but the head silently learns to predict 0
everywhere, with no warning. This complements #2880 (which restored
loading of episodes_df): there the DataFrame is loaded but the
*_subtask_names column is missing/NaN.

Add a one-time validation at processor construction that logs a clear
warning (all episodes missing -> predict-all-zero; some missing ->
partial). Purely additive logging, no change to training math.

Closes #3842

* fix(sarm): fail fast on unusable episode annotations

---------

Co-authored-by: 1thanShih <Smartshithan1620.en12@nycu.edu.tw>
2026-08-03 13:30:00 +02:00
Steven Palma 3e37269dc6 perf(pi0fast): stop FAST decoding at the end-of-action marker (#4275)
* perf(pi0fast): stop FAST decoding at the end-of-action marker

The decode loop always runs all max_decoding_steps (256) tokens, but
detokenize_actions() cuts the output at the first "|", so most of them get
generated and then thrown away. On LIBERO the marker lands around token 25-35.

Stopping there gives the same actions ~4x faster end to end. Checked on
libero_10: 50/50 episodes came out identical to the old path, same success
and same state trajectories.

* feat(policies): early stopping + termination

---------

Co-authored-by: Thomas Landeg <tomlandeg@gmail.com>
Co-authored-by: Pepijn <138571049+pkooij@users.noreply.github.com>
2026-08-03 13:16:58 +02:00
Caroline Pascal 8b2678318c fix(aggregate stats): fix episode statistics aggregation. Frames and episodes index statistics are offseted and tastk index statistics recomputed from the new labels. (#4276) 2026-08-03 12:25:02 +02:00
Caroline Pascal 6b56bf299b fix(warmup): adding back the no-warmup path in the connect() method of the RealSense camera. (#4301) 2026-08-03 12:17:26 +02:00
Caroline Pascal b1ee35e637 fix(video): only default to torchcodec when it can actually be loaded (#4307)
* fix(video): only default to torchcodec when it can actually be loaded

get_safe_default_video_backend() promotes torchcodec to the default video
backend whenever importlib.util.find_spec("torchcodec") succeeds. But
installed is not the same as loadable: torchcodec links against FFmpeg
shared libraries at runtime, which are commonly absent on Windows (and on
minimal Linux images). find_spec still sees the package, the backend
defaults to torchcodec, and every dataset video decode then crashes with a
DLL-load RuntimeError unless the user knows to pass video_backend="pyav"
explicitly.

Probe the actual import (torchcodec.decoders) instead, and fall back to
pyav with an actionable warning when it fails. On healthy installs the
probed module is exactly what decoding imports anyway, so nothing extra is
loaded.

Tested on Windows 11 (Python 3.12, torchcodec installed via the dataset
extra, no FFmpeg shared libraries): LeRobotDataset video decode previously
required the explicit pyav override and now works with defaults. New unit
tests cover absent / loadable / installed-but-unloadable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reviews): taking reviews into account - Adding RuntimeError in the catch, reformatting the warning message and un-bloating code.

---------

Co-authored-by: Ahmed Sohail Butt <butt4320@mylaurier.ca>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 12:15:02 +02:00
Caroline Pascal 6312be2d3f docs(third party): adding docs for noticeable third party packages (#4026)
* docs(third party): adding a small list of noticeable third party robots packages

* feat(upgrade): adding new robots, teleoperators and sensors to the third party packages list

* feat(format): improving robots and cameras page formatting

* feat(format): formatting third party list

* chore(format): formatting code

* fix(typo): fixing typo

* fix(layout): fixing arrays layout

* fix(tables): fixing tables

* fix(borders): removing tables border lines
2026-08-03 12:01:38 +02:00
Nikodem Bartnik d36f429a30 fix broken link (#4304) 2026-08-03 10:57:07 +02:00
Yuxian LI bad0260a46 fix(cameras): D405 RealSense connection timeout on startup (#3894)
* fix(cameras): add color_format config and auto-recovery for RealSense D405

The D405 delivers color from its stereo depth module, not a dedicated
RGB sensor. The driver previously hardcoded rs.format.rgb8, causing
silent frame capture failure on D405.

Changes:
- Add color_format field to RealSenseCameraConfig (default rgb8, D405
  users set bgr8), validated against whitelist
- Use configured format in _configure_rs_pipeline_config
- Fix _postprocess_image to handle both rgb8 and bgr8 source formats
- Add _hardware_reset auto-recovery: if warmup times out, perform USB
  hardware reset and retry once (common D405 recovery path)
- Fix thread race in _read_loop (local ref to stop_event)

Continuation of #3164 (closed due to deleted fork).

Tested on Intel RealSense D405 at 1280x720@30fps with color_format=bgr8.

* style: fix ruff lint B904 and format

* refactor(cameras): clarify RealSense connection retry

* refactor(cameras): address review, retry RealSense connect before hardware reset

Drop color_format (device-side streaming state was the actual cause), keep _open_pipeline attempt-agnostic, catch only retry-worthy errors, reset only as last resort, guard read loop against late frame publication after stop.

* refactor(cameras): restore BaseException teardown, shorten stop-check comment

* test(cameras): expect ConnectionError after retries are exhausted
2026-08-02 21:54:08 +02:00
Xingdong Zuo adccdea1cf fix(robots): retry LeKiwi bus reads on transient Feetech errors (#4283)
Same fix as #4207 (SO follower/leader), applied to LeKiwi, which reads its
motors through the same Feetech bus and had the same gap: `sync_read` was
called without `num_retry`, so a single corrupted status packet raised
ConnectionError and took the control loop down.

Adds `num_read_retries` (default 2, matching #4207) to LeKiwiConfig and
forwards it at all three read sites. Two of them are Present_Position, as in
#4207; the third is the Present_Velocity read of the omniwheel base, which is
LeKiwi-specific and is where this was observed in the field:

    File "lerobot/robots/lekiwi/lekiwi.py", line 351, in get_observation
      base_wheel_vel = self.bus.sync_read("Present_Velocity", self.base_motors)
    ConnectionError: Failed to sync read 'Present_Velocity' on ids=[7, 8, 9]
    after 1 tries. [TxRxResult] There is no status packet!

All nine servos pinged 20/20 with the bus idle immediately afterwards, so this
is transient corruption under load rather than a wiring fault.

Relates to #4207. Refs #3131.
2026-08-01 22:50:31 +02:00
Dimitar Dimitrov 8135a8a8d1 perf(eval): stop simulating environments whose episode has ended (#4247)
rollout() runs `while not np.all(done)` with `done` latched, so a sub-env that
terminates early keeps being driven -- physics and offscreen rendering included
-- until the slowest sub-env in the batch finishes. A batch of N runs for
max(episode_lengths) iterations to complete work that only needs
mean(episode_lengths), and all of the surplus is discarded.

Adds FreezeAfterEpisodeEnd, applied to each sub-env of the eval vector env. It
caches the terminal transition and replays it for any further step(), and also
absorbs Gymnasium's autoreset -- under AutoresetMode.NEXT_STEP the vector env
otherwise rebuilds a finished sub-env and runs it through an entire extra episode
the rollout throws away. Reward is zeroed on replay so a frozen sub-env cannot
inflate a return if a caller sums over the padded tail.

Thawing is signalled explicitly: rollout() passes NEW_ROLLOUT_OPTION in
reset(options=...). Gymnasium's autoreset calls reset() with no arguments, but so
would a caller passing seeds=None, and inferring from that would strand an env
frozen for a whole rollout.

AutoresetMode.DISABLED is not an alternative -- Gymnasium asserts that no
terminated env is ever stepped in that mode, so the wrapper is never reached. An
earlier version of this patch used it and the vector-env test caught the assert.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:36:51 +02:00
Steven Palma 2aba372b4e feat(ci): re-enable stale issues countdown (#4279) 2026-07-31 19:17:23 +02:00
Steven Palma c841a0c258 docs(evo1): add LIBERO reproduction recipe (#4278)
* docs(evo1): add LIBERO reproduction recipe

* docs(evo1): link verified LIBERO checkpoint

* docs(policies): address comments libero results

---------

Co-authored-by: Xingdong Zuo <18168681+zuoxingdong@users.noreply.github.com>
2026-07-31 17:23:10 +02:00
Steven Palma a3ddba2454 feat(cameras): prefer MJPG when fourcc unspecified in ZMQ (#4277)
* feat(cameras): prefer MJPG when fourcc unspecified; allow fourcc in ZMQ image server

With no fourcc set, OpenCV's V4L2 auto-negotiation selects uncompressed
YUYV when the camera offers it: ~16x the USB bandwidth of MJPG for
identical frames, which silently caps the frame rate on shared USB 2.0
buses (e.g. a Raspberry Pi with multiple cameras).

OpenCVCamera now prefers MJPG when config.fourcc is None: it requests
MJPG and keeps it only if the camera reports support (read-back),
falling back to the camera's own negotiation otherwise. An explicit
config.fourcc always takes precedence, so opting out is fourcc="YUYV".
The preference mirrors the existing pre/post size-and-fps ordering to
preserve the Windows DSHOW FOURCC-override handling.

Also adds a fourcc passthrough to the ZMQ image server, which previously
could not request a pixel format at all.

Behavior change: cameras that relied on implicit uncompressed capture
now receive camera-side JPEG unless fourcc is set explicitly.

* chore(cameras): address feedback

---------

Co-authored-by: Xingdong Zuo <18168681+zuoxingdong@users.noreply.github.com>
2026-07-31 17:13:32 +02:00
Steven Palma 99443a936d fix(train): honor policy.use_amp when the policy has no dtype field (#4274)
* fix(train): honor policy.use_amp when the policy has no dtype field

Since the Accelerate migration, accelerator.autocast() has been a no-op for
policies that do not expose a string dtype field (act, diffusion,
multi_task_dit, ...): PR #3912 wires mixed_precision from policy.dtype, so
these policies resolve to None and always train in full fp32 regardless of
--policy.use_amp=true. Meanwhile lerobot-eval does honor use_amp, and
PreTrainedConfig documents the flag as applying to training and evaluation.

Fall back to use_amp when no dtype field is present: bf16 where supported,
fp16 otherwise (matching torch.autocast's cuda default used by eval).

Measured on multi_task_dit / pusht, batch 256, H100: peak allocated drops
from ~75 GiB (fp32) to ~46.5 GiB under bf16 autocast.

* fix(train): conservative check

---------

Co-authored-by: Reece O'Mahoney <reece.omahoney3@gmail.com>
2026-07-31 16:46:54 +02:00
Steven Palma 7a3298ea26 fix(libero): don't reset inside step() on termination (#4273)
* fix(libero): don't reset inside step() on termination

LiberoEnv.step() called self.reset() when an episode terminated. Gymnasium's
vector envs default to AutoresetMode.NEXT_STEP, so the vector env resets the
sub-env again on the following step -- every termination paid two full resets.

The self-reset was also pure waste: `observation` is built from the terminal
raw_obs before it, so the reset's return value was discarded outright.

Worse, LiberoEnv.reset() advances init_state_id by _reset_stride, so the extra
reset skipped an initial state on every episode.

Measured with a counting subclass under SyncVectorEnv (gymnasium 1.3.0,
terminating every 4 steps over a 14-step loop):

  n_envs=1: 3 terminations -> 6 resets = 1 initial + 3 self + 2 autoreset
  n_envs=2: 6 terminations -> 12 resets = 2 initial + 6 self + 4 autoreset

(the final termination's autoreset does not fire before the loop ends)

Each LiberoEnv.reset() is a full LIBERO reset plus num_steps_wait settle steps,
so on the current default reset path this duplicates roughly 1.3 s per
termination.

Standalone (non-vectorised) users must now call reset() themselves after
termination, which is the Gymnasium contract.

* test(libero): pin the autoreset *default*, not the enum's spelling

The previous assertion checked `AutoresetMode.NEXT_STEP.value == "NextStep"`,
which is a naming detail. If a future Gymnasium flipped the vector-env default
to SAME_STEP, that assertion would still pass and the bug this fixes would come
back as a missing reset instead of a double one.

Assert the observable default instead. Verified on gymnasium 1.1.1 (the floor in
pyproject) and 1.3.0, for both SyncVectorEnv and AsyncVectorEnv. Negative control:
constructing with autoreset_mode=SAME_STEP fails the new assertion and passes the
old one.

Also document why `env._env = inner` is not redundant with the monkeypatched
factory: `LiberoEnv.__init__` defers simulator creation, so pre-binding keeps
`_ensure_env()` a no-op and avoids a stray `reset()` on the mock.

* test(libero): drop the redundant `env._env` prebind

@noron12234 flagged this as redundant. Their stated reason was wrong -- `__init__`
defers simulator creation (`self._env = None`, libero.py:180), so the monkeypatched
factory has not been called by the time it returns -- but the conclusion holds:
`_ensure_env()` pulls the same `inner` from the mocked factory on first `step()`.

I claimed the prebind was load-bearing because a stray `reset()` would pollute the
tests' call counts. That was wrong and I had not run it. Ran both variants against
the real class: the stray reset lands on `inner.reset`, which no assertion touches,
and both tests pass with or without the line. Dropping it.

* refactor(env): add explicit NEXT_STEP

* fix(style): pre-commit

---------

Co-authored-by: Dimitar Dimitrov <dvdimitrov13@gmail.com>
Co-authored-by: Dimitar Dimitrov <60075474+dvdimitrov13@users.noreply.github.com>
2026-07-31 16:39:49 +02:00
Steven Palma 2d8f5f314e feat(env): config to skip the discarded scene rebuild on reset Libero (#4272)
* perf(libero): skip the discarded scene rebuild on reset

LIBERO's OffScreenRenderEnv defaults to hard_reset=True, so every reset() frees
the MjSim, re-serialises the scene with model.get_xml(), recompiles it with
MjSim.from_xml_string(), constructs a fresh offscreen GL context and re-wires
every observable.

When init states are in use, LiberoEnv.reset() immediately calls
set_init_state(), which overwrites the whole sim state -- so all of that work is
discarded. This passes hard_reset=not init_states instead. Without init states
the randomisation reset() performs is the only thing placing the objects, so the
hard reset is kept.

Measured on an RTX 3060 Ti (EGL, robosuite 1.4.0, mujoco 3.2.7, 256x256 x2
cameras), through LiberoEnv.reset(), fresh env per arm:

  suite            hard      soft     saved
  libero_spatial   1697 ms   233 ms   1464 ms
  libero_object    1394 ms   172 ms   1222 ms
  libero_goal      1177 ms   164 ms   1013 ms
  libero_10        1528 ms   207 ms   1321 ms

Equivalence
-----------
Immediately after set_init_state, qpos, qvel, ctrl and act are bit-identical
between the two paths on every suite tested.

After the 10 settle steps that reset() runs, 9 of 41 qpos entries differ:
robot0_joint1..7 (<= 2.4e-5 rad) and gripper0_finger_joint1/2 (<= 2.1e-4 rad).
No object joint differs on any suite. The drift is driven by the gripper
component of the settle action ([0,0,0,0,0,0,-1]); replacing it with zeros keeps
the two paths bit-identical for 12 further steps, and with num_steps_wait=0 there
is no divergence at all.

Wrist-camera pixels can differ by up to ~87/255, because a sub-millimetre finger
displacement crosses rasterisation boundaries at 256x256. The pixel metric badly
overstates the physical difference here; 2.1e-4 rad is 0.012 degrees.

So this is not bit-identical end to end, and reviewers should decide whether
0.012 degrees of gripper drift is acceptable for the benchmark. It does not
change object placement, which is what the fixed init states exist to control.


* refactor(env): config param libero + docs

---------

Co-authored-by: Dimitar Dimitrov <dvdimitrov13@gmail.com>
2026-07-31 16:28:12 +02:00
Lin Junrong 732a12108e fix(so_follower): check the observation for None before copying it (#4255)
Four kinematic processor steps read the observation as

    observation = self.transition.get(TransitionKey.OBSERVATION).copy()
    if observation is None:
        raise ValueError("Joints observation is require for computing robot kinematics")

so `.copy()` runs first and the guard below it is unreachable. A transition
without an observation raises `AttributeError: 'NoneType' object has no
attribute 'copy'` instead of the intended message.

That transition is not hypothetical: `RobotProcessorPipeline.process_action`
builds one with `create_transition(action=action)`, which sets
`TransitionKey.OBSERVATION` to None.

Reads the value first, checks it, then copies. Affects EEReferenceAndDelta,
InverseKinematicsEEToJoints, GripperVelocityToJoint and InverseKinematicsRLStep.
Adds a parametrised regression test covering all four; each fails with the
AttributeError if the fix is reverted.

Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-31 16:12:18 +02:00
Caroline Pascal 29fcf057dc fix(edit-dataset): redirect dataset.root via backup_path from get_output_path (#4271)
* fix(edit-dataset): redirect dataset.root via backup_path returned from get_output_path

The in-place backup logic compared `output_dir` (run through `.resolve()`) to
`dataset.root` (unresolved), so when `HF_LEROBOT_HOME` was a symlink the swap
to the `_old` backup never fired and `_copy_and_reindex_videos` then read from
the now-empty output directory. Have `get_output_path` return the backup path
directly so callers don't rely on path equality.

* feat(samefile): making same file detection more robust using os.path.samefile

---------

Co-authored-by: Reece O'Mahoney <reece.omahoney3@gmail.com>
2026-07-31 16:11:57 +02:00
Steven Palma 81db623b44 chore(dependecies): bump setuptools + pytest + uv version in CI (#4270) 2026-07-31 15:30:33 +02:00
Steven Palma 228ecd480f fix(rollout): apply torch compile mode correctly (#4268)
* fix(rollout): apply torch compile mode correctly

* chore(rollout): clarify intent

---------

Co-authored-by: Patrick Ribbsaeter <patrickswedish@gmail.com>
2026-07-31 14:59:17 +02:00
Nikodem Bartnik e4152a2481 Improved main docs page (#4197)
* extend index.mdx

* bring community section up + bigger logo

* fix formatting

* fix logo size

* text fix

* improved explanation

* fix emoji and formatting

* fix link
2026-07-31 14:53:12 +02:00
Xingdong Zuo 4c12ad427f docs(pi05): LIBERO quickstart with a known-good dataset, checkpoint-loading and quantile-stats guidance (#4186)
- Quickstart on LIBERO: finetune lerobot/pi05_libero_base on lerobot/libero
  with a complete, copy-pasteable command; feature table mapping the dataset
  keys to how pi05 consumes them; gated PaliGemma tokenizer tip.
- Explain --policy.path vs --policy.pretrained_path (weights+config vs
  weights-only) and why n_action_steps/empty_cameras must be passed
  explicitly with pretrained_path.
- Quantile statistics section: the exact error message, the
  lerobot-edit-dataset recompute_stats fix (replacing the removed
  augment_dataset_quantile_stats.py reference), where the result lands, and
  the MEAN_STD alternative.
- Update stale link lerobot/pi05_libero -> lerobot/pi05_libero_base; add
  PyPI install variant.

Co-authored-by: Xingdong Zuo <18168681+zuoxingdong@users.noreply.github.com>
2026-07-31 14:49:30 +02:00
Maxime Ellerbach 6e196eea0e feat(rollout): add smooth_handover flag to DAgger strategy config (#4160)
* feat(rollout): add smooth_handover flag to DAgger strategy config

The DAgger phase transitions run blocking smooth handovers: on pause the
leader is driven to the follower (~2 s), and on correction start the
follower is slid to the teleop pose (~1 s), both inside the record loop.

For clutch-style teleoperators (e.g. VR controllers) that re-reference
their command frame at the current robot pose on engage, the handover is
already continuous — the interpolation only delays the start of the
correction and eats its first frames.

Add --strategy.smooth_handover (default true, existing behavior
unchanged) to let such setups skip it, mirroring the episodic strategy's
smooth_leader_to_follower_handover flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-commit auto-fix (prettier markdown table formatting)

---------

Co-authored-by: griffinaddison <griffinnosidda@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:48:43 +02:00
Maxime Ellerbach 573e7d0243 feat(rollout): add smooth_handover flag to episodic strategy config (#4159)
* feat(rollout): add smooth_handover flag to episodic strategy config

Follow-up to #3985, which added the same flag to the DAgger strategy.

The episodic strategy's reset-phase handover had two gaps:
- Non-actuated teleops could not skip the blocking follower slide at all.
- Setting smooth_leader_to_follower_handover=false on an actuated teleop
  swapped which arm moves instead of skipping the handover.

Add --strategy.smooth_handover (default true, existing behavior
unchanged) as a master switch that skips the interpolation entirely,
for clutch-style teleops that re-reference at the current robot pose
on engage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: fix table formatting via prettier

---------

Co-authored-by: griffinaddison <gaddison@seas.upenn.edu>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:48:17 +02:00
Maxime Ellerbach 4808d8457e fix(vla_jepa): use device-safe autocast instead of hardcoded bfloat16 (#4158)
* fix(vla_jepa): use device-safe autocast instead of hardcoded bfloat16

VLA-JEPA hardcodes torch.autocast with dtype=torch.bfloat16, which
crashes on MPS (no AMP support) and silently misbehaves on pre-Ampere
CUDA GPUs (no bf16). Add a _get_autocast_context() helper that reuses
the existing is_amp_available() utility to pick a safe strategy per
device, matching the pattern used by pi05 and molmoact2.

Fixes #3744

* fix: use _get_autocast_context for fp32 action head (MPS compat)

---------

Co-authored-by: devangpratap <115096812+devangpratap@users.noreply.github.com>
2026-07-31 14:47:53 +02:00
Xingdong Zuo 6f2e71ec31 docs(libero): recommend lerobot/libero dataset, add reproducibility tips (#4185)
- Dataset section: compare lerobot/libero (1.9 GB, MP4) with
  HuggingFaceVLA/libero (69.9 GB, PNG-in-parquet) — same demonstrations
  and schema, equivalent loading throughput, 37x smaller download.
- Training example: use lerobot/libero with video_backend=torchcodec.
- Tips: pin --dataset.revision when reporting results; deterministic
  paired evaluation (seed, init_states, single batch per task); average
  over >=3 eval seeds.

Co-authored-by: Xingdong Zuo <18168681+zuoxingdong@users.noreply.github.com>
2026-07-31 14:47:20 +02:00
140 changed files with 12338 additions and 1932 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ permissions:
contents: read
env:
UV_VERSION: "0.8.0"
UV_VERSION: "0.11.30"
PYTHON_VERSION: "3.12"
# Cancel in-flight runs for the same branch/PR.
+1 -1
View File
@@ -27,7 +27,7 @@ on:
# Sets up the environment variables
env:
UV_VERSION: "0.8.0"
UV_VERSION: "0.11.30"
PYTHON_VERSION: "3.12"
DOCKER_IMAGE_NAME_CPU: huggingface/lerobot-cpu:latest
DOCKER_IMAGE_NAME_GPU: huggingface/lerobot-gpu:latest
+1 -1
View File
@@ -48,7 +48,7 @@ permissions:
# Sets up the environment variables
env:
UV_VERSION: "0.8.0"
UV_VERSION: "0.11.30"
PYTHON_VERSION: "3.12"
# Ensures that only the latest commit for a PR or branch is built, canceling older runs.
+1 -1
View File
@@ -37,7 +37,7 @@ permissions:
# Sets up the environment variables
env:
UV_VERSION: "0.8.0"
UV_VERSION: "0.11.30"
PYTHON_VERSION: "3.12"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu
+1 -1
View File
@@ -27,7 +27,7 @@ on:
# Sets up the environment variables
env:
UV_VERSION: "0.8.0"
UV_VERSION: "0.11.30"
PYTHON_VERSION: "3.12"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:latest-deps
+1 -1
View File
@@ -21,7 +21,7 @@ on:
# Sets up the environment variables
env:
UV_VERSION: "0.8.0"
UV_VERSION: "0.11.30"
PYTHON_VERSION: "3.12"
jobs:
+5 -5
View File
@@ -19,8 +19,8 @@ on:
workflow_dispatch:
# Runs at 02:00
# schedule:
# - cron: "0 2 * * *"
schedule:
- cron: "0 2 * * *"
env:
CLOSE_ISSUE_MESSAGE: >
@@ -31,7 +31,7 @@ env:
Feel free to reopen if is still relevant, or to ping a collaborator if you have any questions.
WARN_ISSUE_MESSAGE: >
This issue has been automatically marked as stale because it has not had
recent activity (1 year). It will be closed if no further activity occurs.
recent activity (1 year). It will be closed if no further activity occurs within 30 days.
Any change, comment or update to this issue will reset this count.
Thank you for your contributions.
WARN_PR_MESSAGE: >
@@ -61,8 +61,8 @@ jobs:
exempt-pr-labels: never-stale
days-before-issue-stale: 365
days-before-issue-close: 30
days-before-pr-stale: 365
days-before-pr-close: 30
days-before-pr-stale: -1
days-before-pr-close: -1
delete-branch: true
close-issue-message: ${{ env.CLOSE_ISSUE_MESSAGE }}
close-pr-message: ${{ env.CLOSE_PR_MESSAGE }}
+457
View File
@@ -0,0 +1,457 @@
# Interactive Rollout — Design Notes
Branch: `feat/add_interactive_rollout` · Status: Phases 12 committed; Round 2
(programmatic API, sentry support, muting v2, stdin move) implemented and tested,
uncommitted.
---
## 1. Vision
`lerobot-rollout` runs inference on a real robot: it connects hardware, loads the policy,
builds the processor pipelines, optionally records a dataset, and spins the control loop.
Today that is a **one-shot, fire-and-forget** program. You pass `--task="pick up the cube"`
on the command line, the robot starts moving immediately, and the only interaction left is
Ctrl-C. If you want a different instruction, you kill the process and pay the full startup
cost again — reconnecting motors, re-homing, re-loading a multi-GB VLA onto the GPU.
Since LeRobot gained subtask annotation and language conditioning, that model is the
bottleneck. The **north star** is a chat-style CLI over stdin, where the operator issues
commands *concurrently with the robot moving*:
```
/start begin (or resume) the policy control loop
/subtask Grab the red cube re-instruct the policy on the fly
/ask what's the capital of France? query an LLM while the robot keeps moving
/reset stop movement, return home, clear the subtask —
but keep hardware and policy warm
/stop graceful shutdown
```
The unifying idea: **the expensive things (hardware, policy weights, processors) stay warm
across commands.** Only the cheap things — the instruction, the control loop — start and
stop. That turns a rollout from a batch job into a session you can steer.
## 2. Objective (scoped)
Phased, so each phase lands as a reviewable unit:
| Phase | Scope | Status |
|---|---|---|
| **1** | `--interactive` flag, non-blocking stdin listener, command parser, `/start` `/reset` `/stop` `/help` | ✅ done |
| **1.5** | Mute system logs so they stop fighting the prompt for the terminal | ✅ done |
| **2** | `/subtask <text>` — change the policy's instruction mid-run | ✅ done |
| **2.5** | Round 2: `RolloutController` public API, sentry recording support, muting v2 (errors surface), stdin listener → `lerobot/utils` | ✅ done (see §5) |
| **3** | `/ask` + hierarchical task-vs-subtask semantics (LLM in the loop) | not started |
An explicit constraint through Phases 12: **do not couple this to the language runtime yet.**
Build the mechanism; keep the door open.
## 3. Inspiration — three reference PRs
We read all three and deliberately implemented none of them verbatim.
**PR #4108 — online subtask switching.** Introduces a `PromptBroker` + `PromptListenerBase`
+ `StdinPromptListener`, a `RuntimeContext.prompt_broker` field, `register_on_change`
callbacks, an `--online_task_switching_flush` config flag, and `flush_action_queue()` /
`_apply_pending_flush()` on `PreTrainedPolicy`**with edits to 14 policy files** to call
the flush at the top of `select_action`. Its architecture is designed for pluggable input
sources (network, voice), which is the right long-term shape but more machinery than we
need. *What we took:* the core insight that a mid-run instruction change must invalidate
actions precomputed under the old instruction, and that the flush must happen on a thread
that is safe to touch policy state from.
**PR #4183 — experimental full-UX draft.** Achieves the whole north-star vision, but does
so by adding a `lerobot.runtime` / `language_runtime.py` that **duplicates** `BaseStrategy`,
`send_next_action`, and the rollout control loop. *What we took:* the UX target and the
command vocabulary. *What we rejected:* the parallel runtime — a second control loop is a
second thing to keep correct, and everything it does is already in `rollout/strategies/`.
**PR #4234 — policy-side edits enabling #4183's runtime.** Read for context on where the
language plumbing lands inside a policy. Relevant to Phase 3, not to what we built.
## 4. What we built, and why
Three commits on the branch:
```
072c697c0 feat(rollout): interactive v1
d3ee0b820 feat(rollout): mute logs in interactive mode
39c4e746f feat(rollout): add subtask command
```
Cumulative footprint — one new module, one new test file, small surgical edits elsewhere:
```
src/lerobot/rollout/interactive.py | 580 +++++ (new)
tests/test_interactive_rollout.py | 788 +++++ (new)
docs/source/inference.mdx | 87 +++
src/lerobot/rollout/inference/base.py | 66 +++
src/lerobot/rollout/inference/rtc.py | 61 +-
src/lerobot/rollout/inference/sync.py | 21 +-
src/lerobot/scripts/lerobot_rollout.py | 32 +-
src/lerobot/policies/pretrained.py | 24 +
src/lerobot/rollout/strategies/core.py | 21 +-
src/lerobot/rollout/configs.py | 18 +
src/lerobot/rollout/__init__.py | 16 +-
src/lerobot/rollout/strategies/episodic.py | 4 +-
```
The ratio matters: **~1400 of ~1680 added lines are the new module and its tests.** The
existing rollout architecture was reused, not reshaped.
### 4.1 Segments over a linked event — the load-bearing idea
Every rollout strategy's control loop already polls `ctx.runtime.shutdown_event.is_set()`
to know when to stop. So instead of teaching strategies about interactivity, we **swap in a
smarter event**:
```python
class LinkedEvent(Event):
"""is_set() reflects the local flag OR a parent event."""
def is_set(self) -> bool:
return super().is_set() or self.parent.is_set()
```
`lerobot-rollout` wraps the `ProcessSignalHandler`'s shutdown event in a `LinkedEvent` when
`--interactive=true`. The session sets the **local** flag to end a run *segment*; SIGINT /
SIGTERM still arrive through the **parent**, so Ctrl-C behaves exactly as before.
`InteractiveSession.run()` then drives `strategy.run(ctx)` in restartable segments:
```
setup(ctx) → [idle] → /start → run(ctx) → /reset → [idle] → /start → run(ctx) → /stop → teardown(ctx)
↑ hardware + policy stay warm throughout
```
**Zero strategy code changed** to support this. The only additions to `strategies/core.py`
were `reset_control_state()` (engine + interpolator + cached-observation reset, factored
out of `_init_engine` so a segment can restart cleanly) and making
`_return_to_initial_position` public.
### 4.2 Threading model
```
listener thread ──publishes flags / strings──▶ main thread
(stdin reader) never touches hardware (session loop → strategy.run → control loop)
never mutates policy state
```
The listener only ever writes `threading.Event` flags and a lock-guarded string. Everything
that touches hardware or policy state happens on the thread that already owns it. This
mirrors the existing DAgger events pattern rather than inventing a new concurrency idiom.
### 4.3 stdin must be read with `os.read`, not `readline`
Non-obvious and load-bearing. The first implementation used `select()` + `stream.readline()`
and **two tests failed**: a buffered file object slurps *several* lines off the file
descriptor in one syscall, after which `select` reports the drained fd as not-ready and the
buffered lines are never delivered. Pasted or piped command batches got stuck. The reader
now does `select()` + `os.read(fd, 4096)` + manual `\n` splitting, with a
blocking-`readline` fallback for streams without a `fileno()` (non-POSIX, test doubles).
Also: unlike `TerminalKeyListener`, this reader leaves the terminal in **canonical mode**
the operator is typing chat commands, not pressing hotkeys.
### 4.4 EOF means stop
A closed stdin means there is no way left to command the robot, so EOF (Ctrl-D, or an
exhausted piped script) stops the session. An unexpected read error is treated the same way,
for the same reason. Consequence, documented: piped scripts must hold stdin open —
```bash
(printf '/start\n'; sleep 60; printf '/stop\n') | lerobot-rollout ... --interactive=true
```
### 4.5 Commands are last-write-wins
`/reset` and `/stop` cancel a still-pending `/start`, so the robot never starts moving after
the operator's most recent command said not to. Handlers set their intent flag *first* and
the segment-stop event *second*; `_run_segment` clears the segment-stop flag *before*
re-checking the intent flags. A `/reset` racing a `/start` is therefore either seen before
the segment begins or ends it on its first tick.
### 4.6 Base strategy only (enforced by config validation)
`--interactive=true` with a recording strategy raises a `ValueError`. Two reasons: recording
strategies finalize their dataset inside `run()` (so `run()` is not restartable), and their
keyboard listeners contend with the command reader for the same TTY. This is a deliberate,
documented limitation — not an oversight.
### 4.7 Log muting (Phase 1.5)
Policy, robot and control-loop logs at every level interleave with the chat prompt and
destroy the typing UX. Simplest workable answer, per explicit request: **mute console output
for the duration of the session.**
- Every logger's console `StreamHandler` is raised above `CRITICAL`**not just root**,
because `transformers` and `datasets` attach their own stderr handlers with
`propagate=False`.
- `warnings.simplefilter("ignore")`, with `warnings.filters` saved and restored.
- **File handlers are untouched** — anyone wanting a persistent log can attach one.
- Restored in `run()`'s `finally`, *before* the closing `log_say`, so teardown logs are visible.
The obvious hazard: muting hides fatal errors. So `InferenceEngine` gained a
`failure_traceback` property, RTC captures its traceback in the fatal handler, and the
session prints it on failure. **Do not remove that when touching the failure path.**
"See both logs and prompt" — a pinned input line, `prompt_toolkit`-style — was deliberately
deferred: it needs a new dependency and a real TUI layer.
### 4.8 `/subtask` — the engine *is* the broker
The pivotal call on Phase 2: **skip PR #4108's `PromptBroker`.** After Phase 1, the session
already owns the stdin thread and the parser, so a broker + listener base + on-change
callbacks + a new `RuntimeContext` field would be duplicate machinery — and callbacks firing
on the listener thread are exactly the cross-thread hazard we designed against.
Instead, `InferenceEngine` (the ABC every backend already implements) became the thread-safe
task holder:
```python
@property
def task(self) -> str: ... # lock-guarded read
def set_task(self, task) -> bool: # callable from ANY thread; True if it changed
...
def _take_task(self) -> tuple[str, bool]: # consumed on the INFERENCE thread;
... # returns (task, changed) and clears the edge
```
`/subtask` is then three lines: read `engine.task`, call `engine.set_task(text)`, print the
transition. No new module, no new context field, no callbacks.
**The flush problem, and why it got small.** When the instruction changes, a chunking policy
is still serving actions computed under the old one — up to `chunk_size` ticks of stale
behavior. PR #4108 solved this by adding `flush_action_queue()` / `_apply_pending_flush()`
to `PreTrainedPolicy` **and editing 14 policy files**, because its flush request arrived from
a foreign thread and had to be deferred to a safe point inside `select_action`.
Ours already runs *on* the thread that calls `select_action`. So: one concrete method on
`PreTrainedPolicy` and **zero per-policy edits**.
```python
def drop_queued_actions(self) -> None:
queues = getattr(self, "_queues", None)
if isinstance(queues, dict) and ACTION in queues:
queues[ACTION].clear()
action_queue = getattr(self, "_action_queue", None)
if action_queue is not None:
action_queue.clear()
```
Two `getattr`s cover the repo's two queue idioms across all ~18 policies
(`_queues[ACTION]`: diffusion, smolvla, tdmpc, vqbet, wall_x, xvla, multi_task_dit, vla_jepa;
`_action_queue`: act, pi0, pi05, pi0_fast, eo1, evo1, groot, molmoact2, fastwam, lingbot_va).
Policies with no queue inherit a no-op.
**Why not `policy.reset()`?** That was the first implementation, and review caught it as too
blunt. For Diffusion it wipes the observation history, so the next chunk is planned from a
history of the current frame repeated — a visible discontinuity mid-motion. And ACT /
Diffusion / VQBeT / TDMPC don't read `task` at all, so they'd pay that jerk for nothing.
`drop_queued_actions` keeps episode state and drops only what is actually stale.
**RTC deliberately does *not* flush.** Clearing its queue would leave the robot with no
commands for a full inference latency (~1 s on a VLA). Instead the next chunk is generated
under the new instruction and merged over the previous chunk's leftover prefix — the switch
lands within one inference and the motion stays continuous. That is exactly what RTC's
blending exists for. Documented per-backend in `inference.mdx`; no config flag, one sensible
default per backend.
**`/reset` restores the launch task on the listener thread.** Subtle and worth preserving:
the restore lives in `_cmd_reset`, not in `_reset_robot` (which runs later, on the main
thread). Otherwise `/reset` followed immediately by `/subtask` would be ordered by *service*
time rather than *command* time, and the deferred restore would silently revert the new
instruction — deterministically so, for pasted or piped input. Both writers now run on the
same thread, so command order wins. There is a regression test driving this through a real pipe.
## 5. Round 2 — the feature becomes a library API
Four follow-up asks landed together (currently uncommitted on the branch):
make the components programmatic-API friendly (the priority), extend interactive
to recording where cheap, simplify muting / surface errors, and settle the
ssh/headless + `keyboard_input` question.
### 5.1 `RolloutController` — programmatic control
`interactive.py` bisected cleanly, so the generic control logic moved to a new
`rollout/controller.py`:
```python
controller = RolloutController(strategy, ctx, on_event=my_observer)
controller.serve() # blocking loop (run it on whatever thread you like)
controller.start() # -> bool: False when a segment is already running
controller.set_task(t) # -> bool: re-instruct mid-run, from any thread
controller.reset() # -> bool: True when the launch task was restored
controller.stop()
controller.task / .initial_task / .running / .failed / .failure_traceback
```
- **No I/O of its own** — no stdin, no prints, no log muting, no TTS. Every
state transition that used to be a `print` is now a `RolloutEvent`
(`SEGMENT_STARTED`, `SEGMENT_ENDED`, `RESET_STARTED/DONE/SKIPPED`,
`ENGINE_FAILED`, `STOPPED`) emitted on the serve thread.
- **Thread-safe by lock, not by convention.** The old ordering guarantee
(`/subtask` right after `/reset` must win) relied on both writes running on
the single stdin thread. The controller serializes `start`/`reset`/`stop`/
`set_task` with an internal lock, so the guarantee now holds for arbitrary
caller threads — the prerequisite for network/voice front-ends.
- `InteractiveSession` shrank to a thin adapter: stdin listener + parser +
rendering + muting; each command maps 1:1 onto a controller method, and the
controller is exposed as `session.controller`.
- Exported from `lerobot.rollout`: `RolloutController`, `RolloutEvent`,
`LinkedEvent`. `docs/source/inference.mdx` gained a **Programmatic control**
section with a complete embedding example.
### 5.2 Sentry + interactive — recording while you steer
Decision, per the agreed criteria: the `/record` keyboard-handoff idea is
**medium-to-large** (listeners have no suspend/resume API and start at
creation, `esc` handlers are hardcoded and collide, pynput captures globally
while you type, and each strategy carries per-run stale flags) → rejected.
But the investigation showed **sentry has zero keyboard code** — the config
comment lumping it with the keyboard strategies was simply wrong — and its
only real blocker was one line: `with VideoEncodingManager(dataset)` inside
`run()` finalizes the dataset the first time `run()` returns, after which a
restarted segment would silently truncate the finalized parquet.
So `--interactive=true` now supports `--strategy.type=sentry`:
- **Finalization moved to `teardown()`** (which already called
`dataset.finalize()`); `run()` is segment-restartable. Each segment saves
complete episodes plus one tail partial episode; on a failed tail save the
in-flight streaming encode is cancelled *and* the half-mutated episode
buffer is discarded (see §6, round 2).
- **Frames are labeled with the live `engine.task`** instead of a config
snapshot — the writer already stores a task per frame — so `/subtask`
changes the policy conditioning and the recorded label from the same frame
onward. This also resolved the "recorded frames ignore `/subtask`" open item
for sentry.
- `episodes_since_push` hoisted to instance state so upload cadence survives
segments.
- dagger / highlight / episodic stay excluded: keyboard conflicts plus per-run
recording state that does not survive a restart.
### 5.3 Muting v2 — two lines, and errors surface
The ~30-line per-handler walk became `logging.disable(logging.WARNING)` with
the previous disable level restored afterwards. Strictly better coverage: the
gate applies before handler dispatch, so it covers `propagate=False` library
loggers *and* loggers created mid-session (the old snapshot missed those) —
and **ERROR/CRITICAL now reach the console**, which the audit showed is safe:
no ERROR-level emitter fires periodically in healthy operation (the periodic
nuisances — slow-loop, camera hiccups — are WARNINGs and stay muted).
Documented trade-off: the gate also withholds INFO/WARNING from file handlers
during the session; acceptable because no default code path attaches one
(only `rl/actor`, `rl/learner`, `async_inference` pass `log_file`). The
`warnings` suppression stays (nothing calls `logging.captureWarnings`), and
`failure_traceback` surfacing stays as the belt-and-suspenders for fatal
engine errors.
### 5.4 stdin listener → `lerobot/utils/stdin_input.py`
The ssh/headless audit confirmed the listener was already the right design:
`select`+`os.read` works over SSH (the session pty is a normal fd), from
pipes, and headless — it's `keyboard_input`'s **pynput** backend that needs a
display server. Nothing in `keyboard_input` overlaps enough to reuse
(1-byte cbreak hotkey decoding vs canonical-mode line assembly), so
`StdinCommandListener` moved to a **new** utils module — deliberately not
into `keyboard_input.py`, which attempts a pynput import at module load.
Canonical import only: `lerobot.utils.stdin_input` (removed from
`lerobot.rollout`'s exports).
The move fixed a real bug the audit found: with `sys.stdin is None`
(daemonized processes), the blocking fallback died with an uncaught
`AttributeError` without firing `on_eof` — leaving a session idling with no
command channel. `start()` now treats a missing stream as immediate EOF.
## 6. Bugs the adversarial reviews caught
Four multi-agent review passes were run across the phases (28 / 5 / 27 / 12 agents;
findings adversarially verified before acting). The ones that mattered:
**Round 2 (2 confirmed, 0 refuted):**
- **Controller `start()` race → phantom segment.** `start()` gated on `_running`, but the
serve loop cleared `_start_requested` *before* setting `_running` — a second `start()`
landing in that window (spanning `reset_control_state` and the SEGMENT_STARTED emission)
returned `True` and re-armed the flag, which nothing consumed during the segment; the
robot would start again, uncommanded, when the segment later ended on its own. Fixed:
the serve loop consumes the request and sets `_running` atomically under the control
lock, and `_running` spans the whole startup sequence.
- **Sentry poisoned episode buffer.** `save_episode` mutates the buffer in place (pops
`size`/`task`) *before* the fallible writes; a failed tail save left a half-mutated dict
and the next segment's first `add_frame` crashed with `KeyError('size')`. Fixed: the
except branch discards the buffer so `add_frame` recreates it.
**Rounds 13 (Phases 12):**
- **RTC stale observation (critical).** `RTCInferenceEngine.reset()` never cleared
`_obs_holder["obs"]`. After `/reset` physically moved the robot home, the next `/start`
computed its first chunk from the **pre-reset pose** — a lurch back toward where the arm
used to be. Fixed by clearing the observation and adding a `_reset_epoch` counter so an
in-flight chunk computed across a reset is discarded rather than merged. This also fixes a
pre-existing DAgger staleness path.
- **Muting hid fatal errors** → `failure_traceback` capture + session print (§4.7).
- **Muting scope too narrow** → root-only missed `transformers` / `datasets`; `warnings`
output bypassed logging entirely.
- **Command ordering** → `/reset` and `/stop` didn't cancel a pending `/start` (§4.5); the
`/reset`-then-`/subtask` clobber (§4.8).
- **Flush too heavy** → `policy.reset()``drop_queued_actions()` (§4.8).
- **Empty-task rendering** → `''` replaced with `(none — set one with /subtask <text>)`.
- **Silent switch** → the confirmation now says "(applies from the next policy inference)",
since the explanatory logs are muted.
## 7. Verification
After Round 2:
```
uv run --extra dataset pytest tests/test_interactive_rollout.py \
tests/utils/test_stdin_input.py tests/test_rollout.py -q
→ 81 passed
pre-commit (all changed files)
→ 0 failures
```
Phase 12 numbers (still green at the time): 64 rollout/interactive tests;
223 passed / 5 skipped across `tests/policies/rtc`, factory, and common
(confirming the shared `pretrained.py` change); pre-commit 0 failures.
`tests/test_interactive_rollout.py` covers the parser, `LinkedEvent` semantics,
`RolloutController` (start/reset/stop/set_task flows, events, startup-race
rejection, failure surfacing, broken observers), session flows (start / reset /
restart / stop, cancel-pending-start, engine failure with traceback, natural
end, EOF, and a real `BaseStrategy` end-to-end), muting (INFO/WARNING blocked,
ERROR surfaces, pre-existing disable level restored), `/subtask` semantics,
sentry restartability + live labels + failed-tail-save recovery, the engine
task holder, the sync flush, and `drop_queued_actions`.
`tests/utils/test_stdin_input.py` covers the listener (select path, batched
lines, blocking fallback, EOF, handler errors, None-stdin, broken streams).
## 8. Extension points for Phase 3
The design was built to make `/ask` an additive change:
- **Command table.** `InteractiveSession._commands` is `name → (handler, arg hint, help)`.
`/help` and the startup banner render from it, so a new command is documented for free.
- **Controller API.** New front-ends (network, voice, `/ask`'s LLM worker) call
`RolloutController.start/reset/stop/set_task` from their own threads — the internal lock
makes that safe — and observe `RolloutEvent`s instead of scraping terminal output.
- **Thread discipline.** A command handler runs on the listener thread and must only call
controller methods. An LLM call belongs on its own worker thread so the robot keeps
moving — precisely the concurrency `/ask` is meant to demonstrate.
- **Task holder.** `set_task` / `_take_task` already give any producer a safe way to
re-instruct the policy. Hierarchical task-vs-subtask semantics (per #4183 / #4234) layer
on top of it rather than replacing it.
Open items, deliberately not addressed:
- dagger / highlight / episodic remain non-interactive (keyboard conflicts + per-run
recording state); they also still snapshot the task label per run. Sentry is the
supported recording path for interactive sessions.
- The "see logs and prompt simultaneously" TUI (pinned input line).
- Non-stdin input sources (network, voice) — now unblocked by `RolloutController`; #4108's
pluggable-listener shape remains the reference for the transport layer.
+17
View File
@@ -128,6 +128,23 @@ lerobot-eval \
Learn how to implement your own simulation environment or benchmark and distribute it from the HF Hub by following the [EnvHub Documentation](https://huggingface.co/docs/lerobot/envhub).
### Third-Party Hardware
Beyond the natively supported hardware, the community maintains a growing ecosystem of plugins for other robots, teleoperators, cameras, and sensors - UFACTORY xArm, Universal Robots UR5e, Franka, AgileX Piper, Trossen WidowX, ARX5, I2RT YAM, GELLO, SpaceMouse, Meta Quest, ROS 2 bridges, tactile and depth cameras, and more.
Plugins are auto-discovered by package name: LeRobot imports any installed package prefixed with `lerobot_robot_`, `lerobot_teleoperator_`, or `lerobot_camera_`. Install one and use the `type` it registers straight from the CLI:
```bash
pip install lerobot_robot_<name> lerobot_teleoperator_<name>
lerobot-record \
--robot.type=<robot_name> \
--teleop.type=<teleoperator_name> \
--dataset.repo_id=${HF_USER}/my-dataset
```
Browse the full list in the [Third-Party Robots & Teleoperators](https://huggingface.co/docs/lerobot/main/third_party_robots) and [Third-Party Cameras & Sensors](https://huggingface.co/docs/lerobot/main/third_party_sensors) documentation.
## Resources
- **[Documentation](https://huggingface.co/docs/lerobot/index):** The complete guide to tutorials & API.
+4
View File
@@ -165,6 +165,8 @@
title: OpenArm
- local: rebot_b601
title: reBot B601-DM
- local: third_party_robots
title: Third-Party Robots & Teleoperators
title: "Robots"
- sections:
- local: phone_teleop
@@ -175,6 +177,8 @@
- sections:
- local: cameras
title: Cameras
- local: third_party_sensors
title: Third-Party Cameras & Sensors
title: "Sensors"
- sections:
- local: notebooks
+12 -2
View File
@@ -161,6 +161,16 @@ The methods called by the train/eval loops:
Batches are flat dictionaries keyed by the constants in [`lerobot.utils.constants`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/utils/constants.py): `OBS_STATE` (`observation.state.<motor>`), `OBS_IMAGES` (`observation.images.<camera>`), `OBS_LANGUAGE`, `ACTION`, etc. Reuse the constants — don't invent new prefixes.
If your model is large enough to warrant [sharded multi-GPU training](./multi_gpu_training#sharded-training-fsdp), also declare its FSDP wrap units — the repeated block classes sharding operates on:
```python
class MyPolicy(PreTrainedPolicy):
...
_fsdp_wrap_modules = ["MyTransformerBlock"]
```
With this one declaration, `--parallelism.dp_shard=N` works out of the box for your policy (users can still override it with `--accelerator.fsdp.wrap_modules`). Without any wrap source, sharded runs fail at startup by design.
### Processor functions
LeRobot uses `PolicyProcessorPipeline`s to normalize inputs and de-normalize outputs around your policy. For a concrete reference, see [`processor_act.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/act/processor_act.py) or [`processor_diffusion.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/diffusion/processor_diffusion.py).
@@ -300,7 +310,7 @@ The file names are load-bearing: the factory does lazy imports by name, and the
Two places need to know about your policy. All by name.
1. **`policies/__init__.py`** — re-export `MyPolicyConfig` and add it to `__all__`. This import is what registers your policy: `@PreTrainedConfig.register_subclass("my_policy")` runs, and from then on the factory resolves everything by convention. **Don't** re-export the modeling class; it loads lazily through the factory (so `import lerobot` stays fast).
2. **`templates/lerobot_modelcard_template.md` and the root `README.md`** — the template is what `push_model_to_hub` renders into the model card of every checkpoint trained with your policy: add a one-line description of your policy in the `model_name` branches, map it in `policy_docs` so cards link to your MDX guide, and optionally add an architecture image to `diagrams`. Then add your policy to the models table in the root `README.md`, under the right category, linking to your doc page.
2. **`templates/lerobot_modelcard_template.md` and the root `README.md`** — the template is what the end-of-training publisher renders into the model card of every checkpoint trained with your policy: add a one-line description of your policy in the `model_name` branches, map it in `policy_docs` so cards link to your MDX guide, and optionally add an architecture image to `diagrams`. Then add your policy to the models table in the root `README.md`, under the right category, linking to your doc page.
Mirror an existing policy that's structurally similar to yours; the diff is small.
@@ -344,7 +354,7 @@ A new policy is much easier to review — and far more useful — when it ships
**Pick at least one in-tree benchmark.** LeRobot ships sim benchmarks with per-benchmark Docker images (LIBERO, LIBERO-plus, Meta-World, RoboTwin 2.0, RoboCasa365, RoboCerebra, RoboMME, VLABench and more). Pick the one that matches your policy's modality — VLAs usually go to LIBERO or VLABench; image-only BC to LIBERO or Meta-World. The full list lives under [Benchmarks](./libero) in the docs sidebar.
**Push the checkpoint & processors** to the Hub under `lerobot/<policy>_<benchmark>` (or your namespace if you don't have write access; a maintainer can mirror it). Use `PreTrainedPolicy.push_model_to_hub` so the repo gets `config.json`, `model.safetensors`, and a model card.
**Push the checkpoint & processors** to the Hub under `lerobot/<policy>_<benchmark>` (or your namespace if you don't have write access; a maintainer can mirror it). The easiest way is training with `--policy.repo_id=<namespace>/<repo>` and `--policy.push_to_hub=true`: `lerobot-train` publishes the model, both processors, and a model card at the end of the run. To publish an existing checkpoint after the fact, upload its `pretrained_model/` directory (e.g. `huggingface-cli upload`), or use `lerobot-convert-dcp --push_to_hub=...` for sharded-format checkpoints.
**Report results in your policy's MDX**, with the exact `lerobot-eval` command and hardware so anyone can re-run:
+218 -11
View File
@@ -23,18 +23,18 @@ The broader EVO1 project may include additional training scripts and dataset too
2. Install EVO1 dependencies:
```bash
pip install -e ".[evo1]"
pip install -e ".[training,evo1]"
```
For LIBERO evaluation, install the LIBERO extra as well:
For LIBERO training and evaluation, install the LIBERO extra as well:
```bash
pip install -e ".[evo1,libero]"
pip install -e ".[training,evo1,libero]"
```
3. Install a `flash-attn` wheel only if it is compatible with your Python, PyTorch, CUDA, and GPU stack. EVO1 falls back to standard attention when `flash_attn` is not available.
EVO1 uses the native Hugging Face `transformers` InternVL implementation, so `policy.vlm_model_name` must point to a natively converted checkpoint such as `OpenGVLab/InternVL3-1B-hf` (note the `-hf` suffix). The first run may download the configured VLM checkpoint unless `policy.vlm_model_name` points to a local model directory.
EVO1 uses the native Hugging Face `transformers` InternVL implementation, so `policy.vlm_model_name` must point to a natively converted checkpoint such as `OpenGVLab/InternVL3-1B-hf` (note the `-hf` suffix). The first run downloads the configured VLM checkpoint and later runs reuse it from the Hugging Face cache.
## Data Requirements
@@ -92,7 +92,7 @@ lerobot-train \
### Stage 2
Stage 2 finetunes the VLM branches and action head. A common workflow starts from a Stage 1 checkpoint:
Stage 2 loads the Stage 1 policy, but starts a fresh optimizer and scheduler:
```bash
lerobot-train \
@@ -152,16 +152,154 @@ lerobot-rollout \
### LIBERO Evaluation
> [!NOTE]
> Benchmark results for a `lerobot`-hosted LIBERO checkpoint trained with this implementation
> will be added once training completes.
#### Reference result
The official EVO1 LIBERO rollout protocol uses the raw LIBERO camera feature names
> [!NOTE]
> The released Stage-2 checkpoint passed clean-download and rollout verification:
> [`zuoxingdong/evo1_libero`](https://huggingface.co/zuoxingdong/evo1_libero), revision
> [`515921f4a2c1d3f3ad523721eafa26fdf2af315b`](https://huggingface.co/zuoxingdong/evo1_libero/commit/515921f4a2c1d3f3ad523721eafa26fdf2af315b).
> The clean-download evaluation used LeRobot revision
> [`e40b58a8dfa9e7b86918c374791599d070518d11`](https://github.com/huggingface/lerobot/commit/e40b58a8dfa9e7b86918c374791599d070518d11).
The single-run Stage-2 checkpoint at step 70,000 produced:
| Suite | Successful episodes | Episodes | Success rate |
| -------------- | ------------------: | --------: | -----------: |
| LIBERO Spatial | 485 | 500 | 97.0% |
| LIBERO Object | 496 | 500 | 99.2% |
| LIBERO Goal | 483 | 500 | 96.6% |
| LIBERO-10 | 469 | 500 | 93.8% |
| **Overall** | **1,933** | **2,000** | **96.65%** |
These results use one trained checkpoint and evaluation seed `1000`; they are not a multi-seed
mean or confidence estimate.
#### Reference training recipe
The released checkpoint records the complete resolved Stage-2 configuration in
[`train_config.json`](https://huggingface.co/zuoxingdong/evo1_libero/blob/515921f4a2c1d3f3ad523721eafa26fdf2af315b/train_config.json).
The measured run used two H100 GPUs with two DDP processes and batch 64 per process, giving global batch 128. Both stages used the same topology. The base VLM came from revision
`014c0583a0d4bedf29fbe2dbff4f865eb998e171` of `OpenGVLab/InternVL3-1B-hf`.
The released artifact does not record the exact LeRobot training commit or its original dependency lock,
so the commands below reproduce the recorded configuration and topology from a current checkout rather
than reconstructing the software environment bit for bit.
From a LeRobot source checkout, install the locked dependencies and download that exact VLM revision:
```bash
uv sync --locked --extra training --extra evo1 --extra libero
VLM_DIR=$(uv run hf download OpenGVLab/InternVL3-1B-hf \
--revision=014c0583a0d4bedf29fbe2dbff4f865eb998e171)
```
Stage 1 freezes the VLM and trains the action head for 5,000 steps:
```bash
uv run accelerate launch --num_processes=2 -m lerobot.scripts.lerobot_train \
--dataset.repo_id=lerobot/libero \
--dataset.revision=a1aaacb7f6cd6ee5fb43120f673cebb0cfea7dd4 \
--dataset.video_backend=torchcodec \
--dataset.return_uint8=true \
--dataset.image_transforms.enable=true \
--dataset.use_imagenet_stats=true \
--dataset.eval_split=0.0 \
--policy.type=evo1 \
--policy.training_stage=stage1 \
--policy.apply_training_stage_defaults=true \
--policy.vlm_model_name="${VLM_DIR}" \
--policy.vlm_num_layers=14 \
--policy.vlm_dtype=bfloat16 \
--policy.device=cuda \
--policy.use_amp=true \
--policy.use_flash_attn=true \
--policy.enable_gradient_checkpointing=true \
--policy.gradient_checkpointing_use_reentrant=false \
--policy.image_resolution='[448,448]' \
--policy.chunk_size=50 \
--policy.n_action_steps=50 \
--policy.max_state_dim=24 \
--policy.max_action_dim=24 \
--policy.dropout=0.2 \
--policy.optimizer_lr=1e-5 \
--policy.optimizer_weight_decay=1e-3 \
--policy.optimizer_grad_clip_norm=1.0 \
--policy.scheduler_warmup_steps=1000 \
--policy.push_to_hub=false \
--use_policy_training_preset=true \
--batch_size=64 \
--steps=5000 \
--save_checkpoint=true \
--save_checkpoint_to_hub=false \
--save_freq=2500 \
--log_freq=10 \
--env_eval_freq=0 \
--num_workers=4 \
--prefetch_factor=2 \
--persistent_workers=true \
--seed=1000 \
--wandb.enable=false \
--output_dir=./outputs/evo1-libero-stage1-g128-5k
```
Stage 2 loads the Stage-1 policy but starts a fresh optimizer and scheduler. It trains for 80,000 steps;
the reported checkpoint is the save at step 70,000:
```bash
uv run accelerate launch --num_processes=2 -m lerobot.scripts.lerobot_train \
--dataset.repo_id=lerobot/libero \
--dataset.revision=a1aaacb7f6cd6ee5fb43120f673cebb0cfea7dd4 \
--dataset.video_backend=torchcodec \
--dataset.return_uint8=true \
--dataset.image_transforms.enable=true \
--dataset.use_imagenet_stats=true \
--dataset.eval_split=0.0 \
--policy.path=./outputs/evo1-libero-stage1-g128-5k/checkpoints/005000/pretrained_model \
--policy.training_stage=stage2 \
--policy.apply_training_stage_defaults=true \
--policy.vlm_model_name="${VLM_DIR}" \
--policy.vlm_num_layers=14 \
--policy.vlm_dtype=float32 \
--policy.device=cuda \
--policy.use_amp=true \
--policy.use_flash_attn=true \
--policy.enable_gradient_checkpointing=true \
--policy.gradient_checkpointing_use_reentrant=false \
--policy.image_resolution='[448,448]' \
--policy.chunk_size=50 \
--policy.n_action_steps=50 \
--policy.max_state_dim=24 \
--policy.max_action_dim=24 \
--policy.dropout=0.2 \
--policy.optimizer_lr=1e-5 \
--policy.optimizer_weight_decay=1e-3 \
--policy.optimizer_grad_clip_norm=1.0 \
--policy.scheduler_warmup_steps=1000 \
--policy.push_to_hub=false \
--use_policy_training_preset=true \
--batch_size=64 \
--steps=80000 \
--resume=false \
--save_checkpoint=true \
--save_checkpoint_to_hub=false \
--save_freq=10000 \
--log_freq=10 \
--env_eval_freq=0 \
--num_workers=4 \
--prefetch_factor=2 \
--persistent_workers=true \
--seed=1000 \
--wandb.enable=false \
--output_dir=./outputs/evo1-libero-stage2-g128-80k
```
#### Author-format evaluation profile
The author-format EVO1 LIBERO profile uses the raw LIBERO camera feature names
(`observation.images.agentview_image` and `observation.images.robot0_eye_in_hand_image`), replans every
14 actions, and binarizes the gripper command before stepping the simulator. The EVO1 policy postprocessor
can crop the padded 24D action back to the 7D LIBERO action space and apply that gripper binarization. To
evaluate a LIBERO checkpoint under the same one-episode-per-task setting, keep the raw camera names instead
of the default `image`/`image2` mapping and set the LIBERO action postprocessing flags:
evaluate an author-format checkpoint under the same one-episode-per-task setting, keep the raw camera names
instead of the default `image`/`image2` mapping and set the LIBERO action postprocessing flags:
```bash
lerobot-eval \
@@ -181,6 +319,75 @@ lerobot-eval \
--eval.n_episodes=1
```
#### Native `lerobot/libero` v3 profile
Revision `a1aaacb7f6cd6ee5fb43120f673cebb0cfea7dd4` stores camera features as `image` and
`image2`. This example evaluates all ten LIBERO Object tasks, launching each task in a fresh process:
```bash
export MUJOCO_GL=egl
export PYOPENGL_PLATFORM=egl
suite=libero_object
horizon=280
for task_id in {0..9}; do
lerobot-eval \
--policy.path=zuoxingdong/evo1_libero \
--policy.pretrained_revision=515921f4a2c1d3f3ad523721eafa26fdf2af315b \
--policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
--policy.device=cuda \
--policy.use_amp=true \
--policy.vlm_dtype=bfloat16 \
--policy.use_flash_attn=false \
--policy.enable_gradient_checkpointing=false \
--policy.vlm_num_layers=14 \
--policy.image_resolution='[448,448]' \
--policy.max_text_length=1024 \
--policy.chunk_size=50 \
--policy.n_action_steps=14 \
--policy.max_state_dim=24 \
--policy.max_action_dim=24 \
--policy.num_inference_timesteps=32 \
--policy.postprocess_action_dim=7 \
--policy.binarize_gripper=true \
--policy.gripper_threshold=0.0 \
--policy.gripper_below_threshold_value=-1.0 \
--policy.gripper_above_threshold_value=1.0 \
--env.type=libero \
--env.task="${suite}" \
--env.task_ids="[${task_id}]" \
--env.camera_name=agentview_image,robot0_eye_in_hand_image \
--env.camera_name_mapping="{agentview_image: image, robot0_eye_in_hand_image: image2}" \
--env.control_mode=relative \
--env.obs_type=pixels_agent_pos \
--env.observation_width=448 \
--env.observation_height=448 \
--env.init_states=true \
--env.episode_length="${horizon}" \
--env.render_mode=rgb_array \
--env.max_parallel_tasks=1 \
--eval.n_episodes=50 \
--eval.batch_size=1 \
--eval.use_async_envs=false \
--eval.recording=false \
--seed=1000 \
--output_dir="./outputs/evo1-libero-stage2-70k-eval/${suite}/task-${task_id}" \
--job_name="evo1-libero-stage2-70k-${suite}-task-${task_id}"
done
```
Run all ten task IDs for each suite with these horizons:
| `env.task` | `env.episode_length` |
| ---------------- | -------------------: |
| `libero_spatial` | `280` |
| `libero_object` | `280` |
| `libero_goal` | `300` |
| `libero_10` | `520` |
Set `suite` and `horizon` for each row. This gives 500 episodes per suite and 2,000 episodes overall, while
the loop's fresh process per task matches the measured RNG-reset topology.
## References
- [EVO1 repository](https://github.com/MINT-SJTU/Evo-1)
+4 -1
View File
@@ -62,7 +62,10 @@ Reference data points on a 4×H100 80 GB cluster (`accelerate launch --num_proce
| `smolvla` | 27m 49s | 0.312 | 0.011 | ~80% | `--policy.path=lerobot/smolvla_base`, `freeze_vision_encoder=false`, `train_expert_only=false` |
| `pi05` | 3h 41m | 2.548 | 0.014 | ~95% | `--policy.pretrained_path=lerobot/pi05_base`, `gradient_checkpointing=true`, `dtype=bfloat16`, vision encoder + expert trained |
The `dataloading_s` vs. `update_s` ratio is the diagnostic that matters: when `dataloading_s` approaches `update_s`, more GPUs stop helping — your dataloader is the bottleneck and you should look at `--num_workers`, image resolution, and disk speed before adding compute.
Training logs separate the full iteration into `dataloading_s` (`next(dl_iter)`), `preprocessing_s`
(image conversion and the policy pipeline), and `update_s` (the optimizer update). `step_s` covers all
three and drives `samples_per_s`. The benchmark above predates this split, so its `dataloading_s` includes
preprocessing.
### Schedule and checkpoints
+163 -9
View File
@@ -1,23 +1,177 @@
# LeRobot
<div class="flex justify-center">
<a target="_blank" href="https://huggingface.co/lerobot">
<img
alt="HuggingFace Expert Acceleration Program"
alt="LeRobot, Hugging Face Robotics Library"
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/lerobot-logo-thumbnail.png"
style="width: 100%"
></img>
</a>
</div>
# LeRobot
**State-of-the-art machine learning for real-world robotics**
🤗 LeRobot aims to provide models, datasets, and tools for real-world robotics in PyTorch. The goal is to lower the barrier for entry to robotics so that everyone can contribute and benefit from sharing datasets and pretrained models.
🤗 LeRobot provides a hardware-agnostic, Python-native interface for controlling real robots - from affordable arms like the SO-ARM101 to full humanoids. Plus the tools to record, store, and share the datasets they generate. Every dataset uses the standardized **LeRobotDataset** format (synchronized video + action/state data) and can be streamed directly from the [Hugging Face Hub](https://huggingface.co/lerobot).
🤗 LeRobot contains state-of-the-art approaches that have been shown to transfer to the real-world with a focus on imitation learning and reinforcement learning.
🤗 On top of that data, LeRobot implements state-of-the-art policies - from lightweight imitation-learning models like ACT to large vision-language-action models like π₀ and SmolVLA - all trainable, shareable, and deployable with the same handful of CLI commands.
🤗 LeRobot already provides a set of pretrained models, datasets with human collected demonstrations, and simulated environments so that everyone can get started.
The goal: lower the barrier to entry for robotics, so that everyone can contribute to, and benefit from, shared datasets and pretrained models.
🤗 LeRobot hosts pretrained models and datasets on the LeRobot HuggingFace page.
<div align="center" style="display: flex; justify-content: center; gap: 8px; flex-wrap: wrap; margin: 20px 0;">
<a href="https://discord.gg/s3KuuzsPFb" target="_blank">
<img alt="Discord" src="https://img.shields.io/badge/Discord-Join_the_Community-5865F2?style=flat&logo=discord&logoColor=white">
</a>
<a href="https://x.com/LeRobotHF" target="_blank">
<img alt="X (Twitter)" src="https://img.shields.io/badge/X-Follow_%40LeRobotHF-black?style=flat&logo=x&logoColor=white">
</a>
<a href="https://huggingface.co/lerobot" target="_blank">
<img alt="Hugging Face Hub" src="https://img.shields.io/badge/HF_Hub-Models_%26_Datasets-FFD21E?style=flat">
</a>
</div>
Join the LeRobot community on [Discord](https://discord.gg/s3KuuzsPFb)
<div align="center">
<img src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/robots_control_video.webp" width="640px" alt="Reachy 2 Demo">
</div>
## How It Works
**Teleoperate → Record → Train → Deploy**
1. **Teleoperate** - control the robot yourself (with a leader arm, keyboard, or phone) so it can learn from your movements.
2. **Record** - each demonstration is saved as a dataset: synchronized camera video plus the actions you took.
3. **Train** - a policy (the neural network that will control the robot) learns to imitate your demonstrations.
4. **Deploy** - run the trained policy on the robot and watch it complete the task on its own.
## Get Started
New here? [Install LeRobot](./installation), then pick your path:
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-6">
<div class="border dark:border-gray-700 rounded-lg p-4 shadow">
<div class="text-lg font-semibold mb-2">🔧 I have a robot</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
LeRobot supports a wide range of arms and mobile robots. Popular picks:
</p>
<ul class="text-gray-700 dark:text-gray-300 text-sm list-disc pl-5 mb-2">
<li>
<a href="./so101">SO-101</a> - our flagship, low-cost arm
</li>
<li>
<a href="./lekiwi">LeKiwi</a> - a mobile base with an arm on top
</li>
<li>
<a href="./koch">Koch v1.1</a> - a long-time community favorite
</li>
<li>
or find yours under <strong>Robots</strong> in the sidebar
</li>
</ul>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Once it's assembled and calibrated, record a dataset and train your first
policy with the <a href="./il_robots">imitation learning tutorial</a> - or
skip the CLI entirely with <a href="./lelab">LeLab</a>, a browser GUI for
the same workflow.
</p>
</div>
<div class="border dark:border-gray-700 rounded-lg p-4 shadow">
<div class="text-lg font-semibold mb-2">💻 No hardware yet</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
You can still train and evaluate policies without owning a robot:
</p>
<ul class="text-gray-700 dark:text-gray-300 text-sm list-disc pl-5 mb-2">
<li>
train on an existing
<a href="https://huggingface.co/datasets?other=LeRobot">
LeRobot dataset
</a>
from the Hub
</li>
<li>
evaluate in <a href="./envhub">simulation</a>, against benchmarks like
LIBERO or Meta-World
</li>
<li>
try the free <a href="./notebooks">Colab notebooks</a> - nothing to
install
</li>
</ul>
</div>
<div class="border dark:border-gray-700 rounded-lg p-4 shadow">
<div class="text-lg font-semibold mb-2">🤝 I want to contribute</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Start with the <a href="./contributing">Contributing guide</a>, then
<a href="./bring_your_own_policies">add a new policy</a> or
<a href="./integrate_hardware">bring your own hardware</a>.
</p>
</div>
</div>
## Explore the Docs
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-6">
<a
class="!no-underline border dark:border-gray-700 rounded-lg p-4 shadow hover:shadow-lg"
href="./cheat-sheet"
>
<div class="font-semibold mb-1">📋 Cheat Sheet</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Every LeRobot CLI command, copy-paste ready.
</p>
</a>
<a
class="!no-underline border dark:border-gray-700 rounded-lg p-4 shadow hover:shadow-lg"
href="./hardware_guide"
>
<div class="font-semibold mb-1">🖥️ Compute & Hardware Guide</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Which policy fits your GPU, and how long training takes.
</p>
</a>
<a
class="!no-underline border dark:border-gray-700 rounded-lg p-4 shadow hover:shadow-lg"
href="./lerobot-dataset-v3"
>
<div class="font-semibold mb-1">🗂️ LeRobotDataset</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Load, stream, and visualize robot datasets from the Hub.
</p>
</a>
<a
class="!no-underline border dark:border-gray-700 rounded-lg p-4 shadow hover:shadow-lg"
href="./lelab"
>
<div class="font-semibold mb-1">🖼 LeLab</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
A browser GUI for calibrating, recording, and training - no CLI required.
</p>
</a>
<a
class="!no-underline border dark:border-gray-700 rounded-lg p-4 shadow hover:shadow-lg"
href="./act"
>
<div class="font-semibold mb-1">🧠 Policies</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Start with ACT, our recommended first policy - or browse SmolVLA, π₀, and
more in the sidebar.
</p>
</a>
<a
class="!no-underline border dark:border-gray-700 rounded-lg p-4 shadow hover:shadow-lg"
href="./envhub"
>
<div class="font-semibold mb-1">🎮 Simulation & Benchmarks</div>
<p class="text-gray-700 dark:text-gray-300 text-sm">
Train and evaluate in simulated environments before touching real
hardware.
</p>
</a>
</div>
## Common Problems
Running into issues? A few of the most frequent ones:
- **Blurry or unusable camera footage** - lighting matters more than resolution. See the [Cameras](./cameras) guide.
- **Build or install errors** (`cmake`, `ffmpeg`, CUDA) - see the Troubleshooting section of the [Installation guide](./installation#troubleshooting).
- **Not sure which policy fits your GPU** - check the [Compute & Hardware Guide](./hardware_guide).
- **Still stuck?** Ask on [Discord](https://discord.gg/s3KuuzsPFb) - the community (and the LeRobot team) is there to help.
+138 -31
View File
@@ -149,13 +149,14 @@ lerobot-rollout \
Foot pedal input is also supported via `--strategy.input_device=pedal`. Configure pedal codes with `--strategy.pedal.*` flags.
| Flag | Description |
| ------------------------------------ | ------------------------------------------------------- |
| `--strategy.num_episodes` | Number of correction episodes to record (default: 10) |
| `--strategy.record_autonomous` | Record autonomous frames too (default: false) |
| `--strategy.upload_every_n_episodes` | Push to Hub every N episodes (default: 5) |
| `--strategy.input_device` | Input device: `keyboard` or `pedal` (default: keyboard) |
| `--teleop.type` | **Required.** Teleoperator type |
| Flag | Description |
| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--strategy.num_episodes` | Number of correction episodes to record (default: 10) |
| `--strategy.record_autonomous` | Record autonomous frames too (default: false) |
| `--strategy.upload_every_n_episodes` | Push to Hub every N episodes (default: 5) |
| `--strategy.input_device` | Input device: `keyboard` or `pedal` (default: keyboard) |
| `--strategy.smooth_handover` | Smoothly hand control over at pause / correction start (default: true). Disable for clutch-style teleops that re-reference at the current robot pose on engage |
| `--teleop.type` | **Required.** Teleoperator type |
### Episodic (`--strategy.type=episodic`)
@@ -186,14 +187,15 @@ Teleop is optional — if omitted the robot holds its position during the reset
| `←` (left) | Discard episode and re-record it |
| `ESC` | Stop the recording session |
| Flag | Description |
| ----------------------------------------------- | -------------------------------------------------------------------------- |
| `--dataset.num_episodes` | Number of episodes to record |
| `--dataset.episode_time_s` | Duration of each recording episode in seconds |
| `--dataset.reset_time_s` | Duration of the reset phase between episodes in seconds |
| `--teleop.type` | Optional. Teleoperator to drive the robot during resets |
| `--strategy.reset_to_initial_position` | Whether to reset the robot to its initial position between episodes |
| `--strategy.smooth_leader_to_follower_handover` | Whether to turn on or off the leader -> follower smooth handover behavior. |
| Flag | Description |
| ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--dataset.num_episodes` | Number of episodes to record |
| `--dataset.episode_time_s` | Duration of each recording episode in seconds |
| `--dataset.reset_time_s` | Duration of the reset phase between episodes in seconds |
| `--teleop.type` | Optional. Teleoperator to drive the robot during resets |
| `--strategy.reset_to_initial_position` | Whether to reset the robot to its initial position between episodes |
| `--strategy.smooth_leader_to_follower_handover` | Whether to turn on or off the leader -> follower smooth handover behavior. |
| `--strategy.smooth_handover` | Smoothly hand control to the teleop at reset start (default: true). Disable for clutch-style teleops that re-reference at the current robot pose on engage |
---
@@ -239,24 +241,129 @@ See the [Real-Time Chunking](./rtc) guide for details on tuning RTC parameters.
---
## Interactive Sessions
Add `--interactive=true` to drive the rollout from the terminal instead of starting immediately. Hardware connects and the policy loads as usual, but **the robot stays still until you type `/start`** — useful when you want to position the scene first, re-instruct the policy between attempts, or run several takes without paying the load time again.
```bash
lerobot-rollout \
--strategy.type=base \
--policy.path=${HF_USER}/my_smolvla_policy \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
--task="pick up the cube" \
--interactive=true
```
| Command | Action |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/start` | Start (or restart) the policy control loop |
| `/subtask <text>` | Change the instruction the policy follows, without stopping. No argument prints the current task. Only affects policies that condition on language (SmolVLA, π0/π0.5, and similar) |
| `/reset` | Stop movement, return the robot to its startup position, and restore the `--task` instruction |
| `/stop` | End the session and run the normal shutdown routines |
| `/help` | List the commands |
```text
> /start
Rollout running — task 'pick up the cube'. /subtask <text> to change it, ...
> /subtask put the cube in the box
Task: 'pick up the cube' → 'put the cube in the box' (applies from the next policy inference)
> /reset
Task restored to 'pick up the cube'
Resetting — returning the robot to its initial position...
Robot reset — holding at initial position. /start to run.
> /stop
```
`Ctrl-C` still shuts down as usual, and closing stdin (`Ctrl-D`, or the end of a piped script) ends the session — so a piped script must keep stdin open for the intended duration:
```bash
(printf '/start\n'; sleep 60; printf '/stop\n') | lerobot-rollout ... --interactive=true
```
**How `/subtask` reaches the policy.** The stdin reader publishes the new instruction to the inference engine, which picks it up on its own inference thread, so nothing is mutated across threads while the robot is moving. How quickly the behavior changes depends on the backend:
- **Sync** (`--inference.type=sync`) — precomputed chunk actions are dropped, so the new instruction applies on the very next control tick. Without this a chunking policy would keep executing up to `chunk_size` stale actions (seconds of the old behavior). Only the queued actions are discarded, so observation history and the rest of the episode state are preserved.
- **RTC** (`--inference.type=rtc`) — the next chunk is generated under the new instruction and merged over the previous chunk's leftover prefix, so the switch lands within one inference and the motion stays continuous. The queue is deliberately not cleared: that would leave the robot without commands for a full inference latency. (With blending turned off via `--inference.rtc.enabled=false` the queued chunk drains first, so the switch lands up to one chunk later.)
With `--use_torch_compile=true`, a switch whose instruction tokenizes to a different length can trigger a recompilation on the next forward pass, pausing inference for as long as the original warm-up took. Prefer leaving compilation off for sessions where you expect to re-instruct the policy often.
**Logs below ERROR are muted while the session runs** so routine output doesn't interleave with what you're typing; errors and fatal inference failures still show, and normal logging resumes when the session ends. The gate is process-wide (it also withholds INFO/WARNING from any file handler you attached for the duration). Run without `--interactive` to watch the live log.
Sessions work over SSH and on headless machines — the command reader uses the terminal (or pipe) directly and needs no display server.
**Recording while interactive.** `--strategy.type=sentry` also supports `--interactive=true`: the session records continuously while you steer it. Each `/start`…`/reset` segment saves complete episodes plus one final partial episode, the dataset stays open until shutdown, and **frames are labeled with the live task** — a `/subtask` changes both the policy conditioning and the recorded label from the same frame onwards.
```bash
lerobot-rollout \
--strategy.type=sentry \
--policy.path=${HF_USER}/my_smolvla_policy \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--dataset.repo_id=${HF_USER}/rollout_cube_sessions \
--task="pick up the cube" \
--interactive=true
```
The other recording strategies (episodic, DAgger, highlight) are not supported: they bind their own keyboard controls, which would compete with the command prompt for the same terminal.
### Programmatic control
Everything the CLI session does is available as a library API: `RolloutController` exposes thread-safe `start()` / `reset()` / `stop()` / `set_task()` methods plus a `RolloutEvent` callback, with no stdin, printing, or log muting attached — embed it in your own application, network server, or notebook:
```python
from threading import Event, Thread
from lerobot.rollout import (
LinkedEvent,
RolloutController,
RolloutEvent,
build_rollout_context,
create_strategy,
)
parent = Event() # your application's shutdown signal
ctx = build_rollout_context(cfg, LinkedEvent(parent)) # loads policy, connects robot
strategy = create_strategy(cfg.strategy)
strategy.setup(ctx)
controller = RolloutController(strategy, ctx, on_event=print) # or your own observer
serve_thread = Thread(target=controller.serve) # serve() blocks; run it where you like
serve_thread.start()
controller.start() # robot starts executing the policy
controller.set_task("grab the red cube") # re-instruct mid-run
controller.reset() # stop movement, return home, stay warm
controller.stop() # end serve()
serve_thread.join()
strategy.teardown(ctx) # teardown stays with the caller
```
Set `play_sounds=False` in the config unless you want the vocal announcements, and note that `build_rollout_context` requires the shutdown event to be a `LinkedEvent` (the controller ends run segments through its local flag; your `parent` event still forces a full shutdown). `InteractiveSession` itself is a thin front-end over this controller — commands map 1:1 onto its methods.
---
## Common Flags
| Flag | Description | Default |
| --------------------------------- | ----------------------------------------------------------------- | ------- |
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
| `--robot.port` | Serial port for the robot | -- |
| `--robot.cameras` | Camera configuration (JSON dict) | -- |
| `--fps` | Control loop frequency | 30 |
| `--duration` | Run time in seconds (0 = infinite) | 0 |
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
| `--task` | Task description (used when no dataset is provided) | -- |
| `--display_data` | Stream telemetry to Rerun visualization | false |
| `--display_ip` / `--display_port` | Remote Rerun server address | -- |
| `--interpolation_multiplier` | Action interpolation factor | 1 |
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
| `--resume` | Resume a previous recording session | false |
| `--play_sounds` | Vocal synthesis for events | true |
| Flag | Description | Default |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
| `--robot.port` | Serial port for the robot | -- |
| `--robot.cameras` | Camera configuration (JSON dict) | -- |
| `--fps` | Control loop frequency | 30 |
| `--duration` | Run time in seconds (0 = infinite) | 0 |
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
| `--task` | Task description (used when no dataset is provided) | -- |
| `--display_data` | Stream telemetry to Rerun visualization | false |
| `--display_ip` / `--display_port` | Remote Rerun server address | -- |
| `--interpolation_multiplier` | Action interpolation factor | 1 |
| `--interactive` | Chat-style stdin session (see [Interactive Sessions](#interactive-sessions)); the robot stays idle until `/start`. Base and sentry strategies | false |
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
| `--resume` | Resume a previous recording session | false |
| `--play_sounds` | Vocal synthesis for events | true |
---
+19 -3
View File
@@ -108,6 +108,7 @@ own binding plus a matching image block, e.g.
```yaml
ask_vqa_top:
route: vqa
bindings:
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.top)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.top)"
@@ -127,7 +128,9 @@ ask_vqa_top:
}
```
Add one such sub-recipe per camera the dataset records.
Add one such sub-recipe per camera the dataset records. The explicit
`route: vqa` marker makes a matching sparse VQA annotation take precedence
over normal weighted blend selection; component names are purely descriptive.
## Layer 3 — training format
@@ -141,7 +144,20 @@ sample["target_message_indices"]
The renderer does not apply a tokenizer chat template. Policy processors decide how to serialize the messages for their backbone, which keeps the same dataset usable across SmolVLA, Pi0.5, and any future VLM that expects OpenAI-style chat messages.
## Blends
Blend recipes select one weighted sub-recipe deterministically from the sample index.
`recipes/subtask_mem.yaml` trains the compact core blend — high-level subtask prediction, low-level execution, and memory. `recipes/subtask_mem_vqa_speech.yaml` is the fuller variant that also adds VQA and spoken interjection responses.
`recipes/subtask_joint.yaml` demonstrates joint sequence training rather than a
weighted blend. For the same sample, its assistant subtask is supervised with
text cross-entropy on the `low_level` stream while action prediction remains
active, matching the joint setup from the π0.5 paper. Enable
`--policy.joint_subtask_conditioning=true` to use that subtask conditioning at inference.
## Graceful absence
If both language columns are missing, `None`, or empty, `RenderMessagesStep` is a no-op.
If an event-scoped branch is selected on a frame without the required event row, rendering returns `None`, allowing a loader to retry another sample.
If both language columns are missing, `None`, or empty, `RenderMessagesStep` uses
the task string as low-level supervision when available and otherwise leaves the
sample unchanged. For an annotated sample, if no recipe branch applies and no
task fallback exists, rendering returns `None`, allowing a loader to retry another sample.
+16
View File
@@ -142,6 +142,22 @@ repo_id = "yaak-ai/L2D-v3"
dataset = StreamingLeRobotDataset(repo_id) # streams directly from the Hub
```
Datasets stored in an [HF Storage Bucket](https://huggingface.co/docs/hub/storage-buckets) (`hf://buckets/`) can be streamed the same way by passing `repo_type="bucket"`:
```python
dataset = StreamingLeRobotDataset("my-org/my-bucket", repo_type="bucket")
```
Both options are available in `lerobot-train` through `--dataset.streaming=true`, and `--dataset.repo_type=bucket` to stream from a bucket instead of a Hub dataset repo:
```bash
lerobot-train \
--dataset.repo_id=my-org/my-bucket \
--dataset.repo_type=bucket \
--dataset.streaming=true \
...
```
<div style="display:flex; justify-content:center; gap:12px; flex-wrap:wrap;">
<figure style="margin:0; text-align:center;">
<img
+46 -12
View File
@@ -92,6 +92,20 @@ LIBERO supports two control modes — `relative` (default) and `absolute`. Diffe
--env.control_mode=relative # or "absolute"
```
### Reset performance
By default, LeRobot preserves LIBERO's hard-reset behavior. With fixed initial
states enabled, you can opt into soft resets to skip rebuilding the simulator
model and renderer on every episode:
```bash
--env.init_states=true --env.hard_reset=false
```
Soft resets are faster but are not bit-identical to hard resets after the
environment's settling steps, so camera observations and policy results may
differ slightly. Use hard resets when reproducing benchmark results.
### Policy inputs and outputs
**Observations:**
@@ -114,38 +128,58 @@ LIBERO supports two control modes — `relative` (default) and `absolute`. Diffe
### Recommended evaluation episodes
For reproducible benchmarking, use **10 episodes per task** across all four standard suites (Spatial, Object, Goal, Long). This gives 400 total episodes and matches the protocol used for published results.
For reproducible benchmarking, use **10 episodes per task** across all four standard suites (Spatial, Object, Goal, Long). This gives 400 total episodes and matches the protocol used for published results. Success rates may vary by a few percent across evaluation seeds, so we recommend averaging over 3 seeds.
<Tip>
To compare two policies on the same episodes, use the same `--seed`, keep
`--env.init_states=true`, and run each task in a single batch
(`--eval.batch_size` equal to episodes per task).
</Tip>
## Training
### Dataset
We provide a preprocessed LIBERO dataset fully compatible with LeRobot:
Two preprocessed LIBERO datasets are fully compatible with LeRobot. They contain the same demonstrations with the same schema and differ in how camera frames are stored:
- [HuggingFaceVLA/libero](https://huggingface.co/datasets/HuggingFaceVLA/libero)
| | [lerobot/libero](https://huggingface.co/datasets/lerobot/libero) | [HuggingFaceVLA/libero](https://huggingface.co/datasets/HuggingFaceVLA/libero) |
| ------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| episodes / frames / tasks | 1,693 / 273,465 / 40 | 1,693 / 273,465 / 40 |
| cameras | 2× 256×256×3 | 2× 256×256×3 |
| state / action dims | 8 / 7 | 8 / 7 |
| dataset format | v3.0 | v3.0 |
| camera encoding | MP4 video | PNG in parquet |
| download size | **1.9 GB** | 69.9 GB |
| extra dependency | video backend (`torchcodec` or `pyav`) | none |
**We recommend [lerobot/libero](https://huggingface.co/datasets/lerobot/libero)**: **37× smaller download** with **equivalent loading speed** (~330 samples/s per worker). Video re-encoding is slightly lossy; use the image-based variant if you cannot install a video decoding backend.
For reference, the original dataset published by Physical Intelligence:
- [physical-intelligence/libero](https://huggingface.co/datasets/physical-intelligence/libero)
<Tip>
Pin `--dataset.revision=<commit-sha>` when reporting results — Hub datasets can be re-uploaded, and success rates are only comparable against the same data revision.
</Tip>
### Example training command
Train SmolVLA on the recommended dataset:
```bash
lerobot-train \
--policy.type=smolvla \
--policy.repo_id=${HF_USER}/libero-test \
--policy.load_vlm_weights=true \
--dataset.repo_id=HuggingFaceVLA/libero \
--env.type=libero \
--env.task=libero_10 \
--output_dir=./outputs/ \
--policy.push_to_hub=false \
--dataset.repo_id=lerobot/libero \
--dataset.video_backend=torchcodec \
--output_dir=./outputs/libero_smolvla \
--steps=100000 \
--batch_size=4 \
--eval.batch_size=1 \
--eval.n_episodes=1 \
--env_eval_freq=1000
--batch_size=64
```
To share the result on the Hub, replace `--policy.push_to_hub=false` with `--policy.repo_id=${HF_USER}/libero-smolvla`. Evaluate saved checkpoints with `lerobot-eval` as shown in the [Evaluation](#evaluation) section.
## Reproducing published results
We reproduce the results of Pi0.5 on the LIBERO benchmark. We take the Physical Intelligence LIBERO base model (`pi05_libero`) and finetune for an additional 6k steps in bfloat16, with batch size of 256 on 8 H100 GPUs using the [HuggingFace LIBERO dataset](https://huggingface.co/datasets/HuggingFaceVLA/libero).
+14
View File
@@ -134,6 +134,20 @@ LIBERO-plus supports two control modes — `relative` (default) and `absolute`.
--env.control_mode=relative # or "absolute"
```
### Reset performance
By default, LeRobot preserves LIBERO's hard-reset behavior. With fixed initial
states enabled, you can opt into soft resets to skip rebuilding the simulator
model and renderer on every episode:
```bash
--env.init_states=true --env.hard_reset=false
```
Soft resets are faster but are not bit-identical to hard resets after the
environment's settling steps, so camera observations and policy results may
differ slightly. Use hard resets when reproducing benchmark results.
### Policy inputs and outputs
**Observations:**
+11
View File
@@ -242,6 +242,17 @@ python src/lerobot/scripts/augment_dataset_quantile_stats.py \
--repo-id=your_dataset
```
Recording, resuming, and merging aggregate quantiles from per-episode summaries, so `meta/stats.json` ends up holding a conservative envelope (`min` for `q <= 50`, `max` for `q > 50`) rather than whole-dataset quantiles. To estimate the latter, scan every episode with a running histogram:
```bash
python src/lerobot/scripts/augment_dataset_quantile_stats.py \
--repo-id=your_dataset \
--overwrite \
--skip-images
```
`--skip-images` keeps the existing image statistics and avoids video decoding when only `STATE`/`ACTION` need recomputing, and `--root` reads a local dataset instead of the Hub. These values are histogram estimates, subject to discretization and rebinning error, so they can differ from the conservative ones — which changes MolmoAct2's normalized targets and therefore its loss scale. Statistics already saved inside an existing checkpoint are not affected.
Alternatively, train MolmoAct2 with mean/std normalization:
```bash
+114 -118
View File
@@ -1,28 +1,29 @@
# Multi-GPU Training
This guide shows you how to train policies on multiple GPUs using [Hugging Face Accelerate](https://huggingface.co/docs/accelerate).
LeRobot trains on multiple GPUs through [Hugging Face Accelerate](https://huggingface.co/docs/accelerate). Three data-parallel layouts are supported:
| Layout | What it does | Config |
| -------- | ------------------------------------------------------------- | ------------------------------------------------------- |
| **DDP** | Replicates the full model on every GPU | default on any multi-GPU launch |
| **FSDP** | Shards parameters, gradients, and optimizer state across GPUs | `--parallelism.dp_shard=N` |
| **HSDP** | Shards within groups of GPUs, replicates across groups | `--parallelism.dp_replicate=R --parallelism.dp_shard=S` |
## Installation
`accelerate` is included in the `training` extra. Install it with:
`accelerate` is included in the `training` extra:
```bash
pip install 'lerobot[training]'
```
## Training with Multiple GPUs
## Launching
You can launch training in two ways:
Distributed training can be launched through both `torchrun` and `accelerate launch`. Accelerate is used as a plain launcher: it does not manage the training configuration, and every distributed training setting lives in LeRobot's own config system.
### Option 1: Without config (specify parameters directly)
You can specify all parameters directly in the command without running `accelerate config`:
With `torchrun`:
```bash
accelerate launch \
--multi_gpu \
--num_processes=2 \
$(which lerobot-train) \
torchrun --nproc-per-node=2 $(which lerobot-train) \
--dataset.repo_id=${HF_USER}/my_dataset \
--policy.type=act \
--policy.repo_id=${HF_USER}/my_trained_policy \
@@ -31,32 +32,10 @@ accelerate launch \
--wandb.enable=true
```
**Key accelerate parameters:**
- `--multi_gpu`: Enable multi-GPU training
- `--num_processes=2`: Number of GPUs to use
- `--mixed_precision=fp16`: Use fp16 mixed precision (or `bf16` if supported)
### Option 2: Using accelerate config
If you prefer to save your configuration, you can optionally configure accelerate for your hardware setup by running:
With `accelerate launch` (as a plain launcher):
```bash
accelerate config
```
This interactive setup will ask you questions about your training environment (number of GPUs, mixed precision settings, etc.) and saves the configuration for future use. For a simple multi-GPU setup on a single machine, you can use these recommended settings:
- Compute environment: This machine
- Number of machines: 1
- Number of processes: (number of GPUs you want to use)
- GPU ids to use: (leave empty to use all)
- Mixed precision: fp16 or bf16 (recommended for faster training)
Then launch training with:
```bash
accelerate launch $(which lerobot-train) \
accelerate launch --num_processes=2 $(which lerobot-train) \
--dataset.repo_id=${HF_USER}/my_dataset \
--policy.type=act \
--policy.repo_id=${HF_USER}/my_trained_policy \
@@ -65,116 +44,133 @@ accelerate launch $(which lerobot-train) \
--wandb.enable=true
```
## How It Works
With no `--parallelism.*` flags, a multi-process launch runs plain DDP. Multi-node runs use the standard `torchrun --nnodes/--node-rank/--rdzv-endpoint` flags (or `accelerate launch --num_machines/--machine_rank/--main_process_ip`).
When you launch training with accelerate:
> [!WARNING]
> Accelerate's YAML config files (`accelerate launch --config_file some.yaml`, `accelerate config`) are not supported. They configure the engine through environment variables, bypassing LeRobot's configuration system, so `train_config.json` would no longer describe the settings a run actually used. `lerobot-train` therefore refuses to start when [accelerate environment variables](https://huggingface.co/docs/accelerate/usage_guides/fsdp) are set. Put the settings in `--parallelism.*` / `--accelerator.*` flags instead, or set `LEROBOT_ALLOW_ACCELERATE_ENV=1` to acknowledge the override and proceed anyway.
1. **Automatic detection**: LeRobot automatically detects if it's running under accelerate
2. **Data distribution**: Your batch is automatically split across GPUs
3. **Gradient synchronization**: Gradients are synchronized across GPUs during backpropagation
4. **Single process logging**: Only the main process logs to wandb and saves checkpoints
## Batch semantics, learning rate, and steps
## Learning Rate and Training Steps Scaling
Each of the `dp_replicate × dp_shard` data-parallel workers loads its own `--batch_size` micro-batch every step, so one training step consumes `batch_size × dp_world_size` samples, and `× gradient_accumulation_steps` of those go into each optimizer update:
**Important:** LeRobot does **NOT** automatically scale learning rates or training steps based on the number of GPUs. This gives you full control over your training hyperparameters.
### Why No Automatic Scaling?
Many distributed training frameworks automatically scale the learning rate by the number of GPUs (e.g., `lr = base_lr × num_gpus`).
However, LeRobot keeps the learning rate exactly as you specify it.
### When and How to Scale
If you want to scale your hyperparameters when using multiple GPUs, you should do it manually:
**Learning Rate Scaling:**
```bash
# Example: 2 GPUs with linear LR scaling
# Base LR: 1e-4, with 2 GPUs -> 2e-4
accelerate launch --num_processes=2 $(which lerobot-train) \
--optimizer.lr=2e-4 \
--dataset.repo_id=lerobot/pusht \
--policy.type=act
```
effective_batch_size = batch_size × dp_world_size × gradient_accumulation_steps
```
**Training Steps Scaling:**
The training banner prints this factorization at startup. `--steps` counts loop steps (micro-batches per worker), not optimizer updates.
Since the effective batch size `bs` increases with multiple GPUs (batch_size × num_gpus), you may want to reduce the number of training steps proportionally:
Gradient accumulation is a first-class flag:
```bash
# Example: 2 GPUs with effective batch size 2x larger
# Original: batch_size=8, steps=100000
# With 2 GPUs: batch_size=8 (16 in total), steps=50000
accelerate launch --num_processes=2 $(which lerobot-train) \
--batch_size=8 \
--steps=50000 \
--dataset.repo_id=lerobot/pusht \
--policy.type=act
torchrun --nproc-per-node=2 $(which lerobot-train) \
--batch_size=8 --accelerator.gradient_accumulation.steps=4 ...
```
## Training Large Models with FSDP
**LeRobot does not auto-scale the learning rate or the number of steps** when the effective batch size grows. If you scale out and want equivalent training, please adjust manually, e.g. with 2 GPUs: double `--optimizer.lr` (linear scaling), or halve `--steps`.
DDP replicates the full model on every GPU, so a model that doesn't fit on one GPU won't fit under
DDP either. For large models, use **FSDP** (Fully Sharded Data Parallel), which shards parameters,
gradients, and optimizer state across GPUs. See the [accelerate FSDP guide](https://huggingface.co/docs/accelerate/usage_guides/fsdp) for background.
## Sharded training (FSDP)
An example on how to launch LeRobot training with FSDP across 4 GPUs (1 machine):
If a model is too large to train with DDP, shard it with FSDP2:
```bash
accelerate launch --config_file fsdp.yaml --num_processes=4 $(which lerobot-train) \
torchrun --nproc-per-node=4 $(which lerobot-train) \
--dataset.repo_id=${HF_USER}/my_dataset \
--policy.type=<your_policy> \
--parallelism.dp_shard=4 \
--accelerator.mixed_precision=bf16 \
--output_dir=outputs/train/my_policy_fsdp
```
A minimal `fsdp.yaml` (FSDP1; shards params/grads/optimizer — ZeRO-3-equivalent):
`--parallelism.dp_shard=-1` shards over however many processes the launcher started.
```yaml
compute_environment: LOCAL_MACHINE
distributed_type: FSDP
mixed_precision: bf16
num_machines: 1
num_processes: 4
fsdp_config:
fsdp_version: 1
fsdp_sharding_strategy: FULL_SHARD # params + grads + optimizer (ZeRO-3)
fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP
fsdp_transformer_layer_cls_to_wrap: <YourTransformerBlock> # repeated block class to shard
fsdp_use_orig_params: true # required: optimizer is built pre-prepare
fsdp_state_dict_type: FULL_STATE_DICT
### Wrap units
FSDP shards the model in units (typically the repeated transformer block) and gathers one unit at a time during forward/backward. Policies declare their wrap units via `_fsdp_wrap_modules` on the policy class. For example, ACT declares `["ACTEncoderLayer", "ACTDecoderLayer"]` and FastWAM declares `["MoTLayer"]`. For a policy without a `_fsdp_wrap_modules` declaration, pass one of the flags below. You can specify the module class name explicitly, or use a size-based policy instead:
```bash
--accelerator.fsdp.wrap_modules='["MyTransformerBlock"]' # explicit class names
--accelerator.fsdp.min_num_params=1000000 # or: wrap every submodule above 1M params
```
Set `fsdp_transformer_layer_cls_to_wrap` to your model's repeated transformer-block class so each
block is sharded as its own unit. `fsdp_use_orig_params: true` is required because LeRobot builds the
optimizer before `accelerator.prepare()`.
If a policy doesn't declare `_fsdp_wrap_modules` and no `--accelerator.fsdp.wrap_modules` or `--accelerator.fsdp.min_num_params` is passed, the run fails at startup rather than silently wrapping only the root module (which would forfeit all sharding memory savings).
### FSDP checkpoints
Other sharding settings:
LeRobot gathers the full state dict across all ranks and the main process writes it as a single
`model.safetensors`, loadable as usual with `Policy.from_pretrained(...)`. Two things to look out for:
- `--accelerator.fsdp.reshard_after_forward`: whether to keep each unit's parameters resident after forward.
- `--accelerator.fsdp.cpu_offload`: keeps parameters, gradients and optimizer states on CPU.
- `--accelerator.fsdp.ignored_modules`: a regex of module paths to keep unsharded.
- **Checkpoints store fp32 weights.** Under mixed precision (`bf16`/`fp16`) FSDP keeps an fp32 master
copy, and the checkpoint saves it (~2× the bf16 size on disk) so training can resume consistently
with the fp32 optimizer state; `from_pretrained` casts back to the policy dtype on load. FSDP-specific
caveat: an fp32 checkpoint is materialized in full precision on the target device _before_ casting,
so loading it for inference on a tight GPU can OOM even when the bf16 model would fit — load on CPU
first, or cast `model.safetensors` to the deployment dtype offline.
- The sharded optimizer state is gathered into a full (world-size-independent) state dict and saved
alongside the model in the same `optimizer_state.safetensors` / `optimizer_param_groups.json`
format as single-GPU training, so **resume-from-checkpoint is supported** with `--resume=true`.
Resume reshards both the model and the optimizer state to the _current_ FSDP topology, so you can
resume an FSDP checkpoint on a different number of GPUs. Note that the data sampler is only
sample-exact when the world size and batch size match the original run (a warning is logged
otherwise); the optimizer/model state itself is unaffected.
### HSDP
Hybrid Sharded Data Parallel: parameters, gradients and optimizer states are sharded across `dp_shard` ranks, and that sharding is replicated `dp_replicate` times. Parameter all-gathers and gradient reduce-scatters stay inside a shard group; only the all-reduce that synchronizes the replicas crosses between groups. The two degrees must multiply to the world size:
```bash
# 16 GPUs = 2 nodes × 8: shard within each node, replicate across nodes
torchrun --nnodes=2 --nproc-per-node=8 ... $(which lerobot-train) \
--parallelism.dp_replicate=2 --parallelism.dp_shard=8 ...
```
## Checkpoints
Every checkpoint contains a `pretrained_model/` directory and a `training_state/` directory:
```text
005000/ # the training step at that checkpoint
├── pretrained_model/
│ ├── config.json # policy config
│ ├── train_config.json # the full training config
│ ├── model.safetensors # full weights (checkpoint_format ∈ {safetensors, safetensors_dcp}, or any non-sharded run)
│ ├── pytorch_model_fsdp_0/ # DCP weight shards (checkpoint_format ∈ {dcp, safetensors_dcp})
│ ├── policy_preprocessor.json # preprocessor config (when the run has a preprocessor)
│ ├── policy_preprocessor_step_*.safetensors # state of the stateful preprocessor steps
│ ├── policy_postprocessor.json # postprocessor config (when the run has a postprocessor)
│ └── policy_postprocessor_step_*.safetensors # state of the stateful postprocessor steps
└── training_state/
├── training_step.json # step counter, topology, and batch semantics
├── rng_state.safetensors # rng states
├── scheduler_state.json # scheduler state (when the run has a scheduler)
├── optimizer_state.safetensors # full optimizer state (non-sharded runs)
├── optimizer_param_groups.json # optimizer param groups (non-sharded runs)
└── optimizer_0/ # DCP optimizer shards (sharded runs)
```
During single-GPU or DDP training, the pipeline serializes each state dict into a single file: `model.safetensors` for the model and `optimizer_state.safetensors` for the optimizer.
During sharded training, the optimizer state is saved as DCP shards under `training_state/optimizer_0/`, and the layout of the model under `pretrained_model/` can be configured through `--checkpoint_format`:
| `--checkpoint_format` | Weights artifact | Use when |
| ------------------------- | -------------------------------------------- | --------------------------------------------------------------------- |
| `safetensors` _(default)_ | single `model.safetensors` only | you want every checkpoint immediately loadable with `from_pretrained` |
| `dcp` | `pytorch_model_fsdp_0/` shard directory only | gathering the full weights makes saves and resumes too slow |
| `safetensors_dcp` | both | you want fast resume _and_ immediately loadable checkpoints |
Two things to know about gathered (`safetensors`) checkpoints from sharded runs:
- **They store fp32 weights.** Under mixed precision training, FSDP keeps an fp32 master copy, and the checkpoint saves the master copy to make sure training resumes consistently.
- The gather is collective (all ranks participate) but only the main process writes.
### Converting DCP checkpoints
`lerobot-convert-dcp` merges a DCP shard directory into a regular `model.safetensors`, offline and without GPUs:
```bash
lerobot-convert-dcp --checkpoint_dir=outputs/train/run/checkpoints/005000
lerobot-convert-dcp --checkpoint_dir=... --delete_dcp=true --push_to_hub=${HF_USER}/my_policy
```
`--push_to_hub` publishes the converted directory as a model repo.
### Resuming
Resume with `--resume=true --config_path=.../checkpoints/last/pretrained_model/train_config.json`. Resuming from a DCP checkpoint supports resharding the model and optimizer state to the _current_ topology, which means you can resume with a different `dp_replicate/dp_shard` split. The data sampler can always resume at the right epoch and offset, but is only _sample-exact_ when the world size and batch size match the original run (a warning is logged otherwise).
> [!NOTE]
> FSDP checkpoints written by LeRobot 0.6.x and earlier used a different on-disk layout (a gathered full optimizer state) and **cannot be resumed**.
## Notes
- The `--policy.use_amp` flag in `lerobot-train` is only used when **not** running with accelerate. When using accelerate, mixed precision is controlled by accelerate's configuration.
- Training logs, checkpoints, and hub uploads are only done by the main process to avoid conflicts. Non-main processes have console logging disabled to prevent duplicate output.
- The effective batch size is `batch_size × num_gpus`. If you use 4 GPUs with `--batch_size=8`, your effective batch size is 32.
- Learning rate scheduling is handled correctly across multiple processes—LeRobot sets `step_scheduler_with_optimizer=False` to prevent accelerate from adjusting scheduler steps based on the number of processes.
- When saving or pushing models, LeRobot automatically unwraps the model from accelerate's distributed wrapper to ensure compatibility.
- WandB integration automatically initializes only on the main process, preventing multiple runs from being created.
- Checkpoint saves and end-of-training publishes are collective (every rank enters them). Gathered weights, sidecar files and Hub uploads are written by the main process alone.
- Metrics are reduced across ranks before logging: losses are averaged, and `samples/s` reports cluster-wide throughput.
- Learning-rate scheduling is stepped once per training step regardless of the number of processes (`step_scheduler_with_optimizer=False` is baked in).
For more advanced configurations and troubleshooting, see the [Accelerate documentation](https://huggingface.co/docs/accelerate). If you want to learn more about how to train on a large number of GPUs, checkout this awesome guide: [Ultrascale Playbook](https://huggingface.co/spaces/nanotron/ultrascale-playbook).
For background on the underlying machinery, see the [Accelerate FSDP guide](https://huggingface.co/docs/accelerate/usage_guides/fsdp). To go deeper on large-scale training, check out the [Ultrascale Playbook](https://huggingface.co/spaces/nanotron/ultrascale-playbook).
+125 -24
View File
@@ -36,6 +36,12 @@ This diverse training mixture creates a "curriculum" that enables generalization
pip install -e ".[pi]"
```
If you installed LeRobot from PyPI:
```bash
pip install 'lerobot[pi]'
```
## Usage
To use π₀.₅ in your LeRobot configuration, specify the policy type as:
@@ -46,27 +52,117 @@ policy.type=pi05
## Training
### Training Command Example
### Quickstart on LIBERO
Here's a complete training command for finetuning the base π₀.₅ model on your own dataset:
Finetune the LIBERO base model on [lerobot/libero](https://huggingface.co/datasets/lerobot/libero), a ~1.9 GB video-encoded copy of the demonstrations behind the [results below](#libero-benchmark-results).
It carries the keys π₀.₅ reads, which are also the ones the LIBERO environment produces at evaluation time:
| Feature | Shape in the dataset | How π₀.₅ consumes it |
| --------------------------- | -------------------- | ------------------------------------------------------- |
| `observation.images.image` | 256×256×3, agentview | resized to 224×224 |
| `observation.images.image2` | 256×256×3, wrist | resized to 224×224 |
| `observation.state` | 8 | discretized into 256 bins and written into the prompt |
| `action` | 7 | padded to 32 internally; the loss uses the first 7 dims |
**No `--rename_map` is needed here** — the keys already match; see [Rename Map and Empty Cameras](./rename_map) if yours differ.
<Tip>
π₀.₅ uses the gated
[google/paligemma-3b-pt-224](https://huggingface.co/google/paligemma-3b-pt-224)
tokenizer — accept its license on the Hub and log in with `hf auth login`
before training.
</Tip>
Sized for a single 80 GB GPU:
```bash
lerobot-train \
--dataset.repo_id=your_dataset \
--dataset.repo_id=lerobot/libero \
--policy.type=pi05 \
--output_dir=./outputs/pi05_training \
--job_name=pi05_training \
--policy.repo_id=your_repo_id \
--policy.pretrained_path=lerobot/pi05_base \
--policy.compile_model=true \
--policy.gradient_checkpointing=true \
--wandb.enable=true \
--policy.dtype=bfloat16 \
--policy.pretrained_path=lerobot/pi05_libero_base \
--policy.normalization_mapping='{"ACTION": "MEAN_STD", "STATE": "MEAN_STD", "VISUAL": "IDENTITY"}' \
--policy.n_action_steps=10 \
--policy.empty_cameras=1 \
--policy.freeze_vision_encoder=false \
--policy.train_expert_only=false \
--steps=3000 \
--policy.gradient_checkpointing=true \
--policy.dtype=bfloat16 \
--policy.device=cuda \
--batch_size=32
--policy.push_to_hub=false \
--output_dir=./outputs/pi05_libero \
--job_name=pi05_libero \
--batch_size=64 \
--num_workers=8 \
--steps=30000 \
--save_freq=5000 \
--seed=1000
```
**Mean/std normalization, not π₀.₅'s [quantile default](#quantile-statistics)** — matching [pi05_libero_finetuned_v044](https://huggingface.co/lerobot/pi05_libero_finetuned_v044), the checkpoint the results below were measured on.
**`--policy.n_action_steps=10` and `--policy.empty_cameras=1` are explicit** because `--policy.pretrained_path` loads weights only — `lerobot/pi05_libero_base` stores both, and they would otherwise fall back to `50` and `0` (see [Loading a checkpoint](#loading-a-checkpoint)).
Then evaluate a checkpoint with `lerobot-eval` and compare against the reference success rates — see [LIBERO](./libero).
### Quantile statistics
π₀.₅ normalizes `STATE` and `ACTION` with quantiles, so your dataset's `meta/stats.json` needs `q01` and `q99`. Older datasets carry only `min`/`max`/`mean`/`std` and fail on the first batch:
```
ValueError: QUANTILES normalization mode requires q01 and q99 stats
```
Recompute them:
```bash
lerobot-edit-dataset \
--repo_id your_dataset \
--new_repo_id your_dataset \
--operation.type recompute_stats \
--operation.overwrite true
```
**The result lands in `$HF_LEROBOT_HOME/your_dataset`**, not the cache `--dataset.repo_id` reads — so train with `--dataset.root=$HF_LEROBOT_HOME/your_dataset`, or add `--push_to_hub true` above.
Or keep the dataset as-is and pass `--policy.normalization_mapping='{"ACTION": "MEAN_STD", "STATE": "MEAN_STD", "VISUAL": "IDENTITY"}'`.
Recording, resuming, and merging aggregate quantiles from per-episode summaries, so `meta/stats.json` ends up holding a conservative envelope (`min` for `q <= 50`, `max` for `q > 50`) rather than whole-dataset quantiles. To estimate the latter, scan every episode with a running histogram:
```bash
python src/lerobot/scripts/augment_dataset_quantile_stats.py \
--repo-id=your_dataset \
--overwrite \
--skip-images
```
`--skip-images` keeps the existing image statistics and avoids video decoding when only `STATE`/`ACTION` need recomputing, and `--root` reads a local dataset instead of the Hub. These values are histogram estimates, subject to discretization and rebinning error, so they can differ from the conservative ones — which changes π₀.₅'s normalized targets and therefore its loss scale. Statistics already saved inside an existing checkpoint are not affected.
### Training Command Example
The same finetune with the VLM frozen: less memory, at some cost in success rate. Swap `--dataset.repo_id` for your own dataset.
```bash
lerobot-train \
--dataset.repo_id=lerobot/libero \
--policy.type=pi05 \
--policy.pretrained_path=lerobot/pi05_libero_base \
--policy.normalization_mapping='{"ACTION": "MEAN_STD", "STATE": "MEAN_STD", "VISUAL": "IDENTITY"}' \
--policy.n_action_steps=10 \
--policy.empty_cameras=1 \
--policy.freeze_vision_encoder=true \
--policy.train_expert_only=true \
--policy.gradient_checkpointing=true \
--policy.dtype=bfloat16 \
--policy.device=cuda \
--policy.push_to_hub=false \
--output_dir=./outputs/pi05_libero_expert \
--job_name=pi05_libero_expert \
--batch_size=64 \
--num_workers=8 \
--steps=30000 \
--save_freq=5000 \
--seed=1000
```
### Key Training Parameters
@@ -74,10 +170,24 @@ lerobot-train \
- **`--policy.compile_model=true`**: Enables model compilation for faster training
- **`--policy.gradient_checkpointing=true`**: Reduces memory usage significantly during training
- **`--policy.dtype=bfloat16`**: Use mixed precision training for efficiency
- **`--batch_size=32`**: Batch size for training, adapt this based on your GPU memory
- **`--batch_size=64`**: Batch size for training, adapt this based on your GPU memory
- **`--policy.pretrained_path=lerobot/pi05_base`**: The base π₀.₅ model you want to finetune, options are:
- [lerobot/pi05_base](https://huggingface.co/lerobot/pi05_base)
- [lerobot/pi05_libero](https://huggingface.co/lerobot/pi05_libero) (specifically trained on the Libero dataset)
- [lerobot/pi05_libero_base](https://huggingface.co/lerobot/pi05_libero_base) (specifically trained on the Libero dataset)
### Loading a checkpoint
The two forms are not interchangeable:
| | `--policy.path` | `--policy.pretrained_path` |
| -------------------------------------- | ---------------------------------------------- | ------------------------------------ |
| Loads | weights **and** the checkpoint's `config.json` | weights only |
| Feature names | from the checkpoint | from your dataset |
| Stored settings, e.g. `n_action_steps` | inherited | reset to the defaults |
| `--policy.type` | must be omitted | required |
| `--rename_map` | needed when your camera keys differ | never — the keys come from your data |
Passing a `--rename_map` alongside `--policy.pretrained_path` renames the batch away from those names, and the first batch fails with `All image features are missing from the batch`.
### Training Parameters Explained
@@ -88,15 +198,6 @@ lerobot-train \
**💡 Tip**: Setting `train_expert_only=true` freezes the VLM and trains only the action expert and projections, allowing finetuning with reduced memory usage.
If your dataset is not converted with `quantiles`, you can convert it with the following command:
```bash
python src/lerobot/scripts/augment_dataset_quantile_stats.py \
--repo-id=your_dataset \
```
Or train pi05 with this normalization mapping: `--policy.normalization_mapping='{"ACTION": "MEAN_STD", "STATE": "MEAN_STD", "VISUAL": "IDENTITY"}'`
## Relative Actions
By default, π₀.₅ predicts absolute actions. You can enable **relative actions** so the model predicts offsets relative to the current robot state. This can improve training stability for certain setups.
+19
View File
@@ -2,6 +2,25 @@
https://diffusion-policy.cs.columbia.edu
## Training
The reference implementation maintains an exponential moving average (EMA) of the policy weights during training and evaluates the EMA weights. To reproduce this behavior, enable the trainer's EMA shadow:
```bash
lerobot-train \
--policy.type=diffusion \
--ema.enable=true \
...
```
Checkpoints then contain a directly loadable copy of the EMA weights next to the live ones, e.g. for evaluation:
```bash
lerobot-eval --policy.path=outputs/train/.../checkpoints/last/pretrained_model_ema ...
```
The EMA decay schedule (`--ema.inv_gamma`, `--ema.power`, ...) defaults to the reference implementation's values. For a constant decay instead of the warmup schedule (e.g. to match openpi's pi0/pi05 training), set `--ema.decay=0.99`.
## Citation
```bibtex
+16
View File
@@ -59,6 +59,22 @@ When `use_relative_actions=true`, the training script automatically:
---
## EMA of the policy weights
OpenPI maintains an exponential moving average of the weights during training (`ema_decay=0.99` by default) and keeps the EMA copy for inference. To reproduce this with the LeRobot trainer, enable the EMA shadow with a constant decay:
```bash
python -m lerobot.scripts.lerobot_train \
--policy.type=pi05 \
--dataset.repo_id=your_org/your_dataset \
--ema.enable=true \
--ema.decay=0.99
```
Checkpoints then contain a directly loadable copy of the EMA weights in `pretrained_model_ema/` next to the live ones. Note that the shadow is a full extra copy of the parameters on the GPU. Like OpenPI (which disables EMA in its LoRA configs), EMA is not supported together with PEFT adapters.
---
## Citation
If you use this work, please cite both **OpenPI** and the π₀.₅ paper:
+339
View File
@@ -0,0 +1,339 @@
# Third-Party Robots & Teleoperators
The LeRobot ecosystem extends far beyond its officially supported hardware. Thanks to LeRobot's plugin architecture, the community has built integrations for a wide range of robot arms and teleoperation devices — from industrial manipulators to affordable hobbyist platforms, VR headsets, haptic devices, and full arm-plus-teleoperator kits. This page showcases community-maintained integrations you can use for teleoperation, data collection, and policy deployment.
> [!IMPORTANT]
> These projects are developed and maintained by third parties. Please refer to each repository for installation instructions, hardware requirements, and support.
Drop-in plugins are auto-discovered by package name: LeRobot imports any installed package prefixed with `lerobot_robot_` or `lerobot_teleoperator_`. Once installed, reference the `type` the plugin registers (see its README — it may differ from the package name) directly from any LeRobot command:
```bash
pip install lerobot_robot_<name> lerobot_teleoperator_<name>
lerobot-record \
--robot.type=<robot_name> \
--teleop.type=<teleoperator_name> \
--dataset.repo_id=${HF_USER}/my-dataset \
--dataset.num_episodes=5
```
> [!TIP]
> ⚠️ marks projects that are forks/extensions of LeRobot. They may require custom setup rather than working with an unmodified install. All other entries are drop-in plugins.
## Industrial & Collaborative Arms
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/SpesRobotics/lerobot-robot-xarm">lerobot-robot-xarm</a></td>
<td>Plugin for the xArm collaborative arm series from <a href="https://www.ufactory.cc/">UFACTORY</a>.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/lebai-robotics/lerobot_lebai">lerobot_lebai</a></td>
<td>Plugin for the six-axis collaborative arms from <a href="https://lebai.ltd/en/">Lebai</a>.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/wengmister/LeFranX">LeFranX</a> ⚠️</td>
<td>LeRobot extension for the <a href="https://franka.de/">Franka</a> research arm, paired with the <a href="https://www.robotera.com/">RobotEra XHand</a> hand for VR teleoperation.</td>
</tr>
</tbody>
</table>
#### Universal Robots UR5e
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/yechen056/UR5e-LeRobot">UR5e-LeRobot</a> ⚠️</td>
<td>LeRobot extension for the <a href="https://www.universal-robots.com/">Universal Robots UR5e</a>, with single-arm and bimanual support.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/scy-v/lerobot_ur5e_auto">lerobot_ur5e_auto</a> ⚠️</td>
<td>LeRobot extension for a mobile <a href="https://www.universal-robots.com/">Universal Robots UR5e</a>, adding automated recording at scale with minimal supervision.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/F-Fer/lerobot_ur5e_gello">lerobot_robot_ur5e</a></td>
<td>Plugin for the <a href="https://www.universal-robots.com/">Universal Robots UR5e</a> with a <a href="https://robotiq.com/">Robotiq</a> gripper, over RTDE control.</td>
</tr>
</tbody>
</table>
## Research & Learning Arms
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/TrossenRobotics/lerobot_trossen">lerobot_trossen</a></td>
<td>Plugin for the WidowX and ALOHA-style arms from <a href="https://www.trossenrobotics.com/">Trossen Robotics</a>.</td>
</tr>
</tbody>
</table>
#### AgileX Piper
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/AgRoboticsResearch/lerobot_robot_piper">lerobot_robot_piper (AgRobotics Research)</a></td>
<td>Plugin for the <a href="https://global.agilex.ai/">AgileX Piper</a> arm.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/WeGo-Robotics/lerobot_robot_piper">lerobot_robot_piper (WeGo Robotics)</a></td>
<td>Plugin for the <a href="https://global.agilex.ai/">AgileX Piper</a> arm, with multi-arm teleoperation, safety limits, and GUI tools.</td>
</tr>
</tbody>
</table>
## Affordable & Hobbyist Arms
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/servodevelop/fashionstar-lerobot-robot-cello">fashionstar-lerobot-robot-cello</a></td>
<td>Plugin for the StarAI Cello 6+1 degrees of freedom robot arm from <a href="https://fashionstar.com.hk/">FashionStar</a>.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/servodevelop/fashionstar-lerobot-robot-viola">fashionstar-lerobot-robot-viola</a></td>
<td>Plugin for the compact StarAI Viola 6+1 degrees of freedom robot arm from <a href="https://fashionstar.com.hk/">FashionStar</a>.</td>
</tr>
</tbody>
</table>
## Service, Mobile & Utility Robots
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/ugo-plus/lerobot-robot-ugo-pro">lerobot-robot-ugo-pro</a></td>
<td>Plugin for the ugo Pro dual-arm service robot from <a href="https://ugo.plus/products/ugo-pro/">ugo</a>.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/zuoxingdong/lerobot_robot_lekiwi_pincopen">lerobot_robot_lekiwi_pincopen</a></td>
<td>Plugin for a LeKiwi mobile manipulator with a <a href="https://github.com/pollen-robotics/PincOpen">PincOpen</a> gripper.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/KillingJacky/lerobot-robot-dummy">lerobot-robot-dummy</a></td>
<td>Plugin simulating a robot for recording without hardware. Useful for debugging !</td>
</tr>
</tbody>
</table>
## Teleoperators
### VR & Motion Controllers
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/SpesRobotics/lerobot-teleoperator-teleop">lerobot-teleoperator-teleop</a></td>
<td>Plugin turning a phone or VR headset into a teleoperator via <a href="https://immersiveweb.dev">WebXR</a>, wrapping the open-source <a href="https://github.com/SpesRobotics/teleop"><code>teleop</code></a> library.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/Jas000n/lerobot-teleoperator-spacemouse">lerobot-teleoperator-spacemouse</a></td>
<td>Plugin for the <a href="https://3dconnexion.com/">3Dconnexion SpaceMouse</a>, with inverse kinematics for SO-ARMS robots.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/Dream-Machines-Robotics/vr-teleop-kit">vr-teleop-kit</a></td>
<td>Plugin teleoperating arms from a <a href="https://www.meta.com/quest/">Meta Quest</a> (WebXR), relying on URDF descriptions for inverse kinematics.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/xensedyl/lerobot-teleoperator-pico4">lerobot-teleoperator-pico4</a></td>
<td>Plugin for the <a href="https://www.picoxr.com/">PICO 4</a> VR headset, with a companion controller-free <a href="https://github.com/xensedyl/lerobot-teleoperator-pico4-hand">hand-tracking variant</a>.</td>
</tr>
</tbody>
</table>
### Leader Arms
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/F-Fer/lerobot_ur5e_gello">lerobot_teleoperator_gello</a></td>
<td>Plugin for the 7 degrees of freedom <a href="https://wuphilipp.github.io/gello_site/">GELLO</a> teleoperator.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/uynitsuj/lerobot_teleoperator_yamactiveleader">lerobot_teleoperator_yamactiveleader</a></td>
<td>Plugin for the active YAM teleoperator from <a href="https://i2rt.com/">I2RT</a>, a bilateral force-feedback arm.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/charlie8612/lerobot_teleoperator_omy">lerobot_teleoperator_omy</a></td>
<td>Plugin for the OMY-L100 6 degrees of freedom teleoperator from <a href="https://www.robotis.com/">ROBOTIS</a>.</td>
</tr>
<tr style="border:0">
<td><a href="https://pypi.org/project/lerobot-teleoperator-pipermate/">lerobot-teleoperator-pipermate</a></td>
<td>Plugin for the PiperMate teleoperator (<a href="https://fashionstar.com.hk/">FashionStar</a> UART servos), driving the <a href="https://global.agilex.ai/">AgileX Piper</a> arm.</td>
</tr>
</tbody>
</table>
### Haptic Devices
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/chohh7391/lerobot_teleoperator_inverse3">lerobot_teleoperator_inverse3</a></td>
<td>Plugin for the <a href="https://www.haply.co/">Haply Inverse3</a> haptic device, adding force-feedback teleoperation.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/hzhz112/lerobot_teleoperator_omega7">lerobot_teleoperator_omega7</a></td>
<td>Plugin for the <a href="https://www.forcedimension.com/">Force Dimension omega.7</a> haptic device, adding force-feedback teleoperation.</td>
</tr>
</tbody>
</table>
### Networked & Remote
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://pypi.org/project/lerobot-teleoperator-livekit/">lerobot-teleoperator-livekit</a></td>
<td>Plugin receiving teleoperation commands over a <a href="https://livekit.io/">LiveKit</a> Portal (WebRTC) for remote control.</td>
</tr>
</tbody>
</table>
## Full Kits (Robot + Teleoperator)
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/villekuosmanen/lerobot-arx5">lerobot-arx5</a></td>
<td>Plugin for the <a href="https://www.arx-x.com/">ARX5</a> arm: <a href="https://pypi.org/project/lerobot-robot-arx5/"><code>lerobot-arx5</code></a> robot arm with its <a href="https://pypi.org/project/lerobot-teleoperator-arx5/"><code>lerobot-teleoperator-arx5</code></a> teleoperator arm.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/robertorobotics/Nextis-AIRA-3D">Nextis-AIRA-3D</a></td>
<td>Plugin for the 7 degrees of freedom arm from <a href="https://www.nextis.tech">Nextis</a>: robot arm <code>aira_follower</code> and teleoperator arm <code>aira_leader</code>.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/pravsels/lerobot_yam">lerobot_yam</a></td>
<td>Plugin suite for the YAM arm from <a href="https://i2rt.com/">I2RT</a>: robot arm <code>yam_follower</code> and teleoperator arm <code>yam_leader</code>.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/robot-learning-co/trlc-dk1">trlc-dk1</a></td>
<td>Plugin for the development kit from <a href="https://www.robot-learning.co/">The Robot Learning Company</a>: single and bimanual arms follower/teleoperator types.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/hexfellow/hex_lerobot_drivers">hex_lerobot_drivers</a></td>
<td>Plugin suite for <a href="https://hexfellow.com/">HEXFELLOW</a> devices: robots, teleoperators, and cameras (see <a href="./third_party_sensors">Cameras &amp; Sensors</a>).</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/Hiwonder-official/lerobot-robot-nexarm-follower">lerobot-robot-nexarm-follower</a></td>
<td>Plugin for the NexArm from <a href="https://www.hiwonder.com/">Hiwonder</a>: the <a href="https://github.com/Hiwonder-official/lerobot-robot-nexarm-follower">robot arm</a> and its matching <a href="https://github.com/Hiwonder-official/lerobot-teleoperator-nexarm-leader">teleoperator arm</a>.</td>
</tr>
</tbody>
</table>
## ROS 2 Bridges
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/ngres/leros2">leros2</a></td>
<td>Plugin bridging ROS 2 topics and actions to LeRobot robots and teleoperators.</td>
</tr>
<tr style="border:0">
<td><a href="https://github.com/ROBOTIS-GIT/lerobot_robot_ros2_zenoh">lerobot_robot_ros2_zenoh</a></td>
<td>Plugin bridging ROS 2 robots to LeRobot over <a href="https://zenoh.io">Zenoh</a> pub/sub transport.</td>
</tr>
</tbody>
</table>
## Contributing
Built your own LeRobot hardware integration? The plugin system makes it straightforward to add new robots and teleoperators — check out the [Bring Your Own Hardware](./integrate_hardware) guide to get started, and share your project with the community!
+99
View File
@@ -0,0 +1,99 @@
# Third-Party Cameras & Sensors
The LeRobot ecosystem extends far beyond its natively supported cameras (OpenCV, Intel RealSense, ZMQ, Reachy 2). Thanks to LeRobot's plugin architecture, the community has built drop-in camera and sensor integrations — from depth cameras to vision-based tactile sensors. This page showcases community-maintained camera and sensor integrations you can use for teleoperation, data collection, and policy deployment.
> [!IMPORTANT]
> These projects are developed and maintained by third parties. Please refer to each repository for installation instructions, hardware requirements, and support.
Drop-in plugins are auto-discovered by package name: LeRobot imports any installed package prefixed with `lerobot_camera_`. Once installed, reference the camera `type` the plugin registers (see its README — it may differ from the package name) directly from any LeRobot command:
```bash
pip install lerobot_camera_<name>
lerobot-record \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--robot.cameras="{ front: {type: <name>, width: 640, height: 480, fps: 30} }" \
--dataset.repo_id=${HF_USER}/my-dataset \
--dataset.num_episodes=5
```
## Tactile Sensors
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/xensedyl/lerobot-camera-xense">lerobot-camera-xense</a></td>
<td>Plugin for <a href="https://www.xenserobotics.com/">Xense</a> vision-based tactile sensors, exposing rectified/difference images, depth, and 2D markers.</td>
</tr>
</tbody>
</table>
## Depth Cameras
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/hexfellow/hex_lerobot_drivers/tree/main/lerobot_camera_berxel">lerobot_camera_berxel</a></td>
<td>Plugin for the <a href="https://www.berxel.com/">Berxel</a> depth camera, part of the broader <a href="https://hexfellow.com/">HEXFELLOW</a> driver suite.</td>
</tr>
</tbody>
</table>
## Networked Cameras
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/F-Fer/lerobot_ur5e_gello">lerobot_camera_zmq</a></td>
<td>Plugin streaming <a href="https://www.stereolabs.com/">Stereolabs ZED</a> and USB camera frames from a Raspberry Pi over the network.</td>
</tr>
</tbody>
</table>
## Virtual Cameras
<!-- prettier-ignore -->
<table width="100%" style="display:table; width:100%; table-layout:fixed;">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<thead style="border:0">
<tr style="border:0"><th>Project</th><th>Description</th></tr>
</thead>
<tbody>
<tr style="border:0">
<td><a href="https://github.com/hexfellow/hex_lerobot_drivers/tree/main/lerobot_camera_dummy">lerobot_camera_dummy</a></td>
<td>Plugin simulating a camera for recording without hardware. Useful for debugging !</td>
</tr>
</tbody>
</table>
## Contributing
Built your own LeRobot camera or sensor integration? Package it as an installable `lerobot_camera_<name>` plugin and it will be auto-discovered by the LeRobot CLI — see the [Bring Your Own Hardware](./integrate_hardware) guide and the [Cameras](./cameras) reference to get started, then share your project with the community!
+12
View File
@@ -40,3 +40,15 @@ lerobot-eval \
```
However, in most cases, presence of an accelerator is detected automatically and `policy.device` parameter can be omitted from CLI commands.
## Mixed precision
Training precision is owned by `--accelerator.mixed_precision`, which accepts `no` (default) and `bf16`:
```bash
lerobot-train \
--policy.type=act \
--accelerator.mixed_precision=bf16 ...
```
`bf16` requires an accelerator that supports it.
+19 -3
View File
@@ -25,7 +25,7 @@ discord = "https://discord.gg/s3KuuzsPFb"
[project]
name = "lerobot"
version = "0.6.1"
version = "0.6.2"
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
dynamic = ["readme"]
license = { text = "Apache-2.0" }
@@ -87,7 +87,7 @@ dependencies = [
# Build tools (required by opencv-python-headless on some platforms)
"cmake>=3.29.0.1,<4.2.0",
"setuptools>=71.0.0,<81.0.0",
"setuptools>=71.0.0,<82.0.0", # torch 2.11 requires setuptools<82; a higher cap makes the resolver downgrade torch
]
# Optional dependencies
@@ -261,7 +261,7 @@ annotations = [
# Development
dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools>=1.73.1,<2.0.0", "mypy>=1.19.1", "ruff>=0.14.1", "lerobot[notebook]"]
notebook = ["jupyter>=1.0.0,<2.0.0", "ipykernel>=6.0.0,<7.0.0"]
test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
test = ["pytest>=8.1.0,<10.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"]
# Simulation
@@ -346,6 +346,7 @@ lerobot-record="lerobot.scripts.lerobot_record:main"
lerobot-replay="lerobot.scripts.lerobot_replay:main"
lerobot-setup-motors="lerobot.scripts.lerobot_setup_motors:main"
lerobot-teleoperate="lerobot.scripts.lerobot_teleoperate:main"
lerobot-convert-dcp="lerobot.scripts.lerobot_convert_dcp:main"
lerobot-eval="lerobot.scripts.lerobot_eval:main"
lerobot-train="lerobot.scripts.lerobot_train:main"
lerobot-train-tokenizer="lerobot.scripts.lerobot_train_tokenizer:main"
@@ -475,6 +476,12 @@ default.extend-ignore-identifiers-re = [
# TODO: Enable mypy gradually module by module across multiple PRs
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
[tool.pytest.ini_options]
markers = [
"multigpu: distributed tests needing 2-4 GPUs (CI: docker_publish.yml lane)",
"multigpu_heavy: 8-GPU sweeps and soak tests; never run in CI",
]
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
@@ -521,6 +528,15 @@ disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
[[tool.mypy.overrides]]
module = "lerobot.distributed.*"
ignore_errors = false
# extra strictness for the distributed engine
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
[[tool.mypy.overrides]]
module = "lerobot.optim.*"
ignore_errors = false
+110 -36
View File
@@ -109,6 +109,11 @@ class RealSenseCamera(Camera):
```
"""
# Maximum number of warmup attempts made by connect(). A failed attempt is first
# retried with a plain pipeline stop/start, which is usually enough to recover the
# stream; a USB hardware reset is performed before the final attempt as a last resort.
_MAX_CONNECT_ATTEMPTS = 3
def __init__(self, config: RealSenseCameraConfig):
"""
Initializes the RealSenseCamera instance.
@@ -173,6 +178,76 @@ class RealSenseCamera(Camera):
"""Checks if the camera pipeline is started and streams are active."""
return self.rs_pipeline is not None and self.rs_profile is not None
def _hardware_reset(self, wait_s: float = 5.0) -> None:
"""Issue a USB hardware reset to recover an unresponsive device (common on D405)."""
context = rs.context()
for device in context.query_devices():
if device.get_info(rs.camera_info.serial_number) == self.serial_number:
logger.info(f"{self} performing hardware reset.")
device.hardware_reset()
time.sleep(wait_s)
return
logger.warning(f"{self} device not found for hardware reset, skipping.")
def _open_pipeline(self) -> None:
"""Initializes the RealSense pipeline, starts it, and starts the background read thread.
Raises:
ValueError: If the configuration is invalid, a requested sensor option is unsupported,
or a requested sensor value is invalid.
ConnectionError: If the camera is found but fails to start the pipeline or no RealSense devices are detected at all.
RuntimeError: If the pipeline starts but fails to apply requested settings.
"""
rs_pipeline = rs.pipeline()
rs_config = rs.config()
self._configure_rs_pipeline_config(rs_config)
try:
rs_profile = rs_pipeline.start(rs_config)
except RuntimeError as e:
raise ConnectionError(
f"Failed to open {self}.Run `lerobot-find-cameras realsense` to find available cameras."
) from e
self.rs_pipeline = rs_pipeline
self.rs_profile = rs_profile
try:
self._configure_capture_settings()
self._configure_sensor_options()
self._start_read_thread()
except BaseException:
self._release_after_failed_setup()
raise
def _run_warmup(self) -> None:
"""Blocks until at least one valid frame has been captured by the background thread.
Raises:
ConnectionError: If no frame arrives before ``warmup_s`` elapses.
"""
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
self.warmup_s = max(self.warmup_s, 1)
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
start_time = time.time()
while time.time() - start_time < self.warmup_s:
warmup_read(timeout_ms=self.warmup_s * 1000)
time.sleep(0.1)
with self.frame_lock:
if (self.use_rgb and self.latest_color_frame is None) or (
self.use_depth and self.latest_depth_frame is None
):
raise ConnectionError(f"{self} failed to capture frames during warmup.")
def _release_after_failed_setup(self) -> None:
"""Releases the device handle and restores auto-detected settings after a failed attempt."""
try:
self._cleanup_resources()
except Exception:
logger.exception(f"Failed to fully clean up {self} after connect() failed.")
self._reset_connection_settings()
@check_if_already_connected
def connect(self, warmup: bool = True) -> None:
"""
@@ -181,58 +256,53 @@ class RealSenseCamera(Camera):
Initializes the RealSense pipeline, configures the required streams (color
and optionally depth), starts the pipeline, and validates the actual stream settings.
If the pipeline starts but no frames arrive during warmup, retries up to
``_MAX_CONNECT_ATTEMPTS`` times, performing a USB hardware reset before the
final attempt.
Args:
warmup (bool): If True, waits at connect() time until at least one valid frame
has been captured by the background thread. Defaults to True.
Raises:
DeviceAlreadyConnectedError: If the camera is already connected.
ValueError: If the configuration is invalid, a requested sensor option is unsupported,
or a requested sensor value is invalid.
ValueError: If the configuration is invalid (e.g., missing serial/name, name not unique).
ConnectionError: If the camera is found but fails to start the pipeline or no RealSense devices are detected at all.
RuntimeError: If the pipeline starts but fails to apply requested settings.
"""
self.rs_pipeline = rs.pipeline()
rs_config = rs.config()
self._configure_rs_pipeline_config(rs_config)
if not warmup:
self._open_pipeline()
logger.info(f"{self} connected.")
return
try:
self.rs_profile = self.rs_pipeline.start(rs_config)
except RuntimeError as e:
self.rs_profile = None
self.rs_pipeline = None
raise ConnectionError(
f"Failed to open {self}.Run `lerobot-find-cameras realsense` to find available cameras."
) from e
last_error: Exception | None = None
try:
self._configure_capture_settings()
self._configure_sensor_options()
self._start_read_thread()
for attempt in range(1, self._MAX_CONNECT_ATTEMPTS + 1):
if attempt == self._MAX_CONNECT_ATTEMPTS:
self._hardware_reset()
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
self.warmup_s = max(self.warmup_s, 1)
self._open_pipeline()
warmup_read = self.async_read if self.use_rgb else self.async_read_depth
start_time = time.time()
while time.time() - start_time < self.warmup_s:
warmup_read(timeout_ms=self.warmup_s * 1000)
time.sleep(0.1)
with self.frame_lock:
if (self.use_rgb and self.latest_color_frame is None) or (
self.use_depth and self.latest_depth_frame is None
):
raise ConnectionError(f"{self} failed to capture frames during warmup.")
except BaseException:
connected = False
try:
self._cleanup_resources()
except Exception:
logger.exception(f"Failed to fully clean up {self} after connect() failed.")
self._reset_connection_settings()
raise
self._run_warmup()
connected = True
except (TimeoutError, ConnectionError) as e:
last_error = e
finally:
if not connected:
self._release_after_failed_setup()
logger.info(f"{self} connected.")
if connected:
logger.info(f"{self} connected.")
return
logger.warning(f"{self} warmup failed (attempt {attempt}/{self._MAX_CONNECT_ATTEMPTS}).")
raise ConnectionError(
f"{self} failed to capture frames after {self._MAX_CONNECT_ATTEMPTS} attempts."
) from last_error
@staticmethod
def find_cameras() -> list[dict[str, Any]]:
@@ -629,6 +699,9 @@ class RealSenseCamera(Camera):
capture_time = time.perf_counter()
with self.frame_lock:
# Under the lock, so a late frame cannot resurrect the buffer _stop_read_thread() cleared.
if stop_event.is_set():
break
if self.use_rgb:
self.latest_color_frame = processed_color_frame
if self.use_depth:
@@ -839,4 +912,5 @@ class RealSenseCamera(Camera):
)
self._cleanup_resources()
logger.info(f"{self} disconnected.")
+1
View File
@@ -102,6 +102,7 @@ class ImageServer:
fps=self.fps,
width=shape[1],
height=shape[0],
fourcc=cfg.get("fourcc", "MJPG"),
color_mode=ColorMode.RGB,
)
camera = OpenCVCamera(cam_config)
+604 -174
View File
@@ -13,16 +13,41 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from pathlib import Path
"""Training-output persistence: checkpoints, two-phase resume, and hub publishing.
from huggingface_hub import HfApi, snapshot_download
Rank discipline: every function here that can
contain a collective is documented as such and must run on ALL ranks; rank-0-only file writes
sit under one grouped ``is_main_process()`` gate per contiguous region, placed below all
collectives. The leaf save/load helpers carry no rank gates of their own — the exception is
``PreTrainedPolicy._save_pretrained``, whose gate is internal because its collective gather and
its writes live in the same method.
"""
import logging
from importlib.resources import files
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Any
import torch.distributed as dist
from huggingface_hub import HfApi, ModelCard, ModelCardData, snapshot_download
from torch.optim import Optimizer
from torch.optim.lr_scheduler import LRScheduler
from lerobot.__version__ import __version__
from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.rewards import RewardModelConfig
from lerobot.configs.train import TrainPipelineConfig
from lerobot.distributed.checkpoint import (
is_sharded_module,
load_sharded_model,
load_sharded_optimizer,
save_sharded_model,
save_sharded_optimizer,
)
from lerobot.distributed.utils import is_main_process
from lerobot.optim import (
load_optimizer_state,
load_optimizer_state_dict,
load_scheduler_state,
save_optimizer_state,
save_scheduler_state,
@@ -40,14 +65,39 @@ from lerobot.utils.hub import find_latest_hub_checkpoint
from lerobot.utils.io_utils import load_json, write_json
from lerobot.utils.random_utils import load_rng_state, save_rng_state
if TYPE_CHECKING:
from accelerate import Accelerator
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
from lerobot.rewards.pretrained import PreTrainedRewardModel
def get_step_identifier(step: int, total_steps: int) -> str:
"""Format a step number as the zero-padded identifier used for checkpoint directory names.
Args:
step (int): The training step to format.
total_steps (int): The total number of training steps; sets the padding width
(minimum 6 digits).
Returns:
str: The zero-padded step identifier, e.g. `"005000"`.
"""
num_digits = max(6, len(str(total_steps)))
return f"{step:0{num_digits}d}"
def get_step_checkpoint_dir(output_dir: Path, total_steps: int, step: int) -> Path:
"""Returns the checkpoint sub-directory corresponding to the step number."""
"""Returns the checkpoint sub-directory corresponding to the step number.
Args:
output_dir (Path): The training run's output directory.
total_steps (int): The total number of training steps; sets the identifier padding.
step (int): The training step of the checkpoint.
Returns:
Path: The checkpoint step directory, `output_dir/checkpoints/<step-identifier>`.
"""
step_identifier = get_step_identifier(step, total_steps)
return output_dir / CHECKPOINTS_DIR / step_identifier
@@ -63,37 +113,15 @@ def should_save_checkpoint(step: int, save_freq: int, total_steps: int) -> bool:
return (save_freq > 0 and step % save_freq == 0) or step == total_steps
def save_training_step(
step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None
) -> None:
state: dict = {"step": step}
# num_processes and batch_size are recorded so a resumed run can detect a changed world size or
# batch size: the sampler's resume offset is computed from the (num_processes, batch_size) that
# produced `step`, since both scale how many sampler positions a step consumes (see
# compute_sampler_state).
if num_processes is not None:
state["num_processes"] = num_processes
if batch_size is not None:
state["batch_size"] = batch_size
write_json(state, save_dir / TRAINING_STEP)
def update_last_checkpoint(checkpoint_dir: Path) -> None:
"""Point the `last` symlink in the checkpoints directory at the given checkpoint.
Any existing `last` symlink is replaced. The link target is relative to the checkpoints
directory, so the tree stays valid when the run directory is moved.
def load_training_step(save_dir: Path) -> int:
training_step = load_json(save_dir / TRAINING_STEP)
return training_step["step"]
def load_training_num_processes(checkpoint_dir: Path) -> int | None:
"""World size recorded at checkpoint time, or None for checkpoints written before it was stored."""
return load_json(checkpoint_dir / TRAINING_STATE_DIR / TRAINING_STEP).get("num_processes")
def load_training_batch_size(checkpoint_dir: Path) -> int | None:
"""Per-process batch size recorded at checkpoint time, or None for older checkpoints."""
return load_json(checkpoint_dir / TRAINING_STATE_DIR / TRAINING_STEP).get("batch_size")
def update_last_checkpoint(checkpoint_dir: Path) -> Path:
Args:
checkpoint_dir (Path): The checkpoint step directory the `last` link should target.
"""
last_checkpoint_dir = checkpoint_dir.parent / LAST_CHECKPOINT_LINK
if last_checkpoint_dir.is_symlink():
last_checkpoint_dir.unlink()
@@ -101,6 +129,68 @@ def update_last_checkpoint(checkpoint_dir: Path) -> Path:
last_checkpoint_dir.symlink_to(relative_target)
# ---------------------------------------------------------------------------------------------
# training_step.json
# ---------------------------------------------------------------------------------------------
def save_training_metadata(step: int, save_dir: Path, cfg: TrainPipelineConfig) -> None:
"""Record the step counter plus everything a resume needs to reason about topology changes.
`step` counts loop iterations (= micro-batches), so
the sampler resume offset is `step x batch_size x dp_world_size` with no grad-accum factor.
`grad_accum_steps` and the parallelism snapshot are recorded so a resume can warn precisely
when the optimizer-update cadence or the sharding topology changed.
Args:
step (int): The training step (micro-batch counter) to record.
save_dir (Path): The `training_state/` directory to write `training_step.json` into.
cfg (TrainPipelineConfig): The training config whose batch size, gradient-accumulation,
and parallelism settings are snapshotted alongside the step.
"""
state: dict[str, Any] = {
"step": step,
"dp_world_size": cfg.parallelism.dp_world_size,
"batch_size": cfg.batch_size,
"grad_accum_steps": cfg.accelerator.gradient_accumulation.steps,
"parallelism": {
"dp_replicate": cfg.parallelism.dp_replicate,
"dp_shard": cfg.parallelism.dp_shard,
"ring_degree": cfg.parallelism.context_parallel.ring_degree,
"ulysses_degree": cfg.parallelism.context_parallel.ulysses_degree,
},
}
write_json(state, save_dir / TRAINING_STEP)
def load_training_metadata(training_state_dir: Path) -> dict[str, Any]:
"""Read everything `save_training_metadata` recorded, in a single pass.
Every key is always present: fields a checkpoint predates come back as None, so a caller
reading `metadata["batch_size"]` gets a KeyError on a typo rather than a silent None.
Args:
training_state_dir (Path): The checkpoint's `training_state/` directory.
Returns:
dict[str, Any]: `step` plus the `dp_world_size`, `batch_size`, `grad_accum_steps` and
`parallelism` snapshot recorded alongside it (None where not recorded).
"""
state = load_json(training_state_dir / TRAINING_STEP)
return {
"step": int(state["step"]),
"dp_world_size": state.get("dp_world_size", state.get("num_processes")),
"batch_size": state.get("batch_size"),
"grad_accum_steps": state.get("grad_accum_steps"),
"parallelism": state.get("parallelism"),
}
# ---------------------------------------------------------------------------------------------
# Checkpoint save
# ---------------------------------------------------------------------------------------------
def save_checkpoint(
checkpoint_dir: Path,
step: int,
@@ -110,192 +200,301 @@ def save_checkpoint(
scheduler: LRScheduler | None = None,
preprocessor: PolicyProcessorPipeline | None = None,
postprocessor: PolicyProcessorPipeline | None = None,
num_processes: int | None = None,
batch_size: int | None = None,
model_state_dict: dict | None = None,
optim_state_dict: dict | None = None,
accelerator: "Accelerator | None" = None,
) -> None:
"""This function creates the following directory structure:
005000/ # training step at checkpoint
├── pretrained_model/
│ ├── config.json # policy config
│ ├── model.safetensors # policy weights
│ ├── model.safetensors # policy weights (checkpoint_format ∈ {safetensors, safetensors_dcp}, or any non-sharded run)
│ ├── pytorch_model_fsdp_0/ # DCP model shards (checkpoint_format ∈ {dcp, safetensors_dcp})
│ ├── train_config.json # train config
│ ├── processor.json # processor config (if preprocessor provided)
── step_*.safetensors # processor state files (if any)
│ ├── policy_preprocessor.json # preprocessor config (if preprocessor provided)
── policy_preprocessor_step_*.safetensors # state of the stateful preprocessor steps
│ ├── policy_postprocessor.json # postprocessor config (if postprocessor provided)
│ └── policy_postprocessor_step_*.safetensors # state of the stateful postprocessor steps
└── training_state/
├── optimizer_param_groups.json # optimizer param groups
├── optimizer_state.safetensors # optimizer state
├── optimizer_param_groups.json # optimizer param groups (non-sharded runs)
├── optimizer_state.safetensors # optimizer state (non-sharded runs)
├── optimizer_0/ # DCP optimizer shards (sharded runs)
├── rng_state.safetensors # rng states
├── scheduler_state.json # scheduler state
└── training_step.json # training step
├── scheduler_state.json # scheduler state (if scheduler provided)
└── training_step.json # training step + dp_world_size/batch_size/grad_accum + topology
Collective: MUST be called on every rank. Rank-0-only writes are gated internally, so the
call site needs no rank branches.
Args:
cfg (TrainPipelineConfig): The training config used for this run.
checkpoint_dir (Path): The checkpoint step directory to write (e.g. `.../checkpoints/005000`).
step (int): The training step at that checkpoint.
cfg (TrainPipelineConfig): The training config used for this run.
policy (PreTrainedPolicy): The policy to save.
optimizer (Optimizer | None, optional): The optimizer to save the state from. Defaults to None.
optimizer (Optimizer): The optimizer to save the state from.
scheduler (LRScheduler | None, optional): The scheduler to save the state from. Defaults to None.
preprocessor: The preprocessor/pipeline to save. Defaults to None.
postprocessor: The postprocessor/pipeline to save. Defaults to None.
num_processes (int | None, optional): Distributed world size to record for sample-exact
resume. Defaults to None (not recorded).
batch_size (int | None, optional): Per-process batch size to record for sample-exact
resume. Defaults to None (not recorded).
model_state_dict: Pre-gathered full (unsharded) model state dict. Required under FSDP,
where `policy.state_dict()` would return sharded tensors; the caller gathers it via a
cross-rank collective and passes it here so rank 0 can write it directly. It holds
FSDP's fp32 master weights and is saved as-is (the loader casts to the policy dtype on
read). When None (DDP / single-GPU), the model is saved the normal way. Defaults to None.
optim_state_dict: Pre-gathered full (unsharded) optimizer state dict. Required under FSDP
(gathered alongside `model_state_dict` via `gather_fsdp_state_dicts`); saved in the same
safetensors format as the single-GPU path. When None, `optimizer.state_dict()` is used.
preprocessor (PolicyProcessorPipeline | None, optional): The preprocessor/pipeline to save.
Defaults to None.
postprocessor (PolicyProcessorPipeline | None, optional): The postprocessor/pipeline to save.
Defaults to None.
accelerator (Accelerator | None, optional): The accelerator the policy was prepared with;
used to unwrap the model and required on sharded runs, where it owns the DCP save
channels. Defaults to None (plain single-process saves).
"""
pretrained_dir = checkpoint_dir / PRETRAINED_MODEL_DIR
policy.save_pretrained(pretrained_dir, state_dict=model_state_dict)
cfg.save_pretrained(pretrained_dir)
fmt = cfg.checkpoint_format
policy_to_save = accelerator.unwrap_model(policy) if accelerator is not None else policy
sharded = is_sharded_module(policy_to_save)
# -- model artifact(s): the two collective-capable calls ----------------------------------
if cfg.peft is not None:
# When using PEFT, policy.save_pretrained will only write the adapter weights + config, not the
# policy config which we need for loading the model. In this case we'll write it ourselves.
policy.config.save_pretrained(pretrained_dir)
if preprocessor is not None:
preprocessor.save_pretrained(pretrained_dir)
if postprocessor is not None:
postprocessor.save_pretrained(pretrained_dir)
# PeftModel.save_pretrained is an external API with no internal rank gate, and the
# adapters are replicated (PEFT x sharded is rejected at validation): main rank writes.
if is_main_process():
policy_to_save.save_pretrained(pretrained_dir)
elif fmt.wants_safetensors or not sharded:
# Collective when sharded (full gather); writes happen on the main process only in all
# multi-rank layouts (the gate lives inside _save_pretrained, next to its collective gather).
policy_to_save.save_pretrained(pretrained_dir)
if fmt.wants_dcp and sharded:
save_sharded_model(accelerator, policy_to_save, pretrained_dir)
# -- sidecar configs: ONE gate for the whole contiguous rank-0-only region ----------------
if is_main_process():
if fmt.wants_dcp and not fmt.wants_safetensors:
# save_pretrained did not run: keep the DCP-only checkpoint self-describing.
policy_to_save.config.save_pretrained(pretrained_dir)
cfg.save_pretrained(pretrained_dir)
if cfg.peft is not None:
# PEFT's save_pretrained writes only adapter weights + config; the policy config
# needed to reload the base model is written explicitly.
policy_to_save.config.save_pretrained(pretrained_dir)
if preprocessor is not None:
preprocessor.save_pretrained(pretrained_dir)
if postprocessor is not None:
postprocessor.save_pretrained(pretrained_dir)
save_training_state(
checkpoint_dir,
step,
optimizer,
scheduler,
num_processes=num_processes,
batch_size=batch_size,
optim_state_dict=optim_state_dict,
checkpoint_dir, step, cfg, optimizer, scheduler, accelerator, sharded=sharded, model=policy_to_save
)
if accelerator is not None:
accelerator.wait_for_everyone()
def save_training_state(
checkpoint_dir: Path,
train_step: int,
optimizer: Optimizer | None = None,
step: int,
cfg: TrainPipelineConfig,
optimizer: Optimizer | dict[str, Optimizer] | None = None,
scheduler: LRScheduler | None = None,
num_processes: int | None = None,
batch_size: int | None = None,
optim_state_dict: dict | None = None,
accelerator: "Accelerator | None" = None,
*,
sharded: bool = False,
model: PreTrainedPolicy | None = None,
) -> None:
"""
Saves the training step, optimizer state, scheduler state, and rng state.
"""Write training_state/. Collective under sharding: call on every rank.
Args:
save_dir (Path): The directory to save artifacts to.
train_step (int): Current training step.
optimizer (Optimizer | None, optional): The optimizer from which to save the state_dict.
checkpoint_dir (Path): The checkpoint step directory; `training_state/` is created inside it.
step (int): The training step at that checkpoint.
cfg (TrainPipelineConfig): The training config used for this run (its topology and
accumulation settings are recorded in `training_step.json`).
optimizer (Optimizer | dict[str, Optimizer] | None, optional): The optimizer(s) to save
the state from. Defaults to None.
scheduler (LRScheduler | None, optional): The scheduler to save the state from.
Defaults to None.
scheduler (LRScheduler | None, optional): The scheduler from which to save the state_dict.
Defaults to None.
num_processes (int | None, optional): Distributed world size to record. Defaults to None.
batch_size (int | None, optional): Per-process batch size to record. Defaults to None.
optim_state_dict: Pre-gathered full optimizer state dict (for FSDP). Saved instead of
`optimizer.state_dict()` when provided. Defaults to None.
accelerator (Accelerator | None, optional): Required when `sharded` is True — it owns
the DCP optimizer save channel. Defaults to None.
sharded (bool): The model's sharding state, computed once in `save_checkpoint` and
threaded here so the two sites cannot disagree. Defaults to False.
model (PreTrainedPolicy | None, optional): Required only for the sharded optimizer
channel: torch's optimizer DCP APIs are model-coupled (the state dict is keyed by
model FQNs), so accelerate's `save_fsdp_optimizer` needs the sharded module
alongside the optimizer. Defaults to None.
"""
save_dir = checkpoint_dir / TRAINING_STATE_DIR
# All ranks: the directory must exist before the DCP optimizer collective writes into it
# (exist_ok makes the concurrent mkdir race-free on shared filesystems).
save_dir.mkdir(parents=True, exist_ok=True)
save_training_step(train_step, save_dir, num_processes=num_processes, batch_size=batch_size)
save_rng_state(save_dir)
if optimizer is not None:
save_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict)
if scheduler is not None:
save_scheduler_state(scheduler, save_dir)
if optimizer is not None and sharded:
if accelerator is None or model is None:
raise ValueError("Saving a sharded optimizer state requires the accelerator and model.")
# Collective — all ranks write their DCP shards into optimizer_0/.
save_sharded_optimizer(accelerator, optimizer, model, save_dir)
if is_main_process(): # ONE grouped gate for the whole rank-0-only region
save_training_metadata(step, save_dir, cfg)
save_rng_state(save_dir)
if scheduler is not None:
save_scheduler_state(scheduler, save_dir)
if optimizer is not None and not sharded:
save_optimizer_state(optimizer, save_dir)
def load_training_state(
checkpoint_dir: Path, optimizer: Optimizer, scheduler: LRScheduler | None, load_optimizer: bool = True
) -> tuple[int, Optimizer, LRScheduler | None]:
"""
Loads the training step, optimizer state, scheduler state, and rng state.
This is used to resume a training run.
# ---------------------------------------------------------------------------------------------
# Two-phase resume
# ---------------------------------------------------------------------------------------------
def resume_before_prepare(cfg: TrainPipelineConfig) -> int:
"""Phase 1 — before `accelerator.prepare()`: restore RNG and return the step counter.
Pure loaders only. The sampler resume offset is *derived* from the returned step inside the
dataloader factory, and everything bound to sharded objects (model DCP shards, optimizer,
scheduler) loads in `resume_after_prepare`.
Args:
checkpoint_dir (Path): The checkpoint directory. Should contain a 'training_state' dir.
optimizer (Optimizer): The optimizer to load the state_dict to.
scheduler (LRScheduler | None): The scheduler to load the state_dict to (can be None).
load_optimizer (bool, optional): Whether to load the optimizer state from disk. Defaults to
True. Set to False under FSDP, where the sharded optimizer state must be loaded after
`accelerator.prepare()` via `load_fsdp_optimizer_state` (the optimizer is returned
untouched here).
cfg (TrainPipelineConfig): The resumed training config; `cfg.checkpoint_path` locates
the checkpoint to restore from.
Returns:
int: The training step recorded in the checkpoint (micro-batch counter).
Raises:
NotADirectoryError: If 'checkpoint_dir' doesn't contain a 'training_state' dir
Returns:
tuple[int, Optimizer, LRScheduler | None]: training step, optimizer and scheduler with their
state_dict loaded.
NotADirectoryError: If the checkpoint has no `training_state/` directory.
ValueError: If the resumed topology crosses the sharded/non-sharded boundary relative
to the one recorded in the checkpoint.
"""
training_state_dir = checkpoint_dir / TRAINING_STATE_DIR
training_state_dir = cfg.checkpoint_path / TRAINING_STATE_DIR
if not training_state_dir.is_dir():
raise NotADirectoryError(training_state_dir)
metadata = load_training_metadata(training_state_dir)
_guard_resume_changes(cfg, metadata)
load_rng_state(training_state_dir)
step = load_training_step(training_state_dir)
if load_optimizer:
optimizer = load_optimizer_state(optimizer, training_state_dir)
return metadata["step"]
def _guard_resume_changes(cfg: TrainPipelineConfig, metadata: dict[str, Any]) -> None:
"""Check the resumed run settings against the ones recorded in the checkpoint.
Two tiers, both driven by the checkpoint's recorded parallelism snapshot:
- **Hard error** when the resume crosses the sharded/non-sharded boundary in either
direction: the checkpoint's training-state artifacts only support resuming on the same
kind of topology (resharding works across sizes, not across kinds). Checkpoints without
a recorded snapshot skip this check.
- **One warning** naming every other recorded setting that differs — those changes are
legal (DCP reshards weights and optimizer state across topologies and the sampler offset
adapts), but a changed ``grad_accum_steps`` shifts the optimizer-update cadence, so the
resume says precisely what differs. The sampler-exactness warnings
(``dp_world_size``/``batch_size``) live with the sampler math in the dataloader factory.
Args:
cfg (TrainPipelineConfig): The resumed training config, compared against the settings
recorded in the checkpoint.
metadata (dict[str, Any]): The checkpoint's recorded training metadata, as returned by
`load_training_metadata`.
Raises:
ValueError: If the checkpoint records a sharded topology and the resumed run is
non-sharded, or vice versa.
"""
snapshot = metadata["parallelism"]
if snapshot is not None:
recorded_sharded = (
snapshot.get("dp_shard", 1) != 1
or snapshot.get("ring_degree", 1) * snapshot.get("ulysses_degree", 1) > 1
)
if recorded_sharded != cfg.parallelism.is_sharded:
raise ValueError(
f"Cannot resume: the checkpoint was written with a "
f"{'sharded' if recorded_sharded else 'non-sharded'} topology "
f"(dp_replicate={snapshot.get('dp_replicate')}, dp_shard={snapshot.get('dp_shard')}) "
f"but this run is {'sharded' if cfg.parallelism.is_sharded else 'non-sharded'} "
f"(dp_replicate={cfg.parallelism.dp_replicate}, dp_shard={cfg.parallelism.dp_shard})."
)
recorded = {
"grad_accum_steps": (
metadata["grad_accum_steps"],
cfg.accelerator.gradient_accumulation.steps,
),
}
if snapshot is not None:
recorded.update(
{
"dp_replicate": (snapshot.get("dp_replicate"), cfg.parallelism.dp_replicate),
"dp_shard": (snapshot.get("dp_shard"), cfg.parallelism.dp_shard),
"ring_degree": (
snapshot.get("ring_degree"),
cfg.parallelism.context_parallel.ring_degree,
),
"ulysses_degree": (
snapshot.get("ulysses_degree"),
cfg.parallelism.context_parallel.ulysses_degree,
),
}
)
changed = [f"{key}: {was} -> {now}" for key, (was, now) in recorded.items() if was not in (None, now)]
if changed and is_main_process():
logging.warning(
"Resuming with settings that differ from the checkpoint: " + "; ".join(changed) + ". "
"Topology changes reshard safely via DCP; a changed grad_accum_steps shifts the "
"optimizer-update cadence (the step counter keeps counting micro-batches)."
)
def resume_after_prepare(
cfg: TrainPipelineConfig,
accelerator: "Accelerator",
policy: PreTrainedPolicy,
optimizer: Optimizer | dict[str, Optimizer],
scheduler: LRScheduler | None,
) -> None:
"""Phase 2 — after `accelerator.prepare()`: model (DCP) -> optimizer -> scheduler.
Collective under sharding: call on every rank. The model-weight source follows the
checkpoint's own recorded `checkpoint_format` (on resume, `cfg` was parsed from the
checkpoint's train_config.json): DCP-bearing formats load shards here into the prepared
model (whose construction skipped the safetensors load); the safetensors format was already
loaded by `from_pretrained` before sharding — no model step here.
Args:
cfg (TrainPipelineConfig): The resumed training config; `cfg.checkpoint_path` locates
the checkpoint and `cfg.checkpoint_format` selects the model-weight source.
accelerator (Accelerator): The accelerator the policy was prepared with; it unwraps the
model and owns the DCP load channels.
policy (PreTrainedPolicy): The prepared (possibly sharded) policy to load weights into.
optimizer (Optimizer | dict[str, Optimizer]): The prepared optimizer(s) to restore.
scheduler (LRScheduler | None): The scheduler to restore, or None if the run has none.
Raises:
FileNotFoundError: If the checkpoint format declares DCP model shards but the shard
directory is missing (e.g. it was pruned before upload).
"""
checkpoint_dir = cfg.checkpoint_path
pretrained_dir = checkpoint_dir / PRETRAINED_MODEL_DIR
training_state_dir = checkpoint_dir / TRAINING_STATE_DIR
unwrapped = accelerator.unwrap_model(policy)
sharded = is_sharded_module(unwrapped)
if cfg.checkpoint_format.wants_dcp:
from accelerate.utils.constants import FSDP_MODEL_NAME
dcp_dir = pretrained_dir / f"{FSDP_MODEL_NAME}_0"
if not dcp_dir.is_dir():
raise FileNotFoundError(
f"checkpoint_format={cfg.checkpoint_format.value} declares DCP model shards, "
f"but {dcp_dir} is missing. If the shards were pruned, convert what remains "
"with `lerobot-convert-dcp` or resume from a safetensors checkpoint."
)
load_sharded_model(accelerator, unwrapped, pretrained_dir)
if sharded:
# Requires the prepared optimizer: FSDP2's prepare rebinds param groups to DTensors but
# never migrates optimizer.state — DCP reshards it here (works across topology changes).
load_sharded_optimizer(accelerator, optimizer, unwrapped, training_state_dir)
else:
load_optimizer_state(optimizer, training_state_dir)
if scheduler is not None:
scheduler = load_scheduler_state(scheduler, training_state_dir)
return step, optimizer, scheduler
load_scheduler_state(scheduler, training_state_dir)
def gather_fsdp_state_dicts(model, optimizer) -> tuple[dict, dict]:
"""Gather the full (unsharded) model and optimizer state dicts under FSDP.
`model.state_dict()` and `FSDP.optim_state_dict(...)` are cross-rank collectives, so this must be
called on *every* rank with the prepared (FSDP-wrapped) `model` and `optimizer`. With
`rank0_only=True` and `offload_to_cpu=True`, every rank runs the all-gather but only rank 0
materializes the full dicts (the others get empty dicts) and they are kept on CPU to bound GPU
memory. The returned optimizer state dict is keyed by parameter FQNs and is world-size
independent; `load_fsdp_optimizer_state` reshards it on resume.
Returns:
(model_state_dict, optim_state_dict): full dicts on rank 0, empty dicts on other ranks.
"""
from torch.distributed.fsdp import (
FullOptimStateDictConfig,
FullStateDictConfig,
FullyShardedDataParallel as FSDP, # noqa F401
StateDictType,
)
state_cfg = FullStateDictConfig(offload_to_cpu=True, rank0_only=True)
optim_cfg = FullOptimStateDictConfig(offload_to_cpu=True, rank0_only=True)
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
model_state_dict = model.state_dict()
optim_state_dict = FSDP.optim_state_dict(model, optimizer)
return model_state_dict, optim_state_dict
def load_fsdp_optimizer_state(model, optimizer, checkpoint_dir: Path) -> None:
"""Load the FSDP optimizer state (saved as safetensors) and reshard it into the optimizer.
This is a cross-rank collective and must be called on every rank *after* `accelerator.prepare()`
with the prepared (FSDP-wrapped) `model` and `optimizer`. The saved state is the full,
world-size-independent optimizer state (keyed by parameter FQNs); `FSDP.optim_state_dict_to_load`
reshards it to the current FSDP topology, so resume on a different number of GPUs works.
"""
from torch.distributed.fsdp import (
FullOptimStateDictConfig,
FullStateDictConfig,
FullyShardedDataParallel as FSDP, # noqa F401
StateDictType,
)
# Every rank reads the same full state from the (shared) checkpoint dir, so rank0_only=False.
full_osd = load_optimizer_state_dict(checkpoint_dir / TRAINING_STATE_DIR)
state_cfg = FullStateDictConfig(rank0_only=False)
optim_cfg = FullOptimStateDictConfig(rank0_only=False)
with FSDP.state_dict_type(model, StateDictType.FULL_STATE_DICT, state_cfg, optim_cfg):
sharded_osd = FSDP.optim_state_dict_to_load(model=model, optim=optimizer, optim_state_dict=full_osd)
optimizer.load_state_dict(sharded_osd)
# ---------------------------------------------------------------------------------------------
# Hub: checkpoint push (resume artifact) and publishing (distribution artifact)
# ---------------------------------------------------------------------------------------------
def push_checkpoint_to_hub(
@@ -311,6 +510,16 @@ def push_checkpoint_to_hub(
The model repo is created idempotently, and the commit is tagged with the
checkpoint step so a checkpoint can be recovered with
--policy.pretrained_revision=<step> instead of a commit sha.
The directory is uploaded verbatim — including DCP shards under the DCP formats: this tree
exists for *resume*, not distribution, and `resolve_resume_checkpoint` downloads it back
symmetrically.
Args:
checkpoint_dir (Path): The local checkpoint step directory to upload.
repo_id (str): The Hub model repo to push to (created idempotently if missing).
private (bool | None): Whether a newly created repo should be private. Defaults to
None (public unless the organization's default is private).
"""
api = HfApi()
api.create_repo(repo_id=repo_id, repo_type="model", private=private, exist_ok=True)
@@ -338,6 +547,16 @@ def resolve_resume_checkpoint(repo_id: str, output_dir: Path) -> Path:
into `output_dir/checkpoints/<step>/`, recreate the local `last` symlink, and return that local
checkpoint dir. Used to resume training from the Hub on a machine (or HF Jobs pod) that does not
have the original local run dir.
Args:
repo_id (str): The Hub model repo holding `checkpoints/<step>/` subtrees.
output_dir (Path): The local run directory to download the checkpoint into.
Returns:
Path: The local checkpoint step directory, `output_dir/checkpoints/<step>`.
Raises:
FileNotFoundError: If the repo contains no checkpoints under `checkpoints/`.
"""
latest = find_latest_hub_checkpoint(repo_id)
if latest is None:
@@ -354,3 +573,214 @@ def resolve_resume_checkpoint(repo_id: str, output_dir: Path) -> Path:
checkpoint_dir = output_dir / latest
update_last_checkpoint(checkpoint_dir)
return checkpoint_dir
def publish_trained_model(
cfg: TrainPipelineConfig,
model: "PreTrainedPolicy | PreTrainedRewardModel",
preprocessor: PolicyProcessorPipeline | None,
postprocessor: PolicyProcessorPipeline | None,
dataset_meta: "LeRobotDatasetMetadata | None",
*,
peft_model: Any | None = None,
) -> None:
"""Publish the complete training bundle as a distributable model repo.
Collective-safe: call on ALL ranks — the model commit gathers sharded weights through
`save_pretrained`; uploads happen on the main process only (gated inside
`HubMixin.push_to_hub` and here). Commits, in order: (1) the model (skipped for PEFT —
adapters replace full weights), (2) the preprocessor, (3) the postprocessor, (4) the bundle
sidecar: README.md model card + train_config.json (+ adapter weights and the wrapped
policy's config in the PEFT case). Every commit uploads a freshly assembled directory, so
a published repo carries only the distributable artifacts.
Args:
cfg (TrainPipelineConfig): The training config; saved as `train_config.json` and used
to render the model card.
model (PreTrainedPolicy | PreTrainedRewardModel): The trained model to publish; its
config supplies the target repo id, visibility, license, and tags.
preprocessor (PolicyProcessorPipeline | None): The preprocessor pipeline to publish
alongside the model, if any.
postprocessor (PolicyProcessorPipeline | None): The postprocessor pipeline to publish
alongside the model, if any.
dataset_meta (LeRobotDatasetMetadata | None): Dataset metadata for the model card, if
available.
peft_model (Any | None): The PEFT wrapper when training adapters; its adapter weights
replace the full model weights in the published repo. Defaults to None.
Raises:
ValueError: If the model config carries no repo id (`--policy.repo_id`).
"""
model_cfg = model.config
repo_id = model_cfg.repo_id
if not repo_id:
raise ValueError("Publishing requires a repo id (--policy.repo_id).")
ignore = ["*.tmp", "*.log"]
if peft_model is None:
# Calls are made on the exact objects that own each method (never through PEFT's
# attribute forwarding), so the peft branch below never touches this path.
model.push_to_hub(repo_id, private=model_cfg.private, ignore_patterns=ignore)
if preprocessor is not None:
preprocessor.push_to_hub(repo_id, private=model_cfg.private)
if postprocessor is not None:
postprocessor.push_to_hub(repo_id, private=model_cfg.private)
if is_main_process():
api = HfApi()
repo_id = api.create_repo(repo_id=repo_id, private=model_cfg.private, exist_ok=True).repo_id
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
saved_path = Path(tmp) / repo_id
saved_path.mkdir(parents=True, exist_ok=True)
if peft_model is not None:
peft_model.save_pretrained(saved_path) # adapter weights + adapter config
model.config.save_pretrained(saved_path) # PEFT cannot write the policy config
card = generate_model_card(model_cfg, cfg=cfg, dataset_meta=dataset_meta)
card.save(str(saved_path / "README.md"))
cfg.save_pretrained(saved_path) # train_config.json
commit_info = api.upload_folder(
repo_id=repo_id,
repo_type="model",
folder_path=saved_path,
commit_message="Upload model card and train config",
allow_patterns=["*.safetensors", "*.json", "*.yaml", "*.md"],
ignore_patterns=ignore,
)
# Contract: lerobot.jobs.hf.submit_to_hf watches for this exact "Model pushed to <url>"
# line to end a remote run early. Keep the wording and URL format in sync.
logging.info(f"Model pushed to {commit_info.repo_url.url}")
if dist.is_initialized():
dist.barrier()
# ---------------------------------------------------------------------------------------------
# Model card
# ---------------------------------------------------------------------------------------------
_BASE_MODEL_MAPPING = {
"smolvla": "lerobot/smolvla_base",
"pi0": "lerobot/pi0_base",
"pi05": "lerobot/pi05_base",
"pi0_fast": "lerobot/pi0fast-base",
"xvla": "lerobot/xvla-base",
}
def build_card_context(
cfg: TrainPipelineConfig | None,
dataset_meta: "LeRobotDatasetMetadata | None",
input_features: dict | None,
output_features: dict | None,
) -> dict:
"""Collect optional data for the model-card template.
Returns plain values only (no Markdown) — the template in
``lerobot/templates/lerobot_modelcard_template.md`` decides how and whether to show
each one. Everything is best-effort: anything unavailable is left empty/None and the
template simply skips that section, so this never breaks a Hub push.
Args:
cfg (TrainPipelineConfig | None): The training config supplying the training section,
if available.
dataset_meta (LeRobotDatasetMetadata | None): Dataset metadata supplying the dataset,
robot-type, and camera sections, if available.
input_features (dict | None): The policy's input feature declarations, if any.
output_features (dict | None): The policy's output feature declarations, if any.
Returns:
dict: Template context with `training`, `input_features`, `output_features`,
`dataset`, `robot_type`, and `cameras` entries; unavailable pieces stay
empty/None.
"""
context = {
"training": None,
"input_features": input_features or {},
"output_features": output_features or {},
"dataset": None,
"robot_type": None,
"cameras": [],
}
if cfg is not None:
optimizer = getattr(cfg, "optimizer", None)
context["training"] = {
"steps": cfg.steps,
"batch_size": cfg.batch_size,
"seed": cfg.seed,
"optimizer": getattr(optimizer, "type", None) if optimizer else None,
"lr": getattr(optimizer, "lr", None) if optimizer else None,
"lerobot_version": __version__,
}
if dataset_meta is not None:
context["dataset"] = {
"repo_id": dataset_meta.repo_id,
"episodes": dataset_meta.total_episodes,
"frames": dataset_meta.total_frames,
"fps": dataset_meta.fps,
"tasks": [str(task) for task in dataset_meta.tasks.index],
}
context["robot_type"] = dataset_meta.robot_type
context["cameras"] = [key.split(".")[-1] for key in dataset_meta.camera_keys]
return context
def generate_model_card(
model_cfg: PreTrainedConfig | RewardModelConfig,
cfg: TrainPipelineConfig | None = None,
dataset_meta: "LeRobotDatasetMetadata | None" = None,
) -> ModelCard:
"""Render the LeRobot model card for a trained policy or reward model.
A free function on purpose: every template variable comes from arguments — the model
config, the training config, and the dataset metadata — none from a live model, so a card
can also be rendered from a checkpoint's `config.json` alone (see `lerobot-convert-dcp`).
The config type selects the template: reward models get the reward-model card, policies the
policy card with the training/dataset sections.
Args:
model_cfg (PreTrainedConfig | RewardModelConfig): The model config providing type,
license, tags, repo id, and — for policies — the feature declarations.
cfg (TrainPipelineConfig | None, optional): The training config for the training and
dataset card sections. Defaults to None.
dataset_meta (LeRobotDatasetMetadata | None, optional): Dataset metadata for the
dataset card sections. Defaults to None.
Returns:
ModelCard: The rendered and validated LeRobot model card.
"""
model_type = model_cfg.type
base_model = _BASE_MODEL_MAPPING.get(model_type)
if isinstance(model_cfg, RewardModelConfig):
tags = {"robotics", "lerobot", "reward-model", model_type}
template_card = (
files("lerobot.templates")
.joinpath("lerobot_rewardmodel_modelcard_template.md")
.read_text("utf-8")
)
context: dict[str, Any] = {} # the reward template renders from card_data alone
else:
tags = {"robotics", "lerobot", model_type}
template_card = (
files("lerobot.templates").joinpath("lerobot_modelcard_template.md").read_text("utf-8")
)
context = build_card_context(cfg, dataset_meta, model_cfg.input_features, model_cfg.output_features)
# Used by the template to pre-fill commands and the "Fine-tuned from" line.
context["policy_repo_id"] = model_cfg.repo_id
context["base_model"] = base_model
card_data = ModelCardData(
license=model_cfg.license or "apache-2.0",
library_name="lerobot",
pipeline_tag="robotics",
tags=list(tags.union(model_cfg.tags or [])),
model_name=model_type,
datasets=cfg.dataset.repo_id if cfg is not None else None,
base_model=base_model,
)
card = ModelCard.from_template(card_data, template_str=template_card, **context)
card.validate()
return card
+2 -1
View File
@@ -22,7 +22,7 @@ Import them directly: ``from lerobot.configs.train import TrainPipelineConfig``
"""
from .dataset import DatasetRecordConfig
from .default import DatasetConfig, EvalConfig, JobConfig, PeftConfig, WandBConfig
from .default import DatasetConfig, EMAConfig, EvalConfig, JobConfig, PeftConfig, WandBConfig
from .policies import PreTrainedConfig
from .recipe import MessageTurn, TrainingRecipe, load_recipe
from .types import (
@@ -57,6 +57,7 @@ __all__ = [
# Config classes
"DatasetRecordConfig",
"DatasetConfig",
"EMAConfig",
"EvalConfig",
"JobConfig",
"MessageTurn",
+273
View File
@@ -0,0 +1,273 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Execution-runtime configuration: everything handed to (or applied by) the `Accelerator`.
Each sub-config mirrors the plain-typed subset of the corresponding accelerate object and
builds it at runtime (the way ``OptimizerConfig.build()`` constructs a ``torch.optim.Optimizer``),
so the whole tree round-trips through the CLI and ``train_config.json`` and parsing a config
never imports accelerate.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING
from lerobot.configs.parallelism import ParallelismConfig
if TYPE_CHECKING:
from accelerate import Accelerator
from accelerate.utils import (
DistributedDataParallelKwargs,
FullyShardedDataParallelPlugin,
GradientAccumulationPlugin,
)
@dataclass
class FSDPConfig:
"""Mirror of the `FullyShardedDataParallelPlugin` subset LeRobot supports (FSDP2 only).
Exactly one wrap policy applies: `wrap_modules` (module *class names* forming the FSDP
units — and, later, the activation-checkpointing units) or `min_num_params` (size-based).
When both are None, the policy's own `_fsdp_wrap_modules` declaration is used; a run where
no wrap source exists at all fails loudly rather than silently wrapping only the root.
"""
reshard_after_forward: bool = True
wrap_modules: list[str] | None = None
min_num_params: int | None = None
cpu_offload: bool = False
# Regex matched against module FQNs to exclude their parameters from sharding.
ignored_modules: str | None = None
def __post_init__(self) -> None:
"""Validate the wrap-policy fields.
Raises:
ValueError: If both ``wrap_modules`` and ``min_num_params`` are set (they are
mutually exclusive wrap policies), or if ``min_num_params`` is < 1.
"""
if self.wrap_modules is not None and self.min_num_params is not None:
raise ValueError(
"fsdp.wrap_modules and fsdp.min_num_params are mutually exclusive wrap policies."
)
if self.min_num_params is not None and self.min_num_params < 1:
raise ValueError(f"fsdp.min_num_params must be >= 1, got {self.min_num_params}.")
def build_plugin(self) -> "FullyShardedDataParallelPlugin":
"""Build the FSDP2 plugin for `Accelerator(fsdp_plugin=...)`.
Returns:
FullyShardedDataParallelPlugin: FSDP2 (`fsdp_version=2`) plugin carrying the
mirrored wrap policy, resharding, CPU-offload, and ignored-modules settings.
"""
from accelerate.utils import FullyShardedDataParallelPlugin
use_size_policy = self.min_num_params is not None
return FullyShardedDataParallelPlugin(
fsdp_version=2,
reshard_after_forward=self.reshard_after_forward,
auto_wrap_policy="size_based_wrap" if use_size_policy else "transformer_based_wrap",
# May legitimately still be None here: the policy-declared default is applied right
# before `accelerator.prepare()` (see lerobot.distributed.factory.set_fsdp_wrap_modules).
transformer_cls_names_to_wrap=list(self.wrap_modules) if self.wrap_modules else None,
min_num_params=self.min_num_params,
cpu_offload=self.cpu_offload,
ignored_modules=self.ignored_modules,
# state_dict_type stays at the FSDP2 default (SHARDED_STATE_DICT) and is never
# switched: full gathers go through torch's state-dict API, which does not consult
# the plugin. activation_checkpointing stays False: AC is LeRobot-owned.
)
@dataclass
class DDPConfig:
"""Mirror of the `DistributedDataParallelKwargs` subset LeRobot exposes."""
# Today's in-script default, kept for models with conditional computation.
find_unused_parameters: bool = True
gradient_as_bucket_view: bool = False
static_graph: bool = False
def build_kwargs_handler(self) -> "DistributedDataParallelKwargs":
"""Build the DDP kwargs handler for `Accelerator(kwargs_handlers=[...])`.
Returns:
DistributedDataParallelKwargs: Handler carrying the mirrored DDP fields, applied
by accelerate when it wraps the model in `DistributedDataParallel`.
"""
from accelerate.utils import DistributedDataParallelKwargs
return DistributedDataParallelKwargs(
find_unused_parameters=self.find_unused_parameters,
gradient_as_bucket_view=self.gradient_as_bucket_view,
static_graph=self.static_graph,
)
@dataclass
class GradientAccumulationConfig:
"""Mirror of the `GradientAccumulationPlugin` subset LeRobot supports.
Only the step count is a knob. ``sync_with_dataloader`` is pinned to False by
:meth:`build_plugin`: the training loop cycles a finite dataloader, so accelerate's default
of syncing at every dataloader end would force an optimizer step at every dataset epoch
boundary instead of every ``steps`` micro-batches.
"""
steps: int = 1
def __post_init__(self) -> None:
"""Validate the accumulation step count.
Raises:
ValueError: If ``steps`` is < 1.
"""
if self.steps < 1:
raise ValueError(f"gradient_accumulation.steps must be >= 1, got {self.steps}.")
def build_plugin(self) -> "GradientAccumulationPlugin":
"""Build the plugin for `Accelerator(gradient_accumulation_plugin=...)`.
A named plugin argument, not a `kwargs_handlers` entry: accelerate consumes this object
through its dedicated constructor parameter — the `KwargsHandler` base class only lends
it `to_kwargs()`, so the consumption site, not the inheritance, decides its role.
Returns:
GradientAccumulationPlugin: Carrying the mirrored step count, with
``sync_with_dataloader=False`` pinned (see the class docstring).
"""
from accelerate.utils import GradientAccumulationPlugin
return GradientAccumulationPlugin(num_steps=self.steps, sync_with_dataloader=False)
@dataclass
class CompileConfig:
"""torch.compile knobs — a configured placeholder: wiring lands in a later round.
The setup-order contract it will follow is already fixed: compile applies
after CP dispatch install and activation checkpointing, before `fully_shard`, regionally
(per wrap unit) — the only combination proven with FSDP2.
"""
enabled: bool = False
backend: str = "inductor"
mode: str | None = None
regional: bool = True
class ActivationCheckpointingMode(str, Enum):
NONE = "none"
FULL = "full"
@dataclass
class ActivationCheckpointingConfig:
"""Activation-checkpointing knobs — a configured placeholder: wiring lands in a later round.
AC units will coincide with the FSDP wrap units (one declaration drives both), applied
before torch.compile and `fully_shard` (the same ordering contract as CompileConfig).
"""
mode: ActivationCheckpointingMode = ActivationCheckpointingMode.NONE
@dataclass
class AcceleratorConfig:
"""Builds the `Accelerator` — the runtime counterpart of the `parallelism` topology.
`mixed_precision` selects accelerate-native AMP for DDP/single-GPU runs and the FSDP2
`MixedPrecisionPolicy` for sharded runs (accelerate derives it). Sharded runs support
"no" and "bf16" only; fp16's GradScaler-over-DTensor path is unverified and fails fast
at config validation.
"""
mixed_precision: str = "no"
gradient_accumulation: GradientAccumulationConfig = field(default_factory=GradientAccumulationConfig)
fsdp: FSDPConfig = field(default_factory=FSDPConfig)
ddp: DDPConfig = field(default_factory=DDPConfig)
compile: CompileConfig = field(default_factory=CompileConfig)
activation_checkpointing: ActivationCheckpointingConfig = field(
default_factory=ActivationCheckpointingConfig
)
def __post_init__(self) -> None:
"""Validate the accelerate-facing scalar fields.
Raises:
ValueError: If ``mixed_precision`` is not one of ``"no"``, ``"fp16"``, ``"bf16"``.
"""
if self.mixed_precision not in ("no", "fp16", "bf16"):
raise ValueError(
f"mixed_precision must be one of 'no', 'fp16', 'bf16', got {self.mixed_precision!r}."
)
def build(self, parallelism: ParallelismConfig, *, cpu: bool = False) -> "Accelerator":
"""Translate the mirrored fields into a ready `Accelerator` (call once per process).
`parallelism` must already be resolved against the world size. The degradation matrix
is encoded here and nowhere else: sharded -> FSDP2 (+HSDP via the accelerate
`ParallelismConfig` mesh), replicated-only -> DDP kwargs, single process -> plain.
Args:
parallelism (ParallelismConfig): The resolved process topology; selects which
accelerate path (FSDP2 mesh, DDP kwargs handler, or plain) is configured.
cpu (bool): Force CPU execution even when CUDA is available. Defaults to False.
Returns:
Accelerator: The configured accelerate entry point for this process.
"""
from accelerate import Accelerator
kwargs: dict = {
# LeRobot steps its scheduler manually once per training step; accelerate must not
# rescale scheduler stepping by num_processes.
"step_scheduler_with_optimizer": False,
"gradient_accumulation_plugin": self.gradient_accumulation.build_plugin(),
"mixed_precision": self.mixed_precision,
"cpu": cpu,
}
if parallelism.is_sharded:
kwargs["fsdp_plugin"] = self.fsdp.build_plugin()
kwargs["parallelism_config"] = _accelerate_parallelism_config(parallelism)
elif parallelism.is_replicated_only:
kwargs["kwargs_handlers"] = [self.ddp.build_kwargs_handler()]
return Accelerator(**kwargs)
def _accelerate_parallelism_config(parallelism: ParallelismConfig) -> object:
"""LeRobot topology -> accelerate `ParallelismConfig`.
CP is declared honestly (`cp_size = ring x ulysses`) so accelerate builds the canonical
mesh, folds CP into the FSDP shard group (`dp_shard_cp`), and duplicates batches within CP
groups. The ring/ulysses sub-structure stays private to `lerobot.distributed.ParallelDims`.
Args:
parallelism (ParallelismConfig): The resolved LeRobot topology to translate.
Returns:
object: The accelerate `ParallelismConfig` mirroring `dp_replicate`, `dp_shard`, and
the collapsed `cp_size` (annotated as `object` so importing this module never
imports accelerate).
"""
from accelerate.parallelism_config import ParallelismConfig as AccelerateParallelismConfig
return AccelerateParallelismConfig(
dp_replicate_size=parallelism.dp_replicate,
dp_shard_size=parallelism.dp_shard,
cp_size=parallelism.cp_size,
)
+79
View File
@@ -14,6 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from dataclasses import dataclass, field
from lerobot.transforms import ImageTransformsConfig
@@ -21,6 +22,8 @@ from lerobot.utils.import_utils import get_safe_default_video_backend
from .video import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT
logger = logging.getLogger(__name__)
@dataclass
class DatasetConfig:
@@ -29,10 +32,15 @@ class DatasetConfig:
# "dataset_index" into the returned item. The index mapping is made according to the order in which the
# datasets are provided.
repo_id: str
# Hub repository type: "dataset" (default) or "bucket" for an HF Storage Bucket streamed over
# hf://buckets/. Buckets are streaming-only, so "bucket" requires streaming=true.
repo_type: str = "dataset"
# Root directory for a concrete local dataset tree (e.g. 'dataset/path'). If None, local datasets are
# looked up under $HF_LEROBOT_HOME/repo_id and Hub downloads use a revision-safe cache under $HF_LEROBOT_HOME/hub.
root: str | None = None
episodes: list[int] | None = None
# Episode indices to drop (e.g. corrupt or heterogeneous ones). Applied on top of `episodes`.
exclude_episodes: list[int] | None = None
image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig)
revision: str | None = None
use_imagenet_stats: bool = True
@@ -48,6 +56,16 @@ class DatasetConfig:
eval_split: float = 0.0
def __post_init__(self) -> None:
if self.repo_type not in ("dataset", "bucket"):
raise ValueError(f"repo_type must be 'dataset' or 'bucket', got {self.repo_type!r}")
if self.repo_type == "bucket" and not self.streaming:
raise ValueError(
"repo_type='bucket' is streaming-only: set streaming=true to train from an HF Storage Bucket."
)
if self.repo_type == "bucket" and self.eval_split != 0.0:
raise ValueError(
"eval_split requires map-style datasets and is not supported with repo_type='bucket'."
)
if self.depth_output_unit not in (DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT):
raise ValueError(
f"depth_output_unit must be '{DEPTH_METER_UNIT}' or '{DEPTH_MILLIMETER_UNIT}', got {self.depth_output_unit!r}"
@@ -62,6 +80,14 @@ class DatasetConfig:
if len(self.episodes) != len(set(self.episodes)):
duplicates = sorted({ep for ep in self.episodes if self.episodes.count(ep) > 1})
raise ValueError(f"Episode indices contain duplicates: {duplicates}")
if self.exclude_episodes is not None:
negative_episodes = [episode for episode in self.exclude_episodes if episode < 0]
if negative_episodes:
logger.warning(
"Ignoring negative exclude_episodes entries: %s",
negative_episodes,
)
self.exclude_episodes = [episode for episode in self.exclude_episodes if episode >= 0]
@dataclass
@@ -113,6 +139,59 @@ class EvalConfig:
return min(by_cpu, self.n_episodes, 64)
@dataclass
class EMAConfig:
"""Exponential moving average (EMA) of the policy weights.
Standard practice for diffusion-style policies (Chi et al. 2023, "Diffusion Policy", section V.D):
the reference implementation enables it in every config and evaluates the EMA weights. Off by
default here because it keeps a second full copy of the parameters in memory.
The decay follows the warmup schedule from diffusers' `EMAModel`:
`decay_t = 1 - (1 + t / inv_gamma) ** -power`, clamped to `[min_decay, max_decay]`.
The defaults mirror the reference implementation. Alternatively, set `decay` for a constant
decay at every step, as used by openpi for pi0/pi05 (`ema_decay=0.99`).
"""
enable: bool = False
# Constant decay coefficient (openpi-style, e.g. 0.99 for pi0/pi05). When set, the warmup
# schedule below is bypassed and the shadow uses this decay at every step.
decay: float | None = None
# Number of optimizer steps during which the shadow stays a hard copy of the live weights.
update_after_step: int = 0
# Warmup schedule parameters (see class docstring).
inv_gamma: float = 1.0
power: float = 0.75
min_decay: float = 0.0
max_decay: float = 0.9999
# Evaluate the EMA weights (instead of the live ones) during periodic env eval.
# Offline eval-loss (--eval_steps) always uses the live weights: it runs on every rank
# while the EMA shadow only lives on the main process.
use_for_eval: bool = True
def __post_init__(self) -> None:
if not (0.0 <= self.min_decay <= self.max_decay <= 1.0):
raise ValueError(
"Expected 0 <= ema.min_decay <= ema.max_decay <= 1, got "
f"min_decay={self.min_decay} and max_decay={self.max_decay}."
)
if self.inv_gamma <= 0:
raise ValueError(f"ema.inv_gamma must be positive, got {self.inv_gamma}.")
if self.power <= 0:
raise ValueError(f"ema.power must be positive, got {self.power}.")
if self.update_after_step < 0:
raise ValueError(f"ema.update_after_step must be >= 0, got {self.update_after_step}.")
if self.decay is not None:
if not 0.0 <= self.decay <= 1.0:
raise ValueError(f"ema.decay must be in [0, 1], got {self.decay}.")
# Keep the literals in sync with the field defaults above.
if self.min_decay != 0.0 or self.max_decay != 0.9999:
raise ValueError(
"ema.decay (constant decay) and ema.min_decay/ema.max_decay (schedule clamp) are "
"mutually exclusive: set one or the other."
)
@dataclass
class PeftConfig:
# PEFT offers many fine-tuning methods, layer adapters being the most common and currently also the most
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Declarative process topology for distributed training and inference.
The mesh convention (canonical row-major rank layout, outermost first)::
(dp_replicate, dp_shard, ring, ulysses)
- ``dp_replicate x dp_shard`` is the data-parallel world: HSDP replicates over
``dp_replicate`` and shards parameters over ``dp_shard``. FSDP2's actual shard
group folds context parallelism in (``dp_shard x ring x ulysses``), matching
accelerate's ``dp_shard_cp`` flattening and torchtitan's ``fsdp`` axis.
- ``ring`` is the outer and ``ulysses`` the inner context-parallel dim
(diffusers convention: ulysses all-to-all exchanges run over adjacent, typically
NVLink-connected ranks).
- ``cfg_parallel`` (classifier-free-guidance parallelism) is a branch-parallel,
inference-only dim that sits between dp and the sequence dims. It never
affects weight sharding or checkpoints.
This module is pure configuration: plain-typed dataclasses that draccus can
round-trip through the CLI and ``train_config.json``. Runtime objects (device
meshes, process groups) live in :mod:`lerobot.distributed`.
"""
import os
from dataclasses import dataclass, field
@dataclass
class ContextParallelConfig:
"""Ring x Ulysses context parallelism (sequence parallelism for attention).
Both degrees are configured placeholders in this release: the CP engine is not implemented
yet, and enabling either degree > 1 fails fast at config validation. The fields exist now so
that the CLI surface, checkpoint metadata, and mesh math are stable when the engine lands.
"""
ring_degree: int = 1
ulysses_degree: int = 1
def __post_init__(self) -> None:
"""Validate the declared context-parallel degrees.
Raises:
ValueError: If ``ring_degree`` or ``ulysses_degree`` is < 1.
"""
if self.ring_degree < 1 or self.ulysses_degree < 1:
raise ValueError(
f"Context-parallel degrees must be >= 1, got ring_degree={self.ring_degree}, "
f"ulysses_degree={self.ulysses_degree}."
)
@property
def size(self) -> int:
"""Total number of ranks a full sequence is sharded across."""
return self.ring_degree * self.ulysses_degree
@dataclass
class ParallelismConfig:
"""Degrees of every parallelism dim. Invariant: their product equals the world size.
Degradations are expressed purely through the degrees (no mode flags):
- single process: all degrees 1;
- DDP: ``dp_replicate == world_size`` (auto-filled when every sharding field is left at its
default — plain ``torchrun`` keeps today's out-of-the-box behavior);
- FSDP: ``dp_shard > 1`` (or ``-1`` to fill the remaining world into the shard dim);
- HSDP: ``dp_replicate > 1`` and ``dp_shard > 1``.
``resolve()`` turns the declared degrees into concrete ones once the world size is known and
is the single place the world-size equation is enforced. It is called by
:func:`lerobot.distributed.factory.make_accelerator`; the config is inert until then.
"""
dp_replicate: int = 1
# -1 is an explicit opt-in sentinel: shard over world_size // (dp_replicate * cp).
dp_shard: int = 1
context_parallel: ContextParallelConfig = field(default_factory=ContextParallelConfig)
# Classifier-free-guidance parallelism — inference-only (cosmos/vllm-omni precedent:
# cond/uncond branches on different ranks). Reserved for the serving round; training
# validates it to 1. Meaningful values are 1 or 2 (Cosmos3 has two CFG branches).
cfg_parallel: int = 1
def __post_init__(self) -> None:
"""Validate the declared degrees (world-size-independent checks only).
Raises:
ValueError: If ``dp_replicate`` is < 1, ``dp_shard`` is neither >= 1 nor the
``-1`` infer sentinel, or ``cfg_parallel`` is not 1 or 2.
"""
if self.dp_replicate < 1:
raise ValueError(f"dp_replicate must be >= 1, got {self.dp_replicate}.")
if self.dp_shard < 1 and self.dp_shard != -1:
raise ValueError(f"dp_shard must be >= 1, or -1 to infer, got {self.dp_shard}.")
if self.cfg_parallel not in (1, 2):
raise ValueError(f"cfg_parallel must be 1 or 2, got {self.cfg_parallel}.")
@property
def cp_size(self) -> int:
"""Total context-parallel size (``ring_degree * ulysses_degree``)."""
return self.context_parallel.size
@property
def is_sharded(self) -> bool:
"""True when the run uses FSDP2 (parameters sharded); selects the sharded engine path."""
return self.dp_shard != 1 or self.cp_size > 1
@property
def is_replicated_only(self) -> bool:
"""True for plain DDP (weights replicated, no sharding)."""
return not self.is_sharded and self.dp_replicate > 1
@property
def dp_world_size(self) -> int:
"""Number of distinct data-parallel workers (batches are sharded this many ways).
Returns:
int: ``dp_replicate * dp_shard``.
Raises:
RuntimeError: If accessed while ``dp_shard`` is still the ``-1`` sentinel, i.e.
before :meth:`resolve` has bound the degrees to a world size.
"""
if self.dp_shard == -1:
raise RuntimeError("dp_world_size is undefined before resolve() fills dp_shard=-1.")
return self.dp_replicate * self.dp_shard
def resolve(self, world_size: int) -> None:
"""Bind the declared degrees to a concrete world size (idempotent).
Fills the ``dp_shard=-1`` sentinel, auto-fills ``dp_replicate`` for the DDP degradation,
and enforces ``dp_replicate * dp_shard * cp == world_size`` with every degree echoed on
failure.
Args:
world_size (int): Total number of launched processes (torchrun's ``WORLD_SIZE``).
Raises:
ValueError: If a context-parallel degree is > 1 (the CP engine is not implemented
yet), if ``dp_shard=-1`` cannot be inferred because ``world_size`` is not
divisible by ``dp_replicate * cp``, or if the resolved degrees do not multiply
to ``world_size``.
"""
if self.cp_size > 1:
raise ValueError(
"Context parallelism is not implemented yet: ring_degree and ulysses_degree "
"must be 1. The fields are reserved for the CP engine round."
)
if self.is_sharded:
if self.dp_shard == -1:
self.dp_shard, remainder = divmod(world_size, self.dp_replicate * self.cp_size)
if remainder or self.dp_shard < 1:
raise ValueError(
f"Cannot infer dp_shard: world_size={world_size} is not divisible by "
f"dp_replicate={self.dp_replicate} * cp={self.cp_size}."
)
elif self.dp_replicate == 1:
# Untouched config on a multi-process launch: fill the DDP degradation.
self.dp_replicate = world_size
total = self.dp_replicate * self.dp_shard * self.cp_size
if total != world_size:
raise ValueError(
f"Parallelism degrees do not multiply to the world size: dp_replicate="
f"{self.dp_replicate} * dp_shard={self.dp_shard} * ring="
f"{self.context_parallel.ring_degree} * ulysses="
f"{self.context_parallel.ulysses_degree} = {total} != WORLD_SIZE={world_size}."
)
def world_size_from_env() -> int:
"""World size as set by torchrun (or 1 outside distributed launches).
Returns:
int: The ``WORLD_SIZE`` environment variable, or 1 when unset.
"""
return int(os.environ.get("WORLD_SIZE", "1"))
+24 -7
View File
@@ -23,6 +23,7 @@ from typing import Any, Literal, get_args
MessageRole = Literal["user", "assistant", "system", "tool"]
MessageStream = Literal["high_level", "low_level"]
RecipeRoute = Literal["vqa"]
DEFAULT_BINDINGS = {
"subtask": "active_at(t, style=subtask)",
@@ -40,6 +41,7 @@ discovery (here) and rendered-message substitution (in ``language_render``)."""
_VALID_ROLES = frozenset(get_args(MessageRole))
_VALID_STREAMS = frozenset(get_args(MessageStream))
_VALID_ROUTES = frozenset(get_args(RecipeRoute))
@dataclass
@@ -78,7 +80,7 @@ class MessageTurn:
raise ValueError(f"Unsupported message stream: {self.stream!r}")
if self.content is None and self.tool_calls_from is None:
raise ValueError("MessageTurn.content is required unless tool_calls_from is set.")
if self.content is not None and not isinstance(self.content, (str, list)):
if self.content is not None and not isinstance(self.content, str | list):
raise TypeError("MessageTurn.content must be a string, a list of HF-style blocks, or None.")
if isinstance(self.content, list):
for block in self.content:
@@ -99,13 +101,16 @@ class TrainingRecipe:
A recipe is either a *message recipe* (``messages`` plus optional
``bindings``) or a *blend recipe* (``blend`` mapping names to weighted
sub-recipes). ``weight`` is only meaningful inside a blend.
sub-recipes). ``weight`` and ``route`` are only meaningful inside a blend;
``route: vqa`` gives sparse VQA annotations priority over normal weighted
selection.
"""
messages: list[MessageTurn] | None = None
bindings: dict[str, str] | None = None
blend: dict[str, TrainingRecipe] | None = None
weight: float | None = None
route: RecipeRoute | None = None
def __post_init__(self) -> None:
"""Validate that exactly one of ``messages`` or ``blend`` is set."""
@@ -113,6 +118,10 @@ class TrainingRecipe:
raise ValueError("TrainingRecipe must set only one of messages or blend.")
if self.messages is None and self.blend is None:
raise ValueError("TrainingRecipe must set one of messages or blend.")
if self.route is not None and self.route not in _VALID_ROUTES:
raise ValueError(f"Unsupported recipe route: {self.route!r}")
if self.blend is not None and self.route is not None:
raise ValueError("TrainingRecipe.route may only be set on a message recipe inside a blend.")
if self.messages is not None:
self._validate_message_recipe()
@@ -147,8 +156,9 @@ class TrainingRecipe:
return cls.from_dict(data)
def _validate_message_recipe(self) -> None:
"""Ensure every templated binding is known and at least one turn is a target."""
assert self.messages is not None
"""Validate bindings and require text or low-level action supervision."""
if self.messages is None:
raise ValueError("Cannot validate a message recipe without messages.")
known_bindings = set(DEFAULT_BINDINGS) | set(self.bindings or {}) | {"task"}
for turn in self.messages:
@@ -156,12 +166,19 @@ class TrainingRecipe:
if missing:
raise ValueError(f"MessageTurn references unknown binding(s): {sorted(missing)}")
if not any(turn.target for turn in self.messages):
raise ValueError("Message recipes must contain at least one target turn.")
has_target = any(turn.target for turn in self.messages)
has_low_level = any(turn.stream == "low_level" for turn in self.messages)
if not (has_target or has_low_level):
raise ValueError(
"Message recipes must contain at least one supervised turn — "
"either ``target: true`` (text CE) or ``stream: low_level`` "
"(flow/action loss)."
)
def _validate_blend_recipe(self) -> None:
"""Ensure each blend component is a non-empty, weighted message recipe."""
assert self.blend is not None
if self.blend is None:
raise ValueError("Cannot validate a blend recipe without blend components.")
if not self.blend:
raise ValueError("Blend recipes must contain at least one component.")
+16
View File
@@ -0,0 +1,16 @@
# Predicts subtasks from tasks and trains subtask-conditioned action flow without memory or plans.
# Requires `subtask` annotations; samples with missing `if_present` bindings do not render.
blend:
high_level_subtask:
weight: 0.30
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
low_level_execution:
weight: 0.70
messages:
# The low-level stream trains action flow on the generated or annotated subtask.
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
@@ -0,0 +1,13 @@
# Paper-style joint sequence (pi0.5 §IV-B): one sample supervises the subtask
# text with CE and, because the assistant turn is part of the prefix, conditions
# the FAST and flow action losses on the same annotated subtask in one forward.
# The supervised span is attended causally; the action losses see task + subtask.
#
# Pair with `--policy.joint_subtask_conditioning=true` at inference so the flow
# prefix reproduces this layout (task turn with state + causal generated subtask).
# Samples without a `subtask` annotation fall back to a plain task-prompt
# low-level sample via `if_present`.
messages:
- {role: user, content: "${task}", stream: low_level}
- {role: assistant, content: "${subtask}", stream: low_level, target: true, if_present: subtask}
@@ -0,0 +1,30 @@
# Trains subtask prediction, subtask-conditioned action flow, and memory updates without plans.
# Requires `subtask` and `memory`; missing `if_present` bindings skip the affected sub-recipe.
blend:
high_level_subtask:
weight: 0.25
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
low_level_execution:
weight: 0.60
messages:
# The low-level stream trains action flow on the generated or annotated subtask.
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
memory_update:
# `active_at` densifies sparse boundaries while preserving the prior-memory/subtask mapping.
# Inference controls update timing through `subtask_change` events.
weight: 0.15
bindings:
prior_memory: "nth_prev(style=memory, offset=1)"
current_memory: "active_at(t, style=memory)"
completed_subtask: "nth_prev(style=subtask, offset=1)"
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "Previous memory: ${prior_memory}", stream: high_level, if_present: prior_memory}
- {role: user, content: "Completed subtask: ${completed_subtask}", stream: high_level, if_present: completed_subtask}
- {role: assistant, content: "${current_memory}", stream: high_level, target: true, if_present: current_memory}
@@ -0,0 +1,72 @@
# Adds memory, spoken interjection responses, and camera-grounded VQA to subtask/action training.
# Missing optional annotations skip only their sub-recipe; `say` tool calls tokenize as `<say>...</say>`.
blend:
high_level_subtask:
weight: 0.25
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "${subtask}", stream: high_level, target: true, if_present: subtask}
low_level_execution:
weight: 0.40
messages:
# The low-level stream trains action flow on the generated or annotated subtask.
- {role: user, content: "${subtask}", stream: low_level, if_present: subtask}
memory_update:
# `active_at` densifies sparse boundaries while preserving the prior-memory/subtask mapping.
# Inference controls update timing through `subtask_change` events.
weight: 0.10
bindings:
prior_memory: "nth_prev(style=memory, offset=1)"
current_memory: "active_at(t, style=memory)"
completed_subtask: "nth_prev(style=subtask, offset=1)"
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: assistant, content: "Previous memory: ${prior_memory}", stream: high_level, if_present: prior_memory}
- {role: user, content: "Completed subtask: ${completed_subtask}", stream: high_level, if_present: completed_subtask}
- {role: assistant, content: "${current_memory}", stream: high_level, target: true, if_present: current_memory}
user_interjection_response:
weight: 0.10
bindings:
interjection: "emitted_at(t, style=interjection)"
speech: "emitted_at(t, role=assistant, tool_name=say)"
messages:
- {role: user, content: "${task}", stream: high_level}
- {role: user, content: "${interjection}", stream: high_level, if_present: interjection}
# The assistant target is a `say` tool call flattened to a `<say>...</say>` marker.
- {role: assistant, stream: high_level, target: true, if_present: speech, tool_calls_from: speech}
# Each camera uses a separate VQA sub-recipe for view-specific binding.
ask_vqa_top:
weight: 0.075
route: vqa
bindings:
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.front)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.front)"
messages:
- role: user
stream: high_level
if_present: vqa_query
content:
- {type: image, feature: observation.images.front}
- {type: text, text: "${vqa_query}"}
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
ask_vqa_wrist:
weight: 0.075
route: vqa
bindings:
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.wrist)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.wrist)"
messages:
- role: user
stream: high_level
if_present: vqa_query
content:
- {type: image, feature: observation.images.wrist}
- {type: text, text: "${vqa_query}"}
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
+95 -1
View File
@@ -18,6 +18,7 @@ import multiprocessing
import os
import tempfile
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
@@ -26,19 +27,49 @@ from huggingface_hub import hf_hub_download
from huggingface_hub.errors import HfHubHTTPError
from lerobot import envs
from lerobot.configs.accelerator import AcceleratorConfig, ActivationCheckpointingMode
from lerobot.configs.parallelism import ParallelismConfig
from lerobot.optim import LRSchedulerConfig, OptimizerConfig
from lerobot.utils.constants import PRETRAINED_MODEL_DIR
from lerobot.utils.hub import HubMixin, find_latest_hub_checkpoint
from lerobot.utils.sample_weighting import SampleWeightingConfig
from . import parser
from .default import DatasetConfig, EvalConfig, JobConfig, PeftConfig, WandBConfig
from .default import DatasetConfig, EMAConfig, EvalConfig, JobConfig, PeftConfig, WandBConfig
from .policies import PreTrainedConfig
from .rewards import RewardModelConfig
TRAIN_CONFIG_NAME = "train_config.json"
class CheckpointFormat(str, Enum):
"""Model-artifact format inside training checkpoints.
Selects only the *model* artifact; the training_state layout is format-independent (the
optimizer channel is always DCP under sharded runs, safetensors+json otherwise).
- SAFETENSORS (default): a full `model.safetensors` — maximum compatibility, one gather per
save under sharding.
- DCP: sharded `pytorch_model_fsdp_0/*.distcp` only — fastest save/resume; convert with
`lerobot-convert-dcp` before distributing.
- SAFETENSORS_AND_DCP: both artifacts, written independently.
"""
SAFETENSORS = "safetensors"
DCP = "dcp"
SAFETENSORS_AND_DCP = "safetensors_dcp"
@property
def wants_safetensors(self) -> bool:
"""True when a full `model.safetensors` artifact should be written."""
return self in (CheckpointFormat.SAFETENSORS, CheckpointFormat.SAFETENSORS_AND_DCP)
@property
def wants_dcp(self) -> bool:
"""True when sharded DCP model shards (`pytorch_model_fsdp_0/`) should be written."""
return self in (CheckpointFormat.DCP, CheckpointFormat.SAFETENSORS_AND_DCP)
def _migrate_legacy_rabc_fields(config: dict[str, Any]) -> dict[str, Any] | None:
"""Return migrated payload for legacy RA-BC fields, or None when no migration is needed."""
legacy_fields = (
@@ -121,10 +152,19 @@ class TrainPipelineConfig(HubMixin):
# Checkpoint is saved every `save_freq` training iterations and after the last training step.
# A non-positive value disables periodic saving, keeping only the final checkpoint.
save_freq: int = 20_000
# Model-artifact format inside checkpoints; non-default values require a sharded run.
checkpoint_format: CheckpointFormat = CheckpointFormat.SAFETENSORS
use_policy_training_preset: bool = True
optimizer: OptimizerConfig | None = None
scheduler: LRSchedulerConfig | None = None
# Process topology: dp_replicate / dp_shard (HSDP) and context-parallel degree placeholders.
parallelism: ParallelismConfig = field(default_factory=ParallelismConfig)
# Execution runtime handed to the Accelerator: mixed precision, gradient accumulation,
# FSDP/DDP tuning knobs, compile & activation-checkpointing placeholders.
accelerator: AcceleratorConfig = field(default_factory=AcceleratorConfig)
eval: EvalConfig = field(default_factory=EvalConfig)
# Maintain an EMA shadow of the policy weights during training (see EMAConfig).
ema: EMAConfig = field(default_factory=EMAConfig)
wandb: WandBConfig = field(default_factory=WandBConfig)
peft: PeftConfig | None = None
@@ -291,6 +331,60 @@ class TrainPipelineConfig(HubMixin):
if self.save_checkpoint_to_hub and not (self.policy is not None and self.policy.repo_id):
raise ValueError("save_checkpoint_to_hub requires --policy.repo_id.")
self._validate_distributed()
def _validate_distributed(self) -> None:
"""Fail-fasts for the distributed-training scope.
Raises:
ValueError: If the config requests anything outside the verified scope: context
parallelism or CFG parallelism (reserved placeholders), the compile or
activation-checkpointing placeholders, a DCP checkpoint format on a
non-sharded run, or — under sharded training — fp16 mixed precision, PEFT,
reward-model training, in-training environment evaluation, or multi-optimizer
configs.
"""
if self.parallelism.cp_size > 1:
raise ValueError(
"Context parallelism is not implemented yet: --parallelism.context_parallel.* "
"degrees must be 1 (reserved for the CP engine round)."
)
if self.parallelism.cfg_parallel != 1:
raise ValueError(
"CFG parallelism is inference-only and must be 1 for training "
"(cfg_parallel is reserved for the serving round)."
)
if self.accelerator.compile.enabled:
raise ValueError("--accelerator.compile is a placeholder and not wired yet.")
if self.accelerator.activation_checkpointing.mode is not ActivationCheckpointingMode.NONE:
raise ValueError("--accelerator.activation_checkpointing is a placeholder and not wired yet.")
if self.checkpoint_format is not CheckpointFormat.SAFETENSORS and not self.parallelism.is_sharded:
raise ValueError(
f"checkpoint_format={self.checkpoint_format.value} requires a sharded run "
"(--parallelism.dp_shard != 1); non-sharded checkpoints are always safetensors."
)
if self.parallelism.is_sharded:
if self.accelerator.mixed_precision == "fp16":
raise ValueError(
"fp16 is not supported under sharded training (GradScaler over DTensor "
"gradients is unverified); use bf16 or full precision."
)
if self.peft is not None:
raise ValueError("PEFT is not supported under sharded training yet.")
if self.is_reward_model_training:
raise ValueError(
"Reward-model training is not supported under sharded training yet "
"(reward models declare no FSDP wrap units and have no sharded save path)."
)
if self.env is not None and self.env_eval_freq > 0:
raise ValueError(
"In-training environment evaluation is not supported under sharded training "
"(a rank-0-only rollout of a sharded model deadlocks on collectives); set "
"--env_eval_freq=0 and evaluate with lerobot-eval on saved checkpoints."
)
if self.optimizer is not None and self.optimizer.builds_multiple_optimizers:
raise ValueError("Multi-optimizer configs are not supported under sharded training.")
@classmethod
def __get_path_fields__(cls) -> list[str]:
"""Keys for draccus pretrained-path loading."""
@@ -76,7 +76,7 @@ import torch
from pydantic import BaseModel, Field
from transformers import AutoProcessor, Qwen3VLMoeForConditionalGeneration
from lerobot.datasets import LeRobotDataset
from lerobot.datasets import LeRobotDataset, resolve_episode_indices
# Pydantic Models for SARM Subtask Annotation
@@ -1049,7 +1049,10 @@ def main():
torch_dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype]
# Determine episodes
episode_indices = args.episodes or list(range(dataset.meta.total_episodes))
resolved_episodes = resolve_episode_indices(args.episodes, dataset.meta.total_episodes)
episode_indices = (
resolved_episodes if resolved_episodes is not None else list(range(dataset.meta.total_episodes))
)
existing_annotations = load_annotations_from_dataset(dataset.root, prefix="sparse")
if args.skip_existing:
+2 -1
View File
@@ -52,7 +52,7 @@ from .pipeline_features import aggregate_pipeline_dataset_features, create_initi
from .pyav_utils import check_video_encoder_parameters_pyav, detect_available_encoders_pyav
from .sampler import EpisodeAwareSampler, compute_sampler_state
from .streaming_dataset import StreamingLeRobotDataset
from .utils import DEFAULT_EPISODES_PATH, create_lerobot_dataset_card
from .utils import DEFAULT_EPISODES_PATH, create_lerobot_dataset_card, resolve_episode_indices
from .video_utils import VideoEncodingManager
# NOTE: Low-level I/O functions (cast_stats_to_numpy, get_parquet_file_size_in_mb, etc.)
@@ -97,6 +97,7 @@ __all__ = [
"reencode_dataset",
"remove_feature",
"resolve_delta_timestamps",
"resolve_episode_indices",
"safe_stop_image_writer",
"split_dataset",
"write_stats",
+41
View File
@@ -22,6 +22,7 @@ from pathlib import Path
from typing import Any, NotRequired, TypedDict
import datasets
import numpy as np
import pandas as pd
import tqdm
@@ -303,6 +304,46 @@ def update_meta_data(
df["dataset_to_index"] = df["dataset_to_index"] + dst_meta.info.total_frames
df["episode_index"] = df["episode_index"] + dst_meta.info.total_episodes
# Per-episode stats still describe the pre-merge values of the bookkeeping columns
# reindexed above. index/episode_index shift by a constant; task_index is relabeled,
# so recompute it from the episode's (stable) task strings via the unified tasks table.
shift_stat_keys = ("min", "max", "mean", "q01", "q10", "q50", "q90", "q99")
for name, offset in (
("episode_index", dst_meta.info.total_episodes),
("index", dst_meta.info.total_frames),
):
for stat in shift_stat_keys:
col = f"stats/{name}/{stat}"
if col in df.columns:
df[col] = df[col] + offset
if any(c.startswith("stats/task_index/") for c in df.columns):
quantiles = {"q01": 0.01, "q10": 0.10, "q50": 0.50, "q90": 0.90, "q99": 0.99}
ids_per_row = [
np.array([dst_meta.tasks.loc[t, "task_index"] for t in tasks], dtype=np.float64)
for tasks in df["tasks"]
]
def _task_stat(ids, stat):
if stat == "min":
return ids.min()
if stat == "max":
return ids.max()
if stat == "std":
return ids.std()
if stat in quantiles:
return np.quantile(ids, quantiles[stat])
return ids.mean()
for stat in ("min", "max", "mean", "std", *quantiles):
col = f"stats/task_index/{stat}"
if col in df.columns:
# np.full_like preserves each cell container and dtype so the parquet schema is unchanged.
df[col] = [
np.full_like(orig, _task_stat(ids, stat))
for orig, ids in zip(df[col], ids_per_row, strict=True)
]
return df
+9 -2
View File
@@ -613,8 +613,15 @@ def aggregate_feature_stats(stats_ft_list: list[dict[str, dict]]) -> dict[str, d
for q_key in quantile_keys:
if all(q_key in s for s in stats_ft_list):
quantile_values = np.stack([s[q_key] for s in stats_ft_list])
weighted_quantiles = quantile_values * counts
aggregated[q_key] = weighted_quantiles.sum(axis=0) / total_count
# Exact global quantiles cannot be recovered from quantile summaries.
# Keep a conservative envelope of the available estimates: min
# for lower quantiles and max for upper quantiles. The resulting
# values are bounds across the inputs, not global quantile estimates.
q_percent = int(q_key[1:])
if q_percent <= 50:
aggregated[q_key] = np.min(quantile_values, axis=0)
else:
aggregated[q_key] = np.max(quantile_values, axis=0)
return aggregated
+26 -1
View File
@@ -39,6 +39,7 @@ from .io_utils import (
hf_transform_to_torch,
load_nested_dataset,
)
from .utils import resolve_episode_indices
from .video_utils import decode_video_frames
@@ -83,7 +84,7 @@ class DatasetReader:
"""
self._meta = meta
self.root = root
self.episodes = episodes
self.episodes = resolve_episode_indices(episodes, meta.total_episodes)
self._tolerance_s = tolerance_s
self._video_backend = video_backend
if image_transforms is not None and not callable(image_transforms):
@@ -163,10 +164,34 @@ class DatasetReader:
def _load_hf_dataset(self) -> datasets.Dataset:
"""hf_dataset contains all the observations, states, actions, rewards, etc."""
features = get_hf_features_from_features(self._meta.features)
self._validate_language_columns_declared(features)
hf_dataset = load_nested_dataset(self.root / "data", features=features, episodes=self.episodes)
hf_dataset.set_transform(hf_transform_to_torch)
return hf_dataset
def _validate_language_columns_declared(self, features: datasets.Features) -> None:
"""Require language columns stored in Parquet to be declared in metadata."""
# Leave empty datasets to fail through the normal loading path.
try:
sample = next((self.root / "data").glob("*/*.parquet"))
except StopIteration:
return
from pyarrow import parquet as _pq # noqa: PLC0415
# LeRobot shards are schema-uniform, so one schema represents the dataset.
schema_names = set(_pq.read_schema(sample).names)
from .language import LANGUAGE_COLUMNS # noqa: PLC0415
missing = sorted(set(LANGUAGE_COLUMNS) & schema_names - set(features))
if missing:
raise ValueError(
f"Dataset Parquet files contain language feature(s) missing from metadata: {missing}. "
"Metadata must describe the stored data; add the entries returned by "
"lerobot.datasets.language.language_feature_info() to meta/info.json['features'] "
"or rerun the annotation pipeline's metadata synchronization."
)
def _check_cached_episodes_sufficient(self) -> bool:
"""Check if the cached dataset contains all requested episodes and their video files."""
if self.hf_dataset is None or len(self.hf_dataset) == 0:
+15 -3
View File
@@ -29,6 +29,7 @@ from .dataset_metadata import LeRobotDatasetMetadata
from .lerobot_dataset import LeRobotDataset
from .multi_dataset import MultiLeRobotDataset
from .streaming_dataset import StreamingLeRobotDataset
from .utils import resolve_episode_indices
def resolve_delta_timestamps(
@@ -84,14 +85,24 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
if isinstance(cfg.dataset.repo_id, str):
ds_meta = LeRobotDatasetMetadata(
cfg.dataset.repo_id, root=cfg.dataset.root, revision=cfg.dataset.revision
cfg.dataset.repo_id,
root=cfg.dataset.root,
revision=cfg.dataset.revision,
repo_type=cfg.dataset.repo_type,
)
delta_timestamps = resolve_delta_timestamps(cfg.trainable_config, ds_meta)
episodes = resolve_episode_indices(
cfg.dataset.episodes, ds_meta.total_episodes, cfg.dataset.exclude_episodes
)
if not cfg.dataset.streaming:
if cfg.dataset.repo_type == "bucket":
raise ValueError(
"repo_type='bucket' is streaming-only: set dataset.streaming=true to train from an HF Storage Bucket."
)
dataset = LeRobotDataset(
cfg.dataset.repo_id,
root=cfg.dataset.root,
episodes=cfg.dataset.episodes,
episodes=episodes,
delta_timestamps=delta_timestamps,
image_transforms=image_transforms,
revision=cfg.dataset.revision,
@@ -104,13 +115,14 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas
dataset = StreamingLeRobotDataset(
cfg.dataset.repo_id,
root=cfg.dataset.root,
episodes=cfg.dataset.episodes,
episodes=episodes,
delta_timestamps=delta_timestamps,
image_transforms=image_transforms,
revision=cfg.dataset.revision,
max_num_shards=cfg.num_workers,
tolerance_s=cfg.tolerance_s,
return_uint8=True,
repo_type=cfg.dataset.repo_type,
)
else:
raise NotImplementedError("The MultiLeRobotDataset isn't supported for now.")
+84 -12
View File
@@ -162,14 +162,32 @@ def render_sample(
task: str | None = None,
dataset_ctx: Any | None = None,
) -> RenderedMessages | None:
"""Render the chat-style messages for a single dataset sample.
"""Render recipe-defined messages and supervision for one dataset sample.
Resolves the recipe's bindings against ``persistent`` and ``events`` rows
at frame timestamp ``t``, then expands the recipe's message templates.
Returns ``None`` if the resolved sample contains no target message.
Resolves bindings against ``persistent`` and ``events`` at frame timestamp
``t``. Blend recipes first route matching sparse VQA annotations, then use
deterministic weighted selection for the remaining samples. Returns
``None`` when the selected recipe provides no text or low-level action
supervision for this sample.
"""
persistent_rows = _normalize_rows(persistent or [])
event_rows = _normalize_rows(events or [])
# Route sparse VQA frames to a matching view-specific component before weighted selection.
# This avoids dropping annotated frames or selecting VQA without annotations.
if recipe.blend is not None:
vqa_rendered = _render_vqa_if_present(
recipe,
persistent=persistent_rows,
events=event_rows,
t=t,
sample_idx=sample_idx,
task=task,
dataset_ctx=dataset_ctx,
)
if vqa_rendered is not None:
return vqa_rendered
selected_recipe = _select_recipe(recipe, sample_idx)
bindings = _resolve_bindings(
selected_recipe,
@@ -183,6 +201,58 @@ def render_sample(
return _render_message_recipe(selected_recipe, bindings)
def _render_vqa_if_present(
recipe: TrainingRecipe,
*,
persistent: Sequence[LanguageRow],
events: Sequence[LanguageRow],
t: float,
sample_idx: int,
task: str | None,
dataset_ctx: Any | None,
) -> RenderedMessages | None:
"""Render a matching VQA component, or return ``None`` for normal selection.
Multiple matching views are selected deterministically by relative weight.
"""
if recipe.blend is None:
return None
renderable: list[tuple[float, RenderedMessages]] = []
for component in recipe.blend.values():
if component.route != "vqa":
continue
bindings = _resolve_bindings(
component,
persistent=persistent,
events=events,
t=t,
sample_idx=sample_idx,
task=task,
dataset_ctx=dataset_ctx,
)
rendered = _render_message_recipe(component, bindings)
if rendered is not None:
if component.weight is None:
raise ValueError("Routed VQA blend components must define a weight.")
renderable.append((component.weight, rendered))
if not renderable:
return None
if len(renderable) == 1:
return renderable[0][1]
# Choose among matching cameras by their validated positive relative weights.
total = sum(weight for weight, _ in renderable)
digest = hashlib.blake2b(f"vqa:{sample_idx}".encode(), digest_size=8).digest()
draw = int.from_bytes(digest, "big") / 2**64 * total
cumulative = 0.0
for weight, rendered in renderable:
cumulative += weight
if draw < cumulative:
return rendered
return renderable[-1][1]
def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe:
"""Pick a deterministic blend component for ``sample_idx`` (or return ``recipe``)."""
if recipe.blend is None:
@@ -201,7 +271,8 @@ def _select_recipe(recipe: TrainingRecipe, sample_idx: int) -> TrainingRecipe:
cumulative += component.weight or 0.0
if draw < cumulative:
return component
assert last_component is not None
if last_component is None:
raise ValueError("Blend recipes must contain at least one component.")
return last_component
@@ -321,7 +392,8 @@ def _render_message_recipe(
bindings: dict[str, LanguageRow | str | None],
) -> RenderedMessages | None:
"""Expand ``recipe.messages`` into rendered chat messages using ``bindings``."""
assert recipe.messages is not None
if recipe.messages is None:
raise ValueError("Cannot render a blend recipe as a message recipe.")
messages: list[dict[str, Any]] = []
streams: list[str | None] = []
target_indices: list[int] = []
@@ -346,7 +418,9 @@ def _render_message_recipe(
if turn.target:
target_indices.append(message_idx)
if not target_indices:
# Keep samples with either text targets or low-level action supervision.
has_low_level = any(stream == "low_level" for stream in streams)
if not target_indices and not has_low_level:
return None
rendered = {
@@ -403,14 +477,12 @@ def _validate_rendered(rendered: RenderedMessages) -> None:
if len(streams) != len(messages):
raise ValueError("message_streams must be aligned with messages.")
if not target_indices:
raise ValueError("Rendered samples must contain at least one target message.")
# Require text or low-level action supervision.
if not target_indices and not any(s == "low_level" for s in streams):
raise ValueError("Rendered samples must contain a target message or a low_level-stream message.")
for idx in target_indices:
if idx < 0 or idx >= len(messages):
raise ValueError(f"Target message index {idx} is out of bounds.")
# ``stream`` is enforced non-None at MessageTurn construction time
# (see ``MessageTurn.__post_init__``), so a missing stream here would
# mean the dataclass invariant was bypassed; no need to re-check.
def _nth_relative(
+42
View File
@@ -18,6 +18,7 @@ import dataclasses
import importlib.resources
import json
import logging
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
@@ -98,6 +99,47 @@ VIDEO_DIR = "videos"
CHUNK_FILE_PATTERN = "chunk-{chunk_index:03d}/file-{file_index:03d}"
IMAGE_FILE_PATTERN = "frame-{frame_index:06d}.png"
def resolve_episode_indices(
episodes: Sequence[int] | None,
total_episodes: int,
exclude_episodes: Sequence[int] | None = None,
) -> list[int] | None:
"""Resolve an optional episode allowlist and exclusion list against dataset bounds.
``None`` is preserved when no filtering is requested so callers can retain
their native "all episodes" fast path. Invalid indices are ignored with a
warning, and the input order is preserved.
"""
if total_episodes < 0:
raise ValueError(f"total_episodes must be non-negative, got {total_episodes}")
if episodes is None and not exclude_episodes:
return None
candidates = list(range(total_episodes)) if episodes is None else list(episodes)
invalid = [episode for episode in candidates if not 0 <= episode < total_episodes]
if invalid:
logger.warning(
"Ignoring episode indices outside the dataset range [0, %d): %s",
total_episodes,
invalid,
)
candidates = [episode for episode in candidates if 0 <= episode < total_episodes]
excluded = set(exclude_episodes or [])
invalid_excluded = sorted(episode for episode in excluded if not 0 <= episode < total_episodes)
if invalid_excluded:
logger.warning(
"Ignoring excluded episode indices outside the dataset range [0, %d): %s",
total_episodes,
invalid_excluded,
)
excluded = {episode for episode in excluded if 0 <= episode < total_episodes}
return [episode for episode in candidates if episode not in excluded]
DEPTH_FILE_PATTERN = "frame-{frame_index:06d}.tiff"
DEFAULT_TASKS_PATH = "meta/tasks.parquet"
DEFAULT_EPISODES_PATH = EPISODES_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet"
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Distributed-training runtime for LeRobot.
This package owns everything that turns the declarative topology in
:class:`lerobot.configs.parallelism.ParallelismConfig` into a running engine:
mesh math (:class:`~lerobot.distributed.parallel_dims.ParallelDims`), the
`Accelerator` factory (:func:`~lerobot.distributed.factory.make_accelerator`),
sharding-aware checkpoint helpers, and small rank utilities.
Setup-order contract (normative):
CP dispatch install -> activation checkpointing -> torch.compile ->
``fully_shard``/DDP (via ``accelerator.prepare``) -> optimizer rebind.
Only the last two steps are active today; CP/AC/compile are configured
placeholders wired in later rounds.
"""
from .factory import guard_against_env_interference, make_accelerator, set_fsdp_wrap_modules
from .parallel_dims import ParallelDims
from .utils import finalize_sharded_policy, is_main_process, strip_accelerate_cp_hooks
__all__ = [
"ParallelDims",
"finalize_sharded_policy",
"guard_against_env_interference",
"is_main_process",
"make_accelerator",
"set_fsdp_wrap_modules",
"strip_accelerate_cp_hooks",
]
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Sharding-aware checkpoint primitives.
Two artifact channels with distinct owners:
- the **distributable** ``model.safetensors``: produced by ``PreTrainedPolicy.save_pretrained``
through :func:`full_model_state_dict` — a collective full gather when the model is sharded;
- the **resume** channel (sharded runs): torch DCP directories written/read through accelerate's
``save/load_fsdp_model`` and ``save/load_fsdp_optimizer`` (``pytorch_model_fsdp_0/`` and
``optimizer_0/``, names imported from accelerate constants), which reshard on load across
topology changes.
Every function that touches sharded state is a collective and must run on ALL ranks.
"""
from pathlib import Path
from typing import TYPE_CHECKING
import torch
from torch import nn
if TYPE_CHECKING:
from accelerate import Accelerator
def is_sharded_module(module: nn.Module) -> bool:
"""True when `fully_shard` owns this module's parameters (FSDP2's in-place class swap).
Args:
module (nn.Module): The module to inspect (a torch.compile wrapper is looked through
via `_orig_mod`).
Returns:
bool: True when the module (or its compiled `_orig_mod`) is an `FSDPModule`.
"""
from torch.distributed.fsdp import FSDPModule
if isinstance(module, FSDPModule):
return True
# torch.compile wraps the sharded module; mirror accelerate's `_orig_mod` check.
orig_mod = getattr(module, "_orig_mod", None)
return orig_mod is not None and isinstance(orig_mod, FSDPModule)
def full_model_state_dict(module: nn.Module) -> dict[str, torch.Tensor]:
"""The module's full (unsharded) state dict, however its parameters are laid out.
Sharded modules gather through torch's DCP state-dict API: a COLLECTIVE that must run on
every rank; with ``cpu_offload=True`` the full dict materializes on the main rank only and
every other rank receives a literal ``{}`` (runtime-verified — a
rank-0-gated call deadlocks). Plain modules return ``module.state_dict()`` on every rank.
Args:
module (nn.Module): The (possibly sharded) module to read the state dict from.
Returns:
dict[str, torch.Tensor]: The full state dict — on the main rank only (``{}``
elsewhere) when the module is sharded, on every rank otherwise.
"""
if not is_sharded_module(module):
return module.state_dict()
from torch.distributed.checkpoint.state_dict import StateDictOptions, get_model_state_dict
return get_model_state_dict(module, options=StateDictOptions(full_state_dict=True, cpu_offload=True))
def _fsdp_plugin(accelerator: "Accelerator") -> object:
"""The accelerator's FSDP plugin, required by every DCP save/load helper below.
Args:
accelerator (Accelerator): The accelerator that prepared the sharded model.
Returns:
object: The FSDP plugin held by `accelerator.state`.
Raises:
RuntimeError: If the accelerator was not configured with an FSDP plugin.
"""
plugin = getattr(accelerator.state, "fsdp_plugin", None)
if plugin is None:
raise RuntimeError("Sharded checkpointing requires an FSDP-prepared Accelerator.")
return plugin
def save_sharded_model(accelerator: "Accelerator", model: nn.Module, output_dir: Path) -> None:
"""Write the DCP model shards (`pytorch_model_fsdp_0/`). Collective: call on all ranks.
Args:
accelerator (Accelerator): The accelerator that prepared the sharded model.
model (nn.Module): The prepared (sharded) model to save.
output_dir (Path): The directory the shard subdirectory is created in.
"""
from accelerate.utils import save_fsdp_model
# accelerate 1.14's DCP helpers do string containment checks on the path:
# always hand them str, never Path.
save_fsdp_model(_fsdp_plugin(accelerator), accelerator, model, str(output_dir))
def load_sharded_model(accelerator: "Accelerator", model: nn.Module, input_dir: Path) -> None:
"""Load DCP model shards into the prepared (sharded) model. Collective: call on all ranks.
Args:
accelerator (Accelerator): The accelerator that prepared the sharded model.
model (nn.Module): The prepared (sharded) model to load into.
input_dir (Path): The directory containing the `pytorch_model_fsdp_0/` shard
subdirectory.
"""
from accelerate.utils import load_fsdp_model
from accelerate.utils.constants import FSDP_MODEL_NAME
# Pass the exact shard directory: accelerate's load resolves it with a substring check
# ("pytorch_model_fsdp" in the path -> use as-is), which misfires on run paths that happen
# to contain the marker; the exact dir makes the check deterministic.
load_fsdp_model(_fsdp_plugin(accelerator), accelerator, model, str(input_dir / f"{FSDP_MODEL_NAME}_0"))
def save_sharded_optimizer(
accelerator: "Accelerator", optimizer: torch.optim.Optimizer, model: nn.Module, output_dir: Path
) -> None:
"""Write the DCP optimizer shards (`optimizer_0/`). Collective: call on all ranks.
Args:
accelerator (Accelerator): The accelerator that prepared the model and optimizer.
optimizer (torch.optim.Optimizer): The prepared optimizer to save the state from.
model (nn.Module): The prepared (sharded) model the optimizer state is keyed by.
output_dir (Path): The directory the shard subdirectory is created in.
"""
from accelerate.utils import save_fsdp_optimizer
save_fsdp_optimizer(_fsdp_plugin(accelerator), accelerator, optimizer, model, str(output_dir))
def load_sharded_optimizer(
accelerator: "Accelerator", optimizer: torch.optim.Optimizer, model: nn.Module, input_dir: Path
) -> None:
"""Load DCP optimizer shards into the prepared optimizer. Collective: call on all ranks.
Must run AFTER ``accelerator.prepare()``: FSDP2's prepare rebinds the optimizer's param
groups to sharded DTensors but never migrates ``optimizer.state`` — the resharding load is
the only correct way to restore it.
Args:
accelerator (Accelerator): The accelerator that prepared the model and optimizer.
optimizer (torch.optim.Optimizer): The prepared optimizer to restore the state into.
model (nn.Module): The prepared (sharded) model the optimizer state is keyed by.
input_dir (Path): The directory containing the `optimizer_0/` shard subdirectory.
"""
from accelerate.utils import load_fsdp_optimizer
from accelerate.utils.constants import OPTIMIZER_NAME
# Exact shard directory for the same reason as load_sharded_model: accelerate's substring
# check ("optimizer" in the path) would misread e.g. --job_name=optimizer_sweep run paths.
load_fsdp_optimizer(
_fsdp_plugin(accelerator), accelerator, optimizer, model, str(input_dir / f"{OPTIMIZER_NAME}_0")
)
def dcp_to_safetensors(dcp_dir: Path, output_dir: Path, *, delete_dcp: bool = False) -> Path:
"""Merge a DCP shard directory into a single `model.safetensors` (offline, single process).
Thin wrapper over `accelerate.utils.merge_fsdp_weights`, which loads the shards without a
process group, writes safetensors directly, and — when asked — removes the merged shard
directory itself, only on the main process and only once the merge has succeeded.
Args:
dcp_dir (Path): The DCP shard directory to merge (e.g. `.../pytorch_model_fsdp_0`).
output_dir (Path): The directory the merged `model.safetensors` is written into.
delete_dcp (bool): Whether to remove the shard directory once it has been merged.
Defaults to False.
Returns:
Path: The written `model.safetensors` file's path.
"""
from accelerate.utils import merge_fsdp_weights
merge_fsdp_weights(
str(dcp_dir), str(output_dir), safe_serialization=True, remove_checkpoint_dir=delete_dcp
)
return output_dir / "model.safetensors"
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""The `Accelerator` factory — the only place accelerate gets configured.
`torchrun` is the launcher; every accelerate parameter comes from `TrainPipelineConfig`
(`cfg.parallelism` + `cfg.accelerator`) so a run is reproducible from its `train_config.json`
alone. `accelerate launch` without a `--config_file` remains equivalent (it only sets rendezvous
env vars in that mode); the yaml flow is superseded.
"""
import os
from typing import TYPE_CHECKING
from lerobot.configs.parallelism import world_size_from_env
from lerobot.configs.train import TrainPipelineConfig
if TYPE_CHECKING:
from accelerate import Accelerator
from lerobot.policies.pretrained import PreTrainedPolicy
# Env vars through which `accelerate launch --config_file` (or a stray shell) would configure
# accelerate behind the config system's back, making train_config.json lie about what ran.
_ACCELERATE_ENV_VARS = (
"ACCELERATE_USE_FSDP",
"ACCELERATE_USE_PARALLELISM_CONFIG",
"ACCELERATE_GRADIENT_ACCUMULATION_STEPS",
)
_ENV_OVERRIDE = "LEROBOT_ALLOW_ACCELERATE_ENV"
def guard_against_env_interference() -> None:
"""Hard-error when accelerate-configuring env vars are set.
A silently env-overridden "reproducible" config is worse than a stop: users migrating from
the old `accelerate launch --config_file fsdp.yaml` flow get a precise error instead of a
config that lies. Set LEROBOT_ALLOW_ACCELERATE_ENV=1 to acknowledge and proceed.
Raises:
RuntimeError: If any accelerate-configuring environment variable is set and the
LEROBOT_ALLOW_ACCELERATE_ENV override is not.
"""
if os.environ.get(_ENV_OVERRIDE):
return
offending = sorted(name for name in _ACCELERATE_ENV_VARS if name in os.environ)
if offending:
raise RuntimeError(
f"Accelerate-configuring environment variables are set: {', '.join(offending)}. "
"LeRobot manages accelerate exclusively through TrainPipelineConfig "
"(--parallelism.* / --accelerator.*); launch with plain torchrun and remove these "
"variables (the `accelerate launch --config_file` flow is superseded), or set "
f"{_ENV_OVERRIDE}=1 to acknowledge that they may override your config."
)
def make_accelerator(cfg: TrainPipelineConfig) -> "Accelerator":
"""Resolve the topology against the launched world and build the `Accelerator`.
Must run once per process, before any other component needs the device or the process
group (`Accelerator.__init__` initializes both and builds the device mesh).
Args:
cfg (TrainPipelineConfig): The full training config; `cfg.parallelism` is resolved in
place against the launched world size and `cfg.accelerator` builds the result.
Returns:
Accelerator: The configured accelerator, with device and process group initialized.
Raises:
ValueError: If `cfg.checkpoint_format` requires DCP but the topology resolved to a
non-sharded run.
"""
guard_against_env_interference()
cfg.parallelism.resolve(world_size_from_env())
# The parse-time format check ran against the declared degrees, where the dp_shard=-1
# sentinel counts as sharded; it may resolve to an unsharded run (e.g. -1 at world size 1).
# Re-check against the concrete degrees so the recorded format never lies about the
# artifacts a checkpoint will actually contain.
if cfg.checkpoint_format.wants_dcp and not cfg.parallelism.is_sharded:
raise ValueError(
f"checkpoint_format={cfg.checkpoint_format.value} requires a sharded run, but the "
f"topology resolved to a non-sharded one (dp_replicate={cfg.parallelism.dp_replicate}, "
f"dp_shard={cfg.parallelism.dp_shard}); non-sharded checkpoints are always safetensors."
)
return cfg.accelerator.build(
cfg.parallelism,
cpu=cfg.trainable_config.device == "cpu",
)
def set_fsdp_wrap_modules(accelerator: "Accelerator", policy: "PreTrainedPolicy") -> None:
"""Resolve the FSDP wrap-unit class names onto the plugin before `accelerator.prepare()`.
Resolution order: user override (`--accelerator.fsdp.wrap_modules`, already on the plugin)
-> the policy's `_fsdp_wrap_modules` declaration -> hard error. Root-only wrapping — the
silent default when no wrap source exists — is never accepted: it quietly forfeits all
sharding memory savings.
No-op for the size-based policy (`--accelerator.fsdp.min_num_params`), which needs no class
names, and for non-sharded runs (no fsdp plugin).
Args:
accelerator (Accelerator): The accelerator whose FSDP plugin receives the wrap-unit
class names.
policy (PreTrainedPolicy): The trainable whose class may declare `_fsdp_wrap_modules`.
Raises:
ValueError: If sharded class-based wrapping is configured but neither a user override
nor a policy declaration supplies wrap-unit class names.
"""
plugin = getattr(accelerator.state, "fsdp_plugin", None)
if plugin is None or plugin.min_num_params:
return
if plugin.transformer_cls_names_to_wrap: # user override, set at build time
return
# getattr, not attribute access: non-policy trainables (no `_fsdp_wrap_modules` attribute)
# must reach the actionable error below, not an AttributeError.
declared = getattr(type(policy), "_fsdp_wrap_modules", None)
if not declared:
raise ValueError(
f"Policy '{type(policy).__name__}' declares no FSDP wrap units. Sharded training "
"requires wrap-unit class names: set --accelerator.fsdp.wrap_modules='[\"MyBlock\"]' "
"(or --accelerator.fsdp.min_num_params for a size-based policy), or declare "
"`_fsdp_wrap_modules` on the policy class."
)
plugin.transformer_cls_names_to_wrap = list(declared)
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Runtime mesh math derived from the declarative :class:`ParallelismConfig`.
`ParallelDims` is the training script's single source of truth for topology-derived numbers
(data-parallel world size and rank, sample accounting inputs) and — once the CP engine lands —
the owner of LeRobot's private ``(dp_replicate, dp_shard, ring, ulysses)`` mesh. It is a runtime
object and is never serialized (the config it derives from is what lands in
``train_config.json``).
"""
from dataclasses import dataclass
import torch.distributed as dist
from lerobot.configs.parallelism import ParallelismConfig
@dataclass(frozen=True)
class ParallelDims:
"""Concrete parallelism degrees bound to a world size (canonical row-major rank layout)."""
dp_replicate: int
dp_shard: int
ring: int
ulysses: int
world_size: int
device_type: str
@classmethod
def from_config(cls, cfg: ParallelismConfig, world_size: int, device_type: str) -> "ParallelDims":
"""Bind a *resolved* config to the actual runtime world size (cross-checked here).
Args:
cfg (ParallelismConfig): The declarative topology, already resolved via
`ParallelismConfig.resolve(world_size)`.
world_size (int): The launched world size the declared degrees must multiply to.
device_type (str): The accelerator device type backing the mesh (e.g. "cuda").
Returns:
ParallelDims: The concrete parallelism degrees bound to this world.
Raises:
ValueError: If the config is unresolved (`dp_shard == -1`) or its degrees do not
multiply to `world_size`.
"""
total = cfg.dp_replicate * cfg.dp_shard * cfg.cp_size
if cfg.dp_shard == -1 or total != world_size:
raise ValueError(
f"ParallelismConfig is not resolved against this world: dp_replicate="
f"{cfg.dp_replicate} * dp_shard={cfg.dp_shard} * cp={cfg.cp_size} != "
f"world_size={world_size}. Call ParallelismConfig.resolve(world_size) first "
"(make_accelerator does this)."
)
return cls(
dp_replicate=cfg.dp_replicate,
dp_shard=cfg.dp_shard,
ring=cfg.context_parallel.ring_degree,
ulysses=cfg.context_parallel.ulysses_degree,
world_size=world_size,
device_type=device_type,
)
@property
def cp_size(self) -> int:
"""Total context-parallel degree (`ring * ulysses`)."""
return self.ring * self.ulysses
@property
def is_sharded(self) -> bool:
"""Whether parameters are sharded (`dp_shard > 1` or any context parallelism)."""
return self.dp_shard > 1 or self.cp_size > 1
@property
def dp_world_size(self) -> int:
"""Number of distinct data-parallel workers — the divisor for all sample accounting."""
return self.dp_replicate * self.dp_shard
@property
def dp_rank(self) -> int:
"""This process's data-parallel coordinate (CP peers share one dp_rank).
With the canonical row-major layout and (ring, ulysses) innermost, CP peers are
contiguous global ranks, so the dp coordinate is the integer quotient by cp_size —
the same arithmetic accelerate's mesh-aware dataloader applies.
"""
global_rank = dist.get_rank() if dist.is_initialized() else 0
return global_rank // self.cp_size
def cp_mesh(self) -> None:
"""Private (ring, ulysses) mesh for the CP engine — reserved for the CP round.
Raises:
NotImplementedError: Always — context parallelism is not implemented yet.
"""
raise NotImplementedError(
"Context parallelism is not implemented yet; ParallelDims.cp_mesh is reserved for "
"the CP engine round (a private mesh aligned with accelerate's cp block)."
)
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Rank utilities and post-`prepare()` sharding finalization."""
import logging
from typing import TYPE_CHECKING
import torch.distributed as dist
from torch import nn
if TYPE_CHECKING:
from lerobot.distributed.parallel_dims import ParallelDims
def is_main_process() -> bool:
"""True on the process that owns rank-0-only side effects (file writes, uploads, logging).
Torch-native on purpose: persistence code must not depend on an `Accelerator` handle —
`_save_pretrained` and the hub publishers run in contexts that have none. Outside
distributed runs every process is the main process.
Returns:
bool: True when this process is rank 0 or no process group is initialized.
"""
return not dist.is_initialized() or dist.get_rank() == 0
def strip_accelerate_cp_hooks(model: nn.Module) -> int:
"""Remove accelerate's context-parallel forward-pre-hooks from every module.
When `cp_size > 1` is declared, `accelerator.prepare()` unconditionally attaches hooks that
silently replace any `attention_mask` kwarg of `*self_attn` modules with `is_causal=True`
(`accelerate.big_modeling._attach_context_parallel_hooks`) — mask corruption for policies
with non-causal attention. LeRobot implements CP itself and never enters accelerate's CP
context, so these hooks are pure hazard. Deterministically identified by their defining
module; a version canary pins that identity.
Args:
model (nn.Module): The prepared model to strip the hooks from (all submodules are
visited).
Returns:
int: The number of hooks removed.
"""
removed = 0
for module in model.modules():
for hook_id, hook in list(module._forward_pre_hooks.items()):
if getattr(hook, "__module__", None) == "accelerate.big_modeling":
del module._forward_pre_hooks[hook_id]
module._forward_pre_hooks_with_kwargs.pop(hook_id, None)
removed += 1
return removed
def finalize_sharded_policy(policy: nn.Module, parallel_dims: "ParallelDims") -> None:
"""Sharding correctness protocol, applied once, immediately after `accelerator.prepare()`.
1. Strip accelerate's CP mask hooks (only attached when cp > 1 was declared).
2. Register the policy's non-`forward` entry points (`_fsdp_forward_methods`) so FSDP2
unshards parameters around `select_action` & co. — without this, any inference-style
call on a sharded policy crashes on mixed Tensor/DTensor.
No-op for DDP/single-process runs.
Args:
policy (nn.Module): The policy as returned by `accelerator.prepare()`.
parallel_dims (ParallelDims): The run's resolved topology; decides whether the protocol
applies.
"""
if not parallel_dims.is_sharded:
return
if parallel_dims.cp_size > 1:
removed = strip_accelerate_cp_hooks(policy)
logging.info("Stripped %d accelerate context-parallel attention-mask hooks.", removed)
from torch.distributed.fsdp import FSDPModule, register_fsdp_forward_method
if isinstance(policy, FSDPModule):
for method_name in getattr(type(policy), "_fsdp_forward_methods", ()):
if callable(getattr(policy, method_name, None)):
register_fsdp_forward_method(policy, method_name)
+4
View File
@@ -328,6 +328,7 @@ class LiberoEnv(EnvConfig):
render_mode: str = "rgb_array"
camera_name: str = "agentview_image,robot0_eye_in_hand_image"
init_states: bool = True
hard_reset: bool = True
camera_name_mapping: dict[str, str] | None = None
observation_height: int = 360
observation_width: int = 360
@@ -356,6 +357,8 @@ class LiberoEnv(EnvConfig):
def __post_init__(self):
if self.fps <= 0:
raise ValueError(f"fps must be positive, got {self.fps}")
if not self.hard_reset and not self.init_states:
raise ValueError("hard_reset=False requires init_states=True")
if self.obs_type == "pixels":
self.features[LIBERO_KEY_PIXELS_AGENTVIEW] = PolicyFeature(
@@ -416,6 +419,7 @@ class LiberoEnv(EnvConfig):
"observation_height": self.observation_height,
"observation_width": self.observation_width,
"control_freq": self.fps,
"hard_reset": self.hard_reset,
}
if self.task_ids is not None:
kwargs["task_ids"] = self.task_ids
+15 -2
View File
@@ -128,10 +128,13 @@ class LiberoEnv(gym.Env):
control_freq: int = 20,
control_mode: str = "relative",
is_libero_plus: bool = False,
hard_reset: bool = True,
):
super().__init__()
if control_freq <= 0:
raise ValueError(f"control_freq must be positive, got {control_freq}")
if not hard_reset and not init_states:
raise ValueError("hard_reset=False requires init_states=True")
self.task_id = task_id
self.is_libero_plus = is_libero_plus
self.obs_type = obs_type
@@ -158,6 +161,7 @@ class LiberoEnv(gym.Env):
self.camera_name_mapping = camera_name_mapping
self.num_steps_wait = num_steps_wait
self.control_freq = control_freq
self.hard_reset = hard_reset
self.episode_index = episode_index
self.episode_length = episode_length
# Load once and keep
@@ -265,6 +269,9 @@ class LiberoEnv(gym.Env):
camera_heights=self.observation_height,
camera_widths=self.observation_width,
control_freq=self.control_freq,
# Soft resets skip LIBERO's model and renderer rebuild. They are opt-in
# because settle steps can make their observations differ from hard resets.
hard_reset=self.hard_reset,
)
env.reset()
self._env = env
@@ -377,8 +384,9 @@ class LiberoEnv(gym.Env):
}
)
observation = self._format_raw_obs(raw_obs)
if terminated:
self.reset()
# Return the terminal observation unchanged. The caller owns resetting after
# termination; vector envs created below use NEXT_STEP autoreset. Resetting here
# would therefore reset twice and skip an initial state.
truncated = False
return observation, reward, terminated, truncated, info
@@ -476,6 +484,7 @@ def create_libero_envs(
print(f"Restricting to task_ids={task_ids_filter}")
is_async = env_cls is gym.vector.AsyncVectorEnv
is_sync = env_cls is gym.vector.SyncVectorEnv
out: dict[str, dict[int, Any]] = defaultdict(dict)
for suite_name in suite_names:
@@ -512,6 +521,10 @@ def create_libero_envs(
cached_act_space = lazy.action_space
cached_metadata = lazy.metadata
out[suite_name][tid] = lazy
elif is_sync:
out[suite_name][tid] = gym.vector.SyncVectorEnv(
fns, autoreset_mode=gym.vector.AutoresetMode.NEXT_STEP
)
else:
out[suite_name][tid] = env_cls(fns)
print(f"Built vec env | suite={suite_name} | task_id={tid} | n_envs={n_envs}")
+76 -1
View File
@@ -177,6 +177,76 @@ def _sub_env_has_attr(env: gym.vector.VectorEnv, attr: str) -> bool:
return False
# Passed in `reset(options=...)` by `rollout()` to mark the start of a new rollout.
# FreezeAfterEpisodeEnd thaws only on this, so Gymnasium's argument-less autoreset
# cannot be mistaken for a genuine new episode.
NEW_ROLLOUT_OPTION = "lerobot_new_rollout"
class FreezeAfterEpisodeEnd(gym.Wrapper):
"""Stop doing simulator work once a sub-env's episode has ended.
`rollout()` runs `while not np.all(done)` with `done` latched, so a sub-env that
terminates early keeps being stepped -- physics and offscreen rendering included --
until the slowest sub-env in the batch finishes. The batch runs for
`max(episode_lengths)` iterations to complete work that only needs
`mean(episode_lengths)`.
This caches the terminal transition and replays it for any further `step()` or
autoreset, so a finished sub-env costs nothing. The rollout already ignores those
transitions.
The freeze survives Gymnasium's autoreset deliberately. Under
`AutoresetMode.NEXT_STEP` the vector env resets a terminated sub-env on the
following step and runs it through an entire extra episode that the rollout
discards, because `done` stays latched. Absorbing that reset is most of the saving.
Only an explicit reset carrying `NEW_ROLLOUT_OPTION` thaws it, so the signal is
explicit rather than inferred: Gymnasium's autoreset calls `reset()` with no
arguments, but so would a caller passing `seeds=None`, and confusing the two would
strand an env frozen for a whole rollout.
`AutoresetMode.DISABLED` is not an alternative here — Gymnasium asserts that no
terminated env is ever stepped in that mode, so the wrapper is never reached.
"""
def __init__(self, env: gym.Env):
super().__init__(env)
self._frozen: tuple | None = None
def reset(self, *, seed=None, options=None):
if self._frozen is not None and not (options or {}).get(NEW_ROLLOUT_OPTION):
# Gymnasium's autoreset for a sub-env the rollout has already finished with.
# Replay the terminal observation instead of rebuilding the simulation.
obs, _, _, _, info = self._frozen
return obs, info
self._frozen = None
return self.env.reset(seed=seed, options=options)
def step(self, action):
if self._frozen is not None:
return self._frozen
obs, reward, terminated, truncated, info = self.env.step(action)
if terminated or truncated:
# Zero the reward on replay so a frozen sub-env cannot inflate a return if a
# caller sums rewards over the padded tail.
self._frozen = (obs, 0.0, terminated, truncated, info)
return obs, reward, terminated, truncated, info
@property
def is_frozen(self) -> bool:
return self._frozen is not None
def freeze_after_episode_end(env_fn: Callable[[], gym.Env]) -> Callable[[], gym.Env]:
"""Wrap an env factory so the built env freezes once its episode ends."""
def _fn() -> gym.Env:
return FreezeAfterEpisodeEnd(env_fn())
return _fn
class _LazyAsyncVectorEnv:
"""Defers AsyncVectorEnv creation until first use.
@@ -212,7 +282,12 @@ class _LazyAsyncVectorEnv:
def _ensure(self) -> None:
if self._env is None:
self._env = gym.vector.AsyncVectorEnv(self._env_fns, context="forkserver", shared_memory=True)
self._env = gym.vector.AsyncVectorEnv(
[freeze_after_episode_end(fn) for fn in self._env_fns],
context="forkserver",
shared_memory=True,
autoreset_mode=gym.vector.AutoresetMode.NEXT_STEP,
)
@property
def unwrapped(self):
+1 -1
View File
@@ -432,7 +432,7 @@ def submit_to_hf(cfg: TrainPipelineConfig) -> None:
# Finish as soon as the model is pushed, rather than waiting out the platform's
# post-run finalization before the job stage flips to COMPLETED. This matches the
# exact log line emitted by PreTrainedPolicy.push_model_to_hub — the two must stay
# exact log line emitted by lerobot.common.train_utils.publish_trained_model — the two must stay
# in sync. If it ever stops matching we just fall back to stage-based completion
# (~30s slower), so the contract is an optimization, not a correctness requirement.
success_marker = f"Model pushed to https://huggingface.co/{repo_id}"
-2
View File
@@ -20,7 +20,6 @@ from .optimizers import (
SGDConfig as SGDConfig,
XVLAAdamWConfig as XVLAAdamWConfig,
load_optimizer_state,
load_optimizer_state_dict,
save_optimizer_state,
)
from .schedulers import (
@@ -51,7 +50,6 @@ __all__ = [
"VQBeTSchedulerConfig",
# State management
"load_optimizer_state",
"load_optimizer_state_dict",
"load_scheduler_state",
"save_optimizer_state",
"save_scheduler_state",
+14 -29
View File
@@ -27,7 +27,7 @@ from lerobot.utils.constants import (
OPTIMIZER_PARAM_GROUPS,
OPTIMIZER_STATE,
)
from lerobot.utils.io_utils import deserialize_json_into_object, load_json, write_json
from lerobot.utils.io_utils import deserialize_json_into_object, write_json
from lerobot.utils.utils import flatten_dict, unflatten_dict
# Type alias for parameters accepted by optimizer build() methods.
@@ -52,6 +52,11 @@ class OptimizerConfig(draccus.ChoiceRegistry, abc.ABC):
def type(self) -> str:
return self.get_choice_name(self.__class__)
@property
def builds_multiple_optimizers(self) -> bool:
"""True when build() returns a dict of optimizers (unsupported under sharded training)."""
return False
@classmethod
def default_choice_name(cls) -> str | None:
return "adam"
@@ -245,6 +250,10 @@ class MultiAdamConfig(OptimizerConfig):
grad_clip_norm: float = 10.0
optimizer_groups: dict[str, dict[str, Any]] = field(default_factory=dict)
@property
def builds_multiple_optimizers(self) -> bool:
return True
def build(self, params: OptimizerParams) -> dict[str, torch.optim.Optimizer]:
"""Build multiple Adam optimizers.
@@ -283,35 +292,27 @@ class MultiAdamConfig(OptimizerConfig):
def save_optimizer_state(
optimizer: torch.optim.Optimizer | dict[str, torch.optim.Optimizer],
save_dir: Path,
optim_state_dict: dict | None = None,
) -> None:
"""Save optimizer state to disk.
"""Save optimizer state to disk (non-sharded runs; sharded runs use the DCP channel).
Args:
optimizer: Either a single optimizer or a dictionary of optimizers.
save_dir: Directory to save the optimizer state.
optim_state_dict: Pre-gathered optimizer state dict (for FSDP, where the sharded state must
be gathered across ranks first). If provided, it is saved directly instead of calling
``optimizer.state_dict()``. Only supported for a single optimizer. Defaults to None.
"""
if isinstance(optimizer, dict):
# Handle dictionary of optimizers
if optim_state_dict is not None:
raise ValueError("optim_state_dict is not supported for a dict of optimizers")
for name, opt in optimizer.items():
optimizer_dir = save_dir / name
optimizer_dir.mkdir(exist_ok=True, parents=True)
_save_single_optimizer_state(opt, optimizer_dir)
else:
# Handle single optimizer
_save_single_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict)
_save_single_optimizer_state(optimizer, save_dir)
def _save_single_optimizer_state(
optimizer: torch.optim.Optimizer, save_dir: Path, optim_state_dict: dict | None = None
) -> None:
def _save_single_optimizer_state(optimizer: torch.optim.Optimizer, save_dir: Path) -> None:
"""Save a single optimizer's state to disk."""
state = dict(optim_state_dict) if optim_state_dict is not None else optimizer.state_dict()
state = optimizer.state_dict()
param_groups = state.pop("param_groups")
flat_state = flatten_dict(state)
save_file(flat_state, save_dir / OPTIMIZER_STATE)
@@ -365,19 +366,3 @@ def _load_single_optimizer_state(optimizer: torch.optim.Optimizer, save_dir: Pat
optimizer.load_state_dict(loaded_state_dict)
return optimizer
def load_optimizer_state_dict(save_dir: Path) -> dict:
"""Read a saved optimizer state dict (safetensors + json) back into a plain dict.
Unlike `load_optimizer_state`, this does not load into an optimizer and preserves the original
``state`` keys verbatim (e.g. FSDP parameter FQNs, which are not integer-castable). It is used by
the FSDP resume path, where the full state must be resharded via `FSDP.optim_state_dict_to_load`
before being loaded into the (sharded) optimizer.
"""
flat_state = load_file(save_dir / OPTIMIZER_STATE)
state = unflatten_dict(flat_state)
return {
"state": state.get("state", {}),
"param_groups": load_json(save_dir / OPTIMIZER_PARAM_GROUPS),
}
+2
View File
@@ -47,6 +47,8 @@ class ACTPolicy(PreTrainedPolicy):
config_class = ACTConfig
name = "act"
# FSDP2 wrap units: one unit per transformer layer of both stacks.
_fsdp_wrap_modules = ["ACTEncoderLayer", "ACTDecoderLayer"]
def __init__(
self,
+29 -16
View File
@@ -242,6 +242,7 @@ def make_policy(
ds_meta: LeRobotDatasetMetadata | None = None,
env_cfg: EnvConfig | None = None,
rename_map: dict[str, str] | None = None,
defer_weight_load: bool = False,
) -> PreTrainedPolicy:
"""
Instantiate a policy model.
@@ -252,22 +253,27 @@ def make_policy(
can either initialize a new policy from scratch or load a pretrained one.
Args:
cfg: The configuration for the policy to be created. If `cfg.pretrained_path` is
set, the policy will be loaded with weights from that path.
ds_meta: Dataset metadata used to infer feature shapes and types. Also provides
statistics for normalization layers.
env_cfg: Environment configuration used to infer feature shapes and types.
One of `ds_meta` or `env_cfg` must be provided.
rename_map: Optional mapping of dataset or environment feature keys to match
expected policy feature names (e.g., `"left"` `"camera1"`).
cfg (PreTrainedConfig): The configuration for the policy to be created. If
`cfg.pretrained_path` is set, the policy will be loaded with weights from that path.
ds_meta (LeRobotDatasetMetadata | None): Dataset metadata used to infer feature shapes and
types. Also provides statistics for normalization layers.
env_cfg (EnvConfig | None): Environment configuration used to infer feature shapes and
types. One of `ds_meta` or `env_cfg` must be provided.
rename_map (dict[str, str] | None): Optional mapping of dataset or environment feature
keys to match expected policy feature names (e.g., `"left"` `"camera1"`).
defer_weight_load (bool): Build the exact policy `from_pretrained` would build same
config resolution, same stats-derived buffers, same device placement and eval mode
but skip the safetensors weight load. Used when resuming from a DCP checkpoint, whose
sharded weights stream in after `accelerator.prepare()` (the distributed checkpoint
engine overwrites the random init).
Returns:
An instantiated and device-placed policy model.
PreTrainedPolicy: An instantiated and device-placed policy model.
Raises:
ValueError: If both or neither of `ds_meta` and `env_cfg` are provided.
NotImplementedError: If attempting to use an unsupported policy-backend
combination (e.g., VQBeT with 'mps').
NotImplementedError: If attempting to use an unsupported policy-backend combination
(e.g., VQBeT with 'mps').
"""
if bool(ds_meta) == bool(env_cfg):
raise ValueError("Either one of a dataset metadata or a sim env must be provided.")
@@ -332,11 +338,18 @@ def make_policy(
)
if cfg.pretrained_path and not cfg.use_peft:
# Load a pretrained policy and override the config if needed (for example, if there are inference-time
# hyperparameters that we want to vary).
kwargs["pretrained_name_or_path"] = cfg.pretrained_path
kwargs["revision"] = cfg.pretrained_revision
policy = policy_cls.from_pretrained(**kwargs)
if defer_weight_load:
# Same construction path as from_pretrained (config already resolved from the
# checkpoint by the caller; dataset_stats/dataset_meta kwargs identical), minus the
# weight load — parity by construction.
policy = policy_cls(**kwargs)
policy.eval()
else:
# Load a pretrained policy and override the config if needed (for example, if there
# are inference-time hyperparameters that we want to vary).
kwargs["pretrained_name_or_path"] = cfg.pretrained_path
kwargs["revision"] = cfg.pretrained_revision
policy = policy_cls.from_pretrained(**kwargs)
elif cfg.pretrained_path and cfg.use_peft:
# Load a pretrained PEFT model on top of the policy. The pretrained path points to the folder/repo
# of the adapter and the adapter's config contains the path to the base policy. So we need the
@@ -54,6 +54,9 @@ class FastWAMPolicy(PreTrainedPolicy):
config_class = FastWAMConfig
name = "fastwam"
# FSDP2 wrap units: MoTLayer is the single FSDP owner of each layer's expert blocks
# (the blocks are re-parented onto it precisely so sharding has one boundary to hook).
_fsdp_wrap_modules = ["MoTLayer"]
def __init__(
self,
@@ -604,6 +604,12 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
) -> torch.Tensor:
"""
Optimized autoregressive decoding for FAST tokens using KV Caching.
Greedy decoding stops once every sequence emits the end-of-action marker. The
returned tensor keeps its fixed shape, with positions not generated after the
batch-wide stop left zero-filled. Stochastic decoding always runs to
``max_decoding_steps`` so early stopping does not change the RNG state used by
subsequent calls.
"""
if max_decoding_steps is None:
max_decoding_steps = self.config.max_action_tokens
@@ -612,6 +618,12 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
device = tokens.device
lm_head = self.paligemma_with_expert.paligemma.lm_head
# detokenize_actions() cuts at the first "|", so greedy decoding can stop once
# every sequence has emitted it. Keep stochastic decoding unchanged because
# skipping multinomial calls would shift the RNG state for subsequent calls.
end_of_action_token_id = self._paligemma_tokenizer.convert_tokens_to_ids("|")
finished = torch.zeros(bsize, dtype=torch.bool, device=device) if temperature == 0 else None
# --- 1. PREFILL PHASE ---
# Process Images + Text Prompt + BOS token once to populate the KV cache.
@@ -663,6 +675,10 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
# Initialize storage for generated tokens
generated_action_tokens = torch.zeros((bsize, max_decoding_steps), dtype=torch.long, device=device)
generated_action_tokens[:, 0] = next_token.squeeze(-1)
if finished is not None:
finished |= next_token.squeeze(-1) == end_of_action_token_id
if bool(finished.all()):
return generated_action_tokens
# Track valid tokens mask (0 for pad, 1 for valid)
# We need this to tell the new token what it can attend to (images + text + past actions)
@@ -713,6 +729,11 @@ class PI0FastPytorch(nn.Module): # see openpi `PI0Pytorch`
generated_action_tokens[:, t] = next_token.squeeze(-1)
if finished is not None:
finished |= next_token.squeeze(-1) == end_of_action_token_id
if bool(finished.all()):
break
return generated_action_tokens
+99 -167
View File
@@ -18,20 +18,18 @@ import builtins
import dataclasses
import logging
import os
from importlib.resources import files
import warnings
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, TypedDict, TypeVar, Unpack
from typing import TYPE_CHECKING, Any, ClassVar, TypedDict, TypeVar, Unpack
from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download, save_torch_state_dict
from huggingface_hub import hf_hub_download, save_torch_state_dict
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from huggingface_hub.errors import HfHubHTTPError
from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor
from safetensors.torch import load_model as load_model_as_safetensor
from torch import Tensor, nn
from lerobot.__version__ import __version__
from lerobot.configs import PreTrainedConfig
from lerobot.configs.train import TrainPipelineConfig
from lerobot.utils.constants import ACTION
from lerobot.utils.device_utils import resolve_safetensors_device
from lerobot.utils.hub import HubMixin
from lerobot.utils.import_utils import _peft_available, require_package
@@ -46,56 +44,14 @@ else:
get_peft_model = None
if TYPE_CHECKING:
from lerobot.configs.train import TrainPipelineConfig
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
T = TypeVar("T", bound="PreTrainedPolicy")
def _build_card_context(
cfg: TrainPipelineConfig | None,
dataset_meta: LeRobotDatasetMetadata | None,
input_features: dict | None,
output_features: dict | None,
) -> dict:
"""Collect optional data for the model-card template.
Returns plain values only (no Markdown) the template in
``lerobot/templates/lerobot_modelcard_template.md`` decides how and whether to show
each one. Everything is best-effort: anything unavailable is left empty/None and the
template simply skips that section, so this never breaks a Hub push.
"""
context = {
"training": None,
"input_features": input_features or {},
"output_features": output_features or {},
"dataset": None,
"robot_type": None,
"cameras": [],
}
if cfg is not None:
optimizer = getattr(cfg, "optimizer", None)
context["training"] = {
"steps": cfg.steps,
"batch_size": cfg.batch_size,
"seed": cfg.seed,
"optimizer": getattr(optimizer, "type", None) if optimizer else None,
"lr": getattr(optimizer, "lr", None) if optimizer else None,
"lerobot_version": __version__,
}
if dataset_meta is not None:
context["dataset"] = {
"repo_id": dataset_meta.repo_id,
"episodes": dataset_meta.total_episodes,
"frames": dataset_meta.total_frames,
"fps": dataset_meta.fps,
"tasks": [str(task) for task in dataset_meta.tasks.index],
}
context["robot_type"] = dataset_meta.robot_type
context["cameras"] = [key.split(".")[-1] for key in dataset_meta.camera_keys]
return context
# Pinned far above any policy's total size so save_torch_state_dict always emits exactly one
# `model.safetensors` (no shards, no index) — a constant, not a computed byte count.
_SINGLE_FILE_SHARD_SIZE = "1TB"
class ActionSelectKwargs(TypedDict, total=False):
@@ -110,6 +66,22 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
config_class: None
name: None
# --- declarative parallelism/acceleration surface ----------------------------------------
# Module CLASS names forming the FSDP2 wrap units (and, once wired, the activation-
# checkpointing units). Resolved onto the accelerate plugin right before
# `accelerator.prepare()` by `lerobot.distributed.set_fsdp_wrap_modules`; sharded training
# with no wrap source anywhere fails loudly instead of silently wrapping only the root.
_fsdp_wrap_modules: ClassVar[list[str] | None] = None
# Non-`forward` entry points that must trigger FSDP2 unshard/reshard hooks when called on a
# sharded policy (registered post-prepare via `torch.distributed.fsdp
# .register_fsdp_forward_method`); calling them unregistered crashes on mixed Tensor/DTensor.
_fsdp_forward_methods: ClassVar[tuple[str, ...]] = ("select_action", "predict_action_chunk")
# Capability gate for the (future) activation-checkpointing wiring.
supports_gradient_checkpointing: ClassVar[bool] = False
# Declarative context-parallel plan (diffusers `ContextParallelModelPlan` semantics:
# module FQN -> sequence split/gather spec). Reserved for the CP engine round.
_cp_plan: ClassVar[dict[str, Any] | None] = None
def __init__(self, config: PreTrainedConfig, *inputs, **kwargs):
super().__init__()
if not isinstance(config, PreTrainedConfig):
@@ -127,43 +99,33 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
if not getattr(cls, "name", None):
raise TypeError(f"Class {cls.__name__} must define 'name'")
def save_pretrained(
self,
save_directory: str | Path,
*,
state_dict: dict[str, Tensor] | None = None,
repo_id: str | None = None,
push_to_hub: bool = False,
card_kwargs: dict | None = None,
**push_to_hub_kwargs,
) -> str | None:
"""Save the policy to a directory (and optionally push to the Hub).
def _save_pretrained(self, save_directory: Path) -> None:
"""Serialize this policy's parameters (and config) into `save_directory`.
Overrides `HubMixin.save_pretrained` to add a `state_dict` argument (mirroring
`transformers.PreTrainedModel.save_pretrained`). Under FSDP, `self.state_dict()` would
return sharded tensors, so the caller gathers the full state dict via a cross-rank
collective and passes it here for `_save_pretrained` to write directly.
Sharding is handled internally: under FSDP2 the full state dict is gathered through a
COLLECTIVE, so when the policy is sharded this method (via `save_pretrained`) must be
called on EVERY rank a rank-0-gated call deadlocks. File writes happen on the main
process only, in all layouts (single, DDP, sharded).
Args:
save_directory (Path): Target directory for the policy config (`config.json`) and the
safetensors weight file(s).
"""
save_directory = Path(save_directory)
save_directory.mkdir(parents=True, exist_ok=True)
self._save_pretrained(save_directory, state_dict=state_dict)
if push_to_hub:
if repo_id is None:
repo_id = save_directory.name
return self.push_to_hub(repo_id=repo_id, card_kwargs=card_kwargs, **push_to_hub_kwargs)
return None
# Lazy imports: the persistence layer pulls in lerobot.distributed only when saving.
from lerobot.distributed.checkpoint import full_model_state_dict, is_sharded_module
from lerobot.distributed.utils import is_main_process
def _save_pretrained(self, save_directory: Path, state_dict: dict[str, Tensor] | None = None) -> None:
self.config._save_pretrained(save_directory)
model_to_save = self.module if hasattr(self, "module") else self
if state_dict is None:
save_model_as_safetensor(model_to_save, str(save_directory / SAFETENSORS_SINGLE_FILE))
if is_sharded_module(model_to_save):
logging.info("Gathering the full state dict from all ranks (sharded policy).")
state_dict = full_model_state_dict(model_to_save) # collective when sharded; {} off-main
if not state_dict or not is_main_process():
# Sharded: the gather materializes on the main rank only (emptiness check).
# Non-sharded multi-rank (DDP): every rank holds a full dict — the explicit rank
# gate prevents N ranks racing on the same files. Single process: never taken.
return
# A pre-gathered (e.g. FSDP full) state dict was supplied: write it directly.
# `save_torch_state_dict` discards shared-tensor duplicates just like `save_model` does;
# pin `max_shard_size` above the total size so the output stays a single `model.safetensors`
total_bytes = sum(t.numel() * t.element_size() for t in state_dict.values())
save_torch_state_dict(state_dict, str(save_directory), max_shard_size=max(total_bytes, 1))
self.config._save_pretrained(save_directory)
save_torch_state_dict(state_dict, str(save_directory), max_shard_size=_SINGLE_FILE_SHARD_SIZE)
@classmethod
def from_pretrained(
@@ -249,6 +211,29 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
"""
raise NotImplementedError
def drop_queued_actions(self) -> None:
"""Discard actions precomputed by earlier ``select_action`` calls.
Chunking policies answer most control ticks from a queue filled by an
earlier forward pass, so a mid-episode change to the conditioning
e.g. a new language instruction would otherwise only take effect
once that queue drains (up to ``chunk_size`` ticks). Dropping the
queue forces a fresh forward pass on the next ``select_action``.
Unlike :meth:`reset` this keeps the rest of the episode state (e.g.
observation history), so it does not perturb policies that condition
on it. Call it from the thread that calls ``select_action``: it
mutates the same queues that thread pops from.
Policies that keep no action queue inherit a no-op.
"""
queues = getattr(self, "_queues", None)
if isinstance(queues, dict) and ACTION in queues:
queues[ACTION].clear()
action_queue = getattr(self, "_action_queue", None)
if action_queue is not None:
action_queue.clear()
def supports_rtc(self) -> bool:
"""Whether this policy implements Real-Time Chunking inference semantics."""
return False
@@ -291,92 +276,39 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
peft_model=None,
state_dict: dict[str, Tensor] | None = None,
dataset_meta: LeRobotDatasetMetadata | None = None,
):
api = HfApi()
repo_id = api.create_repo(
repo_id=self.config.repo_id, private=self.config.private, exist_ok=True
).repo_id
) -> None:
"""Publish this policy to the Hub.
# Push the files to the repo in a single commit
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
saved_path = Path(tmp) / repo_id
Deprecated: use :func:`lerobot.common.train_utils.publish_trained_model` instead, which
also publishes the pre/post-processors alongside the model.
if peft_model is not None:
# Since PEFT just forwards calls to `push_model_to_hub`, `self` is not the PeftModel wrapper
# but the actual policy which is why we need the PEFT model passed to us to save the adapter.
# That also means that we need to store the policy config ourselves since PEFT can't.
peft_model.save_pretrained(saved_path)
self.config.save_pretrained(saved_path)
else:
# Calls _save_pretrained and stores model tensors
self.save_pretrained(saved_path, state_dict=state_dict)
Args:
cfg (TrainPipelineConfig): The training config; saved as `train_config.json` and
used to render the model card.
peft_model: The PEFT wrapper when training adapters, whose weights replace the full
model weights in the published repo. Defaults to None.
state_dict (dict[str, Tensor] | None): Ignored; weights are now gathered internally
when the policy is sharded. Defaults to None.
dataset_meta (LeRobotDatasetMetadata | None): Dataset metadata for the model card,
if available. Defaults to None.
"""
from lerobot.common.train_utils import publish_trained_model
card = self.generate_model_card(
cfg.dataset.repo_id,
self.config.type,
self.config.license,
self.config.tags,
cfg=cfg,
dataset_meta=dataset_meta,
warnings.warn(
"PreTrainedPolicy.push_model_to_hub is deprecated and will be removed in a future "
"version. Use lerobot.common.train_utils.publish_trained_model(cfg, model, "
"preprocessor, postprocessor, dataset_meta) instead.",
FutureWarning,
stacklevel=2,
)
if state_dict is not None:
warnings.warn(
"The `state_dict` argument is ignored: sharded weights are gathered internally "
"when the policy is saved.",
FutureWarning,
stacklevel=2,
)
card.save(str(saved_path / "README.md"))
cfg.save_pretrained(saved_path) # Calls _save_pretrained and stores train config
commit_info = api.upload_folder(
repo_id=repo_id,
repo_type="model",
folder_path=saved_path,
commit_message="Upload policy weights, train config and readme",
allow_patterns=["*.safetensors", "*.json", "*.yaml", "*.md"],
ignore_patterns=["*.tmp", "*.log"],
)
# Contract: lerobot.jobs.hf.submit_to_hf watches for this exact
# "Model pushed to <url>" line to end a remote run early. Keep the wording
# and URL format in sync (it falls back to status polling if they drift).
logging.info(f"Model pushed to {commit_info.repo_url.url}")
def generate_model_card(
self,
dataset_repo_id: str,
model_type: str,
license: str | None,
tags: list[str] | None,
cfg: TrainPipelineConfig | None = None,
dataset_meta: LeRobotDatasetMetadata | None = None,
) -> ModelCard:
base_model_mapping = {
"smolvla": "lerobot/smolvla_base",
"pi0": "lerobot/pi0_base",
"pi05": "lerobot/pi05_base",
"pi0_fast": "lerobot/pi0fast-base",
"xvla": "lerobot/xvla-base",
}
card_data = ModelCardData(
license=license or "apache-2.0",
library_name="lerobot",
pipeline_tag="robotics",
tags=list(set(tags or []).union({"robotics", "lerobot", model_type})),
model_name=model_type,
datasets=dataset_repo_id,
base_model=base_model_mapping.get(model_type),
)
context = _build_card_context(
cfg, dataset_meta, self.config.input_features, self.config.output_features
)
# Used by the template to pre-fill commands and the "Fine-tuned from" line.
context["policy_repo_id"] = getattr(self.config, "repo_id", None)
context["base_model"] = base_model_mapping.get(model_type)
template_card = (
files("lerobot.templates").joinpath("lerobot_modelcard_template.md").read_text(encoding="utf-8")
)
card = ModelCard.from_template(card_data, template_str=template_card, **context)
card.validate()
return card
publish_trained_model(cfg, self, None, None, dataset_meta, peft_model=peft_model)
def wrap_with_peft(
self,
@@ -16,6 +16,7 @@ from __future__ import annotations
import logging
from collections import deque
from contextlib import nullcontext
from pathlib import Path
from typing import TYPE_CHECKING, Any
@@ -26,6 +27,7 @@ from torch import Tensor, nn
from lerobot.policies.pretrained import PreTrainedPolicy, T
from lerobot.policies.utils import populate_queues
from lerobot.utils.constants import ACTION, OBS_STATE
from lerobot.utils.device_utils import is_amp_available
from lerobot.utils.import_utils import _transformers_available, require_package
if TYPE_CHECKING or _transformers_available:
@@ -39,6 +41,21 @@ from .configuration_vla_jepa import VLAJEPAConfig
from .qwen_interface import Qwen3VLInterface
from .world_model import ActionConditionedVideoPredictor
def _get_autocast_context(device_type: str, dtype: torch.dtype = torch.bfloat16):
"""Return an autocast context appropriate for the device.
MPS does not support ``torch.autocast`` at all. On CUDA devices
without bfloat16 support (compute capability < 8.0) we fall back to
float16.
"""
if not is_amp_available(device_type):
return nullcontext()
if device_type == "cuda" and dtype == torch.bfloat16 and not torch.cuda.is_bf16_supported():
dtype = torch.float16
return torch.autocast(device_type=device_type, dtype=dtype)
# ============================================================================
# Native VLA-JEPA Model - follows original starVLA VLA_JEPA.py implementation
# ============================================================================
@@ -183,7 +200,7 @@ class VLAJEPAModel(nn.Module):
action_idx = action_mask.nonzero(as_tuple=True)
device_type = next(self.parameters()).device.type
with torch.autocast(device_type=device_type, dtype=torch.bfloat16):
with _get_autocast_context(device_type, torch.bfloat16):
last_hidden = self._qwen_last_decoder_hidden(qwen_inputs) # [B, seq_len, H]
b, _, h = last_hidden.shape
embodied_action_tokens = last_hidden[embodied_idx[0], embodied_idx[1], :].view(b, -1, h)
@@ -250,7 +267,7 @@ class VLAJEPAModel(nn.Module):
) -> Tensor:
"""Flow-matching action-head loss, repeated over `repeated_diffusion_steps`."""
device_type = next(self.parameters()).device.type
with torch.autocast(device_type=device_type, dtype=torch.float32):
with _get_autocast_context(device_type, torch.float32):
r = self.config.repeated_diffusion_steps
horizon = self.config.chunk_size
actions_target = actions[:, -horizon:, :].to(torch.float32).repeat(r, 1, 1)
-3
View File
@@ -175,9 +175,6 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
if isinstance(task_index_value, Tensor) and task_index_value.dim() == 0:
complementary_data["task_index"] = task_index_value.unsqueeze(0)
complementary_data.pop("language_persistent", None)
complementary_data.pop("language_events", None)
if "messages" in complementary_data:
messages = complementary_data["messages"]
if isinstance(messages, list) and (not messages or isinstance(messages[0], dict)):
@@ -647,10 +647,15 @@ def main():
tags = set(tags).union({"robotics", "lerobot", policy_type})
tags = list(tags)
# Generate model card
card = policy.generate_model_card(
dataset_repo_id=dataset_repo_id, model_type=policy_type, license=license, tags=tags
)
# Generate model card through the free helper (PreTrainedPolicy.generate_model_card was
# removed with the publisher redesign), then apply the metadata recovered above — the
# migrated policy config does not carry the original repo's card fields.
from lerobot.common.train_utils import generate_model_card
card = generate_model_card(policy.config)
card.data.datasets = dataset_repo_id
card.data.license = license
card.data.tags = sorted(tags)
# Save model card locally
card.save(str(output_dir / "README.md"))
+106 -4
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, TypedDict, TypeVar, cast
import torch
from huggingface_hub import hf_hub_download
from huggingface_hub import hf_hub_download, snapshot_download
from safetensors.torch import load_file, save_file
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
@@ -212,6 +212,10 @@ class ProcessorStep(ABC):
"""
return None
def save_artifacts(self, save_directory: Path) -> dict[str, str]:
"""Save non-tensor assets and map constructor arguments to relative paths."""
return {}
def reset(self) -> None:
"""Resets the internal state of the processor step, if any."""
return None
@@ -556,6 +560,22 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
pipeline_config = self.get_config()
pipeline_state_dict = self.state_dict()
for processor_step, step_entry in zip(self.steps, pipeline_config["steps"], strict=True):
artifacts = processor_step.save_artifacts(save_directory)
if artifacts:
for config_key, relative_path in artifacts.items():
artifact_path = Path(relative_path)
if artifact_path.is_absolute() or ".." in artifact_path.parts:
raise ValueError(
f"Processor artifact path must be relative to the checkpoint: {relative_path!r}"
)
if not (save_directory / artifact_path).exists():
raise FileNotFoundError(
f"Processor step did not save declared artifact '{relative_path}'"
)
step_entry["config"][config_key] = artifact_path.as_posix()
step_entry["artifacts"] = artifacts
for state_key, step_state_dict in pipeline_state_dict.items():
state_filename = f"{state_key}.safetensors"
save_file(step_state_dict, save_directory / state_filename)
@@ -740,7 +760,13 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
# 3. Build steps with overrides
steps, validated_overrides = cls._build_steps_with_overrides(
loaded_config, overrides or {}, model_id, base_path, hub_download_kwargs, is_local_source
loaded_config,
overrides or {},
model_id,
base_path,
config_filename,
hub_download_kwargs,
is_local_source,
)
# 4. Validate that all overrides were used
@@ -936,6 +962,7 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
overrides: dict[str, Any],
model_id: str,
base_path: Path | None,
config_filename: str,
hub_download_kwargs: dict[str, Any],
is_local_source: bool = False,
) -> tuple[list[ProcessorStep], set[str]]:
@@ -945,6 +972,11 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
**For each step in loaded_config["steps"]**:
0. **Artifact Resolution** (via _resolve_artifact_paths):
- Resolve declared relative artifact paths against a local checkpoint
- Download declared artifacts when loading the pipeline from the Hub
- Reject absolute paths and path traversal before step construction
1. **Class Resolution** (via _resolve_step_class):
- **If "registry_name" exists**: Look up in ProcessorStepRegistry
Example: {"registry_name": "normalize_step"} -> Get registered class
@@ -978,6 +1010,8 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
overrides: User-provided parameter overrides (keyed by class/registry name)
model_id: The model identifier (needed for Hub state file downloads)
base_path: Local directory path for finding state files
config_filename: Processor config path, used as the repository-relative
base for state files and declared artifacts.
hub_download_kwargs: Parameters for hf_hub_download (tokens, cache, etc.)
is_local_source: Whether model_id resolved to a local directory or config file.
@@ -990,15 +1024,80 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
ImportError: If a step class cannot be imported or found in registry
ValueError: If a step cannot be instantiated with its configuration
"""
loaded_config = deepcopy(loaded_config)
cls._resolve_artifact_paths(
loaded_config,
model_id,
base_path,
config_filename,
hub_download_kwargs,
)
steps, remaining_override_keys = cls._build_steps_from_config(loaded_config, overrides)
for step_instance, step_entry in zip(steps, loaded_config["steps"], strict=True):
cls._load_step_state(
step_instance, step_entry, model_id, base_path, hub_download_kwargs, is_local_source
step_instance,
step_entry,
model_id,
base_path,
config_filename,
hub_download_kwargs,
is_local_source,
)
return steps, remaining_override_keys
@classmethod
def _resolve_artifact_paths(
cls,
loaded_config: dict[str, Any],
model_id: str,
base_path: Path | None,
config_filename: str,
hub_download_kwargs: dict[str, Any],
) -> None:
"""Resolve declared relative processor artifacts before step construction.
Args:
loaded_config: Mutable processor configuration containing step artifact declarations.
model_id: Local checkpoint path or Hub model identifier.
base_path: Local directory containing the resolved processor configuration.
config_filename: Processor config path, whose parent is the artifact root on the Hub.
hub_download_kwargs: Authentication, revision, and cache arguments for Hub downloads.
Raises:
ValueError: If a declared artifact path is absolute or escapes the checkpoint.
FileNotFoundError: If a declared artifact cannot be found locally or downloaded.
"""
is_local = Path(model_id).is_dir() or Path(model_id).is_file()
for step_entry in loaded_config["steps"]:
artifacts = step_entry.get("artifacts", {})
for config_key, relative_path in artifacts.items():
artifact_path = Path(relative_path)
if artifact_path.is_absolute() or ".." in artifact_path.parts:
raise ValueError(
f"Processor artifact path must be relative to the checkpoint: {relative_path!r}"
)
resolved_path = base_path / artifact_path if base_path is not None else artifact_path
if not resolved_path.exists() and not is_local:
repository_path = Path(config_filename).parent / artifact_path
snapshot_download(
repo_id=model_id,
repo_type="model",
allow_patterns=f"{repository_path.as_posix()}/**",
**hub_download_kwargs,
)
if not resolved_path.exists():
step_name = step_entry.get("registry_name", step_entry.get("class", "unknown"))
raise FileNotFoundError(
f"Missing processor artifact '{relative_path}' for step '{step_name}' "
f"next to '{config_filename}'. Checkpoint artifacts are incomplete."
)
step_entry["config"][config_key] = str(resolved_path)
@classmethod
def _build_steps_from_config(
cls,
@@ -1158,6 +1257,7 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
step_entry: dict[str, Any],
model_id: str,
base_path: Path | None,
config_filename: str,
hub_download_kwargs: dict[str, Any],
is_local_source: bool = False,
) -> None:
@@ -1198,6 +1298,8 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
step_entry: The step configuration dictionary (may contain "state_file")
model_id: The model identifier (used for Hub downloads if needed)
base_path: Local directory path for finding state files (None for Hub-only)
config_filename: Processor config path, whose parent is used to resolve
repository-relative state files on the Hub.
hub_download_kwargs: Parameters for hf_hub_download (tokens, cache, etc.)
is_local_source: Whether model_id resolved to a local directory or config file.
@@ -1223,7 +1325,7 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
# Download from Hub
state_path = hf_hub_download(
repo_id=model_id,
filename=state_filename,
filename=(Path(config_filename).parent / state_filename).as_posix(),
repo_type="model",
**hub_download_kwargs,
)
@@ -16,9 +16,11 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from typing import Any
import numpy as np
from lerobot.configs import PipelineFeatureType, PolicyFeature
from lerobot.configs.recipe import TrainingRecipe
from lerobot.datasets.language import LANGUAGE_EVENTS, LANGUAGE_PERSISTENT
@@ -32,25 +34,46 @@ from .pipeline import ProcessorStep, ProcessorStepRegistry
@dataclass
@ProcessorStepRegistry.register(name="render_messages_processor")
class RenderMessagesStep(ProcessorStep):
"""Processor step that turns raw language columns into rendered chat messages.
"""Turn raw language columns into recipe-defined messages and supervision.
Reads ``language_persistent`` and ``language_events`` from the transition's
complementary data, renders them through ``recipe`` at the sample timestamp,
and replaces the raw columns with the resulting ``messages`` /
``message_streams`` / ``target_message_indices`` keys.
Reads ``language_persistent`` and ``language_events`` from complementary
data, renders them at each sample timestamp, and replaces the raw columns
with ``messages``, ``message_streams``, and ``target_message_indices``.
Batched inputs are filtered to samples with applicable supervision; samples
without language annotations use their task string as low-level supervision
when one is available.
"""
recipe: TrainingRecipe
dataset_ctx: Any | None = None
def __post_init__(self) -> None:
if isinstance(self.recipe, dict):
self.recipe = TrainingRecipe.from_dict(self.recipe)
def get_config(self) -> dict[str, Any]:
return {"recipe": asdict(self.recipe)}
def __call__(self, transition: EnvTransition) -> EnvTransition | None:
"""Render messages for a single transition; return ``None`` to drop it."""
"""Render messages, preserving unannotated samples and dropping unmatched annotated ones."""
complementary_data = transition.get(TransitionKey.COMPLEMENTARY_DATA) or {}
persistent = complementary_data.get(LANGUAGE_PERSISTENT) or []
events = complementary_data.get(LANGUAGE_EVENTS) or []
if not persistent and not events:
return transition
# A dataset without language annotations remains usable: render its
# task as low-level supervision, or pass it through when no task exists.
rendered = _fallback_low_level_render(complementary_data.get("task"))
if rendered is None:
return transition
new_transition = transition.copy()
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.update(rendered)
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
if _is_batched_language(persistent) or _is_batched_language(events):
return self._call_batch(transition, complementary_data, persistent, events)
timestamp = complementary_data.get("timestamp")
if timestamp is None:
@@ -67,18 +90,171 @@ class RenderMessagesStep(ProcessorStep):
dataset_ctx=self.dataset_ctx,
)
if rendered is None:
return None
# Language is present but this sparse frame has no applicable recipe
# branch. Keep it only when task-level action supervision is possible.
rendered = _fallback_low_level_render(complementary_data.get("task"))
if rendered is None:
return None
new_transition = transition.copy()
new_complementary_data = dict(complementary_data)
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.pop(LANGUAGE_PERSISTENT, None)
new_complementary_data.pop(LANGUAGE_EVENTS, None)
new_complementary_data.update(rendered)
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
def _call_batch(
self,
transition: EnvTransition,
complementary_data: dict[str, Any],
persistent_batch: list,
events_batch: list,
) -> EnvTransition | None:
"""Render a language batch.
Non-empty persistent and event batches must have the same size. Either
list may be empty when that language column is absent from the batch.
"""
timestamp = complementary_data.get("timestamp")
if timestamp is None:
raise KeyError("RenderMessagesStep requires sample timestamp in complementary data.")
non_empty_batch_sizes = {len(batch) for batch in (persistent_batch, events_batch) if batch}
if len(non_empty_batch_sizes) > 1:
raise ValueError(
"Batched language columns must have equal lengths when both are non-empty, "
f"got persistent={len(persistent_batch)} and events={len(events_batch)}."
)
batch_size = next(iter(non_empty_batch_sizes), 0)
messages: list[list[dict[str, Any]]] = []
message_streams: list[list[str | None]] = []
target_message_indices: list[list[int]] = []
keep_indices: list[int] = []
for i in range(batch_size):
rendered = render_sample(
recipe=self.recipe,
persistent=persistent_batch[i] if i < len(persistent_batch) else [],
events=events_batch[i] if i < len(events_batch) else [],
t=_batch_value(timestamp, i),
sample_idx=int(_batch_value(complementary_data.get("index", 0), i)),
task=_batch_value(complementary_data.get("task"), i),
dataset_ctx=self.dataset_ctx,
)
if rendered is None:
rendered = _fallback_low_level_render(_batch_value(complementary_data.get("task"), i))
if rendered is None:
continue
keep_indices.append(i)
messages.append(rendered["messages"])
message_streams.append(rendered["message_streams"])
target_message_indices.append(rendered["target_message_indices"])
if not messages:
return None
new_transition = (
_select_batch_indices(transition, keep_indices, batch_size)
if len(keep_indices) != batch_size
else transition.copy()
)
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.pop(LANGUAGE_PERSISTENT, None)
new_complementary_data.pop(LANGUAGE_EVENTS, None)
new_complementary_data["messages"] = messages
new_complementary_data["message_streams"] = message_streams
new_complementary_data["target_message_indices"] = target_message_indices
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Pass features through unchanged; rendering only touches complementary data."""
return features
def _is_batched_language(value: Any) -> bool:
return isinstance(value, list) and bool(value) and isinstance(value[0], list)
def _batch_value(value: Any, index: int) -> Any:
if value is None:
return None
if isinstance(value, list):
return value[index]
if hasattr(value, "ndim") and value.ndim > 0:
return unwrap_scalar(value[index])
return unwrap_scalar(value)
def _select_batch_indices(transition: EnvTransition, indices: list[int], batch_size: int) -> EnvTransition:
selected = transition.copy()
for key in (TransitionKey.OBSERVATION, TransitionKey.COMPLEMENTARY_DATA):
data = selected.get(key)
if isinstance(data, dict):
selected[key] = {
name: _select_value(value, indices, batch_size, f"{key}.{name}")
for name, value in data.items()
}
action = selected.get(TransitionKey.ACTION)
if action is not None:
selected[TransitionKey.ACTION] = _select_value(action, indices, batch_size, str(TransitionKey.ACTION))
return selected
def _select_value(value: Any, indices: list[int], batch_size: int, path: str) -> Any:
if isinstance(value, dict):
return {key: _select_value(item, indices, batch_size, f"{path}.{key}") for key, item in value.items()}
if isinstance(value, list):
if len(value) != batch_size:
raise ValueError(
f"Cannot filter batched field {path!r}: expected {batch_size} values, got {len(value)}."
)
return [value[i] for i in indices]
if isinstance(value, np.ndarray) and value.ndim > 0:
return value[indices]
if hasattr(value, "index_select") and hasattr(value, "new_tensor") and getattr(value, "ndim", 0) > 0:
return value.index_select(0, value.new_tensor(indices).long())
return value
def _fallback_low_level_render(task: Any) -> dict[str, Any] | None:
"""Keep action-only samples trainable when no recipe branch matches."""
if hasattr(task, "item"):
task = task.item()
if isinstance(task, list):
if not task:
return None
messages = []
message_streams = []
target_message_indices = []
missing_indices = []
for index, t in enumerate(task):
rendered = _fallback_low_level_render(t)
if rendered is None:
missing_indices.append(index)
continue
messages.append(rendered["messages"])
message_streams.append(rendered["message_streams"])
target_message_indices.append(rendered["target_message_indices"])
if missing_indices:
if len(missing_indices) == len(task):
return None
raise ValueError(
"Batched low-level fallback requires a non-empty task for every sample; "
f"missing task at indices {missing_indices}."
)
return {
"messages": messages,
"message_streams": message_streams,
"target_message_indices": target_message_indices,
}
if not isinstance(task, str) or not task:
return None
return {
"messages": [{"role": "user", "content": task}],
"message_streams": ["low_level"],
"target_message_indices": [],
}
+59 -17
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
import torch
@@ -32,6 +33,7 @@ import torch
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.lerobot_types import EnvTransition, RobotObservation, TransitionKey
from lerobot.utils.constants import (
ACTION_CODE_TOKEN_MASK,
ACTION_TOKEN_MASK,
ACTION_TOKENS,
OBS_LANGUAGE_ATTENTION_MASK,
@@ -136,7 +138,7 @@ class TokenizerProcessorStep(ObservationProcessorStep):
# Standardize to a list of strings for the tokenizer
if isinstance(task, str):
return [task]
elif isinstance(task, (list, tuple)) and all(isinstance(t, str) for t in task):
elif isinstance(task, list | tuple) and all(isinstance(t, str) for t in task):
return list(task)
return None
@@ -293,6 +295,15 @@ class TokenizerProcessorStep(ObservationProcessorStep):
return config
def save_artifacts(self, save_directory: Path) -> dict[str, str]:
"""Save the tokenizer so object-provided instances reload without overrides."""
artifact_path = Path("tokenizer")
save_pretrained = getattr(self.input_tokenizer, "save_pretrained", None)
if save_pretrained is None:
raise TypeError("Tokenizer must implement save_pretrained() to save a portable pipeline.")
save_pretrained(save_directory / artifact_path)
return {"tokenizer_name": artifact_path.as_posix()}
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
@@ -349,6 +360,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
max_action_tokens: int = 256
fast_skip_tokens: int = 128
paligemma_tokenizer_name: str = "google/paligemma-3b-pt-224"
allow_truncation: bool = True
# Internal tokenizer instance (not part of the config)
action_tokenizer: Any = field(default=None, init=False, repr=False)
_paligemma_tokenizer: Any = field(default=None, init=False, repr=False)
@@ -412,14 +424,15 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
# During inference, no action is available, skip tokenization
return new_transition
# Tokenize and get both tokens and mask
tokens, mask = self._tokenize_action(action)
# Tokenize and get masks for the full formatted sequence and the discrete action codes.
tokens, mask, code_mask = self._tokenize_action(action)
# Store mask in complementary data
complementary_data = new_transition.get(TransitionKey.COMPLEMENTARY_DATA, {})
if complementary_data is None:
complementary_data = {}
complementary_data[ACTION_TOKEN_MASK] = mask
complementary_data[ACTION_CODE_TOKEN_MASK] = code_mask
complementary_data[ACTION_TOKENS] = tokens
new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data
return new_transition
@@ -430,7 +443,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
"""
return self._paligemma_tokenizer.vocab_size - 1 - self.fast_skip_tokens - tokens
def _tokenize_action(self, action: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
def _tokenize_action(self, action: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
Tokenizes the action tensor and creates a mask.
@@ -459,6 +472,7 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
# The fast tokenizer expects action data and returns token IDs
tokens_list = []
masks_list = []
code_masks_list = []
for i in range(batch_size):
# Tokenize single action (move to CPU first as tokenizer uses scipy which requires numpy)
@@ -476,65 +490,82 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
if tokens.dim() > 1:
tokens = tokens.flatten()
action_code_tokens = self._act_tokens_to_paligemma_tokens(tokens)
bos_id = self._paligemma_tokenizer.bos_token_id
# add bos
prompt_tokens = torch.tensor(
self._paligemma_tokenizer.encode("Action: ", add_special_tokens=False),
device=action.device,
)
end_tokens = torch.tensor(self._paligemma_tokenizer.encode("|"), device=action.device)
code_start = 1 + len(prompt_tokens)
code_end = code_start + len(action_code_tokens)
tokens = torch.cat(
[
torch.tensor([bos_id], device=action.device),
torch.tensor(
self._paligemma_tokenizer.encode("Action: ", add_special_tokens=False),
device=action.device,
),
self._act_tokens_to_paligemma_tokens(tokens),
torch.tensor(self._paligemma_tokenizer.encode("|"), device=action.device),
prompt_tokens,
action_code_tokens,
end_tokens,
]
)
code_mask = torch.zeros(len(tokens), dtype=torch.bool, device=action.device)
code_mask[code_start:code_end] = True
# Truncate or pad to max_action_tokens
if len(tokens) > self.max_action_tokens:
if not self.allow_truncation:
raise ValueError(
f"FAST action sequence has {len(tokens)} tokens, exceeding "
f"max_action_tokens={self.max_action_tokens}."
)
logging.warning(
f"Token length ({len(tokens)}) exceeds max length ({self.max_action_tokens}), truncating. "
"Consider increasing the `max_action_tokens` in your model config if this happens frequently."
)
tokens = tokens[: self.max_action_tokens]
code_mask = code_mask[: self.max_action_tokens]
mask = torch.ones(self.max_action_tokens, dtype=torch.bool, device=action.device)
else:
pad_len = self.max_action_tokens - len(tokens)
mask = torch.cat(
[
torch.ones(len(tokens), dtype=torch.bool, device=action.device),
torch.zeros(
self.max_action_tokens - len(tokens), dtype=torch.bool, device=action.device
),
torch.zeros(pad_len, dtype=torch.bool, device=action.device),
]
)
code_mask = torch.nn.functional.pad(code_mask, (0, pad_len), value=False)
# Pad tokens with zeros
tokens = torch.nn.functional.pad(tokens, (0, self.max_action_tokens - len(tokens)), value=0)
tokens = torch.nn.functional.pad(tokens, (0, pad_len), value=0)
tokens_list.append(tokens)
masks_list.append(mask)
code_masks_list.append(code_mask)
# Stack into batched tensors
tokens_batch = torch.stack(tokens_list, dim=0) # (B, max_action_tokens)
masks_batch = torch.stack(masks_list, dim=0) # (B, max_action_tokens)
code_masks_batch = torch.stack(code_masks_list, dim=0) # (B, max_action_tokens)
# Remove batch dimension if input was single sample
if single_sample:
tokens_batch = tokens_batch.squeeze(0)
masks_batch = masks_batch.squeeze(0)
code_masks_batch = code_masks_batch.squeeze(0)
# Move to the same device as the input
if device is not None:
tokens_batch = tokens_batch.to(device)
masks_batch = masks_batch.to(device)
code_masks_batch = code_masks_batch.to(device)
return tokens_batch, masks_batch
return tokens_batch, masks_batch, code_masks_batch
def action(self, action: torch.Tensor) -> torch.Tensor:
"""
This method is not used since we override __call__.
Required by ActionProcessorStep ABC.
"""
tokens, _ = self._tokenize_action(action)
tokens, _, _ = self._tokenize_action(action)
return tokens
def get_config(self) -> dict[str, Any]:
@@ -550,6 +581,9 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
config = {
"trust_remote_code": self.trust_remote_code,
"max_action_tokens": self.max_action_tokens,
"fast_skip_tokens": self.fast_skip_tokens,
"paligemma_tokenizer_name": self.paligemma_tokenizer_name,
"allow_truncation": self.allow_truncation,
}
# Only save tokenizer_name if it was used to create the tokenizer
@@ -558,6 +592,14 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
return config
def save_artifacts(self, save_directory: Path) -> dict[str, str]:
artifact_path = Path("action_tokenizer")
save_pretrained = getattr(self.action_tokenizer, "save_pretrained", None)
if save_pretrained is None:
raise TypeError("Action tokenizer must implement save_pretrained() to save a portable pipeline.")
save_pretrained(save_directory / artifact_path)
return {"action_tokenizer_name": artifact_path.as_posix()}
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
+33 -49
View File
@@ -16,12 +16,11 @@ import abc
import builtins
import logging
import os
from importlib.resources import files
import warnings
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Any, TypeVar
from huggingface_hub import HfApi, ModelCard, ModelCardData, hf_hub_download
from huggingface_hub import hf_hub_download
from huggingface_hub.constants import SAFETENSORS_SINGLE_FILE
from huggingface_hub.errors import HfHubHTTPError
from safetensors.torch import load_model as load_model_as_safetensor, save_model as save_model_as_safetensor
@@ -61,6 +60,22 @@ class PreTrainedRewardModel(nn.Module, HubMixin, abc.ABC):
raise TypeError(f"Class {cls.__name__} must define 'name'")
def _save_pretrained(self, save_directory: Path) -> None:
"""Serialize this reward model's parameters (and config) into `save_directory`.
Safe to call on every rank: replicas carry identical weights, so only the main process
writes (sharded reward models are rejected at config validation no collective gather).
Args:
save_directory (Path): Target directory for the reward model config (`config.json`)
and `model.safetensors`.
"""
from lerobot.distributed.utils import is_main_process
# save_checkpoint calls this on every rank; replicas carry identical
# weights, so the main process is the only writer. Sharded reward models are rejected
# at config validation, so no collective gather is needed here.
if not is_main_process():
return
self.config._save_pretrained(save_directory)
model_to_save = self.module if hasattr(self, "module") else self
save_model_as_safetensor(model_to_save, str(save_directory / SAFETENSORS_SINGLE_FILE))
@@ -175,53 +190,22 @@ class PreTrainedRewardModel(nn.Module, HubMixin, abc.ABC):
"""
return type(self).forward is not PreTrainedRewardModel.forward
def push_model_to_hub(self, cfg: "TrainPipelineConfig"):
api = HfApi()
repo_id = api.create_repo(
repo_id=self.config.repo_id, private=self.config.private, exist_ok=True
).repo_id
def push_model_to_hub(self, cfg: "TrainPipelineConfig") -> None:
"""Publish this reward model to the Hub.
# Push the files to the repo in a single commit
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
saved_path = Path(tmp) / repo_id
Deprecated: use :func:`lerobot.common.train_utils.publish_trained_model` instead.
self.save_pretrained(saved_path) # Calls _save_pretrained and stores model tensors
Args:
cfg (TrainPipelineConfig): The training config; saved as `train_config.json` and
used to render the model card.
"""
from lerobot.common.train_utils import publish_trained_model
card = self.generate_model_card(
cfg.dataset.repo_id, self.config.type, self.config.license, self.config.tags
)
card.save(str(saved_path / "README.md"))
cfg.save_pretrained(saved_path) # Calls _save_pretrained and stores train config
commit_info = api.upload_folder(
repo_id=repo_id,
repo_type="model",
folder_path=saved_path,
commit_message="Upload reward model weights, train config and readme",
allow_patterns=["*.safetensors", "*.json", "*.yaml", "*.md"],
ignore_patterns=["*.tmp", "*.log"],
)
logging.info(f"Model pushed to {commit_info.repo_url.url}")
def generate_model_card(
self, dataset_repo_id: str, model_type: str, license: str | None, tags: list[str] | None
) -> ModelCard:
card_data = ModelCardData(
license=license or "apache-2.0",
library_name="lerobot",
pipeline_tag="robotics",
tags=list(set(tags or []).union({"robotics", "lerobot", "reward-model", model_type})),
model_name=model_type,
datasets=dataset_repo_id,
warnings.warn(
"PreTrainedRewardModel.push_model_to_hub is deprecated and will be removed in a "
"future version. Use lerobot.common.train_utils.publish_trained_model(cfg, model, "
"preprocessor, postprocessor, dataset_meta) instead.",
FutureWarning,
stacklevel=2,
)
template_card = (
files("lerobot.templates")
.joinpath("lerobot_rewardmodel_modelcard_template.md")
.read_text(encoding="utf-8")
)
card = ModelCard.from_template(card_data, template_str=template_card)
card.validate()
return card
publish_trained_model(cfg, self, None, None, None)
+85 -14
View File
@@ -16,6 +16,7 @@
from __future__ import annotations
import logging
import random
from typing import TYPE_CHECKING, Any
@@ -69,6 +70,8 @@ from .sarm_utils import (
pad_state_to_max_dim,
)
logger = logging.getLogger(__name__)
class SARMEncodingProcessorStep(ProcessorStep):
"""ProcessorStep that encodes images and text with CLIP and generates stage and progress labels for SARM."""
@@ -108,6 +111,8 @@ class SARMEncodingProcessorStep(ProcessorStep):
else None
)
self._validate_annotation_columns()
self.device = torch.device(
self.config.device if self.config.device else "cuda" if torch.cuda.is_available() else "cpu"
)
@@ -120,6 +125,78 @@ class SARMEncodingProcessorStep(ProcessorStep):
self.verbs = ["move", "grasp", "rotate", "push", "pull", "slide", "lift", "place"]
self.fake = Faker()
@staticmethod
def _resolve_annotation_column(episodes_df: pd.DataFrame, annotation_type: str, suffix: str) -> str:
"""Resolve a mode-specific annotation column, falling back to the legacy unprefixed name."""
prefixed = f"{annotation_type}_{suffix}"
return prefixed if prefixed in episodes_df.columns else suffix
@staticmethod
def _annotations_are_usable(names: Any, starts: Any, ends: Any) -> bool:
"""Return whether an episode has non-empty, aligned annotation arrays."""
values = (names, starts, ends)
if not all(isinstance(value, (list, tuple, np.ndarray)) for value in values):
return False
lengths = {len(value) for value in values}
return len(lengths) == 1 and next(iter(lengths)) > 0
def _validate_annotation_columns(self) -> None:
"""Validate annotation coverage before loading models or generating training targets.
A multi-stage head with no usable episode annotations would otherwise train entirely
against all-zero targets. Reject that configuration and warn when only part of the
dataset is usable.
"""
if self.dataset_meta is None:
return
episodes_df = self.dataset_meta.episodes.to_pandas()
num_episodes = len(episodes_df)
modes = []
if self.dense_subtask_names and len(self.dense_subtask_names) > 1:
modes.append(("dense", self.dense_subtask_names))
if self.sparse_subtask_names and len(self.sparse_subtask_names) > 1:
modes.append(("sparse", self.sparse_subtask_names))
for annotation_type, names in modes:
columns = [
self._resolve_annotation_column(episodes_df, annotation_type, suffix)
for suffix in ("subtask_names", "subtask_start_frames", "subtask_end_frames")
]
missing_columns = [column for column in columns if column not in episodes_df.columns]
if missing_columns:
num_usable = 0
else:
num_usable = sum(
self._annotations_are_usable(*(episodes_df.loc[ep_idx, column] for column in columns))
for ep_idx in episodes_df.index
)
if num_usable == 0:
missing_columns_message = (
f" Missing required columns: {', '.join(missing_columns)}." if missing_columns else ""
)
raise ValueError(
f"SARM {annotation_type} head is configured with {len(names)} stages, but none of "
f"the {num_episodes} episodes have usable annotations in meta/episodes/*.parquet. "
f"Required columns: {', '.join(columns)}.{missing_columns_message} "
"Training would produce all-zero "
"targets. Materialize the annotations into the episodes metadata before training."
)
num_unusable = num_episodes - num_usable
if num_unusable:
logger.warning(
"SARM %s head: %d/%d episodes have unusable annotations in columns %s; "
"their targets will be 0 and only the %d annotated episodes will train the head.",
annotation_type,
num_unusable,
num_episodes,
", ".join(columns),
num_usable,
)
def _find_episode_for_frame(self, frame_idx: int) -> int:
"""Find the episode index for a given frame index."""
for ep_idx in range(len(self.dataset_meta.episodes)):
@@ -167,24 +244,18 @@ class SARMEncodingProcessorStep(ProcessorStep):
if episodes_df is None or len(global_names) == 1:
return None, None, None
# Resolve column name with fallback
def col(suffix):
prefixed = f"{annotation_type}_{suffix}"
return prefixed if prefixed in episodes_df.columns else suffix
col_names = col("subtask_names")
if col_names not in episodes_df.columns or ep_idx >= len(episodes_df):
columns = [
self._resolve_annotation_column(episodes_df, annotation_type, suffix)
for suffix in ("subtask_names", "subtask_start_frames", "subtask_end_frames")
]
if any(column not in episodes_df.columns for column in columns) or ep_idx >= len(episodes_df):
return None, None, None
subtask_names = episodes_df.loc[ep_idx, col_names]
if subtask_names is None or (isinstance(subtask_names, float) and pd.isna(subtask_names)):
annotations = tuple(episodes_df.loc[ep_idx, column] for column in columns)
if not self._annotations_are_usable(*annotations):
return None, None, None
return (
subtask_names,
episodes_df.loc[ep_idx, col("subtask_start_frames")],
episodes_df.loc[ep_idx, col("subtask_end_frames")],
)
return annotations
def __call__(self, transition: EnvTransition) -> EnvTransition:
"""
@@ -58,12 +58,11 @@ import builtins
import logging
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import TYPE_CHECKING, Any, TypeVar
import numpy as np
import torch
from huggingface_hub import HfApi, hf_hub_download
from huggingface_hub import hf_hub_download
from huggingface_hub.constants import CONFIG_NAME
from huggingface_hub.errors import HfHubHTTPError
from torch import Tensor
@@ -75,9 +74,6 @@ from lerobot.rewards.topreward.configuration_topreward import TOPRewardConfig
from lerobot.rewards.topreward.processor_topreward import TOPREWARD_FEATURE_PREFIX, TOPREWARD_INPUT_KEYS
from lerobot.utils.import_utils import _transformers_available, require_package
if TYPE_CHECKING:
from lerobot.configs.train import TrainPipelineConfig
if TYPE_CHECKING or _transformers_available:
from transformers import Qwen3VLForConditionalGeneration
else:
@@ -205,34 +201,3 @@ class TOPRewardModel(PreTrainedRewardModel):
instance.to(config.device)
instance.eval()
return instance
def push_model_to_hub(self, cfg: TrainPipelineConfig):
"""Push the TOPReward ``config.json`` + model card to the Hub."""
api = HfApi()
repo_id = api.create_repo(
repo_id=self.config.repo_id, private=self.config.private, exist_ok=True
).repo_id
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
saved_path = Path(tmp) / repo_id
saved_path.mkdir(parents=True, exist_ok=True)
self.config._save_pretrained(saved_path)
card = self.generate_model_card(
cfg.dataset.repo_id, self.config.type, self.config.license, self.config.tags
)
card.save(str(saved_path / "README.md"))
cfg.save_pretrained(saved_path)
commit_info = api.upload_folder(
repo_id=repo_id,
repo_type="model",
folder_path=saved_path,
commit_message="Upload TOPReward config and readme",
allow_patterns=["*.json", "*.yaml", "*.md"],
ignore_patterns=["*.tmp", "*.log", "*.safetensors"],
)
logger.info(f"Model pushed to {commit_info.repo_url.url}")
+17 -10
View File
@@ -74,13 +74,14 @@ from torch.optim.optimizer import Optimizer
from lerobot.cameras import opencv # noqa: F401
from lerobot.common.train_utils import (
get_step_checkpoint_dir,
load_training_state as utils_load_training_state,
load_training_metadata,
save_checkpoint,
update_last_checkpoint,
)
from lerobot.common.wandb_utils import WandBLogger
from lerobot.configs import parser
from lerobot.datasets import LeRobotDataset, make_dataset
from lerobot.optim import load_optimizer_state
from lerobot.policies import make_policy, make_pre_post_processors
from lerobot.robots import so_follower # noqa: F401
from lerobot.teleoperators import gamepad, so_leader # noqa: F401
@@ -103,7 +104,7 @@ from lerobot.utils.constants import (
from lerobot.utils.device_utils import get_safe_torch_device
from lerobot.utils.io_utils import load_json, write_json
from lerobot.utils.process import ProcessSignalHandler, ensure_multiprocessing_start_method
from lerobot.utils.random_utils import set_seed
from lerobot.utils.random_utils import load_rng_state, set_seed
from lerobot.utils.utils import (
format_big_number,
init_logging,
@@ -716,15 +717,18 @@ def load_training_state(
algorithm-owned tensors) from the most recent checkpoint.
Args:
cfg: Training configuration.
optimizers: Optimizers to load state into.
algorithm: Algorithm whose state dict should be restored.
Required for full main-equivalent resume;
the policy itself is restored separately via ``make_policy``.
device: Device on which to place loaded algorithm tensors.
cfg (TrainRLServerPipelineConfig): Training configuration; `cfg.resume` gates the load and
`cfg.output_dir` locates the last checkpoint.
optimizers (Optimizer | dict[str, Optimizer]): Optimizers to load state into.
algorithm (RLAlgorithm | None, optional): Algorithm whose state dict should be restored.
Required for full main-equivalent resume; the policy itself is restored separately via
`make_policy`. Defaults to None.
device (str | torch.device, optional): Device on which to place loaded algorithm tensors.
Defaults to "cpu".
Returns:
tuple: (optimization_step, interaction_step) or (None, None) if not resuming
tuple[int | None, int | None]: `(optimization_step, interaction_step)`, or `(None, None)`
when not resuming or when loading the training state fails.
"""
if not cfg.resume:
return None, None
@@ -736,7 +740,10 @@ def load_training_state(
try:
# Restore optimizers + RNG + step from the standard `training_state/` folder
step, optimizers, _ = utils_load_training_state(checkpoint_dir, optimizers, None)
training_state_dir = checkpoint_dir / TRAINING_STATE_DIR
load_rng_state(training_state_dir)
step = load_training_metadata(training_state_dir)["step"]
optimizers = load_optimizer_state(optimizers, training_state_dir)
# Restore algorithm-owned tensors
if algorithm is not None:
@@ -58,6 +58,12 @@ class LeKiwiConfig(RobotConfig):
# Set to `True` for backward compatibility with previous policies/dataset
use_degrees: bool = True
# Number of extra attempts when a `sync_read` of the motors fails. Feetech buses can occasionally
# return a corrupted status packet ("Incorrect status packet!"), especially when several joints move
# at once, which otherwise aborts the control loop. Retries are immediate (no sleep) and only happen on
# failure, so the steady-state read cost is unchanged.
num_read_retries: int = 2
@dataclass
class LeKiwiHostConfig:
+13 -4
View File
@@ -347,8 +347,12 @@ class LeKiwi(Robot):
def get_observation(self) -> RobotObservation:
# Read actuators position for arm and vel for base
start = time.perf_counter()
arm_pos = self.bus.sync_read("Present_Position", self.arm_motors)
base_wheel_vel = self.bus.sync_read("Present_Velocity", self.base_motors)
arm_pos = self.bus.sync_read(
"Present_Position", self.arm_motors, num_retry=self.config.num_read_retries
)
base_wheel_vel = self.bus.sync_read(
"Present_Velocity", self.base_motors, num_retry=self.config.num_read_retries
)
base_vel = self._wheel_raw_to_body(
base_wheel_vel["base_left_wheel"],
@@ -397,8 +401,13 @@ class LeKiwi(Robot):
# Cap goal position when too far away from present position.
# /!\ Slower fps expected due to reading from the follower.
if self.config.max_relative_target is not None:
present_pos = self.bus.sync_read("Present_Position", self.arm_motors)
goal_present_pos = {key: (g_pos, present_pos[key]) for key, g_pos in arm_goal_pos.items()}
present_pos = self.bus.sync_read(
"Present_Position", self.arm_motors, num_retry=self.config.num_read_retries
)
# `arm_goal_pos` is keyed with the ".pos" suffix, `present_pos` with bare motor names.
goal_present_pos = {
key: (g_pos, present_pos[key.removesuffix(".pos")]) for key, g_pos in arm_goal_pos.items()
}
arm_safe_goal_pos = ensure_safe_goal_position(goal_present_pos, self.config.max_relative_target)
arm_goal_pos = arm_safe_goal_pos
@@ -77,11 +77,13 @@ class EEReferenceAndDelta(RobotActionProcessorStep):
_command_when_disabled: np.ndarray | None = field(default=None, init=False, repr=False)
def action(self, action: RobotAction) -> RobotAction:
observation = self.transition.get(TransitionKey.OBSERVATION).copy()
raw_observation = self.transition.get(TransitionKey.OBSERVATION)
if observation is None:
if raw_observation is None:
raise ValueError("Joints observation is require for computing robot kinematics")
observation = raw_observation.copy()
if self.use_ik_solution and "IK_solution" in self.transition.get(TransitionKey.COMPLEMENTARY_DATA):
q_raw = self.transition.get(TransitionKey.COMPLEMENTARY_DATA)["IK_solution"]
else:
@@ -311,10 +313,12 @@ class InverseKinematicsEEToJoints(RobotActionProcessorStep):
"Missing required end-effector pose components: ee.x, ee.y, ee.z, ee.wx, ee.wy, ee.wz, ee.gripper_pos must all be present in action"
)
observation = self.transition.get(TransitionKey.OBSERVATION).copy()
if observation is None:
raw_observation = self.transition.get(TransitionKey.OBSERVATION)
if raw_observation is None:
raise ValueError("Joints observation is require for computing robot kinematics")
observation = raw_observation.copy()
q_raw = np.array(
[float(v) for k, v in observation.items() if isinstance(k, str) and k.endswith(".pos")],
dtype=float,
@@ -391,13 +395,15 @@ class GripperVelocityToJoint(RobotActionProcessorStep):
discrete_gripper: bool = False
def action(self, action: RobotAction) -> RobotAction:
observation = self.transition.get(TransitionKey.OBSERVATION).copy()
raw_observation = self.transition.get(TransitionKey.OBSERVATION)
gripper_vel = action.pop("ee.gripper_vel")
if observation is None:
if raw_observation is None:
raise ValueError("Joints observation is require for computing robot kinematics")
observation = raw_observation.copy()
q_raw = np.array(
[float(v) for k, v in observation.items() if isinstance(k, str) and k.endswith(".pos")],
dtype=float,
@@ -583,10 +589,12 @@ class InverseKinematicsRLStep(ProcessorStep):
"Missing required end-effector pose components: ee.x, ee.y, ee.z, ee.wx, ee.wy, ee.wz, ee.gripper_pos must all be present in action"
)
observation = new_transition.get(TransitionKey.OBSERVATION).copy()
if observation is None:
raw_observation = new_transition.get(TransitionKey.OBSERVATION)
if raw_observation is None:
raise ValueError("Joints observation is require for computing robot kinematics")
observation = raw_observation.copy()
q_raw = np.array(
[float(v) for k, v in observation.items() if isinstance(k, str) and k.endswith(".pos")],
dtype=float,
+18 -2
View File
@@ -38,6 +38,11 @@ from .context import (
RuntimeContext,
build_rollout_context,
)
from .controller import (
LinkedEvent,
RolloutController,
RolloutEvent,
)
from .inference import (
InferenceEngine,
InferenceEngineConfig,
@@ -47,6 +52,11 @@ from .inference import (
SyncInferenceEngine,
create_inference_engine,
)
from .interactive import (
InteractiveCommand,
InteractiveSession,
parse_command,
)
from .strategies import (
BaseStrategy,
DAggerStrategy,
@@ -65,19 +75,24 @@ __all__ = [
"DAggerStrategy",
"DAggerStrategyConfig",
"DatasetContext",
"EpisodicStrategy",
"EpisodicStrategyConfig",
"HardwareContext",
"HighlightStrategy",
"HighlightStrategyConfig",
"EpisodicStrategy",
"EpisodicStrategyConfig",
"InferenceEngine",
"InferenceEngineConfig",
"InteractiveCommand",
"InteractiveSession",
"LinkedEvent",
"PolicyContext",
"ProcessorContext",
"RTCInferenceConfig",
"RTCInferenceEngine",
"RolloutConfig",
"RolloutContext",
"RolloutController",
"RolloutEvent",
"RolloutStrategy",
"RolloutStrategyConfig",
"RuntimeContext",
@@ -88,4 +103,5 @@ __all__ = [
"build_rollout_context",
"create_inference_engine",
"create_strategy",
"parse_command",
]
+36
View File
@@ -149,6 +149,15 @@ class EpisodicStrategyConfig(RolloutStrategyConfig):
# Note that leader -> follower handover is only supported when the leader has `send_feedback` capability.
smooth_leader_to_follower_handover: bool = True
# Whether to turn on or off the smooth handover behavior at the start of the
# reset phase: the leader is driven to the follower position (actuated
# teleops, see `smooth_leader_to_follower_handover`), or the follower is
# slid to the teleop pose (non-actuated teleops). Disable for clutch-style
# teleoperators (e.g. VR controllers) that re-reference at the current robot
# pose on engage: the handover is already continuous there, and the blocking
# interpolation only delays the start of the reset phase.
smooth_handover: bool = True
@RolloutStrategyConfig.register_subclass("dagger")
@dataclass
@@ -180,6 +189,14 @@ class DAggerStrategyConfig(RolloutStrategyConfig):
# Target video file size in MB for episode rotation (record_autonomous
# mode only). Defaults to DEFAULT_VIDEO_FILE_SIZE_IN_MB when None.
target_video_file_size_mb: int | None = None
# Whether to turn on or off the smooth handover behavior at phase transitions:
# the leader is driven to the follower position on pause (teleops with
# `send_feedback` capability), and the follower is slid to the teleop pose when
# a correction starts (non-actuated teleops). Disable for clutch-style
# teleoperators (e.g. VR controllers) that re-reference at the current robot
# pose on engage: the handover is already continuous there, and the blocking
# interpolation only delays the start of the correction.
smooth_handover: bool = True
input_device: str = "keyboard"
keyboard: DAggerKeyboardConfig = field(default_factory=DAggerKeyboardConfig)
pedal: DAggerPedalConfig = field(default_factory=DAggerPedalConfig)
@@ -222,6 +239,14 @@ class RolloutConfig:
# Runtime
fps: float = 30.0
duration: float = 0.0 # 0 = infinite (24/7 mode)
# Interactive session: control the rollout from stdin with chat-style
# commands (/start, /subtask <text>, /reset, /stop) while hardware and
# policy stay warm. The robot does not move until /start is received,
# `/subtask` re-instructs the policy mid-run, and logs below ERROR are
# muted while the session runs so they don't interleave with the prompt.
# Supported with --strategy.type=base (no recording) and sentry
# (continuous recording; frames are labeled with the live task).
interactive: bool = False
interpolation_multiplier: int = 1
device: str | None = None
task: str = ""
@@ -277,6 +302,17 @@ class RolloutConfig:
"Base strategy does not record data. Use sentry, highlight, or dagger for recording."
)
# Interactive mode drives strategy.run() in restartable segments and reads
# commands from stdin. Base and sentry qualify: their run() loops keep no
# per-run terminal or dataset-finalization state. The other recording
# strategies are excluded for now: they bind their own keyboard controls
# (which fight the command prompt for the terminal) and their run() loops
# finalize the dataset on exit, so they cannot be restarted.
if self.interactive and not isinstance(self.strategy, (BaseStrategyConfig, SentryStrategyConfig)):
raise ValueError(
f"--interactive=true supports --strategy.type=base or sentry (got '{self.strategy.type}')."
)
# Sentry MUST use streaming encoding to avoid disk I/O blocking the control loop
if (
isinstance(self.strategy, SentryStrategyConfig)
+43 -12
View File
@@ -22,6 +22,7 @@ and :class:`DatasetContext` — assembled into :class:`RolloutContext`.
from __future__ import annotations
import logging
from copy import copy
from dataclasses import dataclass, field
from threading import Event
from typing import TYPE_CHECKING
@@ -69,6 +70,35 @@ else:
logger = logging.getLogger(__name__)
def _wrap_predict_action_chunk_with_torch_compile(
policy: PreTrainedPolicy,
*,
backend: str,
mode: str,
) -> bool:
"""Install the JIT wrapper and report whether it was configured successfully.
``torch.compile`` compiles lazily on the first invocation, so success here
does not guarantee that backend compilation will succeed during warm-up.
"""
if not hasattr(torch, "compile"):
logger.warning("torch.compile is not available in this PyTorch build")
return False
try:
policy.predict_action_chunk = torch.compile(
policy.predict_action_chunk,
backend=backend,
mode=mode,
)
except Exception as exc:
logger.warning("Failed to configure torch.compile: %s", exc)
return False
logger.info("torch.compile configured for predict_action_chunk")
return True
def _resolve_action_key_order(
policy_action_names: list[str] | None, dataset_action_names: list[str]
) -> list[str]:
@@ -241,18 +271,19 @@ def build_rollout_context(
policy.eval()
logger.info("Policy loaded: type=%s, device=%s", policy_config.type, cfg.device)
torch_compile_active = cfg.use_torch_compile
if cfg.use_torch_compile and policy.type not in ("pi0", "pi05"):
try:
if hasattr(torch, "compile"):
compile_kwargs = {
"backend": cfg.torch_compile_backend,
"mode": cfg.torch_compile_mode,
"options": {"triton.cudagraphs": False},
}
policy.predict_action_chunk = torch.compile(policy.predict_action_chunk, **compile_kwargs)
logger.info("torch.compile applied to predict_action_chunk")
except Exception as e:
logger.warning("Failed to apply torch.compile: %s", e)
torch_compile_active = _wrap_predict_action_chunk_with_torch_compile(
policy,
backend=cfg.torch_compile_backend,
mode=cfg.torch_compile_mode,
)
if cfg.use_torch_compile and not torch_compile_active:
# RolloutConfig.__post_init__ reloads the policy configuration, so avoid
# dataclasses.replace when carrying the effective state downstream.
cfg = copy(cfg)
cfg.use_torch_compile = False
# --- 2. Robot-side processors (user-supplied or defaults) --------
if (
@@ -470,7 +501,7 @@ def build_rollout_context(
task=task_str,
fps=cfg.fps,
device=cfg.device,
use_torch_compile=cfg.use_torch_compile,
use_torch_compile=torch_compile_active,
compile_warmup_inferences=cfg.compile_warmup_inferences,
shutdown_event=shutdown_event,
)
+382
View File
@@ -0,0 +1,382 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Programmatic control of a rollout: start, pause, re-instruct, and stop a
policy while hardware and policy stay connected and warm.
:class:`RolloutController` is the embedding-friendly core of interactive
rollouts. It has no I/O of its own no stdin, no printing, no log
manipulation so it can be driven from any application code: a CLI
(:class:`lerobot.rollout.interactive.InteractiveSession` is exactly that), a
network server, a voice front-end, or a notebook.
Typical embedding::
from threading import Event, Thread
from lerobot.rollout import (
LinkedEvent,
RolloutController,
build_rollout_context,
create_strategy,
)
parent = Event() # your application's shutdown signal
ctx = build_rollout_context(cfg, LinkedEvent(parent))
strategy = create_strategy(cfg.strategy)
strategy.setup(ctx)
controller = RolloutController(strategy, ctx)
serve_thread = Thread(target=controller.serve)
serve_thread.start() # or call serve() on your main thread
controller.start() # robot starts executing the policy
controller.set_task("grab the red cube") # re-instruct mid-run
controller.reset() # stop movement, return home, stay warm
controller.stop() # end serve()
serve_thread.join()
strategy.teardown(ctx) # teardown stays with the caller
"""
from __future__ import annotations
import logging
import time
from collections.abc import Callable
from enum import Enum
from threading import Event, Lock
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .context import RolloutContext
from .strategies import RolloutStrategy
logger = logging.getLogger(__name__)
class LinkedEvent(Event):
"""A ``threading.Event`` whose ``is_set`` also reflects a parent event.
``set``/``clear`` act only on the local flag, so a controller can raise
and clear its own segment-stop requests without masking (or accidentally
re-arming) the process-wide shutdown event carried by ``parent``. Every
rollout strategy control loop polls ``ctx.runtime.shutdown_event.is_set()``,
so installing a ``LinkedEvent`` there makes the loops react both to
controller commands and to real shutdown signals.
"""
_WAIT_SLICE_S = 0.05
def __init__(self, parent: Event) -> None:
super().__init__()
self.parent = parent
def is_set(self) -> bool:
return super().is_set() or self.parent.is_set()
def wait(self, timeout: float | None = None) -> bool:
"""Wait for either the local or the parent flag.
The base ``Event.wait`` only watches the local flag, so poll in short
slices to also observe the parent. Strategy loops only call
``is_set()``; this coarse wait exists for API completeness.
"""
deadline = None if timeout is None else time.perf_counter() + timeout
while not self.is_set():
remaining = None if deadline is None else deadline - time.perf_counter()
if remaining is not None and remaining <= 0:
return False
wait_slice = self._WAIT_SLICE_S if remaining is None else min(self._WAIT_SLICE_S, remaining)
super().wait(wait_slice)
return True
class RolloutEvent(Enum):
"""Lifecycle notifications emitted by :class:`RolloutController`.
All events are emitted on the thread running :meth:`RolloutController.serve`;
callbacks must be quick and must not call back into the controller's
blocking methods.
"""
SEGMENT_STARTED = "segment_started"
"""A control-loop segment is about to run (control state freshly reset)."""
SEGMENT_ENDED = "segment_ended"
"""The segment returned on its own (e.g. ``--duration`` elapsed); the
controller is idle again and the robot is holding position."""
RESET_STARTED = "reset_started"
"""A reset is being executed: inference paused, robot about to move home."""
RESET_DONE = "reset_done"
"""The robot is back at its initial position, holding."""
RESET_SKIPPED = "reset_skipped"
"""No initial position was captured; the robot holds its current pose."""
ENGINE_FAILED = "engine_failed"
"""The inference engine hit an unrecoverable error; ``serve()`` is about
to return. Read :attr:`RolloutController.failure_traceback` for details."""
STOPPED = "stopped"
"""``serve()`` is returning (after :meth:`RolloutController.stop`, EOF of
the driving front-end, an engine failure, or a parent shutdown signal)."""
class RolloutController:
"""Drive a rollout strategy through thread-safe start/reset/stop/set_task calls.
The controller owns the outer lifecycle between ``strategy.setup(ctx)``
and ``strategy.teardown(ctx)`` (both stay with the caller): the robot is
idle until :meth:`start`, each run *segment* executes ``strategy.run(ctx)``
on the thread that called :meth:`serve` until interrupted or until the
strategy returns on its own (e.g. ``--duration`` elapsed). :meth:`reset`
pauses the inference engine, returns the robot to its initial position,
and restores the launch task, while hardware and policy stay warm.
:meth:`stop` ends :meth:`serve` so the caller can run
``strategy.teardown(ctx)``.
Requires ``ctx.runtime.shutdown_event`` to be a :class:`LinkedEvent`: the
controller sets the local flag to end a segment, and process shutdown
signals still propagate through the parent. Build the context with
``build_rollout_context(cfg, LinkedEvent(shutdown_event))``.
Thread safety: the control methods (:meth:`start`, :meth:`reset`,
:meth:`stop`, :meth:`set_task`) may be called from any thread and are
serialized by an internal lock, so calls issued in order from one thread
keep that order e.g. a ``set_task`` right after a ``reset`` is not
clobbered by the reset's task restore. Commands are last-write-wins:
``reset`` and ``stop`` cancel a still-pending ``start`` so the robot
never starts moving after the caller's most recent command asked it not
to. Events are emitted on the :meth:`serve` thread via ``on_event``.
"""
_POLL_INTERVAL_S = 0.2
def __init__(
self,
strategy: RolloutStrategy,
ctx: RolloutContext,
on_event: Callable[[RolloutEvent], None] | None = None,
) -> None:
stop_event = ctx.runtime.shutdown_event
if not isinstance(stop_event, LinkedEvent):
raise TypeError(
"RolloutController requires ctx.runtime.shutdown_event to be a LinkedEvent so "
"reset() can end a run segment without triggering process shutdown. Build the "
"rollout context with build_rollout_context(cfg, LinkedEvent(shutdown_event))."
)
self._strategy = strategy
self._ctx = ctx
self._segment_stop = stop_event
self._global_shutdown = stop_event.parent
self._on_event = on_event
# The instruction the rollout was launched with; reset() restores it.
self._initial_task = ctx.policy.inference.task
# Serializes the control methods so multi-writer task updates (e.g.
# reset()'s restore followed by a set_task()) keep their call order.
self._control_lock = Lock()
# Written by control methods (any thread), consumed by the serve loop.
self._start_requested = Event()
self._reset_requested = Event()
self._stop_requested = Event()
self._wake = Event()
self._running = Event()
# ------------------------------------------------------------------
# Introspection
# ------------------------------------------------------------------
@property
def task(self) -> str:
"""The language instruction currently conditioning inference."""
return self._ctx.policy.inference.task
@property
def initial_task(self) -> str:
"""The instruction the rollout was launched with (restored by :meth:`reset`)."""
return self._initial_task
@property
def running(self) -> bool:
"""True while a control-loop segment is executing."""
return self._running.is_set()
@property
def failed(self) -> bool:
"""True if the inference engine hit an unrecoverable error."""
return self._ctx.policy.inference.failed
@property
def failure_traceback(self) -> str | None:
"""Formatted traceback of the engine failure, when :attr:`failed` is True."""
return self._ctx.policy.inference.failure_traceback
# ------------------------------------------------------------------
# Control methods (callable from any thread)
# ------------------------------------------------------------------
def start(self) -> bool:
"""Request a control-loop segment.
Returns ``False`` when a segment is already running (the request is
ignored); ``True`` when the segment was scheduled. The segment itself
executes on the :meth:`serve` thread.
"""
with self._control_lock:
if self._running.is_set():
return False
self._start_requested.set()
self._wake.set()
return True
def reset(self) -> bool:
"""Stop movement, return the robot to its initial position, restore the launch task.
Hardware and policy stay warm; call :meth:`start` to run again.
Returns ``True`` when the task was restored to the launch task (i.e.
it had been changed), ``False`` when it was already the launch task.
"""
with self._control_lock:
# Last command wins: a start() still waiting to be serviced is
# cancelled so the robot never starts moving after the caller
# asked it not to. Flag first, segment-stop second (see the
# ordering note in _run_segment).
self._start_requested.clear()
# Restore the task here, under the control lock, rather than in
# _reset_robot (which runs later, on the serve thread) so that a
# set_task() issued right after this reset() is not silently
# reverted by a deferred restore.
restored = self._ctx.policy.inference.set_task(self._initial_task)
self._reset_requested.set()
self._segment_stop.set()
self._wake.set()
return restored
def stop(self) -> None:
"""End :meth:`serve`; the caller then runs ``strategy.teardown(ctx)``."""
with self._control_lock:
self._start_requested.clear() # last command wins, see reset()
self._stop_requested.set()
self._segment_stop.set()
self._wake.set()
def set_task(self, task: str) -> bool:
"""Change the instruction the policy follows, effective from the next inference.
Returns ``True`` when the value actually changed. Safe to call while
a segment is running: the engine applies the switch on its own
inference thread (sync backends also drop actions precomputed under
the previous instruction).
"""
with self._control_lock:
return self._ctx.policy.inference.set_task(task)
# ------------------------------------------------------------------
# Serve loop (blocks the calling thread)
# ------------------------------------------------------------------
def serve(self) -> None:
"""Service control requests until :meth:`stop`, engine failure, or parent shutdown.
Blocks the calling thread; run segments execute here. Emits
:class:`RolloutEvent` notifications through ``on_event``.
"""
try:
while not self._global_shutdown.is_set():
if self._ctx.policy.inference.failed:
self._emit(RolloutEvent.ENGINE_FAILED)
break
if self._stop_requested.is_set():
break
if self._reset_requested.is_set():
self._reset_requested.clear()
self._reset_robot()
continue
if self._start_requested.is_set():
# Consume the request and mark the segment running in one
# atomic step: start() gates on _running, so a concurrent
# start() is rejected for the entire startup sequence
# (reset_control_state, SEGMENT_STARTED emission), not just
# once strategy.run() begins — otherwise it could re-arm
# _start_requested behind the running segment and the robot
# would start again, uncommanded, when the segment ends.
with self._control_lock:
starting = self._start_requested.is_set()
if starting:
self._start_requested.clear()
self._running.set()
if starting:
self._run_segment()
continue
self._wake.wait(timeout=self._POLL_INTERVAL_S)
self._wake.clear()
finally:
self._emit(RolloutEvent.STOPPED)
def _run_segment(self) -> None:
"""Execute one ``strategy.run`` segment until interrupted or finished.
The serve loop has already set ``_running`` (under the control lock),
so this method must clear it on every exit path.
"""
engine = self._ctx.policy.inference
try:
# Clear the local flag *before* checking the request flags: control
# methods set their flag first and the segment-stop event second, so
# a reset() or stop() racing with this start() is either seen here or
# ends the freshly started loop on its first tick.
self._segment_stop.clear()
if (
self._stop_requested.is_set()
or self._reset_requested.is_set()
or self._global_shutdown.is_set()
):
return
self._strategy.reset_control_state()
self._emit(RolloutEvent.SEGMENT_STARTED)
try:
self._strategy.run(self._ctx)
finally:
engine.pause()
finally:
self._running.clear()
if engine.failed:
return # the serve loop emits ENGINE_FAILED and shuts down
if not (
self._stop_requested.is_set() or self._reset_requested.is_set() or self._global_shutdown.is_set()
):
self._emit(RolloutEvent.SEGMENT_ENDED)
def _reset_robot(self) -> None:
"""Pause inference and return the robot home (the task was restored by :meth:`reset`)."""
self._emit(RolloutEvent.RESET_STARTED)
self._ctx.policy.inference.pause()
if self._ctx.hardware.initial_position:
self._strategy.return_to_initial_position(self._ctx.hardware)
self._emit(RolloutEvent.RESET_DONE)
else:
logger.warning("No initial position captured — skipping the return move")
self._emit(RolloutEvent.RESET_SKIPPED)
def _emit(self, event: RolloutEvent) -> None:
if self._on_event is None:
return
try:
self._on_event(event)
except Exception: # a broken observer must not kill the serve loop
logger.exception("Error in RolloutController event callback for %s", event)
+66
View File
@@ -22,9 +22,13 @@ or asynchronously in a background thread (RTC).
from __future__ import annotations
import abc
import logging
from threading import Lock
import torch
logger = logging.getLogger(__name__)
class InferenceEngine(abc.ABC):
"""Abstract backend for producing actions during rollout.
@@ -47,12 +51,69 @@ class InferenceEngine(abc.ABC):
backends always compute from ``obs_frame``; async backends ignore
it (they receive observations via ``notify_observation``).
Task
----
``task`` / ``set_task`` hold the language instruction the policy is
conditioned on. ``set_task`` is safe to call from any thread (the
interactive session's ``/subtask`` command calls it from its stdin
reader); subclasses pick the new value up on their own inference
thread via :meth:`_take_task`, so no policy state is ever mutated
across threads.
Optional hooks
--------------
``notify_observation`` / ``pause`` / ``resume`` have a no-op default
so rollout strategies can invoke them unconditionally.
Subclasses must call ``super().__init__(task=...)``; the task holder
is set up there.
"""
def __init__(self, task: str = "") -> None:
self._task = task
self._task_changed = False
self._task_lock = Lock()
# ------------------------------------------------------------------
# Task (language instruction)
# ------------------------------------------------------------------
@property
def task(self) -> str:
"""The language instruction currently conditioning inference."""
with self._task_lock:
return self._task
def set_task(self, task: str) -> bool:
"""Set the instruction used from the next inference onwards.
Callable from any thread. Returns ``True`` when the value
actually changed, so callers can report no-op switches.
"""
with self._task_lock:
if task == self._task:
return False
previous, self._task = self._task, task
self._task_changed = True
logger.info("Task changed: '%s' -> '%s'", previous, task)
return True
def _take_task(self) -> tuple[str, bool]:
"""Read the task and whether it changed since the last read.
Call from the thread that runs inference: the "changed" edge is
consumed here so the backend can drop actions precomputed under
the previous instruction before using the new one.
"""
with self._task_lock:
changed, self._task_changed = self._task_changed, False
return self._task, changed
def _discard_task_change(self) -> None:
"""Drop a pending task-change edge, e.g. from ``reset`` (state is already cleared)."""
with self._task_lock:
self._task_changed = False
@abc.abstractmethod
def start(self) -> None:
"""Initialise the backend."""
@@ -87,3 +148,8 @@ class InferenceEngine(abc.ABC):
def failed(self) -> bool:
"""True if an unrecoverable error occurred in the backend."""
return False
@property
def failure_traceback(self) -> str | None:
"""Formatted traceback of the unrecoverable error, when ``failed`` is True."""
return None
+55 -6
View File
@@ -124,13 +124,13 @@ class RTCInferenceEngine(InferenceEngine):
rtc_queue_threshold: int = 30,
shutdown_event: Event | None = None,
) -> None:
super().__init__(task=task)
self._policy = policy
self._preprocessor = preprocessor
self._postprocessor = postprocessor
self._robot = robot_wrapper
self._rtc_config = rtc_config
self._hw_features = hw_features
self._task = task
self._fps = fps
self._device = device or "cpu"
self._use_torch_compile = use_torch_compile
@@ -140,10 +140,14 @@ class RTCInferenceEngine(InferenceEngine):
self._action_queue: ActionQueue | None = None
self._obs_holder: dict[str, Any] = {}
self._obs_lock = Lock()
# Bumped by reset() (under _obs_lock) so chunks whose inference started
# before a reset are discarded instead of merged into the fresh queue.
self._reset_epoch = 0
self._policy_active = Event()
self._compile_warmup_done = Event()
self._shutdown_event = Event()
self._rtc_error = Event()
self._failure_traceback: str | None = None
self._global_shutdown_event = shutdown_event
self._rtc_thread: Thread | None = None
@@ -190,6 +194,15 @@ class RTCInferenceEngine(InferenceEngine):
"""True if the RTC background thread exited due to an unrecoverable error."""
return self._rtc_error.is_set()
@property
def failure_traceback(self) -> str | None:
"""Traceback captured when the RTC thread died (see ``failed``).
Kept on the engine so consumers that mute console logging (the
interactive session) can still surface the fatal error.
"""
return self._failure_traceback
@property
def action_queue(self) -> ActionQueue | None:
"""The shared action queue between the RTC thread and the main loop."""
@@ -235,13 +248,29 @@ class RTCInferenceEngine(InferenceEngine):
self._policy_active.set()
def reset(self) -> None:
"""Reset the policy, processors, and action queue."""
"""Reset the policy, processors, and action queue.
Call while the engine is paused (both DAgger transitions and the
interactive session do): the RTC thread may still be finishing an
inference started before the pause, so ``reset`` also drops the last
published observation it can be arbitrarily stale by the time the
engine resumes (e.g. the robot was returned to its initial position
in the meantime), and a chunk computed from it would jerk the robot
toward the old pose and bumps the reset epoch so any in-flight
chunk is discarded instead of merged into the cleared queue.
"""
logger.info("Resetting RTC inference state (policy + processors + queue)")
self._policy.reset()
self._preprocessor.reset()
self._postprocessor.reset()
if self._action_queue is not None:
self._action_queue.clear()
with self._obs_lock:
self._obs_holder["obs"] = None
self._reset_epoch += 1
# The queue was just cleared, so a pending task change has nothing
# stale left to blend against.
self._discard_task_change()
# ------------------------------------------------------------------
# Action production (called from main thread)
@@ -281,6 +310,7 @@ class RTCInferenceEngine(InferenceEngine):
queue = self._action_queue
with self._obs_lock:
obs = self._obs_holder.get("obs")
epoch_before = self._reset_epoch
if queue is None or obs is None:
time.sleep(_RTC_IDLE_SLEEP_S)
continue
@@ -294,11 +324,24 @@ class RTCInferenceEngine(InferenceEngine):
latency = latency_tracker.max()
delay = math.ceil(latency / time_per_chunk) if latency else 0
task, task_changed = self._take_task()
if task_changed:
# No queue flush on purpose: dropping the queued
# actions would leave the robot without commands for
# a full inference latency. With RTC blending on
# (the default) this chunk — already conditioned on
# the new instruction — is merged over the previous
# chunk's leftover prefix, so the switch lands within
# one inference and the transition stays continuous.
# With blending disabled the queue drains first, so
# it lands up to one chunk later.
logger.info("Task changed to '%s' — applied from this chunk on", task)
obs_batch = build_dataset_frame(self._hw_features, obs, prefix="observation")
obs_batch = prepare_observation_for_inference(
obs_batch, policy_device, self._task, self._robot.robot_type
obs_batch, policy_device, task, self._robot.robot_type
)
obs_batch["task"] = [self._task]
obs_batch["task"] = [task]
preprocessed = self._preprocessor(obs_batch)
@@ -339,7 +382,12 @@ class RTCInferenceEngine(InferenceEngine):
else:
latency_tracker.add(new_latency)
queue.merge(original, processed, new_delay, idx_before)
with self._obs_lock:
epoch_unchanged = epoch_before == self._reset_epoch
if epoch_unchanged:
queue.merge(original, processed, new_delay, idx_before)
else:
logger.info("Discarding action chunk computed before an engine reset")
if (
is_warmup
@@ -368,8 +416,9 @@ class RTCInferenceEngine(InferenceEngine):
time.sleep(_RTC_IDLE_SLEEP_S)
except Exception as e:
self._failure_traceback = traceback.format_exc()
logger.error("Fatal error in RTC thread: %s", e)
logger.error(traceback.format_exc())
logger.error(self._failure_traceback)
self._rtc_error.set()
# Unblock any warmup waiters so the main loop doesn't spin forever
self._compile_warmup_done.set()
+17 -4
View File
@@ -65,12 +65,12 @@ class SyncInferenceEngine(InferenceEngine):
device: str | None,
robot_type: str,
) -> None:
super().__init__(task=task)
self._policy = policy
self._preprocessor = preprocessor
self._postprocessor = postprocessor
self._dataset_features = dataset_features
self._ordered_action_keys = ordered_action_keys
self._task = task
self._device = torch.device(device or "cpu")
self._robot_type = robot_type
logger.info(
@@ -93,6 +93,9 @@ class SyncInferenceEngine(InferenceEngine):
self._policy.reset()
self._preprocessor.reset()
self._postprocessor.reset()
# The policy was just reset, so a pending task change has nothing
# stale left to flush.
self._discard_task_change()
def get_action(self, obs_frame: dict | None) -> torch.Tensor | None:
"""Run the full inference pipeline on ``obs_frame`` and return an action tensor."""
@@ -107,10 +110,20 @@ class SyncInferenceEngine(InferenceEngine):
if self._device.type == "cuda" and self._policy.config.use_amp
else nullcontext()
)
task, task_changed = self._take_task()
with torch.inference_mode(), autocast_ctx:
observation = prepare_observation_for_inference(
observation, self._device, self._task, self._robot_type
)
if task_changed:
# Chunking policies serve actions from an internal queue filled
# under the previous instruction (up to chunk_size ticks of stale
# behavior), so drop them and let the new instruction take effect
# on this very tick. Deliberately narrower than ``policy.reset``:
# observation history and other episode state are kept, so a
# policy that conditions on them (and one that ignores the task
# entirely) sees no discontinuity. Safe to mutate here — this is
# the thread that calls ``select_action``.
logger.info("Task changed to '%s' — dropping precomputed actions", task)
self._policy.drop_queued_actions()
observation = prepare_observation_for_inference(observation, self._device, task, self._robot_type)
observation = self._preprocessor(observation)
action = self._policy.select_action(observation)
action = self._postprocessor(action)
+304
View File
@@ -0,0 +1,304 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Interactive rollout session: chat-style stdin commands for ``lerobot-rollout``.
Enabled with ``--interactive=true``, this module lets the operator control a
rollout from the terminal while hardware and policy stay connected and warm:
/start start (or restart) the policy control loop
/subtask <text> change the instruction the policy follows, mid-run
/reset stop movement, return the robot to its initial position,
and restore the instruction passed on the command line
/stop end the session and run the normal shutdown routines
/help show the available commands
This module is only the CLI front-end: stdin reading
(:class:`lerobot.utils.stdin_input.StdinCommandListener`), command parsing,
terminal output, and log muting. All control logic lives in
:class:`lerobot.rollout.controller.RolloutController`, which is the public
API for driving a rollout programmatically (from an application, a network
server, a notebook, ...) without any of this module's terminal I/O.
Threading model: a daemon stdin-listener thread parses lines and calls the
controller's thread-safe methods (``start``/``reset``/``stop``/``set_task``);
it never touches hardware or policy state. ``RolloutController.serve()``
runs on the main thread and executes ``strategy.run(ctx)`` in *segments*,
ended through the session's :class:`LinkedEvent` (installed as
``ctx.runtime.shutdown_event``). Real shutdown signals (SIGINT/SIGTERM)
propagate through the linked event's parent, so Ctrl-C behaves exactly as in
non-interactive runs.
While the session runs, log records below ERROR and Python warnings are
suppressed process-wide (via ``logging.disable``) so routine system output
does not interleave with the chat prompt; ERROR and CRITICAL records still
reach the console, and a fatal inference-engine error is additionally
reported with its captured traceback. Normal logging resumes when the
session ends (so teardown logs are visible). Run without ``--interactive``
to see the full live log output.
The command table is intentionally a name (handler, argument hint, help)
mapping so further commands (``/ask`` and the rest of the language-runtime
work in PR #4183/#4234) can be registered without restructuring the parser,
the help output, or the session loop.
"""
from __future__ import annotations
import contextlib
import logging
import warnings
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from typing import IO, TYPE_CHECKING
from lerobot.utils.stdin_input import StdinCommandListener
from lerobot.utils.utils import log_say
from .controller import RolloutController, RolloutEvent
if TYPE_CHECKING:
from .context import RolloutContext
from .strategies import RolloutStrategy
logger = logging.getLogger(__name__)
_BANNER_RULE = "" * 60
@contextlib.contextmanager
def _mute_system_output() -> Iterator[None]:
"""Suppress log records below ERROR and Python warnings, process-wide.
System logs (policy, robot, control loop) contend with the chat prompt
for the terminal. ``logging.disable`` gates records before any handler
dispatch, which covers non-propagating library loggers (``transformers``,
``datasets``) and loggers created mid-session alike; ERROR and CRITICAL
records still get through, so failures stay visible. The gate applies to
every handler including file handlers, which therefore also miss
INFO/WARNING records for the duration. Python warnings bypass logging
entirely and are silenced separately.
"""
previous_disable = logging.root.manager.disable
saved_warning_filters = warnings.filters[:]
logging.disable(logging.WARNING)
warnings.simplefilter("ignore")
try:
yield
finally:
logging.disable(previous_disable)
warnings.filters[:] = saved_warning_filters
@dataclass(frozen=True)
class InteractiveCommand:
"""A parsed ``/name args`` line from the interactive prompt."""
name: str
args: str = ""
def _format_task(task: str) -> str:
"""Render a task string for the operator, naming the empty case explicitly."""
return repr(task) if task else "(none — set one with /subtask <text>)"
def _strip_quotes(text: str) -> str:
"""Drop one layer of matching surrounding quotes from a command argument."""
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
return text[1:-1]
return text
def parse_command(line: str) -> InteractiveCommand | None:
"""Parse an input line into an :class:`InteractiveCommand`.
Commands are ``/name`` optionally followed by free-text arguments (e.g.
``/subtask grab the red cube``). Returns ``None`` for lines that are not
commands (no leading ``/`` or a bare ``/``).
"""
line = line.strip()
if not line.startswith("/"):
return None
head, *rest = line.split(maxsplit=1)
name = head[1:].lower()
if not name:
return None
return InteractiveCommand(name=name, args=rest[0].strip() if rest else "")
class InteractiveSession:
"""Drive a rollout from chat-style stdin commands.
A thin terminal front-end over :class:`RolloutController`: the stdin
listener parses lines into commands, each command calls one of the
controller's thread-safe methods, and controller events are rendered
back as terminal output. The controller is exposed as
:attr:`controller` for tests and embedders.
Commands are last-write-wins: ``/reset`` and ``/stop`` cancel a pending
``/start`` so the robot never starts moving after the operator's final
command asked it not to. End-of-file on the command stream stops the
session (a closed stdin means there is no way left to command the
robot), so piped scripts must keep stdin open for the intended session
duration, e.g. ``(printf '/start\\n'; sleep 60; printf '/stop\\n') |
lerobot-rollout ... --interactive=true``. The session works over SSH
and in headless setups it reads the terminal (or pipe) directly and
needs no display server.
"""
def __init__(
self,
strategy: RolloutStrategy,
ctx: RolloutContext,
input_stream: IO[str] | None = None,
) -> None:
self.controller = RolloutController(strategy, ctx, on_event=self._on_event)
self._play_sounds = ctx.runtime.cfg.play_sounds
self._listener = StdinCommandListener(self._handle_line, on_eof=self._handle_eof, stream=input_stream)
# name -> (handler, argument hint, help line); /help and the banner
# render from this table, so future commands (e.g. /ask) stay
# documented for free.
self._commands: dict[str, tuple[Callable[[InteractiveCommand], None], str, str]] = {
"start": (self._cmd_start, "", "start (or restart) the policy control loop"),
"subtask": (self._cmd_subtask, " <text>", "set the instruction the policy follows"),
"reset": (self._cmd_reset, "", "stop movement, return to initial position, restore the task"),
"stop": (self._cmd_stop, "", "end the session and shut down"),
"help": (self._cmd_help, "", "show this help"),
}
def run(self) -> None:
"""Run the session until ``/stop``, EOF, engine failure, or a shutdown signal."""
try:
with _mute_system_output():
self._print(self._render_banner())
self._listener.start()
try:
self.controller.serve()
finally:
self._listener.stop()
finally:
# Outside the muting context so the closing announcement and any
# teardown logs are visible again.
log_say("Interactive session ended", self._play_sounds)
# ------------------------------------------------------------------
# Controller events (fired on the serve thread) -> terminal output
# ------------------------------------------------------------------
def _on_event(self, event: RolloutEvent) -> None:
if event is RolloutEvent.SEGMENT_STARTED:
log_say("Starting rollout", self._play_sounds)
self._print(
f"Rollout running — task {_format_task(self.controller.task)}. "
"/subtask <text> to change it, /reset to return to initial position, /stop to shut down."
)
elif event is RolloutEvent.SEGMENT_ENDED:
self._print(
"Rollout run ended on its own (duration reached). Robot is holding position — "
"/start to run again, /reset to return to initial position, /stop to shut down."
)
elif event is RolloutEvent.RESET_STARTED:
log_say("Resetting robot to initial position", self._play_sounds)
self._print("Resetting — returning the robot to its initial position...")
elif event is RolloutEvent.RESET_DONE:
self._print("Robot reset — holding at initial position. /start to run.")
elif event is RolloutEvent.RESET_SKIPPED:
self._print("Robot paused — no initial position captured, holding current pose. /start to run.")
elif event is RolloutEvent.ENGINE_FAILED:
self._report_engine_failure()
def _report_engine_failure(self) -> None:
"""Surface a fatal engine error despite the muted console logging."""
self._print("Inference engine failed — shutting down.")
failure_traceback = self.controller.failure_traceback
if failure_traceback:
self._print(failure_traceback)
else:
self._print("Re-run without --interactive=true to see the error output.")
# ------------------------------------------------------------------
# Command handlers (called from the listener thread; the controller's
# methods are thread-safe and only publish state)
# ------------------------------------------------------------------
def _handle_line(self, line: str) -> None:
cmd = parse_command(line)
if cmd is None:
self._print("Input not recognized — commands start with '/'. Type /help for the list.")
return
entry = self._commands.get(cmd.name)
if entry is None:
self._print(f"Unknown command '/{cmd.name}'. Type /help for the list.")
return
handler = entry[0]
handler(cmd)
def _handle_eof(self) -> None:
self._print("Input stream closed — stopping the session.")
self.controller.stop()
def _cmd_start(self, cmd: InteractiveCommand) -> None:
if not self.controller.start():
self._print("Already running — /reset to pause first, or /stop to shut down.")
def _cmd_subtask(self, cmd: InteractiveCommand) -> None:
if not cmd.args:
self._print(f"Current task: {_format_task(self.controller.task)}")
return
task = _strip_quotes(cmd.args)
previous = self.controller.task
if self.controller.set_task(task):
self._print(
f"Task: {_format_task(previous)}{_format_task(task)} "
"(applies from the next policy inference)"
)
else:
self._print(f"Task unchanged: {_format_task(task)}")
def _cmd_reset(self, cmd: InteractiveCommand) -> None:
if self.controller.reset():
self._print(f"Task restored to {_format_task(self.controller.initial_task)}")
def _cmd_stop(self, cmd: InteractiveCommand) -> None:
self.controller.stop()
def _cmd_help(self, cmd: InteractiveCommand) -> None:
self._print(self._render_help())
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _render_help(self) -> str:
usages = {name: f"/{name}{entry[1]}" for name, entry in self._commands.items()}
width = max(len(usage) for usage in usages.values())
lines = [f" {usages[name]:<{width}} {entry[2]}" for name, entry in self._commands.items()]
return "Available commands:\n" + "\n".join(lines)
def _render_banner(self) -> str:
return (
f"{_BANNER_RULE}\n"
"Interactive rollout session — the robot will NOT move until you type /start.\n"
f"Task: {_format_task(self.controller.initial_task)}\n"
f"{self._render_help()}\n"
"System logs and warnings are muted during the session (errors still show).\n"
f"{_BANNER_RULE}"
)
@staticmethod
def _print(message: str) -> None:
"""User-facing chat output; logging stays on stderr, replies on stdout."""
print(message, flush=True)
+17 -4
View File
@@ -63,12 +63,25 @@ class RolloutStrategy(abc.ABC):
self._interpolator = ActionInterpolator(multiplier=ctx.runtime.cfg.interpolation_multiplier)
self._engine = ctx.policy.inference
logger.info("Starting inference engine...")
self._engine.reset()
self.reset_control_state()
self._engine.start()
self._warmup_flushed = False
self._cached_obs_processed = None
logger.info("Inference engine started")
def reset_control_state(self) -> None:
"""Clear episode-scoped control state so a paused session can restart cleanly.
Resets the inference engine (policy hidden state, action queues), the
action interpolator, and the cached processed observation. Used by the
interactive session between run segments; only call while the control
loop is not running.
"""
if self._engine is not None:
self._engine.reset()
if self._interpolator is not None:
self._interpolator.reset()
self._cached_obs_processed = None
def _process_observation_and_notify(self, processors: ProcessorContext, obs_raw: dict) -> dict:
"""Run the observation processor and notify the engine — throttled to policy ticks.
@@ -125,7 +138,7 @@ class RolloutStrategy(abc.ABC):
if robot.is_connected:
if return_to_initial_position and hw.initial_position:
logger.info("Returning robot to initial position before shutdown...")
self._return_to_initial_position(hw)
self.return_to_initial_position(hw)
elif not return_to_initial_position:
logger.info(
"Skipping return-to-initial-position (disabled by config); leaving robot in final pose."
@@ -138,7 +151,7 @@ class RolloutStrategy(abc.ABC):
teleop.disconnect()
@staticmethod
def _return_to_initial_position(hw: HardwareContext, duration_s: float = 3.0, fps: int = 50) -> None:
def return_to_initial_position(hw: HardwareContext, duration_s: float = 3.0, fps: int = 50) -> None:
"""Smoothly interpolate the robot back to its initial position."""
robot = hw.robot_wrapper
target = hw.initial_position
+11 -3
View File
@@ -623,8 +623,8 @@ class DAggerStrategy(RolloutStrategy):
# State-machine transition side-effects
# ------------------------------------------------------------------
@staticmethod
def _apply_transition(
self,
old_phase: DAggerPhase,
new_phase: DAggerPhase,
engine,
@@ -634,6 +634,10 @@ class DAggerStrategy(RolloutStrategy):
) -> None:
"""Execute side-effects for a validated phase transition, including smooth handovers.
The smooth handovers below can be disabled with
``--strategy.smooth_handover=false`` (useful for clutch-style teleops
that re-reference at the current robot pose on engage).
AUTONOMOUS -> PAUSED (actuated teleop):
Pause the engine, then drive the leader arm to the follower's last
commanded position so the operator takes over without a jerk.
@@ -657,7 +661,7 @@ class DAggerStrategy(RolloutStrategy):
logger.info("Pausing engine - robot holds position")
engine.pause()
if teleop_supports_feedback(teleop) and prev_action is not None:
if self.config.smooth_handover and teleop_supports_feedback(teleop) and prev_action is not None:
# TODO(Maxime): prev_action is in robot action key space (output of robot_action_processor).
# send_feedback expects teleop feedback key space. For homogeneous setups (e.g. SO-101
# leader + SO-101 follower) the keys are identical so this works. If the processor pipeline
@@ -668,7 +672,11 @@ class DAggerStrategy(RolloutStrategy):
elif old_phase == DAggerPhase.PAUSED and new_phase == DAggerPhase.CORRECTING:
logger.info("Entering correction mode - human teleop control")
if not teleop_supports_feedback(teleop) and prev_action is not None:
if (
self.config.smooth_handover
and not teleop_supports_feedback(teleop)
and prev_action is not None
):
logger.info("Smooth handover: sliding follower to teleop position")
obs = robot.get_observation()
teleop_action = teleop.get_action()
+21 -17
View File
@@ -143,25 +143,29 @@ class EpisodicStrategy(RolloutStrategy):
# position so the operator takes over without fighting the arm.
# For non-actuated teleops: slide the follower to the teleop's current
# pose instead, since the leader cannot be driven.
obs = robot.get_observation()
current_pos = {k: v for k, v in obs.items() if k.endswith(".pos")}
if (
teleop_supports_feedback(teleop)
and self.config.smooth_leader_to_follower_handover
):
logger.info("Smooth handover: moving leader arm to follower position")
teleop_smooth_move_to(teleop, current_pos, duration_s=2)
teleop.disable_torque()
else:
logger.info("Smooth handover: sliding follower to teleop position")
teleop_action = teleop.get_action()
processed = ctx.processors.teleop_action_processor((teleop_action, obs))
target = ctx.processors.robot_action_processor((processed, obs))
follower_smooth_move_to(robot, current_pos, target, duration_s=1)
# Disabled entirely with --strategy.smooth_handover=false (useful for
# clutch-style teleops that re-reference at the current robot pose on
# engage).
if self.config.smooth_handover:
obs = robot.get_observation()
current_pos = {k: v for k, v in obs.items() if k.endswith(".pos")}
if (
teleop_supports_feedback(teleop)
and self.config.smooth_leader_to_follower_handover
):
logger.info("Smooth handover: moving leader arm to follower position")
teleop_smooth_move_to(teleop, current_pos, duration_s=2)
teleop.disable_torque()
else:
logger.info("Smooth handover: sliding follower to teleop position")
teleop_action = teleop.get_action()
processed = ctx.processors.teleop_action_processor((teleop_action, obs))
target = ctx.processors.robot_action_processor((processed, obs))
follower_smooth_move_to(robot, current_pos, target, duration_s=1)
elif self.config.reset_to_initial_position:
# No teleop: return the robot to its startup position.
self._return_to_initial_position(hw=ctx.hardware, duration_s=1)
self.return_to_initial_position(hw=ctx.hardware, duration_s=1)
self._reset_loop(
ctx=ctx,
@@ -183,7 +187,7 @@ class EpisodicStrategy(RolloutStrategy):
# returns to its initial joint positions captured at startup
if not teleop and self.config.reset_to_initial_position:
self._return_to_initial_position(hw=ctx.hardware, duration_s=1)
self.return_to_initial_position(hw=ctx.hardware, duration_s=1)
continue
+89 -63
View File
@@ -22,7 +22,6 @@ import time
from concurrent.futures import Future, ThreadPoolExecutor
from threading import Event, Lock
from lerobot.datasets import VideoEncodingManager
from lerobot.datasets.utils import DEFAULT_VIDEO_FILE_SIZE_IN_MB
from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame
@@ -55,6 +54,14 @@ class SentryStrategy(RolloutStrategy):
Requires ``streaming_encoding=True`` (enforced in config validation)
to prevent disk I/O from blocking the control loop.
``run()`` is restartable: each call records complete episodes plus one
final partial episode, and the dataset is only finalized in
``teardown()`` this is what lets ``--interactive=true`` drive sentry
in start/reset/start segments while the dataset stays open. Frames are
labeled with the inference engine's *live* task, so a mid-run
``/subtask`` changes both the policy conditioning and the recorded
label from the same frame onwards.
"""
config: SentryStrategyConfig
@@ -70,6 +77,9 @@ class SentryStrategy(RolloutStrategy):
"""Initialise the inference engine and background push executor."""
self._init_engine(ctx)
self._push_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="sentry-push")
# Instance state (not run()-local) so the upload cadence survives
# interactive run segments.
self._episodes_since_push = 0
target_mb = self.config.target_video_file_size_mb or DEFAULT_VIDEO_FILE_SIZE_IN_MB
self._episode_duration_s = estimate_max_episode_seconds(
ctx.data.dataset_features, ctx.runtime.cfg.fps, target_size_mb=target_mb
@@ -97,79 +107,95 @@ class SentryStrategy(RolloutStrategy):
start_time = time.perf_counter()
episode_start = time.perf_counter()
episodes_since_push = 0
task_str = cfg.dataset.single_task if cfg.dataset else cfg.task
logger.info("Sentry recording started (episode_duration=%.0fs)", episode_duration_s)
with VideoEncodingManager(dataset):
try:
while not ctx.runtime.shutdown_event.is_set():
loop_start = time.perf_counter()
# No dataset finalization here: run() must be restartable (interactive
# segments), so the dataset stays open until teardown() finalizes it.
try:
while not ctx.runtime.shutdown_event.is_set():
loop_start = time.perf_counter()
if cfg.duration > 0 and (time.perf_counter() - start_time) >= cfg.duration:
logger.info("Duration limit reached (%.0fs)", cfg.duration)
break
if cfg.duration > 0 and (time.perf_counter() - start_time) >= cfg.duration:
logger.info("Duration limit reached (%.0fs)", cfg.duration)
break
obs = robot.get_observation()
obs_processed = self._process_observation_and_notify(ctx.processors, obs)
obs = robot.get_observation()
obs_processed = self._process_observation_and_notify(ctx.processors, obs)
if self._handle_warmup(cfg.use_torch_compile, loop_start, control_interval):
continue
if self._handle_warmup(cfg.use_torch_compile, loop_start, control_interval):
continue
action_dict = send_next_action(obs_processed, obs, ctx, interpolator)
action_dict = send_next_action(obs_processed, obs, ctx, interpolator)
if action_dict is not None:
self._log_telemetry(obs_processed, action_dict, ctx.runtime)
obs_frame = build_dataset_frame(features, obs_processed, prefix=OBS_STR)
action_frame = build_dataset_frame(features, action_dict, prefix=ACTION)
frame = {**obs_frame, **action_frame, "task": task_str}
# ``add_frame`` writes to the in-progress episode buffer; the
# background pusher only ever touches *finalised* episode
# artifacts on disk. The two operate on disjoint state, so
# ``add_frame`` does not need ``_episode_lock``.
dataset.add_frame(frame)
if action_dict is not None:
self._log_telemetry(obs_processed, action_dict, ctx.runtime)
obs_frame = build_dataset_frame(features, obs_processed, prefix=OBS_STR)
action_frame = build_dataset_frame(features, action_dict, prefix=ACTION)
# The task is read live from the engine (not snapshotted from
# config) so an interactive /subtask relabels frames from the
# moment it re-instructs the policy; the writer stores a task
# per frame. At launch the engine holds the configured task.
frame = {**obs_frame, **action_frame, "task": engine.task}
# ``add_frame`` writes to the in-progress episode buffer; the
# background pusher only ever touches *finalised* episode
# artifacts on disk. The two operate on disjoint state, so
# ``add_frame`` does not need ``_episode_lock``.
dataset.add_frame(frame)
# Episode rotation derived from video file-size target.
# The duration is a conservative estimate so the actual
# video has crossed DEFAULT_VIDEO_FILE_SIZE_IN_MB by now,
# keeping push_to_hub efficient (uploads complete files).
elapsed = time.perf_counter() - episode_start
if elapsed >= episode_duration_s:
# ``save_episode`` finalises the in-progress episode and
# flushes it to disk; ``_episode_lock`` serialises this with
# ``push_to_hub`` (run in the background executor) so the
# pusher never reads a half-written episode.
with self._episode_lock:
dataset.save_episode()
episodes_since_push += 1
self._needs_push.set()
logger.info(
"Episode saved (total: %d, elapsed: %.1fs)",
dataset.num_episodes,
elapsed,
)
log_say(f"Episode {dataset.num_episodes} saved", play_sounds)
if episodes_since_push >= self.config.upload_every_n_episodes:
self._background_push(dataset, cfg)
episodes_since_push = 0
episode_start = time.perf_counter()
dt = time.perf_counter() - loop_start
if (sleep_t := control_interval - dt) > 0:
precise_sleep(sleep_t)
else:
logger.warning(
f"Record loop is running slower ({1 / dt:.1f} Hz) than the target FPS ({cfg.fps} Hz). Dataset frames might be dropped and robot control might be unstable. Common causes are: 1) Camera FPS not keeping up 2) Policy inference taking too long 3) CPU starvation"
)
finally:
logger.info("Sentry control loop ended — saving final episode")
with contextlib.suppress(Exception):
# Episode rotation derived from video file-size target.
# The duration is a conservative estimate so the actual
# video has crossed DEFAULT_VIDEO_FILE_SIZE_IN_MB by now,
# keeping push_to_hub efficient (uploads complete files).
elapsed = time.perf_counter() - episode_start
if elapsed >= episode_duration_s:
# ``save_episode`` finalises the in-progress episode and
# flushes it to disk; ``_episode_lock`` serialises this with
# ``push_to_hub`` (run in the background executor) so the
# pusher never reads a half-written episode.
with self._episode_lock:
dataset.save_episode()
self._episodes_since_push += 1
self._needs_push.set()
logger.info(
"Episode saved (total: %d, elapsed: %.1fs)",
dataset.num_episodes,
elapsed,
)
log_say(f"Episode {dataset.num_episodes} saved", play_sounds)
if self._episodes_since_push >= self.config.upload_every_n_episodes:
self._background_push(dataset, cfg)
self._episodes_since_push = 0
episode_start = time.perf_counter()
dt = time.perf_counter() - loop_start
if (sleep_t := control_interval - dt) > 0:
precise_sleep(sleep_t)
else:
logger.warning(
f"Record loop is running slower ({1 / dt:.1f} Hz) than the target FPS ({cfg.fps} Hz). Dataset frames might be dropped and robot control might be unstable. Common causes are: 1) Camera FPS not keeping up 2) Policy inference taking too long 3) CPU starvation"
)
finally:
logger.info("Sentry control loop ended — saving final episode")
try:
with self._episode_lock:
dataset.save_episode()
self._needs_push.set()
except Exception:
# The tail episode could not be committed (nothing was
# recorded, or the save failed mid-write). Drop the in-flight
# streaming encode so teardown()'s finalize does not flush a
# half-written video, and discard the episode buffer: a failed
# save_episode leaves it half-mutated, which would crash the
# first add_frame of a restarted segment. add_frame recreates
# a fresh buffer from None.
logger.warning("Tail episode was not saved — discarding it", exc_info=True)
if dataset.writer is not None:
with contextlib.suppress(Exception):
dataset.writer.cancel_pending_videos()
dataset.writer.episode_buffer = None
def teardown(self, ctx: RolloutContext) -> None:
"""Flush pending pushes, finalise the dataset, and disconnect hardware."""
@@ -25,6 +25,11 @@ quantile statistics (q01, q10, q50, q90, q99) in their metadata. This script:
3. If missing, computes quantile statistics for all features
4. Updates the dataset metadata with the new quantile statistics
Statistics are accumulated into a single running histogram per feature across
all episodes rather than aggregating per-episode quantile summaries. The
resulting quantiles are histogram approximations, subject to discretization and
range-rebinning error; image/video frames are sampled by default.
Usage:
```bash
@@ -34,9 +39,7 @@ python src/lerobot/scripts/augment_dataset_quantile_stats.py \
"""
import argparse
import concurrent.futures
import logging
import os
from pathlib import Path
import numpy as np
@@ -49,11 +52,10 @@ from lerobot.datasets import (
CODEBASE_VERSION,
DEFAULT_QUANTILES,
LeRobotDataset,
aggregate_stats,
get_feature_stats,
write_stats,
)
from lerobot.datasets.compute_stats import sample_indices
from lerobot.datasets.compute_stats import RunningQuantileStats, sample_indices
from lerobot.utils.utils import init_logging
@@ -79,20 +81,25 @@ def has_quantile_stats(stats: dict[str, dict] | None, quantile_list_keys: list[s
return False
def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampling: bool = True) -> dict:
"""Process a single episode and return its statistics.
def collect_episode_arrays(
dataset: LeRobotDataset,
episode_idx: int,
use_sampling: bool = True,
skip_images: bool = False,
) -> dict[str, tuple[np.ndarray, int]]:
"""Collect one episode's frames per feature, flattened to (num_samples, dim).
Args:
dataset: The LeRobot dataset
episode_idx: Index of the episode to process
use_sampling: If True, sub-sample image/video frames per episode to bound
memory. If False, use every frame (exact, higher memory).
episode_idx: Index of the episode to read
use_sampling: If True, sub-sample image/video frames to bound memory.
If False, use every frame (higher memory).
skip_images: If True, skip image/video features entirely.
Returns:
Dictionary containing episode statistics
Mapping of feature name to that episode's values and the number of frames
they came from (which differs from the row count for image features).
"""
logging.info(f"Computing stats for episode {episode_idx}")
start_idx = dataset.meta.episodes[episode_idx]["dataset_from_index"]
end_idx = dataset.meta.episodes[episode_idx]["dataset_to_index"]
@@ -102,7 +109,9 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampli
# numeric columns are cheap, so read them in full (exact).
image_keys = [k for k in dataset.features if dataset.features[k]["dtype"] in ("image", "video")]
numeric_keys = [
k for k in dataset.features if dataset.features[k]["dtype"] not in ("image", "video", "string")
k
for k in dataset.features
if dataset.features[k]["dtype"] not in ("image", "video", "string", "language")
]
collected_data: dict[str, list] = {}
@@ -114,7 +123,7 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampli
collected_data[key] = [torch.as_tensor(v) for v in numeric_cols[key]]
# Image/video features: decode only a sampled subset of frames.
if image_keys:
if image_keys and not skip_images:
sampled_offsets = sample_indices(episode_len) if use_sampling else list(range(episode_len))
for offset in sampled_offsets:
item = dataset[start_idx + offset]
@@ -122,87 +131,82 @@ def process_single_episode(dataset: LeRobotDataset, episode_idx: int, use_sampli
if key in item:
collected_data.setdefault(key, []).append(item[key])
ep_stats = {}
episode_arrays: dict[str, tuple[np.ndarray, int]] = {}
for key, data_list in collected_data.items():
if dataset.features[key]["dtype"] == "string":
continue
data = torch.stack(data_list).cpu().numpy()
if dataset.features[key]["dtype"] in ["image", "video"]:
if data.dtype == np.uint8:
data = data.astype(np.float32) / 255.0
axes_to_reduce = (0, 2, 3)
keepdims = True
# (N, C, H, W) -> (N * H * W, C) so quantiles are computed per channel.
channels = data.shape[1]
values = data.transpose(0, 2, 3, 1).reshape(-1, channels)
else:
axes_to_reduce = 0
keepdims = data.ndim == 1
values = data.reshape(-1, data.shape[-1]) if data.ndim > 1 else data.reshape(-1, 1)
episode_arrays[key] = (values, len(data_list))
ep_stats[key] = get_feature_stats(
data, axis=axes_to_reduce, keepdims=keepdims, quantile_list=DEFAULT_QUANTILES
)
if dataset.features[key]["dtype"] in ["image", "video"]:
ep_stats[key] = {
k: v if k == "count" else np.squeeze(v, axis=0) for k, v in ep_stats[key].items()
}
return ep_stats
return episode_arrays
def compute_quantile_stats_for_dataset(dataset: LeRobotDataset, use_sampling: bool = True) -> dict[str, dict]:
"""Compute quantile statistics for all episodes in the dataset.
def compute_quantile_stats_for_dataset(
dataset: LeRobotDataset,
use_sampling: bool = True,
skip_images: bool = False,
) -> dict[str, dict]:
"""Compute whole-dataset statistics with one running histogram per feature.
Args:
dataset: The LeRobot dataset to compute statistics for
use_sampling: If True, sub-sample image/video frames per episode to bound
memory. If False, use every frame (exact, higher memory).
memory. If False, use every frame (higher memory).
skip_images: If True, skip image/video features and leave their stats untouched.
Returns:
Dictionary containing aggregated statistics with quantiles
Dictionary containing statistics with histogram-based global quantile estimates
Note:
Video decoding operations are not thread-safe, so we process episodes sequentially
when video keys are present. For datasets without videos, we use parallel processing
with ThreadPoolExecutor for better performance.
Episodes are accumulated sequentially because the running accumulators are
shared across all of them.
"""
logging.info(f"Computing quantile statistics for dataset with {dataset.num_episodes} episodes")
episode_stats_list = []
has_videos = len(dataset.meta.video_keys) > 0
running_stats: dict[str, RunningQuantileStats] = {}
frame_counts: dict[str, int] = {}
row_counts: dict[str, int] = {}
# Kept only while a feature has a single row, so it can still be finalized.
single_row_arrays: dict[str, np.ndarray] = {}
if has_videos:
logging.info("Dataset contains video keys - using sequential processing for thread safety")
for episode_idx in tqdm(range(dataset.num_episodes), desc="Processing episodes"):
ep_stats = process_single_episode(dataset, episode_idx, use_sampling)
episode_stats_list.append(ep_stats)
else:
logging.info("Dataset has no video keys - using parallel processing for better performance")
max_workers = min(dataset.num_episodes, int(os.environ.get("LEROBOT_STATS_MAX_WORKERS", 16)))
for episode_idx in tqdm(range(dataset.num_episodes), desc="Processing episodes"):
episode_arrays = collect_episode_arrays(
dataset, episode_idx, use_sampling=use_sampling, skip_images=skip_images
)
for key, (array, num_frames) in episode_arrays.items():
running_stats.setdefault(key, RunningQuantileStats()).update(array)
frame_counts[key] = frame_counts.get(key, 0) + num_frames
row_counts[key] = row_counts.get(key, 0) + len(array)
if row_counts[key] < 2:
single_row_arrays[key] = array
else:
single_row_arrays.pop(key, None)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_episode = {
executor.submit(process_single_episode, dataset, episode_idx, use_sampling): episode_idx
for episode_idx in range(dataset.num_episodes)
}
episode_results = {}
with tqdm(total=dataset.num_episodes, desc="Processing episodes") as pbar:
for future in concurrent.futures.as_completed(future_to_episode):
episode_idx = future_to_episode[future]
ep_stats = future.result()
episode_results[episode_idx] = ep_stats
pbar.update(1)
for episode_idx in range(dataset.num_episodes):
if episode_idx in episode_results:
episode_stats_list.append(episode_results[episode_idx])
if not episode_stats_list:
if not running_stats:
raise ValueError("No episode data found for computing statistics")
logging.info(f"Aggregating statistics from {len(episode_stats_list)} episodes")
return aggregate_stats(episode_stats_list)
aggregated_stats: dict[str, dict] = {}
for key, accumulator in running_stats.items():
if row_counts[key] < 2:
# Histograms need at least two samples; mirror get_feature_stats' basic-stats path.
stats = get_feature_stats(single_row_arrays[key], axis=0, keepdims=False)
else:
stats = accumulator.get_statistics()
if dataset.features[key]["dtype"] in ["image", "video"]:
# Image stats are stored as (C, 1, 1) to broadcast over height and width.
stats = {k: v if k == "count" else v[:, np.newaxis, np.newaxis] for k, v in stats.items()}
# `get_feature_stats` counts frames, not the per-channel rows the accumulator sees.
stats["count"] = np.array([frame_counts[key]])
aggregated_stats[key] = stats
logging.info(f"Computed global histogram statistics for {len(aggregated_stats)} features")
return aggregated_stats
def augment_dataset_with_quantile_stats(
@@ -210,6 +214,7 @@ def augment_dataset_with_quantile_stats(
root: str | Path | None = None,
overwrite: bool = False,
use_sampling: bool = True,
skip_images: bool = False,
) -> None:
"""Augment a dataset with quantile statistics if they are missing.
@@ -218,7 +223,8 @@ def augment_dataset_with_quantile_stats(
root: Local root directory for the dataset
overwrite: Overwrite existing quantile statistics if they already exist
use_sampling: If True, sub-sample image/video frames per episode to bound
memory. If False, use every frame (exact, higher memory).
memory. If False, use every frame (higher memory).
skip_images: If True, skip image/video features and keep their existing stats
"""
logging.info(f"Loading dataset: {repo_id}")
dataset = LeRobotDataset(
@@ -232,7 +238,13 @@ def augment_dataset_with_quantile_stats(
logging.info("Dataset does not contain quantile statistics. Computing them now...")
new_stats = compute_quantile_stats_for_dataset(dataset, use_sampling=use_sampling)
new_stats = compute_quantile_stats_for_dataset(
dataset, use_sampling=use_sampling, skip_images=skip_images
)
if skip_images and dataset.meta.stats:
for key, feature_stats in dataset.meta.stats.items():
new_stats.setdefault(key, feature_stats)
logging.info("Updating dataset metadata with new quantile statistics")
dataset.meta.stats = new_stats
@@ -276,10 +288,15 @@ def main():
"--no-sampling",
action="store_true",
help=(
"Compute stats over every frame (exact, higher memory). By default, "
"Compute stats over every frame (higher memory). By default, "
"image/video frames are sub-sampled per episode to bound memory."
),
)
parser.add_argument(
"--skip-images",
action="store_true",
help="Skip image/video features and preserve their existing stats",
)
args = parser.parse_args()
root = Path(args.root) if args.root else None
@@ -291,6 +308,7 @@ def main():
root=root,
overwrite=args.overwrite,
use_sampling=not args.no_sampling,
skip_images=args.skip_images,
)
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Convert a DCP-format checkpoint into a distributable safetensors model, offline.
Runs single-process (no GPUs, no process group). Example:
```bash
lerobot-convert-dcp --checkpoint_dir=outputs/train/run/checkpoints/005000
lerobot-convert-dcp --checkpoint_dir=... --delete_dcp=true --push_to_hub=user/my-policy
```
`--push_to_hub` publishes the converted directory as a model repo, degrading gracefully: the
core artifacts (model.safetensors, config.json, processor files) always upload; the README
model card is enriched with training/dataset metadata only when `train_config.json` (and the
dataset it names) are reachable, with a WARNING naming exactly what was skipped otherwise.
DCP shard artifacts are never uploaded published repos carry safetensors only.
"""
import logging
from dataclasses import dataclass
from pathlib import Path
from huggingface_hub import HfApi
from lerobot.configs import parser
from lerobot.distributed.checkpoint import dcp_to_safetensors
from lerobot.utils.constants import PRETRAINED_MODEL_DIR
from lerobot.utils.utils import init_logging
@dataclass
class ConvertDcpConfig:
"""CLI config for the offline DCP-to-safetensors checkpoint conversion."""
# A checkpoint step directory (containing pretrained_model/) or a pretrained_model
# directory itself.
checkpoint_dir: Path
# Remove the DCP shard directory after a successful conversion.
delete_dcp: bool = False
# Publish the converted directory to this Hub repo id (e.g. "user/my-policy").
push_to_hub: str | None = None
private: bool | None = None
def _locate_pretrained_dir(checkpoint_dir: Path) -> Path:
"""Resolve the pretrained_model/ directory from a user-supplied checkpoint path.
Args:
checkpoint_dir (Path): A checkpoint step directory (containing `pretrained_model/`) or a
`pretrained_model` directory itself.
Returns:
Path: The nested `pretrained_model/` directory when present, otherwise `checkpoint_dir`
unchanged.
"""
nested = checkpoint_dir / PRETRAINED_MODEL_DIR
return nested if nested.is_dir() else checkpoint_dir
def _publish_converted(pretrained_dir: Path, repo_id: str, private: bool | None) -> None:
"""Best-effort publish of a converted checkpoint dir, degrading gracefully.
The core artifacts (model.safetensors, config.json, processor files) always upload; the README
model card gains training/dataset metadata only when `train_config.json` (and the dataset it
names) are reachable, with a WARNING naming what was skipped otherwise. DCP shard artifacts are
excluded from the upload.
Args:
pretrained_dir (Path): The converted `pretrained_model/` directory to upload.
repo_id (str): Target Hub model repo id (e.g. "user/my-policy"); created if missing.
private (bool | None): Repo visibility passed to `create_repo`; None keeps the Hub (or
existing repo's) default.
"""
from lerobot.common.train_utils import generate_model_card
from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.train import TRAIN_CONFIG_NAME, TrainPipelineConfig
train_cfg = None
dataset_meta = None
if (pretrained_dir / TRAIN_CONFIG_NAME).is_file():
try:
train_cfg = TrainPipelineConfig.from_pretrained(pretrained_dir)
except Exception as e: # noqa: BLE001 — degrade, never block the upload
logging.warning(f"Could not parse {TRAIN_CONFIG_NAME} ({e}); README will lack training metadata.")
else:
logging.warning(f"{TRAIN_CONFIG_NAME} missing; README will lack training metadata.")
if train_cfg is not None:
try:
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
dataset_meta = LeRobotDatasetMetadata(
repo_id=train_cfg.dataset.repo_id,
root=train_cfg.dataset.root,
revision=train_cfg.dataset.revision,
)
except Exception as e: # noqa: BLE001
logging.warning(
f"Dataset '{train_cfg.dataset.repo_id}' unreachable ({e}); README will lack dataset metadata."
)
try:
model_cfg = PreTrainedConfig.from_pretrained(pretrained_dir)
card = generate_model_card(model_cfg, cfg=train_cfg, dataset_meta=dataset_meta)
card.save(str(pretrained_dir / "README.md"))
except Exception as e: # noqa: BLE001
logging.warning(f"Could not build the model card ({e}); publishing without README.")
api = HfApi()
repo_id = api.create_repo(repo_id=repo_id, private=private, exist_ok=True).repo_id
commit_info = api.upload_folder(
repo_id=repo_id,
repo_type="model",
folder_path=str(pretrained_dir),
commit_message="Upload converted policy (DCP -> safetensors)",
allow_patterns=["*.safetensors", "*.json", "*.yaml", "*.md"],
# The checkpoint keeps its DCP shard directory unless --delete_dcp was passed; the
# allow list above admits neither `.distcp` shards nor their `.metadata` sidecar.
ignore_patterns=["*.tmp", "*.log"],
)
logging.info(f"Model pushed to {commit_info.repo_url.url}")
@parser.wrap()
def convert_checkpoint(cfg: ConvertDcpConfig) -> Path:
"""Merge a checkpoint's DCP shards into `model.safetensors`, then optionally publish it.
Args:
cfg (ConvertDcpConfig): Conversion options the checkpoint directory to convert, whether
to delete the DCP shards after a successful merge, and the optional Hub repo id (and
visibility) to publish the converted directory to.
Returns:
Path: The path to the merged `model.safetensors` file.
Raises:
FileNotFoundError: If the checkpoint has no DCP shard directory, i.e. it was not saved
with `checkpoint_format=dcp` (or `safetensors_dcp`).
"""
from accelerate.utils.constants import FSDP_MODEL_NAME
pretrained_dir = _locate_pretrained_dir(cfg.checkpoint_dir)
dcp_dir = pretrained_dir / f"{FSDP_MODEL_NAME}_0"
if not dcp_dir.is_dir():
raise FileNotFoundError(
f"No DCP shard directory at {dcp_dir}. Point --checkpoint_dir at a checkpoint "
"saved with checkpoint_format=dcp (or safetensors_dcp)."
)
logging.info(f"Merging {dcp_dir} -> {pretrained_dir / 'model.safetensors'}")
safetensors_path = dcp_to_safetensors(dcp_dir, pretrained_dir, delete_dcp=cfg.delete_dcp)
if cfg.push_to_hub:
_publish_converted(pretrained_dir, cfg.push_to_hub, cfg.private)
return safetensors_path
def main() -> None:
"""`lerobot-convert-dcp` console entry point: set up logging and run the conversion."""
init_logging()
convert_checkpoint()
if __name__ == "__main__":
main()
+29 -17
View File
@@ -240,6 +240,7 @@ Using JSON config file:
import abc
import logging
import os
import shutil
import sys
from dataclasses import dataclass, field
@@ -384,24 +385,35 @@ def _resolve_io_paths(
return output_repo_id, input_path, output_path
def _is_in_place(input_path: Path, output_path: Path) -> bool:
"""Whether both paths point to the same dataset directory.
Uses os.path.samefile (device+inode) which is robust to case-insensitive filesystems, hardlinks
and symlinks.
"""
try:
return os.path.samefile(input_path, output_path)
except OSError:
return False
def get_output_path(
repo_id: str,
new_repo_id: str | None,
root: Path | str | None,
new_root: Path | str | None,
) -> tuple[str, Path]:
) -> tuple[str, Path, Path | None]:
output_repo_id, input_path, output_path = _resolve_io_paths(repo_id, new_repo_id, root, new_root)
# In case of in-place modification, create a backup of the original dataset (if it exists)
if output_path == input_path:
# In case of in-place modification, create a backup of the original dataset (if it exists).
backup_path: Path | None = None
if _is_in_place(input_path, output_path):
backup_path = input_path.with_name(input_path.name + "_old")
if backup_path.exists():
shutil.rmtree(backup_path)
shutil.move(input_path, backup_path)
if input_path.exists():
if backup_path.exists():
shutil.rmtree(backup_path)
shutil.move(input_path, backup_path)
return output_repo_id, output_path
return output_repo_id, output_path, backup_path
def handle_delete_episodes(cfg: EditDatasetConfig) -> None:
@@ -412,7 +424,7 @@ def handle_delete_episodes(cfg: EditDatasetConfig) -> None:
raise ValueError("episode_indices must be specified for delete_episodes operation")
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
output_repo_id, output_dir = get_output_path(
output_repo_id, output_dir, backup_path = get_output_path(
cfg.repo_id,
new_repo_id=cfg.new_repo_id,
root=cfg.root,
@@ -420,8 +432,8 @@ def handle_delete_episodes(cfg: EditDatasetConfig) -> None:
)
# In case of in-place modification, make the dataset point to the backup directory
if output_dir == dataset.root:
dataset.root = dataset.root.with_name(dataset.root.name + "_old")
if backup_path is not None:
dataset.root = backup_path
logging.info(f"Deleting episodes {cfg.operation.episode_indices} from {cfg.repo_id}")
new_dataset = delete_episodes(
@@ -525,7 +537,7 @@ def handle_remove_feature(cfg: EditDatasetConfig) -> None:
raise ValueError("feature_names must be specified for remove_feature operation")
dataset = LeRobotDataset(cfg.repo_id, root=cfg.root)
output_repo_id, output_dir = get_output_path(
output_repo_id, output_dir, backup_path = get_output_path(
cfg.repo_id,
new_repo_id=cfg.new_repo_id,
root=cfg.root,
@@ -533,8 +545,8 @@ def handle_remove_feature(cfg: EditDatasetConfig) -> None:
)
# In case of in-place modification, make the dataset point to the backup directory
if output_dir == dataset.root:
dataset.root = dataset.root.with_name(dataset.root.name + "_old")
if backup_path is not None:
dataset.root = backup_path
logging.info(f"Removing features {cfg.operation.feature_names} from {cfg.repo_id}")
new_dataset = remove_feature(
@@ -671,7 +683,7 @@ def handle_recompute_stats(cfg: EditDatasetConfig) -> None:
cfg.new_root,
default_new_repo_id=f"{cfg.repo_id}_recomputed_stats",
)
in_place = output_root == input_root
in_place = _is_in_place(input_root, output_root)
if in_place and not cfg.operation.overwrite:
raise ValueError(
@@ -731,7 +743,7 @@ def handle_reencode_videos(cfg: EditDatasetConfig) -> None:
cfg.new_root,
default_new_repo_id=f"{cfg.repo_id}_reencoded",
)
in_place = output_root == input_root
in_place = _is_in_place(input_root, output_root)
if in_place and not cfg.operation.overwrite:
raise ValueError(
+4 -1
View File
@@ -82,6 +82,7 @@ from lerobot.envs import (
make_env_pre_post_processors,
preprocess_observation,
)
from lerobot.envs.utils import NEW_ROLLOUT_OPTION
from lerobot.lerobot_types import PolicyAction
from lerobot.policies import PreTrainedPolicy, make_policy, make_pre_post_processors
from lerobot.processor import PolicyProcessorPipeline
@@ -217,7 +218,9 @@ def rollout(
# Reset the policy and environments.
policy.reset()
observation, info = env.reset(seed=seeds)
# NEW_ROLLOUT_OPTION tells FreezeAfterEpisodeEnd this is a genuine new episode, as
# opposed to Gymnasium's argument-less autoreset of a sub-env that already finished.
observation, info = env.reset(seed=seeds, options={NEW_ROLLOUT_OPTION: True})
if render_callback is not None:
render_callback(env)
+30 -3
View File
@@ -44,6 +44,19 @@ Usage examples
--robot.port=/dev/ttyACM0 \\
--task="pick up cube" --duration=30
# Interactive session (base or sentry strategy): the robot stays idle
# until /start is typed; /subtask <text> re-instructs the policy mid-run;
# /reset returns it to the initial position (hardware and policy stay
# warm); /stop shuts down gracefully. With --strategy.type=sentry the
# session also records continuously, labeling frames with the live task.
lerobot-rollout \\
--strategy.type=base \\
--policy.path=lerobot/act_koch_real \\
--robot.type=koch_follower \\
--robot.port=/dev/ttyACM0 \\
--task="pick up cube" \\
--interactive=true
# Base mode — RTC inference for slow VLAs (Pi0, Pi0.5, SmolVLA)
lerobot-rollout \\
--strategy.type=base \\
@@ -173,7 +186,13 @@ from lerobot.robots import ( # noqa: F401
so_follower,
unitree_g1 as unitree_g1_robot,
)
from lerobot.rollout import RolloutConfig, build_rollout_context, create_strategy
from lerobot.rollout import (
InteractiveSession,
LinkedEvent,
RolloutConfig,
build_rollout_context,
create_strategy,
)
from lerobot.teleoperators import ( # noqa: F401
Teleoperator,
TeleoperatorConfig,
@@ -215,6 +234,10 @@ def rollout(cfg: RolloutConfig):
signal_handler = ProcessSignalHandler(use_threads=True, display_pid=False)
shutdown_event = signal_handler.shutdown_event
if cfg.interactive:
# Session commands (/reset, /stop) end the running control loop by setting
# the local flag; process signals still propagate through the parent event.
shutdown_event = LinkedEvent(shutdown_event)
logger.info("Building rollout context...")
ctx = build_rollout_context(cfg, shutdown_event)
@@ -230,8 +253,12 @@ def rollout(cfg: RolloutConfig):
try:
strategy.setup(ctx)
logger.info("Rollout setup complete, starting rollout...")
strategy.run(ctx)
if cfg.interactive:
logger.info("Rollout setup complete — starting interactive session (robot idle until /start)")
InteractiveSession(strategy, ctx).run()
else:
logger.info("Rollout setup complete, starting rollout...")
strategy.run(ctx)
except KeyboardInterrupt:
logger.info("Interrupted by user")
finally:
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ from torch.utils.data._utils.collate import default_collate
from lerobot.datasets.language import LANGUAGE_COLUMNS
_PYTHON_LIST_KEYS = {"messages", "message_streams", "target_message_indices"}
_PYTHON_LIST_KEYS = {"messages", "message_streams", "target_message_indices", *LANGUAGE_COLUMNS}
def lerobot_collate_fn(batch: list[dict[str, Any] | None]) -> dict[str, Any] | None:
+2
View File
@@ -26,6 +26,7 @@ OBS_IMAGES = OBS_IMAGE + "s"
OBS_LANGUAGE = OBS_STR + ".language"
OBS_LANGUAGE_TOKENS = OBS_LANGUAGE + ".tokens"
OBS_LANGUAGE_ATTENTION_MASK = OBS_LANGUAGE + ".attention_mask"
OBS_LANGUAGE_CAUSAL_MARKS = OBS_LANGUAGE + ".causal_marks"
OBS_LANGUAGE_SUBTASK = OBS_STR + ".subtask"
OBS_LANGUAGE_SUBTASK_TOKENS = OBS_LANGUAGE_SUBTASK + ".tokens"
OBS_LANGUAGE_SUBTASK_ATTENTION_MASK = OBS_LANGUAGE_SUBTASK + ".attention_mask"
@@ -34,6 +35,7 @@ ACTION = "action"
ACTION_PREFIX = ACTION + "."
ACTION_TOKENS = ACTION + ".tokens"
ACTION_TOKEN_MASK = ACTION + ".token_mask"
ACTION_CODE_TOKEN_MASK = ACTION + ".code_token_mask"
REWARD = "next.reward"
TRUNCATED = "next.truncated"
DONE = "next.done"
+31 -5
View File
@@ -25,6 +25,9 @@ from .constants import CHECKPOINTS_DIR
T = TypeVar("T", bound="HubMixin")
# Sharded-training resume artifacts (torch DCP shard dirs + shard files). Published model repos
# carry safetensors only, so publishing uploads exclude these — checkpoint pushes (which exist
# for resume, not distribution) deliberately do not.
def find_latest_hub_checkpoint(
repo_id: str,
*,
@@ -36,6 +39,16 @@ def find_latest_hub_checkpoint(
Training runs push checkpoints to ``checkpoints/<step>/`` (see
``push_checkpoint_to_hub``). This lists those step dirs and returns
``checkpoints/<highest-step>``, or ``None`` if the repo has no checkpoints.
Args:
repo_id (str): The Hub model repo to inspect.
token (str | bool | None): Hub authentication token. Defaults to None (the token
cached by `huggingface-cli login`).
revision (str | None): Repo revision to list. Defaults to None (the default branch).
Returns:
str | None: The repo-relative path `checkpoints/<highest-step>`, or None if the repo
has no checkpoints.
"""
files = HfApi().list_repo_files(repo_id=repo_id, repo_type="model", revision=revision, token=token)
prefix = f"{CHECKPOINTS_DIR}/"
@@ -164,7 +177,7 @@ class HubMixin:
ignore_patterns: list[str] | str | None = None,
delete_patterns: list[str] | str | None = None,
card_kwargs: dict[str, Any] | None = None,
) -> str:
) -> str | None:
"""
Upload model checkpoint to the Hub.
@@ -172,6 +185,10 @@ class HubMixin:
`delete_patterns` to delete existing remote files in the same commit. See [`upload_folder`] reference for more
details.
Distributed contract: call on EVERY rank. `save_pretrained` runs on all ranks for
sharded objects it can contain a collective gather (rank-gating it would deadlock)
while repo creation and the upload happen on the main process only.
Args:
repo_id (`str`):
ID of the repository to push to (example: `"username/my-model"`).
@@ -197,11 +214,17 @@ class HubMixin:
Additional arguments passed to the card template to customize the card.
Returns:
The url of the commit of your object in the given repository.
`str` or `None`: The url of the commit of your object in the given repository, or
`None` on non-main ranks of a distributed run (only the main process uploads).
"""
api = HfApi(token=token)
repo_id = api.create_repo(repo_id=repo_id, private=private, exist_ok=True).repo_id
# Lazy import: hub code must not import the distributed package at module load
# (configs -> hub is on the import path of lerobot.distributed itself).
from lerobot.distributed.utils import is_main_process
# Distributed contract: `save_pretrained` runs on EVERY rank — for sharded policies it
# contains a collective gather (rank-gating it would deadlock) and it writes into this
# rank's private tmpdir only on the main process. Repo creation and upload are then
# main-process-only.
if commit_message is None:
if "Policy" in self.__class__.__name__:
commit_message = "Upload policy"
@@ -210,10 +233,13 @@ class HubMixin:
else:
commit_message = f"Upload {self.__class__.__name__}"
# Push the files to the repo in a single commit
with TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
saved_path = Path(tmp) / repo_id
self.save_pretrained(saved_path, card_kwargs=card_kwargs)
if not is_main_process():
return None
api = HfApi(token=token)
repo_id = api.create_repo(repo_id=repo_id, private=private, exist_ok=True).repo_id
return api.upload_folder(
repo_id=repo_id,
repo_type="model",
+10 -1
View File
@@ -72,7 +72,16 @@ def is_package_available(
def get_safe_default_video_backend():
logger = logging.getLogger(__name__)
if importlib.util.find_spec("torchcodec"):
return "torchcodec"
# Despite being installed, torchcodec may not be loadable at runtime.
try:
importlib.import_module("torchcodec")
return "torchcodec"
except (ImportError, OSError, RuntimeError) as e:
logger.warning(
f"{e}\n'torchcodec' is installed but cannot be loaded (see the error above). "
"Falling back to 'pyav' as a default decoder."
)
return "pyav"
else:
logger.warning(
"'torchcodec' is not available in your platform, falling back to 'pyav' as a default decoder"

Some files were not shown because too many files have changed in this diff Show More