Compare commits

..

4 Commits

Author SHA1 Message Date
CarolinePascal 35d40f353f docs(teleoperators): write the API reference docstrings
Completes Wave 1. Takes src/lerobot/teleoperators/ (excluding teleoperator.py, off-limits) to 100% public
docstring coverage across all 16 hardware families. Fixes a real check_docstrings.py-breaking bug in
ExoskeletonIKHelper's docstring format. Several other real bugs (missing @property, undefined attribute
reference, wrong parameter name in an existing docstring) were found and documented accurately but left
unfixed per the docstrings-only scope, detailed in the PR description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:25:26 +02:00
Pepijn 741005d719 docs: write the API reference docstrings
Every docstring change for the API reference, on top of the infrastructure PR
which contains none. Two halves: a repo-wide pass over what the renderer cannot
handle, and `src/lerobot/robots/` taken to 100% as the worked example.

**Renderer fixes, repo-wide.** Both of these render incorrectly the moment
`[[autodoc]]` is on, and both were verified against a local build:

- 24 Sphinx roles across three files. They are unsupported and render as literal
  `:pymeth:` text. Method references become doc-builder cross-references; the
  ones pointing at instance attributes become inline code, since attributes get
  no autodoc anchor and a cross-reference would be a dead link.
- 43 `Attributes:` sections across 27 files. doc-builder parses a bare
  `Attributes:` as a synonym for `Parameters:` — `Robot`'s attributes rendered
  inside `<paramsdesc>`, presenting `config_class` and `name` to readers as
  constructor arguments when the actual parameter is `config`. Where the
  original carried no type, the type comes from the real class annotation rather
  than being invented.

The four base classes every other module inherits from — `robot.py`,
`teleoperator.py`, `motors_bus.py`, `camera.py` — are rewritten to the standard,
since subclasses document only their deviations from that text.

Three docstring errors corrected in passing: `Teleoperator.get_action` pointed
at `observation_features`, which `Teleoperator` does not have; `send_feedback`
documented a `Returns:` for a method returning `None`; and `config_class` was
typed `RobotConfig` instead of `type[TeleoperatorConfig]`.

**`robots/`, 109/306 -> 306/306.** The configuration dataclasses were the
substantial part. Their fields were documented only with `#` comments above each
field, which doc-builder cannot see: before this, `SO101FollowerConfig` rendered
all eleven of its fields with not one description. Each config now carries an
`Args:` block on the concrete registered class, covering inherited fields too,
because doc-builder renders only a class's own docstring and several of these
configs are thin multiple-inheritance shims whose body is `pass`.

The inline comments are kept rather than removed, so fields stay annotated in
the source as well as on the rendered page. Note this leaves each field
described twice, and only the `Args:` block is checked against the signature by
`make check-docstrings`, so the two can drift.

Writing them turned up things worth stating plainly on the page rather than
leaving in a comment: which configs have no serial port at all because they talk
over a network or the cloud (Reachy 2, Unitree G1, LeKiwi's client, EarthRover),
which manage their own calibration so `calibration_dir` does nothing, that
OpenArm's default joint limits are deliberately tiny until `side` is set, and
that reBot's `port` means a different thing depending on `can_adapter`.

Two pre-existing docstring bugs that the doctest infrastructure surfaced are
fixed here: `SerialMotorsBus` used `>>>` inside a ```bash block to show CLI
output, which doctest read as Python and failed on with a SyntaxError, and
`MotorsBus.torque_disabled`'s example referenced an undefined name.
`ensure_safe_goal_position` gains a genuinely executing example so the doctest
gate is not vacuous.

**Gates ratcheted**, each of which the infrastructure PR left deliberately
loose:

- `check_docstrings.py`'s ignore list emptied — the ten objects it held all had
  bare `Attributes:` sections, now converted.
- `check_config_docstrings.py`'s ignore list emptied — every registered robot
  config documents its port and calibration semantics.
- `robots/` removed from the ruff `D` per-file-ignores, as are the two
  package-root files, whose one-line docstring issues are fixed here. D100 and
  D104 are ignored globally instead: they ask for a banner on every file and
  every `__init__.py`, which appears on no rendered page.
- `interrogate` raised 52 -> 55 against a measured 55.3%.
- The four `robots/` files carrying examples added to the doctest allowlist,
  which shipped empty.

Verified: all 66 changed files under `src/lerobot/` are provably docstring-only
(AST with docstrings stripped is byte-identical to main), no comment line is
removed anywhere in `robots/`, and 708 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 21:01:34 +02:00
Pepijn 2e8345a5cc docs: add API documentation infrastructure
LeRobot's documentation build passes `--not_python_module`, which tells
doc-builder there is no importable Python package and disables `[[autodoc]]`
entirely. The result is that all 90+ pages are hand-written guides and there is
no generated API reference at all.

This is the machinery to change that. It deliberately contains no docstring
changes of its own — every docstring edit lives in the follow-up PR, so this
one can be reviewed as tooling and configuration alone.

**The standard.** `docs/source/writing_docstrings.mdx` is the contract: Google
section headers with Hugging Face type formatting, the machine-checked argument
line, `**Attributes**:`, doc-builder cross-references, fenced doctest examples.
It also records three behaviours that are not discoverable from the source and
were verified against a local build: `[[autodoc]]` silently skips members with
no docstring; doc-builder does not inherit docstrings from base classes, so a
registered config shim whose body is `pass` renders every field with no
description; and module-level aliases resolve to the canonical class.

**Autodoc turned on**, with two changes that are not obvious:

- `--version main` on the main-docs job. Without `--not_python_module`,
  doc-builder resolves the version from `lerobot.__version__` and only maps it
  to the default branch when it contains "dev". transformers relies on that;
  our main carries 0.6.2. Verified by building both ways — dropping the flag
  alone would publish the main docs to /lerobot/v0.6.2/ instead of
  /lerobot/main/ and disable notebook building.
- `pre_command` on both jobs. doc-builder ships a mock-deps registry entry for
  lerobot, so the reusable workflow takes its light-install path, which cannot
  import the package. The heavy dependencies cannot be mocked either: draccus
  runs `register_subclass` at import time and `processor/converters.py` calls
  `functools.singledispatch.register(torch.Tensor)`, which needs a real class.
  `[dataset]` is the only extra required.

Workflow triggers gain `src/**`, since the reference is now generated from
docstrings. `docs/source/api/` is excluded from the prettier hook, which reads
`[[autodoc]]` member lists as lazy paragraph continuations and joins a ten-entry
list onto one line.

Nine API reference pages, scaffolded with each module's base class.

**Doctests.** `LeRobotDocTestParser` is mandatory rather than optional here:
ruff's `docstring-code-format = true` drops the blank line before a closing
fence, after which stdlib's `_EXAMPLE_RE` reads the fence as expected output and
every example with output fails. It is written against the installed pytest
rather than copied from transformers, whose version predates pytest 9's
`import_path` signature and its own fix for the `@property` line-number bug.
`preprocess_string` also diverges: the upstream fenced-block split puts a
single-line example's code in a chunk with no `>>>` in it, so neither the CUDA
skip nor the `+IGNORE_RESULT` injection fires for it.

**Checkers.** `utils/check_docstrings.py` is the ~300-line core of the
2203-line transformers original; the `@auto_docstring` system, modular
propagation, GitPython and `checkers.py` are not ported.
`utils/check_config_docstrings.py` checks that every registered robot config
documents its port and calibration semantics.

**Gates**, all set to values that pass today: ruff `D` with per-file-ignores
per unconverted module, `interrogate` at `fail-under = 52` against a measured
52.1%, and Makefile targets wired into the quality workflow. The doctest
allowlist ships empty and the `doctest` target handles that, because the files
carrying runnable examples arrive with the docstring PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:57:26 +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
177 changed files with 11885 additions and 2167 deletions
+21 -5
View File
@@ -24,19 +24,24 @@ on:
required: false
type: string
# Triggers the workflow on push events to main for the docs folder
# Triggers on pushes to main that touch the docs or the sources the API reference is generated from.
# `src/**` is included because the API reference is built from docstrings via `[[autodoc]]`: without it,
# published API pages would go stale as soon as a docstring changed.
push:
branches:
- main
paths:
- "docs/**"
- "src/**"
# Triggers the workflow on pull request events targeting main for the docs folder
# Same for pull requests, so a docstring change gets a preview build and a broken `[[autodoc]]` path
# fails the PR rather than main.
pull_request:
branches:
- main
paths:
- "docs/**"
- "src/**"
release:
types: [published]
@@ -59,12 +64,21 @@ jobs:
with:
commit_sha: ${{ github.sha }}
package: lerobot
# doc-builder ships a mock-deps registry entry for lerobot, so the reusable workflow takes its
# "light install" path: `pip install ./lerobot --no-deps` plus a handful of real dependencies.
# That is not enough to import lerobot — draccus runs `register_subclass` at import time and
# `processor/converters.py` calls `functools.singledispatch.register(torch.Tensor)`, neither of
# which works against a mock. Install the package for real before the build.
pre_command: uv pip install "./lerobot[dataset]"
# `--version main` is load-bearing: without `--not_python_module`, doc-builder falls back to
# `lerobot.__version__` and only maps that to the default branch when it contains "dev". Our main
# branch carries a release version (0.6.2), so omitting this would publish the main docs to
# /lerobot/v0.6.2/ instead of /lerobot/main/ and disable notebook building.
additional_args: >-
--not_python_module
${{
(github.event_name == 'release' && format('--version {0}', github.event.release.tag_name)) ||
(inputs.version != '' && format('--version {0}', inputs.version)) ||
''
'--version main'
}}
secrets:
token: ${{ secrets.HUGGINGFACE_PUSH }}
@@ -83,4 +97,6 @@ jobs:
commit_sha: ${{ github.event.pull_request.head.sha }}
pr_number: ${{ github.event.number }}
package: lerobot
additional_args: --not_python_module
# See the comment on build_main_docs. The PR workflow passes its own `--version pr_<n>`, so no
# additional_args are needed here.
pre_command: uv pip install "./lerobot[dataset]"
+38
View File
@@ -56,3 +56,41 @@ jobs:
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
with:
extra_args: --all-files --show-diff-on-failure --color=always
# This job runs the examples in our docstrings and validates the doctest allowlist.
# See docs/source/writing_docstrings.mdx for the standard these enforce.
doc-checks:
name: Run Documentation Checks (Doctests)
runs-on: ubuntu-latest
env:
# Examples that need a physical robot, a serial port or a Hub download are skipped by content.
# Everything else has to actually run. See src/lerobot/utils/doctest_utils.py.
SKIP_HARDWARE_DOCTEST: "1"
SKIP_CUDA_DOCTEST: "1"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
version: "0.11.30"
python-version: "3.12"
- name: Install dependencies
run: uv sync --locked --extra test --extra dataset
- name: Check the doctest list is sorted and its paths exist
run: make check-doctest-list
- name: Check documented arguments match their signatures
run: make check-docstrings
- name: Check docstring coverage has not regressed
run: uv run --with interrogate interrogate --config=pyproject.toml
- name: Run doctests
run: make doctest
+11 -2
View File
@@ -67,7 +67,11 @@ repos:
args: [--prose-wrap=preserve]
# Jinja2 model-card templates use a .md extension but contain {% ... %} /
# {{ ... }} tags that prettier's Markdown formatter mangles (e.g. table loops).
exclude: ^src/lerobot/templates/.*\.md$
#
# docs/source/api/ holds the generated API reference. Its `[[autodoc]]` blocks restrict output
# to an indented `- member` list, which prettier reads as a lazy paragraph continuation and
# joins onto one line — silently turning a member list into part of the directive.
exclude: ^(src/lerobot/templates/.*\.md|docs/source/api/.*\.mdx)$
##### Security #####
- repo: https://github.com/gitleaks/gitleaks
@@ -104,8 +108,13 @@ repos:
# args: ["--docstring-style", "google", "-v", "2"]
# exclude: ^tests/.*$
# interrogate runs in CI (quality.yml, doc-checks job) rather than here. Its 1.7.0 release still imports
# the deprecated `py` package, which resolves against whatever `py` happens to be importable in
# pre-commit's isolated env — on a machine with miniconda on the path that is a stray `py.py` and the
# hook dies before it reads any config. The gate is the same either way; the CI step is just reliable.
# - repo: https://github.com/econchick/interrogate
# rev: 1.7.0
# hooks:
# - id: interrogate
# args: ["-vv", "--config=pyproject.toml"]
# args: ["--config=pyproject.toml"]
# pass_filenames: false
+4
View File
@@ -50,6 +50,10 @@ To run checks manually on all files:
pre-commit run --all-files
```
### Docstrings
The API reference is generated from the docstrings in `src/lerobot/`. If you add or change anything public, follow the [docstring standard](https://huggingface.co/docs/lerobot/writing_docstrings) — the format is parsed by the renderer and checked in CI.
### Running Tests
We use `pytest`. First, ensure you have test artifacts by installing **git-lfs**:
+26
View File
@@ -184,3 +184,29 @@ test-smolvla-ete-eval:
# backend, so it does not require a real model checkpoint or GPU.
annotation-e2e:
uv run python -m tests.annotations.run_e2e_smoke
# Docstring & doctest checks. See docs/source/writing_docstrings.mdx for the standard these enforce.
# Run the examples in the docstrings listed in utils/documentation_tests.txt. Hardware and GPU examples are
# skipped by content (see src/lerobot/utils/doctest_utils.py); CI sets both flags.
doctest:
@files=$$(grep -v '^\s*#' utils/documentation_tests.txt | grep -v '^\s*$$'); \
if [ -z "$$files" ]; then \
echo "utils/documentation_tests.txt lists no files; nothing to run."; \
else \
SKIP_HARDWARE_DOCTEST=1 uv run pytest --doctest-modules --no-header -q $$files; \
fi
check-doctest-list:
uv run python utils/check_doctest_list.py
fix-doctest-list:
uv run python utils/check_doctest_list.py --fix_and_overwrite
check-docstrings:
uv run python utils/check_docstrings.py
uv run python utils/check_config_docstrings.py
fix-docstrings:
uv run python utils/check_docstrings.py --fix_and_overwrite
uv run python utils/check_doctest_list.py --fix_and_overwrite
+60
View File
@@ -0,0 +1,60 @@
# 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.
"""Root conftest: makes doctest collection use LeRobot's parser.
This only affects `--doctest-modules` runs (see `make doctest`). The test suite itself is configured by
`tests/conftest.py`.
"""
import doctest
import _pytest.doctest
from lerobot.utils.doctest_utils import LeRobotDoctestModule, LeRobotDocTestParser
# Lets an example opt out of output comparison with `# doctest: +IGNORE_RESULT`, for calls whose output is
# a progress bar or otherwise not reproducible.
IGNORE_RESULT = doctest.register_optionflag("IGNORE_RESULT")
OutputChecker = doctest.OutputChecker
class CustomOutputChecker(OutputChecker):
"""An output checker that honours the `IGNORE_RESULT` flag."""
def check_output(self, want, got, optionflags):
"""Return `True` when `IGNORE_RESULT` is set, otherwise defer to stdlib.
Args:
want (`str`):
The expected output.
got (`str`):
The actual output.
optionflags (`int`):
Bitmask of active doctest option flags.
Returns:
`bool`: Whether the output is considered a match.
"""
if IGNORE_RESULT & optionflags:
return True
return OutputChecker.check_output(self, want, got, optionflags)
# Reassigning these module attributes is how doctest behaviour is customised; mypy sees it as assigning to
# a type, which is exactly what is intended here.
doctest.OutputChecker = CustomOutputChecker # type: ignore[misc]
_pytest.doctest.DoctestModule = LeRobotDoctestModule
doctest.DocTestParser = LeRobotDocTestParser # type: ignore[misc]
+22
View File
@@ -191,6 +191,28 @@
- sections:
- local: contributing
title: Contribute to LeRobot
- local: writing_docstrings
title: Writing docstrings
- local: backwardcomp
title: Backward compatibility
title: "About"
- sections:
- local: api/robots
title: Robots
- local: api/teleoperators
title: Teleoperators
- local: api/cameras
title: Cameras
- local: api/motors
title: Motors
- local: api/datasets
title: Datasets
- local: api/policies
title: Policies
- local: api/processor
title: Processors
- local: api/envs
title: Environments
- local: api/configs
title: Configuration
title: "API Reference"
+24
View File
@@ -0,0 +1,24 @@
# Cameras
Cameras supply the image observations a policy sees. Every backend — OpenCV, Intel RealSense, Reachy 2 —
implements the [`Camera`] interface, so swapping hardware does not change the code that reads frames.
See the [Cameras guide](../cameras) for choosing and configuring a camera, and
[Third-Party Cameras & Sensors](../third_party_sensors) for devices outside the core set.
## Camera
[[autodoc]] lerobot.cameras.Camera
- connect
- disconnect
- read
- async_read
- find_cameras
## CameraConfig
[[autodoc]] lerobot.cameras.CameraConfig
## make_cameras_from_configs
[[autodoc]] lerobot.cameras.make_cameras_from_configs
+27
View File
@@ -0,0 +1,27 @@
# Configuration
LeRobot configuration is plain dataclasses parsed by [draccus](https://github.com/dlwh/draccus), so every
field is settable from the CLI. [`TrainPipelineConfig`] is the top-level object for `lerobot-train`.
Polymorphic configs (policies, robots, environments) use `draccus.ChoiceRegistry`: a subclass registers
itself with `@register_subclass("name")` and is then selectable by that name on the command line.
## TrainPipelineConfig
[[autodoc]] lerobot.configs.train.TrainPipelineConfig
## PreTrainedConfig
[[autodoc]] lerobot.configs.PreTrainedConfig
## DatasetConfig
[[autodoc]] lerobot.configs.DatasetConfig
## EvalConfig
[[autodoc]] lerobot.configs.EvalConfig
## WandBConfig
[[autodoc]] lerobot.configs.WandBConfig
+23
View File
@@ -0,0 +1,23 @@
# Datasets
[`LeRobotDataset`] is the format every LeRobot script reads and writes. It is episode-aware, decodes video
observations on the fly, and round-trips to the Hugging Face Hub.
See [Using LeRobotDataset](../lerobot-dataset-v3) for the format and the common operations,
[Porting Large Datasets](../porting_datasets_v3) for migration, and [Tools](../tools) for the CLI.
## LeRobotDataset
[[autodoc]] lerobot.datasets.LeRobotDataset
## LeRobotDatasetMetadata
[[autodoc]] lerobot.datasets.LeRobotDatasetMetadata
## MultiLeRobotDataset
[[autodoc]] lerobot.datasets.MultiLeRobotDataset
## StreamingLeRobotDataset
[[autodoc]] lerobot.datasets.StreamingLeRobotDataset
+19
View File
@@ -0,0 +1,19 @@
# Environments
Simulation environments are configured through [`EnvConfig`] and built by [`make_env`]. Each subclass
declares its `gym_kwargs` and how to construct the vectorised environments.
See [Environments from the Hub](../envhub) for using published environments and
[Adding a New Benchmark](../adding_benchmarks) for contributing one.
## EnvConfig
[[autodoc]] lerobot.envs.EnvConfig
## make_env
[[autodoc]] lerobot.envs.make_env
## make_env_config
[[autodoc]] lerobot.envs.make_env_config
+23
View File
@@ -0,0 +1,23 @@
# Motors
`MotorsBus` is the low-level interface to a chain of servos on a serial bus. Robots use it to read positions
and write goal positions; you rarely touch it directly unless you are adding hardware.
See [Bring Your Own Hardware](../integrate_hardware) for adding a new bus, and
[Updating Feetech Firmware](../feetech) and [Damiao Motors and CAN Bus](../damiao) for device-specific notes.
## MotorsBus
[[autodoc]] lerobot.motors.motors_bus.MotorsBus
## Motor
[[autodoc]] lerobot.motors.Motor
## MotorCalibration
[[autodoc]] lerobot.motors.MotorCalibration
## MotorNormMode
[[autodoc]] lerobot.motors.MotorNormMode
+20
View File
@@ -0,0 +1,20 @@
# Policies
Every policy inherits [`PreTrainedPolicy`], which combines a `torch.nn.Module` with the Hub mixin, so any
policy can be pushed to and loaded from the Hugging Face Hub with the same two calls.
Each policy has its own guide with training recipes and results — [ACT](../act), [SmolVLA](../smolvla),
[π₀](../pi0), [π₀.₅](../pi05) and the rest are listed under Policies. To add one, see
[Adding a Policy](../bring_your_own_policies).
## PreTrainedPolicy
[[autodoc]] lerobot.policies.pretrained.PreTrainedPolicy
## PreTrainedConfig
[[autodoc]] lerobot.configs.PreTrainedConfig
## make_policy
[[autodoc]] lerobot.policies.factory.make_policy
+20
View File
@@ -0,0 +1,20 @@
# Processors
Processors are the data transformation layer between a robot, a dataset and a policy. A pipeline is a chain
of [`ProcessorStep`]s; each step declares how it transforms both the data and the feature contract.
See [Introduction to Robot Processors](../introduction_processors) for the concepts,
[Implement your own processor](../implement_your_own_processor) to write a step, and
[Debug your processor pipeline](../debug_processor_pipeline) when a pipeline misbehaves.
## ProcessorStep
[[autodoc]] lerobot.processor.pipeline.ProcessorStep
## DataProcessorPipeline
[[autodoc]] lerobot.processor.pipeline.DataProcessorPipeline
## PolicyProcessorPipeline
[[autodoc]] lerobot.processor.pipeline.PolicyProcessorPipeline
+147
View File
@@ -0,0 +1,147 @@
# Robots
Every robot in LeRobot implements the [`Robot`] interface: connect, read an observation, send an action,
disconnect. Writing a policy or a recording script against that interface means it works with any supported
arm without change.
This page is the generated reference. For wiring, calibration and first-run instructions, start with the
hardware guides — [SO-101](../so101), [LeKiwi](../lekiwi), [Hope Jr](../hope_jr), [Reachy 2](../reachy2),
[OpenArm](../openarm) — or [Imitation Learning for Robots](../il_robots) for the end-to-end workflow. To add
a robot of your own, see [Bring Your Own Hardware](../integrate_hardware).
## Robot
The abstract base class. Subclasses implement every method below; the contract described here is what a
policy or recording loop can rely on.
[[autodoc]] lerobot.robots.Robot
- connect
- disconnect
- configure
- calibrate
- get_observation
- send_action
- observation_features
- action_features
- is_connected
- is_calibrated
## RobotConfig
[[autodoc]] lerobot.robots.RobotConfig
## make_robot_from_config
[[autodoc]] lerobot.robots.make_robot_from_config
## SO-100 and SO-101 followers
`SO100Follower` and `SO101Follower` are aliases of the same `SOFollower` class; the two arms differ in their
configuration, not their control code. `SO100FollowerConfig` and `SO101FollowerConfig` are likewise aliases
of `SOFollowerRobotConfig`.
[[autodoc]] lerobot.robots.so_follower.SOFollower
- all
[[autodoc]] lerobot.robots.so_follower.SOFollowerRobotConfig
## BiSOFollower
Two SO followers driven as one bimanual robot.
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollower
- all
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollowerConfig
## KochFollower
[[autodoc]] lerobot.robots.koch_follower.KochFollower
- all
[[autodoc]] lerobot.robots.koch_follower.KochFollowerConfig
## LeKiwi
`LeKiwi` runs on the robot itself. `LeKiwiClient` is the host-side proxy that talks to it over the network
and presents the same [`Robot`] interface.
[[autodoc]] lerobot.robots.lekiwi.LeKiwi
- all
[[autodoc]] lerobot.robots.lekiwi.LeKiwiConfig
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClient
- all
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClientConfig
## OpenArmFollower
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollower
- all
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollowerConfig
## BiOpenArmFollower
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollower
- all
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollowerConfig
## OmxFollower
[[autodoc]] lerobot.robots.omx_follower.OmxFollower
- all
[[autodoc]] lerobot.robots.omx_follower.OmxFollowerConfig
## Reachy2Robot
[[autodoc]] lerobot.robots.reachy2.Reachy2Robot
- all
[[autodoc]] lerobot.robots.reachy2.Reachy2RobotConfig
## UnitreeG1
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1
- all
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1Config
## Hope Jr
The Hope Jr humanoid is exposed as two independent robots, an arm and a hand.
[[autodoc]] lerobot.robots.hope_jr.HopeJrArm
- all
[[autodoc]] lerobot.robots.hope_jr.HopeJrArmConfig
[[autodoc]] lerobot.robots.hope_jr.HopeJrHand
- all
[[autodoc]] lerobot.robots.hope_jr.HopeJrHandConfig
## RebotB601Follower
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601Follower
- all
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601FollowerRobotConfig
## BiRebotB601Follower
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601Follower
- all
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601FollowerConfig
## EarthRoverMiniPlus
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlus
- all
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlusConfig
+258
View File
@@ -0,0 +1,258 @@
# Teleoperators
A teleoperator produces actions for a robot to follow — a leader arm, a gamepad, a keyboard, a phone. All of
them implement the [`Teleoperator`] interface, so a recording script written against it works with any input
device.
See [Phone teleoperation](../phone_teleop) and [Isaac Teleop](../isaac_teleop) for setup guides, and
[Imitation Learning for Robots](../il_robots) for the recording workflow.
## Teleoperator
[[autodoc]] lerobot.teleoperators.Teleoperator
- connect
- disconnect
- configure
- calibrate
- get_action
- send_feedback
- action_features
- feedback_features
- is_connected
- is_calibrated
## TeleoperatorConfig
[[autodoc]] lerobot.teleoperators.TeleoperatorConfig
## make_teleoperator_from_config
[[autodoc]] lerobot.teleoperators.make_teleoperator_from_config
## SO-100 and SO-101 leaders
`SO100Leader` and `SO101Leader` are aliases of the same `SOLeader` class; the two arms differ in their
configuration, not their control code. `SO100LeaderConfig` and `SO101LeaderConfig` are likewise aliases of
`SOLeaderTeleopConfig`.
[[autodoc]] lerobot.teleoperators.so_leader.SOLeader
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.so_leader.SOLeaderTeleopConfig
## KochLeader
[[autodoc]] lerobot.teleoperators.koch_leader.KochLeader
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.koch_leader.KochLeaderConfig
## OmxLeader
[[autodoc]] lerobot.teleoperators.omx_leader.OmxLeader
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.omx_leader.OmxLeaderConfig
## OpenArmLeader
CAN-based leader arm using Damiao motors.
[[autodoc]] lerobot.teleoperators.openarm_leader.OpenArmLeader
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.openarm_leader.OpenArmLeaderConfig
## BiOpenArmLeader
A bimanual pair of `OpenArmLeader` arms.
[[autodoc]] lerobot.teleoperators.bi_openarm_leader.BiOpenArmLeader
- all
[[autodoc]] lerobot.teleoperators.bi_openarm_leader.BiOpenArmLeaderConfig
## OpenArmMini
CAN-based leader arm using Damiao motors, a smaller/simpler OpenArm variant.
[[autodoc]] lerobot.teleoperators.openarm_mini.OpenArmMini
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.openarm_mini.OpenArmMiniConfig
## BiOpenArmMini
A bimanual pair of `OpenArmMini` arms.
[[autodoc]] lerobot.teleoperators.bi_openarm_mini.BiOpenArmMini
- all
[[autodoc]] lerobot.teleoperators.bi_openarm_mini.BiOpenArmMiniConfig
## HomunculusArm
A wearable exoskeleton arm read over a serial link.
[[autodoc]] lerobot.teleoperators.homunculus.HomunculusArm
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.homunculus.HomunculusArmConfig
## HomunculusGlove
A wearable exoskeleton glove read over a serial link, remapped to HopeJR hand joints via
`homunculus_glove_to_hope_jr_hand`.
[[autodoc]] lerobot.teleoperators.homunculus.HomunculusGlove
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.homunculus.HomunculusGloveConfig
[[autodoc]] lerobot.teleoperators.homunculus.homunculus_glove_to_hope_jr_hand
## RebotArm102Leader
[[autodoc]] lerobot.teleoperators.rebot_102_leader.RebotArm102Leader
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.rebot_102_leader.RebotArm102LeaderTeleopConfig
## BiRebot102Leader
A bimanual pair of `RebotArm102Leader` arms.
[[autodoc]] lerobot.teleoperators.bi_rebot_102_leader.BiRebot102Leader
- all
[[autodoc]] lerobot.teleoperators.bi_rebot_102_leader.BiRebot102LeaderConfig
## BiSOLeader
A bimanual pair of `SOLeader` arms.
[[autodoc]] lerobot.teleoperators.bi_so_leader.BiSOLeader
- all
[[autodoc]] lerobot.teleoperators.bi_so_leader.BiSOLeaderConfig
## Phone
Reads pose and touch input from a phone app (iOS or Android).
[[autodoc]] lerobot.teleoperators.phone.Phone
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.phone.PhoneConfig
## Keyboard
`KeyboardTeleop`, `KeyboardEndEffectorTeleop`, and `KeyboardRoverTeleop` read key-press events for manual
control, targeting joint-space, end-effector, or mobile-base actions respectively.
[[autodoc]] lerobot.teleoperators.keyboard.KeyboardTeleop
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.keyboard.KeyboardTeleopConfig
[[autodoc]] lerobot.teleoperators.keyboard.KeyboardEndEffectorTeleop
- all
- action_features
[[autodoc]] lerobot.teleoperators.keyboard.KeyboardEndEffectorTeleopConfig
[[autodoc]] lerobot.teleoperators.keyboard.KeyboardRoverTeleop
- all
- action_features
- is_calibrated
[[autodoc]] lerobot.teleoperators.keyboard.KeyboardRoverTeleopConfig
## GamepadTeleop
Reads joystick/button input from a gamepad via pygame.
[[autodoc]] lerobot.teleoperators.gamepad.GamepadTeleop
- all
- action_features
- feedback_features
- is_connected
[[autodoc]] lerobot.teleoperators.gamepad.GamepadTeleopConfig
## UnitreeG1Teleoperator
A wearable exoskeleton for teleoperating the Unitree G1 humanoid's arms, mapping exoskeleton joint angles to
G1 end-effector poses via forward/inverse kinematics.
[[autodoc]] lerobot.teleoperators.unitree_g1.UnitreeG1Teleoperator
- all
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.unitree_g1.UnitreeG1TeleoperatorConfig
[[autodoc]] lerobot.teleoperators.unitree_g1.ExoskeletonArm
- all
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.unitree_g1.ExoskeletonArmPortConfig
[[autodoc]] lerobot.teleoperators.unitree_g1.ExoskeletonIKHelper
- all
[[autodoc]] lerobot.teleoperators.unitree_g1.ExoskeletonCalibration
[[autodoc]] lerobot.teleoperators.unitree_g1.ExoskeletonJointCalibration
## Reachy2Teleoperator
[[autodoc]] lerobot.teleoperators.reachy2_teleoperator.Reachy2Teleoperator
- all
- action_features
- feedback_features
- is_connected
- is_calibrated
[[autodoc]] lerobot.teleoperators.reachy2_teleoperator.Reachy2TeleoperatorConfig
+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:
+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).
+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.
+287
View File
@@ -0,0 +1,287 @@
# Writing docstrings
LeRobot's API reference is generated directly from the docstrings in `src/lerobot/`. A docstring is not a
comment — it is the published documentation for that object, and the format below is what the renderer and
the CI checks parse.
This page is the contract. If you are adding or editing anything public in `src/lerobot/`, follow it.
> [!IMPORTANT]
> **An undocumented public method is an invisible one.** `[[autodoc]]` silently skips members that have no
> docstring — no warning, no error, it simply does not appear on the rendered page. Coverage and
> API-reference completeness are the same problem.
## The format in one example
Google section headers, Hugging Face type formatting. Both, not one or the other.
````python
def send_action(self, action: RobotAction, rate_hz: float = 30.0) -> RobotAction:
"""Command the robot to move to a target joint configuration.
Values are clipped by the configured maximum relative target before reaching the motors, so the
returned action may differ from the requested one.
Args:
action (`dict[str, float]`):
Target values keyed by motor name, e.g. `{"shoulder_pan.pos": 0.0}`. Keys must match the
robot's action features.
rate_hz (`float`, *optional*, defaults to `30.0`):
Control loop frequency.
Returns:
`dict[str, float]`: The action actually written to the motors after safety clipping.
Raises:
DeviceNotConnectedError: If the robot has not been connected.
Example:
```python
>>> from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0")) # doctest: +SKIP
>>> robot.connect() # doctest: +SKIP
>>> robot.send_action({"shoulder_pan.pos": 0.0}) # doctest: +SKIP
```
"""
````
Cross-references are omitted from the examples on this page — see [Cross-references](#cross-references) for
their syntax and why they cannot be shown inside a code block.
## Rules
### Sections
`Args:` · `Returns:` · `Raises:` · `Yields:` · `Example:` · `Note:`
In that order. No other section headers. A one-line summary comes first, then an optional free-form
description, then the sections.
### The `Args:` line is machine-parsed
```
name (`type`, *optional*, defaults to `X`):
Description, indented on its own line.
```
The `*optional*, defaults to` clause is **checked against the real signature default** by
`make check-docstrings`. It is not decorative — if you write a default that has drifted from the code, CI
fails. Omit the clause entirely for required parameters:
```python
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps the magnitude of the relative positional target vector. `None` disables clipping.
use_degrees (`bool`, *optional*, defaults to `True`):
Keep `True` for backward compatibility with existing policies and datasets.
```
Types go in backticks. Use `*optional*` with no `defaults to` when the default is `None` or is otherwise not
worth restating.
### `Returns:` is type-first
One indented line, type first, then a colon, then the description:
```python
Returns:
`dict[str, float]`: The action actually written to the motors after safety clipping.
```
`Yields:` takes the same shape.
### `**Attributes**:`, never `Attributes:`
doc-builder parses a bare `Attributes:` as a **synonym for `Parameters:`**, so your attributes get rendered
as constructor arguments. This is silent and wrong. Whenever the attributes differ from the constructor
parameters, use the bold form with a `--` separator:
```python
class Robot(abc.ABC):
"""The base abstract class for all LeRobot-compatible robots.
**Attributes**:
- **config_class** (`type[RobotConfig]`) -- The expected configuration class for this robot.
- **name** (`str`) -- The unique robot name used to identify this robot type.
"""
```
Note `--`, not `:`.
### Cross-references
Use doc-builder's bracket syntax: a square-bracketed backtick-quoted path. **Sphinx roles (`:pymeth:`,
`:pyattr:`) are not supported** and render as literal text on the page.
| Want | Write |
| ---------------------------- | ----------------------------------- |
| Class in the main package | &#91;`Robot`&#93; |
| Method, show the full path | &#91;`Robot.connect`&#93; |
| Method, show the bare name | &#91;`~Robot.connect`&#93; |
| Nested path | &#91;`~robots.Robot.connect`&#93; |
| Object in another HF library | &#91;`~accelerate.Accelerator`&#93; |
The `~` strips the path from the **link text only**; the link still resolves to the full path.
> [!NOTE]
> doc-builder resolves this syntax everywhere in a page — including inside fenced code blocks. That is why
> the docstring examples on this page use plain prose instead of cross-references: a code block containing
> one would render the resolved link rather than the syntax you need to type. In your own docstrings, use
> cross-references freely; this restriction only affects documentation _about_ the syntax.
### Callouts
Use GitHub-style blockquotes:
```markdown
> [!TIP]
> Call this once at startup — it takes about two seconds.
> [!WARNING]
> Torque is disabled on disconnect. The arm will drop if it is holding a load.
```
The `<Tip>` component is legacy per doc-builder; don't add new ones.
### Examples must be fenced
An example lives inside a fenced ` ```python ` block containing `>>> `. The fence is what makes it render
as a code block, and it is what the doctest preprocessor's regex looks for:
````python
Example:
```python
>>> from lerobot.robots.so_follower import SO101FollowerConfig
>>> cfg = SO101FollowerConfig(port="/dev/ttyACM0")
>>> cfg.use_degrees
True
```
````
> [!WARNING]
> An unfenced `>>>` is still collected — doctest finds prompts anywhere in a docstring. What you lose is the
> rendering, so it shows up as a wall of prose on the page. Every example needs the fence.
Every example either executes in CI or carries `# doctest: +SKIP`. Anything that touches hardware, a GPU, or
downloads from the Hub gets `+SKIP`:
````python
Example:
```python
>>> robot.connect() # doctest: +SKIP
>>> policy = ACTPolicy.from_pretrained("lerobot/act_aloha_sim_transfer_cube_human") # doctest: +SKIP
```
````
Add files containing runnable examples to `utils/documentation_tests.txt`.
Put examples on the three to five genuine entry points of a module. Examples on trivial accessors are noise.
## Three patterns you will hit constantly
### Config dataclasses
Configuration fields are historically documented with `#` comments above each field. **doc-builder cannot
see inline comments** — such a class renders with every field listed and not a single description. Move them
into an `Args:` block on the class docstring:
```python
@dataclass
class SOFollowerConfig:
"""Configuration for SO-family follower arms.
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps the magnitude of the relative positional target vector. A scalar applies to all motors;
a dict maps motor name to a per-motor cap. `None` disables clipping.
use_degrees (`bool`, *optional*, defaults to `True`):
Keep `True` for backward compatibility with existing policies and datasets.
"""
port: str
max_relative_target: float | dict[str, float] | None = None
use_degrees: bool = True
```
> [!IMPORTANT]
> **doc-builder does not inherit docstrings from base classes.** LeRobot's registered config classes are
> often thin multiple-inheritance shims:
>
> ```python
> @RobotConfig.register_subclass("so101_follower")
> @dataclass
> class SOFollowerRobotConfig(RobotConfig, SOFollowerConfig):
> pass
> ```
>
> That class renders **every** field — including the ones it inherits — with no descriptions at all, no
> matter how well the bases are documented. The `Args:` block must live on the concrete class that
> `[[autodoc]]` names, and it must cover inherited fields too.
### Base class, then concrete subclass
The abstract base carries the canonical contract. Subclasses document only what deviates — port semantics,
calibration quirks, motor layout, supported feature keys. Do not copy the base contract into every subclass.
`Robot`, `Teleoperator`, `Camera`, `MotorsBus`, `ProcessorStep`, and `PreTrainedPolicy` all follow this
shape.
### Module-level aliases
Several public names are aliases rather than distinct classes:
```python
SO100FollowerConfig = SOFollowerRobotConfig
SO101FollowerConfig = SOFollowerRobotConfig
```
`[[autodoc]]` resolves the alias and renders the **canonical** class name, so a `## SO101FollowerConfig`
heading will show `class lerobot.robots.so_follower.SOFollowerRobotConfig` in the body. Document the
canonical class once, and mention the aliases in the page's prose rather than giving each alias its own
autodoc block.
## What not to document
- **Private members.** Anything starting with `_` is not part of the public API.
- **The type annotation restated as prose.** `port (`str`): A string.` adds nothing. Say what it is for.
- **Vendored upstream code.** `src/lerobot/policies/molmoact2/molmoact2_hf_model/` is vendored from
`transformers` and already carries upstream-style docstrings. Leave it alone — restyling it only creates
conflicts on the next sync. It is excluded from the API reference and from the docstring checks.
## How this is enforced
| Check | What it catches |
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `make check-docstrings` | An `Args:` entry that doesn't match the signature; a documented default that has drifted from the real one |
| `make doctest` | Examples that no longer run |
| `make check-doctest-list` | Stale or unsorted entries in `utils/documentation_tests.txt` |
| `ruff` (`D` rules) | Google-convention style violations |
| `interrogate` | Docstring coverage falling below the current threshold |
| doc-builder | A `[[autodoc]]` path that points at something that doesn't exist — this breaks the docs build |
Run them together before opening a PR:
```bash
make check-docstrings && make doctest && pre-commit run --all-files
```
Then render the page and actually look at it:
```bash
doc-builder build lerobot docs/source/ --build_dir /tmp/doc-build
```
## Checklist
- [ ] Every public member you touched has a docstring.
- [ ] Every `Args:` entry matches the signature, including the `*optional*, defaults to` clause.
- [ ] `Returns:` is type-first on one indented line.
- [ ] No bare `Attributes:` — use `**Attributes**:` with `--` separators.
- [ ] No Sphinx roles — cross-references use &#91;`~module.Class.method`&#93;.
- [ ] Examples are inside a fenced ` ```python ` block, and either run in CI or carry `# doctest: +SKIP`.
- [ ] Config dataclass fields are in an `Args:` block on the concrete class, not `#` comments.
- [ ] The rendered page has been eyeballed.
+81 -17
View File
@@ -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"
@@ -400,19 +401,64 @@ exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"]
# N: pep8-naming
# TODO: Uncomment rules when ready to use
select = [
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM" #, "A", "S", "D", "RUF"
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM", "D" #, "A", "S", "RUF"
]
ignore = [
"E501", # Line too long
"T201", # Print statement found
"T203", # Pprint statement found
"B008", # Perform function call in argument defaults
# D100/D104: module- and package-level docstrings. The API reference is generated from class and
# function docstrings; a banner at the top of every file and every __init__.py would not appear on any
# rendered page. Coverage of the things that do get rendered is enforced by interrogate instead.
"D100",
"D104",
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "E402"]
"__init__.py" = ["F401", "F403", "E402", "D104"]
# E402: conditional-import guards (TYPE_CHECKING / is_package_available) must precede the imports they protect
"src/lerobot/scripts/convert_dataset_v21_to_v30.py" = ["E402"]
# D (pydocstyle) is enabled globally, but only holds for code that has been converted to the docstring
# standard in docs/source/writing_docstrings.mdx. Every module below is still on the old style; each entry
# is deleted as that module is converted, and this block can be removed once it is empty.
#
# Not part of the API reference and not planned for conversion: tests, examples, benchmarks, templates,
# CI helper scripts and the packaging shim.
"tests/**" = ["D"]
"examples/**" = ["D"]
"benchmarks/**" = ["D"]
"scripts/**" = ["D"]
"setup.py" = ["D"]
"src/lerobot/templates/**" = ["D"]
# Vendored from transformers; keeps its upstream docstring style so syncs stay clean.
"src/lerobot/policies/molmoact2/molmoact2_hf_model/**" = ["D"]
# Awaiting conversion, one PR per module.
"src/lerobot/annotations/**" = ["D"]
"src/lerobot/async_inference/**" = ["D"]
"src/lerobot/cameras/**" = ["D"]
"src/lerobot/common/**" = ["D"]
"src/lerobot/configs/**" = ["D"]
"src/lerobot/data_processing/**" = ["D"]
"src/lerobot/datasets/**" = ["D"]
"src/lerobot/distributed/**" = ["D"]
"src/lerobot/envs/**" = ["D"]
"src/lerobot/jobs/**" = ["D"]
"src/lerobot/model/**" = ["D"]
"src/lerobot/motors/**" = ["D"]
"src/lerobot/optim/**" = ["D"]
"src/lerobot/policies/**" = ["D"]
"src/lerobot/processor/**" = ["D"]
"src/lerobot/rewards/**" = ["D"]
"src/lerobot/rl/**" = ["D"]
"src/lerobot/rollout/**" = ["D"]
"src/lerobot/scripts/**" = ["D"]
"src/lerobot/teleoperators/teleoperator.py" = ["D"]
"src/lerobot/transforms/**" = ["D"]
"src/lerobot/transport/**" = ["D"]
"src/lerobot/utils/**" = ["D"]
"src/lerobot/lerobot_types.py" = ["D"]
[tool.ruff.lint.isort]
combine-as-imports = true
known-first-party = ["lerobot"]
@@ -456,25 +502,34 @@ default.extend-ignore-identifiers-re = [
"seperated_timestep",
]
# TODO: Uncomment when ready to use
# [tool.interrogate]
# ignore-init-module = true
# ignore-init-method = true
# ignore-nested-functions = false
# ignore-magic = false
# ignore-semiprivate = false
# ignore-private = false
# ignore-property-decorators = false
# ignore-module = false
# ignore-setters = false
# fail-under = 80
# output-format = "term-missing"
# color = true
# paths = ["src/lerobot"]
# Docstring coverage gate. `fail-under` is a RATCHET, not a target: it is set just below the currently
# measured coverage so it passes today, and is raised in the same PR that documents a module. Never set it
# to a value that fails on main. The destination is 100; see docs/source/writing_docstrings.mdx.
[tool.interrogate]
ignore-init-module = true
ignore-init-method = true
ignore-nested-functions = false
ignore-magic = false
ignore-semiprivate = false
ignore-private = false
ignore-property-decorators = false
ignore-module = false
ignore-setters = false
fail-under = 55
output-format = "term-missing"
color = true
paths = ["src/lerobot"]
exclude = ["src/lerobot/policies/molmoact2/molmoact2_hf_model"]
# 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 +576,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
+1 -2
View File
@@ -14,8 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
LeRobot -- PyTorch library for real-world robotics.
"""LeRobot -- PyTorch library for real-world robotics.
Provides datasets, pretrained policies, and tools for training, evaluation,
data collection, and robot control. Integrates with Hugging Face Hub for
+1 -1
View File
@@ -13,7 +13,7 @@
# 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.
"""To enable `lerobot.__version__`"""
"""To enable `lerobot.__version__`."""
from importlib.metadata import PackageNotFoundError, version
+4 -4
View File
@@ -33,10 +33,10 @@ class Camera(abc.ABC):
- Connection/disconnection
- Frame capture (sync/async/latest)
Attributes:
fps (int | None): Configured frames per second
width (int | None): Frame width in pixels
height (int | None): Frame height in pixels
**Attributes**:
- **fps** (`int | None`) -- Configured frames per second.
- **width** (`int | None`) -- Frame width in pixels.
- **height** (`int | None`) -- Frame height in pixels.
"""
def __init__(self, config: CameraConfig):
@@ -40,17 +40,20 @@ class OpenCVCameraConfig(CameraConfig):
OpenCVCameraConfig(0, 30, 1280, 720, fourcc="YUYV") # With YUYV format
```
Attributes:
index_or_path: Either an integer representing the camera device index,
or a Path object pointing to a video file.
fps: Requested frames per second for the color stream.
width: Requested frame width in pixels for the color stream.
height: Requested frame height in pixels for the color stream.
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation.
warmup_s: Time reading frames before returning from connect (in seconds)
fourcc: FOURCC code for video format (e.g., "MJPG", "YUYV", "I420"). Defaults to None (auto-detect).
backend: OpenCV backend identifier (https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html). Defaults to ANY.
**Attributes**:
- **index_or_path** (`int | Path`) -- Either an integer representing the camera device index, or a
Path object pointing to a video file.
- **fps** -- Requested frames per second for the color stream.
- **width** -- Requested frame width in pixels for the color stream.
- **height** -- Requested frame height in pixels for the color stream.
- **color_mode** (`ColorMode`) -- Color mode for image output (RGB or BGR). Defaults to RGB.
- **rotation** (`Cv2Rotation`) -- Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no
rotation.
- **warmup_s** (`int`) -- Time reading frames before returning from connect (in seconds)
- **fourcc** (`str | None`) -- FOURCC code for video format (e.g., "MJPG", "YUYV", "I420"). Defaults
to None (auto-detect).
- **backend** (`Cv2Backends`) -- OpenCV backend identifier
(https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html). Defaults to ANY.
Note:
- Only 3-channel color output (RGB/BGR) is currently supported.
@@ -43,16 +43,16 @@ class Reachy2CameraConfig(CameraConfig):
) # Left teleop camera, 640x480 @ 30FPS
```
Attributes:
name: Name of the camera device. Can be "teleop" or "depth".
image_type: Type of image stream. For "teleop" camera, can be "left" or "right".
For "depth" camera, can be "rgb" or "depth". (depth is not supported yet)
fps: Requested frames per second for the color stream. Not configurable for Reachy 2 cameras.
width: Requested frame width in pixels for the color stream.
height: Requested frame height in pixels for the color stream.
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
ip_address: IP address of the robot. Defaults to "localhost".
port: Port number for the camera server. Defaults to 50065.
**Attributes**:
- **name** (`str`) -- Name of the camera device. Can be "teleop" or "depth".
- **image_type** (`str`) -- Type of image stream. For "teleop" camera, can be "left" or "right". For
"depth" camera, can be "rgb" or "depth". (depth is not supported yet)
- **fps** -- Requested frames per second for the color stream. Not configurable for Reachy 2 cameras.
- **width** -- Requested frame width in pixels for the color stream.
- **height** -- Requested frame height in pixels for the color stream.
- **color_mode** (`ColorMode`) -- Color mode for image output (RGB or BGR). Defaults to RGB.
- **ip_address** (`str | None`) -- IP address of the robot. Defaults to "localhost".
- **port** (`int`) -- Port number for the camera server. Defaults to 50065.
Note:
- Only 3-channel color output (RGB/BGR) is currently supported.
@@ -36,27 +36,28 @@ class RealSenseCameraConfig(CameraConfig):
RealSenseCameraConfig("0123456789", 30, 640, 480, rotation=Cv2Rotation.ROTATE_90) # With 90° rotation
```
Attributes:
fps: Requested frames per second for the color stream.
width: Requested frame width in pixels for the color stream.
height: Requested frame height in pixels for the color stream.
serial_number_or_name: Unique serial number or human-readable name to identify the camera.
color_mode: Color mode for image output (RGB or BGR). Defaults to RGB.
use_rgb: Whether to enable the color stream. Defaults to True.
use_depth: Whether to enable depth stream. Defaults to False.
rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation.
warmup_s: Time reading frames before returning from connect (in seconds)
exposure: Manual exposure value for the color sensor. When set, auto-exposure is
disabled and this fixed value is used. Valid ranges are camera-model specific
and reported if the value is rejected. Defaults to None (leave unchanged).
gain: Manual gain value for the color sensor. When set, auto-exposure is disabled
and this fixed gain is used, which also freezes exposure at its current value
when no exposure is configured. Valid ranges are camera-model specific and
reported if the value is rejected. Defaults to None (leave unchanged).
white_balance: Manual white balance value for the color sensor. When set, auto
white balance is disabled and this fixed value is used. Valid ranges are
camera-model specific and reported if the value is rejected. Defaults to None
(leave unchanged).
**Attributes**:
- **fps** -- Requested frames per second for the color stream.
- **width** -- Requested frame width in pixels for the color stream.
- **height** -- Requested frame height in pixels for the color stream.
- **serial_number_or_name** (`str`) -- Unique serial number or human-readable name to identify the
camera.
- **color_mode** (`ColorMode`) -- Color mode for image output (RGB or BGR). Defaults to RGB.
- **use_rgb** (`bool`) -- Whether to enable the color stream. Defaults to True.
- **use_depth** (`bool`) -- Whether to enable depth stream. Defaults to False.
- **rotation** (`Cv2Rotation`) -- Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no
rotation.
- **warmup_s** (`int`) -- Time reading frames before returning from connect (in seconds)
- **exposure** (`int | None`) -- Manual exposure value for the color sensor. When set, auto-exposure
is disabled and this fixed value is used. Valid ranges are camera-model specific and reported if the
value is rejected. Defaults to None (leave unchanged).
- **gain** (`int | None`) -- Manual gain value for the color sensor. When set, auto-exposure is
disabled and this fixed gain is used, which also freezes exposure at its current value when no
exposure is configured. Valid ranges are camera-model specific and reported if the value is
rejected. Defaults to None (leave unchanged).
- **white_balance** (`int | None`) -- Manual white balance value for the color sensor. When set, auto
white balance is disabled and this fixed value is used. Valid ranges are camera-model specific and
reported if the value is rejected. Defaults to None (leave unchanged).
Note:
- Either name or serial_number must be specified.
+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
+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,
)
+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"))
+92
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,6 +27,8 @@ 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
@@ -39,6 +42,34 @@ 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,9 +152,16 @@ 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)
wandb: WandBConfig = field(default_factory=WandBConfig)
peft: PeftConfig | None = None
@@ -291,6 +329,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."""
+1 -6
View File
@@ -240,12 +240,7 @@ class DatasetReader:
def _get_query_indices(
self, abs_idx: int, ep_idx: int
) -> tuple[dict[str, list[int]], dict[str, torch.Tensor]]:
"""Compute query indices for delta timestamps.
A delta is padding when ``abs_idx + delta`` falls outside the episode's
``[dataset_from_index, dataset_to_index)`` range, and is clamped back into it
otherwise.
"""
"""Compute query indices for delta timestamps."""
ep = self._meta.episodes[ep_idx]
ep_start = ep["dataset_from_index"]
ep_end = ep["dataset_to_index"]
+87 -45
View File
@@ -32,6 +32,8 @@ from .feature_utils import get_delta_indices
from .io_utils import item_to_torch
from .utils import (
check_version_compatibility,
find_float_index,
is_float_in_list,
safe_shard,
)
from .video_utils import (
@@ -476,28 +478,49 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
lookback, lookahead = self._get_window_steps(self.delta_timestamps)
return Backtrackable(dataset, history=lookback, lookahead=lookahead)
def _get_query_indices(
self, abs_idx: int, ep_idx: int
) -> tuple[dict[str, list[int]], dict[str, torch.BoolTensor]]:
"""Video-key query indices and padding from integer episode boundaries.
def _make_timestamps_from_indices(
self, start_ts: float, indices: dict[str, list[int]] | None = None
) -> dict[str, list[float]]:
if indices is not None:
return {
key: (
start_ts + torch.tensor(indices[key]) / self.fps
).tolist() # NOTE: why not delta_timestamps directly?
for key in self.delta_timestamps
}
else:
return dict.fromkeys(self.meta.video_keys, [start_ts])
Mirrors ``DatasetReader._get_query_indices`` but only for video keys.
"""
ep = self.meta.episodes[ep_idx]
ep_start, ep_end = ep["dataset_from_index"], ep["dataset_to_index"]
query_indices = {
key: [max(ep_start, min(ep_end - 1, abs_idx + delta)) for delta in delta_idx]
for key, delta_idx in self.delta_indices.items()
if key in self.meta.video_keys
}
padding = {
f"{key}_is_pad": torch.BoolTensor(
[(abs_idx + delta < ep_start) or (abs_idx + delta >= ep_end) for delta in delta_idx]
)
for key, delta_idx in self.delta_indices.items()
if key in self.meta.video_keys
}
return query_indices, padding
def _make_padding_camera_frame(self, camera_key: str):
"""Variable-shape padding frame for given camera keys, given in (H, W, C)"""
return torch.zeros(self.meta.info.features[camera_key]["shape"]).permute(-1, 0, 1)
def _get_video_frame_padding_mask(
self,
video_frames: dict[str, torch.Tensor],
query_timestamps: dict[str, list[float]],
original_timestamps: dict[str, list[float]],
) -> dict[str, torch.BoolTensor]:
padding_mask = {}
for video_key, timestamps in original_timestamps.items():
if video_key not in video_frames:
continue # only padding on video keys that are available
frames = []
mask = []
padding_frame = self._make_padding_camera_frame(video_key)
for ts in timestamps:
if is_float_in_list(ts, query_timestamps[video_key]):
idx = find_float_index(ts, query_timestamps[video_key])
frames.append(video_frames[video_key][idx, :])
mask.append(False)
else:
frames.append(padding_frame)
mask.append(True)
padding_mask[f"{video_key}_is_pad"] = torch.BoolTensor(mask)
return padding_mask
def make_frame(self, dataset_iterator: Backtrackable) -> Generator:
"""Makes a frame starting from a dataset iterator"""
@@ -510,11 +533,19 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
updates = [] # list of "updates" to apply to the item retrieved from hf_dataset (w/o camera features)
# Get episode index and absolute frame index from the item
# Get episode index from the item
ep_idx = item["episode_index"]
abs_idx = int(item["index"])
ep_start = self.meta.episodes[ep_idx]["dataset_from_index"]
current_ts = float(item["timestamp"])
# "timestamp" restarts from 0 for each episode, whereas we need a global timestep within the single .mp4 file (given by index/fps)
current_ts = item["index"] / self.fps
episode_boundaries_ts = {
key: (
self.meta.episodes[ep_idx][f"videos/{key}/from_timestamp"],
self.meta.episodes[ep_idx][f"videos/{key}/to_timestamp"],
)
for key in self.meta.video_keys
}
# Apply delta querying logic if necessary
if self.delta_indices is not None:
@@ -524,19 +555,12 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
# Load video frames, when needed
if len(self.meta.video_keys) > 0:
query_indices = None
if self.delta_indices is not None:
query_indices, video_padding = self._get_query_indices(abs_idx, ep_idx)
original_timestamps = self._make_timestamps_from_indices(current_ts, self.delta_indices)
# Episode-local timestamps; `_query_videos` shifts them by the per-key `from_timestamp` at decode.
query_timestamps = {
key: (
[(idx - ep_start) / self.fps for idx in query_indices[key]]
if query_indices is not None and key in query_indices
else [current_ts]
)
for key in self.meta.video_keys
}
# Some timestamps might not result available considering the episode's boundaries
query_timestamps = self._get_query_timestamps(
current_ts, self.delta_indices, episode_boundaries_ts
)
video_frames = self._query_videos(query_timestamps, ep_idx)
if self.image_transforms is not None:
@@ -548,7 +572,10 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
if self.delta_indices is not None:
# We always return the same number of frames. Unavailable frames are padded.
updates.append(video_padding)
padding_mask = self._get_video_frame_padding_mask(
video_frames, query_timestamps, original_timestamps
)
updates.append(padding_mask)
result = item.copy()
for update in updates:
@@ -567,6 +594,27 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
yield result
def _get_query_timestamps(
self,
current_ts: float,
query_indices: dict[str, list[int]] | None = None,
episode_boundaries_ts: dict[str, tuple[float, float]] | None = None,
) -> dict[str, list[float]]:
query_timestamps = {}
keys_to_timestamps = self._make_timestamps_from_indices(current_ts, query_indices)
for key in self.meta.video_keys:
if query_indices is not None and key in query_indices:
timestamps = keys_to_timestamps[key]
# Clamp out timesteps outside of episode boundaries
query_timestamps[key] = torch.clamp(
torch.tensor(timestamps), *episode_boundaries_ts[key]
).tolist()
else:
query_timestamps[key] = [current_ts]
return query_timestamps
def _query_videos(self, query_timestamps: dict[str, list[float]], ep_idx: int) -> dict:
"""Note: When using data workers (e.g. DataLoader with num_workers>0), do not call this function
in the main process (e.g. by using a second Dataloader with num_workers=0). It will result in a
@@ -574,14 +622,8 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
the main process and a subprocess fails to access it.
"""
ep = self.meta.episodes[ep_idx]
item = {}
for video_key, ep_local_ts in query_timestamps.items():
# Episode-local timestamps restart from 0 each episode; shift by the per-key
# `from_timestamp` to reach the episode's segment within its video file, matching
# `DatasetReader._decode_single`.
from_timestamp = ep[f"videos/{video_key}/from_timestamp"]
query_ts = [from_timestamp + ts for ts in ep_local_ts]
for video_key, query_ts in query_timestamps.items():
root = self.meta.url_root if self.streaming and not self.streaming_from_local else self.root
video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}"
if video_key in self.meta.depth_keys:
+11
View File
@@ -523,6 +523,17 @@ def create_lerobot_dataset_card(
)
def is_float_in_list(target, float_list, threshold=1e-6):
return any(abs(target - x) <= threshold for x in float_list)
def find_float_index(target, float_list, threshold=1e-6):
for i, x in enumerate(float_list):
if abs(target - x) <= threshold:
return i
return -1
def safe_shard(dataset: datasets.IterableDataset, index: int, num_shards: int) -> datasets.Dataset:
"""
Safe shards the dataset.
+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"
+147
View File
@@ -0,0 +1,147 @@
#!/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. Plugin `__post_init__`s read these silently as
# field fallbacks (ACCELERATE_DYNAMO_* enables torch.compile through the default
# TorchDynamoPlugin; ACCELERATE_GRADIENT_ACCUMULATION_STEPS overrides the explicitly passed
# value inside Accelerator.__init__), which would make train_config.json lie about what ran.
_ACCELERATE_ENV_PREFIXES = ("FSDP_", "PARALLELISM_CONFIG_", "ACCELERATE_DYNAMO_")
_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 os.environ
if name in _ACCELERATE_ENV_VARS or name.startswith(_ACCELERATE_ENV_PREFIXES)
)
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)
+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}"
+24 -17
View File
@@ -314,11 +314,16 @@ class SerialMotorsBus(MotorsBusBase):
To find the port, you can run our utility script:
```bash
lerobot-find-port.py
>>> Finding all available ports for the MotorsBus.
>>> ["/dev/tty.usbmodem575E0032081", "/dev/tty.usbmodem575E0031751"]
>>> Remove the usb cable from your MotorsBus and press Enter when done.
>>> The port of this MotorsBus is /dev/tty.usbmodem575E0031751.
>>> Reconnect the usb cable.
```
which prints:
```
Finding all available ports for the MotorsBus.
["/dev/tty.usbmodem575E0032081", "/dev/tty.usbmodem575E0031751"]
Remove the usb cable from your MotorsBus and press Enter when done.
The port of this MotorsBus is /dev/tty.usbmodem575E0031751.
Reconnect the usb cable.
```
Example of usage for 1 Feetech sts3215 motor connected to the bus:
@@ -595,7 +600,7 @@ class SerialMotorsBus(MotorsBusBase):
ID, and finally programs the bus' default baud-rate.
Args:
motor (str): Key of the motor in :pyattr:`motors`.
motor (str): Key of the motor in `motors`.
initial_baudrate (int | None, optional): Current baud-rate (skips scanning when provided).
Defaults to None.
initial_id (int | None, optional): Current ID (skips scanning when provided). Defaults to None.
@@ -666,7 +671,7 @@ class SerialMotorsBus(MotorsBusBase):
"""Enable torque on selected motors.
Args:
motors (int | str | list[str] | None, optional): Same semantics as :pymeth:`disable_torque`.
motors (int | str | list[str] | None, optional): Same semantics as [`~motors.motors_bus.MotorsBus.disable_torque`].
Defaults to `None`.
num_retry (int, optional): Number of additional retry attempts on communication failure.
Defaults to 0.
@@ -679,10 +684,12 @@ class SerialMotorsBus(MotorsBusBase):
This helper is useful to temporarily disable torque when configuring motors.
Examples:
>>> with bus.torque_disabled():
Example:
```python
>>> with bus.torque_disabled(): # doctest: +SKIP
... # Safe operations here
... pass
```
"""
self.disable_torque(motors)
try:
@@ -695,7 +702,7 @@ class SerialMotorsBus(MotorsBusBase):
Args:
timeout_ms (int | None, optional): Timeout in *milliseconds*. If `None` (default) the method falls
back to :pyattr:`default_timeout`.
back to `default_timeout`.
"""
timeout_ms = timeout_ms if timeout_ms is not None else self.default_timeout
self.port_handler.setPacketTimeoutMillis(timeout_ms)
@@ -746,8 +753,8 @@ class SerialMotorsBus(MotorsBusBase):
Args:
calibration_dict (dict[str, MotorCalibration]): Calibration obtained from
:pymeth:`read_calibration` or crafted by the user.
cache (bool, optional): Save the calibration to :pyattr:`calibration`. Defaults to True.
[`~motors.motors_bus.MotorsBus.read_calibration`] or crafted by the user.
cache (bool, optional): Save the calibration to `calibration`. Defaults to True.
"""
pass
@@ -755,7 +762,7 @@ class SerialMotorsBus(MotorsBusBase):
"""Restore factory calibration for the selected motors.
Homing offset is set to ``0`` and min/max position limits are set to the full usable range.
The in-memory :pyattr:`calibration` is cleared.
The in-memory `calibration` is cleared.
Args:
motors (NameOrID | Sequence[NameOrID] | None, optional): Selection of motors. `None` (default)
@@ -1069,9 +1076,9 @@ class SerialMotorsBus(MotorsBusBase):
) -> None:
"""Write a value to a single motor's register.
Contrary to :pymeth:`sync_write`, this expects a response status packet emitted by the motor, which
Contrary to [`~motors.motors_bus.MotorsBus.sync_write`], this expects a response status packet emitted by the motor, which
provides a guarantee that the value was written to the register successfully. In consequence, it is
slower than :pymeth:`sync_write` but it is more reliable. It should typically be used when configuring
slower than [`~motors.motors_bus.MotorsBus.sync_write`] but it is more reliable. It should typically be used when configuring
motors.
Args:
@@ -1228,8 +1235,8 @@ class SerialMotorsBus(MotorsBusBase):
) -> None:
"""Write the same register on multiple motors.
Contrary to :pymeth:`write`, this *does not* expects a response status packet emitted by the motor, which
can allow for lost packets. It is faster than :pymeth:`write` and should typically be used when
Contrary to [`~motors.motors_bus.MotorsBus.write`], this *does not* expects a response status packet emitted by the motor, which
can allow for lost packets. It is faster than [`~motors.motors_bus.MotorsBus.write`] and should typically be used when
frequency matters and losing some packets is acceptable (e.g. teleoperation loops).
Args:
-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,
+39 -22
View File
@@ -131,12 +131,16 @@ class ProcessorConfigKwargs(TypedDict, total=False):
This provides type hints for the optional arguments passed to `make_pre_post_processors`,
improving code clarity and enabling static analysis.
Attributes:
preprocessor_config_filename: The filename for the preprocessor configuration.
postprocessor_config_filename: The filename for the postprocessor configuration.
preprocessor_overrides: A dictionary of overrides for the preprocessor configuration.
postprocessor_overrides: A dictionary of overrides for the postprocessor configuration.
dataset_stats: Dataset statistics for normalization.
**Attributes**:
- **preprocessor_config_filename** (`str | None`) -- The filename for the preprocessor configuration.
- **postprocessor_config_filename** (`str | None`) -- The filename for the postprocessor
configuration.
- **preprocessor_overrides** (`dict[str, Any] | None`) -- A dictionary of overrides for the
preprocessor configuration.
- **postprocessor_overrides** (`dict[str, Any] | None`) -- A dictionary of overrides for the
postprocessor configuration.
- **dataset_stats** (`dict[str, dict[str, torch.Tensor]] | None`) -- Dataset statistics for
normalization.
"""
preprocessor_config_filename: str | None
@@ -242,6 +246,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 +257,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 +342,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,
+75 -167
View File
@@ -18,20 +18,17 @@ 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.device_utils import resolve_safetensors_device
from lerobot.utils.hub import HubMixin
from lerobot.utils.import_utils import _peft_available, require_package
@@ -46,56 +43,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 +65,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 +98,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(
@@ -291,92 +252,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,
+5 -4
View File
@@ -46,10 +46,11 @@ class ActionQueue:
Args:
cfg (RTCConfig): Configuration for Real-Time Chunking behavior.
Attributes:
queue (Tensor | None): Processed actions for robot rollout (time_steps, action_dim).
original_queue (Tensor | None): Original actions for RTC computation (time_steps, action_dim).
last_index (int): Current consumption index in the queue.
**Attributes**:
- **queue** (`Tensor | None`) -- Processed actions for robot rollout (time_steps, action_dim).
- **original_queue** (`Tensor | None`) -- Original actions for RTC computation (time_steps,
action_dim).
- **last_index** (`int`) -- Current consumption index in the queue.
"""
def __init__(self, cfg: RTCConfig):
+13 -13
View File
@@ -27,19 +27,19 @@ from torch import Tensor
class DebugStep:
"""Container for debug information from a single denoising step.
Attributes:
step_idx (int): Step index/counter.
x_t (Tensor | None): Current latent/state tensor.
v_t (Tensor | None): Velocity from denoiser.
x1_t (Tensor | None): Denoised prediction (x_t - time * v_t).
correction (Tensor | None): Correction gradient tensor.
err (Tensor | None): Weighted error term.
weights (Tensor | None): Prefix attention weights.
guidance_weight (float | Tensor | None): Applied guidance weight.
time (float | Tensor | None): Time parameter.
inference_delay (int | None): Inference delay parameter.
execution_horizon (int | None): Execution horizon parameter.
metadata (dict[str, Any]): Additional metadata.
**Attributes**:
- **step_idx** (`int`) -- Step index/counter.
- **x_t** (`Tensor | None`) -- Current latent/state tensor.
- **v_t** (`Tensor | None`) -- Velocity from denoiser.
- **x1_t** (`Tensor | None`) -- Denoised prediction (x_t - time * v_t).
- **correction** (`Tensor | None`) -- Correction gradient tensor.
- **err** (`Tensor | None`) -- Weighted error term.
- **weights** (`Tensor | None`) -- Prefix attention weights.
- **guidance_weight** (`float | Tensor | None`) -- Applied guidance weight.
- **time** (`float | Tensor | None`) -- Time parameter.
- **inference_delay** (`int | None`) -- Inference delay parameter.
- **execution_horizon** (`int | None`) -- Execution horizon parameter.
- **metadata** (`dict[str, Any]`) -- Additional metadata.
"""
step_idx: int = 0
+6 -4
View File
@@ -217,10 +217,12 @@ class AddBatchDimensionProcessorStep(ProcessorStep):
This step combines individual processors for actions, observations, and complementary data
to create a batched transition (batch size 1) from a single-instance transition.
Attributes:
to_batch_action_processor: Processor for the action component.
to_batch_observation_processor: Processor for the observation component.
to_batch_complementary_data_processor: Processor for the complementary data component.
**Attributes**:
- **to_batch_action_processor** (`AddBatchDimensionActionStep`) -- Processor for the action component.
- **to_batch_observation_processor** (`AddBatchDimensionObservationStep`) -- Processor for the
observation component.
- **to_batch_complementary_data_processor** (`AddBatchDimensionComplementaryDataStep`) -- Processor
for the complementary data component.
"""
to_batch_action_processor: AddBatchDimensionActionStep = field(
@@ -32,9 +32,8 @@ class MapTensorToDeltaActionDictStep(ActionProcessorStep):
It decomposes the vector into named components for delta movements of the
end-effector (x, y, z) and optionally the gripper.
Attributes:
use_gripper: If True, assumes the 4th element of the tensor is the
gripper action.
**Attributes**:
- **use_gripper** (`bool`) -- If True, assumes the 4th element of the tensor is the gripper action.
"""
use_gripper: bool = True
@@ -81,10 +80,10 @@ class MapDeltaActionToRobotActionStep(RobotActionProcessorStep):
into a target action format that includes an "enabled" flag and target
end-effector positions. It also handles scaling and noise filtering.
Attributes:
position_scale: A factor to scale the delta position inputs.
noise_threshold: The magnitude below which delta inputs are considered noise
and do not trigger an "enabled" state.
**Attributes**:
- **position_scale** (`float`) -- A factor to scale the delta position inputs.
- **noise_threshold** (`float`) -- The magnitude below which delta inputs are considered noise and do
not trigger an "enabled" state.
"""
# Scale factors for delta movements
+4 -4
View File
@@ -40,10 +40,10 @@ class DeviceProcessorStep(ProcessorStep):
This is crucial for preparing data for model training or inference on hardware like GPUs.
Attributes:
device: The target device for tensors (e.g., "cpu", "cuda", "cuda:0").
float_dtype: The target floating-point dtype as a string (e.g., "float32", "float16", "bfloat16").
If None, the dtype is not changed.
**Attributes**:
- **device** (`str`) -- The target device for tensors (e.g., "cpu", "cuda", "cuda:0").
- **float_dtype** (`str | None`) -- The target floating-point dtype as a string (e.g., "float32",
"float16", "bfloat16"). If None, the dtype is not changed.
"""
device: str = "cpu"
@@ -33,10 +33,9 @@ class Torch2NumpyActionProcessorStep(ActionProcessorStep):
This step is useful when the output of a policy (typically a torch.Tensor)
needs to be passed to an environment or component that expects a NumPy array.
Attributes:
squeeze_batch_dim: If True, removes the first dimension of the array
if it is of size 1. This is useful for converting a
batched action of size (1, D) to a single action of size (D,).
**Attributes**:
- **squeeze_batch_dim** (`bool`) -- If True, removes the first dimension of the array if it is of size
1. This is useful for converting a batched action of size (1, D) to a single action of size (D,).
"""
squeeze_batch_dim: bool = True
+28 -28
View File
@@ -101,8 +101,8 @@ class AddTeleopActionAsComplimentaryDataStep(ComplementaryDataProcessorStep):
be available to downstream processors, for example, to override a policy's action
during an intervention.
Attributes:
teleop_device: The teleoperator instance to get the action from.
**Attributes**:
- **teleop_device** (`Teleoperator`) -- The teleoperator instance to get the action from.
"""
teleop_device: "Teleoperator"
@@ -137,9 +137,9 @@ class AddTeleopEventsAsInfoStep(InfoProcessorStep):
This step extracts control events from teleoperators that support event-based
interaction, making these signals available to other parts of the system.
Attributes:
teleop_device: An instance of a teleoperator that implements the
`HasTeleopEvents` protocol.
**Attributes**:
- **teleop_device** (`TeleopWithEvents`) -- An instance of a teleoperator that implements the
`HasTeleopEvents` protocol.
"""
teleop_device: TeleopWithEvents
@@ -180,10 +180,10 @@ class ImageCropResizeProcessorStep(ObservationProcessorStep):
the specified transformations. It handles device placement, moving tensors to the
CPU if necessary for operations not supported on certain accelerators like MPS.
Attributes:
crop_params_dict: A dictionary mapping image keys to cropping parameters
(top, left, height, width).
resize_size: A tuple (height, width) to resize all images to.
**Attributes**:
- **crop_params_dict** (`dict[str, tuple[int, int, int, int]] | None`) -- A dictionary mapping image
keys to cropping parameters (top, left, height, width).
- **resize_size** (`tuple[int, int] | None`) -- A tuple (height, width) to resize all images to.
"""
crop_params_dict: dict[str, tuple[int, int, int, int]] | None = None
@@ -267,9 +267,9 @@ class TimeLimitProcessorStep(TruncatedProcessorStep):
"""
Tracks episode steps and enforces a time limit by truncating the episode.
Attributes:
max_episode_steps: The maximum number of steps allowed per episode.
current_step: The current step count for the active episode.
**Attributes**:
- **max_episode_steps** (`int`) -- The maximum number of steps allowed per episode.
- **current_step** (`int`) -- The current step count for the active episode.
"""
max_episode_steps: int
@@ -358,11 +358,11 @@ class GripperPenaltyProcessorStep(ProcessorStep):
This discourages gripper oscillation while leaving "stay" and saturating-further
commands unpenalized.
Attributes:
penalty: The negative reward value to apply.
max_gripper_pos: The maximum position value for the gripper, used for normalization.
open_threshold: Normalized state below which the gripper is considered "open".
closed_threshold: Normalized state above which the gripper is considered "closed".
**Attributes**:
- **penalty** (`float`) -- The negative reward value to apply.
- **max_gripper_pos** (`float`) -- The maximum position value for the gripper, used for normalization.
- **open_threshold** (`float`) -- Normalized state below which the gripper is considered "open".
- **closed_threshold** (`float`) -- Normalized state above which the gripper is considered "closed".
"""
penalty: float = -0.02
@@ -456,10 +456,10 @@ class InterventionActionProcessorStep(ProcessorStep):
this step replaces the policy's action with the human's teleoperated action.
It also processes signals to terminate the episode or flag success.
Attributes:
use_gripper: Whether to include the gripper in the teleoperated action.
terminate_on_success: If True, automatically sets the `done` flag when a
`success` event is received.
**Attributes**:
- **use_gripper** (`bool`) -- Whether to include the gripper in the teleoperated action.
- **terminate_on_success** (`bool`) -- If True, automatically sets the `done` flag when a `success`
event is received.
"""
use_gripper: bool = False
@@ -557,13 +557,13 @@ class RewardClassifierProcessorStep(ProcessorStep):
This step uses a model to determine if the current state is successful, updating
the reward and potentially terminating the episode.
Attributes:
pretrained_path: Path to the pretrained reward classifier model.
device: The device to run the classifier on.
success_threshold: The probability threshold to consider a prediction as successful.
success_reward: The reward value to assign on success.
terminate_on_success: If True, terminates the episode upon successful classification.
reward_classifier: The loaded classifier model instance.
**Attributes**:
- **pretrained_path** (`str | None`) -- Path to the pretrained reward classifier model.
- **device** (`str`) -- The device to run the classifier on.
- **success_threshold** (`float`) -- The probability threshold to consider a prediction as successful.
- **success_reward** (`float`) -- The reward value to assign on success.
- **terminate_on_success** (`bool`) -- If True, terminates the episode upon successful classification.
- **reward_classifier** (`Any`) -- The loaded classifier model instance.
"""
pretrained_path: str | None = None
@@ -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"))
+17 -16
View File
@@ -71,22 +71,23 @@ class _NormalizationMixin:
)
```
Attributes:
features: A dictionary mapping feature names to `PolicyFeature` objects, defining
the data structure to be processed.
norm_map: A dictionary mapping `FeatureType` to `NormalizationMode`, specifying
which normalization method to use for each type of feature.
stats: A dictionary containing the normalization statistics (e.g., mean, std,
min, max) for each feature.
device: The PyTorch device on which to store and perform tensor operations.
eps: A small epsilon value to prevent division by zero in normalization
calculations.
normalize_observation_keys: An optional set of keys to selectively apply
normalization to specific observation features.
_tensor_stats: An internal dictionary holding the normalization statistics as
PyTorch tensors.
_stats_explicitly_provided: Internal flag tracking whether stats were explicitly
provided during construction (used for override preservation).
**Attributes**:
- **features** (`dict[str, PolicyFeature]`) -- A dictionary mapping feature names to `PolicyFeature`
objects, defining the data structure to be processed.
- **norm_map** (`dict[FeatureType, NormalizationMode]`) -- A dictionary mapping `FeatureType` to
`NormalizationMode`, specifying which normalization method to use for each type of feature.
- **stats** (`dict[str, dict[str, Any]] | None`) -- A dictionary containing the normalization
statistics (e.g., mean, std, min, max) for each feature.
- **device** (`torch.device | str | None`) -- The PyTorch device on which to store and perform tensor
operations.
- **eps** (`float`) -- A small epsilon value to prevent division by zero in normalization
calculations.
- **normalize_observation_keys** (`set[str] | None`) -- An optional set of keys to selectively apply
normalization to specific observation features.
- **_tensor_stats** (`dict[str, dict[str, Tensor]]`) -- An internal dictionary holding the
normalization statistics as PyTorch tensors.
- **_stats_explicitly_provided** (`bool`) -- Internal flag tracking whether stats were explicitly
provided during construction (used for override preservation).
"""
features: dict[str, PolicyFeature]
+12 -7
View File
@@ -269,13 +269,18 @@ class DataProcessorPipeline[TInput, TOutput](HubMixin):
data processing workflow. It's generic, allowing for custom input and output types,
which are handled by the `to_transition` and `to_output` converters.
Attributes:
steps: A sequence of `ProcessorStep` objects that make up the pipeline.
name: A descriptive name for the pipeline.
to_transition: A function to convert raw input data into the standardized `EnvTransition` format.
to_output: A function to convert the final `EnvTransition` into the desired output format.
before_step_hooks: A list of functions to be called before each step is executed.
after_step_hooks: A list of functions to be called after each step is executed.
**Attributes**:
- **steps** (`Sequence[ProcessorStep]`) -- A sequence of `ProcessorStep` objects that make up the
pipeline.
- **name** (`str`) -- A descriptive name for the pipeline.
- **to_transition** (`Callable[[TInput], EnvTransition]`) -- A function to convert raw input data into
the standardized `EnvTransition` format.
- **to_output** (`Callable[[EnvTransition], TOutput]`) -- A function to convert the final
`EnvTransition` into the desired output format.
- **before_step_hooks** (`list[Callable[[int, EnvTransition], None]]`) -- A list of functions to be
called before each step is executed.
- **after_step_hooks** (`list[Callable[[int, EnvTransition], None]]`) -- A list of functions to be
called after each step is executed.
"""
steps: Sequence[ProcessorStep] = field(default_factory=list)
@@ -91,11 +91,11 @@ class RelativeActionsProcessorStep(ProcessorStep):
Caches the last seen state so a paired AbsoluteActionsProcessorStep can reverse
the conversion during postprocessing.
Attributes:
enabled: Whether to apply the relative conversion.
exclude_joints: Joint names to keep absolute (not converted to relative).
action_names: Action dimension names from dataset metadata, used to build
the mask from exclude_joints. If None, all dims are converted.
**Attributes**:
- **enabled** (`bool`) -- Whether to apply the relative conversion.
- **exclude_joints** (`list[str]`) -- Joint names to keep absolute (not converted to relative).
- **action_names** (`list[str] | None`) -- Action dimension names from dataset metadata, used to build
the mask from exclude_joints. If None, all dims are converted.
"""
enabled: bool = False
@@ -168,9 +168,10 @@ class AbsoluteActionsProcessorStep(ProcessorStep):
predicted relative offsets are converted back to absolute positions for execution.
Reads the cached state from its paired RelativeActionsProcessorStep.
Attributes:
enabled: Whether to apply the absolute conversion.
relative_step: Reference to the paired RelativeActionsProcessorStep that caches state.
**Attributes**:
- **enabled** (`bool`) -- Whether to apply the absolute conversion.
- **relative_step** (`RelativeActionsProcessorStep | None`) -- Reference to the paired
RelativeActionsProcessorStep that caches state.
"""
enabled: bool = False
+3 -4
View File
@@ -32,10 +32,9 @@ class RenameObservationsProcessorStep(ObservationProcessorStep):
from an environment's format to the format expected by a LeRobot policy or
other downstream components.
Attributes:
rename_map: A dictionary mapping from old key names to new key names.
Keys present in an observation that are not in this map will
be kept with their original names.
**Attributes**:
- **rename_map** (`dict[str, str]`) -- A dictionary mapping from old key names to new key names. Keys
present in an observation that are not in this map will be kept with their original names.
"""
rename_map: dict[str, str] = field(default_factory=dict)
+22 -15
View File
@@ -65,15 +65,17 @@ class TokenizerProcessorStep(ObservationProcessorStep):
Requires the `transformers` library to be installed.
Attributes:
tokenizer_name: The name of a pretrained tokenizer from the Hugging Face Hub (e.g., "bert-base-uncased").
tokenizer: A pre-initialized tokenizer object. If provided, `tokenizer_name` is ignored.
max_length: The maximum length to pad or truncate sequences to.
task_key: The key in `complementary_data` where the task string is stored.
padding_side: The side to pad on ('left' or 'right').
padding: The padding strategy ('max_length', 'longest', etc.).
truncation: Whether to truncate sequences longer than `max_length`.
input_tokenizer: The internal tokenizer instance, loaded during initialization.
**Attributes**:
- **tokenizer_name** (`str | None`) -- The name of a pretrained tokenizer from the Hugging Face Hub
(e.g., "bert-base-uncased").
- **tokenizer** (`Any | None`) -- A pre-initialized tokenizer object. If provided, `tokenizer_name` is
ignored.
- **max_length** (`int`) -- The maximum length to pad or truncate sequences to.
- **task_key** (`str`) -- The key in `complementary_data` where the task string is stored.
- **padding_side** (`str`) -- The side to pad on ('left' or 'right').
- **padding** (`str`) -- The padding strategy ('max_length', 'longest', etc.).
- **truncation** (`bool`) -- Whether to truncate sequences longer than `max_length`.
- **input_tokenizer** (`Any`) -- The internal tokenizer instance, loaded during initialization.
"""
tokenizer_name: str | None = None
@@ -346,12 +348,17 @@ class ActionTokenizerProcessorStep(ActionProcessorStep):
Requires the `transformers` library to be installed.
Attributes:
tokenizer_name: The name of a pretrained processor from the Hugging Face Hub (e.g., "lerobot/fast-action-tokenizer").
tokenizer: A pre-initialized processor/tokenizer object. If provided, `tokenizer_name` is ignored.
trust_remote_code: Whether to trust remote code when loading the tokenizer (required for some tokenizers).
action_tokenizer: The internal tokenizer/processor instance, loaded during initialization.
paligemma_tokenizer_name: The name of a pretrained PaliGemma tokenizer from the Hugging Face Hub (e.g., "google/paligemma-3b-pt-224").
**Attributes**:
- **tokenizer_name** -- The name of a pretrained processor from the Hugging Face Hub (e.g.,
"lerobot/fast-action-tokenizer").
- **tokenizer** -- A pre-initialized processor/tokenizer object. If provided, `tokenizer_name` is
ignored.
- **trust_remote_code** (`bool`) -- Whether to trust remote code when loading the tokenizer (required
for some tokenizers).
- **action_tokenizer** (`Any`) -- The internal tokenizer/processor instance, loaded during
initialization.
- **paligemma_tokenizer_name** (`str`) -- The name of a pretrained PaliGemma tokenizer from the
Hugging Face Hub (e.g., "google/paligemma-3b-pt-224").
"""
action_tokenizer_name: str | None = None
+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)
@@ -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}")
@@ -38,11 +38,11 @@ class JointVelocityProcessorStep(ObservationProcessorStep):
difference between the current and the last observed joint positions. The
resulting velocity vector is then concatenated to the original state vector.
Attributes:
dt: The time step (delta time) in seconds between observations, used for
calculating velocity.
last_joint_positions: Stores the joint positions from the previous step
to enable velocity calculation.
**Attributes**:
- **dt** (`float`) -- The time step (delta time) in seconds between observations, used for calculating
velocity.
- **last_joint_positions** (`torch.Tensor | None`) -- Stores the joint positions from the previous
step to enable velocity calculation.
"""
dt: float = 0.1
@@ -138,9 +138,9 @@ class MotorCurrentProcessorStep(ObservationProcessorStep):
This step queries the robot's hardware interface to get the present current
for each motor and concatenates this information to the existing state vector.
Attributes:
robot: An instance of a `lerobot` Robot class that provides access to
the hardware bus.
**Attributes**:
- **robot** (`Robot | None`) -- An instance of a `lerobot` Robot class that provides access to the
hardware bus.
"""
robot: Robot | None = None
+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:
@@ -29,14 +29,18 @@ logger = logging.getLogger(__name__)
class BiOpenArmFollower(BimanualMixin, Robot):
"""
Bimanual OpenArm Follower Arms
"""
"""A bimanual pair of OpenArm follower arms driven as one robot."""
config_class = BiOpenArmFollowerConfig
name = "bi_openarm_follower"
def __init__(self, config: BiOpenArmFollowerConfig):
"""Build the robot from its configuration.
Args:
config (`BiOpenArmFollowerConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
@@ -114,19 +118,43 @@ class BiOpenArmFollower(BimanualMixin, Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
raise NotImplementedError(
"Motor ID configuration is typically done via manufacturer tools for CAN motors."
)
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
obs_dict: RobotObservation = {}
# Add "left_" prefix to per-arm keys; keep top-level camera keys unprefixed.
@@ -146,6 +174,23 @@ class BiOpenArmFollower(BimanualMixin, Robot):
custom_kp: dict[str, float] | None = None,
custom_kd: dict[str, float] | None = None,
) -> RobotAction:
"""Command both arms to move towards a target configuration.
Args:
action (`dict[str, Any]`):
Target values, keyed as in [`~robots.Robot.action_features`], i.e. prefixed `left_` and
`right_`.
custom_kp (`dict[str, float]`, *optional*):
Per-motor proportional gains for this step only. Defaults to each arm's `position_kp`.
custom_kd (`dict[str, float]`, *optional*):
Per-motor derivative gains for this step only. Defaults to each arm's `position_kd`.
Returns:
`dict[str, Any]`: The action actually sent, which may be clipped by `max_relative_target`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
# Remove "left_" prefix
left_action = {
key.removeprefix("left_"): value for key, value in action.items() if key.startswith("left_")
@@ -25,7 +25,28 @@ from ..openarm_follower import OpenArmFollowerConfigBase
@RobotConfig.register_subclass("bi_openarm_follower")
@dataclass(kw_only=True)
class BiOpenArmFollowerConfig(RobotConfig):
"""Configuration class for Bi OpenArm Follower robots."""
"""Configuration for a bimanual pair of OpenArm follower arms.
The two arms are configured independently, then driven as one robot: observation and action keys from
each arm are prefixed with `left_` and `right_`.
Calibration is per arm, taken from each arm config's own settings.
Args:
id (`str`, *optional*, defaults to `"bi_openarm_follower"`):
Identifier for the pair as a whole.
calibration_dir (`Path`, *optional*):
Unused at this level; each arm calibrates through its own config.
left_arm_config (`OpenArmFollowerConfigBase`):
Configuration for the left arm, including its own CAN interface. Set its `side` to `"left"` so
the correct joint limits apply.
right_arm_config (`OpenArmFollowerConfigBase`):
Configuration for the right arm, including its own CAN interface. Set its `side` to `"right"`.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras not attached to either arm, such as an overhead view. These keys appear in
observations unchanged, whereas cameras declared on an arm config are prefixed with that
arm's side.
"""
id: str | None = "bi_openarm_follower"
@@ -39,6 +39,12 @@ class BiRebotB601Follower(BimanualMixin, Robot):
name = "bi_rebot_b601_follower"
def __init__(self, config: BiRebotB601FollowerConfig):
"""Build the robot from its configuration.
Args:
config (`BiRebotB601FollowerConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
@@ -120,14 +126,33 @@ class BiRebotB601Follower(BimanualMixin, Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
obs_dict: RobotObservation = {}
for k, v in self.left_arm.get_observation().items():
obs_dict[k if k in self._top_level_cam_keys else f"left_{k}"] = v
@@ -137,6 +162,18 @@ class BiRebotB601Follower(BimanualMixin, Robot):
@check_if_not_connected
def send_action(self, action: RobotAction) -> RobotAction:
"""Command the robot to move towards a target configuration.
Args:
action (`dict[str, Any]`):
Target values, keyed as in [`~robots.Robot.action_features`].
Returns:
`dict[str, Any]`: The action actually sent, which may be clipped by `max_relative_target`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
left_action = {
key.removeprefix("left_"): value for key, value in action.items() if key.startswith("left_")
}
@@ -25,7 +25,27 @@ from ..rebot_b601_follower import RebotB601FollowerConfig
@RobotConfig.register_subclass("bi_rebot_b601_follower")
@dataclass
class BiRebotB601FollowerConfig(RobotConfig):
"""Configuration class for the bimanual reBot B601-DM follower robot."""
"""Configuration for a bimanual pair of reBot B601-DM follower arms.
The two arms are configured independently, then driven as one robot: observation and action keys from
each arm are prefixed with `left_` and `right_`.
Calibration is per arm, taken from each arm config's own `id` and `calibration_dir`.
Args:
left_arm_config (`RebotB601FollowerConfig`):
Configuration for the left arm, including its own `port` and CAN settings.
right_arm_config (`RebotB601FollowerConfig`):
Configuration for the right arm, including its own `port` and CAN settings.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras not attached to either arm, such as an overhead view. These keys appear in
observations unchanged, whereas cameras declared on an arm config are prefixed with that
arm's side.
id (`str`, *optional*):
Identifier for the pair as a whole.
calibration_dir (`Path`, *optional*):
Unused at this level; each arm calibrates through its own config.
"""
left_arm_config: RebotB601FollowerConfig
right_arm_config: RebotB601FollowerConfig
@@ -29,14 +29,18 @@ logger = logging.getLogger(__name__)
class BiSOFollower(BimanualMixin, Robot):
"""
[Bimanual SO Follower Arms](https://github.com/TheRobotStudio/SO-ARM100) designed by TheRobotStudio
"""
"""A bimanual pair of [SO follower arms](https://github.com/TheRobotStudio/SO-ARM100) by TheRobotStudio."""
config_class = BiSOFollowerConfig
name = "bi_so_follower"
def __init__(self, config: BiSOFollowerConfig):
"""Build the robot from its configuration.
Args:
config (`BiSOFollowerConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
@@ -107,18 +111,42 @@ class BiSOFollower(BimanualMixin, Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
self.left_arm.setup_motors()
self.right_arm.setup_motors()
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
obs_dict: RobotObservation = {}
# Add "left_" prefix to per-arm keys; keep top-level camera keys unprefixed.
@@ -134,6 +162,18 @@ class BiSOFollower(BimanualMixin, Robot):
@check_if_not_connected
def send_action(self, action: RobotAction) -> RobotAction:
# Remove "left_" prefix
"""Command the robot to move towards a target configuration.
Args:
action (`dict[str, Any]`):
Target values, keyed as in [`~robots.Robot.action_features`].
Returns:
`dict[str, Any]`: The action actually sent, which may be clipped by `max_relative_target`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
left_action = {
key.removeprefix("left_"): value for key, value in action.items() if key.startswith("left_")
}
@@ -25,7 +25,27 @@ from ..so_follower import SOFollowerConfig
@RobotConfig.register_subclass("bi_so_follower")
@dataclass
class BiSOFollowerConfig(RobotConfig):
"""Configuration class for Bi SO Follower robots."""
"""Configuration for a bimanual pair of SO follower arms.
The two arms are configured independently, then driven as one robot: observation and action keys from
each arm are prefixed with `left_` and `right_`.
Calibration is per arm, taken from each arm config's own `id` and `calibration_dir`.
Args:
left_arm_config (`SOFollowerConfig`):
Configuration for the left arm, including its own `port`.
right_arm_config (`SOFollowerConfig`):
Configuration for the right arm, including its own `port`.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras not attached to either arm, such as an overhead view. These keys appear in
observations unchanged, whereas cameras declared on an arm config are prefixed with that
arm's side.
id (`str`, *optional*):
Identifier for the pair as a whole.
calibration_dir (`Path`, *optional*):
Unused at this level; each arm calibrates through its own config.
"""
left_arm_config: SOFollowerConfig
right_arm_config: SOFollowerConfig
+27
View File
@@ -21,12 +21,33 @@ import draccus
@dataclass(kw_only=True)
class RobotConfig(draccus.ChoiceRegistry, abc.ABC):
"""Base configuration shared by every robot.
Concrete robots subclass this and register themselves with
`@RobotConfig.register_subclass("name")`, which is what makes `--robot.type=name` work on the command
line. Subclasses inherit the two fields below and must document them alongside their own.
Args:
id (`str`, *optional*):
Identifier for this particular unit, used to tell apart several robots of the same type. It
also names the calibration file, so keep it stable for a given piece of hardware.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to a per-robot directory under the
LeRobot calibration home.
"""
# Allows to distinguish between different robots of the same type
id: str | None = None
# Directory to store calibration file
calibration_dir: Path | None = None
def __post_init__(self):
"""Validate that every configured camera specifies the fields a robot requires.
Raises:
ValueError: If a camera does not set `width`, `height` and `fps`. A robot records frames at a
fixed shape, so these cannot be left to the driver's defaults.
"""
if hasattr(self, "cameras") and self.cameras:
for _, config in self.cameras.items():
for attr in ["width", "height", "fps"]:
@@ -37,4 +58,10 @@ class RobotConfig(draccus.ChoiceRegistry, abc.ABC):
@property
def type(self) -> str:
"""The registered name of this robot type.
Returns:
`str`: The name passed to `@RobotConfig.register_subclass`, e.g. `"so101_follower"`. This is
what `make_robot_from_config` dispatches on and what a user writes as `--robot.type=...`.
"""
return self.get_choice_name(self.__class__)
@@ -23,13 +23,18 @@ from ..config import RobotConfig
@RobotConfig.register_subclass("earthrover_mini_plus")
@dataclass
class EarthRoverMiniPlusConfig(RobotConfig):
"""Configuration for EarthRover Mini Plus robot using Frodobots SDK.
"""Configuration for the EarthRover Mini Plus rover.
This robot uses cloud-based control via the Frodobots SDK HTTP API.
Camera frames are accessed directly through SDK HTTP endpoints.
This robot is driven over the cloud through the Frodobots SDK's HTTP API rather than a local bus, so
there is no serial port and no LeRobot calibration file. Camera frames come from SDK HTTP endpoints.
Attributes:
sdk_url: URL of the Frodobots SDK server (default: http://localhost:8000)
Args:
sdk_url (`str`, *optional*, defaults to `"http://localhost:8000"`):
Base URL of the Frodobots SDK server. Commands and camera frames both go through it.
id (`str`, *optional*):
Identifier for this particular rover.
calibration_dir (`Path`, *optional*):
Unused: the rover exposes no calibration.
"""
sdk_url: str = "http://localhost:8000"
@@ -70,8 +70,7 @@ OBS_WHEEL_RPM_3 = "wheel_rpm_3"
class EarthRoverMiniPlus(Robot):
"""
EarthRover Mini Plus robot controlled via Frodobots SDK HTTP API.
"""EarthRover Mini Plus robot controlled via Frodobots SDK HTTP API.
This robot uses cloud-based control through the Frodobots SDK instead of direct
hardware connection. Cameras stream via WebRTC through Agora cloud, and control
@@ -82,9 +81,9 @@ class EarthRoverMiniPlus(Robot):
- Linear and angular velocity control
- Battery and orientation telemetry
Attributes:
config: Robot configuration
sdk_base_url: URL of the Frodobots SDK server (default: http://localhost:8000)
**Attributes**:
- **config** -- Robot configuration
- **sdk_base_url** -- URL of the Frodobots SDK server (default: http://localhost:8000)
"""
config_class = EarthRoverMiniPlusConfig
@@ -130,7 +129,6 @@ class EarthRoverMiniPlus(Robot):
DeviceAlreadyConnectedError: If robot is already connected
DeviceNotConnectedError: If cannot connect to SDK server
"""
# Verify SDK is running and accessible
try:
response = requests.get(f"{self.sdk_base_url}/data", timeout=10.0)
@@ -280,7 +278,6 @@ class EarthRoverMiniPlus(Robot):
Robot telemetry is retrieved from /data endpoint.
All SDK values are normalized to appropriate ranges for dataset recording.
"""
observation = {}
# Get camera images from SDK
@@ -370,7 +367,6 @@ class EarthRoverMiniPlus(Robot):
Raises:
DeviceNotConnectedError: If robot is not connected
"""
# Stop the robot before disconnecting
try:
self._send_command_to_sdk(0.0, 0.0)
@@ -24,6 +24,27 @@ from ..config import RobotConfig
@RobotConfig.register_subclass("hope_jr_hand")
@dataclass
class HopeJrHandConfig(RobotConfig):
"""Configuration for one Hope Jr hand.
Each hand is a separate robot, so a two-handed setup uses two of these with different `side` and
`port` values.
Args:
port (`str`):
Serial port the hand is connected to. Run `lerobot-find-port` to identify it.
side (`str`):
Which hand this is, `"left"` or `"right"`. Determines the motor layout, so it must match the
hardware.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the hand's joint positions.
id (`str`, *optional*):
Identifier for this particular hand; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
port: str # Port to connect to the hand
side: str # "left" / "right"
@@ -32,6 +53,12 @@ class HopeJrHandConfig(RobotConfig):
cameras: dict[str, CameraConfig] = field(default_factory=dict)
def __post_init__(self):
"""Validate the camera settings and the hand side.
Raises:
ValueError: If `side` is not `"left"` or `"right"`, or if a camera omits `width`, `height` or
`fps`.
"""
super().__post_init__()
if self.side not in ["right", "left"]:
raise ValueError(self.side)
@@ -40,6 +67,26 @@ class HopeJrHandConfig(RobotConfig):
@RobotConfig.register_subclass("hope_jr_arm")
@dataclass
class HopeJrArmConfig(RobotConfig):
"""Configuration for one Hope Jr arm.
Args:
port (`str`):
Serial port the arm is connected to. Run `lerobot-find-port` to identify it.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect. Leave `True` unless the arm is holding a load it
must not drop.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, as a safety limit. A
scalar applies to every motor; a dict maps motor name to a per-motor cap. `None` disables
clipping.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the arm's joint positions.
id (`str`, *optional*):
Identifier for this particular arm; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
port: str # Port to connect to the hand
disable_torque_on_disconnect: bool = True
+80 -4
View File
@@ -35,10 +35,22 @@ logger = logging.getLogger(__name__)
class HopeJrArm(Robot):
"""One arm of the Hope Jr humanoid.
The arm and the hand are separate robots; pair this with [`~robots.hope_jr.HopeJrHand`] for a full
limb. See [`~robots.Robot`] for the contract every method here implements.
"""
config_class = HopeJrArmConfig
name = "hope_jr_arm"
def __init__(self, config: HopeJrArmConfig):
"""Build the robot from its configuration.
Args:
config (`HopeJrArmConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
self.bus = FeetechMotorsBus(
@@ -77,23 +89,47 @@ class HopeJrArm(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""
We assume that at connection time, arm is in a rest position,
and torque can be safely disabled to run calibration.
"""
"""Connect the motor bus and cameras, calibrating and configuring the arm.
> [!WARNING]
> The arm is assumed to be at rest when this is called, because torque is disabled to run
> calibration.
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to run calibration if the arm is not already calibrated.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.bus.connect(handshake=False)
if not self.is_calibrated and calibrate:
self.calibrate()
@@ -107,9 +143,18 @@ class HopeJrArm(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return self.bus.is_calibrated
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
groups = {
"all": list(self.bus.motors.keys()),
"shoulder": ["shoulder_pitch", "shoulder_yaw", "shoulder_roll"],
@@ -122,11 +167,17 @@ class HopeJrArm(Robot):
print("Calibration saved to", self.calibration_fpath)
def configure(self) -> None:
"""Apply the operating mode, gains and limits from the configuration to the robot."""
with self.bus.torque_disabled():
self.bus.configure_motors(maximum_acceleration=30, acceleration=30)
def setup_motors(self) -> None:
# TODO: add docstring
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
for motor in reversed(self.bus.motors):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
@@ -135,6 +186,14 @@ class HopeJrArm(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
# Read arm position
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
start = time.perf_counter()
obs_dict = self.bus.sync_read("Present_Position", self.other_motors)
obs_dict[self.shoulder_pitch] = self.bus.read("Present_Position", self.shoulder_pitch)
@@ -160,6 +219,18 @@ class HopeJrArm(Robot):
@check_if_not_connected
def send_action(self, action: RobotAction) -> RobotAction:
"""Command the robot to move towards a target configuration.
Args:
action (`dict[str, Any]`):
Target values, keyed as in [`~robots.Robot.action_features`].
Returns:
`dict[str, Any]`: The action actually sent, which may be clipped by `max_relative_target`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")}
# Cap goal position when too far away from present position.
@@ -174,6 +245,11 @@ class HopeJrArm(Robot):
@check_if_not_connected
def disconnect(self):
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
cam.disconnect()
@@ -59,10 +59,22 @@ LEFT_HAND_INVERSIONS = [
class HopeJrHand(Robot):
"""One hand of the Hope Jr humanoid.
Each hand is its own robot, so a two-handed setup uses two of these with different `side` values. See
[`~robots.Robot`] for the contract every method here implements.
"""
config_class = HopeJrHandConfig
name = "hope_jr_hand"
def __init__(self, config: HopeJrHandConfig):
"""Build the robot from its configuration.
Args:
config (`HopeJrHandConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
self.bus = FeetechMotorsBus(
@@ -113,18 +125,43 @@ class HopeJrHand(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""Connect to the robot and its cameras, then apply the configured settings.
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to run calibration if the robot is not already calibrated.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.bus.connect()
if not self.is_calibrated and calibrate:
self.calibrate()
@@ -138,9 +175,18 @@ class HopeJrHand(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return self.bus.is_calibrated
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
fingers = {}
for finger in ["thumb", "index", "middle", "ring", "pinky"]:
fingers[finger] = [motor for motor in self.bus.motors if motor.startswith(finger)]
@@ -152,11 +198,17 @@ class HopeJrHand(Robot):
print("Calibration saved to", self.calibration_fpath)
def configure(self) -> None:
"""Apply the operating mode, gains and limits from the configuration to the robot."""
with self.bus.torque_disabled():
self.bus.configure_motors()
def setup_motors(self) -> None:
# TODO: add docstring
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
for motor in self.bus.motors:
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
@@ -164,6 +216,14 @@ class HopeJrHand(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
obs_dict = {}
# Read hand position
@@ -191,12 +251,29 @@ class HopeJrHand(Robot):
@check_if_not_connected
def send_action(self, action: RobotAction) -> RobotAction:
"""Command the robot to move towards a target configuration.
Args:
action (`dict[str, Any]`):
Target values, keyed as in [`~robots.Robot.action_features`].
Returns:
`dict[str, Any]`: The action actually sent, which may be clipped by `max_relative_target`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")}
self.bus.sync_write("Goal_Position", goal_pos)
return action
@check_if_not_connected
def disconnect(self):
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
cam.disconnect()
@@ -22,6 +22,30 @@ from ..config import RobotConfig
@RobotConfig.register_subclass("koch_follower")
@dataclass
class KochFollowerConfig(RobotConfig):
"""Configuration for the Koch v1.1 follower arm.
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyACM0` on Linux or `COM3` on Windows. Run
`lerobot-find-port` to identify it.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect. Leave `True` unless the arm is holding a load it
must not drop.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, as a safety limit. A
scalar applies to every motor; a dict maps motor name to a per-motor cap. `None` disables
clipping.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the arm's joint positions, keyed by the name they appear under in
observations. Each must specify `width`, `height` and `fps`.
use_degrees (`bool`, *optional*, defaults to `False`):
Whether to report and accept joint positions in degrees rather than as a normalised range.
id (`str`, *optional*):
Identifier for this particular arm; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
# Port to connect to the arm
port: str
@@ -35,16 +35,24 @@ logger = logging.getLogger(__name__)
class KochFollower(Robot):
"""
- [Koch v1.0](https://github.com/AlexanderKoch-Koch/low_cost_robot), with and without the wrist-to-elbow
expansion, developed by Alexander Koch from [Tau Robotics](https://tau-robotics.com)
- [Koch v1.1](https://github.com/jess-moss/koch-v1-1) developed by Jess Moss
"""The Koch follower arm, in either of its two revisions.
- [Koch v1.0](https://github.com/AlexanderKoch-Koch/low_cost_robot), with and without the
wrist-to-elbow expansion, developed by Alexander Koch from
[Tau Robotics](https://tau-robotics.com).
- [Koch v1.1](https://github.com/jess-moss/koch-v1-1), developed by Jess Moss.
"""
config_class = KochFollowerConfig
name = "koch_follower"
def __init__(self, config: KochFollowerConfig):
"""Build the robot from its configuration.
Args:
config (`KochFollowerConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
norm_mode_body = MotorNormMode.DEGREES if config.use_degrees else MotorNormMode.RANGE_M100_100
@@ -79,23 +87,47 @@ class KochFollower(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""
We assume that at connection time, arm is in a rest position,
and torque can be safely disabled to run calibration.
"""
"""Connect the motor bus and cameras, calibrating and configuring the arm.
> [!WARNING]
> The arm is assumed to be at rest when this is called, because torque is disabled to run
> calibration.
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to run calibration if the arm is not already calibrated.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.bus.connect()
if not self.is_calibrated and calibrate:
logger.info(
@@ -111,9 +143,18 @@ class KochFollower(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return self.bus.is_calibrated
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
self.bus.disable_torque()
if self.calibration:
# Calibration file exists, ask user whether to use it or run new calibration
@@ -157,6 +198,7 @@ class KochFollower(Robot):
logger.info(f"Calibration saved to {self.calibration_fpath}")
def configure(self) -> None:
"""Apply the operating mode, gains and limits from the configuration to the robot."""
with self.bus.torque_disabled():
self.bus.configure_motors()
# Use 'extended position mode' for all motors except gripper, because in joint mode the servos
@@ -181,6 +223,11 @@ class KochFollower(Robot):
self.bus.write("Position_D_Gain", "elbow_flex", 600)
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
for motor in reversed(self.bus.motors):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
@@ -189,6 +236,14 @@ class KochFollower(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
# Read arm position
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
start = time.perf_counter()
obs_dict = self.bus.sync_read("Present_Position")
obs_dict = {f"{motor}.pos": val for motor, val in obs_dict.items()}
@@ -225,7 +280,6 @@ class KochFollower(Robot):
Returns:
RobotAction: The action sent to the motors, potentially clipped.
"""
goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")}
# Cap goal position when too far away from present position.
@@ -241,6 +295,11 @@ class KochFollower(Robot):
@check_if_not_connected
def disconnect(self):
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
cam.disconnect()
@@ -21,6 +21,12 @@ from ..config import RobotConfig
def lekiwi_cameras_config() -> dict[str, CameraConfig]:
"""Build the default camera set for a LeKiwi base.
Returns:
`dict[str, CameraConfig]`: The `front` and `wrist` OpenCV cameras at the device paths and
rotations of a standard LeKiwi build. Override the `cameras` field if yours is wired differently.
"""
return {
"front": OpenCVCameraConfig(
index_or_path="/dev/video0",
@@ -44,6 +50,34 @@ def lekiwi_cameras_config() -> dict[str, CameraConfig]:
@RobotConfig.register_subclass("lekiwi")
@dataclass
class LeKiwiConfig(RobotConfig):
"""Configuration for LeKiwi, running on the robot itself.
This is the config used by the process on the LeKiwi's own computer. To drive one from another machine,
use [`LeKiwiClientConfig`] instead.
Args:
port (`str`, *optional*, defaults to `"/dev/ttyACM0"`):
Serial port of the motor bus on the robot's computer.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, as a safety limit. A
scalar applies to every motor; a dict maps motor name to a per-motor cap. `None` disables
clipping.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the joint positions. Defaults to the standard `front` and `wrist`
build; see [`lekiwi_cameras_config`].
use_degrees (`bool`, *optional*, defaults to `True`):
Whether to report and accept arm joint positions in degrees.
num_read_retries (`int`, *optional*, defaults to 2):
Extra attempts when a `sync_read` fails. Feetech buses occasionally return a corrupted status
packet, which would otherwise abort the control loop.
id (`str`, *optional*):
Identifier for this particular robot; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
port: str = "/dev/ttyACM0" # port to connect to the bus
disable_torque_on_disconnect: bool = True
@@ -67,6 +101,22 @@ class LeKiwiConfig(RobotConfig):
@dataclass
class LeKiwiHostConfig:
"""Configuration for the host process that serves a LeKiwi over the network.
Args:
port_zmq_cmd (`int`, *optional*, defaults to 5555):
ZMQ port the host listens on for actions.
port_zmq_observations (`int`, *optional*, defaults to 5556):
ZMQ port the host publishes observations on.
connection_time_s (`int`, *optional*, defaults to 30):
How long the host stays up before shutting down.
watchdog_timeout_ms (`int`, *optional*, defaults to 500):
Stop the robot if no command arrives within this window. Guards against a dropped client
leaving the base driving.
max_loop_freq_hz (`int`, *optional*, defaults to 30):
Control loop frequency. Lower it if the robot jitters, and watch CPU load with `top`.
"""
# Network Configuration
port_zmq_cmd: int = 5555
port_zmq_observations: int = 5556
@@ -84,6 +134,32 @@ class LeKiwiHostConfig:
@RobotConfig.register_subclass("lekiwi_client")
@dataclass
class LeKiwiClientConfig(RobotConfig):
"""Configuration for driving a LeKiwi from another machine.
Presents the same [`~robots.Robot`] interface as the robot-side [`LeKiwiConfig`], but every call goes
over ZMQ to the host process. Calibration lives on the robot, so nothing here configures it.
Args:
remote_ip (`str`):
IP address of the LeKiwi's computer on the network.
port_zmq_cmd (`int`, *optional*, defaults to 5555):
ZMQ port to send actions to. Must match the host's `port_zmq_cmd`.
port_zmq_observations (`int`, *optional*, defaults to 5556):
ZMQ port to receive observations on. Must match the host's `port_zmq_observations`.
teleop_keys (`dict[str, str]`, *optional*):
Keyboard bindings for driving the base: movement, rotation, speed control and quit.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras expected in the observation stream. Defaults to the standard `front` and `wrist` build.
polling_timeout_ms (`int`, *optional*, defaults to 15):
How long to wait for an observation before giving up on that step.
connect_timeout_s (`int`, *optional*, defaults to 5):
How long to wait for the host to answer when connecting.
id (`str`, *optional*):
Identifier for this particular robot.
calibration_dir (`Path`, *optional*):
Unused by the client: calibration is held on the robot.
"""
# Network Configuration
remote_ip: str
port_zmq_cmd: int = 5555
+69 -11
View File
@@ -39,17 +39,25 @@ logger = logging.getLogger(__name__)
class LeKiwi(Robot):
"""
The robot includes a three omniwheel mobile base and a remote follower arm.
The leader arm is connected locally (on the laptop) and its joint positions are recorded and then
forwarded to the remote follower arm (after applying a safety clamp).
In parallel, keyboard teleoperation is used to generate raw velocity commands for the wheels.
"""A three-omniwheel mobile base with a follower arm on top, running on the robot itself.
The leader arm is connected to the operator's laptop; its joint positions are recorded and forwarded
to this follower arm after a safety clamp. In parallel, keyboard teleoperation generates raw velocity
commands for the wheels.
To drive one of these from another machine, use [`~robots.lekiwi.LeKiwiClient`].
"""
config_class = LeKiwiConfig
name = "lekiwi"
def __init__(self, config: LeKiwiConfig):
"""Build the robot from its configuration.
Args:
config (`LeKiwiConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
norm_mode_body = MotorNormMode.DEGREES if config.use_degrees else MotorNormMode.RANGE_M100_100
@@ -105,18 +113,43 @@ class LeKiwi(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._state_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._state_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""Connect to the robot and its cameras, then apply the configured settings.
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to run calibration if the robot is not already calibrated.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.bus.connect()
if not self.is_calibrated and calibrate:
logger.info(
@@ -132,9 +165,18 @@ class LeKiwi(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return self.bus.is_calibrated
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
if self.calibration:
# Calibration file exists, ask user whether to use it or run new calibration
user_input = input(
@@ -189,6 +231,7 @@ class LeKiwi(Robot):
# Set-up arm actuators (position mode)
# We assume that at connection time, arm is in a rest position,
# and torque can be safely disabled to run calibration.
"""Apply the operating mode, gains and limits from the configuration to the robot."""
self.bus.disable_torque()
self.bus.configure_motors()
for name in self.arm_motors:
@@ -205,6 +248,11 @@ class LeKiwi(Robot):
self.bus.enable_torque()
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
for motor in chain(reversed(self.arm_motors), reversed(self.base_motors)):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
@@ -238,8 +286,7 @@ class LeKiwi(Robot):
base_radius: float = 0.125,
max_raw: int = 3000,
) -> dict:
"""
Convert desired body-frame velocities into wheel raw commands.
"""Convert desired body-frame velocities into wheel raw commands.
Parameters:
x_cmd : Linear velocity in x (m/s).
@@ -302,8 +349,7 @@ class LeKiwi(Robot):
wheel_radius: float = 0.05,
base_radius: float = 0.125,
) -> dict[str, Any]:
"""
Convert wheel raw command feedback back into body-frame velocities.
"""Convert wheel raw command feedback back into body-frame velocities.
Parameters:
wheel_raw : Vector with raw wheel commands ("base_left_wheel", "base_back_wheel", "base_right_wheel").
@@ -313,7 +359,6 @@ class LeKiwi(Robot):
Returns:
A dict (x.vel, y.vel, theta.vel) all in m/s
"""
# Convert each raw command back to an angular speed in deg/s.
wheel_degps = np.array(
[
@@ -346,6 +391,14 @@ class LeKiwi(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
# Read actuators position for arm and vel for base
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
start = time.perf_counter()
arm_pos = self.bus.sync_read(
"Present_Position", self.arm_motors, num_retry=self.config.num_read_retries
@@ -390,7 +443,6 @@ class LeKiwi(Robot):
Returns:
RobotAction: the action sent to the motors, potentially clipped.
"""
arm_goal_pos = {k: v for k, v in action.items() if k.endswith(".pos")}
base_goal_vel = {k: v for k, v in action.items() if k.endswith(".vel")}
@@ -419,11 +471,17 @@ class LeKiwi(Robot):
return {**arm_goal_pos, **base_goal_vel}
def stop_base(self):
"""Bring the mobile base to a halt by commanding zero velocity on its wheels."""
self.bus.sync_write("Goal_Velocity", dict.fromkeys(self.base_motors, 0), num_retry=5)
logger.info("Base motors stopped")
@check_if_not_connected
def disconnect(self):
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.stop_base()
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
+65 -14
View File
@@ -31,10 +31,23 @@ from .config_lekiwi import LeKiwiClientConfig
class LeKiwiClient(Robot):
"""Drives a LeKiwi over the network from another machine.
Presents the same [`~robots.Robot`] interface as [`~robots.lekiwi.LeKiwi`], but every observation and
action crosses a ZMQ connection to the host process running on the robot. Calibration stays on the
robot, so this class does not perform it.
"""
config_class = LeKiwiClientConfig
name = "lekiwi_client"
def __init__(self, config: LeKiwiClientConfig):
"""Build the robot from its configuration.
Args:
config (`LeKiwiClientConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
import zmq
self._zmq = zmq
@@ -105,24 +118,50 @@ class LeKiwiClient(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._state_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._state_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self._is_connected
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
pass
@check_if_already_connected
def connect(self) -> None:
"""Establishes ZMQ sockets with the remote mobile robot"""
"""Open the ZMQ command and observation sockets to the LeKiwi host.
Takes no `calibrate` argument: calibration lives on the robot, not on the client.
Raises:
DeviceAlreadyConnectedError: If the client is already connected.
"""
zmq = self._zmq
self.zmq_context = zmq.Context()
self.zmq_cmd_socket = self.zmq_context.socket(zmq.PUSH)
@@ -146,6 +185,10 @@ class LeKiwiClient(Robot):
self._is_connected = True
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
pass
def _poll_and_get_latest_message(self) -> list[bytes] | None:
@@ -203,7 +246,6 @@ class LeKiwiClient(Robot):
self, observation: RobotObservation
) -> tuple[dict[str, np.ndarray], RobotObservation]:
"""Extracts frames, and state from the parsed observation."""
flat_state = {key: observation.get(key, 0.0) for key in self._state_order}
state_vec = np.array([flat_state[key] for key in self._state_order], dtype=np.float32)
@@ -222,14 +264,12 @@ class LeKiwiClient(Robot):
return current_frames, obs_dict
def _get_data(self) -> tuple[dict[str, np.ndarray], RobotObservation]:
"""
Polls the video socket for the latest observation data.
"""Polls the video socket for the latest observation data.
Attempts to retrieve and decode the latest message within a short timeout.
If successful, updates and returns the new frames, speed, and arm state.
If no new data arrives or decoding fails, returns the last known values.
"""
# 1. Get the latest message's frames from the socket
latest_frames = self._poll_and_get_latest_message()
@@ -258,12 +298,17 @@ class LeKiwiClient(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""
Capture observations from the remote robot: current follower arm positions,
present wheel speeds (converted to body-frame velocities: x, y, theta),
and a camera frame. Receives over ZMQ, translate to body-frame vel
"""
"""Receive one observation from the remote robot over ZMQ.
Wheel speeds arrive as raw motor velocities and are converted here to body-frame `x`, `y` and
`theta`.
Returns:
`dict[str, Any]`: Follower arm positions, body-frame base velocities and camera frames.
Raises:
DeviceNotConnectedError: If the client is not connected.
"""
frames, obs_dict = self._get_data()
# Loop over each configured camera
@@ -308,21 +353,24 @@ class LeKiwiClient(Robot):
}
def configure(self):
"""Apply the operating mode, gains and limits from the configuration to the robot."""
pass
@check_if_not_connected
def send_action(self, action: RobotAction) -> RobotAction:
"""Command lekiwi to move to a target joint configuration. Translates to motor space + sends over ZMQ
"""Send a target configuration to the remote robot over ZMQ.
Body-frame base velocities are translated into wheel velocities before sending.
Args:
action (RobotAction): array containing the goal positions for the motors.
action (`dict[str, Any]`): Goal positions for the arm and body-frame velocities for the base.
Raises:
RobotDeviceNotConnectedError: if robot is not connected.
Returns:
np.ndarray: the action sent to the motors, potentially clipped.
"""
# Action values may be torch tensors (e.g. replayed from a dataset) or numpy
# scalars; json.dumps only serializes Python primitives, so coerce each value to a
# plain float before sending.
@@ -338,8 +386,11 @@ class LeKiwiClient(Robot):
@check_if_not_connected
def disconnect(self):
"""Cleans ZMQ comms"""
"""Close the ZMQ sockets and terminate the context.
Raises:
DeviceNotConnectedError: If the client is not connected.
"""
self.zmq_observation_socket.close()
self.zmq_cmd_socket.close()
self.zmq_context.term()
+23
View File
@@ -36,7 +36,19 @@ class LeKiwiServerConfig:
class LeKiwiHost:
"""Serves a [`~robots.lekiwi.LeKiwi`] over ZMQ so a client can drive it from another machine.
Runs on the robot's own computer, receiving actions on one socket and publishing observations on
another.
"""
def __init__(self, config: LeKiwiHostConfig):
"""Bind the command and observation sockets.
Args:
config (`LeKiwiHostConfig`):
Ports, loop frequency and watchdog settings for the host.
"""
self.zmq_context = zmq.Context()
self.zmq_cmd_socket = self.zmq_context.socket(zmq.PULL)
self.zmq_cmd_socket.setsockopt(zmq.CONFLATE, 1)
@@ -53,6 +65,11 @@ class LeKiwiHost:
self.max_loop_freq_hz = config.max_loop_freq_hz
def disconnect(self):
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.zmq_observation_socket.close()
self.zmq_cmd_socket.close()
self.zmq_context.term()
@@ -60,6 +77,12 @@ class LeKiwiHost:
@draccus.wrap()
def main(cfg: LeKiwiServerConfig):
"""Run the LeKiwi host loop until the configured connection time elapses.
Args:
cfg (`LeKiwiServerConfig`):
The robot and host configuration to serve.
"""
logging.info("Configuring LeKiwi")
robot = LeKiwi(cfg.robot)
@@ -22,6 +22,30 @@ from ..config import RobotConfig
@RobotConfig.register_subclass("omx_follower")
@dataclass
class OmxFollowerConfig(RobotConfig):
"""Configuration for the OpenMANIPULATOR-X follower arm.
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyUSB0`. Run `lerobot-find-port` to identify
it.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect. Leave `True` unless the arm is holding a load it
must not drop.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, as a safety limit. A
scalar applies to every motor; a dict maps motor name to a per-motor cap. `None` disables
clipping.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the arm's joint positions, keyed by the name they appear under in
observations. Each must specify `width`, `height` and `fps`.
use_degrees (`bool`, *optional*, defaults to `False`):
Whether to report and accept joint positions in degrees rather than as a normalised range.
id (`str`, *optional*):
Identifier for this particular arm; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
# Port to connect to the arm
port: str
+68 -10
View File
@@ -36,15 +36,21 @@ logger = logging.getLogger(__name__)
class OmxFollower(Robot):
"""
- [OMX](https://github.com/ROBOTIS-GIT/open_manipulator),
expansion, developed by Woojin Wie and Junha Cha from [ROBOTIS](https://ai.robotis.com/)
"""The [OpenMANIPULATOR-X](https://github.com/ROBOTIS-GIT/open_manipulator) follower arm.
Developed by Woojin Wie and Junha Cha at [ROBOTIS](https://ai.robotis.com/).
"""
config_class = OmxFollowerConfig
name = "omx_follower"
def __init__(self, config: OmxFollowerConfig):
"""Build the robot from its configuration.
Args:
config (`OmxFollowerConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
norm_mode_body = MotorNormMode.DEGREES if config.use_degrees else MotorNormMode.RANGE_M100_100
@@ -79,25 +85,50 @@ class OmxFollower(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""
For OMX robots that come pre-calibrated:
- If default calibration from package doesn't match motors, read from motors and save
- This allows using pre-calibrated robots without manual calibration
- If no calibration file exists, use factory default values (homing_offset=0, range_min=0, range_max=4095)
"""
"""Connect the motor bus and cameras, handling the pre-calibrated case.
OMX arms ship calibrated, so this avoids asking for a manual calibration where possible:
- if the packaged default calibration does not match the motors, the motors' own values are read
and saved;
- if no calibration file exists, factory defaults are used (`homing_offset=0`, `range_min=0`,
`range_max=4095`).
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to calibrate if the arm is not already calibrated.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.bus.connect()
if not self.is_calibrated and calibrate:
logger.info(
@@ -113,9 +144,18 @@ class OmxFollower(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return self.bus.is_calibrated
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
self.bus.disable_torque()
logger.info(f"\nUsing factory default calibration values for {self}")
logger.info(f"\nWriting default configuration of {self} to the motors")
@@ -140,6 +180,7 @@ class OmxFollower(Robot):
logger.info(f"Calibration saved to {self.calibration_fpath}")
def configure(self) -> None:
"""Apply the operating mode, gains and limits from the configuration to the robot."""
with self.bus.torque_disabled():
self.bus.configure_motors()
# Use 'extended position mode' for all motors except gripper, because in joint mode the servos
@@ -164,6 +205,11 @@ class OmxFollower(Robot):
self.bus.write("Position_D_Gain", "elbow_flex", 600)
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
for motor in reversed(self.bus.motors):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
@@ -172,6 +218,14 @@ class OmxFollower(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
# Read arm position
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
start = time.perf_counter()
obs_dict = self.bus.sync_read("Present_Position")
obs_dict = {f"{motor}.pos": val for motor, val in obs_dict.items()}
@@ -208,7 +262,6 @@ class OmxFollower(Robot):
Returns:
RobotAction: The action sent to the motors, potentially clipped.
"""
goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")}
# Cap goal position when too far away from present position.
@@ -224,6 +277,11 @@ class OmxFollower(Robot):
@check_if_not_connected
def disconnect(self):
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
cam.disconnect()
@@ -45,7 +45,13 @@ RIGHT_DEFAULT_JOINTS_LIMITS: dict[str, tuple[float, float]] = {
@dataclass
class OpenArmFollowerConfigBase:
"""Base configuration for the OpenArms follower robot with Damiao motors."""
"""Field definitions for the OpenArm follower, a 7-DOF arm plus gripper on Damiao CAN motors.
This class only carries the fields. The registered configuration users instantiate is
[`OpenArmFollowerConfig`], which documents them all in one place doc-builder renders only a class's
own docstring, never its bases'. It is also used directly as the per-arm config of
[`~robots.bi_openarm_follower.BiOpenArmFollowerConfig`].
"""
# CAN interfaces - one per arm
# arm CAN interface (e.g., "can1")
@@ -123,4 +129,57 @@ class OpenArmFollowerConfigBase:
@RobotConfig.register_subclass("openarm_follower")
@dataclass
class OpenArmFollowerConfig(RobotConfig, OpenArmFollowerConfigBase):
"""Configuration for a single OpenArm follower arm.
OpenArm is a 7-DOF arm plus gripper on Damiao CAN motors, so `port` names a CAN interface rather than a
serial device. Calibration follows the usual LeRobot flow and is stored per `id`.
> [!WARNING]
> `joint_limits` defaults to a deliberately tiny range so an uncalibrated arm cannot swing. Set `side`
> to `"left"` or `"right"` to get the real limits for that arm, or pass your own.
The per-joint lists `position_kp`, `position_kd` hold 8 values in motor order: `joint_1` through
`joint_7`, then `gripper`.
Args:
port (`str`):
CAN interface the arm is on, e.g. `"can0"` on Linux.
side (`str`, *optional*):
Which arm this is, `"left"` or `"right"`. Selects that side's joint limits. Leaving it `None`
keeps the small safety defaults.
can_interface (`str`, *optional*, defaults to `"socketcan"`):
CAN backend: `"socketcan"` on Linux, `"slcan"` for a serial adapter, or `"auto"` to detect.
use_can_fd (`bool`, *optional*, defaults to `True`):
Whether to use CAN FD. OpenArm uses it by default.
can_bitrate (`int`, *optional*, defaults to 1000000):
Nominal CAN bitrate, 1 Mbps.
can_data_bitrate (`int`, *optional*, defaults to 5000000):
CAN FD data bitrate, 5 Mbps. Only used when `use_can_fd` is `True`.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect. Leave `True` unless the arm is holding a load it
must not drop.
use_velocity_and_torque (`bool`, *optional*, defaults to `False`):
Whether to expose `.vel` and `.torque` per motor in the observation features. Kept `False` by
default for compatibility with the position-only `openarm_mini` teleoperator.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, as a safety limit. A
scalar applies to every motor; a dict maps motor name to a per-motor cap. `None` disables
clipping.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the arm's joint positions.
motor_config (`dict[str, tuple[int, int, str]]`, *optional*):
Maps motor name to `(send_can_id, recv_can_id, motor_type)`. Defaults to the stock OpenArm
layout; change it only if you have rewired or re-addressed the motors.
position_kp (`list[float]`, *optional*):
MIT-mode proportional gains used by `send_action`, 8 values in motor order.
position_kd (`list[float]`, *optional*):
MIT-mode derivative gains used by `send_action`, 8 values in motor order.
joint_limits (`dict[str, tuple[float, float]]`, *optional*):
Soft `(min, max)` limits in degrees per joint, clipped against on every action.
id (`str`, *optional*):
Identifier for this particular arm; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
pass
@@ -37,15 +37,22 @@ logger = logging.getLogger(__name__)
class OpenArmFollower(Robot):
"""
OpenArms Follower Robot which uses CAN bus communication to control 7 DOF arm with a gripper.
The arm uses Damiao motors in MIT control mode.
"""The OpenArm follower: a 7-DOF arm plus gripper on a CAN bus.
Uses Damiao motors in MIT control mode. See [`~robots.Robot`] for the contract every method here
implements.
"""
config_class = OpenArmFollowerConfig
name = "openarm_follower"
def __init__(self, config: OpenArmFollowerConfig):
"""Build the robot from its configuration.
Args:
config (`OpenArmFollowerConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
@@ -127,13 +134,11 @@ class OpenArmFollower(Robot):
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""
Connect to the robot and optionally calibrate.
"""Connect to the robot and optionally calibrate.
We assume that at connection time, the arms are in a safe rest position,
and torque can be safely disabled to run calibration if needed.
"""
# Connect to CAN bus
logger.info(f"Connecting arm on {self.config.port}...")
self.bus.connect()
@@ -160,8 +165,7 @@ class OpenArmFollower(Robot):
return self.bus.is_calibrated
def calibrate(self) -> None:
"""
Run calibration procedure for OpenArms robot.
"""Run calibration procedure for OpenArms robot.
The calibration procedure:
1. Disable torque
@@ -217,14 +221,18 @@ class OpenArmFollower(Robot):
self.bus.configure_motors()
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building the robot. Interactive: prompts you to connect the controller board to a
single motor at a time.
"""
raise NotImplementedError(
"Motor ID configuration is typically done via manufacturer tools for CAN motors."
)
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""
Get current observation from robot including position, velocity, and torque.
"""Get current observation from robot including position, velocity, and torque.
Reads all motor states (pos/vel/torque) in one CAN refresh cycle
instead of 3 separate reads.
@@ -268,8 +276,7 @@ class OpenArmFollower(Robot):
custom_kp: dict[str, float] | None = None,
custom_kd: dict[str, float] | None = None,
) -> RobotAction:
"""
Send action command to robot.
"""Send action command to robot.
The action magnitude may be clipped based on safety limits.
@@ -281,7 +288,6 @@ class OpenArmFollower(Robot):
Returns:
The action actually sent (potentially clipped)
"""
goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")}
# Apply joint limit clipping to arm
@@ -343,7 +349,6 @@ class OpenArmFollower(Robot):
@check_if_not_connected
def disconnect(self):
"""Disconnect from robot."""
# Disconnect CAN bus
self.bus.disconnect(self.config.disable_torque_on_disconnect)
@@ -23,6 +23,58 @@ from ..config import RobotConfig
@RobotConfig.register_subclass("reachy2")
@dataclass
class Reachy2RobotConfig(RobotConfig):
"""Configuration for the Reachy 2 humanoid.
Reachy 2 is driven over the network rather than a serial bus, so `port` is a TCP port on the robot's
gRPC service rather than a device path. Calibration is handled by the robot itself and there is no
LeRobot calibration file.
Which joints appear in observations and actions is selected by the `with_*` flags: turning a part off
removes its joints entirely. At least one part must stay enabled.
Args:
max_relative_target (`float`, *optional*):
Caps how far a single action may move a joint from its present position, as a safety limit.
`None` disables clipping.
ip_address (`str`, *optional*, defaults to `"localhost"`):
Address of the Reachy 2 robot.
port (`int`, *optional*, defaults to 50065):
TCP port of the robot's service. Not a serial port.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `False`):
Whether to call `turn_off_smoothly()` before disconnecting.
use_external_commands (`bool`, *optional*, defaults to `False`):
Set `True` when another system drives the robot, such as the official
[teleoperation app](https://github.com/pollen-robotics/Reachy2Teleoperation). In that mode
[`~robots.Robot.send_action`] does not send anything to the robot.
with_mobile_base (`bool`, *optional*, defaults to `True`):
Whether to include the mobile base's joints.
with_l_arm (`bool`, *optional*, defaults to `True`):
Whether to include the left arm's joints.
with_r_arm (`bool`, *optional*, defaults to `True`):
Whether to include the right arm's joints.
with_neck (`bool`, *optional*, defaults to `True`):
Whether to include the neck's joints.
with_antennas (`bool`, *optional*, defaults to `True`):
Whether to include the antennas' joints.
with_left_teleop_camera (`bool`, *optional*, defaults to `False`):
Whether to add the left teleoperation camera to observations.
with_right_teleop_camera (`bool`, *optional*, defaults to `False`):
Whether to add the right teleoperation camera to observations.
with_torso_camera (`bool`, *optional*, defaults to `False`):
Whether to add the torso RGB camera to observations.
camera_width (`int`, *optional*, defaults to 640):
Frame width for the built-in cameras. Their frame rate is fixed at 30 and is not configurable.
camera_height (`int`, *optional*, defaults to 480):
Frame height for the built-in cameras.
cameras (`dict[str, CameraConfig]`, *optional*):
Additional cameras beyond the three built-in ones. The `with_*_camera` flags populate this
field, so anything set here is merged with them.
id (`str`, *optional*):
Identifier for this particular robot.
calibration_dir (`Path`, *optional*):
Unused: Reachy 2 manages its own calibration.
"""
# `max_relative_target` limits the magnitude of the relative positional target vector for safety purposes.
# Set this to a positive scalar to have the same value for all motors.
max_relative_target: float | None = None
@@ -65,6 +117,11 @@ class Reachy2RobotConfig(RobotConfig):
cameras: dict[str, CameraConfig] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Add the built-in cameras selected by the `with_*_camera` flags and validate the part selection.
Raises:
ValueError: If every robot part is disabled, which would leave no joints to control.
"""
# Add cameras with same ip_address as the robot
if self.with_left_teleop_camera:
self.cameras["teleop_left"] = Reachy2CameraConfig(
+79 -3
View File
@@ -73,14 +73,18 @@ REACHY2_VEL = {
class Reachy2Robot(Robot):
"""
[Reachy 2](https://www.pollen-robotics.com/reachy/), by Pollen Robotics.
"""
"""[Reachy 2](https://www.pollen-robotics.com/reachy/), the humanoid by Pollen Robotics."""
config_class = Reachy2RobotConfig
name = "reachy2"
def __init__(self, config: Reachy2RobotConfig):
"""Build the robot from its configuration.
Args:
config (`Reachy2RobotConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
require_package("reachy2_sdk", extra="reachy2")
super().__init__(config)
@@ -97,18 +101,41 @@ class Reachy2Robot(Robot):
@property
def observation_features(self) -> dict[str, Any]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self.motors_features, **self.camera_features}
@property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self.motors_features
@property
def camera_features(self) -> dict[str, tuple[int | None, int | None, int]]:
"""The shape of each configured camera's frames.
Returns:
`dict[str, tuple[int | None, int | None, int]]`: Camera name mapped to
`(height, width, channels)`.
"""
return {cam: (self.cameras[cam].height, self.cameras[cam].width, 3) for cam in self.cameras}
@property
def motors_features(self) -> dict[str, type]:
"""The joints this robot exposes, given which parts are enabled in the config.
Returns:
`dict[str, type]`: Joint name mapped to `float`, including the mobile base's velocity
components when `with_mobile_base` is set.
"""
if self.config.with_mobile_base:
return {
**dict.fromkeys(
@@ -125,9 +152,23 @@ class Reachy2Robot(Robot):
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.reachy.is_connected() if self.reachy is not None else False
def connect(self, calibrate: bool = False) -> None:
"""Connect to the robot and its cameras, then apply the configured settings.
Args:
calibrate (`bool`, *optional*, defaults to `False`):
Accepted for interface compatibility and ignored: Reachy 2 manages its own calibration.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.reachy = ReachySDK(self.config.ip_address)
if not self.is_connected:
raise ConnectionError()
@@ -138,15 +179,25 @@ class Reachy2Robot(Robot):
self.configure()
def configure(self) -> None:
"""Apply the operating mode, gains and limits from the configuration to the robot."""
if self.reachy is not None:
self.reachy.turn_on()
self.reachy.reset_default_limits()
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return True
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
pass
def _generate_joints_dict(self) -> dict[str, str]:
@@ -172,6 +223,14 @@ class Reachy2Robot(Robot):
return {}
def get_observation(self) -> RobotObservation:
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
obs_dict: RobotObservation = {}
# Read Reachy 2 state
@@ -186,6 +245,18 @@ class Reachy2Robot(Robot):
return obs_dict
def send_action(self, action: RobotAction) -> RobotAction:
"""Command the robot to move towards a target configuration.
Args:
action (`dict[str, Any]`):
Target values, keyed as in [`~robots.Robot.action_features`].
Returns:
`dict[str, Any]`: The action actually sent, which may be clipped by `max_relative_target`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
if self.reachy is not None:
if not self.is_connected:
raise ConnectionError()
@@ -228,6 +299,11 @@ class Reachy2Robot(Robot):
return action
def disconnect(self) -> None:
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
if self.reachy is not None:
for cam in self.cameras.values():
cam.disconnect()
@@ -23,10 +23,12 @@ from ..config import RobotConfig
@dataclass
class RebotB601FollowerConfig:
"""Base configuration class for the Seeed Studio reBot B601-DM follower arm.
"""Field definitions for the Seeed Studio reBot B601-DM follower arm.
The B601-DM is a 6-DOF arm plus gripper driven by Damiao CAN motors. Motor
communication goes through the ``motorbridge`` package.
This class only carries the fields. The registered configuration users instantiate is
[`RebotB601FollowerRobotConfig`], which documents them all in one place doc-builder renders only a
class's own docstring, never its bases'. It is also used directly as the per-arm config of
[`~robots.bi_rebot_b601_follower.BiRebotB601FollowerConfig`].
"""
# Communication port. For ``can_adapter="damiao"`` this is the Damiao serial
@@ -104,6 +106,62 @@ class RebotB601FollowerConfig:
@RobotConfig.register_subclass("rebot_b601_follower")
@dataclass
class RebotB601FollowerRobotConfig(RobotConfig, RebotB601FollowerConfig):
"""Registered configuration for the reBot B601-DM follower robot."""
"""Configuration for the Seeed Studio reBot B601-DM follower arm.
The B601-DM is a 6-DOF arm plus gripper on Damiao CAN motors, driven through the `motorbridge`
package. What `port` means depends on `can_adapter`. Calibration follows the usual LeRobot flow and is
stored per `id`.
The arm and the gripper are controlled separately: `control_mode` governs the six arm joints and
`gripper_control_mode` the gripper, and each mode uses a different subset of the gain fields.
Per-joint lists hold 7 values in motor order: `shoulder_pan`, `shoulder_lift`, `elbow_flex`,
`wrist_flex`, `wrist_yaw`, `wrist_roll`, `gripper`.
Args:
port (`str`):
Where the arm is reached. For `can_adapter="damiao"` this is the serial bridge device, e.g.
`/dev/ttyACM0`; for `can_adapter="socketcan"` it is the CAN channel name, e.g. `can0`.
can_adapter (`str`, *optional*, defaults to `"damiao"`):
`"damiao"` for the dedicated Damiao serial bridge, or `"socketcan"` for SocketCAN adapters
such as PCAN, slcan and embedded controllers.
dm_serial_baud (`int`, *optional*, defaults to 921600):
Baud rate of the Damiao serial bridge. Only used when `can_adapter="damiao"`.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect. Leave `True` unless the arm is holding a load it
must not drop.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, in degrees. A scalar
applies to every motor; a dict maps motor name to a per-motor cap. `None` disables clipping.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the arm's joint positions.
motor_can_ids (`dict[str, tuple[int, int]]`, *optional*):
Maps motor name to its `(send_can_id, recv_can_id)` pair. Change it only if you have
re-addressed the motors.
pos_vel_velocity (`float | list[float]`, *optional*):
Maximum speed in deg/s per joint, used by the arm in `pos_vel` mode and by the gripper in
`force_pos` mode.
control_mode (`str`, *optional*, defaults to `"mit"`):
How the six arm joints are driven: `"mit"` or `"pos_vel"`.
mit_kp (`float | list[float]`, *optional*):
MIT-mode proportional gains per arm joint. Unused when `control_mode="pos_vel"`.
mit_kd (`float | list[float]`, *optional*):
MIT-mode derivative gains per arm joint. Unused when `control_mode="pos_vel"`.
gripper_control_mode (`str`, *optional*, defaults to `"force_pos"`):
How the gripper is driven: `"force_pos"` or `"mit"`.
gripper_torque_ratio (`float`, *optional*, defaults to 0.07):
Maximum grip force as a fraction in `[0, 1]`. Only used when
`gripper_control_mode="force_pos"`.
gripper_mit_kp (`float`, *optional*, defaults to 8.0):
Gripper MIT-mode proportional gain. Only used when `gripper_control_mode="mit"`.
gripper_mit_kd (`float`, *optional*, defaults to 0.3):
Gripper MIT-mode derivative gain. Only used when `gripper_control_mode="mit"`.
joint_limits (`dict[str, tuple[float, float]]`, *optional*):
Soft `(min, max)` limits in degrees per joint, clipped against on every action.
id (`str`, *optional*):
Identifier for this particular arm; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
"""
pass
@@ -66,6 +66,12 @@ class RebotB601Follower(Robot):
name = "rebot_b601_follower"
def __init__(self, config: RebotB601FollowerRobotConfig):
"""Build the robot from its configuration.
Args:
config (`RebotB601FollowerRobotConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
require_package("motorbridge", extra="rebot")
super().__init__(config)
self.config = config
@@ -91,18 +97,43 @@ class RebotB601Follower(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The values this robot reports, and their types or shapes.
Returns:
`dict`: Keys as returned by [`~robots.Robot.get_observation`], mapped to a scalar type for
proprioceptive values or to a `(height, width, channels)` shape for images.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The values this robot accepts, and their types.
Returns:
`dict`: Keys accepted by [`~robots.Robot.send_action`], mapped to their type.
"""
return self._motors_ft
@property
def is_connected(self) -> bool:
"""Whether every device this robot uses is connected.
Returns:
`bool`: `True` only when the robot and all its cameras are connected.
"""
return self.bus is not None and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""Connect to the robot and its cameras, then apply the configured settings.
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to run calibration if the robot is not already calibrated.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
logger.info(f"Connecting {self} on {self.config.port} (adapter={self.config.can_adapter})...")
if self.config.can_adapter == "damiao":
self.bus = MotorBridgeController.from_dm_serial(
@@ -133,9 +164,18 @@ class RebotB601Follower(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the robot is calibrated.
Returns:
`bool`: `True` when no calibration is needed before use.
"""
return bool(self.calibration)
def calibrate(self) -> None:
"""Calibrate the robot and store the result.
Interactive: prompts on stdin and asks you to move the robot through the required positions.
"""
if self.calibration:
user_input = input(
f"Press ENTER to use provided calibration file associated with the id {self.id}, "
@@ -174,6 +214,7 @@ class RebotB601Follower(Robot):
print(f"Calibration saved to {self.calibration_fpath}")
def configure(self) -> None:
"""Apply the operating mode, gains and limits from the configuration to the robot."""
if self.config.control_mode not in ("pos_vel", "mit"):
raise ValueError(
f"Unsupported control_mode '{self.config.control_mode}'. Use 'pos_vel' or 'mit'."
@@ -226,6 +267,14 @@ class RebotB601Follower(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""Read the robot's current state and a frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
start = time.perf_counter()
obs_dict = {f"{motor}.pos": pos for motor, pos in self._present_pos().items()}
dt_ms = (time.perf_counter() - start) * 1e3
@@ -311,6 +360,11 @@ class RebotB601Follower(Robot):
@check_if_not_connected
def disconnect(self) -> None:
"""Disconnect from the robot and its cameras.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
for motor in self.motors.values():
if self.config.disable_torque_on_disconnect:
motor.disable()
+95 -63
View File
@@ -28,15 +28,22 @@ from .config import RobotConfig
# TODO(aliberts): action/obs typing such as Generic[ObsType, ActType] similar to gym.Env ?
# https://github.com/Farama-Foundation/Gymnasium/blob/3287c869f9a48d99454306b0d4b4ec537f0f35e3/gymnasium/core.py#L23
class Robot(abc.ABC):
"""
The base abstract class for all LeRobot-compatible robots.
"""The base abstract class for all LeRobot-compatible robots.
This class provides a standardized interface for interacting with physical robots.
Subclasses must implement all abstract methods and properties to be usable.
This class provides a standardized interface for interacting with physical robots. Subclasses must
implement all abstract methods and properties to be usable.
Attributes:
config_class (RobotConfig): The expected configuration class for this robot.
name (str): The unique robot name used to identify this robot type.
Used as a context manager, a robot connects on entry and disconnects on exit even if the body raises:
```python
>>> with SO101Follower(config) as robot: # doctest: +SKIP
... obs = robot.get_observation()
... robot.send_action(action)
```
**Attributes**:
- **config_class** (`type[RobotConfig]`) -- The expected configuration class for this robot.
- **name** (`str`) -- The unique robot name used to identify this robot type.
"""
# Set these in ALL subclasses
@@ -44,6 +51,13 @@ class Robot(abc.ABC):
name: str
def __init__(self, config: RobotConfig):
"""Set up identity and calibration paths, loading an existing calibration file if there is one.
Args:
config (`RobotConfig`):
The robot's configuration. Its `id` and `calibration_dir` decide where calibration is
read from and written to.
"""
self.robot_type = self.name
self.id = config.id
self.calibration_dir = (
@@ -56,28 +70,24 @@ class Robot(abc.ABC):
self._load_calibration()
def __str__(self) -> str:
"""Return this robot's id and class name, e.g. `"my_arm SO101Follower"`.
Returns:
`str`: A short identifier used in log messages.
"""
return f"{self.id} {self.__class__.__name__}"
def __enter__(self):
"""
Context manager entry.
Automatically connects to the camera.
"""
"""Context manager entry. Automatically connects to the robot."""
self.connect()
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
"""
Context manager exit.
Automatically disconnects, ensuring resources are released even on error.
"""
"""Context manager exit. Disconnects, ensuring resources are released even on error."""
self.disconnect()
def __del__(self) -> None:
"""
Destructor safety net.
Attempts to disconnect if the object is garbage collected without cleanup.
"""
"""Destructor safety net. Disconnects if the object is garbage collected without cleanup."""
try:
if self.is_connected:
self.disconnect()
@@ -88,83 +98,102 @@ class Robot(abc.ABC):
@property
@abc.abstractmethod
def observation_features(self) -> dict:
"""
A dictionary describing the structure and types of the observations produced by the robot.
Its structure (keys) should match the structure of what is returned by :pymeth:`get_observation`.
Values for the dict should either be:
- The type of the value if it's a simple value, e.g. `float` for single proprioceptive value (a joint's position/velocity)
- A tuple representing the shape if it's an array-type value, e.g. `(height, width, channel)` for images
"""A dictionary describing the structure and types of the observations produced by the robot.
Note: this property should be able to be called regardless of whether the robot is connected or not.
Its keys should match the structure of what is returned by [`~robots.Robot.get_observation`]. Values
should either be:
- the type of the value if it's a simple value, e.g. `float` for a single proprioceptive value
(a joint's position or velocity)
- a tuple representing the shape if it's an array-type value, e.g. `(height, width, channel)` for
images
> [!NOTE]
> This property must be callable regardless of whether the robot is connected.
Returns:
`dict`: Observation names mapped to their type or shape.
"""
pass
@property
@abc.abstractmethod
def action_features(self) -> dict:
"""
A dictionary describing the structure and types of the actions expected by the robot. Its structure
(keys) should match the structure of what is passed to :pymeth:`send_action`. Values for the dict
should be the type of the value if it's a simple value, e.g. `float` for single proprioceptive value
(a joint's goal position/velocity)
"""A dictionary describing the structure and types of the actions expected by the robot.
Note: this property should be able to be called regardless of whether the robot is connected or not.
Its keys should match the structure of what is passed to [`~robots.Robot.send_action`]. Values should
be the type of the value if it's a simple value, e.g. `float` for a single proprioceptive value
(a joint's goal position or velocity).
> [!NOTE]
> This property must be callable regardless of whether the robot is connected.
Returns:
`dict`: Action names mapped to their type or shape.
"""
pass
@property
@abc.abstractmethod
def is_connected(self) -> bool:
"""
Whether the robot is currently connected or not. If `False`, calling :pymeth:`get_observation` or
:pymeth:`send_action` should raise an error.
"""Whether the robot is currently connected.
If `False`, calling [`~robots.Robot.get_observation`] or [`~robots.Robot.send_action`] should raise
an error.
Returns:
`bool`: `True` if communication with the robot is established.
"""
pass
@abc.abstractmethod
def connect(self, calibrate: bool = True) -> None:
"""
Establish communication with the robot.
"""Establish communication with the robot.
Args:
calibrate (bool): If True, automatically calibrate the robot after connecting if it's not
calibrated or needs calibration (this is hardware-dependant).
calibrate (`bool`, *optional*, defaults to `True`):
Whether to automatically calibrate the robot after connecting, if it is not calibrated or
needs recalibration. Whether calibration is needed is hardware-dependent.
"""
pass
@property
@abc.abstractmethod
def is_calibrated(self) -> bool:
"""Whether the robot is currently calibrated or not. Should be always `True` if not applicable"""
"""Whether the robot is currently calibrated.
Returns:
`bool`: `True` if the robot is calibrated. Always `True` for robots where calibration does not
apply.
"""
pass
@abc.abstractmethod
def calibrate(self) -> None:
"""
Calibrate the robot if applicable. If not, this should be a no-op.
"""Calibrate the robot if applicable. If not, this should be a no-op.
This method should collect any necessary data (e.g., motor offsets) and update the
:pyattr:`calibration` dictionary accordingly.
This method should collect any necessary data (e.g. motor offsets) and update the `calibration`
dictionary accordingly.
"""
pass
def _load_calibration(self, fpath: Path | None = None) -> None:
"""
Helper to load calibration data from the specified file.
"""Helper to load calibration data from the specified file.
Args:
fpath (Path | None): Optional path to the calibration file. Defaults to `self.calibration_fpath`.
fpath (`Path`, *optional*):
Path to the calibration file. Defaults to `self.calibration_fpath`.
"""
fpath = self.calibration_fpath if fpath is None else fpath
with open(fpath) as f, draccus.config_type("json"):
self.calibration = draccus.load(dict[str, MotorCalibration], f)
def _save_calibration(self, fpath: Path | None = None) -> None:
"""
Helper to save calibration data to the specified file.
"""Helper to save calibration data to the specified file.
Args:
fpath (Path | None): Optional path to save the calibration file. Defaults to `self.calibration_fpath`.
fpath (`Path`, *optional*):
Path to save the calibration file to. Defaults to `self.calibration_fpath`.
"""
fpath = self.calibration_fpath if fpath is None else fpath
with open(fpath, "w") as f, draccus.config_type("json"):
@@ -172,36 +201,39 @@ class Robot(abc.ABC):
@abc.abstractmethod
def configure(self) -> None:
"""
Apply any one-time or runtime configuration to the robot.
"""Apply any one-time or runtime configuration to the robot.
This may include setting motor parameters, control modes, or initial state.
"""
pass
@abc.abstractmethod
def get_observation(self) -> RobotObservation:
"""
Retrieve the current observation from the robot.
"""Retrieve the current observation from the robot.
Returns:
RobotObservation: A flat dictionary representing the robot's current sensory state. Its structure
should match :pymeth:`observation_features`.
"""
`dict[str, Any]`: A flat dictionary representing the robot's current sensory state. Its structure
should match [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If [`~robots.Robot.connect`] has not been called.
"""
pass
@abc.abstractmethod
def send_action(self, action: RobotAction) -> RobotAction:
"""
Send an action command to the robot.
"""Send an action command to the robot.
Args:
action (RobotAction): Dictionary representing the desired action. Its structure should match
:pymeth:`action_features`.
action (`dict[str, Any]`):
The desired action. Its structure should match [`~robots.Robot.action_features`].
Returns:
RobotAction: The action actually sent to the motors potentially clipped or modified, e.g. by
safety limits on velocity.
`dict[str, Any]`: The action actually sent to the motors, potentially clipped or modified, e.g.
by safety limits on velocity. Prefer this over the requested action when logging or recording.
Raises:
DeviceNotConnectedError: If [`~robots.Robot.connect`] has not been called.
"""
pass
@@ -23,7 +23,12 @@ from ..config import RobotConfig
@dataclass
class SOFollowerConfig:
"""Base configuration class for SO Follower robots."""
"""Field definitions shared by the SO-family follower arms.
This class only carries the fields. The registered configuration users instantiate is
[`SOFollowerRobotConfig`], which combines these with [`~robots.RobotConfig`] and documents them all in
one place doc-builder renders only a class's own docstring, never its bases'.
"""
# Port to connect to the arm
port: str
@@ -57,6 +62,51 @@ class SOFollowerConfig:
@RobotConfig.register_subclass("so100_follower")
@dataclass
class SOFollowerRobotConfig(RobotConfig, SOFollowerConfig):
"""Configuration for the SO-100 and SO-101 follower arms.
Both arms share this class; `SO100FollowerConfig` and `SO101FollowerConfig` are aliases for it. They
differ in their calibration and gearing, not in their control code.
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyACM0` on Linux or `COM3` on Windows. Run
`lerobot-find-port` to identify it.
disable_torque_on_disconnect (`bool`, *optional*, defaults to `True`):
Whether to release the motors on disconnect. Leave `True` unless the arm is holding a load it
must not drop.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps how far a single action may move the arm from its present position, as a safety limit. A
scalar applies to every motor; a dict maps motor name to a per-motor cap. `None` disables
clipping. Enabling this costs an extra read of the present position on every step.
cameras (`dict[str, CameraConfig]`, *optional*):
Cameras to read alongside the arm's joint positions, keyed by the name they appear under in
observations. Each must specify `width`, `height` and `fps`.
use_degrees (`bool`, *optional*, defaults to `True`):
Whether to report and accept joint positions in degrees. Keep `True` for compatibility with
existing policies and datasets.
position_p_coefficient (`int`, *optional*, defaults to 16):
Proportional gain written to the Feetech STS3215 motors at connect time.
position_i_coefficient (`int`, *optional*, defaults to 0):
Integral gain written to the motors at connect time.
position_d_coefficient (`int`, *optional*, defaults to 32):
Derivative gain written to the motors at connect time.
num_read_retries (`int`, *optional*, defaults to 2):
Extra attempts when a `sync_read` fails. Feetech buses occasionally return a corrupted status
packet, especially when several joints move at once, which would otherwise abort the control
loop. Retries are immediate and only happen on failure, so steady-state read cost is unchanged.
id (`str`, *optional*):
Identifier for this particular arm; also names its calibration file.
calibration_dir (`Path`, *optional*):
Where to read and write the calibration file. Defaults to the LeRobot calibration home.
Example:
```python
>>> from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
>>> config = SO101FollowerConfig(port="/dev/ttyACM0", max_relative_target=5.0) # doctest: +SKIP
>>> robot = SO101Follower(config) # doctest: +SKIP
```
"""
pass
@@ -40,8 +40,7 @@ logger = logging.getLogger(__name__)
@ProcessorStepRegistry.register("ee_reference_and_delta")
@dataclass
class EEReferenceAndDelta(RobotActionProcessorStep):
"""
Computes a target end-effector pose from a relative delta command.
"""Computes a target end-effector pose from a relative delta command.
This step takes a desired change in position and orientation (`target_*`) and applies it to a
reference end-effector pose to calculate an absolute target pose. The reference pose is derived
@@ -53,15 +52,16 @@ class EEReferenceAndDelta(RobotActionProcessorStep):
2. `use_latched_reference=False`: The reference pose is updated to the robot's current pose at
every step.
Attributes:
kinematics: The robot's kinematic model for forward kinematics.
end_effector_step_sizes: A dictionary scaling the input delta commands.
motor_names: A list of motor names required for forward kinematics.
use_latched_reference: If True, latch the reference pose on enable; otherwise, always use the
current pose as the reference.
reference_ee_pose: Internal state storing the latched reference pose.
_prev_enabled: Internal state to detect the rising edge of the enable signal.
_command_when_disabled: Internal state to hold the last command while disabled.
**Attributes**:
- **kinematics** (`RobotKinematics`) -- The robot's kinematic model for forward kinematics.
- **end_effector_step_sizes** (`dict`) -- A dictionary scaling the input delta commands.
- **motor_names** (`list[str]`) -- A list of motor names required for forward kinematics.
- **use_latched_reference** (`bool`) -- If True, latch the reference pose on enable; otherwise, always
use the current pose as the reference.
- **reference_ee_pose** (`np.ndarray | None`) -- Internal state storing the latched reference pose.
- **_prev_enabled** (`bool`) -- Internal state to detect the rising edge of the enable signal.
- **_command_when_disabled** (`np.ndarray | None`) -- Internal state to hold the last command while
disabled.
"""
kinematics: RobotKinematics
@@ -77,6 +77,15 @@ class EEReferenceAndDelta(RobotActionProcessorStep):
_command_when_disabled: np.ndarray | None = field(default=None, init=False, repr=False)
def action(self, action: RobotAction) -> RobotAction:
"""Transform the action for this step.
Args:
action (`dict[str, Any]`):
The incoming robot action.
Returns:
`dict[str, Any]`: The transformed action.
"""
raw_observation = self.transition.get(TransitionKey.OBSERVATION)
if raw_observation is None:
@@ -167,6 +176,16 @@ class EEReferenceAndDelta(RobotActionProcessorStep):
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
for feat in [
"enabled",
"target_x",
@@ -190,21 +209,19 @@ class EEReferenceAndDelta(RobotActionProcessorStep):
@ProcessorStepRegistry.register("ee_bounds_and_safety")
@dataclass
class EEBoundsAndSafety(RobotActionProcessorStep):
"""
Clips the end-effector pose to predefined bounds and checks for unsafe jumps.
"""Clips the end-effector pose to predefined bounds and checks for unsafe jumps.
This step ensures that the target end-effector pose remains within a safe operational workspace.
It also moderates the command to prevent large, sudden movements between consecutive steps.
Attributes:
end_effector_bounds: A dictionary with "min" and "max" keys for position clipping.
max_ee_step_m: The maximum allowed change in position (in meters) between steps.
raise_on_jump: When ``True`` (default) an over-limit per-frame step raises
``ValueError`` (aborting the control loop). When ``False`` the step is
rate-limited to ``max_ee_step_m`` and a warning is logged instead the
safer choice for live teleoperation, where a transient tracking glitch
should not crash the loop and leave the robot uncontrolled.
_last_pos: Internal state storing the last commanded position.
**Attributes**:
- **end_effector_bounds** (`dict`) -- A dictionary with "min" and "max" keys for position clipping.
- **max_ee_step_m** (`float`) -- The maximum allowed change in position (in meters) between steps.
- **raise_on_jump** (`bool`) -- When ``True`` (default) an over-limit per-frame step raises
``ValueError`` (aborting the control loop). When ``False`` the step is rate-limited to
``max_ee_step_m`` and a warning is logged instead the safer choice for live teleoperation, where a
transient tracking glitch should not crash the loop and leave the robot uncontrolled.
- **_last_pos** (`np.ndarray | None`) -- Internal state storing the last commanded position.
"""
end_effector_bounds: dict
@@ -213,6 +230,15 @@ class EEBoundsAndSafety(RobotActionProcessorStep):
_last_pos: np.ndarray | None = field(default=None, init=False, repr=False)
def action(self, action: RobotAction) -> RobotAction:
"""Transform the action for this step.
Args:
action (`dict[str, Any]`):
The incoming robot action.
Returns:
`dict[str, Any]`: The transformed action.
"""
x = action["ee.x"]
y = action["ee.y"]
z = action["ee.z"]
@@ -268,29 +294,39 @@ class EEBoundsAndSafety(RobotActionProcessorStep):
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
return features
@ProcessorStepRegistry.register("inverse_kinematics_ee_to_joints")
@dataclass
class InverseKinematicsEEToJoints(RobotActionProcessorStep):
"""
Computes desired joint positions from a target end-effector pose using inverse kinematics (IK).
"""Computes desired joint positions from a target end-effector pose using inverse kinematics (IK).
This step translates a Cartesian command (position and orientation of the end-effector) into
the corresponding joint-space commands for each motor.
Attributes:
kinematics: The robot's kinematic model for inverse kinematics.
motor_names: A list of motor names for which to compute joint positions.
q_curr: Internal state storing the last joint positions, used as an initial guess for the IK solver.
initial_guess_current_joints: If True, use the robot's current joint state as the IK guess.
If False, use the solution from the previous step.
orientation_weight: Weight for the orientation constraint passed to
``RobotKinematics.inverse_kinematics``. Defaults to ``0.01`` (matching the solver
default, so existing callers are unchanged). Set to ``0.0`` for position-only IK on
under-actuated arms; a small nonzero weight gives soft-orientation IK on the 5-DOF
SO-101, where the wrist tracks orientation only partially (position dominates).
**Attributes**:
- **kinematics** (`RobotKinematics`) -- The robot's kinematic model for inverse kinematics.
- **motor_names** (`list[str]`) -- A list of motor names for which to compute joint positions.
- **q_curr** (`np.ndarray | None`) -- Internal state storing the last joint positions, used as an
initial guess for the IK solver.
- **initial_guess_current_joints** (`bool`) -- If True, use the robot's current joint state as the IK
guess. If False, use the solution from the previous step.
- **orientation_weight** (`float`) -- Weight for the orientation constraint passed to
``RobotKinematics.inverse_kinematics``. Defaults to ``0.01`` (matching the solver default, so
existing callers are unchanged). Set to ``0.0`` for position-only IK on under-actuated arms; a small
nonzero weight gives soft-orientation IK on the 5-DOF SO-101, where the wrist tracks orientation
only partially (position dominates).
"""
kinematics: RobotKinematics
@@ -300,6 +336,15 @@ class InverseKinematicsEEToJoints(RobotActionProcessorStep):
orientation_weight: float = 0.01
def action(self, action: RobotAction) -> RobotAction:
"""Transform the action for this step.
Args:
action (`dict[str, Any]`):
The incoming robot action.
Returns:
`dict[str, Any]`: The transformed action.
"""
x = action.pop("ee.x")
y = action.pop("ee.y")
z = action.pop("ee.z")
@@ -355,6 +400,16 @@ class InverseKinematicsEEToJoints(RobotActionProcessorStep):
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
for feat in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]:
features[PipelineFeatureType.ACTION].pop(f"ee.{feat}", None)
@@ -373,20 +428,20 @@ class InverseKinematicsEEToJoints(RobotActionProcessorStep):
@ProcessorStepRegistry.register("gripper_velocity_to_joint")
@dataclass
class GripperVelocityToJoint(RobotActionProcessorStep):
"""
Converts a gripper velocity command into a target gripper joint position.
"""Converts a gripper velocity command into a target gripper joint position.
This step integrates a normalized velocity command over time to produce a position command,
taking the current gripper position as a starting point. It also supports a discrete mode
where integer actions map to open, close, or no-op.
Attributes:
motor_names: A list of motor names, which must include 'gripper'.
speed_factor: A scaling factor to convert the normalized velocity command to a position change.
clip_min: The minimum allowed gripper joint position.
clip_max: The maximum allowed gripper joint position.
discrete_gripper: If True, interpret the input as a discrete class index
{0 = close, 1 = stay, 2 = open}, matching `GamepadTeleop.GripperAction`.
**Attributes**:
- **motor_names** -- A list of motor names, which must include 'gripper'.
- **speed_factor** (`float`) -- A scaling factor to convert the normalized velocity command to a
position change.
- **clip_min** (`float`) -- The minimum allowed gripper joint position.
- **clip_max** (`float`) -- The maximum allowed gripper joint position.
- **discrete_gripper** (`bool`) -- If True, interpret the input as a discrete class index {0 = close,
1 = stay, 2 = open}, matching `GamepadTeleop.GripperAction`.
"""
speed_factor: float = 20.0
@@ -395,6 +450,15 @@ class GripperVelocityToJoint(RobotActionProcessorStep):
discrete_gripper: bool = False
def action(self, action: RobotAction) -> RobotAction:
"""Transform the action for this step.
Args:
action (`dict[str, Any]`):
The incoming robot action.
Returns:
`dict[str, Any]`: The transformed action.
"""
raw_observation = self.transition.get(TransitionKey.OBSERVATION)
gripper_vel = action.pop("ee.gripper_vel")
@@ -428,6 +492,16 @@ class GripperVelocityToJoint(RobotActionProcessorStep):
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
features[PipelineFeatureType.ACTION].pop("ee.gripper_vel", None)
features[PipelineFeatureType.ACTION]["ee.gripper_pos"] = PolicyFeature(
type=FeatureType.ACTION, shape=(1,)
@@ -439,6 +513,21 @@ class GripperVelocityToJoint(RobotActionProcessorStep):
def compute_forward_kinematics_joints_to_ee(
joints: dict[str, Any], kinematics: RobotKinematics, motor_names: list[str]
) -> dict[str, Any]:
"""Replace joint positions with the end-effector pose they produce.
Args:
joints (`dict[str, Any]`):
Joint values keyed `"<motor>.pos"`, including `"gripper.pos"`. Modified in place: the joint
keys named in `motor_names` are removed.
kinematics (`RobotKinematics`):
The arm's kinematic model.
motor_names (`list[str]`):
The motors, in the order the kinematic model expects them.
Returns:
`dict[str, Any]`: The same dict with `ee.x`, `ee.y`, `ee.z` for position, `ee.wx`, `ee.wy`,
`ee.wz` for orientation as a rotation vector, and `ee.gripper_pos` carried through unchanged.
"""
motor_joint_values = [joints[f"{n}.pos"] for n in motor_names]
q = np.array(motor_joint_values, dtype=float)
@@ -461,26 +550,44 @@ def compute_forward_kinematics_joints_to_ee(
@ProcessorStepRegistry.register("forward_kinematics_joints_to_ee_observation")
@dataclass
class ForwardKinematicsJointsToEEObservation(ObservationProcessorStep):
"""
Computes the end-effector pose from joint positions using forward kinematics (FK).
"""Computes the end-effector pose from joint positions using forward kinematics (FK).
This step is typically used to add the robot's Cartesian pose to the observation space,
which can be useful for visualization or as an input to a policy.
Attributes:
kinematics: The robot's kinematic model.
**Attributes**:
- **kinematics** (`RobotKinematics`) -- The robot's kinematic model.
"""
kinematics: RobotKinematics
motor_names: list[str]
def observation(self, observation: RobotObservation) -> RobotObservation:
"""Replace the observation's joint positions with the end-effector pose.
Args:
observation (`dict[str, Any]`):
The incoming observation, containing `"<motor>.pos"` keys.
Returns:
`dict[str, Any]`: The observation with `ee.*` keys in place of the joint positions.
"""
return compute_forward_kinematics_joints_to_ee(observation, self.kinematics, self.motor_names)
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
# We only use the ee pose in the dataset, so we don't need the joint positions
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
for n in self.motor_names:
features[PipelineFeatureType.OBSERVATION].pop(f"{n}.pos", None)
# We specify the dataset features of this step that we want to be stored in the dataset
@@ -494,26 +601,44 @@ class ForwardKinematicsJointsToEEObservation(ObservationProcessorStep):
@ProcessorStepRegistry.register("forward_kinematics_joints_to_ee_action")
@dataclass
class ForwardKinematicsJointsToEEAction(RobotActionProcessorStep):
"""
Computes the end-effector pose from joint positions using forward kinematics (FK).
"""Computes the end-effector pose from joint positions using forward kinematics (FK).
This step is typically used to add the robot's Cartesian pose to the observation space,
which can be useful for visualization or as an input to a policy.
Attributes:
kinematics: The robot's kinematic model.
**Attributes**:
- **kinematics** (`RobotKinematics`) -- The robot's kinematic model.
"""
kinematics: RobotKinematics
motor_names: list[str]
def action(self, action: RobotAction) -> RobotAction:
"""Transform the action for this step.
Args:
action (`dict[str, Any]`):
The incoming robot action.
Returns:
`dict[str, Any]`: The transformed action.
"""
return compute_forward_kinematics_joints_to_ee(action, self.kinematics, self.motor_names)
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
# We only use the ee pose in the dataset, so we don't need the joint positions
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
for n in self.motor_names:
features[PipelineFeatureType.ACTION].pop(f"{n}.pos", None)
# Store end-effector features as actions in the dataset schema
@@ -527,10 +652,21 @@ class ForwardKinematicsJointsToEEAction(RobotActionProcessorStep):
@ProcessorStepRegistry.register(name="forward_kinematics_joints_to_ee")
@dataclass
class ForwardKinematicsJointsToEE(ProcessorStep):
"""Applies forward kinematics to whichever of the action and observation are present.
A convenience wrapper over [`ForwardKinematicsJointsToEEAction`] and
[`ForwardKinematicsJointsToEEObservation`], so a pipeline needs one step instead of two.
**Attributes**:
- **kinematics** (`RobotKinematics`) -- The arm's kinematic model.
- **motor_names** (`list[str]`) -- The motors, in the order the kinematic model expects them.
"""
kinematics: RobotKinematics
motor_names: list[str]
def __post_init__(self):
"""Build the action and observation sub-steps this step delegates to."""
self.joints_to_ee_action_processor = ForwardKinematicsJointsToEEAction(
kinematics=self.kinematics, motor_names=self.motor_names
)
@@ -539,6 +675,15 @@ class ForwardKinematicsJointsToEE(ProcessorStep):
)
def __call__(self, transition: EnvTransition) -> EnvTransition:
"""Apply forward kinematics to whichever of the action and observation are present.
Args:
transition (`EnvTransition`):
The transition to transform.
Returns:
`EnvTransition`: The transition with `ee.*` keys in place of joint positions.
"""
if transition.get(TransitionKey.ACTION) is not None:
transition = self.joints_to_ee_action_processor(transition)
if transition.get(TransitionKey.OBSERVATION) is not None:
@@ -548,6 +693,16 @@ class ForwardKinematicsJointsToEE(ProcessorStep):
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
if features[PipelineFeatureType.ACTION] is not None:
features = self.joints_to_ee_action_processor.transform_features(features)
if features[PipelineFeatureType.OBSERVATION] is not None:
@@ -558,8 +713,7 @@ class ForwardKinematicsJointsToEE(ProcessorStep):
@ProcessorStepRegistry.register("inverse_kinematics_rl_step")
@dataclass
class InverseKinematicsRLStep(ProcessorStep):
"""
Computes desired joint positions from a target end-effector pose using inverse kinematics (IK).
"""Computes desired joint positions from a target end-effector pose using inverse kinematics (IK).
This is modified from the InverseKinematicsEEToJoints step to be used in the RL pipeline.
"""
@@ -570,6 +724,15 @@ class InverseKinematicsRLStep(ProcessorStep):
initial_guess_current_joints: bool = True
def __call__(self, transition: EnvTransition) -> EnvTransition:
"""Solve inverse kinematics for the transition's end-effector action.
Args:
transition (`EnvTransition`):
The transition to transform.
Returns:
`EnvTransition`: The transition with joint targets in place of the `ee.*` action.
"""
new_transition = dict(transition)
action = new_transition.get(TransitionKey.ACTION)
if action is None:
@@ -633,6 +796,16 @@ class InverseKinematicsRLStep(ProcessorStep):
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
"""Update the feature contract to match what this step does to the data.
Args:
features (`dict[PipelineFeatureType, dict[str, PolicyFeature]]`):
The pipeline's feature contract so far.
Returns:
`dict[PipelineFeatureType, dict[str, PolicyFeature]]`: The contract with this step's key changes
applied.
"""
for feat in ["x", "y", "z", "wx", "wy", "wz", "gripper_pos"]:
features[PipelineFeatureType.ACTION].pop(f"ee.{feat}", None)
+87 -8
View File
@@ -35,15 +35,35 @@ logger = logging.getLogger(__name__)
class SOFollower(Robot):
"""
Generic SO follower base implementing common functionality for SO-100/101/10X.
Designed to be subclassed with a per-hardware-model `config_class` and `name`.
"""The SO-family follower arm: a 5-DOF arm plus gripper on a Feetech bus.
`SO100Follower` and `SO101Follower` are aliases of this class. The two arms differ in calibration and
gearing, not control code, so both are driven through the same implementation with a different
`config_class` and `name`.
Actions and observations are keyed `"<motor>.pos"`; cameras named in the config appear in observations
under their own keys. See [`~robots.Robot`] for the contract every method here implements.
Example:
```python
>>> from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0")) # doctest: +SKIP
>>> with robot: # doctest: +SKIP
... observation = robot.get_observation()
... robot.send_action({"shoulder_pan.pos": 0.0})
```
"""
config_class = SOFollowerRobotConfig
name = "so_follower"
def __init__(self, config: SOFollowerRobotConfig):
"""Build the robot from its configuration.
Args:
config (`SOFollowerRobotConfig`):
The robot's configuration. Its `port` and `cameras` determine what is connected.
"""
super().__init__(config)
self.config = config
# choose normalization mode depending on config if available
@@ -78,23 +98,48 @@ class SOFollower(Robot):
@cached_property
def observation_features(self) -> dict[str, type | tuple]:
"""The arm's joint positions plus one entry per configured camera.
Returns:
`dict[str, type | tuple]`: `"<motor>.pos"` keys mapped to `float`, and one key per camera
mapped to its `(height, width, channels)` shape.
"""
return {**self._motors_ft, **self._cameras_ft}
@cached_property
def action_features(self) -> dict[str, type]:
"""The arm's goal joint positions.
Returns:
`dict[str, type]`: `"<motor>.pos"` keys mapped to `float`.
"""
return self._motors_ft
@property
def is_connected(self) -> bool:
"""Whether the motor bus and every configured camera are connected.
Returns:
`bool`: `True` only when all of them are.
"""
return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values())
@check_if_already_connected
def connect(self, calibrate: bool = True) -> None:
"""
We assume that at connection time, arm is in a rest position,
and torque can be safely disabled to run calibration.
"""
"""Connect the motor bus and cameras, calibrating and configuring the arm.
> [!WARNING]
> The arm is assumed to be at rest when this is called, because torque is disabled to run
> calibration. Do not call it with the arm holding a load.
Args:
calibrate (`bool`, *optional*, defaults to `True`):
Whether to run calibration when the motors disagree with the calibration file, or no file
exists yet. Calibration is interactive and prompts on stdin.
Raises:
DeviceAlreadyConnectedError: If the robot is already connected.
"""
self.bus.connect()
if not self.is_calibrated and calibrate:
logger.info(
@@ -110,9 +155,19 @@ class SOFollower(Robot):
@property
def is_calibrated(self) -> bool:
"""Whether the motors' stored calibration matches the calibration file.
Returns:
`bool`: `True` when the arm needs no recalibration.
"""
return self.bus.is_calibrated
def calibrate(self) -> None:
"""Calibrate the arm, writing the result to the motors and the calibration file.
This is interactive: it prompts on stdin to reuse an existing calibration file, and otherwise asks
you to move the arm to its middle position and then through each joint's full range.
"""
if self.calibration:
# Calibration file exists, ask user whether to use it or run new calibration
user_input = input(
@@ -157,6 +212,11 @@ class SOFollower(Robot):
print("Calibration saved to", self.calibration_fpath)
def configure(self) -> None:
"""Write the position-mode operating mode and the configured PID gains to every motor.
The gripper additionally gets reduced torque, current and overload limits so that gripping a rigid
object does not burn out its motor.
"""
with self.bus.torque_disabled():
self.bus.configure_motors()
for motor in self.bus.motors:
@@ -171,6 +231,11 @@ class SOFollower(Robot):
self.bus.write("Overload_Torque", motor, 25) # 25% torque when overloaded
def setup_motors(self) -> None:
"""Assign each motor its bus ID, one at a time.
Run this once when building an arm. It is interactive: it prompts you to connect the controller
board to a single motor at a time, working from the gripper back to the base.
"""
for motor in reversed(self.bus.motors):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
@@ -178,6 +243,14 @@ class SOFollower(Robot):
@check_if_not_connected
def get_observation(self) -> RobotObservation:
"""Read the arm's joint positions and one frame from each camera.
Returns:
`dict[str, Any]`: Keys matching [`~robots.Robot.observation_features`].
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
# Read arm position
start = time.perf_counter()
obs_dict = self.bus.sync_read("Present_Position", num_retry=self.config.num_read_retries)
@@ -215,7 +288,6 @@ class SOFollower(Robot):
Returns:
RobotAction: the action sent to the motors, potentially clipped.
"""
goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")}
# Cap goal position when too far away from present position.
@@ -231,6 +303,13 @@ class SOFollower(Robot):
@check_if_not_connected
def disconnect(self):
"""Disconnect the motor bus and every camera.
Torque is released first unless `disable_torque_on_disconnect` is `False`.
Raises:
DeviceNotConnectedError: If the robot is not connected.
"""
self.bus.disconnect(self.config.disable_torque_on_disconnect)
for cam in self.cameras.values():
cam.disconnect()
@@ -47,6 +47,42 @@ _DEFAULT_KP, _DEFAULT_KD = _build_gains()
@RobotConfig.register_subclass("unitree_g1")
@dataclass
class UnitreeG1Config(RobotConfig):
"""Configuration for the Unitree G1 humanoid.
The G1 is reached over a ZMQ bridge rather than a serial bus, so there is no `port` field and
calibration is handled by the robot's own firmware.
All 29 joints are addressed by index, so `kp`, `kd` and `default_positions` are lists in the G1's joint
order: left leg, right leg, waist, left arm, left wrist, right arm, right wrist.
Args:
kp (`list[float]`, *optional*):
Per-joint proportional gains, 29 values. Defaults to the per-body-part gains recommended by
Unitree.
kd (`list[float]`, *optional*):
Per-joint derivative gains, 29 values.
default_positions (`list[float]`, *optional*):
Per-joint home positions, 29 values. Defaults to all zeros.
control_dt (`float`, *optional*, defaults to 0.004):
Control loop timestep in seconds, i.e. 250 Hz.
is_simulation (`bool`, *optional*, defaults to `True`):
Whether to drive a MuJoCo simulation instead of the physical robot. Keep `True` until the
behaviour is validated in sim.
robot_ip (`str`, *optional*, defaults to `"192.168.123.164"`):
Address of the robot's ZMQ bridge. The default is the G1's factory address.
cameras (`dict[str, CameraConfig]`, *optional*):
ZMQ-based remote cameras to read alongside the joint states.
gravity_compensation (`bool`, *optional*, defaults to `False`):
Whether to compensate for gravity on the arms using the arm IK solver.
controller (`str`, *optional*):
Class name of the lower-body locomotion controller, e.g. `"GrootLocomotionController"` or
`"HolosomaLocomotionController"`. `None` leaves the legs uncontrolled.
id (`str`, *optional*):
Identifier for this particular robot.
calibration_dir (`Path`, *optional*):
Unused: the G1 manages its own calibration.
"""
kp: list[float] = field(default_factory=lambda: _DEFAULT_KP.copy())
kd: list[float] = field(default_factory=lambda: _DEFAULT_KD.copy())
@@ -24,7 +24,17 @@ logger = logging.getLogger(__name__)
class WeightedMovingFilter:
"""A fixed-length weighted moving average over recent samples, used to smooth IK solutions."""
def __init__(self, weights, data_size=14):
"""Set up the filter.
Args:
weights:
Per-sample weights, newest first. Their length sets the window size.
data_size (`int`, *optional*, defaults to 14):
Number of values in each sample.
"""
self._window_size = len(weights)
self._weights = np.array(weights)
self._data_size = data_size
@@ -39,6 +49,12 @@ class WeightedMovingFilter:
return data_array.T @ self._weights
def add_data(self, new_data):
"""Push a sample into the window and recompute the filtered value.
Args:
new_data:
A sample of length `data_size`. Ignored if identical to the newest one already held.
"""
assert len(new_data) == self._data_size
if len(self._data_queue) > 0 and np.array_equal(
@@ -51,11 +67,24 @@ class WeightedMovingFilter:
@property
def filtered_data(self):
"""The current weighted average.
Returns:
`np.ndarray`: The filtered sample.
"""
return self._filtered_data
class G1_29_ArmIK: # noqa: N801
"""Inverse kinematics for the G1's two arms, solved together as one optimisation problem."""
def __init__(self, unit_test=False):
"""Build the arm model and the IK solver.
Args:
unit_test (`bool`, *optional*, defaults to `False`):
Whether to run in test mode, which visualises the solution instead of driving a robot.
"""
import casadi
import pinocchio as pin
from huggingface_hub import snapshot_download
@@ -230,6 +259,21 @@ class G1_29_ArmIK: # noqa: N801
self.smooth_filter = WeightedMovingFilter(np.array([0.4, 0.3, 0.2, 0.1]), 14)
def solve_ik(self, left_wrist, right_wrist, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None):
"""Solve for the arm joint angles that place both wrists at the requested poses.
Args:
left_wrist:
Target pose of the left wrist as a 4x4 homogeneous transform.
right_wrist:
Target pose of the right wrist as a 4x4 homogeneous transform.
current_lr_arm_motor_q (*optional*):
Present arm joint positions, used as the solver's initial guess.
current_lr_arm_motor_dq (*optional*):
Present arm joint velocities, used to compute feed-forward torques.
Returns:
`tuple`: The solved joint positions and the corresponding torques.
"""
if current_lr_arm_motor_q is not None:
self.init_data = current_lr_arm_motor_q
self.opti.set_initial(self.var_q, self.init_data)
@@ -268,6 +312,17 @@ class G1_29_ArmIK: # noqa: N801
return sol_q, sol_tauff
def solve_tau(self, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None):
"""Compute the gravity-compensating torques for the arms at a given state.
Args:
current_lr_arm_motor_q (*optional*):
Present arm joint positions.
current_lr_arm_motor_dq (*optional*):
Present arm joint velocities.
Returns:
`np.ndarray`: Per-joint torques.
"""
try:
q_g1 = np.array(current_lr_arm_motor_q, dtype=float)
if q_g1.shape[0] != len(self._arm_joint_names_g1):
@@ -44,6 +44,8 @@ def get_gravity_orientation(quaternion: list[float] | np.ndarray) -> np.ndarray:
class G1_29_JointArmIndex(IntEnum):
"""Indices of the G1's arm and wrist joints within its 29-joint state vector."""
# Left arm
kLeftShoulderPitch = 15
kLeftShoulderRoll = 16
@@ -79,6 +81,8 @@ def make_locomotion_controller(name: str | None):
class G1_29_JointIndex(IntEnum):
"""Indices of all 29 G1 joints, in the order the robot reports and accepts them."""
# Left leg
kLeftHipPitch = 0
kLeftHipRoll = 1
@@ -83,6 +83,7 @@ class GrootLocomotionController:
control_dt = CONTROL_DT # Expose for unitree_g1.py
def __init__(self):
"""Load the GR00T locomotion policy and set up its observation history."""
# Load policies
self.policy_balance, self.policy_walk = load_groot_policies()
@@ -101,6 +101,7 @@ class HolosomaLocomotionController:
control_dt = CONTROL_DT # Expose for unitree_g1.py
def __init__(self):
"""Load the HoloSoma locomotion policy and set up its observation history."""
# Load policy and gains
self.policy, self.kp, self.kd = load_policy()
@@ -14,8 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""
DDS-to-ZMQ bridge server for Unitree G1 robot.
"""DDS-to-ZMQ bridge server for Unitree G1 robot.
This server runs on the robot and forwards:
- Robot state (LowState) from DDS to ZMQ (for remote clients)

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