Compare commits

..

42 Commits

Author SHA1 Message Date
Pepijn 18e81504ef navigation: live Rerun visualization of the map (+ dynamic carving)
MapVisualizer (lazy rerun-sdk, lerobot[viz]) streams the map as it builds:
voxel cloud (RGB or recency colormap), robot pose, top-down occupancy,
planned path, and the located target. The dynamic part is visible — each
keyframe re-logs the full snapshot so carved voxels vanish, and this
frame's removed voxels flash red under world/carved.

Wired into LiveMapper.tick (per-keyframe map/removed/robot) and
DogController (map/occupancy/robot/target/path after each action), behind
a --viz flag (+ --color-mode rgb|recency) on dog-nav; works in --dry-run,
--map-only, and --live. Headless tests (spawn=False) cover the log paths
and a full dry-run navigate-with-viz. 140 tests in tests/navigation/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 20:09:01 +02:00
Pepijn 32b044f54e navigation: add --map-only live mode + hardware bring-up guide
--map-only runs the live mapper (perceive → integrate) with ZERO
autonomous motion: teleop the dog to build the map, type an object name
to report where it is. The safe first-contact mode before enabling
autonomous nav.

BRINGUP.md documents the staged path: get the branch to the GPU host,
install SDK/models, verify DDS+sensors, teleop, map-only, then autonomous
— with the odometry/FOV items to check on first hardware contact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:43:59 +02:00
Pepijn 7d8c165e08 navigation: fix live frame consistency + calibratable carve focal
The live mapper was building the voxel map in LingBot-Map's own monocular
world frame while the robot pose came from odometry — two different
frames, so goto would drive to wrong coordinates. Fix: use the odometry
frame as the single world frame and project the model's camera-frame
geometry (local_points) through the odometry pose (new
pipeline.local_points_to_world). The model is now a depth source, not the
world-frame authority — matching the plan.

Also: carve focal length is derived from a --camera-hfov-deg knob instead
of a placeholder; conservative default speed caps (0.4 m/s / 0.8 rad/s)
for first bring-up.

New: tests/navigation/test_live_mapper.py exercises the real perceive→
project→integrate loop with a fake robot + FakeGeometryRunner, asserting
voxels land in (and track) the odometry world frame. 155 tests total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:41:45 +02:00
Pepijn 8affa5147c navigation: LingBot-Map geometry runner, keyframe pipeline, live CLI
- geometry.py: GeometryRunner protocol + GeometryOutput contract
  ({points, local_points, conf, camera_poses}); LingBotMapRunner (lazy
  streaming reconstruction, no window stitching); FakeGeometryRunner for
  tests; umeyama_similarity + align_trajectory_to_odometry to anchor the
  monocular scale to Go2 sport-mode odometry (metric map, real m/s A*).
- pipeline.py: viz-free integrate_keyframe (carve → add) + bilinear
  feature upsampling.
- dog_cli.py: LiveMapper (perceive → geometry → features → integrate one
  keyframe, feed the watchdog) + _build_live wiring the real Unitree Go2
  stack (DDS + LingBot-Map + SigLIP2) + `--live` REPL. Lazy throughout, so
  import/--help/dry-run stay model- and SDK-free.
- dog-nav console entry point; README updated.

14 new tests (130 in tests/navigation/), all model/hardware-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:34:33 +02:00
Pepijn e6616456fb navigation: port value maps, features, skills, agent + dog-nav CLI
Copied from the dyna360 research stack into lerobot.navigation:

- value_map.py: DynaMem §3.4 exploration scoring (V_T recency + V_S
  query-similarity, combined value with distance discount).
- features.py: SiglipFeatureExtractor (MaskCLIP dense patches, lazy
  transformers) + FeatureExtractor protocol + BasisVectorFeatureExtractor
  stand-in for model-free dry-run/tests.
- skills.py: SpatialSkills locate/goto/explore over the voxel memory +
  base controller + text encoder.
- agent.py: DeterministicAgent (locate→goto / explore→relocate policy) +
  HardcodedTaskParser (regex NL→Task).
- sim.py: self-contained synthetic scenes (kitchen) + basis-vector text
  encoder, replacing the dyna360 eval harness for dry-run.
- dog_cli.py: `dog-nav` interactive REPL — idle→explore, prompt→locate+
  goto (explore-to-find on miss), preemptible, Ctrl-C e-stop, --dry-run.

Silenced benign fp16 matmul warnings in query/similarity via np.errstate.
Pure numpy + optional lazy torch/transformers; 67 new tests (116 total in
tests/navigation/), all model/hardware-free. Deliverable runs:
`python -m lerobot.navigation.dog_cli --dry-run`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:27:07 +02:00
Pepijn 8793d1a4d5 navigation: port voxel map + occupancy/A* mapping backbone
Copied from the dyna360 research stack into lerobot.navigation:

- voxel_map.py: sparse-hash VoxelMap (5 cm default), count-weighted
  running-mean geometry + optional per-voxel semantic feature; carve()
  for DynaMem-style free-space removal (dynamic updates), query() for
  top-k cosine text matches, remove_voxels_in_box() for scene mutation.
  Added an inverse.reshape(-1) guard for numpy 2.x's np.unique.
- occupancy.py: derived 3-class top-down grid (UNOBSERVED/NAVIGABLE/
  OBSTACLE) projected from the voxel map, A* with no corner-cutting,
  obstacle inflation, and frontier extraction for exploration. Added
  OccupancyGrid.is_obstacle() used by SafeBaseController's occupancy gate.

Pure numpy, no torch/hardware/SDK. 42 new tests (71 total in
tests/navigation/). Next: LingBot-Map geometry runner + integration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 18:05:15 +02:00
Pepijn ff94e6385b Add Unitree Go2 robot + spatial-memory navigation base
unitree_go2 robot: high-level sport-mode body-velocity control over DDS
(unitree_sdk2py) straight from the host — no companion computer, unlike
the G1's low-level ZMQ bridge. Actions x.vel/y.vel/theta.vel; observation
is planar odometry (rt/sportmodestate) + the built-in front camera via
VideoClient. SDK imported only in connect() so configs/features/tests
work without it; 20 tests via mocks. unitree_go2 pyproject extra.

lerobot.navigation package: BaseController protocol + StubBaseController +
SafeBaseController (velocity clamp, occupancy gate, keyframe watchdog,
e-stop latch) + RobotBaseController wrapping any Robot on the standard
REP-103 mobile-base contract, carrying the world<->body velocity and
odometry<->world pose frame math. Robot-agnostic: the SDK lives only in
the robot class. 29 tests, SDK/torch-free.

First step of consolidating the dyna360 DynaMem navigation stack into
lerobot; dyna360 is a source to copy from, not a runtime dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 17:50:44 +02:00
Steven Palma ddc2aa7a27 refactor(pi0_fast): reuse shared VLA components (#4055) 2026-07-20 15:34:34 +02:00
Steven Palma 76b67d6ca8 refactor(eo1): reuse shared VLA components (#4061) 2026-07-20 15:34:16 +02:00
Steven Palma f3c0707c5f refactor(pi0): use shared VLA components (#4062) 2026-07-20 15:34:00 +02:00
Steven Palma 5361e0259e refactor(pi05): use shared VLA components (#4063) 2026-07-20 15:33:43 +02:00
Steven Palma a9879e69ed refactor(wall-x): subclass native Transformers Qwen2.5-VL instead of vendoring it (#4035) 2026-07-17 19:09:12 +02:00
Steven Palma 9d82bb9871 refactor(vla): extract shared model components (#4054) 2026-07-17 17:37:05 +02:00
Steven Palma c5371d0691 refactor(processors): share policy pipeline builders (#4016)
* refactor(processors): share policy pipeline builders

* Apply suggestions from code review

Co-authored-by: Martino Russi <77496684+nepyope@users.noreply.github.com>
Signed-off-by: Steven Palma <imstevenpmwork@ieee.org>

* fix(processor): solve style after commit suggestions

---------

Signed-off-by: Steven Palma <imstevenpmwork@ieee.org>
Co-authored-by: Martino Russi <77496684+nepyope@users.noreply.github.com>
2026-07-17 14:10:32 +02:00
Steven Palma b2c062c0f4 refactor(policies): resolve policy components by convention (#4015)
* refactor(policies): resolve policy components by convention

* remove fron None no-op

* extend processor resolver error handling logic to policy class resolver as well

---------

Co-authored-by: Martino Russi <nopyeps@gmail.com>
2026-07-17 13:59:38 +02:00
Maxime Ellerbach 051b13573e fix(safetensors): expand bare "cuda" to current device for safetensors loads (#4042) 2026-07-17 10:44:20 +02:00
Pepijn 7de2e4c1ef Move annotation dependencies to module scope (#4040) 2026-07-16 18:35:32 +02:00
Nikodem Bartnik 8db50611c2 pin pip installs (#4041) 2026-07-16 16:55:13 +02:00
Maxime Ellerbach 92f96f33b3 Aggregate policy sub-losses through MetricsTracker (#4024) 2026-07-16 12:12:37 +02:00
Steven Palma d4b3ca569c refactor(hub): load safetensors directly on target device (#4012) 2026-07-16 10:49:59 +02:00
Steven Palma 3f2179f3b6 refactor(evo1): use transformers flash attention probe (#4013)
Co-authored-by: Martino Russi <77496684+nepyope@users.noreply.github.com>
2026-07-15 17:02:01 +02:00
Nikodem Bartnik 867b58cfb2 generate new readme (#4029)
Co-authored-by: Pepijn <138571049+pkooij@users.noreply.github.com>
2026-07-15 16:32:02 +02:00
Pepijn 279c6c7af3 feat(annotate): improve VLM subtask annotation (legible contact sheets, seeded relabeling, self-hosted vLLM recipe) (#3896)
* feat(annotate): WGO-tuned subtask prompt (atomic completed-events + duration prior)

Rework the plan-module subtask segmentation prompt toward the WGO-Bench
atomic annotation protocol: segment by completed world-state changes
(grasp/place/open/close/pour/insert), fold approach+retreat into their
event, keep separate events separate, and add a 2-10s duration prior.
Drops the pi0.7 "fewer larger composites preferred" bias that drove
under-segmentation on the benchmark. Output JSON shape unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(annotate): seeded-relabeling second pass for subtasks

Add an opt-in relabel pass (plan.subtask_seeded_relabel) that, after
segmentation, re-labels each span using previous/current/next segment
contact sheets and the seed label as a strong prior, minimally correcting
it. Mirrors macrodata's best end-to-end labeling step. Boundaries are
untouched; one extra VLM call per span. Off by default.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(annotate): robust OpenAI-compat client for hosted VLMs

Guard against a choice with no message (safety filter or a thinking model
that spends its whole budget before emitting content) so one empty reply
no longer crashes the whole annotation run; treat it as an empty response
and let the existing JSON-retry path handle it.

Add an optional `reasoning_effort` knob on VlmConfig, forwarded to the
server when set, to cap a thinking model's reasoning (needed for Gemini
via its OpenAI-compatible endpoint).

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(annotate): legible tile-scaled timestamp on contact sheets

The burned-in timestamp used the ~10px bitmap default font, which blurs
once the model downsamples a full contact sheet into 768px tiles, so the
VLM can no longer read the exact source time a boundary depends on. Scale
the timestamp to the tile height (with a graceful fallback on older
Pillow) so the visual time cue stays readable at sheet resolution.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(annotate): lean GEPA-aligned subtask segmentation prompt

Replace the verbose, label-heavy segmentation prompt with a lean
adaptation of the blog's GEPA-found completed_events_duration_prior
recipe: focus on completed manipulation events, explicit no-split /
no-merge rules, a 2-10s duration prior, and an instruction to prioritize
temporally correct boundaries over label wording. The previous prompt
over-weighted label guidance, which traded away boundary precision.

Co-authored-by: Cursor <cursoragent@cursor.com>

* revert: restore original subtask segmentation prompt

The lean GEPA-aligned paraphrase (dd4b0110d) regressed Gemini on the
30-ep subset: Seg F1 0.259 -> 0.189 and E2E 0.184 -> 0.135, driven by
worse under-segmentation (224 -> 188 preds). The blog's 0.306 came from
the actual GEPA-search artifact, which a hand paraphrase does not
reproduce. Restore the original prompt, which remains our best config.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(annotate): env-var override for prompt templates

Allow LEROBOT_PROMPT_OVERRIDE_<name> to supersede the packaged prompt
file at load time. Enables prompt search (GEPA) to inject candidate
segmentation prompts into a remote annotate job via an env secret,
without committing a branch per candidate.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(annotate): genericize hosted-VLM comments (no model name)

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(annotate): document seeded-relabel and reasoning_effort flags

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(annotate): update subtask-prompt marker to match WGO-tuned prompt

The three plan-module tests keyed the canned VLM responder on the
literal 'atomic subtasks', which the WGO-tuned segmentation prompt no
longer contains (it now segments 'COMPLETED manipulation events'). Point
the fixture markers at the current wording so the subtask call is matched
again.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:38:49 +02:00
Lior Ben Horin e40b58a8df Update GR00T 1.7 LIBERO checkpoints (#3961)
Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-08 13:25:54 +02:00
Mishig 3e538352ca Make doc builds faster (#3958)
* Update doc build workflow: light installs, drop custom container

* Keep the pin comment dependabot-compatible
2026-07-08 07:31:10 +02:00
Steven Palma 8a74e0ac6d chore(dependencies): Bump lerobot to 0.6.1 (#3957) 2026-07-06 12:52:39 +02:00
Steven Palma 30da8e687a chore(dependencies): Bump lerobot to 0.6.0 (#3956) 2026-07-06 12:06:51 +02:00
Steven Palma 93257e3468 chore(dependencies): update uv.lock (#3928) 2026-07-06 11:21:38 +02:00
Caroline Pascal b895ed0fe4 docs(prettier): making video encoding parameters docs prettier (#3911)
* docs(prettier): making video encoding parameters docs prettier

* chore(format): formatting code

* chore(contrast): removing poor contrast elements
2026-07-05 23:39:05 +02:00
Caroline Pascal 293a8d9a77 feat(examples): add Isaac Teleop → SO-101 teleoperation and dataset recording example (#3927)
* Add Isaac Teleop SO-101 leader-arm teleoperator

Add the NVIDIA Isaac Teleop teleoperator scaffolding and its first device:
SO101LeaderArm, a back-drivable SO-101 leader arm on Isaac Teleop's generic
joint-space device path. It reads the leader's joints from the so101_leader
plugin via JointStateSource and emits follower-ready {joint}.pos (rad2deg arm,
gripper -> RANGE_0_100) for direct 1:1 joint drive.

- IsaacTeleopTeleoperator base + IsaacTeleopConfig (shared session/CloudXR config)
- SO101LeaderArm / SO101LeaderArmConfig and leader_joints_to_robot_action
- examples/isaac_teleop_to_so101/teleoperate_leader.py example
- pure-numpy conversion tests
- isaac-teleop optional extra + NVIDIA PyPI index in pyproject

* Add Isaac Teleop XR controller teleoperator for SO-101

Add end-to-end XR (VR) controller teleoperation of an SO-101 follower arm via
the NVIDIA Isaac Teleop stack, layered on the Isaac Teleop scaffolding.

Teleoperator (src/lerobot/teleoperators/isaac_teleop/):
- XRController / XRControllerConfig: connect to the CloudXR runtime, auto-launch
  the Isaac Teleop session, and expose get_action() emitting the raw base-frame
  grip pose, squeeze, and trigger.
- MapXRControllerActionToRobotAction: stateless per-frame mapper from the XR
  action to the IK input contract (absolute ee.x/y/z, ee.gripper_pos, wrist_roll).
- OverwriteWristRollFromAngle: post-IK step writing the operator wrist-roll [rad]
  onto wrist_roll.pos [deg], recovering the under-determined roll DOF.

Example (examples/isaac_teleop_to_so101/):
- teleoperate.py: thin absolute-pose IK pipeline with an in-loop clutch (engage
  latch + 1:1 delta rebase of position and orientation), EEBoundsAndSafety, and
  InverseKinematicsEEToJoints; slews to a recorded home on startup.
- record_reset_pose.py / download_assets.py / webxr.env / .gitignore.

Also:
- Extend robot_kinematic_processor.py with EEBoundsAndSafety and
  InverseKinematicsEEToJoints.
- Add XRControllerConfig + base_T_anchor to the Isaac Teleop config.
- Add docs/source/isaac_teleop.mdx and the _toctree entry.
- Add unit tests for the CloudXR launcher and the XR controller processor.

* Unify Isaac Teleop SO-101 scripts behind a mandatory device selector

Merge teleoperate.py (XR controller: clutch + soft-orientation IK) and
teleoperate_leader.py (SO-101 leader arm: 1:1 joint mirror) into a single
teleoperate.py driven by a `lerobot-teleoperate`-style draccus CLI: a follower
`--robot.*` and an input `--teleop.*`, where `--teleop.type` (xr_controller |
so101_leader) selects the Isaac device.

Uses a "dispatch, don't merge" shape: per-device setup_xr/setup_leader build a
Device bundle (compute / startup / cleanup / command); a shared slew() takes a
per-step target callable (XR a fixed reset pose, leader a live re-read so the
1:1 handoff stays continuous); one device-branchless outer loop runs both, with
compute() -> None meaning "hold at the measured pose" (XR disengaged or leader
stale). The entrypoint is @parser.wrap()'d over a TeleoperateConfig dataclass and
dispatches on the parsed config type; device knobs ride on --teleop.* (the leader
serial port is --teleop.port, forwarded to the plugin) and loop/launch knobs are
top-level (--launch_plugin=<path> collapses the old --launch-plugin/--plugin-bin
pair; --reset_to_origin/--align/--dry_run).

To let the Isaac devices claim the natural --teleop.type names without colliding
with the serial so101_leader of lerobot-teleoperate, give IsaacTeleopConfig its
own draccus choice registry (own _choice_registry, decoupled from the global
TeleoperatorConfig one) and register XRControllerConfig as "xr_controller" and
SO101LeaderArmConfig as "so101_leader" there; the example types its teleop field
as IsaacTeleopConfig so the choices resolve against that scoped registry. These
devices drive the bespoke clutch/IK/align loop and are not routed through
make_teleoperator_from_config, so dropping them from the global registry is inert.

YAGNI sweep of the commit train: delete the orphaned OverwriteWristRollFromAngle
(wrist_roll_processor.py) plus its export and tests -- no producer emits
wrist_roll; the live XR path uses orientation-weight IK on the 5-DOF arm by
design. Kept the load-bearing knobs (orientation_weight, raise_on_jump,
base_T_anchor) and the optional reset-pose recorder. Updated isaac_teleop.mdx
for the unified entrypoint and excised the stale roll-retargeter prose.

Net LOC down (two scripts 714 lines -> one), in-loop device branches reduced to
zero. Planned and reviewed via a 6-persona multi-agent panel (3-round planning
convergence + 2-round review). Verification (isaacteleop/placo not installable
here, so the device classes cannot connect, but their config dataclasses and the
script import fine via deferred imports): the teleoperators test suite passes
(45 passed, 2 skipped), draccus parsing of both target command lines yields the
right config subclass with scoped --teleop.type, --help renders the scoped
choices, the serial so101_leader stays in the global registry, and ruff
check/format are green.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>

* Add Isaac Teleop SO-101 dataset recording script

record.py records a LeRobot dataset while driving the SO-101 follower
with either Isaac Teleop device (--teleop.type=xr_controller |
so101_leader), mirroring teleoperate.py's device dispatch.

* Extract shared Isaac Teleop SO-101 example infra into common.py

teleoperate.py and record.py both built the per-device pipeline and ran the
same read -> compute -> hold-when-idle -> sleep loop, with record.py importing
internals from teleoperate.py. Move the shared device/loop infrastructure
(Device, slew, Clutch, setup_xr/setup_leader + leader helpers, reset infra and
constants) into a new common.py, and add build_device() + hold_action() to
collapse the connect/dispatch/startup and idle-hold glue duplicated in both
entry points. The setup functions now type their config against a LoopConfig
Protocol, so common.py is decoupled from either CLI; both import from it.

Also rename record_reset_pose.py -> override_reset_pose.py so it is not confused
with record.py, and update the doc references.

* Add stdin keyboard backend so recording shortcuts work over SSH/headless

lerobot's init_keyboard_listener() uses pynput, which hooks GLOBAL key events
from the display server. Over SSH, under Wayland, or on a headless box with only a
TTY, keystrokes go to the terminal's stdin instead, so the listener never fires and
the Right/Left/Esc recording shortcuts silently do nothing.

Add a stdin (termios) keyboard backend to the example's common.py and an
init_keyboard_listener() that prefers it whenever stdin is an interactive TTY
(works over SSH / Wayland / headless-with-tty), falling back to lerobot's
pynput/headless listener for GUI launches with no controlling terminal. Selectable
via LEROBOT_KEYBOARD_BACKEND={auto,stdin,pynput,none}. The backend keeps ISIG so
Ctrl-C still works and always restores the terminal (on stop() and via atexit).
record.py now sources init_keyboard_listener from common; the Right/Left/Esc -> flag
mapping and the (listener, events) contract are unchanged.

Also convert record.py's loop_kwargs to a dict literal (ruff C408).

* Wait for the XR headset to connect before driving the arm

On the xr_controller path the example connected CloudXR and immediately ran the
reset slew + control loop, even if no headset was connected — the arm moved before
the operator was in VR, and get_action() just returned zeros so the clutch never
engaged.

Add an is_tracking property to XRController (set from the controller stream's
optional group, mirroring SO101LeaderArm) and a _wait_for_xr_controller() helper in
common.py that prints connection instructions (CloudXR web client URL + this
workstation's candidate IPv4s, with loopback/link-local and virtual/bridge/USB-gadget
interfaces filtered out) and polls until the controllers stream (indefinite, 15s
reminder, Ctrl-C to abort). setup_xr.startup() now connects, waits for the headset,
THEN runs the reset slew and seeds the clutch — so the arm only moves once the
operator is connected and watching. Mirrors the leader path's _wait_for_leader; both
record.py and teleoperate.py inherit it via the shared setup_xr.

* Address review feedback on the Isaac Teleop -> SO-101 example

Review-response and CI fixes for the Isaac Teleop -> SO-101 example.

- Move the XR Clutch into src/lerobot/teleoperators/isaac_teleop/clutch.py
  (pure numpy + Rotation, no isaacteleop import), export it, and add
  tests/teleoperators/test_clutch.py.
- Drop the vendored stdin keyboard listener; record.py uses a small terminal-
  first wrapper over upstream's TerminalKeyListener (works over SSH even with a
  local X display), falling back to upstream init_keyboard_listener otherwise.
- record.py: pass rgb_encoder/depth_encoder to LeRobotDataset create()/resume()
  (upstream removed camera_encoder), fixing the AttributeError at record time.
- build_device: derive motor names from robot.action_features instead of
  robot.bus (supports non-bus robots), and disconnect the follower if any step
  after connect() fails so a failed setup never leaks the connection.
- Read leader joints by the group's declared names (_joints_group_to_rad)
  instead of positionally, so a layout mismatch can't silently mirror the wrong
  DOF onto the follower; add tests including a reversed-layout group.
- base.py: hoist `from pathlib import Path` to module scope; only the
  isaacteleop CloudXRLauncher import stays lazy (optional dep).
- Trim the common.py module docstring and point to docs/source/isaac_teleop.mdx.
- default.env: correct the NV_DEVICE_PROFILE comment (auto-webrtc is the default;
  this file overrides to Quest3, which works for both Quest 3 and Pico 4).
- download_assets.py: correct the RAW_BASE comment (tracks main, not pinned) and
  add `# nosec B310` next to the existing `# noqa: S310` for the bandit hook.
- uv.lock: add the isaac-teleop extra's deps so `uv sync --locked` matches
  pyproject; regenerated with uv 0.8.0 to keep lockfile revision 2 (CI's uv).
- isaac_teleop.mdx: prettier formatting.

* fix(.gitignore): removing .gitignore and using lerobot cache folder instead to store local user files

* chore(docstrings): reducing docstrings in default.env

* feat(URDF): cleaning up and simplifying the URDF download procedure

* feat(robot guard): adding a guard in case an unsupported robot type is provided (so-arms only)

* fix(imports): enforcing a python module structure to simplify imports

* feat(safe read): extending the motor bus safe read rationale to reset pose setting

* chore(trim): trimming lenghty comments and docstrings

* fix(deps): use isaacteleop [retargeters-lite] extra to unblock aarch64 (DGX Spark) (#3933)

* fix(deps): drop isaacteleop [retargeters] extra to unblock aarch64

The [retargeters] extra pulls dex-retargeting (pins numpy<2.0, conflicting
with lerobot's numpy>=2.0) and nlopt>=2.8 (no aarch64 wheels), making
lerobot[isaac-teleop] unresolvable on ARM (DGX Spark, Jetson Thor, GH200)
and over-constrained on numpy everywhere else.

The LeRobot teleoperators only import isaacteleop.retargeting_engine,
isaacteleop.cloudxr and isaacteleop.teleop_session_manager, all shipped in
the base wheel (requires only numpy>=1.23), so the extra is unused.

Verified on DGX Spark (aarch64, Python 3.12): resolves and installs with
isaacteleop 1.3.131 + numpy 2.2.6; all imported symbols load.

* fix(deps): use isaacteleop [retargeters-lite] extra for aarch64 support

Pin to isaacteleop ~=1.3.131 (the release that added ARM64/aarch64 support)
and swap the full [retargeters] extra for the new [retargeters-lite] one
(scipy-only). The full extra drags in dex-retargeting (pins numpy<2,
conflicting with lerobot's numpy>=2.0) and nlopt>=2.8 (no aarch64 wheels),
making lerobot[isaac-teleop] unresolvable on ARM hosts (DGX Spark, Jetson
Thor, GH200) and over-constrained on numpy everywhere else.

The LeRobot teleoperators only import isaacteleop.retargeting_engine,
isaacteleop.cloudxr and isaacteleop.teleop_session_manager — all covered
by the base wheel + retargeters-lite.

Verified on DGX Spark (aarch64, Python 3.12/3.13): resolves and installs
with isaacteleop 1.3.131 + numpy 2.2.6 + scipy 1.18.

* feat(deps): re-add full [retargeters] extra gated to x86_64

Keep the dex-retargeting/nlopt-based retargeters available on x86_64 (where
their wheels exist) via an environment marker, while ARM hosts (DGX Spark,
Jetson Thor, GH200) resolve with base + [retargeters-lite] only.

Verified: uv lock resolves on both platforms; on aarch64 the compile
excludes nlopt/dex-retargeting, on x86_64 they are included.

---------

Co-authored-by: Johnny Nunez <22727137+johnnynunez@users.noreply.github.com>

* chore(docstrings): trimming latest docstrings

* chore(teleop): move isaac-teleop to examples + update docs + add readme with installation notes

* chore(deps): restore uv.lock

* fix(example: isaac teleop parsing config

* fix(examples): isaac atomic-gripper controller

* feat(Examples): isaac-teleop holdlatch

* chore(examples): some other minor improvements for isaac-teleop

* chore(examples): top-level imports isaac-teleop

* chore(Examples): address ai review isaac-teleop

---------

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
Co-authored-by: Jiwen Cai <jiwenc@nvidia.com>
Co-authored-by: Johnny <johnnync13@gmail.com>
Co-authored-by: Johnny Nunez <22727137+johnnynunez@users.noreply.github.com>
Co-authored-by: Steven Palma <steven.palma@huggingface.co>
Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-05 20:56:26 +02:00
Steven Palma 7957d4e2dc chore(docs): update readme + gr00t libero results (#3941)
* chore(docs): update readme + gr00t libero results

* chore(docs): update template and in-tree policy steps
2026-07-05 15:11:46 +02:00
Steven Palma 192a0b9282 chore(dependencies): update uv.lock (#3816) 2026-07-04 10:18:01 +02:00
Steven Palma 0530dd9b97 chore(infra): remove requirements files (#3925) 2026-07-03 22:42:50 +02:00
Steven Palma 698d2a0e77 feat(policies): add EVO1 policy (#3908)
* feat(policies): add EVO1 policy

* fix(evo1): infer batch size after normalizing image dims

`_collect_image_batches` read `batch_size = batch[camera_keys[0]].shape[0]`
before normalizing per-camera tensors to `(B, C, H, W)`. For an unbatched
`(C, H, W)` input (which the function tries to support via the `image.dim() == 3`
branch), this picked up the channel count `C` instead of the real batch size,
making the subsequent per-sample loop iterate `C` times and indexing go
out of bounds.

Normalize each camera tensor up-front, then read `batch_size` from the
normalized batch dim. Adds `test_collect_image_batches_handles_unbatched_chw`
covering the regression.

Reported by Copilot review on huggingface/lerobot#3545.

* chore(lock): regenerate uv.lock for evo1 extra

Adds the `evo1` entry to `[package.metadata.requires-dist]` and the
`provides-extras` list so that `uv sync --locked --extra test` (used by
fast_tests.yml) no longer reports the lockfile as stale.

Generated with `uv 0.8.0` (matching `UV_VERSION` in fast_tests.yml).
The non-evo1 marker tweaks are produced by `uv lock` re-resolving the
existing dep graph and are not introduced by this PR.

* chore(evo1): align with policy contribution guide conventions

- Add `src/lerobot/policies/evo1/README.md` symlink into `docs/source/evo1.mdx`
  to match the in-tree README convention (mirroring the EO-1 layout).
- Convert `transformers` import in `internvl3_embedder.py` to the standard
  `TYPE_CHECKING + _transformers_available` two-step gating used by other
  optional-backbone policies (e.g. diffusion). The previous lazy-in-`__init__`
  import was functionally equivalent for runtime gating but didn't expose the
  real symbols to type checkers.
- Add `lerobot[evo1]` to the `all` extra in `pyproject.toml` so
  `pip install 'lerobot[all]'` keeps installing every optional policy.

Per the guidance in https://moon-ci-docs.huggingface.co/docs/lerobot/pr_3534/en/contributing_a_policy.

* fix(evo1): finalize policy guide alignment

* docs(evo1): format results table

* Fix EVO1 LIBERO rollout processors

* Fix EVO1 LIBERO eval action postprocessing

* Fix eval action conversion for bf16 policies

* fix(evo1): move LIBERO padding into policy processors

* refactor(evo1): use native HF InternVL3-1B-hf, drop trust_remote_code

- Switch from OpenGVLab/InternVL3-1B (requires trust_remote_code=True)
  to OpenGVLab/InternVL3-1B-hf (native transformers implementation).
- Replace manual _extract_feature + _prepare_and_fuse_embeddings with
  a single model.forward() call — verified bit-for-bit identical output.
- Remove ~170 lines of manual ViT/pixel-shuffle/projection logic.
- Symlink README.md to docs/source/ following repo convention.

Weights are byte-identical between both model variants; only the module
naming differs. All 12 existing unit tests pass. Local training (10 steps)
on maximellerbach/omx_pickandplace confirmed working.

* refactor(policy): evo1 GPU-batched preprocessing +  vectorized attention masking + remove dead code

* fix(style): pre-commit

oops

* chore(evo1): delete added test + reduce diff

* refactor(policies): use config for evo1 + local imports

* refactor(policies): multiple improvements

* chore: update docs + remove legacy codepaths

* feat(policies): implement RTC to EVO1

---------

Co-authored-by: javadcc_mac <javadcc1@sjtu.edu.cn>
Co-authored-by: Yiming Wang <145452074+JAVAdcc@users.noreply.github.com>
Co-authored-by: Martino Russi <nopyeps@gmail.com>
2026-07-03 22:17:15 +02:00
Steven Palma 708fa1d189 feat(policies): add Gr00t N1.7 policy (#3922)
* Add GR00T N1.7 support

Add GR00T N1.7 policy configuration, checkpoint compatibility, processor parity, LIBERO documentation, and focused tests.

Co-authored-by: Ryan Halabi <ryhalabi@nvidia.com>

* Move Groot processor compatibility into Groot loader

* Restore GR00T Flash Attention install guidance

* Allow Groot fake RTC chunk prefetch

* Fix GR00T N1.7 RTC action decoding

* Trim GR00T N1.7 RTC chunks to valid horizon

* Ignore padded GR00T N1.7 RTC prefix rows

* removed n1.5 dependency

* removed remaining N1.5 traces

* groot: auto-enable LIBERO gripper action transform for libero_sim

GR00T N1.7 emits gripper in [0,1] but LIBERO expects [-1,1]. The decode
transform existed but was never auto-enabled for embodiment_tag=libero_sim,
so the policy scored 0% on LIBERO eval. Auto-set it in __post_init__ (still
overridable). LIBERO Spatial eval: 0% -> 98%.

* Reconnect GR00T relative action processors

* groot: remove dead N1.5 code (eagle2_hg_model, flow_matching_action_head, action_encoder)

N1.7 backbone is nvidia/Cosmos-Reason2-2B via Qwen3VLForConditionalGeneration,
not Eagle2 — eagle2_hg_model/ had zero refs outside its own dir.

GR00TN17ActionHead (groot_n1_7.py) re-implements MultiEmbodimentActionEncoder +
CategorySpecificLinear + swish + SinusoidalPositionalEncoding locally, so
flow_matching_action_head.py (N1.5 FlowmatchingActionHead) and its sole
dependency action_encoder.py are dead. Verified: no src/ or tests/ reference.

Removed (~2037 LOC):
- eagle2_hg_model/ (4 files, ~1575 LOC)
- action_head/flow_matching_action_head.py (408 LOC)
- action_head/action_encoder.py (54 LOC)

cross_attention_dit.py KEPT (DiT/AlternateVLDiT/SelfAttentionTransformer live in N1.7).

* groot: reuse lerobot get_device_from_parameters instead of inline lookup

modeling_groot.py duplicated next(self.parameters()).device twice. LeRobot
ships get_device_from_parameters in policies/utils.py (used by diffusion,
vqbet, tdmpc, gaussian_actor). Reuse it for consistency with the framework.

* groot: fix stale Eagle VLM docstring in processor (N1.7 uses Qwen3-VL backbone)

Addresses checker nit: processor_groot.py docstring still described the N1.5
Eagle VLM path with eagle_content/eagle_* keys that no longer exist in the code.

* test(groot): add N1.7 original-vs-LeRobot output parity test

Verifies the LeRobot GR00T N1.7 integration produces equivalent raw
action_pred to NVIDIA Isaac-GR00T for the same checkpoint, inputs, seed,
precision (fp32) and attention kernel (SDPA): max|diff|=8.9e-7 on the
libero_sim embodiment (GR00T-N1.7-LIBERO/libero_10).

The two impls pin incompatible transformers majors (orig 4.57.3 vs
LeRobot 5.x) and cannot share a process, so the original outputs + exact
collated inputs are produced out-of-process and loaded from an .npz. The
test skips on CI / when the checkpoint or artifact are absent.

* test(groot): parametrize N1.7 parity across all checkpoint embodiments

Generalize the original-vs-LeRobot N1.7 output-parity test from a single
libero_sim case to every embodiment tag in the checkpoint (libero_sim, oxe_droid,
real_g1, the real_r1_pro_sharpa family, and the xdof family). Inputs are built
generically from checkpoint metadata; the test discovers per-tag .npz artifacts
and runs one parametrized case each, loading the LeRobot model once via a fixture.

All 9 embodiments match the original to fp32 epsilon (max|diff| < 3e-6), confirming
the integration is correct across the model's full embodiment space and not overfit
to libero_sim.

* test(groot): self-contained parity test + in-repo producer + docs

- Rename test_groot_n1_7_vs_original.py -> test_groot_vs_original.py
- Make the test self-contained: producer script (dump_original_n1_7.py) now lives
  next to the test; default artifact dir is repo-relative
  (tests/policies/groot/artifacts/), overridable via GROOT_N1_7_PARITY_DIR. The
  test only reads artifacts and skips if absent -- it never creates external dirs.
- Heavy .npz artifacts (~6-9MB each) are gitignored and regenerated by the producer;
  never committed.
- Drop the verbose 'MULTIPLE EMBODIMENTS' docstring block (kept a one-line note).
- Document the parity procedure in the groot policy README (docs/source/policy_groot_README.md).
- Rename test fn test_groot_n1_7_get_action_parity -> test_groot_get_action_parity.

9/9 embodiments still pass (max|diff| < 3e-6, fp32 eps).

* docs(groot): drop WHY TWO ENVIRONMENTS block from parity test docstring

* test(groot): move parity producer into utils/ package

Mirror the tests/policies/pi0_pi05/utils convention: move dump_original_n1_7.py into
a tests/policies/groot/utils/ package (with __init__.py) and update all path
references in the test docstring/skip-message and the policy README.

* test(groot): adopt test_groot_lerobot for GR00T N1.7, drop N1.5

The test loaded MODEL_PATH='aractingi/bimanual-handover-groot-10k', an N1.5
checkpoint (config base_model_path=nvidia/GR00T-N1.5-3B, no model_version). On
load, model_version defaults to n1.7 while the base path infers n1.5, so the
version-consistency guard in GrootConfig.__post_init__ raised ValueError and both
test_lerobot_groot_inference and test_lerobot_groot_forward_pass failed. N1.5 is no
longer a supported model_version.

Adopt the test for N1.7:
- MODEL_PATH -> nvidia/GR00T-N1.7-3B (root-level sharded safetensors; loads via
  GrootPolicy.from_pretrained as a base N1.7 model).
- Embodiment tag 'gr1' (N1.5) -> 'gr1_unified' (valid N1.7 tag from the checkpoint
  embodiment_id.json), via a single EMBODIMENT_TAG constant.
- DUMMY_ACTION_HORIZON 16 -> 40 to match N1.7's native action-chunk size.
- Docstrings/labels updated to 'GR00T N1.7'.

Both tests run and pass on CUDA; full tests/policies/groot/ suite is
73 passed / 0 failed / 0 skipped.

* docs(groot): document the N1.5 removal and the N1.7 parity test

- groot.mdx: breaking-change warning and migration path (pin lerobot==0.5.1 to
  keep N1.5, or move to N1.7); the dead `huggingface-cli download` is replaced
  with `hf download`.
- policy_groot_README.md: N1.5 removal note, updated paper / model-card links,
  and the two-comparison (model parity + preprocessor parity) description of
  the original-vs-LeRobot test, including the raw-observation artifacts and
  recorded seed.

* fix(groot): N1.7 backbone loading and DiT parameter-count logging

- select_layer default tracks the N1.7-3B checkpoint value (16); real
  checkpoint loads still override it from config.json.
- get_backbone_cls recognizes Cosmos-Reason2 / Qwen3-VL backbones by name and
  warns (instead of silently assuming) when an unrecognized backbone is loaded
  only on the strength of backbone_model_type='qwen'.
- 'revision' pins the GR00T checkpoint repo only and is no longer forwarded
  into the unrelated backbone repo load; pin the backbone via
  transformers_loading_kwargs instead.
- DiT / SelfAttentionTransformer parameter counts go through logging.debug
  instead of print().

* fix(groot): N1.7 config defaults, N1.5 rejection, and processor/model runtime fixes

Covers the GR00T N1.7 source trio (configuration, processor, model wrapper).

Config:
- GrootConfig defaults are the N1.7 values; explicitly passed legacy N1.5-era
  values (chunk_size=50, max_state_dim=64, ...) are remapped with a warning
  instead of silently.
- action_decode_transform gains an 'auto' sentinel so an explicit 'none'
  opt-out wins over the libero_sim default and survives save/load round-trips.
- action_delta_indices is cached on the inputs that determine it.
- Legacy N1.5 checkpoints/configs (tokenizer_assets_repo, model_type/
  architectures/eagle backbone markers) are rejected with a single clear
  error pointing to lerobot==0.5.1.

Processor:
- GrootN17ActionDecodeStep handles the 2-D (B, D) actions delivered by sync
  select_action (relative eef/non-eef decode in eval/record flows).
- Postprocessor falls back to dataset stats when a raw checkpoint lacks the
  configured embodiment tag; raw-state cache is per-instance, not
  process-global; caller overrides (device, rename_map) are honored on the
  raw-checkpoint branch.
- Camera/modality-key mismatches warn (including the zero-match fallback);
  deprecated Qwen2VLImageProcessorFast replaced with Qwen2VLImageProcessor;
  removed N1.5 processor steps are stubbed to raise the removal guidance and
  the action-unpack step is re-registered as _v2.

Model:
- Flash-attention probe is diagnostic-only; forward raises on a missing loss;
  print() replaced with logging; N1.5 base-path mismatch includes the
  removal guidance.

* fix(groot): skip normalization overrides for training

* fix(groot): GPU/tensor N1.7 image preprocessing + resize to trained resolution

GR00T training was dataloader-bound (0->100->0 GPU-utilization sawtooth).
GrootN17VLMEncodeStep ran the Qwen3-VL image processor per frame on PIL images
on the single CPU main-loop thread, and that cost is timed inside dataloading_s
(preprocessor(batch) runs in the main process, not the dataloader workers), so
adding workers cannot hide it.

- Feed the torchvision-backed Qwen3-VL processor (C,H,W) uint8 tensors instead
  of a per-frame Image.fromarray PIL roundtrip, and run resize/normalize/patchify
  on config.device (GPU) when available. Bit-identical on CPU when no resize is
  configured; with a resize only the PIL->torchvision bicubic backend differs
  (<2/255 per pixel). The use_albumentations path stays PIL/cv2; reload on a box
  without the saved device falls back to CPU.

- Default image_target_size/crop to the N1.7 backbone's training geometry
  (256x256 / 230x230) when a checkpoint ships no image sizing (checkpoint_assets
  is None, e.g. finetuning nvidia/GR00T-N1.7-3B via repo-id with a new
  embodiment). Previously image_target_size=None disabled the resize, so
  full-resolution frames were patchified into ~4.7x more vision tokens than the
  model was trained on -- inflating dataloading_s (patchify) and update_s (VLM
  sequence) and skewing the input distribution. Checkpoints that pin their own
  sizing are honored; the default constants are shared with GR00T_N1_7_DEFAULTS.

Net: preprocessing leaves the CPU critical path and the VLM sees the resolution
it was trained on -- faster training/inference and a correct train/serve
distribution. Affects inference too (shared preprocessor); existing checkpoints
still load (backward compatible) but must be retrained to gain the benefits.

* refactor(groot): N1.7 style cleanup (utils, imports, flash-attn, config)

Mechanical refactor of the GR00T N1.7 policy to match the repo's architecture and
style standards. No change to policy algorithm/numerics; only UX/CLI and packaging
changes. Tests are intentionally left untouched (out of scope) and need updating
for the removed `model_version` field.

Cleanup & consolidation:
- Add `groot/utils.py` holding the pure, side-effect-free helpers (JSON I/O, value
  coercion, stat flattening, rot6d/SE3 math, language/batch prep) shared by the
  config and processor layers.
- Remove dead code: the unused `resolve_groot_n1_7_backbone_model` cache-resolver
  cluster, `GR00TN17Config.to_filtered_dict/json`, and the `_copy_default` wrapper.

Imports & execution guards:
- Hoist nested imports to module top; relative imports within the package, absolute
  for external modules. The version-gated Qwen3-VL classes import under the single
  `_transformers_available` guard (transformers is pinned >=5.4, which ships them).
- No import-time side effects: `_register_with_transformers()` now runs in
  `GR00TN17.__init__` (idempotent via `register(exist_ok=True)`), and the N1.5 step
  stubs register lazily before pipeline deserialization (idempotent via the
  registry, no run-once globals).
- Gate optional deps at the point of use with `require_package(..., extra="groot")`.

Dependencies & docs:
- Drop `flash-attn` (and its build-only dep `ninja`) from the `groot` extra; default
  to SDPA (numerically equivalent) with opt-in via `--policy.use_flash_attention`.
  Un-comment `lerobot[groot]` in the `all` extra and regenerate `uv.lock`.
- Rewrite the `groot.mdx` install section: flash-attn is a purely optional,
  user-managed optimization that LeRobot neither installs nor requires.

Config & CLI:
- Surface previously-frozen knobs on `GrootConfig` (plumbed into `GR00TN17Config`;
  no-ops at their defaults): inference — `num_inference_timesteps`, `rtc_ramp_rate`,
  `use_flash_attention`; fine-tuning — `tune_top_llm_layers` (partial-LLM tuning)
  and `tune_vlln` (previously hardwired to True).
- Convert the single-valued `model_version` and `n1_7_backbone_model` fields to
  internal constants.
- Keep `base_model_path`: it is NOT equivalent to `pretrained_path` (raw NVIDIA
  checkpoints have no LeRobot `type` field and load only via `base_model_path`) and
  is genuinely user-tunable.
- Keep the deprecated Isaac-GR00T/N1.5 fields (and the dead LoRA fields) as a
  back-compat block so a v0.5.1 N1.5 `config.json` still parses under draccus and is
  rejected with the friendly N1.5 removal message instead of an opaque decode error.

* Optimize GR00T N1.7 image preprocessing

* Remove PIL fallback from GR00T preprocessing

* Fix GROOT relative action training stats

* Address GROOT relative action review feedback

* Fix GROOT N1.7 relative action stats

* Fix GROOT relative action training stats

* Fix GROOT relative action padding and RTC leftovers

* Reset rollout state after robot episode end

* Revert "Reset rollout state after robot episode end"

This reverts commit 1322f45aec.

* Move GROOT relative stats out of train script

* Guard GR00T relative action stepwise decode

* Match GR00T N1.7 OSS preprocessing and relative actions

* Apply LIBERO action decode override after loading

* Format GR00T OSS parity changes

* chore(policies): add guards, warnings and comments + recover tests n1.5 check

* fix(style): pre-commit

* fix(ci): guard dependecy checks

* chore(groot): move cv2 to the top as its in the default install tag

* chore(policies): add explicit dataset dependecy to gr00t implementation

* fix(test): add guard

* fix(groot): make N1.7 letterbox opt-in

* feat(groot): activate checkpoint-configured N1.7 raw-state dropout during training

Isaac-GR00T applies dual state regularization during fine-tuning: raw-state
zeroing driven by the processor sidecar's state_dropout_prob (0.2 for the
inspected N1.7 checkpoint) plus encoded-feature dropout. Baseline LeRobot kept
the processor in deterministic mode, so the raw-state dropout never activated
(RCA Tier-2 contributor to the LeRobot-trained SO-101 failures).

- GrootN17PackInputsStep: runtime-only 'training' flag + state_dropout_prob;
  whole-sample state zeroing gated on torch.is_grad_enabled() so eval and
  no_grad validation paths are unaffected
- sidecar loader reads state_dropout_prob from processor_config.json
- state_dropout_prob serializes with the step; the training flag intentionally
  does not (reloaded pipelines default to eval, re-enabled only when processors
  are rebuilt with dataset_meta)
- _set_groot_preprocessor_training toggles any dataclass step exposing a
  'training' field on serialized-pipeline reloads

Verification: tests/policies/groot/test_groot_state_dropout.py (4 passed) on
RTX PRO 6000 / CUDA 13.3.

* fix(groot): align N1.7 fine-tuning optimizer/scheduler/precision with Isaac-GR00T

Evidence from the LeRobot-vs-OSS checkpoint comparison: the LeRobot/HF 8k
checkpoint's DiT moved only ~19% as far from base as the OSS-trained one
(0.0547 vs 0.285 relative L2) - undertrained because the scheduler decayed over
a hardcoded 10k steps regardless of --steps, on top of beta1/clip mismatches.

- AdamW betas (0.95, 0.999) -> (0.9, 0.999) and grad_clip_norm 10.0 -> 1.0
  (Isaac defaults)
- scheduler: hardcoded CosineDecayWithWarmup(10k decay, floor 10% peak) ->
  DiffuserSchedulerConfig HF cosine with ceil(max_steps * warmup_ratio) warmup,
  deriving num_training_steps from the outer --steps at runtime
- model_params_fp32 (default true): keep master weights in FP32 and compute
  under BF16 autocast like the native N1.7 recipe (fixes optimizer-update
  numerics vs pure-BF16 params)
- weight-decay grouping via transformers get_parameter_names: biases and norm
  parameters excluded from decay
- restore the TF4 lm_head/embedding weight tie so the unused Qwen LM head stays
  frozen and deduplicated in checkpoints
- action_mask kept in native dtype for the masked flow-matching loss
- drop_n_last_frames: exclude episode tails that cannot supply a complete
  action chunk (Isaac sampler behavior)

Verification: tests/policies/groot/test_groot_training_optim_contract.py
(7 passed) + remaining groot suite 11 passed/5 skipped on RTX PRO 6000 /
CUDA 13.3. Note: tests/policies/groot/test_groot_n1_7.py does not collect on
the base branch (pre-existing ImportError, fixed in PR #37).

* feat(groot): train-time random crop for N1.7 (eval keeps center crop)

Isaac-GR00T crops a random crop_fraction window during training and the
deterministic center window at eval, replaying the sampled window across all
camera views of a sample. This contract is unchanged since the N1.5 release
(gr00t/data/transform/video.py: "If mode is 'train', return a random crop
transform. If mode is 'eval', return a center crop transform.") and mirrors
LeRobot's own Diffusion/VQBeT crop_is_random pattern. The LeRobot N1.7 port
used the eval center crop for training too, so the fine-tuned projector/DiT
never sees frame borders and trains on a single fixed appearance point.

Scope: crop geometry ONLY - no color jitter, no new dependencies. The random
window is plain numpy slicing inside the existing cv2 eval transform:

- _transform_n1_7_image_for_vlm_albumentations gains crop_position=(y, x)
  fractions; None keeps the center crop byte-identical to before (verified
  by test)
- GrootN17VLMEncodeStep gains a runtime-only 'training' flag (never
  serialized; reloaded pipelines default to eval); training samples ONE
  window per sample and reuses it across (timestep, view) frames - Isaac's
  cross-view consistency
- gated on torch.is_grad_enabled() so no_grad validation and frozen-eval
  paths are unaffected
- wired via dataset_meta is not None in make_groot_pre_post_processors and
  the existing _set_groot_preprocessor_training on serialized reloads

Verification: tests/policies/groot/test_groot_train_random_crop.py (8 passed:
center-crop bit-exactness with crop_position=None, corner/center windows,
cross-view replay, train!=eval, no_grad gating, seed reproducibility,
serialization contract) + groot suite 23 passed / 5 skipped on RTX PRO 6000 /
CUDA 13.3.

* docs(groot): update Training & hardware Evaluation commands

Replace the multi-GPU accelerate-launch Training snippet with the current
single-command 'uv run lerobot-train' N1.7 recipe (relative actions excluding
gripper, bf16, flash attention, chunk/n_action_steps=16, bs64/20k steps).

Replace the bimanual 'Evaluate in your hardware setup' rollout example with the
SO-101 follower RTC 'uv run lerobot-rollout' command (strategy.type=base,
inference.type=rtc, wrist+front cameras, place-the-vial task).

Docs-only; no source/test changes.

* docs(groot): parameterize commands with env vars + fill LIBERO results

- Introduce BASE_MODEL / DATASET_ID / REPO_ID / JOB_NAME / OUTPUT_DIR env vars
  in the training command and reuse OUTPUT_DIR + BASE_MODEL in the rollout cmd.
- Fill the LIBERO benchmark table with GR00T-LeRobot success rates
  (Spatial 94%, Object 98%, Goal 93%, LIBERO 10/Long 90%; avg 93.75%),
  drop the OSS column and XX placeholders. LeRobot-focused.

* docs(groot): drop export block, reference env vars directly

Use $DATASET_ID / $BASE_MODEL / $REPO_ID / $OUTPUT_DIR / $JOB_NAME as
bare placeholders in the commands without concrete export assignments.

* docs(groot): keep BASE_MODEL export in training command

* docs(groot): use literal HF repo IDs for dataset/policy repo_id

Public-facing Hub references (--dataset.repo_id, --policy.repo_id) shown as
concrete IDs; local-only values ($OUTPUT_DIR, $JOB_NAME) stay as placeholders.

* docs(groot): add LIBERO training command example

* docs(groot): remove LIBERO checkpoints subdirectory section

* docs(groot): use $BASE_MODEL for base_model_path in LIBERO eval

* docs(groot): drop hf download step from LIBERO eval, fix intro

* docs(groot): restore suite checkpoint download intro sentence

* docs(groot): remove checkpoint download note above LIBERO eval

* docs(groot): update training and rollout commands with new parameters and dependencies

* Add sample so101 training command

* Remove sample so101 training command

* docs(groot): remove optional Flash Attention setup instructions and update base model path for evaluation

* docs(groot): update training command with  image transformation parameters

* docs(groot): add note on inference.queue_threshold value for stable inference

* chore(style): pre-commit gr00t

* docs(groot): update

* chore(policies): minor details

* fix(groot): license headers + test guards

* chore(policies): fix tests

* docs(groot): relative actions param doc

* chore(policy): address some of the AI review items

---------

Co-authored-by: Andrew Wrenn <awrenn@nvidia.com>
Co-authored-by: Ryan Halabi <ryhalabi@nvidia.com>
Co-authored-by: nv-sachdevkartik <ksachdev@nvidia.com>
Co-authored-by: groot-validation <groot-validation@localhost>
Co-authored-by: johnnynunez <johnnynuca14@gmail.com>
Co-authored-by: lbenhorin <lbenhorin@nvidia.com>
2026-07-03 21:15:09 +02:00
Pepijn e275ea3960 LingBot-VA: video-action world model (#3731)
* feat(policies): add LingBot-VA autoregressive video-action world model

Port the LingBot-VA policy (Wan2.2 dual-stream video+action world model) into
LeRobot, following the EO-1 / VLA-JEPA conventions. Covers inference, checkpoint
conversion, and predicted-video saving (training is deferred to a follow-up PR).

- Vendored Wan transformer/attention/flex/VAE/scheduler modules (key names preserved
  for near-identity conversion); torch SDPA default, flashattn/flex lazy-guarded.
- LingBotVAConfig (registered "lingbot_va") + processor with fixed-quantile action
  unnormalization; full dual-stream sampling loop with CFG, two flow-matching
  schedulers and KV cache, mapped onto select_action with observed-keyframe feedback.
- convert_lingbot_va_checkpoints.py (libero/robotwin variants): bundles the ~5B
  transformer, lazy-pulls the frozen VAE+UMT5 from the source repo.
- Predicted-video plumbing in lerobot_eval (predicted_frames_callback; opt-in via
  --policy.save_predicted_video) and ConstantWithWarmupSchedulerConfig.
- pyproject: widen diffusers-dep to <0.37, add lingbot_va + imageio-dep extras,
  add lingbot_va and (missing) eo1 to `all`.
- Factory + policies/__init__ wiring, docs page + toctree, and tests.

Note: the LIBERO success-rate correctness gate must be validated on a CUDA GPU
with the converted checkpoint.

* feat(lingbot_va): RoboTwin eef-pose eval, single-file model, Hub checkpoints

Make the LingBot-VA port runnable on both LIBERO and RoboTwin and clean up the
package to LeRobot conventions.

- Consolidate all vendored Wan2.2 model code (transformer, attention, VAE helpers,
  flow-matching scheduler, grid utils, flex-attention) into a single
  modeling_lingbot_va.py; remove the separate wan_*/schedulers modules.
- Move the fixed action (un)normalization quantiles out of the config and into the
  post-processor (LIBERO 7-DoF + RoboTwin 16-d eef); remove the conversion script in
  favour of ready-to-use LeRobot-format checkpoints on the Hub.
- Fixes found via on-sim validation: undo LIBERO's 180-degree image flip
  (image_hflip), encode obs as a multi-frame streaming-VAE clip, reset the streaming
  VAE cache between episodes, run the transformer in config.dtype, lazy-load frozen
  VAE/UMT5 by subfolder with the text encoder on CPU.
- RoboTwin: add an end-effector-pose action mode to RoboTwinEnv (16-d per-arm
  xyz+quat+gripper deltas composed onto the initial eef pose, executed via CuRobo IK)
  and the robotwin_tshape latent layout (full-res head + half-res wrists via a second
  streaming VAE) with the upstream RoboTwin action quantiles + camera mapping.
- Predicted-video saving works for both benchmarks; docs + tests updated.

* feat(lingbot_va): implement training / fine-tuning (flow-matching loss)

- Implement LingBotVAPolicy.forward(): dual-stream flow-matching training loss
  (latent + action, timestep-weighted, action-masked) ported from upstream train.py;
  VAE-encodes camera clips, UMT5-encodes the task, noises both streams, runs the
  block-causal flex-attention training pass (forward_train).
- training_loss_from_streams() core + _build_training_streams() data prep (action
  scatter into the 30-d space, multi-frame VAE encode incl. robotwin_tshape).
- get_optim_params returns only trainable transformer params (LoRA/PEFT friendly);
  VAE/UMT5 stay frozen. Training needs attn_mode='flex'.
- Add a tiny-config single-training-step test (forward->loss->backward->AdamW) and a
  Training/fine-tuning section in the docs.

* fix(lingbot_va): CI quality gate + fast-test collection

- Add tests/policies/lingbot_va/__init__.py so the test files don't clash by basename
  with tests/policies/vla_jepa/* under pytest's default import mode (fast-test collection error).
- Fix vendored typos flagged by the typos hook (pach_scale->patch_scale, total_tolen->
  total_token_len, stablized->stabilized) and a mypy union-attr in RoboTwinEnv._read_eef_pose.
- Apply Prettier formatting to docs/source/lingbot_va.mdx.

* docs(lingbot_va): document EEF action-channel schema + camera order

* Update lingbot_va.mdx

Signed-off-by: Pepijn <138571049+pkooij@users.noreply.github.com>

* Update pyproject.toml

Signed-off-by: Pepijn <138571049+pkooij@users.noreply.github.com>

* Update pyproject.toml

Signed-off-by: Pepijn <138571049+pkooij@users.noreply.github.com>

* refactor(lingbot_va): drop hardcoded action quantiles; source from checkpoint

The LIBERO/RoboTwin action (un)normalization quantiles were hardcoded as module
constants in processor_lingbot_va.py. They are already serialized into each
checkpoint's policy_postprocessor.json (via LingBotVAActionUnnormalizeStep.get_config)
and restored on load by PolicyProcessorPipeline.from_pretrained, so the constants are
dead at eval/load time for the released checkpoints (verified: libero_long/robotwin/base
all carry their quantiles on the Hub).

- Remove LIBERO_ACTION_Q01/Q99, ROBOTWIN_ACTION_Q01/Q99 and _default_action_quantiles.
- make_lingbot_va_pre_post_processors now defaults a fresh (unconverted) build to a
  neutral [-1, 1] mapping (identity rescale); real per-benchmark stats come from the
  saved checkpoint (or postprocessor_overrides), analogous to dataset-stats normalization.
- Update the config doc comment to point at the checkpoint as the source of truth.
- Tests: replace the LIBERO-default assertion with a neutral-default check, and add a
  save_pretrained/from_pretrained round-trip guard for the quantile serialization.

* docs(lingbot_va): trim verbose comments

- configuration_lingbot_va.py: condense multi-line field comments to one-liners
  (keep the ── section headers).
- processor_lingbot_va.py: shorten the action-quantile explanation block.
- modeling_lingbot_va.py: drop the bare "# ----" separator rules, keeping the
  one-line section headers.

No code changes.

* docs(lingbot_va): trim provenance comments; default wan path to base repo

- configuration_lingbot_va.py: drop the "──" decorations and the
  "(from transformer/config.json)" note; default wan_pretrained_path to
  robbyant/lingbot-va-base (has the frozen vae/text_encoder/tokenizer subfolders).
- modeling_lingbot_va.py: remove the vendored-code banner and the
  "(upstream wan_va/...)" section-header provenance/dash decorations; condense the
  transformer-dtype comment to one line.

No code changes.

* refactor(lingbot_va): use built-in UnnormalizerProcessorStep for actions

Replace the bespoke LingBotVAActionUnnormalizeStep with the standard
UnnormalizerProcessorStep in QUANTILES mode, which computes the identical
(action + 1) / 2 * (q99 - q01) + q01 mapping. The per-channel q01/q99 are stored
as the step's saved state (a safetensors file) and restored on load; a fresh build
has no action stats so the step is an identity passthrough.

The 3 Hub checkpoints (lerobot/lingbot_va_{libero_long,robotwin,base}) have been
re-uploaded with the new post-processor (policy_postprocessor.json +
*_unnormalizer_processor.safetensors); reloading from the Hub round-trips q01/q99.

- processor_lingbot_va.py: drop the custom step + registry; build the post-processor
  with UnnormalizerProcessorStep (explicit ACTION->QUANTILES norm_map so the
  preprocessor / training path is unchanged).
- tests: assert the built-in step is used, identity-when-no-stats, correct quantile
  unnormalization, and a save_pretrained/from_pretrained stats round-trip.

* docs(lingbot_va): point checkpoint paths at the lerobot org

The LeRobot-format checkpoints moved from pepijn223/* to lerobot/* (libero_long,
robotwin, base). Update the eval/train --policy.path examples accordingly.

* docs(lingbot_va): condense processor normalization comments

* fix(lingbot-va): align RoboTwin evaluation (#3784)

Thank you for the RoboTwin fix, and alignment!

* applying fixes

* updating uv lock and linting

* adjusting test to match expected values

* cleaning up deps

* cleaning up top level imports, styling, and deps guards

* cleanup
* moving wan utils and loading utils to `utils.py`
* removing ftfy by replicating the prompt_clean function without it (we don't expect to have weird chars given in the prompt anyway)

* removing unused function

* guarding for scipy dep, renaming test to avoid collision

* adding back accelerate for peak memory usage optim + justifying robotwin description dep

---------

Signed-off-by: Pepijn <138571049+pkooij@users.noreply.github.com>
Co-authored-by: pepijn223 <pepijn223@hf.co>
Co-authored-by: Gangwei XU <gwxu@hust.edu.cn>
Co-authored-by: Maxime Ellerbach <maxime.ellerbach@huggingface.co>
2026-07-03 13:32:38 +02:00
Nikodem Bartnik 911734ec9c Docs/improve HF jobs documentation (#3909)
* improve hf jobs docs

* Update docs/source/hardware_guide.mdx

Co-authored-by: Nicolas Rabault <rabault.nicolas@gmail.com>
Signed-off-by: Nikodem Bartnik <39432165+NikodemBartnik@users.noreply.github.com>

---------

Signed-off-by: Nikodem Bartnik <39432165+NikodemBartnik@users.noreply.github.com>
Co-authored-by: Nicolas Rabault <rabault.nicolas@gmail.com>
2026-07-03 11:39:16 +02:00
Pepijn 07285677a3 fix(train): drive Accelerate mixed precision from policy.dtype (#3912)
* fix(train): drive Accelerate mixed precision from policy.dtype

`accelerator.autocast()` was always a no-op because `mixed_precision`
was never set, so `--policy.dtype=bfloat16` only cast the model params
(via the policy) while autocast-eligible ops still ran in fp32/tf32.

Map the active policy's `dtype` onto Accelerate's `mixed_precision`
(bfloat16 -> bf16, float16 -> fp16, float32 -> no) so autocast is active
for bf16/fp16 and stays full precision for float32. Policies without a
string `dtype` field fall back to Accelerate's launcher default, so
existing behavior is preserved.

* style(train): condense mixed-precision comment to one line
2026-07-02 19:15:19 +02:00
Caroline Pascal 7ae12124b0 fix(save codec options): making sure codec options are always set via set_if (#3910)
* fix(save codec options): making sure codec options are always safely set through `set_if`

* tests(update): updating tests
2026-07-02 15:29:14 +02:00
Caroline Pascal c746ca2df2 fix(depth unit): adding input depth unit storage in the dataset metadata (#3899)
* fix(depth unit): storing raw depth units in the dataset metadata for correct depth statistics and depth raw frames handling. The unit is stored as a string ("m","mm") under "depth_unit" at the same level as "is_depth_map". Unit is inferred from the depth frame type.

* feat(raw frame unit): adapting dataset reader so that raw depth frames are scaled according to the requested unit

* feat(stats units): rescaling stats when loading a dataset so that the stats are given in the requested unit

* tests(unit): adapting and extending depth tests to units manipulations

* chore(format): formating code

* feat(warning): adding a warning when depth unit is not specified in the dataset

* chore(infer_depth_unit): moving the depth unit inference utility in a more accessible location

* feat(rerun unit): adding correct depth unit display for rerun (foxglove does not support units yet)

* feat(unit getter): adding a proper output_depth_unit getter to LeRobotDataset for cleaner integration

* fix(streaming dataset): extending support for depth units to streaming datasets

* test(rerun): fixing rerun tests
2026-07-02 11:53:13 +02:00
Caroline Pascal b961d2a8c5 feat(libaom-av1): adding support for libaom-av1 codec (#3898) 2026-07-02 11:03:41 +02:00
Steven Palma 052d329470 feat(visualization): add foxglove support (#3902)
* Add Foxglove display mode for teleoperate

Add a --display_mode flag (rerun|foxglove) to lerobot-teleoperate. When set
to foxglove, stream observations/actions over a Foxglove WebSocket server:
images as RawImage/CompressedImage, scalars as typed JSON channels with
schemas generated from the feature names (sanitized so paths don't need
quoting). Adds a `foxglove` extra.

* Add Foxglove display mode to lerobot-record

Wire the --display_mode flag (rerun|foxglove) into lerobot-record, matching
lerobot-teleoperate: route init/log through the backend-agnostic dispatchers
and stop the visualization backend on exit.

* update foxglove-sdk to 0.25.1

* Use static lerobot.Scalars schema for Foxglove state topics

Replace the per-topic JSON schema derived from feature names with a single
static lerobot.Scalars schema: a scalars array of {label, value} objects. The
same schema fits any robot regardless of which observation/action features it
reports, and the label field lets Foxglove name each series automatically so
one filtered path plots every feature.

* add foxglove option to dataset viz

* Make Foxglove dataset playback loop the sole frame emitter

Address review: the listener no longer emits frames, it only mutates
playback state and queues a one-shot seek index that the playback loop
services. The loop is now the only caller of emit_frame, so concurrent
random access into the on-disk dataset / video decoder never overlaps.

Also remove the dead server_holder and tighten the _foxglove_safe_name
docstring to state what it does and why.

* Label Foxglove dataset scalars with feature dimension names

Use the dataset's per-dimension feature names (e.g. joint names) as the
Foxglove series labels for /observation/state and /action/state instead
of bare indices. LeRobot stores `names` inconsistently (flat list,
{category: [...]}, or {name: index}), so _feature_dim_names handles each
and falls back to indices on any unknown format or length mismatch.

* Make Foxglove server host bindable and refactor topic/channel handling

Pass display_ip through as the Foxglove WebSocket bind host (127.0.0.1
for local only, 0.0.0.0 for all interfaces) instead of always binding
locally. In lerobot-dataset-viz, fold the separate --port into --web-port
so one flag covers both the Rerun web viewer and the Foxglove server port.

Add a _foxglove_topic() helper and thread a per-topic channel cache
through the log helpers so dataset playback stays self-contained instead
of mutating the module-global cache. Promote SUCCESS to constants.py.

* feat(viz): add support for foxglove in rollout + add to viz tag

* fix(docs): remove misleading installation note

* fix(visualization): no duplicated prefix, consolidated norm + warnings log

* chore(viz): minor improvements

* refactor(viz): split files + autoplay + updated docs + added minimal tests

* fix(viz): right tags + warning

* feat(deprecated ws-port): removing rerun's depreacted ws-port parameter in dataset visualization

* chore(web ports): adding global variables for default foxglove/rerun web ports

* feat(depth): adding depth support to foxglove visualizer. Because of foxglove limitations (min and max values on RawImage cannot be set from the SDK), depth is normalized between [0,1] when a depth range is provided.

* fix(rerun depth range): making rerun depth range computation safe against missing stats

* chore(foxglove depth): make it simple, and make it work.

* fix(scaling): fixing depth frames scaling

---------

Co-authored-by: Roman Shtylman <roman@foxglove.dev>
Co-authored-by: Caroline Pascal <caroline8.pascal@gmail.com>
2026-07-01 18:39:32 +02:00
197 changed files with 29758 additions and 11469 deletions
+4
View File
@@ -22,6 +22,10 @@ outputs
rl
media
# Local virtualenvs (the image provides its own)
.venv
venv
# Logging
logs
+2 -2
View File
@@ -55,7 +55,7 @@ jobs:
github.repository == 'huggingface/lerobot'
permissions:
contents: read
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@2430c1ec91d04667414e2fa31ecfc36c153ea391 # main
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@e60a538eea9817ab312196d0d233604b01697265 # main
with:
commit_sha: ${{ github.sha }}
package: lerobot
@@ -78,7 +78,7 @@ jobs:
permissions:
contents: read
pull-requests: write
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@2430c1ec91d04667414e2fa31ecfc36c153ea391 # main
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@e60a538eea9817ab312196d0d233604b01697265 # main
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
pr_number: ${{ github.event.number }}
+8 -8
View File
@@ -87,7 +87,7 @@ Learn more about it in the [LeRobotDataset Documentation](https://huggingface.co
## SoTA Models
LeRobot implements state-of-the-art policies in pure PyTorch, covering Imitation Learning, Reinforcement Learning, and Vision-Language-Action (VLA) models, with more coming soon. It also provides you with the tools to instrument and inspect your training process.
LeRobot implements state-of-the-art policies in pure PyTorch, covering Imitation Learning, Reinforcement Learning, Vision-Language-Action (VLA) models, World Models, and Reward Models, with more coming soon. It also provides you with the tools to instrument and inspect your training process.
<p align="center">
<img alt="Gr00t Architecture" src="./media/readme/VLA_architecture.jpg" width="640px">
@@ -101,13 +101,13 @@ lerobot-train \
--dataset.repo_id=lerobot/aloha_mobile_cabinet
```
| Category | Models |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Imitation Learning** | [ACT](./docs/source/policy_act_README.md), [Diffusion](./docs/source/policy_diffusion_README.md), [VQ-BeT](./docs/source/policy_vqbet_README.md), [Multitask DiT Policy](./docs/source/policy_multi_task_dit_README.md) |
| **Reinforcement Learning** | [HIL-SERL](./docs/source/hilserl.mdx), [TDMPC](./docs/source/policy_tdmpc_README.md) & QC-FQL (coming soon) |
| **VLAs Models** | [Pi0](./docs/source/pi0.mdx), [Pi0Fast](./docs/source/pi0fast.mdx), [Pi0.5](./docs/source/pi05.mdx), [GR00T N1.5](./docs/source/policy_groot_README.md), [SmolVLA](./docs/source/policy_smolvla_README.md), [XVLA](./docs/source/xvla.mdx), [EO-1](./docs/source/eo1.mdx), [MolmoAct2](./docs/source/molmoact2.mdx), [WALL-OSS](./docs/source/walloss.mdx) |
| **World Models** | [VLA-JEPA](./docs/source/vla_jepa.mdx) (more coming soon) |
| **Reward Models** | [SARM](./docs/source/sarm.mdx), [TOPReward](./docs/source/topreward.mdx), [Robometer](./docs/source/robometer.mdx) |
| Category | Models |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Imitation Learning** | [ACT](./docs/source/policy_act_README.md), [Diffusion](./docs/source/policy_diffusion_README.md), [VQ-BeT](./docs/source/policy_vqbet_README.md), [Multitask DiT Policy](./docs/source/policy_multi_task_dit_README.md) |
| **Reinforcement Learning** | [HIL-SERL](./docs/source/hilserl.mdx), [TDMPC](./docs/source/policy_tdmpc_README.md) & QC-FQL (coming soon) |
| **VLAs Models** | [Pi0](./docs/source/pi0.mdx), [Pi0Fast](./docs/source/pi0fast.mdx), [Pi0.5](./docs/source/pi05.mdx), [GR00T N1.7](./docs/source/policy_groot_README.md), [SmolVLA](./docs/source/policy_smolvla_README.md), [XVLA](./docs/source/xvla.mdx), [EO-1](./docs/source/eo1.mdx), [MolmoAct2](./docs/source/molmoact2.mdx), [WALL-OSS](./docs/source/walloss.mdx), [EVO1](./docs/source/evo1.mdx) |
| **World Models** | [VLA-JEPA](./docs/source/vla_jepa.mdx), [LingBot-VA](./docs/source/lingbot_va.mdx), [FastWAM](./docs/source/fastwam.mdx) |
| **Reward Models** | [SARM](./docs/source/sarm.mdx), [TOPReward](./docs/source/topreward.mdx), [Robometer](./docs/source/robometer.mdx) |
Similarly to the hardware, you can easily implement your own policy & leverage LeRobot's data collection, training, and visualization tools, and share your model to the HF Hub
+7 -1
View File
@@ -69,10 +69,14 @@
title: VLA-JEPA
- local: eo1
title: EO-1
- local: lingbot_va
title: LingBot-VA
- local: fastwam
title: FastWAM
- local: evo1
title: EVO1
- local: groot
title: NVIDIA GR00T N1.5
title: NVIDIA GR00T
- local: xvla
title: X-VLA
- local: multi_task_dit
@@ -165,6 +169,8 @@
- sections:
- local: phone_teleop
title: Phone
- local: isaac_teleop
title: Isaac Teleop
title: "Teleoperators"
- sections:
- local: cameras
+30 -21
View File
@@ -81,6 +81,12 @@ merged. Both prompts also carry a causal **event-boundary** definition (a
new event starts when an object becomes held / is released / reaches a new
location / a lid changes state / contents move) to sharpen where cuts land.
Optionally, a third **seeded-relabel** pass (`--plan.subtask_seeded_relabel`)
revisits each span with its previous/current/next segment contact sheets and
minimally corrects the label, using the first label as a prior — it keeps the
boundaries fixed and only sharpens wording, at the cost of one extra call per
subtask.
The resulting spans are then stitched into a gap-free, full-episode
cover, so **every frame has exactly one active subtask**. See
[`run_hf_job.py`](https://github.com/huggingface/lerobot/blob/main/examples/annotations/run_hf_job.py)
@@ -157,30 +163,33 @@ Every module is on by default and can be toggled independently (set to
### The VLM (`--vlm.*`)
| Flag | Default | What it does |
| -------------------------- | ------------------ | ----------------------------------------------------------------------------------- |
| `--vlm.model_id` | `Qwen/Qwen3.6-27B` | The model to serve and prompt. |
| `--vlm.camera_key` | first `images.*` | Which camera every prompt is grounded on. |
| `--vlm.serve_command` | auto | The exact `vllm serve …` command (set TP size, GPU memory, `--max-model-len` here). |
| `--vlm.parallel_servers` | `1` | Independent servers for round-robin routing (one per GPU). |
| `--vlm.num_gpus` | `0` | GPUs per server (`0` = one each). |
| `--vlm.client_concurrency` | `16` | In-flight requests across all servers. |
| `--vlm.max_new_tokens` | `512` | Generation cap per call. |
| `--vlm.temperature` | `0.2` | Sampling temperature. |
| Flag | Default | What it does |
| -------------------------- | ------------------ | ------------------------------------------------------------------------------------ |
| `--vlm.model_id` | `Qwen/Qwen3.6-27B` | The model to serve and prompt. |
| `--vlm.camera_key` | first `images.*` | Which camera every prompt is grounded on. |
| `--vlm.serve_command` | auto | The exact `vllm serve …` command (set TP size, GPU memory, `--max-model-len` here). |
| `--vlm.parallel_servers` | `1` | Independent servers for round-robin routing (one per GPU). |
| `--vlm.num_gpus` | `0` | GPUs per server (`0` = one each). |
| `--vlm.client_concurrency` | `16` | In-flight requests across all servers. |
| `--vlm.max_new_tokens` | `512` | Generation cap per call. |
| `--vlm.temperature` | `0.2` | Sampling temperature. |
| `--vlm.reasoning_effort` | `null` | Thinking-budget hint (`low`/`medium`/`high`) forwarded to OpenAI-compatible servers. |
### Subtasks / plan / memory (`--plan.*`)
| Flag | Default | What it does |
| ------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------- |
| `--plan.frames_per_second` | `2.0` | Frame sampling rate for the contact sheets (`2.0` = one frame every 0.5s). |
| `--plan.max_frames_per_prompt` | `60` | Frame budget per VLM call. Episodes whose sampling exceeds this are auto-windowed at the same density, then stitched. |
| `--plan.contact_sheet_columns` | `5` | Columns per contact-sheet grid (`contact_sheet_frames_per_sheet` tiles, time row-major). |
| `--plan.plan_max_steps` | `8` | Upper bound on subtasks per episode. |
| `--plan.subtask_describe_first` | `true` | Run the describe→segment grounding pass (best subtask quality; +1 call/episode). |
| `--plan.emit_plan` | `true` | Emit the numbered `plan` rows (`false` = subtasks + memory only). |
| `--plan.emit_memory` | `true` | Emit the `memory` rows (`false` = subtasks + plan only); symmetric to `emit_plan`. |
| `--plan.n_task_rephrasings` | `10` | How many `task_aug` rephrasings to emit (`0` disables). |
| `--plan.derive_task_from_video` | `if_short` | Use the dataset task as-is (`off`), only when it's missing/short (`if_short`), or always re-derive from video (`always`). |
| Flag | Default | What it does |
| ------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `--plan.frames_per_second` | `2.0` | Frame sampling rate for the contact sheets (`2.0` = one frame every 0.5s). |
| `--plan.max_frames_per_prompt` | `60` | Frame budget per VLM call. Episodes whose sampling exceeds this are auto-windowed at the same density, then stitched. |
| `--plan.contact_sheet_columns` | `5` | Columns per contact-sheet grid (`contact_sheet_frames_per_sheet` tiles, time row-major). |
| `--plan.plan_max_steps` | `8` | Upper bound on subtasks per episode. |
| `--plan.subtask_describe_first` | `true` | Run the describe→segment grounding pass (best subtask quality; +1 call/episode). |
| `--plan.subtask_seeded_relabel` | `false` | Second pass: re-label each subtask from its prev/current/next contact sheets, seeded with the first label (+1 call/subtask). |
| `--plan.subtask_relabel_frames` | `5` | Frames sampled uniformly per segment sheet in the relabel pass (only used when `subtask_seeded_relabel=true`). |
| `--plan.emit_plan` | `true` | Emit the numbered `plan` rows (`false` = subtasks + memory only). |
| `--plan.emit_memory` | `true` | Emit the `memory` rows (`false` = subtasks + plan only); symmetric to `emit_plan`. |
| `--plan.n_task_rephrasings` | `10` | How many `task_aug` rephrasings to emit (`0` disables). |
| `--plan.derive_task_from_video` | `if_short` | Use the dataset task as-is (`off`), only when it's missing/short (`if_short`), or always re-derive from video (`always`). |
### Interjections + VQA
+18 -13
View File
@@ -150,14 +150,14 @@ class MyPolicy(PreTrainedPolicy):
The methods called by the train/eval loops:
| Method | Used by | What it does |
| ----------------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reset() -> None` | `lerobot-eval` | Clear per-episode state at the start of each episode. |
| `select_action(batch, **kwargs) -> Tensor` | `lerobot-eval` | Return the next action `(B, action_dim)`. Called every step. |
| `predict_action_chunk(batch, **kwargs) -> Tensor` | the policy itself | Return an action chunk `(B, chunk_size, action_dim)`. Currently abstract on the base class — raise `NotImplementedError` if your policy doesn't chunk. |
| `forward(batch, reduction="mean") -> tuple[Tensor, dict \| None]` | `lerobot-train` | Return `(loss, output_dict)`. Accept `reduction="none"` if you want to support per-sample weighting. |
| `get_optim_params() -> dict` | the optimizer | Return `self.parameters()` for simple policies; return a named parameter dict for [multi-optimizer policies](https://github.com/huggingface/lerobot/blob/ecd38c50d7d15b4184cf42649ff1185ee2e11eeb/src/lerobot/policies/sac/modeling_sac.py#L61-L73). |
| `update() -> None` _(optional)_ | `lerobot-train` | Called after each optimizer step _if defined_. Use for EMA, target nets, replay buffers (TDMPC uses this). |
| Method | Used by | What it does |
| ----------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reset() -> None` | `lerobot-eval` | Clear per-episode state at the start of each episode. |
| `select_action(batch, **kwargs) -> Tensor` | `lerobot-eval` | Return the next action `(B, action_dim)`. Called every step. |
| `predict_action_chunk(batch, **kwargs) -> Tensor` | the policy itself | Return an action chunk `(B, chunk_size, action_dim)`. Currently abstract on the base class — raise `NotImplementedError` if your policy doesn't chunk. |
| `forward(batch, reduction="mean") -> tuple[Tensor, dict \| None]` | `lerobot-train` | Return `(loss, output_dict)`. Accept `reduction="none"` if you want to support per-sample weighting. |
| `get_optim_params() -> dict` | the optimizer | Return `self.parameters()` for simple policies; return a named parameter dict for multi-optimizer policies (see `get_optim_params` in [`modeling_act.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/act/modeling_act.py) for a per-group learning-rate example). |
| `update() -> None` _(optional)_ | `lerobot-train` | Called after each optimizer step _if defined_. Use for EMA, target nets, replay buffers (TDMPC uses this). |
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.
@@ -295,11 +295,10 @@ The file names are load-bearing: the factory does lazy imports by name, and the
### Wiring
Three places need to know about your policy. All by name.
Two places need to know about your policy. All by name.
1. **`policies/__init__.py`** — re-export `MyPolicyConfig` and add it to `__all__`. **Don't** re-export the modeling class; it loads lazily through the factory (so `import lerobot` stays fast).
2. **`factory.py:get_policy_class`** — add a branch returning `MyPolicy` from a lazy import.
3. **`factory.py:make_policy_config`** and **`factory.py:make_pre_post_processors`** — same idea, two more branches.
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.
Mirror an existing policy that's structurally similar to yours; the diff is small.
@@ -331,6 +330,10 @@ This way:
Add a matching extra to [`pyproject.toml`](https://github.com/huggingface/lerobot/blob/main/pyproject.toml) `[project.optional-dependencies]` and include it in the `all` extra so `pip install 'lerobot[all]'` keeps installing everything.
### Avoid copying a modeling file — subclass it
If your policy needs to modify a backbone that already exists in `transformers` (custom conditioning, extra inputs, a swapped sub-module), **do not vendor a copy of its `modeling_*.py`**. Instead, subclass the smallest upstream unit and override only what changes. [`pi_gemma.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/pi_gemma.py) is the canonical reference: it injects AdaRMS conditioning into PaliGemma/Gemma in ~370 lines by subclassing `GemmaModel`/`PaliGemmaModel` and overriding the decoder-layer forward, instead of forking the ~2,000-line modeling file. Model surgery on a _loaded_ native model is also fine (layer truncation, tokenizer expansion, hidden-state capture — see `evo1/internvl3_embedder.py`, `eo1/modeling_eo1.py`, `groot/groot_n1_7.py` for working examples). Reviewers will ask for this pattern when a PR arrives with a copied modeling file; the only accepted exception is a model that does not exist in `transformers` at all.
### Benchmarks and a published checkpoint
A new policy is much easier to review — and far more useful — when it ships with a working checkpoint and at least one number you can reproduce.
@@ -366,11 +369,13 @@ If your policy is real-robot-only and no sim benchmark applies, swap the sim eva
The general expectations are in [`CONTRIBUTING.md`](https://github.com/huggingface/lerobot/blob/main/CONTRIBUTING.md) and the [PR template](https://github.com/huggingface/lerobot/blob/main/.github/PULL_REQUEST_TEMPLATE.md). On top of those, reviewers will look for:
- [ ] `MyPolicy` and `MyPolicyConfig` cover the surface above; `__init_subclass__` accepts the class.
- [ ] `factory.py` and `policies/__init__.py` are wired (lazy imports for modeling).
- [ ] `policies/__init__.py` re-exports the config (this registers the policy; the factory resolves modeling/processor by naming convention).
- [ ] `make_my_policy_pre_post_processors` follows the naming convention.
- [ ] Optional deps live behind a `[project.optional-dependencies]` extra and the `TYPE_CHECKING + require_package` guard.
- [ ] `tests/policies/` updated; backward-compat artifact committed & policy-specific tests.
- [ ] `src/lerobot/policies/<name>/README.md` symlinked into `docs/source/policy_<name>_README.md`; user-facing `docs/source/<name>.mdx` written and added to `_toctree.yml`.
- [ ] `templates/lerobot_modelcard_template.md` has a description entry and a `policy_docs` link for your policy.
- [ ] The models table in the root `README.md` lists your policy in the right category, linking to your doc page.
- [ ] At least one reproducible benchmark eval in the policy MDX with a published checkpoint (sim benchmark, or real-robot dataset + checkpoint).
The fastest way to get a clean PR is to copy the directory of the existing policy closest to yours, rename, and replace contents method by method. Don't wait until everything is polished — open a draft PR early and iterate with us; reviewers would much rather give feedback on a half-finished branch than a fully-merged one.
+1 -1
View File
@@ -193,7 +193,7 @@ To learn more about training policies with LeRobot, please refer to the training
- [SmolVLA](./smolvla)
- [Pi0.5](./pi05)
- [GR00T N1.5](./groot)
- [GR00T N1.7](./groot)
Sample IsaacLab Arena datasets are available on HuggingFace Hub for experimentation:
+191
View File
@@ -0,0 +1,191 @@
# EVO1
EVO1 is a Vision-Language-Action policy for robot control built around an InternVL3 backbone and a continuous flow-matching action head. This LeRobot integration exposes EVO1 as a standard policy type so it can be trained and evaluated with the usual LeRobot dataset, checkpoint, and processor APIs.
## Model Overview
The policy embeds one or more camera images and the language task prompt with InternVL3, pads robot state/action vectors to fixed maximum dimensions, and predicts future action chunks with a flow-matching action head. During inference, the policy samples an action chunk and returns `n_action_steps` actions from that chunk before sampling again.
### What the LeRobot Integration Covers
- Standard `policy.type=evo1` configuration through LeRobot
- InternVL3 image/text embedding with optional FlashAttention fallback
- Stage-based finetuning controls for action-head-only and VLM finetuning runs
- Continuous flow-matching action prediction
- Checkpoint save/load through LeRobot policy APIs
- Training with `lerobot-train` and evaluation with standard policy inference APIs
The broader EVO1 project may include additional training scripts and dataset tooling. This page focuses on the LeRobot robot-control policy path.
## Installation Requirements
1. Install LeRobot by following the [Installation Guide](./installation).
2. Install EVO1 dependencies:
```bash
pip install -e ".[evo1]"
```
For LIBERO evaluation, install the LIBERO extra as well:
```bash
pip install -e ".[evo1,libero]"
```
3. Install a `flash-attn` wheel only if it is compatible with your Python, PyTorch, CUDA, and GPU stack. EVO1 falls back to standard attention when `flash_attn` is not available.
EVO1 uses the native Hugging Face `transformers` InternVL implementation, so `policy.vlm_model_name` must point to a natively converted checkpoint such as `OpenGVLab/InternVL3-1B-hf` (note the `-hf` suffix). The first run may download the configured VLM checkpoint unless `policy.vlm_model_name` points to a local model directory.
## Data Requirements
EVO1 expects a LeRobot dataset with:
- One to `policy.max_views` visual observations, for example `observation.images.image`
- `observation.state`
- `action`
- A language task instruction in the dataset `task` field, or another field configured with `policy.task_field`
State and action vectors are padded to `policy.max_state_dim` and `policy.max_action_dim`. Predictions are cropped back to the dataset action dimension before being returned.
## Usage
To use EVO1 in a LeRobot configuration, specify:
```python
policy.type=evo1
```
By default, a new EVO1 policy initializes its VLM from:
```python
policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf
```
Once a LeRobot-format EVO1 checkpoint is available, load it with:
```python
policy.path=your-org/your-evo1-checkpoint
```
## Training
### Stage 1
Stage 1 freezes the VLM and trains the action head:
```bash
lerobot-train \
--dataset.repo_id=your_org/your_dataset \
--policy.type=evo1 \
--policy.training_stage=stage1 \
--policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
--policy.device=cuda \
--policy.chunk_size=50 \
--policy.n_action_steps=50 \
--policy.max_state_dim=24 \
--policy.max_action_dim=24 \
--policy.optimizer_lr=1e-5 \
--batch_size=4 \
--steps=5000 \
--output_dir=./outputs/evo1_stage1
```
### Stage 2
Stage 2 finetunes the VLM branches and action head. A common workflow starts from a Stage 1 checkpoint:
```bash
lerobot-train \
--dataset.repo_id=your_org/your_dataset \
--policy.path=./outputs/evo1_stage1/checkpoints/005000/pretrained_model \
--policy.training_stage=stage2 \
--policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
--policy.device=cuda \
--policy.chunk_size=50 \
--policy.n_action_steps=50 \
--policy.max_state_dim=24 \
--policy.max_action_dim=24 \
--policy.optimizer_lr=1e-5 \
--batch_size=4 \
--steps=80000 \
--output_dir=./outputs/evo1_stage2
```
By default, `policy.training_stage` reapplies the finetuning defaults for that stage. This is important when
starting Stage 2 from a Stage 1 checkpoint, because the Stage 1 checkpoint config stores the VLM finetuning
flags as disabled. These stage defaults take precedence over saved or manually supplied `policy.finetune_*`
flags unless `policy.apply_training_stage_defaults=false`, so set that flag only when manually controlling
every finetuning flag.
### Key Training Parameters
| Parameter | Default | Description |
| --------------------------------------------- | --------------------------- | ----------------------------------------------------------------- |
| `policy.vlm_model_name` | `OpenGVLab/InternVL3-1B-hf` | Natively converted InternVL3 checkpoint or local model directory |
| `policy.training_stage` | `stage1` | `stage1` trains the action head; `stage2` finetunes VLM branches |
| `policy.apply_training_stage_defaults` | `true` | Reapplies stage finetuning defaults after loading a checkpoint |
| `policy.vlm_num_layers` | `14` | Number of InternVL3 language layers kept for the policy |
| `policy.vlm_dtype` | `bfloat16` | Requested VLM dtype |
| `policy.use_flash_attn` | `true` | Requests FlashAttention when installed; otherwise falls back |
| `policy.enable_gradient_checkpointing` | `true` | Enables checkpointing on supported InternVL3 modules |
| `policy.gradient_checkpointing_use_reentrant` | `false` | Reentrant setting passed to gradient checkpointing when supported |
| `policy.chunk_size` | `50` | Number of future actions predicted per chunk |
| `policy.n_action_steps` | `50` | Number of actions consumed from a sampled chunk |
| `policy.max_state_dim` | `24` | State padding dimension |
| `policy.max_action_dim` | `24` | Action padding dimension |
| `policy.postprocess_action_dim` | `null` | Optional action dimension returned after EVO1 postprocessing |
| `policy.binarize_gripper` | `false` | Binarizes the postprocessed gripper channel for LIBERO-style eval |
| `policy.task_field` | `task` | Batch field used as the language prompt |
## Inference
Try it out with a trained EVO1 checkpoint:
```bash
lerobot-rollout \
--policy.path=your-org/your-evo1-checkpoint \
--inference.type=rtc \ # optional
...
```
## Results
### LIBERO Evaluation
> [!NOTE]
> Benchmark results for a `lerobot`-hosted LIBERO checkpoint trained with this implementation
> will be added once training completes.
The official EVO1 LIBERO rollout protocol uses the raw LIBERO camera feature names
(`observation.images.agentview_image` and `observation.images.robot0_eye_in_hand_image`), replans every
14 actions, and binarizes the gripper command before stepping the simulator. The EVO1 policy postprocessor
can crop the padded 24D action back to the 7D LIBERO action space and apply that gripper binarization. To
evaluate a LIBERO checkpoint under the same one-episode-per-task setting, keep the raw camera names instead
of the default `image`/`image2` mapping and set the LIBERO action postprocessing flags:
```bash
lerobot-eval \
--policy.path=your-org/your-evo1-libero-checkpoint \
--policy.vlm_model_name=OpenGVLab/InternVL3-1B-hf \
--policy.device=cuda \
--policy.use_flash_attn=true \
--policy.n_action_steps=14 \
--policy.postprocess_action_dim=7 \
--policy.binarize_gripper=true \
--env.type=libero \
--env.task=libero_object \
--env.camera_name_mapping="{agentview_image: agentview_image, robot0_eye_in_hand_image: robot0_eye_in_hand_image}" \
--env.observation_height=448 \
--env.observation_width=448 \
--eval.batch_size=1 \
--eval.n_episodes=1
```
## References
- [EVO1 repository](https://github.com/MINT-SJTU/Evo-1)
- [InternVL3-1B-hf](https://huggingface.co/OpenGVLab/InternVL3-1B-hf)
## License
This LeRobot integration follows the Apache 2.0 License used by LeRobot. Check the upstream EVO1 and InternVL3 model pages for the licenses of released checkpoints and data.
+160 -67
View File
@@ -1,16 +1,19 @@
# GR00T N1.5 Policy
# GR00T Policy
GR00T N1.5 is an open foundation model from NVIDIA designed for generalized humanoid robot reasoning and skills. It is a cross-embodiment model that accepts multimodal input, including language and images, to perform manipulation tasks in diverse environments.
GR00T is an NVIDIA foundation model family for generalized humanoid robot reasoning and skills. It is a cross-embodiment policy that accepts multimodal input, including language, images, and proprioception, to perform manipulation tasks in diverse environments.
This document outlines the specifics of its integration and usage within the LeRobot framework.
LeRobot integrates GR00T N1.7 through the `groot` policy type.
> [!WARNING]
> **Breaking change:** GR00T N1.5 support was removed from LeRobot, and current releases support GR00T N1.7 only. N1.5 checkpoints and configs are rejected with a migration note. To keep using an N1.5 checkpoint, pin the last release that supports it: `pip install 'lerobot==0.5.1'`. To use the current release, migrate to GR00T N1.7 (base model [`nvidia/GR00T-N1.7-3B`](https://huggingface.co/nvidia/GR00T-N1.7-3B)).
## Model Overview
NVIDIA Isaac GR00T N1.5 is an upgraded version of the GR00T N1 foundation model. It is built to improve generalization and language-following abilities for humanoid robots.
GR00T N1.7 uses a Cosmos-Reason2/Qwen3-VL backbone and provides checkpoints for SimplerEnv, DROID, and LIBERO.
Developers and researchers can post-train GR00T N1.5 with their own real or synthetic data to adapt it for specific humanoid robots or tasks.
Developers and researchers can post-train GR00T with their own real or synthetic data to adapt it for specific humanoid robots or tasks.
GR00T N1.5 (specifically the GR00T-N1.5-3B model) is built using pre-trained vision and language encoders. It utilizes a flow matching action transformer to model a chunk of actions, conditioned on vision, language, and proprioception.
GR00T uses pre-trained vision and language encoders with a flow matching action transformer to model a chunk of actions conditioned on vision, language, and proprioception.
<img
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/lerobot-groot-paper1%20(1).png"
@@ -28,33 +31,24 @@ This approach allows the model to be highly adaptable through post-training for
## Installation Requirements
As of today, GR00T N1.5 requires flash attention for it's internal working.
We are working on making this optional, but in the meantime that means that we require an extra installation step and it can only be used in CUDA enabled devices.
1. Following the Environment Setup of our [Installation Guide](./installation). **Attention** don't install `lerobot` in this step.
2. Install [Flash Attention](https://github.com/Dao-AILab/flash-attention) by running:
GR00T is intended for NVIDIA GPU-accelerated systems. Install LeRobot with the GR00T extra:
```bash
# Check https://pytorch.org/get-started/locally/ for your system
pip install "torch>=2.2.1,<2.8.0" "torchvision>=0.21.0,<0.23.0" # --index-url https://download.pytorch.org/whl/cu1XX
pip install ninja "packaging>=24.2,<26.0" # flash attention dependencies
pip install "flash-attn>=2.5.9,<3.0.0" --no-build-isolation
python -c "import flash_attn; print(f'Flash Attention {flash_attn.__version__} imported successfully')"
pip install "lerobot[groot]"
```
3. Install LeRobot by running:
For a source checkout:
```bash
pip install lerobot[groot]
pip install -e ".[groot]"
```
## Usage
To use GR00T in your LeRobot configuration, specify the policy type as:
To use GR00T N1.7:
```python
policy.type=groot
```bash
--policy.type=groot
```
## Training
@@ -63,72 +57,171 @@ policy.type=groot
Here's a complete training command for finetuning the base GR00T model on your own dataset:
This command is using the `new_embodiment` flag, which is used for the SO-101 robot, [read more about how GR00T handles different embodiments.](https://github.com/NVIDIA/Isaac-GR00T/blob/main/getting_started/policy.md#--embodiment-tag).
```bash
# Using a multi-GPU setup
accelerate launch \
--multi_gpu \
--num_processes=$NUM_GPUS \
$(which lerobot-train) \
--output_dir=$OUTPUT_DIR \
--save_checkpoint=true \
--batch_size=$BATCH_SIZE \
--steps=$NUM_STEPS \
--save_freq=$SAVE_FREQ \
--log_freq=$LOG_FREQ \
--policy.push_to_hub=true \
# install extra deps for training
pip install "lerobot[training]"
hf auth login
wandb login
export DATASET_NAME=your_data_set
export HF_USER=your_hf_username
export DATASET=$HF_USER/$DATASET_NAME
export REPO_ID="${DATASET}_GR00T17" #this is the model that will be uploaded to huggingface
export OUTPUT_DIR=outputs/train/$REPO_ID
lerobot-train \
--dataset.repo_id=$DATASET \
--dataset.image_transforms.enable=true \
--policy.type=groot \
--policy.device=cuda \
--policy.base_model_path=nvidia/GR00T-N1.7-3B \
--policy.embodiment_tag=new_embodiment \
--policy.chunk_size=16 \
--policy.n_action_steps=16 \
--policy.use_relative_actions=true \
--policy.relative_exclude_joints='["gripper"]' \
--policy.use_bf16=true \
--policy.push_to_hub=true \
--policy.repo_id=$REPO_ID \
--policy.tune_diffusion_model=false \
--dataset.repo_id=$DATASET_ID \
--seed=42 \
--batch_size=64 \
--steps=20000 \
--save_checkpoint=true \
--save_freq=5000 \
--use_policy_training_preset=true \
--env_eval_freq=0 \
--eval_steps=0 \
--log_freq=10 \
--output_dir=$OUTPUT_DIR \
--job_name=$DATASET \
--wandb.enable=true \
--wandb.disable_artifact=true \
--job_name=$JOB_NAME
--wandb.disable_artifact=true
```
## Performance Results
### Libero Benchmark Results
### LIBERO Benchmark Results
> [!NOTE]
> Follow our instructions for Libero usage: [Libero](./libero)
> Follow the [LIBERO](./libero) setup instructions before running `lerobot-eval`.
GR00T has demonstrated strong performance on the Libero benchmark suite. To compare and test its LeRobot implementation, we finetuned the GR00T N1.5 model for 30k steps on the Libero dataset and compared the results to the GR00T reference results.
GR00T N1.7 has demonstrated strong performance on the LIBERO benchmark suite. To reproduce LeRobot results, follow the instructions in the [LIBERO](./libero) section.
| Benchmark | LeRobot Implementation | GR00T Reference |
| ------------------ | ---------------------- | --------------- |
| **Libero Spatial** | 82.0% | 92.0% |
| **Libero Object** | 99.0% | 92.0% |
| **Libero Long** | 82.0% | 76.0% |
| **Average** | 87.0% | 87.0% |
### Train on LIBERO
These results demonstrate GR00T's strong generalization capabilities across diverse robotic manipulation tasks. To reproduce these results, you can follow the instructions in the [Libero](https://huggingface.co/docs/lerobot/libero) section.
Example training command for a LIBERO suite (here `libero_spatial`):
```bash
IMAGE_TRANSFORMS='{
"brightness": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"brightness": [0.7, 1.3]}},
"contrast": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"contrast": [0.6, 1.4]}},
"saturation": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"saturation": [0.5, 1.5]}},
"hue": {"weight": 1.0, "type": "ColorJitter", "kwargs": {"hue": [-0.08, 0.08]}}
}'
lerobot-train \
--dataset.repo_id=IPEC-COMMUNITY/libero_spatial_no_noops_1.0.0_lerobot \
--dataset.root=/datasets/libero_spatial \
--dataset.revision=main \
--dataset.video_backend=pyav \
--dataset.image_transforms.enable=true \
--dataset.image_transforms.max_num_transforms=4 \
--dataset.image_transforms.tfs="$IMAGE_TRANSFORMS" \
--policy.type=groot \
--policy.base_model_path=nvidia/GR00T-N1.7-3B \
--policy.embodiment_tag=libero_sim \
--policy.push_to_hub=false \
--policy.use_relative_actions=false \
--policy.max_steps=20000 \
--batch_size=320 \
--steps=20000 \
--save_freq=2000 \
--env_eval_freq=0 \
--eval_steps=0 \
--log_freq=10 \
--wandb.enable=true \
--wandb.project=lerobot \
--wandb.mode=online \
--wandb.disable_artifact=true \
--num_workers=4 \
--prefetch_factor=2 \
--persistent_workers=true \
--output_dir=$OUTPUT_DIR \
--job_name=$JOB_NAME
```
This will follow the recipe found [here](https://github.com/NVIDIA/Isaac-GR00T/blob/main/examples/LIBERO/README.md).
### GR00T N1.7 LIBERO Results
Preliminary LeRobot integration results (GR00T-LeRobot, `eval.n_episodes >= 50` per suite):
| Suite | Success rate | Checkpoint |
| ---------------- | -----------: | ------------------------------------------------------------------------------------------------------------- |
| LIBERO Spatial | 95% | [nvidia/gr00t17-lerobot-libero_spatial-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_spatial-640) |
| LIBERO Object | 100% | [nvidia/gr00t17-lerobot-libero_object-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_object-640) |
| LIBERO Goal | 98% | [nvidia/gr00t17-lerobot-libero_goal-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_goal-640) |
| LIBERO 10 (Long) | 93% | [nvidia/gr00t17-lerobot-libero_10-640](https://huggingface.co/nvidia/gr00t17-lerobot-libero_10-640) |
| **Average** | **96.5%** | |
```bash
export MODEL_ID=your_trained_model_on_huggingface
lerobot-eval \
--policy.type=groot \
--policy.base_model_path=$MODEL_ID \
--policy.embodiment_tag=libero_sim \
--env.type=libero \
--env.task=libero_spatial \
--eval.n_episodes=50
```
Use `eval.n_episodes >= 50` per suite when reporting success rates.
### Evaluate in your hardware setup
Once you have trained your model using your parameters you can run inference in your downstream task. Follow the instructions in [Policy Deployment (lerobot-rollout)](./inference). For example:
```bash
lerobot-rollout\
--strategy.type=sentry \
--strategy.upload_every_n_episodes=5 \
--robot.type=bi_so_follower \
--robot.left_arm_port=/dev/ttyACM1 \
--robot.right_arm_port=/dev/ttyACM0 \
--robot.id=bimanual_follower \
--robot.cameras='{ right: {"type": "opencv", "index_or_path": 0, "width": 640, "height": 480, "fps": 30},
left: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30},
top: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30},
}' \
# install extra deps for roullout and real hardware
pip install "lerobot[feetech,viz]"
export MODEL_ID=your_trained_model_on_huggingface
# make sure that camera index matches your setup!
# find index using `uv run lerobot-find-cameras opencv`
WRIST_CAM='wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30, fourcc: "MJPG"}'
FRONT_CAM='front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30, fourcc: "MJPG"}'
export ROBOT_CAMERAS="{ $WRIST_CAM, $FRONT_CAM }"
export ROBOT_ID=follower_robot
export ROBOT_PORT=/dev/ttyACM0
uv run lerobot-rollout \
--strategy.type=base \
--policy.path=$MODEL_ID \
--policy.base_model_path=nvidia/GR00T-N1.7-3B \
--policy.n_action_steps=8 \
--robot.type=so101_follower \
--robot.port=$ROBOT_PORT \
--robot.id=$ROBOT_ID \
--robot.cameras="$ROBOT_CAMERAS" \
--task="place the vial in the rack" \
--duration=60 \
--device=cuda \
--display_data=true \
--dataset.repo_id=<user>/eval_groot-bimanual \
--dataset.single_task="Grab and handover the red cube to the other arm" \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.rgb_encoder.vcodec=auto \
--policy.path=<user>/groot-bimanual \ # your trained model
--duration=600
--inference.type=rtc \
--inference.rtc.enabled=True \ # set to False if it causes inference instability
--inference.rtc.execution_horizon=8 \
--inference.queue_threshold=0
```
> [!NOTE]
> Value of `inference.queue_threshold` should not exceed 5 to ensure stable inference.
## License
This model follows NVIDIA's proprietary license, consistent with the original [GR00T repository](https://github.com/NVIDIA/Isaac-GR00T). Future versions (starting from N1.7) will follow **Apache 2.0 License**.
GR00T N1.7 is released under the [NVIDIA Open Model License Agreement](https://www.nvidia.com/en-us/agreements/enterprise-software/nvidia-open-model-license/).
+9 -9
View File
@@ -82,18 +82,18 @@ VRAM is the first filter. Within a tier, pick by budget and availability — the
### Hugging Face Jobs
[Hugging Face Jobs](https://huggingface.co/docs/hub/jobs) lets you run training on managed HF infrastructure, billed by the second. The repo publishes a ready-to-use image: **`huggingface/lerobot-gpu:latest`**, rebuilt **every night at 02:00 UTC from `main`** ([`docker_publish.yml`](https://github.com/huggingface/lerobot/blob/main/.github/workflows/docker_publish.yml)) — so it tracks the current state of the repo, not a tagged release.
[Hugging Face Jobs](https://huggingface.co/docs/hub/jobs) lets you run training on managed HF infrastructure, billed by the second, without owning a GPU. `lerobot-train` submits and streams the job for you — just add `--job.target=<flavor>` to a normal training command:
```bash
hf jobs run --flavor a10g-large huggingface/lerobot-gpu:latest \
bash -c "nvidia-smi && lerobot-train \
--policy.type=act --dataset.repo_id=<USER>/<DATASET> \
--policy.repo_id=<USER>/act_<task> --batch_size=8 --steps=50000"
lerobot-train \
--policy.type=act --dataset.repo_id=<USER>/<DATASET> \
--policy.repo_id=<USER>/act_<task> \
--job.target=a10g-large
```
Notes:
- The leading `nvidia-smi` is a quick sanity check that CUDA is visible inside the container — useful to fail fast if the flavor or driver mismatched.
- The default Job timeout is 30 minutes; pass `--timeout 4h` (or longer) for real training.
- `--flavor` maps onto the table above: `t4-small`/`t4-medium` (T4, ACT only), `l4x1`/`l4x4` (L4 24 GB), `a10g-small/large/largex2/largex4` (A10G 24 GB scaled out), `a100-large` (A100). For the current full catalogue + pricing see [https://huggingface.co/docs/hub/jobs](https://huggingface.co/docs/hub/jobs).
- Prefer not to write the `hf jobs run` wrapper yourself? `lerobot-train` can submit the job for you: just add `--job.target=<flavor>` to a normal training command and it handles dataset upload, log streaming, and the final model push. See the [imitation-learning training guide](./il_robots).
- Run `hf auth login` once before submitting, the job runs under your token.
- `--job.target` maps onto the table above: `t4-small`/`t4-medium` (T4, ACT only), `l4x1`/`l4x4` (L4 24 GB), `a10g-small/large/largex2/largex4` (A10G 24 GB scaled out), `a100-large` (A100). List the current catalogue with pricing via `hf jobs hardware`, or see [https://huggingface.co/docs/hub/jobs](https://huggingface.co/docs/hub/jobs).
- The job defaults to a `2d` (48h) timeout. Override it with `--job.timeout=4h` (or any other valid duration string) to shorten or extend the timeout. The job automatically stops when the command completes.
- For the full walkthrough — dataset upload, checkpoint streaming, resuming a run on a job — see the [imitation-learning training guide](./il_robots#train-using-hugging-face-jobs).
+6 -83
View File
@@ -126,7 +126,7 @@ import time
from lerobot.teleoperators.so_leader import SO101Leader, SO101LeaderConfig
from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
from lerobot.cameras.opencv import OpenCVCameraConfig
from lerobot.utils.visualization_utils import init_rerun, log_rerun_data, shutdown_rerun
from lerobot.utils.visualization_utils import init_visualization, log_visualization_data, shutdown_visualization
robot_config = SO101FollowerConfig(
port="/dev/tty.usbmodem5AB90687491",
@@ -142,7 +142,7 @@ teleop_config = SO101LeaderConfig(
id="my_leader_arm",
)
init_rerun(session_name="teleoperation")
init_visualization("rerun", session_name="teleoperation") # pass "foxglove" to stream to Foxglove instead
robot = SO101Follower(robot_config)
teleop_device = SO101Leader(teleop_config)
@@ -158,7 +158,7 @@ while True:
observation = robot.get_observation()
action = teleop_device.get_action()
robot.send_action(action)
log_rerun_data(observation=observation, action=action)
log_visualization_data("rerun", observation=observation, action=action)
elapsed_time = time.perf_counter() - start_time
sleep_time = TIME_PER_FRAME - elapsed_time
@@ -223,7 +223,7 @@ from lerobot.teleoperators.so_leader.config_so_leader import SO101LeaderConfig
from lerobot.teleoperators.so_leader.so_leader import SO101Leader
from lerobot.common.control_utils import init_keyboard_listener
from lerobot.utils.utils import log_say
from lerobot.utils.visualization_utils import init_rerun
from lerobot.utils.visualization_utils import init_visualization
from lerobot.scripts.lerobot_record import record_loop
from lerobot.processor import make_default_processors
@@ -270,7 +270,7 @@ def main():
# Initialize the keyboard listener and rerun visualization
_, events = init_keyboard_listener()
init_rerun(session_name="recording")
init_visualization("rerun", session_name="recording")
# Connect the robot and teleoperator
robot.connect()
@@ -532,84 +532,7 @@ If your local computer doesn't have a powerful GPU you could utilize Google Cola
Hugging Face jobs let's you easily select hardware and run the training in the cloud. So if you don't have a powerful GPU or you need more VRAM or just want to train a model much faster use HF Jobs! It's pay as you go and you simply pay for each second of use, you can see the pricing and additional information [here](https://huggingface.co/docs/hub/jobs).
> **Tip:** if you just want to launch a standard training run, you can skip building the command below and use the integrated **Train on HF Jobs via `--job.target`** flow described further down — `lerobot-train` then submits the job, uploads a local-only dataset for you, and streams the logs.
To run the training manually use this command:
<hfoptions id="train_with_hf_jobs">
<hfoption id="Command">
```bash
hf jobs run \
--flavor a10g-small \
--timeout 4h \
--secrets HF_TOKEN \
huggingface/lerobot-gpu:latest \
-- \
python -m lerobot.scripts.lerobot_train \
--dataset.repo_id=username/dataset \
--policy.type=act \
--steps=5000 \
--batch_size=16 \
--policy.device=cuda \
--policy.repo_id=username/your_policy \
--log_freq=100
```
</hfoption>
<hfoption id="API example">
<!-- prettier-ignore-start -->
```python
from huggingface_hub import run_job, get_token
run_name = "act_so101_hf_jobs"
dataset_id = "username/dataset"
user_hub_id = "username"
command_args = [
"python", "-m", "lerobot.scripts.lerobot_train",
"--dataset.repo_id", dataset_id,
"--policy.type", "act",
"--steps", "5000",
"--batch_size", "16",
"--num_workers", "4",
"--policy.device", "cuda",
"--log_freq", "100",
"--save_freq", "1000",
"--save_checkpoint", "true",
"--wandb.enable", "false",
"--policy.repo_id", f"{user_hub_id}/{run_name}"
]
print(f"Submitting job '{run_name}' to Hugging Face Infrastructure...")
job_info = run_job(
image="huggingface/lerobot-gpu:latest",
command=command_args,
flavor="a10g-small",
timeout="4h",
secrets={"HF_TOKEN": get_token()}
)
print("\n🚀 Job successfully launched!")
print(f"🔹 Job ID: {job_info.id}")
print(f"🔗 Live UI Dashboard & Logs: {job_info.url}")
```
<!-- prettier-ignore-end -->
</hfoption>
</hfoptions>
You can modify the `--flavor` to use different hardware, for example: `t4-small`, `a100-large`, `h200`. Use `hf jobs hardware` to see the full list with pricing.
Depending on the model you want to train and the hardware you selected you can also modify the `--batch_size` and `--number_of_workers`.
For longer training sessions increase the timeout.
Once the training is started you can go to [Jobs](https://huggingface.co/settings/jobs) and see if your jobs is running as well as all the outputs. Sometimes it takes a few minutes to schedule your job so be patient.
After training the model will be pushed to hub and you can use it as any other model with LeRobot.
#### Train on HF Jobs via `--job.target` (integrated CLI)
`lerobot-train` runs locally by default. To run on a HuggingFace GPU without constructing the Docker command yourself, pass `--job.target` with a hardware flavor name:
`lerobot-train` runs locally by default. To run on a HuggingFace GPU, pass `--job.target` with a hardware flavor name:
```bash
lerobot-train \
+397
View File
@@ -0,0 +1,397 @@
# Isaac Teleop
Control your robot with NVIDIA [Isaac Teleop](https://github.com/NVIDIA/IsaacTeleop), a
multi-modal teleoperation framework. Isaac Teleop drives a single `TeleopSession` from a range
of input devices — XR (VR) controllers, hand tracking, full-body tracking, Manus gloves, foot
pedals, and more.
In LeRobot, Isaac Teleop ships as a self-contained example under
[`examples/isaac_teleop_to_so101/`](https://github.com/huggingface/lerobot/tree/main/examples/isaac_teleop_to_so101).
Each Isaac Teleop input device is its own `Teleoperator` subclass in the example's
`isaac_teleop` package, sharing one session lifecycle (see `IsaacTeleopTeleoperator`). The
devices available today are the **XR controller** (`XRController`) and a back-drivable
**SO-101 leader arm** (`SO101LeaderArm`); Manus gloves and hand/full-body tracking are the
natural next devices. This guide focuses on the XR controller; the SO-101 leader is summarized
under [Run the example](#step-3-run-the-example).
**In this guide you'll learn:**
- How an Isaac Teleop device drives a robot endeffector (EE) target
- How the _clutch_ (squeeze/grip on the XR controller) engages teleoperation without jerking the arm
- How to run the SO101 teleoperation example and tune motion / gripper / IK
## Installation
The example lives in the LeRobot repository (it is not part of the `lerobot` pip package), so
clone the repo and install from source. The canonical, always-up-to-date install and usage
reference is the example's
[`README.md`](https://github.com/huggingface/lerobot/tree/main/examples/isaac_teleop_to_so101/README.md);
in short:
```bash
git clone https://github.com/huggingface/lerobot.git
cd lerobot
uv pip install -e ".[feetech,kinematics,dataset]" "huggingface_hub>=1.5"
uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131" "scipy>=1.14"
```
`isaacteleop` is published on public PyPI (Linux only). The `cloudxr` extra brings the CloudXR
runtime bindings; `retargeters-lite` is the scipy-based retargeter path that resolves on both
x86_64 and ARM (on aarch64 — e.g. a DGX Spark — the full `retargeters` extra does not resolve
because of its `dex-retargeting`/`nlopt` pins, which is why it is not the default here). On
x86_64 you can additionally install the full retargeter stack:
```bash
uv pip install "isaacteleop[retargeters]~=1.3.131"
```
### Set up CloudXR and connect a headset
Isaac Teleop streams the headset to your machine over **NVIDIA CloudXR**, which provides the
OpenXR runtime the session connects to. By default LeTeleop **auto-launches the CloudXR runtime
for you** when you call `teleop_device.connect()` — you no longer have to run `python -m
isaacteleop.cloudxr` and `source cloudxr.env` in a separate shell. All you need is a supported
headset connected and the CloudXR firewall ports open. Follow the Isaac Teleop
[Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html) for the
headset-pairing and firewall details.
**First run (EULA).** The very first launch must accept the NVIDIA CloudXR EULA. The auto-launch
prompts for it **on stdin**, so on a headless machine it will hang waiting for input. Bootstrap
the EULA once, interactively, with:
```bash
python -m isaacteleop.cloudxr --accept-eula # one-time: accept the CloudXR EULA
```
After that, `connect()` launches the runtime non-interactively. The launch **blocks for ~30s**
while the runtime comes up.
**Configuration.** Two fields on `IsaacTeleopConfig` (shared by every device) control this:
- `auto_launch_cloudxr` (default `True`) — whether `connect()` starts the runtime. Set `False`
when CloudXR is already running externally.
- `cloudxr_env_file` (default `None`) — an optional CloudXR device-profile `.env` selecting the
headset transport (e.g. an Apple Vision Pro profile). This is launcher **input**; it is not the
`~/.cloudxr/run/cloudxr.env` **output** file the old manual flow told you to `source`. `None`
keeps the default auto-WebRTC profile — though the SO-101 example overrides it to the
`default.env` shipped next to `teleoperate.py` unless you pass `--teleop.cloudxr_env_file`.
**Opting out.** To skip the auto-launch (CloudXR already running), either set
`auto_launch_cloudxr=False` or export:
```bash
export LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1
```
The **env var takes precedence over the config field**: if `LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1` is
set, the auto-launch is skipped even when `auto_launch_cloudxr=True`. This variable is
**independent** of Isaac Lab's `ISAACLAB_CXR_SKIP_AUTOLAUNCH` — setting one does not affect the
other.
**One teleoperator per process.** The CloudXR runtime configures the environment process-wide (a
singleton), so run a single Isaac Teleop teleoperator per process.
**Shutting down.** Always call `teleop_device.disconnect()` on exit — including on Ctrl-C. Wrap
your teleoperation loop in `try/finally` and call `disconnect()` in the `finally`. This tears down
the OpenXR session **before** the CloudXR runtime, which is the required order; the launcher's
`atexit` hook only reaps the runtime and does not run the session's `__exit__`, so without an
explicit `disconnect()` an interrupted run shuts down in the wrong order.
```python
teleop_device.connect()
try:
while True:
action = teleop_device.get_action()
# ... drive the robot ...
finally:
teleop_device.disconnect()
```
See [System Requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html)
for supported OS / GPU / CloudXR versions and headsets.
## How it works
The XR controller is one Isaac Teleop **input** device. `XRController` is a deliberately thin
reader: it exposes the **raw** controller grip pose — already statically rebased into the robot
base frame — plus the squeeze and trigger analog values. It has **no** retargeters and **no**
clutch logic of its own. The clutch (engage latch + delta rebasing onto the EE) and the gripper
mapping live downstream in the example loop, which then feeds LeRobot's existing closedloop
Cartesian IK pipeline — the same one the phone teleoperator uses. The devicespecific pieces are
`XRController`, the loop's `Clutch`, and `MapXRControllerActionToRobotAction`; everything downstream
(`EEBoundsAndSafety`, `InverseKinematicsEEToJoints`) is shared, and a future device (e.g. Manus
gloves) would swap in its own `teleop_<device>.py` + processor while reusing the rest.
`XRController._build_pipeline` wires Isaac Teleop's `ControllersSource` — statically rebased into
the robot base frame by the native `ControllerTransform` (`base_T_anchor`) — and exposes the
transformed controller stream verbatim. `get_action()` reads the grip pose, squeeze, and trigger
straight off it; the session is always stepped `RUNNING` (there is no clutch retargeter to gate).
The `Clutch` class (in `examples/isaac_teleop_to_so101/isaac_teleop/clutch.py`, driven by the
loop in `common.py`) mirrors Isaac Teleop's `SO101ClutchRetargeter`, but lives in-loop so the
device can stay a thin reader:
- It latches its engage origin on the squeeze **engage edge** (the frame the squeeze first crosses
`clutch_threshold`) and rebases both position and orientation around it, so engaging does not
teleport the arm. `Clutch.rebase` returns the absolute base-frame target as a `(pos, quat)`
pair, which the loop concatenates into the 7D `ee_pose` fed to the processor.
- The analog trigger becomes a gripper `closedness` in `[0, 1]` (0 = open, 1 = closed),
proportional to the trigger pull, which `MapXRControllerActionToRobotAction` maps to a jaw target.
See the Isaac Teleop
[Retargeting interface](https://nvidia.github.io/IsaacTeleop/main/references/retargeting/index.html)
and [architecture overview](https://nvidia.github.io/IsaacTeleop/main/overview/architecture.html)
for how source nodes and retargeters compose.
```text
VR controller (OpenXR)
XRController.get_action() ── raw base-frame grip_pos / grip_quat + squeeze + trigger
│ (TeleopSession always stepped RUNNING; clutch lives downstream)
Clutch.rebase(grip_pos, grip_quat) ── engage-relative delta applied to the EE home (pos + orient)
│ ee_pose (7) / closedness → absolute ee_pose; closedness = trigger
MapXRControllerActionToRobotAction ── absolute ee.x/y/z; ee.w* = orientation rotvec target;
│ ee.x/y/z / ee.w* / ee.gripper_pos ee.gripper_pos = (1 - closedness) * 100
EEBoundsAndSafety ── workspace clip + per-frame step clamp (clamp+warn)
InverseKinematicsEEToJoints ── closed-loop Placo IK; position + soft-orientation
│ (orientation_weight=0.01) (passes ee.gripper_pos → gripper.pos)
SO-101 follower joint targets
```
### The clutch: owned by the example loop
Unlike the phone pipeline (which splits the clutch across `MapPhoneActionToRobotAction` and
`EEReferenceAndDelta`), the XR clutch lives entirely in the example loop's `Clutch` class. It emits
an **absolute** EE pose, so there is no `EEReferenceAndDelta` stage and no delta accumulation in the
processor — `MapXRControllerActionToRobotAction` is a pure, stateless perframe mapping.
The clutch latches its engage origin on the squeeze **engage edge** (the moment the squeeze crosses
`clutch_threshold`) and drives the EE from the motion _relative_ to that origin, so the arm does not
teleport on engage. On **every** engage — startup and midtask reclutch alike — the home
_position_ is latched from forward kinematics on the arm's **measured joints**, so the home equals
where the arm physically is even if it moved while disengaged, and the engage is jumpfree. The
home _orientation_ keeps the last commanded rotation: the 5DOF arm tracks orientation only
softly, so latching the measured wrist orientation would inject its tracking offset into the
command on every reclutch.
## Controls
- **Squeeze / grip** — the **clutch** (deadman). Hold it past `clutch_threshold` to engage
teleoperation; release to pause. Each engage recaptures the origin, so you can reposition
your hand while paused and reengage without the arm jumping (index/clutch style).
- **Trigger** — the **gripper**, controlled **analog**. The jaw tracks the trigger
proportionally — a halfpressed trigger leaves the jaw halfclosed — via a closedness in
`[0, 1]` (0 = open, 1 = closed) that maps to an absolute gripper joint target.
- **Controller orientation** — the **wrist**. The clutch rebases the controller orientation
(engagerelative, baseframe) into a soft IK orientation target the wrist tracks alongside
position. On the 5DOF SO101 the wrist follows the hand only partially by design — see
`orientation_weight` below.
## Get started
### Step 1: Create the teleoperator
```python
# Run from the repo root so the `examples` package is importable.
from examples.isaac_teleop_to_so101.isaac_teleop import XRController, XRControllerConfig
teleop_config = XRControllerConfig(
hand_side="right", # "left" or "right" controller
clutch_threshold=0.5, # squeeze value above which the clutch engages
)
teleop_device = XRController(teleop_config)
```
`XRController.get_action()` returns the **raw** baseframe controller pose, not a clutchrebased
target: `grip_pos` (3,) `[x, y, z]` [m] and `grip_quat` (4,) `[qx, qy, qz, qw]` in the robot base
frame, plus scalar `squeeze` and `trigger` analog values in `[0, 1]`. The example loop's `Clutch`
turns these into the absolute `ee_pose`, and the squeeze is thresholded by the loop against
`clutch_threshold` to engage.
### Step 2: Connect
Calling `teleop_device.connect()` first auto-launches the CloudXR runtime (unless you opted out —
see [Set up CloudXR and connect a headset](#set-up-cloudxr-and-connect-a-headset); this blocks for
~30s and on the first run prompts for the EULA on stdin), then starts the Isaac Teleop
[`TeleopSession`](https://nvidia.github.io/IsaacTeleop/main/getting_started/teleop_session.html)
(opens the OpenXR session and discovers the controllers). XR controllers are selfcalibrating, so
there is no manual calibration step — the clutch handles recentering each time you engage. Pair
`connect()` with a `try/finally` that calls `disconnect()` so the session tears down before the
runtime on exit/Ctrl-C.
### Step 3: Run the example
The example assumes you configured your robot (SO101 follower) and set the correct serial port.
The **robot URDF and its meshes are fetched automatically** on first run: the XR device downloads
the SO-101 URDF from the
[`lerobot/robot-urdfs` Hugging Face bucket](https://huggingface.co/buckets/lerobot/robot-urdfs/tree/so101)
into the LeRobot cache (`HF_LEROBOT_HOME/robot-urdfs/so101/`) and reuses it after, so there is no
separate download step :
```bash
python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 \
--robot.id=so101_follower_arm --teleop.type=xr_controller
```
The CLI is `lerobot-teleoperate`-style (draccus): `--robot.*` configures the SO-101 follower and
`--teleop.type` selects the Isaac input device (`xr_controller` | `so101_leader`), with
`--teleop.*` its device knobs. `--teleop.type=xr_controller` runs the XR-controller path described
above. The startup safety contract: by default it slews all joints to a default reset pose over
`--reset_duration` seconds (`--reset_to_origin=false` keeps the arm where it is), then seeds the
clutch home from the arm's measured pose so the first engage is jump-free; the follower is
commanded only while the clutch is engaged.
**Customizing the reset pose.** The reset pose ships as a built-in default (a comfortable mid-range
pose) and works out of the box — you do **not** need to record anything. To tailor it to your setup,
back-drive the arm to the pose you want and run
`python -m examples.isaac_teleop_to_so101.override_reset_pose --id <robot.id>`; it writes the
current joints to a per-arm file in the LeRobot cache
(`HF_LEROBOT_HOME/reset_poses/<robot.name>/<robot.id>.json`, keyed like calibration), which then takes
priority over the built-in default on the next run. Because it lives in the user-local cache (not
the repo), your override stays on your machine, and both `teleoperate` and `record` honor it
when launched with the same `--robot.id`.
The other device, `--teleop.type=so101_leader`, mirrors the follower 1:1 from a back-drivable
SO-101 _leader arm_ whose joints are streamed by Isaac Teleop's native `so101_leader` plugin (no
clutch, no IK — the leader and follower share the SO-101 kinematics).
The `so101_leader_plugin` binary is a C++ plugin that is **not** part of the `isaacteleop` pip
package — you build it from the Isaac Teleop source tree. Follow
[Build Isaac Teleop from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html)
(in short, from your Isaac Teleop checkout: `cmake -B build && cmake --build build --parallel &&
cmake --install build`); the build installs the plugins under `<IsaacTeleop>/install/plugins/`, so
the binary lands at `install/plugins/so101_leader/so101_leader_plugin` — the `--launch_plugin` path
below. See the plugin's own `README.md` (next to the binary) for its serial/calibration details.
Point `--teleop.port` at the physical leader's serial port and `--launch_plugin` at that plugin
binary to have the script spawn it after CloudXR is up:
```bash
python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 \
--robot.id=so101_follower_arm --teleop.type=so101_leader \
--teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \
--launch_plugin=/code/Teleop/install/plugins/so101_leader/so101_leader_plugin
```
(Note `so101_leader` here is the _Isaac_ leader, resolved against the Isaac Teleop device
registry, distinct from `lerobot-teleoperate`'s serial `so101_leader`.) When a `--teleop.port` is
set, the plugin's tick→radian calibration is inferred from `--teleop.id` and passed to the plugin
as its third positional arg — the LeRobot-format JSON at
`HF_LEROBOT_CALIBRATION/teleoperators/so_leader/<id>.json`, the same file the serial SO-101 leader
uses (`lerobot-calibrate --teleop.type=so101_leader --teleop.id=<id>`). If it is missing the script
warns and the plugin uses built-in defaults. Run `python -m examples.isaac_teleop_to_so101.teleoperate --help` for all flags. Its
startup safety contract: by default the follower is
slewed to the leader's first reading over `--align_duration` seconds (`--align=false` to skip) so
the arm does not snap when the mirror begins, and while the leader stream is stale the follower is
held at its measured pose.
The URDF fetch uses `huggingface_hub` (already a LeRobot dependency) against the public
`lerobot/robot-urdfs` bucket, so it needs no login. It is cached under
`HF_LEROBOT_HOME/robot-urdfs/so101/`; delete that folder to force a redownload.
Then, in your headset: squeeze and hold the grip to engage, move the controller to drive the
arm, twist/tilt it to orient the wrist, and press the trigger to close the gripper
(proportionally — release to open).
To record a dataset (not just teleoperate), use `record.py` in the same folder. It dispatches on
`--teleop.type` (`xr_controller` | `so101_leader`) exactly like `teleoperate.py`, so either device
can drive the follower, and it saves the commanded joints to a LeRobot dataset (`lerobot-record`-style
`--dataset.*` flags). See its module docstring for the full CLI and the keyboard recording shortcuts.
## Important pipeline steps and options
The clutch already produces an absolute baseframe pose, so the processor side is a thin
**absolutepose** path — there is no frame remap, no delta accumulation, and no
`EEReferenceAndDelta` stage.
- `MapXRControllerActionToRobotAction` is a stateless perframe mapping from the device output to
the IK input contract. It writes the absolute baseframe position, encodes the absolute
orientation as a rotvec target, and inverts the closedness into a motor gripper target:
```python
action["ee.x"], action["ee.y"], action["ee.z"] = ee_pose[:3] # absolute, base frame [m]
action["ee.wx"], action["ee.wy"], action["ee.wz"] = orient_rotvec # orientation target (rotvec)
action["ee.gripper_pos"] = (1 - closedness) * 100 # motor units; SO-101 calibrates 100 = open
```
The gripper polarity (`100 = open, 0 = closed`) is a hardwarecalibration convention in the source — flip it there if the jaw opens when it should close.
- `EEBoundsAndSafety` clamps the EE to a workspace and ratelimits perframe jumps. The clutch's
noteleport keeps frames small, so `max_ee_step_m` mostly catches transient controller tracking
glitches. The z floor is `0.0` (the table plane) so a stray target cannot drive the EE below the
table; x/y stay at the loose `[-1, 1]` m box. Set `raise_on_jump=False` so an overlimit frame is
**clamped and warned** instead of raising — a crash midloop would leave the arm uncontrolled:
```python
EEBoundsAndSafety(
end_effector_bounds={"min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 1.0]},
max_ee_step_m=0.10,
raise_on_jump=False,
)
```
- `InverseKinematicsEEToJoints(initial_guess_current_joints=False, orientation_weight=0.01)` solves
closedloop Placo IK. SO101 is a 5DOF arm, so the IK is positiondominant; the small
`orientation_weight` lets it softly track the orientation target carried in `ee.w*` so the wrist
follows the hand, while the underdetermined roll stays partial by design. There is **no**
`GripperVelocityToJoint`: the absolute `ee.gripper_pos` is passed straight to `gripper.pos`.
`initial_guess_current_joints=False` warmstarts each solve from the **previous IK solution**
rather than reseeding from the measured joints, so the joint trajectory stays continuous
frametoframe. Tune `orientation_weight` on hardware — too high fights position tracking, too
low ignores the orientation command.
The example also gates safety at the loop level: after the startup reset slew (on by default —
pass `--reset_to_origin=false` to keep the arm where it is), it commands the robot **only while
the clutch is engaged**, and resends the measured joints while disengaged, so releasing the
clutch freezes the arm in place.
See the [Processors for Robots and Teleoperators](./processors_robots_teleop) guide for more on
adapting the pipeline to other robots.
## Troubleshooting
- **`ModuleNotFoundError: isaacteleop`** — the `isaacteleop` package is not installed in the
active environment. Re-run the install command at the top of this guide:
`uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131"`.
- **No controllers found** — make sure the CloudXR runtime is running, the firewall ports are
whitelisted, and the headset is connected (see
[Set up CloudXR and connect a headset](#set-up-cloudxr-and-connect-a-headset) and the Isaac
Teleop [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html)).
- **CloudXR auto-launch failed** — `connect()` raises a `RuntimeError` if the runtime does not
come up within its startup timeout. Check the launcher logs under `~/.cloudxr/logs`. Common
causes: the EULA was never accepted (run `python -m isaacteleop.cloudxr --accept-eula` once,
interactively — the auto-launch prompts on stdin and hangs headless), or the runtime is already
running externally (set `LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1` or `auto_launch_cloudxr=False` to
skip the auto-launch).
- **Arm does not move** — the clutch is a deadman: you must hold the squeeze/grip past
`clutch_threshold`. Lower the threshold if your controller's squeeze is reported softly.
- **Motion feels misaligned** — confirm the headset/play space orientation. The controller stream
is rebased into the robot base frame by the `base_T_anchor` transform on `XRControllerConfig`
(default: standard OpenXR → robot axis convention); adjust it if your anchor frame differs.
## Learn more
NVIDIA Isaac Teleop documentation ([docs home](https://nvidia.github.io/IsaacTeleop/),
[GitHub](https://github.com/NVIDIA/IsaacTeleop)):
- [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html) —
install, run the CloudXR server, connect a headset, run a teleop example.
- [TeleopSession](https://nvidia.github.io/IsaacTeleop/main/getting_started/teleop_session.html) —
the session API `XRController` wraps.
- [Retargeting interface](https://nvidia.github.io/IsaacTeleop/main/references/retargeting/index.html)
and [architecture overview](https://nvidia.github.io/IsaacTeleop/main/overview/architecture.html) —
how source nodes and retargeters compose into a pipeline.
- [Build from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html) —
build `isaacteleop` (and its C++ plugins, including the `so101_leader` plugin used above) from a
local checkout.
- [System Requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html) and
the [CloudXR SDK docs](https://docs.nvidia.com/cloudxr-sdk) — supported platforms, GPUs,
CloudXR/OpenXR runtime versions, and headsets.
+187
View File
@@ -0,0 +1,187 @@
# LingBot-VA
LingBot-VA is an **autoregressive video-action world-model policy** built on the **Wan2.2**
video-diffusion stack. It interleaves, in one autoregressive sequence, the prediction of
future **video latents** and **robot actions** ("VA" = Video-Action). The LeRobot
integration wires LingBot-VA into the standard training, evaluation and processor
interfaces.
## Model Overview
LingBot-VA is a **dual-stream "mixture-of-transformers"**: a video/latent stream
(`patch_embedding_mlp → blocks → proj_out`) and an action stream
(`action_embedder → blocks → action_proj_out`) share the same 30 transformer blocks and
text conditioning.
| Component | Class | Role |
| ------------------------ | ----------------------- | ----------------------------------------------------------- |
| DiT backbone (trainable) | `WanTransformer3DModel` | ~5B-param dual-stream transformer. |
| VAE (frozen) | `AutoencoderKLWan` | Wan2.2 VAE, `z_dim=48`. Lazy-pulled from the source repo. |
| Text encoder (frozen) | `UMT5EncoderModel` | UMT5-XXL, `d_model=4096`. Lazy-pulled from the source repo. |
At inference the policy runs an autoregressive loop per chunk: it denoises the video-latent
stream (CFG, ~20 steps) and the action stream (~50 steps) with two independent
flow-matching schedulers, maintaining a KV cache across chunks. Real observed keyframes are
fed back into the KV cache as the chunk is executed (closed-loop world modeling).
### What the LeRobot Integration Covers
- Standard `policy.type=lingbot_va` configuration through LeRobot.
- Ready-to-use LeRobot-format checkpoints on the Hub (converted from the released upstream ones).
- Autoregressive dual-stream inference behind the standard `select_action` interface
(single-environment eval, `--eval.batch_size=1`).
- Opt-in saving of the policy's **predicted (imagined) videos** during eval / training.
- Evaluation with `lerobot-eval` on LIBERO and RoboTwin.
- Training / fine-tuning via the dual-stream flow-matching loss (`policy.forward`), see below.
## Installation
1. Install LeRobot by following the [Installation Guide](./installation).
2. Install the LingBot-VA extra:
```bash
pip install -e ".[lingbot_va]"
```
## Checkpoints
The released upstream checkpoints have been converted to LeRobot format and pushed to the Hub:
| Variant | LeRobot checkpoint |
| ---------------------- | -------------------------------- |
| LIBERO-Long post-train | `lerobot/lingbot_va_libero_long` |
| RoboTwin post-train | `lerobot/lingbot_va_robotwin` |
| Pretrained base | `lerobot/lingbot_va_base` |
Only the trainable ~5B transformer is stored in the LeRobot
`model.safetensors`. The frozen VAE + UMT5 + tokenizer (~20 GB) are pulled from
`config.wan_pretrained_path` at load time (defaults to the source `robbyant/*` repo). The
UMT5-XXL text encoder runs on CPU by default (`config.text_encoder_device`) so the 5B
transformer + VAE fit on a single 2432 GB GPU.
## Evaluation (LIBERO)
```bash
lerobot-eval \
--policy.path=lerobot/lingbot_va_libero_long \
--policy.device=cuda \
--env.type=libero --env.task=libero_10 \
--env.observation_height=128 --env.observation_width=128 \
--eval.n_episodes=50 --eval.batch_size=1 \
--output_dir=outputs/eval/lingbot_va_libero
```
LingBot-VA's streaming inference (KV cache + observed-keyframe feedback) is implemented for
single-environment eval; use `--eval.batch_size=1`.
## Evaluation (RoboTwin)
RoboTwin 2.0 needs the SAPIEN + CuRobo simulator stack. You can use the benchmark Docker image
(`docker/Dockerfile.benchmark.robotwin`, which also needs `warp-lang==1.3.1` and CuRobo built
with the GPU's compute capability in `TORCH_CUDA_ARCH_LIST`). RoboTwin uses **end-effector-pose
control**, so run with `--env.action_mode=ee`: the policy predicts per-arm `xyz+quaternion+gripper`
deltas (`robotwin_tshape` latent layout) that are composed onto the episode's initial eef pose and
executed via CuRobo IK.
```bash
lerobot-eval \
--policy.path=lerobot/lingbot_va_robotwin \
--policy.device=cuda \
--env.type=robotwin --env.task=beat_block_hammer --env.action_mode=ee \
--eval.n_episodes=10 --eval.batch_size=1 \
--output_dir=outputs/eval/lingbot_va_robotwin
```
### Saving predicted (imagined) videos
Set `--policy.save_predicted_video=true` to additionally VAE-decode the predicted video
latents and write `pred_episode_*.mp4` next to the env-rendered `eval_episode_*.mp4` videos.
The same flag works for the periodic eval during `lerobot-train`.
## Training / fine-tuning
`LingBotVAPolicy.forward(batch)` implements the dual-stream **flow-matching** loss
(`latent_loss + action_loss`, timestep-weighted, action-masked) from the paper: it VAE-encodes
the camera clips into video latents, UMT5-encodes the task, noises both streams, runs the
transformer's block-causal training pass and returns `(loss, metrics)`. Optimizer preset is AdamW
with a linear-warmup-then-constant schedule (matching upstream).
Requirements:
- The block-causal masks use PyTorch **flex-attention**, so build the policy with
`--policy.attn_mode=flex` for training (the default `torch` SDPA is inference-only).
- The full 5B DiT does not fit a single 2432 GB GPU under AdamW; fine-tune with **LoRA**
(`--policy.use_peft=true`) and/or optimizer offload. `get_optim_params` returns only the
trainable (e.g. adapter) parameters; the VAE + UMT5 text encoder stay frozen.
```bash
lerobot-train \
--policy.path=lerobot/lingbot_va_libero_long --policy.attn_mode=flex \
--policy.use_peft=true \
--dataset.repo_id=<your LeRobot-format dataset> \
--batch_size=1 --steps=... --output_dir=outputs/train/lingbot_va
```
The dataset must provide camera clips (a temporal window per camera, VAE-encoded to
`frame_chunk_size` latent frames) and `frame_chunk_size * action_per_frame` action steps per item.
## Data format (action channels & camera order)
LingBot-VA is an **end-effector (Cartesian) pose** policy, it predicts EEF poses + gripper, not
joint positions. Actions live in a fixed multi-embodiment **30-dim** layout; map your robot's
action dimensions into these channels and pad the rest with `0` (`used_action_channel_ids` selects
the channels a given checkpoint actually uses):
| channels | meaning |
| -------- | ----------------------------------------------------- |
| 06 | Left-arm end-effector pose |
| 713 | Right-arm end-effector pose |
| 1420 | Left-arm joints (unused by the released checkpoints) |
| 2127 | Right-arm joints (unused by the released checkpoints) |
| 28 | Left gripper |
| 29 | Right gripper |
- **LIBERO** uses channels `06`: a 6-DoF EEF delta (xyz + rotation) + gripper (single arm).
- **RoboTwin** uses channels `[06, 28, 713, 29]`: left EEF (xyz + quaternion) + left gripper +
right EEF + right gripper (16 dims). The env converts these poses to joint trajectories via
CuRobo IK — joints are never predicted.
Joint-space datasets (or a different EEF convention) must be remapped into this schema before
fine-tuning these checkpoints.
**Camera order is fixed and order-sensitive**, per-camera latents are concatenated spatially in
`obs_cam_keys` order, so the physical camera→slot mapping must match training:
| benchmark | `obs_cam_keys` (in order) | `camera_layout` |
| --------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| LIBERO | `observation.images.image` (agentview / 3rd-person), `observation.images.image2` (eye-in-hand wrist) | `width_concat` (latents concatenated on width) |
| RoboTwin | `observation.images.head_camera`, `observation.images.left_camera`, `observation.images.right_camera` | `robotwin_tshape` (full-res head below, two half-res wrists on top) |
The first camera is the exterior/head view and the rest are wrist views.
## Inference Hyperparameters (LIBERO)
| Key | Value |
| -------------------------------------- | --------------------------------------------------------------------------------- |
| height × width | 128 × 128 |
| cameras | `observation.images.image` (agentview), `observation.images.image2` (eye-in-hand) |
| action channels used | 06 (7-DoF arm + gripper) |
| action_per_frame / frame_chunk_size | 4 / 4 |
| attn_window | 30 |
| video / action denoising steps | 20 / 50 |
| guidance_scale / action_guidance_scale | 5 / 1 |
| snr_shift / action_snr_shift | 5.0 / 0.05 |
These are the defaults of `LingBotVAConfig`; override any of them via `--policy.<name>=...`.
## Notes
- **Attention backend:** inference uses the `torch` SDPA backend (always available). The
`flashattn` and `flex` backends are optional; `flex` is only needed for training.
- **Model size:** the DiT is ~5B params and the frozen VAE+UMT5 add ~20 GB; inference needs
roughly 1824 GB of VRAM.
## License
LingBot-VA is released under Apache-2.0. See the
[upstream repository](https://github.com/Robbyant/lingbot-va).
+18
View File
@@ -0,0 +1,18 @@
# EVO1
EVO1 is a Vision-Language-Action policy for robot control. The LeRobot
integration uses an InternVL3 vision-language backbone with a flow-matching
action head, and supports staged training through the standard LeRobot policy
APIs.
The upstream EVO1 project is available at
[MINT-SJTU/Evo-1](https://github.com/MINT-SJTU/Evo-1).
```bibtex
@misc{evo1,
title = {EVO1},
author = {{MINT-SJTU}},
year = {2025},
howpublished = {\url{https://github.com/MINT-SJTU/Evo-1}},
}
```
+113 -2
View File
@@ -1,6 +1,13 @@
## Research Paper
Paper: https://research.nvidia.com/labs/gear/gr00t-n1_5/
GR00T N1 technical report (covers the GR00T N1.x family, including N1.7): https://arxiv.org/abs/2503.14734
GR00T N1.7 model card: https://huggingface.co/nvidia/GR00T-N1.7-3B
GR00T N1.5 research page (earlier version): https://research.nvidia.com/labs/gear/gr00t-n1_5/
> GR00T N1.5 support was removed from LeRobot; the last release supporting it is `lerobot==0.5.1`.
> Current releases support GR00T N1.7 only.
## Repository
@@ -24,4 +31,108 @@ Code: https://github.com/NVIDIA/Isaac-GR00T
Blog: https://developer.nvidia.com/isaac/gr00t
Hugging Face Model: https://huggingface.co/nvidia/GR00T-N1.5-3B
Hugging Face Models:
- GR00T N1.7: https://huggingface.co/nvidia/GR00T-N1.7-3B
- GR00T N1.7 LIBERO checkpoints: https://huggingface.co/nvidia/GR00T-N1.7-LIBERO
<details>
<summary><b>Original-vs-LeRobot parity test</b></summary>
## Original-vs-LeRobot parity test
`tests/policies/groot/test_groot_vs_original.py` verifies this LeRobot
reimplementation of GR00T N1.7 (Qwen3-VL backbone + flow-matching action head)
against NVIDIA's original `gr00t` package with two comparisons, each parametrized
over every embodiment tag present in the checkpoint:
1. **Model parity** — given byte-identical pre-processed inputs and the same
flow-matching seed (recorded in each artifact), both implementations must produce
the **same raw model output** (`get_action(...)["action_pred"]`, the normalized
flow-matching prediction). Output shapes must match exactly; any action-horizon
or action-dim mismatch fails the test.
2. **Preprocessor parity** — given the identical raw observations (per-camera
frames, state vectors, language instruction), LeRobot's own preprocessor pipeline
(real Qwen3-VL chat template / tokenizer / image packing + checkpoint-driven
state normalization, no mocks) must produce the **same collated model inputs**
(`input_ids`, `attention_mask`, `pixel_values`, `image_grid_thw`, `state`,
`embodiment_id`) as the original package's processor.
### Why two environments
The original `gr00t` package pins `transformers==4.57.3` (Python 3.10); this
integration requires `transformers>=5.x` (Qwen3-VL). Under 5.x, `PretrainedConfig`
is itself a defaulted dataclass, so the original config dataclasses fail to import
(`non-default argument follows default argument`). The two implementations therefore
**cannot be imported in the same Python process**.
So the test uses a **producer / consumer** split across two venvs:
1. **Producer**`tests/policies/groot/utils/dump_original_n1_7.py`, run in the _original_
gr00t venv. For each embodiment it builds dummy inputs generically from the
checkpoint metadata (state dims from `statistics.json`; camera/language keys from
the processor modality configs), runs the original model, and saves to one `.npz`
per tag: the raw observations (`raw::` keys), the exact collated inputs
(`in::` keys), the seed, and the raw `action_pred`.
2. **Consumer** — the pytest above, run in the _LeRobot_ venv. It discovers every
`.npz`; the model-parity case replays the byte-identical collated inputs through
the LeRobot model with the recorded seed and asserts the outputs match, and the
preprocessor-parity case replays the raw observations through LeRobot's full
preprocessor pipeline and asserts the collated tensors match.
> Artifacts generated by older versions of the dump script contain no `raw::`
> fields; the preprocessor-parity case then **skips** with a regeneration hint.
> Re-run the producer to refresh them.
### Fairness controls
- **Same pre-processed inputs (model parity)** — the original processor's `input_ids`,
`pixel_values`, `image_grid_thw`, `attention_mask`, `state`, `embodiment_id` are
fed verbatim to the LeRobot model (no re-tokenization / re-normalization), so the
model comparison isolates the model. LeRobot's own tokenization / image packing is
covered separately by the preprocessor-parity case, which compares its output
against those same collated tensors from identical raw observations.
- **Same precision + attention kernel** — both sides run **fp32 + SDPA**. The
original defaults to `use_flash_attention=True` (flash_attention_2 + bf16); the
producer forces SDPA + fp32. (With the defaults the gap is ~3e-2 — pure
kernel/rounding noise, not an implementation difference.)
- **Same flow-matching seed** — fixed right before sampling on both sides; the
producer records it in each artifact (`--seed`, default 42) and the consumer
replays the recorded value.
### How to run
```bash
# Resolve a local checkpoint (GR00T-N1.7-LIBERO / libero_10)
CKPT=$(python - <<'PY'
import os
from huggingface_hub import snapshot_download
print(os.path.join(snapshot_download("nvidia/GR00T-N1.7-LIBERO",
allow_patterns=["libero_10/*"]), "libero_10"))
PY
)
# 1) Produce the original-side artifacts for all embodiments (original gr00t venv, CUDA)
CUDA_VISIBLE_DEVICES=0 /path/to/Isaac-GR00T/.venv-original/bin/python \
tests/policies/groot/utils/dump_original_n1_7.py \
--ckpt "$CKPT" --out-dir tests/policies/groot/artifacts --device cuda --seed 42
# 2) Run the parity test (LeRobot venv) — one parametrized case per embodiment
CUDA_VISIBLE_DEVICES=0 GROOT_PARITY_DEVICE=cuda \
uv run pytest tests/policies/groot/test_groot_vs_original.py -v -s
```
The `.npz` artifacts are local-only (gitignored, ~610 MB each) and are regenerated by
the producer; they are never committed. The tests **skip** (do not fail) on CI or
when the checkpoint / artifacts are absent.
#### Env knobs (all optional)
| Var | Default | Purpose |
| ----------------------------------------- | -------------------------------- | ------------------------------------- |
| `GROOT_N1_7_PARITY_DIR` | `tests/policies/groot/artifacts` | directory of per-tag `.npz` artifacts |
| `GROOT_N1_7_LIBERO_CKPT` | auto (HF cache) | override checkpoint dir |
| `GROOT_PARITY_DEVICE` | `cuda` if available | `cpu` or `cuda` |
| `GROOT_PARITY_ATOL` / `GROOT_PARITY_RTOL` | `1e-3` | comparison tolerance |
</details>
+2
View File
@@ -265,6 +265,8 @@ lerobot-dataset-viz \
Once executed, the tool opens `rerun.io` and displays the camera streams, robot states, and actions for the selected episode.
To use [Foxglove](https://foxglove.dev) instead of Rerun, install the extra add `--display-mode foxglove`. This starts a WebSocket server (connect the Foxglove app to `ws://127.0.0.1:8765`) that serves the episode as a seekable timeline you can play/pause and scrub.
For advanced usage—including visualizing datasets stored on a remote server—run:
```bash
+108 -44
View File
@@ -6,12 +6,11 @@ Encoding frames into an MP4 is a full FFmpeg pipeline: choice of encoder, pixel
You can set these parameters from the CLI with `--dataset.rgb_encoder.<field>` (e.g. with `lerobot-record` or `lerobot-rollout`). The same block applies to every camera video stream in that run.
<Tip>
Video storage must be on for `rgb_encoder` to have any effect —
`use_videos=True` in Python APIs, or `--dataset.video=true` on the CLI (the
recording default). With video off, inputs stay as images and `rgb_encoder` is
ignored.
</Tip>
> [!TIP]
> Video storage must be on for `rgb_encoder` to have any effect —
> `use_videos=True` in Python APIs, or `--dataset.video=true` on the CLI (the
> recording default). With video off, inputs stay as images and `rgb_encoder` is
> ignored.
For details on **when** frames are written vs. encoded (streaming vs. post-episode), queues, and other top-level `--dataset.*` switches, see [Streaming Video Encoding](./streaming_video_encoding). For an encoding-parameter comparison and experiments, see the [video-benchmark Space](https://huggingface.co/spaces/lerobot/video-benchmark).
@@ -43,12 +42,10 @@ lerobot-record \
## Tuning parameters
<Tip warning={true}>
The defaults are tuned to balance **compression ratio**, **visual quality**, and **decoding/seek speed** for typical robotics datasets. Changing them can affect both recording (CPU load, frame drops) and training (decoding throughput, image quality).
Only override these parameters if you have a specific reason to, and measure the impact on your pipeline before relying on the new settings.
</Tip>
> [!WARNING]
> The defaults are tuned to balance **compression ratio**, **visual quality**, and **decoding/seek speed** for typical robotics datasets. Changing them can affect both recording (CPU load, frame drops) and training (decoding throughput, image quality).
>
> Only override these parameters if you have a specific reason to, and measure the impact on your pipeline before relying on the new settings.
All flags below are prefixed with `--dataset.rgb_encoder.` on the CLI.
@@ -69,25 +66,92 @@ All flags below are prefixed with `--dataset.rgb_encoder.` on the CLI.
Depth maps (Intel RealSense, Reachy 2) are stored as their **own video streams** alongside the RGB streams. Raw depth (`uint16` millimetres or `float32` metres) can't survive an 8-bit codec, so LeRobot **quantizes** each map to a 12-bit code (`[0, 4095]`) — logarithmically by default, to match the `1/depth` error profile of depth sensors — then packs it into a high-bit-depth pixel format (`gray12le`) and encodes it with a 12-bit codec.
```mermaid
flowchart LR
A["Raw depth (uint16 mm / float32 m)"] --> B["Clip to depth_min, depth_max"]
B --> C["Quantize to 12-bit code 04095 (log or linear)"]
C --> D["Pack into gray12le"]
D --> E["Encode video (hevc Main 12)"]
E --> F[("MP4 + metadata: depth_min/max, shift, use_log")]
F -. "load time (depth_output_unit)" .-> G["Dequantize to mm or m"]
classDef input fill:#e3f2fd,stroke:#1565c0,color:#0d47a1;
classDef encode fill:#ede7f6,stroke:#5e35b1,color:#311b92;
classDef store fill:#fff8e1,stroke:#f9a825,color:#e65100;
classDef load fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;
class A input;
class B,C,D,E encode;
class F store;
class G load;
```
<div style="margin:28px 0;padding:14px 0;">
<div style="margin:0 auto;display:flex;flex-wrap:wrap;justify-content:center;align-items:stretch;gap:6px;font-family:'Source Sans 3',ui-sans-serif,system-ui,sans-serif;font-size:14px;font-weight:600;color:#1B1B1D;">
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#DBEAFE;color:#1D4ED8;border-radius:9px;padding:8px 12px;">
<span>Raw depth</span>
<span style="font-size:11px;font-weight:400;color:#3B6FD4;white-space:nowrap;">
uint16 mm
<br />
float32 m
</span>
</span>
<span style="display:flex;align-items:center;font-size:16px;color:#C3CBD9;">
</span>
<div style="border:2px dashed #C4B5FD;border-radius:13px;padding:18px 12px 12px;position:relative;display:flex;align-items:stretch;gap:6px;">
<span style="position:absolute;top:-10px;left:12px;background:#fff;padding:0 6px;font-size:11px;font-weight:700;color:#7E22CE;text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;">
Record time
</span>
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#F3E8FF;color:#7E22CE;border-radius:9px;padding:8px 12px;">
<span>Clip</span>
<span style="font-size:11px;font-weight:400;color:#9061C2;white-space:nowrap;">
to [depth_min,
<br />
depth_max]
</span>
</span>
<span style="display:flex;align-items:center;font-size:16px;color:#C3CBD9;">
</span>
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#F3E8FF;color:#7E22CE;border-radius:9px;padding:8px 12px;">
<span>Quantize</span>
<span style="font-size:11px;font-weight:400;color:#9061C2;white-space:nowrap;">
12-bit codes 04095
<br />
log (default) or linear
</span>
</span>
<span style="display:flex;align-items:center;font-size:16px;color:#C3CBD9;">
</span>
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#F3E8FF;color:#7E22CE;border-radius:9px;padding:8px 12px;">
<span>Pack</span>
<span style="font-size:11px;font-weight:400;color:#9061C2;white-space:nowrap;">
into gray12le
<br />
plane
</span>
</span>
<span style="display:flex;align-items:center;font-size:16px;color:#C3CBD9;">
</span>
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#F3E8FF;color:#7E22CE;border-radius:9px;padding:8px 12px;">
<span>Encode</span>
<span style="font-size:11px;font-weight:400;color:#9061C2;white-space:nowrap;">
HEVC
<br />
Main 12
</span>
</span>
</div>
<span style="display:flex;align-items:center;font-size:16px;color:#C3CBD9;">
</span>
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#FEF3C7;color:#B45309;border-radius:9px;padding:8px 12px;">
<span>MP4</span>
<span style="font-size:11px;font-weight:400;color:#C77D18;white-space:nowrap;">
stored
<br />
stream
</span>
</span>
<span style="display:flex;align-items:center;font-size:16px;color:#34A06B;">
</span>
<div style="border:2px dashed #6EE7B7;border-radius:13px;padding:18px 12px 12px;position:relative;display:flex;align-items:center;gap:6px;">
<span style="position:absolute;top:-10px;left:12px;background:#fff;padding:0 6px;font-size:11px;font-weight:700;color:#047857;text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;">
Load time
</span>
<span style="display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;gap:2px;background:#D1FAE5;color:#047857;border-radius:9px;padding:8px 12px;">
<span>Dequantize</span>
<span style="font-size:11px;font-weight:400;color:#059669;white-space:nowrap;">
to mm / m
</span>
</span>
</div>
</div>
</div>
Configure the depth pipeline through a parallel **`depth_encoder`** block (`DepthEncoderConfig`). It shares every `RGBEncoderConfig` field (`vcodec`, `pix_fmt`, `crf`, …) and adds four quantizer knobs, set via `--dataset.depth_encoder.<field>`:
@@ -134,9 +198,6 @@ lerobot-train \
> [!TIP]
> This is purely a decode-time presentation choice — it does **not** alter the stored video or its metadata, so the same dataset can be read as `mm` or `m` without re-encoding. It has no effect on datasets without depth cameras.
> [!IMPORTANT]
> Depth statistics in `meta/stats.json` are always computed in **millimetres**, regardless of the raw frame dtype.
---
## Persistence in dataset metadata
@@ -171,15 +232,16 @@ After the first episode of a video stream is encoded, the encoder configuration
Two sources contribute to the `info` block:
- **Stream-derived** (read back from the encoded MP4 with PyAV): `video.height`, `video.width`, `video.codec`, `video.pix_fmt`, `video.fps`, `video.channels`, `is_depth_map`, plus `audio.*` if an audio stream is present.
- **Encoder-derived** (taken from `RGBEncoderConfig` or `DepthEncoderConfig`): `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.video_backend`, `video.extra_options`.
| Source | Where it comes from | Fields |
| ------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| **Stream-derived** | Read back from the encoded MP4 with PyAV. | `video.height`, `video.width`, `video.codec`, `video.pix_fmt`, `video.fps`, `video.channels`, `is_depth_map`, `audio.*` |
| **Encoder-derived** | Taken from `RGBEncoderConfig` / `DepthEncoderConfig`. | `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.video_backend`, `video.extra_options` |
<Tip>
This block is populated **once**, from the **first** episode. It assumes every
episode in the dataset was encoded with the same `rgb_encoder`. Changing
encoder settings partway through a recording is not supported — the
`info.json` will only reflect the parameters used for the first episode.
</Tip>
> [!IMPORTANT]
> This block is populated **once**, from the **first** episode. It assumes every
> episode in the dataset was encoded with the same `rgb_encoder`. Changing
> encoder settings partway through a recording is not supported — the
> `info.json` will only reflect the parameters used for the first episode.
---
@@ -187,5 +249,7 @@ Two sources contribute to the `info` block:
When aggregating datasets with `merge_datasets`, video files are concatenated as-is (no re-encoding), and encoder fields in `info.json` are merged per-key:
- **Stream-derived fields must match** across sources: `video.codec`, `video.pix_fmt`, `video.height`, `video.width`, `video.fps`. Otherwise FFmpeg's concat demuxer fails.
- **Encoder-tuning fields are merged loosely**: `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.extra_options`. If every source agrees, the value is kept; if not, it's set to `null` (or `{}` for `video.extra_options`) and a warning is logged.
| Merge rule | Fields | Behaviour |
| ------------------ | ---------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Must match** | `video.codec`, `video.pix_fmt`, `video.height`, `video.width`, `video.fps` | Stream-derived fields must match across sources, otherwise FFmpeg's concat demuxer fails. |
| **Merged loosely** | `video.g`, `video.crf`, `video.preset`, `video.fast_decode`, `video.extra_options` | Encoder-tuning fields. If every source agrees, the value is kept; if not, it's set to `null` (or `{}` for `video.extra_options`) and a warning is logged. |
+4 -1
View File
@@ -46,8 +46,11 @@ CMD = (
"apt-get update -qq && apt-get install -y -qq git ffmpeg && "
"pip install --no-deps "
"'lerobot @ git+https://github.com/huggingface/lerobot.git@main' && "
# Pins mirror pyproject.toml — unpinned installs pull av 18 / datasets 5 /
# draccus 0.11, which break lerobot at import time.
"pip install --upgrade-strategy only-if-needed "
"datasets pyarrow av jsonlines draccus gymnasium torchcodec mergedeep pyyaml-include toml typing-inspect "
"'datasets>=4.7.0,<5.0.0' 'pyarrow>=21.0.0,<30.0.0' 'av>=15.0.0,<16.0.0' 'draccus==0.10.0' "
"'pandas>=2.0.0,<3.0.0' jsonlines gymnasium torchcodec mergedeep pyyaml-include toml typing-inspect "
"openai && "
"export VLLM_MEMORY_PROFILER_ESTIMATE_CUDAGRAPHS=0 && "
"export VLLM_VIDEO_BACKEND=pyav && "
+131
View File
@@ -0,0 +1,131 @@
# Isaac Teleop → SO-101
Teleoperate an SO-101/SO-100 follower arm — and record LeRobot datasets — with NVIDIA
[Isaac Teleop](https://github.com/NVIDIA/IsaacTeleop). Two input devices ship today:
- **XR (VR) controller** (`--teleop.type=xr_controller`) — the controller's grip pose drives the
end-effector through a squeeze-to-engage clutch and LeRobot's Cartesian IK pipeline; the analog
trigger drives the gripper.
- **SO-101 leader arm** (`--teleop.type=so101_leader`) — a back-drivable leader arm mirrored 1:1
onto the follower via Isaac Teleop's native `so101_leader` plugin (no clutch, no IK).
The full narrative guide (how the clutch works, CloudXR setup, headset pairing, tuning, and
troubleshooting) is in the [LeRobot docs](https://huggingface.co/docs/lerobot/isaac_teleop)
(source: `docs/source/isaac_teleop.mdx`). This README is the canonical install and usage
reference.
## Requirements
- Linux workstation (see NVIDIA's
[system requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html)
for supported OS/GPU/headset combinations; `isaacteleop` publishes Linux wheels only).
- An SO-101 (or SO-100) follower arm, calibrated with `lerobot-calibrate`.
- For the XR device: a CloudXR-capable headset (e.g. Quest 3, Pico 4, Apple Vision Pro) on the
same network.
- For the leader device: a second, back-drivable SO-101 leader arm and the `so101_leader` plugin
binary built from the Isaac Teleop source tree (see
[Build from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html)).
## Installation
This example lives in the LeRobot repository and is not part of the `lerobot` pip package, so
work from a source checkout. From the repo root:
```bash
# LeRobot with the extras this example uses:
# feetech - SO-101 serial motor bus
# kinematics - Placo IK solver (XR controller path)
# dataset - dataset recording (record.py)
# huggingface_hub >= 1.5 is needed by the automatic URDF fetch (Buckets API).
uv pip install -e ".[feetech,kinematics,dataset]" "huggingface_hub>=1.5"
# Isaac Teleop from public PyPI. `cloudxr` brings the CloudXR runtime bindings;
# `retargeters-lite` is the scipy-based retargeter path that resolves on both
# x86_64 and ARM (the full `retargeters` extra does not resolve on aarch64).
uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131" "scipy>=1.14"
# Optional, x86_64 only: the full retargeter stack.
uv pip install "isaacteleop[retargeters]~=1.3.131"
```
One-time CloudXR EULA (the auto-launch prompts on stdin and would hang on a headless machine):
```bash
python -m isaacteleop.cloudxr --accept-eula
```
## Usage
Run everything from the repo root with `python -m` so the `examples` package resolves.
### Teleoperate — XR controller
```bash
python -m examples.isaac_teleop_to_so101.teleoperate \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--robot.id=so101_follower_arm \
--teleop.type=xr_controller
```
On startup the script launches the CloudXR runtime (~30 s), prints the workstation IP to enter in
the headset's CloudXR web client, waits for the controllers to stream, slews the arm to a reset
pose (`--reset_to_origin=false` to skip), and then: **hold the squeeze/grip** to engage, move the
controller to drive the arm, pull the trigger to close the gripper. Releasing the squeeze freezes
the arm. The SO-101 URDF is fetched automatically from the `lerobot/robot-urdfs` Hugging Face
bucket into the LeRobot cache on first run.
To customize the reset pose: back-drive the arm to the pose you want, then
```bash
python -m examples.isaac_teleop_to_so101.override_reset_pose --port /dev/ttyACM0 --id so101_follower_arm
```
which writes it to `HF_LEROBOT_HOME/reset_poses/<robot.name>/<robot.id>.json`; runs with the same
`--robot.id` use it automatically.
### Teleoperate — SO-101 leader arm
```bash
python -m examples.isaac_teleop_to_so101.teleoperate \
--robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm \
--teleop.type=so101_leader --teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \
--launch_plugin=/path/to/IsaacTeleop/install/plugins/so101_leader/so101_leader_plugin
```
The follower is first slewed to the leader's pose over `--align_duration` seconds
(`--align=false` to skip), then mirrors it 1:1. The plugin reuses the serial leader's calibration
(`HF_LEROBOT_CALIBRATION/teleoperators/so_leader/<teleop.id>.json`).
### Record a dataset
`record.py` takes the same `--robot.*`/`--teleop.*`/loop flags plus `lerobot-record`-style
`--dataset.*` flags:
```bash
python -m examples.isaac_teleop_to_so101.record \
--robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm \
--teleop.type=xr_controller \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
--dataset.repo_id=<hf_user>/<dataset_name> \
--dataset.single_task="Pick up the cube" \
--dataset.num_episodes=3 --dataset.episode_time_s=20 --dataset.reset_time_s=5
```
Keyboard shortcuts (terminal-first, so they work over SSH): **Right/n** end episode early,
**Left/r** re-record, **Esc/q** stop after the current episode.
Run either script with `--help` for all flags.
## Layout
```
isaac_teleop/ device library: session lifecycle (base.py), XRController,
SO101LeaderArm, Clutch, configs, and the XR→IK processor step
common.py shared loop infra: device bundles, clutch/IK pipeline wiring,
reset/align slews, URDF fetch, keyboard listener
teleoperate.py teleoperation CLI (device selected via --teleop.type)
record.py dataset-recording CLI (same device selection + --dataset.*)
override_reset_pose.py save the current joints as the per-arm reset pose
default.env CloudXR device-profile overrides passed to the launcher
```
@@ -0,0 +1,17 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Isaac Teleop -> SO-101 example package."""
+650
View File
@@ -0,0 +1,650 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Shared device + control-loop infrastructure for the Isaac Teleop -> SO-101 examples.
Consumed by ``teleoperate.py`` and ``record.py``, which both build a per-device
:class:`Device` bundle and run the same loop: read -> (maybe command) -> hold-when-idle ->
sleep. A :class:`Device` bundles three closures: ``compute(obs) -> RobotAction | None``
(``None`` = hold at the measured pose while idle), ``startup``, and ``cleanup``. The devices:
* ``xr_controller`` — a thin :class:`XRController` whose raw grip pose an in-loop
:class:`Clutch` turns into an EE target for LeRobot's Cartesian IK pipeline.
* ``so101_leader`` — a back-drivable leader arm mirrored 1:1 into the follower.
Requires the ``isaacteleop`` package and an OpenXR runtime (install instructions in this
folder's ``README.md``). User-facing guide: ``docs/source/isaac_teleop.mdx``.
"""
import json
import logging
import socket
import subprocess
import sys
import time
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from importlib.resources import files
from pathlib import Path
from typing import Protocol
import numpy as np
from lerobot.model.kinematics import RobotKinematics
from lerobot.processor import (
RobotProcessorPipeline,
robot_action_observation_to_transition,
transition_to_robot_action,
)
from lerobot.robots import RobotConfig, make_robot_from_config
from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
from lerobot.robots.so_follower.robot_kinematic_processor import (
EEBoundsAndSafety,
InverseKinematicsEEToJoints,
)
from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.constants import HF_LEROBOT_CALIBRATION, HF_LEROBOT_HOME, TELEOPERATORS
from lerobot.utils.robot_utils import precise_sleep
from .isaac_teleop import (
Clutch,
IsaacTeleopConfig,
MapXRControllerActionToRobotAction,
SO101LeaderArm,
SO101LeaderArmConfig,
XRController,
)
# Fixed rate [Hz] for the teleoperate loop and the pre-loop slews / connect-wait poll sleeps.
FPS = 30
# CloudXR device-profile env file passed to the launcher (see default.env in this package).
CLOUDXR_ENV_FILE = str(files(__package__) / "default.env")
class LoopConfig(Protocol):
"""Structural type for the loop/launch knobs ``build_device`` and the ``setup_*`` read.
Both ``TeleoperateConfig`` and ``RecordConfig`` satisfy it, keeping ``common`` decoupled
from either entry point's concrete config.
"""
teleop: IsaacTeleopConfig
robot: RobotConfig
launch_plugin: str | None
reset_to_origin: bool
reset_duration: float
align: bool
align_duration: float
# Per-device bundle consumed by the shared loop. ``compute`` returns None to mean
# "idle -> hold at the measured pose"; ``startup`` warms up; ``cleanup`` reaps/disconnects.
@dataclass(frozen=True)
class Device:
compute: Callable[[RobotObservation | None], RobotAction | None]
startup: Callable[[], None]
cleanup: Callable[[], None]
def hold_action(obs: RobotObservation, motor_names: list[str]) -> dict[str, float]:
"""Re-send the measured joints — the explicit hold when a device is idle."""
return {f"{name}.pos": float(obs[f"{name}.pos"]) for name in motor_names}
class HoldLatch:
"""Resolve the per-frame action, holding one LATCHED pose while the device is idle.
Re-sending the freshly measured joints on every idle frame would ratchet the arm
downward: under gravity the P-only servo settles below its goal by a steady-state
error, so each re-command of the measurement lowers the goal by that error again.
Latching the target once on the active->idle transition holds a fixed pose instead.
"""
def __init__(self, motor_names: list[str]):
self._motor_names = motor_names
self._held: dict[str, float] | None = None
def resolve(self, action: RobotAction | None, obs: RobotObservation) -> RobotAction:
"""Pass through an active action (clearing the latch); latch + hold when idle."""
if action is not None:
self._held = None
return action
if self._held is None:
self._held = hold_action(obs, self._motor_names)
return self._held
def slew(
robot,
motor_names: list[str],
target_fn: Callable[[], dict[str, float]],
duration_s: float,
) -> None:
"""Linearly slew all joints from their current measured pose toward a target.
``target_fn`` is called EACH step, so the leader can pass a live re-read (landing on its
current pose at ``alpha == 1`` for a continuous handoff) while XR passes a constant.
"""
obs = robot.get_observation()
start = {name: float(obs[f"{name}.pos"]) for name in motor_names}
n_steps = max(1, int(duration_s * FPS))
for step in range(1, n_steps + 1):
alpha = step / n_steps
target = target_fn()
action = {f"{name}.pos": start[name] + alpha * (target[name] - start[name]) for name in motor_names}
robot.send_action(action)
precise_sleep(1.0 / FPS)
# ============================================================================
# XR controller device
# ============================================================================
# Per-frame EE rate limit [m]. With raise_on_jump=False, EEBoundsAndSafety clamps an
# over-limit step instead of raising, absorbing a tracking glitch as one slow frame. At
# FPS=30, 0.1 m/frame caps EE speed at ~3 m/s. (end_effector_bounds clips the absolute target.)
MAX_EE_STEP_M = 0.1
# Soft-orientation IK weight: small but nonzero so the wrist follows the hand while position
# dominates (the 5-DOF SO-101 cannot realize an arbitrary orientation). 0.0 = position-only.
IK_ORIENTATION_WEIGHT = 0.01
def _ensure_so101_urdf() -> str:
"""Return the cached SO-101 URDF path, fetching the ``so101`` folder (URDF + meshes) from
the public ``lerobot/robot-urdfs`` HF bucket into the LeRobot cache on first use."""
dest_dir = HF_LEROBOT_HOME / "robot-urdfs" / "so101"
urdf_path = dest_dir / "so101_new_calib.urdf"
# Completeness marker written only after a FULL sync: the URDF file alone is not a
# completeness signal (an interrupted first sync can leave the meshes it references
# missing, which the URDF's mere existence would then hide forever). Re-syncing is
# idempotent and repairs a partial cache; delete the folder to force a re-download.
marker = dest_dir / ".sync_complete"
if not marker.exists():
from huggingface_hub import sync_bucket
sync_bucket("hf://buckets/lerobot/robot-urdfs/so101", str(dest_dir), quiet=True)
marker.touch()
return str(urdf_path)
# Default duration [s] for the startup reset-to-origin slew.
RESET_DURATION_S = 5.0
# Optional cached file written by override_reset_pose.py. When present it takes priority over RESET_ORIGIN_DEG.
RESET_POSE_FILE = str(HF_LEROBOT_HOME / "reset_poses" / "{robot_name}" / "{robot_id}.json")
# Reset target in each motor's native units (arm joints in degrees, gripper RANGE_0_100,
# 100 = open). An empirically comfortable pose (elbow/wrist bent) avoiding the singularity of
# a fully-extended arm; assumes standard calibration. Override per-arm via override_reset_pose.py.
RESET_ORIGIN_DEG: dict[str, float] = {
"shoulder_pan": -4.0,
"shoulder_lift": -103.0,
"elbow_flex": 97.0,
"wrist_flex": 78.0,
"wrist_roll": -65.0,
"gripper": 0.0,
}
def _load_reset_target(reset_pose_file: Path, motor_names: list[str]) -> dict[str, float]:
"""Return reset targets: the saved reset pose if present, else RESET_ORIGIN_DEG."""
if reset_pose_file.exists():
saved = json.loads(reset_pose_file.read_text())
# Fill any missing motors from the fallback dict.
return {name: float(saved.get(name, RESET_ORIGIN_DEG.get(name, 0.0))) for name in motor_names}
return {name: RESET_ORIGIN_DEG.get(name, 0.0) for name in motor_names}
# CloudXR web client URL opened in the headset (Isaac Teleop quick start, step 5).
_CLOUDXR_WEB_CLIENT_URL = "https://nvidia.github.io/IsaacTeleop/client"
# WSS-proxy / self-signed-cert port the operator accepts in-browser before connecting.
_CLOUDXR_WSS_PORT = 48322
# How often to re-print the connection hint while waiting for the headset [s].
_XR_CONNECT_REMINDER_S = 15.0
# Virtual / bridge / USB-gadget interfaces a headset can't reach over the network — skip
# by name prefix (``docker0``, compose ``br-*``, ``veth*``, libvirt ``virbr*``, and the
# Tegra USB device-mode bridge ``l4tbr0``).
_SKIP_IFACE_PREFIXES = ("docker", "br-", "veth", "virbr", "l4tbr")
def _primary_ipv4() -> str | None:
"""The workstation's primary outbound IPv4, via the UDP-socket trick (``connect()`` on a
datagram socket selects the egress interface without sending packets)."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except OSError:
return None
def _candidate_ipv4s() -> list[tuple[str, str]]:
"""Return ``[(interface, ipv4), ...]`` the headset might reach this workstation at.
Lists each interface's IPv4 via ``psutil`` (dropping loopback, link-local, and the
virtual/bridge interfaces in ``_SKIP_IFACE_PREFIXES``), primary outbound first. Falls
back to just the primary IP when ``psutil`` is unavailable.
"""
primary = _primary_ipv4()
found: list[tuple[str, str]] = []
try:
import psutil
for iface, addrs in psutil.net_if_addrs().items():
if iface.startswith(_SKIP_IFACE_PREFIXES):
continue
for addr in addrs:
if addr.family != socket.AF_INET:
continue
ip = addr.address
if ip.startswith("127.") or ip.startswith("169.254."):
continue
found.append((iface, ip))
except Exception:
if primary:
found.append(("default", primary))
found.sort(key=lambda t: t[1] != primary) # primary outbound interface first
return found
def _print_xr_connect_help() -> None:
"""Print how to connect the headset to this workstation over CloudXR."""
ips = _candidate_ipv4s()
print("\n" + "=" * 76)
print("Connect your XR headset to this workstation over NVIDIA CloudXR:")
print(f" 1. In the headset, open the CloudXR web client: {_CLOUDXR_WEB_CLIENT_URL}")
print(" 2. Enter this workstation's IP address:")
if ips:
for iface, ip in ips:
print(f" {ip:<15} ({iface})")
if len(ips) > 1:
print(" (use the address on the same network as your headset)")
else:
print(" <could not determine — check `hostname -I` / `ip addr`>")
print(f" 3. Accept the self-signed cert at https://<that-ip>:{_CLOUDXR_WSS_PORT}/ , then Connect.")
print("=" * 76 + "\n")
def _wait_for_xr_controller(teleop_device: XRController) -> None:
"""Block until the XR controller is tracked, polling ``get_action()`` and re-printing a
reminder every ``_XR_CONNECT_REMINDER_S``. User-paced; ``Ctrl-C`` aborts (no hard timeout).
"""
_print_xr_connect_help()
print("Waiting for the headset controllers to start streaming… (Ctrl-C to abort)")
last_reminder = time.time()
while True:
teleop_device.get_action() # steps the session; updates is_tracking
if teleop_device.is_tracking:
print("Headset connected — controllers are streaming.")
return
if time.time() - last_reminder >= _XR_CONNECT_REMINDER_S:
print("…still waiting for the headset to connect (Ctrl-C to abort).")
last_reminder = time.time()
time.sleep(1.0 / FPS)
def setup_xr(cfg: LoopConfig, robot, motor_names: list[str]) -> Device:
"""Build the XR controller device bundle (clutch + soft-orientation IK pipeline)."""
kinematics_solver = RobotKinematics(
urdf_path=_ensure_so101_urdf(),
target_frame_name="gripper_frame_link",
joint_names=motor_names,
)
teleop_config = cfg.teleop # XRControllerConfig (selected via --teleop.type=xr_controller)
teleop_device = XRController(teleop_config)
# The clutch (below) turns the raw grip pose into an absolute base-frame ee_pose; this
# pipeline maps it to joint targets: rename -> bounds/rate-limit -> IK.
xr_to_robot_joints_processor = RobotProcessorPipeline[tuple[RobotAction, RobotObservation], RobotAction](
steps=[
MapXRControllerActionToRobotAction(),
# raise_on_jump=False: an over-limit step (e.g. a tracking glitch) is clamped +
# warned instead of raised, since a crash mid-loop would leave the arm uncontrolled.
# z floor 0.0 keeps a stray target above the table; x/y stay at a loose [-1,1]m box.
EEBoundsAndSafety(
end_effector_bounds={"min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 1.0]},
max_ee_step_m=MAX_EE_STEP_M,
raise_on_jump=False,
),
# initial_guess_current_joints=False: warm-start from the previous IK solution so
# the joint trajectory stays continuous frame-to-frame.
InverseKinematicsEEToJoints(
kinematics=kinematics_solver,
motor_names=motor_names,
initial_guess_current_joints=False,
orientation_weight=IK_ORIENTATION_WEIGHT,
),
],
to_transition=robot_action_observation_to_transition,
to_output=transition_to_robot_action,
)
# The clutch is built in startup() (after the optional reset slew, seeded from the
# post-slew MEASURED pose) and shared with compute() via nonlocal.
clutch: Clutch | None = None
prev_enabled = False
def startup() -> None:
nonlocal clutch
# Connect and wait for the operator to don the headset BEFORE moving the arm, so the
# reset slew happens while they are watching in VR.
teleop_device.connect()
if not teleop_device.is_connected:
raise ValueError("Teleop is not connected!")
_wait_for_xr_controller(teleop_device)
if cfg.reset_to_origin:
reset_pose_file = Path(RESET_POSE_FILE.format(robot_name=robot.name, robot_id=robot.id))
target = _load_reset_target(reset_pose_file, motor_names)
source = str(reset_pose_file) if reset_pose_file.exists() else "hardcoded defaults"
print(f"Reset target source: {source}")
print(f"Resetting to origin over {cfg.reset_duration:.1f} s…")
slew(robot, motor_names, lambda: target, cfg.reset_duration)
print("Reset complete.")
# Seed the clutch home from the arm's measured pose (FK of the current joints) so the
# first engage is jump-free, whether or not a reset slew ran.
obs0 = robot.get_observation()
q_measured_deg = np.array([float(obs0[f"{name}.pos"]) for name in motor_names], dtype=float)
home_base_T_ee = kinematics_solver.forward_kinematics(q_measured_deg) # noqa: N806
clutch = Clutch(home_base_T_ee)
print("Starting teleop loop. Squeeze and move the controller to teleoperate the robot...")
def compute(robot_obs: RobotObservation | None) -> RobotAction | None:
nonlocal prev_enabled
if clutch is None: # set in startup(), which runs before compute()
raise RuntimeError("compute() called before startup(); the clutch is not initialized")
xr_action = teleop_device.get_action()
grip_pos = np.asarray(xr_action["grip_pos"], dtype=float)
grip_quat = np.asarray(xr_action["grip_quat"], dtype=float)
squeeze = float(xr_action["squeeze"])
trigger = float(xr_action["trigger"])
enabled = squeeze > teleop_config.clutch_threshold
# On the engage edge, latch the clutch home at the arm's MEASURED EE pose (FK of
# the live joints) and the controller origin so the per-frame delta starts at zero.
# Latching the last commanded pose instead would snap the arm back to it at full
# servo speed if the arm moved while disengaged (gravity sag, external contact).
is_engage_frame = enabled and not prev_enabled
if is_engage_frame:
q_measured = np.array([float(robot_obs[f"{name}.pos"]) for name in motor_names], dtype=float)
measured_base_T_ee = kinematics_solver.forward_kinematics(q_measured) # noqa: N806
clutch.engage(grip_pos, grip_quat, measured_base_T_ee=measured_base_T_ee)
# Re-anchor the pipeline state at the measured pose as well: EEBoundsAndSafety's
# rate limiter and the IK warm start otherwise still reference the stale
# pre-disengage command and would fight the fresh home for several frames.
xr_to_robot_joints_processor.reset()
prev_enabled = enabled
# SAFETY GATE: command the robot ONLY while the clutch is engaged; otherwise return
# None so the loop holds the measured joints (releasing the clutch freezes the arm).
if not enabled:
return None
# Rebase the raw grip pose onto the EE, then run the pipeline. closedness = trigger.
ee_pos, ee_quat = clutch.rebase(grip_pos, grip_quat)
ee_action = {
"ee_pose": np.concatenate([ee_pos, ee_quat]).astype(np.float32),
"closedness": trigger,
}
return xr_to_robot_joints_processor((ee_action, robot_obs))
return Device(compute=compute, startup=startup, cleanup=teleop_device.disconnect)
# ============================================================================
# SO-101 leader arm device
# ============================================================================
# Default duration [s] for the startup alignment slew (follower current -> leader first pose).
ALIGN_DURATION_S = 3.0
# How long to wait for the leader plugin to start streaming before aligning / looping.
LEADER_WARMUP_TIMEOUT_S = 20.0
# The plugin converts the leader's servo ticks to radians, so it reuses the serial SO-101
# leader's calibration, stored by lerobot-calibrate under SO101Leader.name == "so_leader".
SO_LEADER_CALIBRATION_NAME = "so_leader"
def _leader_calibration_path(cfg: LoopConfig) -> Path | None:
"""Infer the calibration JSON the launched plugin should read, or None.
Path convention: ``HF_LEROBOT_CALIBRATION / teleoperators / so_leader / {--teleop.id}.json``
(or ``--teleop.calibration_dir`` if set). Returns None (plugin falls back to defaults) when
it does not exist, warning if an id was given, or when no ``--teleop.id`` is set.
"""
if not cfg.teleop.id:
return None
calib_dir = cfg.teleop.calibration_dir or (
HF_LEROBOT_CALIBRATION / TELEOPERATORS / SO_LEADER_CALIBRATION_NAME
)
calib_path = Path(calib_dir) / f"{cfg.teleop.id}.json"
if calib_path.is_file():
return calib_path
print(
f"WARNING: no leader calibration at {calib_path}; the plugin will use built-in defaults. "
f"Calibrate with the serial leader (`lerobot-calibrate --teleop.type=so101_leader "
f"--teleop.id={cfg.teleop.id}`) or the plugin's `calibrate` subcommand."
)
return None
def _wait_for_leader(teleop: SO101LeaderArm, timeout_s: float) -> dict[str, float]:
"""Poll the leader until it streams a live frame; return that frame's ``{joint}.pos``.
Raises ``SystemExit`` if no live frame arrives within ``timeout_s`` (plugin not pushing,
wrong ``--teleop.collection_id``, or CloudXR not up).
"""
print(f"Waiting up to {timeout_s:.0f}s for the so101_leader plugin to stream…")
deadline = time.time() + timeout_s
while time.time() < deadline:
action = teleop.get_action()
if teleop.is_tracking:
print("Leader is streaming.")
return action
time.sleep(1.0 / FPS)
raise SystemExit(
f"FAILED: leader did not stream within {timeout_s:.0f}s. Is the so101_leader plugin "
"running and pushing (check --teleop.collection_id)? Is CloudXR up?"
)
def _maybe_launch_plugin(cfg: LoopConfig) -> subprocess.Popen | None:
"""Spawn the so101_leader plugin if ``--launch_plugin <path>`` was given (after connect())."""
if cfg.launch_plugin is None:
return None
if not Path(cfg.launch_plugin).exists():
raise SystemExit(
f"plugin binary not found: {cfg.launch_plugin} (build it in the IsaacTeleop repo first)"
)
leader_port = cfg.teleop.port # SO101LeaderArmConfig.port, forwarded to the plugin
backend = f"leader on {leader_port}" if leader_port else "synthetic trajectory"
print(f"launching plugin: {cfg.launch_plugin} ({backend})")
# Positional args: [device_path] [collection_id] [calibration_file]. Empty device_path ->
# synthetic backend. Calibration (only real hardware needs it) is appended when a port is set.
argv = [cfg.launch_plugin, leader_port, cfg.teleop.collection_id]
if leader_port:
calib_path = _leader_calibration_path(cfg)
if calib_path is not None:
argv.append(str(calib_path))
print(f" leader calibration: {calib_path}")
# Spawned after connect() so it inherits the CloudXR runtime env (XR_RUNTIME_JSON, ...).
proc = subprocess.Popen(argv)
time.sleep(1.5) # let it create its OpenXR session and start pushing
return proc
def setup_leader(cfg: LoopConfig, robot, motor_names: list[str]) -> Device:
"""Build the SO-101 leader arm device bundle (1:1 joint mirror)."""
teleop_config = cfg.teleop # SO101LeaderArmConfig (selected via --teleop.type=so101_leader)
teleop = SO101LeaderArm(teleop_config)
plugin_proc: subprocess.Popen | None = None
def startup() -> None:
nonlocal plugin_proc
# connect() auto-launches CloudXR (unless opted out); spawn the plugin AFTER so it
# inherits the runtime env. The plugin is reaped in cleanup().
teleop.connect()
plugin_proc = _maybe_launch_plugin(cfg)
if not teleop.is_connected:
raise ValueError("Teleop is not connected!")
# Block until the leader streams a live frame (clear error if it never does).
_wait_for_leader(teleop, LEADER_WARMUP_TIMEOUT_S)
if cfg.align:
print(f"Aligning follower to leader over {cfg.align_duration:.1f}s…")
# Re-read the live leader pose once per step so alpha=1 lands on its current pose
# from a single coherent frame.
def _leader_target() -> dict[str, float]:
leader_now = teleop.get_action()
return {name: float(leader_now[f"{name}.pos"]) for name in motor_names}
slew(robot, motor_names, _leader_target, cfg.align_duration)
print("Alignment complete.")
print(
"Starting joint-mirror loop. Back-drive the leader to teleoperate the follower… (Ctrl-C to stop)"
)
def compute(robot_obs: RobotObservation | None) -> RobotAction | None:
leader_action = teleop.get_action()
# Hold the follower at its measured pose when the leader drops out (stale stream)
# rather than commanding a possibly-old target.
if not teleop.is_tracking:
return None
return leader_action
def cleanup() -> None:
# A plugin-reaping failure must not skip the session disconnect (and vice versa
# the disconnect runs after the plugin stops pushing on it).
try:
if plugin_proc is not None:
plugin_proc.terminate()
try:
plugin_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
plugin_proc.kill()
finally:
teleop.disconnect()
return Device(compute=compute, startup=startup, cleanup=cleanup)
# ============================================================================
# Shared setup
# ============================================================================
def build_device(cfg: LoopConfig) -> tuple:
"""Connect the follower, build the selected Isaac device, and run its pre-loop startup.
Connects the follower FIRST (so the startup slew / clutch-home seed can read live joints),
dispatches on ``--teleop.type``, then runs ``device.startup()`` before returning. On any
failure after ``connect()`` the follower is disconnected so the connection never leaks.
Returns ``(robot, device, motor_names)``.
"""
# Default the CloudXR input profile to this example's default.env unless the user overrode
# it via --teleop.cloudxr_env_file.
if cfg.teleop.cloudxr_env_file is None:
cfg.teleop.cloudxr_env_file = CLOUDXR_ENV_FILE
# SO-101/SO-100 only (both share the SO-101 URDF), reject other followers.
supported_robots = {"so101_follower", "so100_follower"}
if cfg.robot.type not in supported_robots:
raise ValueError(
f"This example only supports SO-101/SO-100 followers ({sorted(supported_robots)}), "
f"but got --robot.type={cfg.robot.type}."
)
# The degree-based pipeline relies on --robot.use_degrees (default True).
robot = make_robot_from_config(cfg.robot)
# Connect FIRST so the startup slew and clutch-home seed can read live joints.
robot.connect()
# Everything after connect() can fail; this runs outside the callers' try/finally, so
# disconnect the follower on any failure to avoid leaking the connection.
device: Device | None = None
try:
# Joint names in action order, read from {name}.pos action features (robot-agnostic).
motor_names = [key.removesuffix(".pos") for key in robot.action_features if key.endswith(".pos")]
if isinstance(cfg.teleop, SO101LeaderArmConfig):
device = setup_leader(cfg, robot, motor_names)
else:
device = setup_xr(cfg, robot, motor_names)
device.startup()
except BaseException:
# Reap a partially-started device, then always disconnect the follower.
if device is not None:
with suppress(Exception):
device.cleanup()
robot.disconnect()
raise
return robot, device, motor_names
# ============================================================================
# Keyboard control
# ============================================================================
def init_keyboard_listener():
"""Recording shortcuts, terminal-first so they work over SSH.
Whenever stdin is a TTY we use the stdlib :class:`TerminalKeyListener` directly rather
than upstream's pynput-first :func:`init_keyboard_listener`, whose global listener would
capture the workstation console instead of this (often SSH) terminal. With no TTY we defer
to upstream (pynput on a GUI, else headless no-op).
"""
if not (sys.stdin is not None and sys.stdin.isatty()):
from lerobot.utils.keyboard_input import init_keyboard_listener as _upstream
return _upstream()
from lerobot.utils.keyboard_input import TerminalKeyListener, apply_recording_control
events = {"exit_early": False, "rerecord_episode": False, "stop_recording": False}
# n/r/q are the arrow/Esc equivalents that survive escape-sequence splitting over laggy
# SSH/VNC links. Case-insensitive so Shift+letter still works.
def on_key(name: str) -> None:
key = name.lower()
if key in ("right", "n"):
apply_recording_control("right", events)
elif key in ("left", "r"):
apply_recording_control("left", events)
elif key in ("esc", "q"):
apply_recording_control("esc", events)
listener = TerminalKeyListener(on_key)
listener.start()
logging.info(
"Keyboard control via terminal — keep this terminal focused: "
"Right/n = end episode early, Left/r = re-record, Esc/q = stop."
)
return listener, events
@@ -0,0 +1,21 @@
# CloudXR device-profile overrides for the Isaac Teleop XR -> SO-101 example.
#
# Passed to isaacteleop's CloudXRLauncher as `env_config` (via
# XRControllerConfig.cloudxr_env_file). Format: KEY=value, one per line; `#`
# comments and blank lines ignored; $VARS / ~ expanded. See
# isaacteleop/cloudxr/env_config.py::_load_env_file.
#
# Runtime-resolved keys (XR_RUNTIME_JSON, XRT_NO_STDIN, NV_CXR_RUNTIME_DIR,
# NV_CXR_OUTPUT_DIR) are reserved and ignored if set here.
# Transport profile the runtime advertises (CloudXR default: auto-webrtc).
# "Quest3" also covers the Pico 4. Other values: auto-native, AppleVisionPro.
NV_DEVICE_PROFILE=Quest3
# Input device discovery channels (both default to true; pinned for clarity).
NV_CXR_ENABLE_PUSH_DEVICES=true
NV_CXR_ENABLE_TENSOR_DATA=true
# Runtime logs to ~/.cloudxr/logs — helps debug connection issues
# (e.g. "Failed to get OpenXR system: -35").
NV_CXR_FILE_LOGGING=true
@@ -0,0 +1,40 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""NVIDIA Isaac Teleop teleoperators for LeRobot.
Each input device is an :class:`IsaacTeleopTeleoperator` subclass: :class:`XRController`
(XR/VR controller) and :class:`SO101LeaderArm` (back-drivable SO-101 leader arm) ship today.
"""
from .base import IsaacTeleopTeleoperator
from .clutch import Clutch
from .config_isaac_teleop import IsaacTeleopConfig, SO101LeaderArmConfig, XRControllerConfig
from .teleop_so101_leader_arm import SO101LeaderArm, leader_joints_to_robot_action
from .teleop_xr_controller import XRController
from .xr_controller_processor import MapXRControllerActionToRobotAction
__all__ = [
"Clutch",
"IsaacTeleopConfig",
"IsaacTeleopTeleoperator",
"MapXRControllerActionToRobotAction",
"SO101LeaderArm",
"SO101LeaderArmConfig",
"XRController",
"XRControllerConfig",
"leader_joints_to_robot_action",
]
@@ -0,0 +1,282 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Shared base for NVIDIA Isaac Teleop-backed LeRobot teleoperators.
Isaac Teleop is a multi-modal framework: a single ``TeleopSession`` can be driven by
XR controllers, hand tracking, Manus gloves, etc. Each modality is a
:class:`Teleoperator` subclass in its own ``teleop_<device>.py``.
:class:`IsaacTeleopTeleoperator` owns what those devices share — the session
lifecycle, the per-step staleness/worker-health guard, and the no-op calibration
tracking devices need. A concrete device implements :meth:`_build_pipeline` (its
retargeting graph) and :meth:`get_action` (usually via :meth:`_step`).
``isaacteleop`` is an optional NVIDIA dependency (install instructions in the example's
``README.md``); its imports are guarded behind an availability check at module top, so this
module imports without it and constructing a device fails fast with install instructions.
"""
from __future__ import annotations
import abc
import logging
import os
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
from lerobot.teleoperators.teleoperator import Teleoperator
from lerobot.utils.import_utils import is_package_available
from .config_isaac_teleop import IsaacTeleopConfig
_isaacteleop_available = is_package_available("isaacteleop")
if TYPE_CHECKING or _isaacteleop_available:
from isaacteleop.cloudxr import CloudXRLauncher
from isaacteleop.retargeting_engine.interface import (
ExecutionEvents,
ExecutionState,
GraphExecutable,
RetargeterIO,
)
from isaacteleop.teleop_session_manager import TeleopSession, TeleopSessionConfig
else:
CloudXRLauncher = None
ExecutionEvents = None
ExecutionState = None
GraphExecutable = None
RetargeterIO = None
TeleopSession = None
TeleopSessionConfig = None
logger = logging.getLogger(__name__)
# Gripper closedness [0, 1] -> SO-101 follower motor units [0, 100] (RANGE_0_100, 100 = OPEN).
# Shared by the XR processor and leader device, which invert via ``pos = (1 - c) * SCALE``.
_GRIPPER_MOTOR_SCALE = 100.0
def _require_isaacteleop() -> None:
"""Fail fast with install pointers when the optional ``isaacteleop`` package is missing."""
if not _isaacteleop_available:
raise ImportError(
"The 'isaacteleop' package is required for Isaac Teleop devices but is not "
"installed. See examples/isaac_teleop_to_so101/README.md for install instructions."
)
class IsaacTeleopTeleoperator(Teleoperator):
"""Abstract base for teleoperators backed by an Isaac Teleop ``TeleopSession``.
Owns the session lifecycle and the per-step health guard; subclasses supply
:meth:`_build_pipeline` and :meth:`get_action`.
"""
config_class = IsaacTeleopConfig
def __init__(self, config: IsaacTeleopConfig):
_require_isaacteleop()
super().__init__(config)
self.config: IsaacTeleopConfig = config
self._session: TeleopSession | None = None
self._cloudxr_launcher: CloudXRLauncher | None = None
# ------------------------------------------------------------------
# Pipeline construction (device override point)
# ------------------------------------------------------------------
@abc.abstractmethod
def _build_pipeline(self) -> GraphExecutable:
"""Build this device's retargeting pipeline (the ``GraphExecutable`` for
``TeleopSessionConfig.pipeline``). Called once in :meth:`connect`; its output
keys must match what :meth:`get_action` unpacks.
"""
raise NotImplementedError
# ------------------------------------------------------------------
# Lifecycle (shared)
# ------------------------------------------------------------------
@property
def is_connected(self) -> bool:
return self._session is not None
@property
def is_calibrated(self) -> bool:
return True # Tracking devices are self-calibrating.
def calibrate(self) -> None:
pass
def configure(self) -> None:
pass
def connect(self, calibrate: bool = True) -> None:
"""Auto-launch the CloudXR runtime (unless opted out) and open the session.
The CloudXR launch blocks ~30s and, on the first run, prompts on stdin for the
EULA (accept once via ``python -m isaacteleop.cloudxr --accept-eula``). Opt out
when CloudXR runs externally via ``config.auto_launch_cloudxr=False`` or
``LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1`` (env var wins).
"""
if self._session is not None:
raise RuntimeError("Already connected. Call disconnect() first.")
self._ensure_cloudxr_runtime()
try:
pipeline = self._build_pipeline()
session_config = TeleopSessionConfig(app_name=self.config.app_name, pipeline=pipeline)
self._session = TeleopSession(session_config)
self._session.__enter__()
except Exception:
self._session = None
try:
self._stop_cloudxr_runtime()
except Exception:
logger.exception("Failed to stop CloudXR runtime during connect() rollback")
raise
logger.info("Isaac Teleop session started: %s", self.config.app_name)
def disconnect(self) -> None:
try:
if self._session is not None:
# Null the handle BEFORE __exit__: even a failed session teardown must not
# wedge the device as is_connected (blocking every later connect/disconnect).
session = self._session
self._session = None
session.__exit__(None, None, None)
logger.info("Isaac Teleop session ended")
finally:
# Reap the CloudXR runtime even if session teardown raised, and even if no
# session was ever established (e.g. the launcher came up but session creation
# failed before this point); a no-op when we never launched CloudXR (opt-out /
# externally-owned runtime), so we never stop a runtime we don't own.
self._stop_cloudxr_runtime()
# ------------------------------------------------------------------
# CloudXR runtime (shared)
# ------------------------------------------------------------------
def _ensure_cloudxr_runtime(self) -> None:
"""Auto-launch the CloudXR runtime once, unless opted out.
Idempotent (no-op once the launcher is up). ``LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH``
is checked first and wins over ``config.auto_launch_cloudxr``. Constructing
:class:`CloudXRLauncher` mutates the process env (``XR_RUNTIME_JSON`` etc.) and
blocks until the runtime is ready or raises :class:`RuntimeError`.
"""
if self._cloudxr_launcher is not None:
return
if os.environ.get("LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH", "").strip() == "1":
logger.info(
"LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1 set; skipping CloudXR auto-launch "
"(assuming CloudXR is already running externally)"
)
return
if not self.config.auto_launch_cloudxr:
logger.info(
"config.auto_launch_cloudxr is False; skipping CloudXR auto-launch "
"(assuming CloudXR is already running externally)"
)
return
logger.info("Launching CloudXR runtime (first run may prompt for EULA and take ~30s)...")
self._cloudxr_launcher = CloudXRLauncher(
install_dir=str(Path.home() / ".cloudxr"),
env_config=self.config.cloudxr_env_file,
accept_eula=False,
)
def _stop_cloudxr_runtime(self) -> None:
"""Stop the auto-launched CloudXR runtime, if any.
Clean stop nulls the handle. On :class:`RuntimeError` the handle is RETAINED so
the launcher's ``atexit`` hook owns the retry — a later :meth:`connect` then
treats the retained runtime as still up and will not relaunch.
"""
if self._cloudxr_launcher is None:
return
try:
self._cloudxr_launcher.stop()
except RuntimeError:
logger.warning("CloudXR runtime could not be terminated; handle retained for atexit cleanup")
else:
self._cloudxr_launcher = None
logger.info("CloudXR runtime stopped")
def send_feedback(self, feedback: dict[str, Any]) -> None:
pass # Haptic feedback not yet implemented.
# ------------------------------------------------------------------
# Stepping (shared)
# ------------------------------------------------------------------
def _running_events(self) -> ExecutionEvents:
"""Constant ``RUNNING`` ``ExecutionEvents`` for a device with no clutch lifecycle.
Keeps the stream flowing; ``reset`` stays ``False``. A clutched device that needs
a real lifecycle should build its own ``ExecutionEvents`` instead.
"""
return ExecutionEvents(execution_state=ExecutionState.RUNNING, reset=False)
def _step(
self,
*,
execution_events: ExecutionEvents | None = None,
external_inputs: Mapping[str, Any] | None = None,
) -> RetargeterIO:
"""Step the session once and return the raw pipeline outputs.
Applies the shared guard: re-raises a retargeting-worker exception and warns on a
stale frame. Subclasses call this from :meth:`get_action`.
Args:
execution_events: The ``ExecutionEvents`` driving the session this frame.
Devices with a lifecycle (clutch) MUST pass this every frame — when
``None``, ``TeleopSession.step`` auto-fires ``RUNNING`` (the clutch would
latch immediately and never stop).
external_inputs: Per-step inputs (e.g. a static ``base_T_anchor``) in the
``{leaf_node_name: {output_port_name: TensorGroup}}`` shape ``step`` expects.
Raises:
RuntimeError: If not connected, or if the retargeting worker raised.
"""
if self._session is None:
raise RuntimeError("Not connected. Call connect() first.")
result = self._session.step(
execution_events=execution_events,
external_inputs=external_inputs,
)
info = self._session.last_step_info
if info is not None:
if info.worker_exception is not None:
raise RuntimeError(
"Isaac Teleop retargeting worker raised an exception"
) from info.worker_exception
if info.frame_deadline_miss:
logger.warning(
"Isaac Teleop frame deadline miss (returned_age_frames=%s)",
info.returned_age_frames,
)
return result
@@ -0,0 +1,102 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Engage-relative clutch for the XR -> SO-101 teleop loop.
Turns the raw controller grip pose into an absolute base-frame EE target, so the XR
device can stay a thin raw-pose reader. Pure numpy + the local ``Rotation`` helper (no
``isaacteleop``), so it is unit-testable without the XR runtime.
"""
from __future__ import annotations
import numpy as np
from lerobot.utils.rotation import Rotation
class Clutch:
"""Engage-relative clutch for both position AND orientation.
Latch an origin on engage, then track the base-frame delta from it, applied
independently to position and orientation. State:
- ``_last_commanded_pos`` / ``_last_commanded_rot``: last commanded EE pose; held
while disengaged so the arm freezes where it was left.
- ``_home_pos`` / ``_home_rot``: latched on engage — the EE pose the delta applies to.
The position comes from the arm's MEASURED pose when the caller provides it (so an
arm that moved while disengaged is not snapped back to a stale command); the
orientation always comes from the last commanded rotation (see NOTE below).
- ``_origin_pos`` / ``_origin_rot``: latched on engage — the controller pose the delta
is measured against.
Each engaged frame :meth:`rebase` returns::
pos = home_pos + (grip_pos - origin_pos) # 1:1 controller -> EE translation
rot = (R_ctrl @ R_origin ^ -1) @ R_home # base-frame delta, left-composed
On the engage edge the output is exactly the home pose (no teleport). The orientation
delta is left-composed (base frame), so hand rotation about base Z maps to EE rotation
about base Z. A re-clutch latches a fresh home/origin.
NOTE: ``_home_rot`` is the last *commanded* orientation even when the measured pose is
supplied: the 5-DOF SO-101 tracks orientation only softly, so its measured wrist
orientation persistently differs from the command, and latching the measurement would
inject that offset into the commanded signal on every re-clutch. Position has no such
tracking gap, and there latching the measurement is what prevents the snap-back.
"""
def __init__(self, home_base_T_ee: np.ndarray): # noqa: N803
# Seed the held pose from the arm's measured startup EE pose so the first
# engage latches home there (no jump on the first squeeze).
home = np.asarray(home_base_T_ee, dtype=float)
self._last_commanded_pos = home[:3, 3].copy()
self._last_commanded_rot = Rotation.from_matrix(home[:3, :3])
self._home_pos = self._last_commanded_pos.copy()
self._home_rot = self._last_commanded_rot
self._origin_pos = np.zeros(3, dtype=float)
self._origin_rot = Rotation.from_quat(np.array([0.0, 0.0, 0.0, 1.0]))
def engage(
self,
grip_pos: np.ndarray,
grip_quat: np.ndarray,
measured_base_T_ee: np.ndarray | None = None, # noqa: N803
) -> None:
"""Latch the engage home (where the arm is now) and controller origin.
Pass ``measured_base_T_ee`` (FK of the measured joints) so the home POSITION is
where the arm physically is — if the arm moved while disengaged (gravity sag,
external contact), latching the stale last-commanded position would make the
first engaged frame command a full-speed jump back to it. The home ORIENTATION
always stays the last commanded one (see the class NOTE).
"""
if measured_base_T_ee is not None:
self._home_pos = np.asarray(measured_base_T_ee, dtype=float)[:3, 3].copy()
else:
self._home_pos = self._last_commanded_pos.copy()
self._home_rot = self._last_commanded_rot
self._origin_pos = np.asarray(grip_pos, dtype=float).copy()
self._origin_rot = Rotation.from_quat(np.asarray(grip_quat, dtype=float))
def rebase(self, grip_pos: np.ndarray, grip_quat: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Return the absolute base-frame EE target ``(pos [m], quat [xyzw])`` for this frame."""
pos = self._home_pos + (np.asarray(grip_pos, dtype=float) - self._origin_pos)
rot_ctrl = Rotation.from_quat(np.asarray(grip_quat, dtype=float))
rot = (rot_ctrl * self._origin_rot.inv()) * self._home_rot
self._last_commanded_pos = pos.copy()
self._last_commanded_rot = rot
return pos, rot.as_quat()
@@ -0,0 +1,135 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Configuration dataclasses for NVIDIA Isaac Teleop-backed teleoperators.
:class:`IsaacTeleopConfig` holds the shared fields; each device adds its own subclass
(e.g. :class:`XRControllerConfig`, :class:`SO101LeaderArmConfig`).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import ClassVar
from lerobot.teleoperators.config import TeleoperatorConfig
@dataclass(kw_only=True)
class IsaacTeleopConfig(TeleoperatorConfig):
"""Shared config for all Isaac Teleop-backed teleoperators.
Uses its own draccus ``_choice_registry`` (decoupled from the global
:class:`TeleoperatorConfig` one) so ``--teleop.type`` on a field typed
``IsaacTeleopConfig`` resolves against ONLY the Isaac devices — letting them claim
short names (``xr_controller``, ``so101_leader``) without colliding with the global
registry. These devices are selected by the example scripts, not routed through
``make_teleoperator_from_config``.
"""
_choice_registry: ClassVar[dict] = {}
app_name: str = "LeTeleop"
"""Application name for the OpenXR / Isaac Teleop session."""
auto_launch_cloudxr: bool = True
"""Auto-launch the CloudXR runtime on :meth:`connect`. Set ``False`` (or export
``LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1``, which wins) when CloudXR runs externally.
"""
cloudxr_env_file: str | None = None
"""Optional CloudXR device-profile ``.env`` (an INPUT profile selecting the headset
transport) passed to ``CloudXRLauncher``. ``None`` keeps the default auto-WebRTC profile.
"""
# Static rebase from the OpenXR controller anchor frame (X=Right, Y=Up, Z=Backward) into the
# robot base frame (X=Forward, Y=Left, Z=Up). A proper rotation (det=+1): controller motion
# forward -> robot +X, right -> robot -Y (i.e. rightward), up -> robot +Z.
_DEFAULT_BASE_T_ANCHOR: list[list[float]] = [
[0.0, 0.0, -1.0, 0.0],
[-1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]
@IsaacTeleopConfig.register_subclass("xr_controller")
@dataclass(kw_only=True)
class XRControllerConfig(IsaacTeleopConfig):
"""Config for Isaac Teleop XR (VR) controller teleoperation.
Exposes the raw base-frame grip pose, squeeze, and trigger via ``ControllersSource``.
No retargeters: the clutch and gripper mapping live in the owning loop.
"""
hand_side: str = "right"
"""Which controller hand to use: ``"left"`` or ``"right"``. A plain ``str`` (validated in
``__post_init__``) because draccus cannot decode ``Literal``-typed fields from the CLI."""
clutch_threshold: float = 0.5
"""Squeeze value above which the owning loop's clutch engages (held-to-enable). The
device reports only the raw squeeze; the threshold is applied by the loop."""
base_T_anchor: list[list[float]] = field( # noqa: N815 (frameA_T_frameB transform-matrix convention)
# Fresh copy per instance: returning the module-level list itself would alias one
# mutable matrix across every config.
default_factory=lambda: [row.copy() for row in _DEFAULT_BASE_T_ANCHOR]
)
"""Static 4x4 [row-major] transform rebasing the OpenXR controller anchor frame into
the robot base frame. Defaults to OpenXR (X=Right, Y=Up, Z=Backward) -> robot
(X=Forward, Y=Left, Z=Up). Plain nested lists so the config stays serializable.
"""
def __post_init__(self):
if self.hand_side not in ("left", "right"):
raise ValueError(f"hand_side must be 'left' or 'right', got {self.hand_side!r}")
# Provisional gripper open/close endpoints [rad], normalizing the streamed gripper angle
# into the follower's RANGE_0_100 jaw target. Derived from the so101_leader plugin README's
# example calibration (home_ticks=2048, range 2000..3000; angle = (ticks-home)*2*pi/4096).
_DEFAULT_GRIPPER_OPEN_RAD = -0.074
_DEFAULT_GRIPPER_CLOSE_RAD = 1.460
@IsaacTeleopConfig.register_subclass("so101_leader")
@dataclass(kw_only=True)
class SO101LeaderArmConfig(IsaacTeleopConfig):
"""Config for an Isaac Teleop SO-101 *leader arm* (generic joint-space device).
Mirrors the leader's joint angles 1:1 onto a follower SO-101. The leader state is
streamed in radians by the native ``so101_leader`` plugin and read via a
``JointStateSource``; the device converts arm joints to degrees and the gripper to the
follower's RANGE_0_100 jaw target (no IK/clutch/retargeter on the LeRobot side).
"""
port: str = ""
"""Serial port of the physical LEADER arm (e.g. ``/dev/ttyACM1``), forwarded to the
plugin (which reads the servos) when the example launches it. Empty -> the plugin runs
its synthetic trajectory."""
collection_id: str = "so101_leader"
"""Tensor collection id the leader plugin pushes on; must match the running
``so101_leader`` plugin (its second positional arg, default ``"so101_leader"``)."""
gripper_open_rad: float = _DEFAULT_GRIPPER_OPEN_RAD
"""Leader gripper angle [rad] at fully OPEN -> follower jaw 100. Provisional default;
set from the plugin's ``calibrate`` subcommand. See ``_DEFAULT_GRIPPER_OPEN_RAD``."""
gripper_close_rad: float = _DEFAULT_GRIPPER_CLOSE_RAD
"""Leader gripper angle [rad] at fully CLOSED -> follower jaw 0. Provisional default;
set from the plugin's ``calibrate`` subcommand. See ``_DEFAULT_GRIPPER_CLOSE_RAD``."""
@@ -0,0 +1,186 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""SO-101 leader-arm device for NVIDIA Isaac Teleop, exposed to LeRobot.
The leader is a back-drivable SO-101 whose six joint angles are streamed (in radians) by
the native ``so101_leader`` plugin; this device reads them via a ``JointStateSource`` and
converts them into follower-ready ``{joint}.pos``. Same kinematics as the follower, so it
needs no retargeting — a 1:1 joint mirror, direct joint drive.
Units (converted in the device so the output is always follower-valid):
* arm joints: ``rad2deg`` — correct only if the leader's calibrated zero and the follower's
homing map to the same physical zero (the standard same-hardware assumption).
* gripper: normalized from ``[gripper_open_rad, gripper_close_rad]`` to RANGE_0_100.
``isaacteleop`` imports are guarded behind the availability flag so this module — and the
pure :func:`leader_joints_to_robot_action` converter — import without it (construction
fails fast via the base class).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from lerobot.types import RobotAction
from .base import _GRIPPER_MOTOR_SCALE, IsaacTeleopTeleoperator, _isaacteleop_available
from .config_isaac_teleop import SO101LeaderArmConfig
if TYPE_CHECKING or _isaacteleop_available:
from isaacteleop.retargeting_engine.deviceio_source_nodes import JointStateSource
from isaacteleop.retargeting_engine.interface import OutputCombiner
else:
JointStateSource = None
OutputCombiner = None
# Canonical SO-101 DOF names and order — matches the plugin stream and the follower's motor
# order. Passed to the ``JointStateSource`` as its output layout; the source maps by name and
# :func:`_joints_group_to_rad` reads back by name, so a layout mismatch can't mislabel a DOF.
SO101_LEADER_JOINTS = [
"shoulder_pan",
"shoulder_lift",
"elbow_flex",
"wrist_flex",
"wrist_roll",
"gripper",
]
def leader_joints_to_robot_action(
joints_rad: dict[str, float],
*,
gripper_joint: str,
gripper_open_rad: float,
gripper_close_rad: float,
) -> RobotAction:
"""Convert streamed leader joint angles [rad] to follower-ready ``{joint}.pos``.
Pure (no ``isaacteleop``, no I/O). Iteration follows ``joints_rad`` insertion order, so
pass it in :data:`SO101_LEADER_JOINTS` order for a stable layout. Arm joints are
converted ``rad2deg``; ``gripper_joint`` is normalized from
``[gripper_open_rad, gripper_close_rad]`` to RANGE_0_100 (clipped).
"""
action: RobotAction = {}
span = gripper_close_rad - gripper_open_rad
for name, rad in joints_rad.items():
if name == gripper_joint:
# Closedness c=0 at open, c=1 at closed; invert to the follower's 100=open jaw.
closedness = 0.0 if span == 0.0 else (rad - gripper_open_rad) / span
closedness = min(1.0, max(0.0, closedness))
action[f"{name}.pos"] = (1.0 - closedness) * _GRIPPER_MOTOR_SCALE
else:
action[f"{name}.pos"] = float(np.rad2deg(rad))
return action
def _joints_group_to_rad(joints) -> dict[str, float]:
"""Read a ``JointStateSource`` output group into ``{joint_name: angle [rad]}``.
Pure (duck-typed on the group). The group is positional but each slot carries its joint
name in ``group.group_type.types``; we key off those names (not a positional index) so a
layout mismatch surfaces as a wrong/missing key here rather than a mislabeled DOF.
"""
names = [t.name for t in joints.group_type.types]
return {name: float(joints[i]) for i, name in enumerate(names)}
class SO101LeaderArm(IsaacTeleopTeleoperator):
"""SO-101 leader-arm teleoperator (joint-space), direct joint mirror to the follower.
Reads the six joint angles off a single ``JointStateSource`` each frame; no retargeter,
no clutch. When the leader is not streaming, :meth:`get_action` returns the held-last
joints and :attr:`is_tracking` is ``False`` so the owning loop can hold the follower.
"""
config_class = SO101LeaderArmConfig
name = "isaac_teleop_so101_leader"
def __init__(self, config: SO101LeaderArmConfig):
super().__init__(config)
self.config: SO101LeaderArmConfig = config
# Held-last joint angles [rad], seeded at zero (URDF/home pose) so the first frames
# before the plugin starts pushing read as the home pose, not garbage.
self._last_joints_rad: dict[str, float] = dict.fromkeys(SO101_LEADER_JOINTS, 0.0)
# Whether the most recent get_action() read live leader data (vs held-last).
self._is_tracking = False
# ------------------------------------------------------------------
# Pipeline construction
# ------------------------------------------------------------------
def _build_pipeline(self) -> OutputCombiner:
"""Build the joint-mirror pipeline: a single ``JointStateSource`` leaf that converts
the raw stream into a name-keyed joint group. No retargeter (shared kinematics)."""
source = JointStateSource(
name="so101_leader",
collection_id=self.config.collection_id,
joint_names=SO101_LEADER_JOINTS,
)
return OutputCombiner({"joints": source.output(JointStateSource.JOINTS)})
# ------------------------------------------------------------------
# Action features
# ------------------------------------------------------------------
@property
def action_features(self) -> dict[str, type]:
# Matches the serial SOLeader's action features so this is a drop-in joint-space
# leader: one float `{joint}.pos` per DOF, sendable straight to an SO-101 follower.
return {f"{name}.pos": float for name in SO101_LEADER_JOINTS}
@property
def feedback_features(self) -> dict[str, type]:
return {}
@property
def is_tracking(self) -> bool:
"""Whether the last :meth:`get_action` read live leader data (vs held-last)."""
return self._is_tracking
# ------------------------------------------------------------------
# Action extraction
# ------------------------------------------------------------------
def get_action(self) -> RobotAction:
"""Step the session and return the leader joints as follower-ready ``{joint}.pos``.
When the leader is streaming, the live angles are cached and converted; otherwise the
held-last angles are reused and :attr:`is_tracking` is set ``False``.
"""
result = self._step(execution_events=self._running_events())
joints = result["joints"]
# The JointStateSource output is Optional: absent (is_none) when the device is
# inactive. Treat that as "not tracking" and reuse the held-last angles.
self._is_tracking = not getattr(joints, "is_none", False)
if self._is_tracking:
try:
self._last_joints_rad = _joints_group_to_rad(joints)
except (AttributeError, IndexError, KeyError, TypeError, ValueError):
# A partially-populated / malformed group on an odd frame: keep held-last, but
# report it as not-tracking so the loop holds the follower rather than trusting it.
self._is_tracking = False
return leader_joints_to_robot_action(
self._last_joints_rad,
gripper_joint="gripper",
gripper_open_rad=self.config.gripper_open_rad,
gripper_close_rad=self.config.gripper_close_rad,
)
@@ -0,0 +1,204 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""XR (VR) controller device for NVIDIA Isaac Teleop, exposed to LeRobot.
A deliberately thin reader: exposes the raw controller grip pose off
``ControllersSource`` (statically rebased into the robot base frame by
``ControllerTransform``), plus squeeze and trigger. No retargeters and no clutch —
the clutch rebasing and gripper mapping live downstream in the owning loop, so this
device is stateless across frames.
``isaacteleop`` imports are guarded behind the availability flag so this module imports
without it (construction fails fast via the base class).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy as np
from lerobot.types import RobotAction
from .base import IsaacTeleopTeleoperator, _isaacteleop_available
from .config_isaac_teleop import XRControllerConfig
if TYPE_CHECKING or _isaacteleop_available:
from isaacteleop.retargeting_engine.deviceio_source_nodes import ControllersSource
from isaacteleop.retargeting_engine.interface import OutputCombiner, TensorGroup, ValueInput
from isaacteleop.retargeting_engine.tensor_types import TransformMatrix
from isaacteleop.retargeting_engine.tensor_types.indices import ControllerInputIndex
else:
ControllersSource = None
OutputCombiner = None
TensorGroup = None
ValueInput = None
TransformMatrix = None
ControllerInputIndex = None
# Source-node name for the static base_T_anchor rebase input fed via
# ``TeleopSession.step(external_inputs=...)`` each frame.
_BASE_T_ANCHOR_INPUT = "base_T_anchor"
class XRController(IsaacTeleopTeleoperator):
"""Raw XR controller grip-pose teleoperator (base-frame), no retargeters.
Reads the raw grip pose + squeeze + trigger off a ``ControllersSource`` rebased into
the robot base frame. :meth:`get_action` returns the absolute base-frame grip pose
untouched; the owning loop owns the clutch and gripper mapping.
"""
config_class = XRControllerConfig
name = "isaac_teleop_controller"
def __init__(self, config: XRControllerConfig):
super().__init__(config)
self.config: XRControllerConfig = config
# Constant base_T_anchor input, built once in connect() (a TensorGroup is heavy and
# isaacteleop-backed) and reused every step.
self._external_inputs: dict[str, Any] | None = None
# Whether the last get_action() read a tracked controller; the owning loop polls this
# to wait for the operator to connect before driving the arm.
self._is_tracking = False
# ------------------------------------------------------------------
# Pipeline construction
# ------------------------------------------------------------------
def _build_pipeline(self) -> OutputCombiner:
"""Build the raw-grip-pose pipeline: a ``ControllersSource`` rebased into the base
frame by ``ControllerTransform``, exposed verbatim as ``"controller"``. No retargeters.
"""
side = self.config.hand_side
controller_key = f"controller_{side}"
controllers = ControllersSource(name="controllers")
# Static base_T_anchor rebase fed via external_inputs each step.
xform = ValueInput(_BASE_T_ANCHOR_INPUT, TransformMatrix())
transformed = controllers.transformed(xform.output("value"))
ctrl = transformed.output(controller_key)
return OutputCombiner({"controller": ctrl})
def _build_external_inputs(self) -> dict[str, Any]:
"""Materialize the constant ``base_T_anchor`` external input (once, in connect)."""
tg = TensorGroup(TransformMatrix())
tg[0] = np.asarray(self.config.base_T_anchor, dtype=np.float32)
return {_BASE_T_ANCHOR_INPUT: {"value": tg}}
def connect(self, calibrate: bool = True) -> None:
super().connect(calibrate=calibrate)
try:
self._external_inputs = self._build_external_inputs()
except Exception:
# Roll the session/runtime back so a failed connect() leaves no half-state
# (a live session behind a raised connect would leak the CloudXR runtime).
self.disconnect()
raise
# ------------------------------------------------------------------
# Action features
# ------------------------------------------------------------------
@property
def action_features(self) -> dict:
return {
"grip_pos": {
"dtype": "float32",
"shape": (3,),
"names": {"x": 0, "y": 1, "z": 2},
},
"grip_quat": {
"dtype": "float32",
"shape": (4,),
"names": {"qx": 0, "qy": 1, "qz": 2, "qw": 3},
},
# ``get_action`` returns scalars for these two, so the advertised
# shape is () (0-d) to stay consistent with the returned values.
"squeeze": {
"dtype": "float32",
"shape": (),
"names": None,
},
"trigger": {
"dtype": "float32",
"shape": (),
"names": None,
},
}
@property
def feedback_features(self) -> dict:
return {}
@property
def is_tracking(self) -> bool:
"""Whether the last :meth:`get_action` read a tracked controller. ``False`` until the
headset is connected over CloudXR and its controllers are live; the owning loop polls
it to wait for the operator before commanding the arm."""
return self._is_tracking
# ------------------------------------------------------------------
# Action extraction
# ------------------------------------------------------------------
def get_action(self) -> RobotAction:
"""Step the session and return the raw base-frame grip pose.
Reads the grip pose + squeeze + trigger off the transformed controller stream (with
the constant ``base_T_anchor`` rebase). When the controller is not tracked, returns
identity pose and squeeze/trigger = 0.0 so the owning loop freezes the arm.
Returns:
``{"grip_pos": (3,) [m], "grip_quat": (4,) [qx,qy,qz,qw], "squeeze": float,
"trigger": float}`` — pose in the robot base frame; squeeze/trigger in ``[0, 1]``.
"""
result = self._step(execution_events=self._running_events(), external_inputs=self._external_inputs)
# Optional controller group is None until the headset is connected and its controllers
# are live; expose that as is_tracking so the loop can wait before driving the arm.
controller = result["controller"]
grip_pos = np.zeros(3, dtype=np.float32)
grip_quat = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float32)
squeeze = 0.0
trigger = 0.0
self._is_tracking = not getattr(controller, "is_none", False)
if self._is_tracking:
# Read ALL four fields into locals before committing any of them: a failure on a
# partially-populated frame must not mix live values with the safe defaults (a
# live squeeze paired with a defaulted trigger=0.0 would keep the clutch engaged
# while commanding the gripper fully open, dropping whatever is grasped). On
# failure the defaults stand untouched and the frame reports not-tracked.
try:
pos = np.asarray(controller[ControllerInputIndex.GRIP_POSITION], dtype=np.float32)
quat = np.asarray(controller[ControllerInputIndex.GRIP_ORIENTATION], dtype=np.float32)
squeeze_val = float(controller[ControllerInputIndex.SQUEEZE_VALUE])
trigger_val = float(controller[ControllerInputIndex.TRIGGER_VALUE])
except (IndexError, KeyError, TypeError, ValueError):
self._is_tracking = False
else:
grip_pos, grip_quat = pos, quat
squeeze, trigger = squeeze_val, trigger_val
return {
"grip_pos": grip_pos,
"grip_quat": grip_quat,
"squeeze": squeeze,
"trigger": trigger,
}
@@ -0,0 +1,87 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Processor step that maps XR controller actions to robot EE targets.
Analogous to ``MapPhoneActionToRobotAction``, this bridges the clutch-rebased EE pose to
the IK pipeline's input contract (``EEBoundsAndSafety`` -> ``InverseKinematicsEEToJoints``).
Pure (no ``isaacteleop``), so it is unit-testable without the XR runtime.
"""
from __future__ import annotations
from dataclasses import dataclass
from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.processor import ProcessorStepRegistry, RobotActionProcessorStep
from lerobot.types import RobotAction
from lerobot.utils.rotation import Rotation
from .base import _GRIPPER_MOTOR_SCALE
@ProcessorStepRegistry.register("map_xr_controller_action_to_robot_action")
@dataclass
class MapXRControllerActionToRobotAction(RobotActionProcessorStep):
"""Maps an absolute base-frame EE pose + gripper closedness to the IK input contract.
Pure, stateless rename (the owning loop's clutch already produced the absolute base-frame
target). Each frame it writes:
- ``ee.x/y/z`` = ``ee_pose[:3]`` (position [m]);
- ``ee.wx/wy/wz`` = rotvec of ``ee_pose[3:7]`` (orientation; the IK tracks it softly at a
small ``orientation_weight`` on the 5-DOF SO-101);
- ``ee.gripper_pos`` = ``(1 - closedness) * _GRIPPER_MOTOR_SCALE`` (jaw target [0, 100],
RANGE_0_100 where 100 = open, so closedness is inverted).
Input keys: ``ee_pose`` ``(7,)`` ``[x,y,z,qx,qy,qz,qw]``, ``closedness`` float in [0, 1].
"""
def action(self, action: RobotAction) -> RobotAction:
ee_pose = action.pop("ee_pose")
closedness = float(action.pop("closedness"))
action["ee.x"] = float(ee_pose[0])
action["ee.y"] = float(ee_pose[1])
action["ee.z"] = float(ee_pose[2])
# Orientation target as a rotvec (quat [qx,qy,qz,qw] -> axis-angle); the IK
# consumes ee.w* as a rotvec and tracks it with orientation_weight.
rotvec = Rotation.from_quat(ee_pose[3:7]).as_rotvec()
action["ee.wx"] = float(rotvec[0])
action["ee.wy"] = float(rotvec[1])
action["ee.wz"] = float(rotvec[2])
# Inverted: closedness c=1 (closed) -> 0, c=0 (open) -> 100 (SO-101 calibration).
action["ee.gripper_pos"] = (1.0 - closedness) * _GRIPPER_MOTOR_SCALE
return action
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
for feat in ["ee_pose", "closedness"]:
features[PipelineFeatureType.ACTION].pop(feat, None)
for feat in [
"ee.x",
"ee.y",
"ee.z",
"ee.wx",
"ee.wy",
"ee.wz",
"ee.gripper_pos",
]:
features[PipelineFeatureType.ACTION][feat] = PolicyFeature(type=FeatureType.ACTION, shape=(1,))
return features
@@ -0,0 +1,73 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Save the current SO-101 joint positions as the reset-origin pose (override).
Move the arm to the desired reset pose by hand (torque off), then run this script to write
those joints to a per-arm file in the LeRobot cache. ``teleoperate.py`` / ``record.py`` load
it on startup (matched by ``--robot.id``) as the reset target instead of the defaults.
Usage::
# 1. Move arm to desired reset pose by hand
python -m examples.isaac_teleop_to_so101.override_reset_pose [--port /dev/ttyACM0] [--id so101_follower_arm]
# 2. Launch teleop with the SAME --robot.id — it will now reset to this pose on startup
python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm --teleop.type=xr_controller
"""
import argparse
import json
from pathlib import Path
from lerobot.robots.so_follower import SO100Follower, SO100FollowerConfig
from .common import RESET_POSE_FILE
def parse_args():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--port", type=str, default="/dev/ttyACM0")
parser.add_argument("--id", type=str, default="so101_follower_arm")
return parser.parse_args()
def main():
args = parse_args()
robot = SO100Follower(SO100FollowerConfig(port=args.port, id=args.id, use_degrees=True))
robot.connect()
# Always disconnect the follower so a failure never leaks the serial connection.
try:
obs = robot.get_observation()
motor_names = list(robot.bus.motors.keys())
pose = {name: float(obs[f"{name}.pos"]) for name in motor_names}
finally:
robot.disconnect()
print("Current joint positions:")
for name, val in pose.items():
print(f" {name:20s}: {val:.2f}")
reset_pose_file = Path(RESET_POSE_FILE.format(robot_name=robot.name, robot_id=robot.id))
reset_pose_file.parent.mkdir(parents=True, exist_ok=True)
reset_pose_file.write_text(json.dumps(pose, indent=2))
print(f"\nSaved to {reset_pose_file}")
if __name__ == "__main__":
main()
+321
View File
@@ -0,0 +1,321 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Record a LeRobot dataset via NVIDIA Isaac Teleop -> SO-101.
Runs ``teleoperate.py``'s control loop while also saving each frame to a LeRobot dataset.
``--teleop.type`` selects the device (``xr_controller`` | ``so101_leader``) as in
``teleoperate.py``.
Usage::
# XR (VR) controller: clutch + soft-orientation IK
python -m examples.isaac_teleop_to_so101.record \\
--robot.type=so101_follower \\
--robot.port=/dev/ttyACM0 \\
--robot.id=so101_follower_arm \\
--teleop.type=xr_controller \\
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \\
--dataset.repo_id=<hf_user>/<dataset_name> \\
--dataset.single_task="Pick up vial from rack on the left side" \\
--dataset.num_episodes=3 \\
--dataset.episode_time_s=20 \\
--dataset.reset_time_s=5
# SO-101 leader arm: 1:1 joint mirror (real leader on /dev/ttyACM1)
python -m examples.isaac_teleop_to_so101.record \\
--robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm \\
--teleop.type=so101_leader --teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \\
--launch_plugin=/path/to/IsaacTeleop/install/plugins/so101_leader/so101_leader_plugin \\
--dataset.repo_id=<hf_user>/<dataset_name> --dataset.single_task="Pick up the cube" \\
--dataset.num_episodes=3 --dataset.episode_time_s=20 --dataset.reset_time_s=5
The loop/launch knobs mirror ``teleoperate.py`` (tagged ``[xr]`` / ``[leader]`` below).
Keyboard shortcuts: Right/n = end episode early and save, Left/r = discard + re-record,
Esc/q = stop after the current episode. All frames are recorded (including hold frames).
"""
import logging
import time
from dataclasses import asdict, dataclass
from pprint import pformat
from lerobot.cameras import CameraConfig # noqa: F401
from lerobot.cameras.opencv import OpenCVCameraConfig # noqa: F401
from lerobot.common.control_utils import sanity_check_dataset_robot_compatibility
from lerobot.configs import parser
from lerobot.configs.dataset import DatasetRecordConfig
from lerobot.datasets import (
LeRobotDataset,
VideoEncodingManager,
aggregate_pipeline_dataset_features,
create_initial_features,
safe_stop_image_writer,
)
from lerobot.processor import make_default_processors
from lerobot.robots import RobotConfig
from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
from lerobot.utils.constants import ACTION, OBS_STR
from lerobot.utils.feature_utils import build_dataset_frame, combine_feature_dicts
from lerobot.utils.robot_utils import precise_sleep
from lerobot.utils.utils import init_logging
from .common import (
ALIGN_DURATION_S,
RESET_DURATION_S,
Device,
HoldLatch,
build_device,
init_keyboard_listener,
)
from .isaac_teleop import IsaacTeleopConfig
@dataclass
class RecordConfig:
"""CLI config for Isaac Teleop -> SO-101 dataset recording.
``--robot.*`` / ``--teleop.*`` / ``--dataset.*`` configure the follower, device, and
recording; the loop/launch knobs below carry the same ``[xr]`` / ``[leader]`` tags as
``teleoperate.py``. Use ``--flag=false`` for booleans (draccus style).
"""
robot: RobotConfig
# --teleop.type=xr_controller|so101_leader, resolved against IsaacTeleopConfig's registry.
teleop: IsaacTeleopConfig
dataset: DatasetRecordConfig
# [leader] Path to the so101_leader plugin binary to spawn after CloudXR is up (it then
# inherits the runtime env). None (default) -> assume the plugin already runs externally.
launch_plugin: str | None = None
# [xr] Slew all joints to the reset pose before the first episode (--reset_to_origin=false to
# keep the arm where it is). After the slew the clutch seeds its home from the measured pose.
reset_to_origin: bool = True
# [xr] Duration [s] of the reset-to-origin slew (passed through to setup_xr).
reset_duration: float = RESET_DURATION_S
# [leader] Slew the follower to the leader's first pose before mirroring (--align=false to
# begin the 1:1 mirror immediately; the follower may snap).
align: bool = True
# [leader] Duration [s] of the startup alignment slew.
align_duration: float = ALIGN_DURATION_S
# Resume recording on an existing (previously interrupted) dataset.
resume: bool = False
@safe_stop_image_writer
def _record_loop(
robot,
device: Device,
motor_names: list[str],
events: dict,
fps: int,
dataset: LeRobotDataset | None = None,
control_time_s: float = 0.0,
single_task: str | None = None,
) -> None:
"""Run one episode (or reset phase) of the control loop.
When ``dataset`` is None the loop still controls the robot (so the operator
can reposition the arm during the reset window) but does not record frames.
"""
control_interval = 1.0 / fps
timestamp = 0.0
start_t = time.perf_counter()
record_frames = dataset is not None
hold = HoldLatch(motor_names)
while timestamp < control_time_s:
loop_start = time.perf_counter()
if events["exit_early"]:
events["exit_early"] = False
break
obs = robot.get_observation()
if record_frames:
observation_frame = build_dataset_frame(dataset.features, obs, prefix=OBS_STR)
# Device idle (XR clutch disengaged, or leader stream stale) -> hold the pose
# latched on the active->idle edge.
action = hold.resolve(device.compute(obs), obs)
robot.send_action(action)
if record_frames:
action_frame = build_dataset_frame(dataset.features, action, prefix=ACTION)
dataset.add_frame({**observation_frame, **action_frame, "task": single_task})
dt_s = time.perf_counter() - loop_start
precise_sleep(max(control_interval - dt_s, 0.0))
timestamp = time.perf_counter() - start_t
@parser.wrap()
def record(cfg: RecordConfig) -> LeRobotDataset:
init_logging()
logging.info(pformat(asdict(cfg)))
# Connect the follower, build the selected Isaac device, and run its pre-loop startup
# (reset slew / leader align) — shared with teleoperate.py.
robot, device, motor_names = build_device(cfg)
# Build dataset feature spec. The IK pipeline lives inside device.compute(), so the
# action features are exactly robot.action_features (joint positions in degrees).
teleop_proc, _, obs_proc = make_default_processors()
dataset_features = combine_feature_dicts(
aggregate_pipeline_dataset_features(
pipeline=teleop_proc,
initial_features=create_initial_features(action=robot.action_features),
use_videos=cfg.dataset.video,
),
aggregate_pipeline_dataset_features(
pipeline=obs_proc,
initial_features=create_initial_features(observation=robot.observation_features),
use_videos=cfg.dataset.video,
),
)
num_cameras = len(robot.cameras) if hasattr(robot, "cameras") else 0
image_writer_threads = cfg.dataset.num_image_writer_threads_per_camera * num_cameras
dataset: LeRobotDataset | None = None
listener = None
try:
if cfg.resume:
dataset = LeRobotDataset.resume(
cfg.dataset.repo_id,
root=cfg.dataset.root,
batch_encoding_size=cfg.dataset.video_encoding_batch_size,
rgb_encoder=cfg.dataset.rgb_encoder,
depth_encoder=cfg.dataset.depth_encoder,
encoder_threads=cfg.dataset.encoder_threads,
streaming_encoding=cfg.dataset.streaming_encoding,
encoder_queue_maxsize=cfg.dataset.encoder_queue_maxsize,
image_writer_processes=cfg.dataset.num_image_writer_processes if num_cameras > 0 else 0,
image_writer_threads=image_writer_threads if num_cameras > 0 else 0,
)
sanity_check_dataset_robot_compatibility(dataset, robot, cfg.dataset.fps, dataset_features)
else:
cfg.dataset.stamp_repo_id()
dataset = LeRobotDataset.create(
cfg.dataset.repo_id,
cfg.dataset.fps,
root=cfg.dataset.root,
robot_type=robot.name,
features=dataset_features,
use_videos=cfg.dataset.video,
image_writer_processes=cfg.dataset.num_image_writer_processes,
image_writer_threads=image_writer_threads,
batch_encoding_size=cfg.dataset.video_encoding_batch_size,
rgb_encoder=cfg.dataset.rgb_encoder,
depth_encoder=cfg.dataset.depth_encoder,
encoder_threads=cfg.dataset.encoder_threads,
streaming_encoding=cfg.dataset.streaming_encoding,
encoder_queue_maxsize=cfg.dataset.encoder_queue_maxsize,
)
listener, events = init_keyboard_listener()
loop_kwargs = {
"robot": robot,
"device": device,
"motor_names": motor_names,
"events": events,
"fps": cfg.dataset.fps,
"single_task": cfg.dataset.single_task,
}
with VideoEncodingManager(dataset):
recorded_episodes = 0
while recorded_episodes < cfg.dataset.num_episodes and not events["stop_recording"]:
logging.info(f"Recording episode {dataset.num_episodes}")
_record_loop(
**loop_kwargs,
dataset=dataset,
control_time_s=cfg.dataset.episode_time_s,
)
# Reset window: give the operator time to reposition the scene.
# Skipped for the last episode (or if stop_recording was set).
if not events["stop_recording"] and (
recorded_episodes < cfg.dataset.num_episodes - 1 or events["rerecord_episode"]
):
logging.info("Reset the environment")
_record_loop(
**loop_kwargs,
dataset=None,
control_time_s=cfg.dataset.reset_time_s,
)
if events["rerecord_episode"]:
logging.info("Re-record episode")
events["rerecord_episode"] = False
events["exit_early"] = False
dataset.clear_episode_buffer()
continue
dataset.save_episode()
recorded_episodes += 1
finally:
logging.info("Stop recording")
# Hardware teardown FIRST, each step guarded: the arm must be freed promptly (not
# after a potentially long finalize/encode), a cleanup failure must not skip the
# follower disconnect (which is what disables torque), and neither must prevent
# the dataset from being finalized below.
try:
device.cleanup()
except Exception:
logging.exception("Device cleanup failed")
try:
if robot.is_connected:
robot.disconnect()
except Exception:
logging.exception("Robot disconnect failed")
# Restore the terminal before the (potentially long) finalize/encode.
if listener is not None:
try:
listener.stop()
except Exception:
logging.exception("Keyboard listener stop failed")
if dataset is not None:
dataset.finalize()
if cfg.dataset.push_to_hub:
if dataset is not None and dataset.num_episodes > 0:
dataset.push_to_hub(tags=cfg.dataset.tags, private=cfg.dataset.private)
else:
logging.warning("No episodes saved — skipping push to hub")
logging.info("Exiting")
return dataset
def main():
record()
if __name__ == "__main__":
main()
@@ -0,0 +1,117 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
"""Teleoperate an SO-101 follower arm via NVIDIA Isaac Teleop.
``lerobot-teleoperate``-style CLI (draccus): ``--teleop.type`` selects the Isaac device
(``xr_controller`` | ``so101_leader``), ``--robot.*`` the follower::
# XR (VR) controller: clutch + soft-orientation IK
python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower \
--robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm --teleop.type=xr_controller
# SO-101 leader arm: 1:1 joint mirror (real leader on /dev/ttyACM1)
python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower \
--robot.port=/dev/ttyACM0 --robot.id=so101_follower_arm --teleop.type=so101_leader \
--teleop.port=/dev/ttyACM1 --teleop.id=so101_leader_arm \
--launch_plugin=/code/Teleop/install/plugins/so101_leader/so101_leader_plugin
``--teleop.type`` resolves against the Isaac device registry (see :class:`IsaacTeleopConfig`),
distinct from the serial ``so101_leader``. The pipelines, clutch/IK/align internals, and
reset-pose behavior live in ``common.py``. Requires the ``isaacteleop`` package and an OpenXR
runtime (install instructions in this folder's ``README.md``).
"""
import time
from dataclasses import dataclass
from lerobot.configs import parser
from lerobot.robots import RobotConfig
from lerobot.robots.so_follower import SOFollowerConfig # noqa: F401 (registers so101_follower)
from lerobot.utils.robot_utils import precise_sleep
from .common import (
ALIGN_DURATION_S,
FPS,
RESET_DURATION_S,
HoldLatch,
build_device,
)
from .isaac_teleop import IsaacTeleopConfig
@dataclass
class TeleoperateConfig:
"""``lerobot-teleoperate``-style CLI for the Isaac Teleop -> SO-101 example.
The fields below are the loop/launch knobs (not part of either device's config); the
``[xr]`` / ``[leader]`` tags mark which device a knob applies to. Use ``--flag=false``
for booleans (draccus style).
"""
# Isaac Teleop input device + its knobs (--teleop.type=xr_controller|so101_leader,
# then --teleop.<field>=...). Resolved against IsaacTeleopConfig's own choice registry.
teleop: IsaacTeleopConfig
# SO-101 FOLLOWER arm (--robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=...).
robot: RobotConfig
# [leader] Path to the so101_leader plugin binary to spawn AFTER CloudXR is up (it then
# inherits the runtime env). None (default) -> assume the plugin already runs externally.
# The leader's serial port is --teleop.port (forwarded to the plugin; empty -> synthetic).
launch_plugin: str | None = None
# [xr] Slew all joints to a default reset pose before the loop (--reset_to_origin=false to
# keep the arm where it is). After the slew the clutch seeds its home from the measured pose.
reset_to_origin: bool = True
# [xr] Duration [s] of the reset-to-origin slew.
reset_duration: float = RESET_DURATION_S
# [leader] Slew the follower to the leader's first pose before mirroring (--align=false to
# begin the 1:1 mirror immediately; the follower may snap).
align: bool = True
# [leader] Duration [s] of the startup alignment slew.
align_duration: float = ALIGN_DURATION_S
@parser.wrap()
def teleoperate(cfg: TeleoperateConfig):
robot, device, motor_names = build_device(cfg)
hold = HoldLatch(motor_names)
try:
while True:
t0 = time.perf_counter()
obs = robot.get_observation()
# Idle (compute() -> None) holds the pose latched on the active->idle edge.
action = hold.resolve(device.compute(obs), obs)
robot.send_action(action)
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
except KeyboardInterrupt:
pass
finally:
# A failing device cleanup must not skip the follower disconnect (which is what
# disables torque on the arm).
try:
device.cleanup()
finally:
robot.disconnect()
def main():
teleoperate()
if __name__ == "__main__":
main()
+16 -7
View File
@@ -25,7 +25,7 @@ discord = "https://discord.gg/s3KuuzsPFb"
[project]
name = "lerobot"
version = "0.5.2"
version = "0.6.1"
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
dynamic = ["readme"]
license = { text = "Apache-2.0" }
@@ -125,6 +125,7 @@ hardware = [
]
viz = [
"rerun-sdk>=0.24.0,<0.34.0",
"foxglove-sdk>=0.25.1,<0.26.0",
]
# ── User-facing composite extras (map to CLI scripts) ─────
# lerobot-record, lerobot-replay, lerobot-calibrate, lerobot-teleoperate, etc.
@@ -163,6 +164,7 @@ pynput-dep = ["pynput>=1.7.8,<1.9.0"]
pyzmq-dep = ["pyzmq>=26.2.1,<28.0.0"]
motorbridge-dep = ["motorbridge>=0.3.2,<0.4.0"]
motorbridge-smart-servo-dep = ["motorbridge-smart-servo>=0.0.4,<0.1.0"]
timm-dep = ["timm>=1.0.0,<1.1.0"]
# Motors
feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0", "lerobot[pyserial-dep]", "lerobot[deepdiff-dep]"]
@@ -185,6 +187,11 @@ unitree_g1 = [
"lerobot[matplotlib-dep]",
"lerobot[pygame-dep]",
]
# Go2 talks plain DDS from the host — no bridge server, no extra deps beyond
# the SDK itself (cyclonedds-based, hence Linux-only).
unitree_go2 = [
"unitree_sdk2py>=1.0.1; sys_platform == 'linux'",
]
# reachy2-sdk caps grpcio<=1.73.1 and protobuf<=6.32.0; quarantined here so downstream users aren't held back. reachy2-sdk is unlikely to release new versions.
reachy2 = [
"reachy2_sdk>=1.0.15,<1.1.0",
@@ -218,11 +225,10 @@ groot = [
"lerobot[transformers-dep]",
"lerobot[peft-dep]",
"lerobot[diffusers-dep]",
"lerobot[dataset]", # NOTE: processor_groot builds a LeRobotDataset for relative-action training stats
"dm-tree>=0.1.8,<1.0.0",
"timm>=1.0.0,<1.1.0",
"lerobot[timm-dep]",
"decord>=0.6.0,<1.0.0; (platform_machine == 'AMD64' or platform_machine == 'x86_64')",
"ninja>=1.11.1,<2.0.0",
"flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin'"
]
sarm = ["lerobot[transformers-dep]", "pydantic>=2.0.0,<3.0.0", "faker>=33.0.0,<35.0.0", "lerobot[matplotlib-dep]", "lerobot[qwen-vl-utils-dep]"]
robometer = ["lerobot[transformers-dep]", "lerobot[qwen-vl-utils-dep]", "lerobot[peft-dep]"]
@@ -233,8 +239,10 @@ fastwam = [
"lerobot[transformers-dep]",
"lerobot[diffusers-dep]",
]
evo1 = ["lerobot[transformers-dep]"]
hilserl = ["lerobot[transformers-dep]", "lerobot[dataset]", "gym-hil>=0.1.14,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"]
vla_jepa = ["lerobot[transformers-dep]", "lerobot[diffusers-dep]", "lerobot[qwen-vl-utils-dep]"]
lingbot_va = ["lerobot[transformers-dep]", "lerobot[diffusers-dep]", "lerobot[accelerate-dep]"]
# Features
async = ["lerobot[grpcio-dep]", "lerobot[matplotlib-dep]"]
@@ -313,10 +321,12 @@ all = [
"lerobot[molmoact2]",
"lerobot[smolvla]",
"lerobot[fastwam]",
# "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn
"lerobot[groot]",
"lerobot[xvla]",
"lerobot[evo1]",
"lerobot[hilserl]",
"lerobot[vla_jepa]",
"lerobot[lingbot_va]",
"lerobot[async]",
"lerobot[dev]",
"lerobot[test]",
@@ -352,6 +362,7 @@ lerobot-edit-dataset="lerobot.scripts.lerobot_edit_dataset:main"
lerobot-setup-can="lerobot.scripts.lerobot_setup_can:main"
lerobot-annotate="lerobot.scripts.lerobot_annotate:main"
lerobot-rollout="lerobot.scripts.lerobot_rollout:main"
dog-nav="lerobot.navigation.dog_cli:main"
# ---------------- Tool Configurations ----------------
@@ -408,8 +419,6 @@ ignore = [
"__init__.py" = ["F401", "F403", "E402"]
# 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"]
"src/lerobot/policies/wall_x/**" = ["N801", "N812", "SIM102", "SIM108", "SIM210", "SIM211", "B006", "B007", "SIM118"] # Supprese these as they are coming from original Qwen2_5_vl code TODO(pepijn): refactor original
[tool.ruff.lint.isort]
combine-as-imports = true
known-first-party = ["lerobot"]
-729
View File
@@ -1,729 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=requirements-macos.txt requirements.in
#
-e .[all]
# via -[all]
absl-py==2.4.0
# via
# dm-control
# dm-env
# dm-tree
# labmaze
# mujoco
accelerate==1.13.0
# via
# lerobot
# peft
aiohappyeyeballs==2.6.1
# via aiohttp
aiohttp==3.13.3
# via fsspec
aiosignal==1.4.0
# via aiohttp
annotated-doc==0.0.4
# via
# fastapi
# typer
annotated-types==0.7.0
# via pydantic
anyio==4.12.1
# via
# httpx
# starlette
# watchfiles
asttokens==3.0.1
# via stack-data
attrs==25.4.0
# via
# aiohttp
# dm-tree
# jsonlines
# rerun-sdk
av==15.1.0
# via
# lerobot
# qwen-vl-utils
certifi==2026.2.25
# via
# httpcore
# httpx
# requests
# sentry-sdk
cffi==2.0.0
# via pymunk
cfgv==3.5.0
# via pre-commit
charset-normalizer==3.4.5
# via requests
click==8.3.1
# via
# typer
# uvicorn
# wandb
cloudpickle==3.1.2
# via gymnasium
cmake==4.1.3
# via lerobot
cmeel==0.59.0
# via
# cmeel-assimp
# cmeel-boost
# cmeel-console-bridge
# cmeel-octomap
# cmeel-qhull
# cmeel-tinyxml2
# cmeel-urdfdom
# cmeel-zlib
# coal-library
# eigenpy
# eiquadprog
# pin
# placo
# rhoban-cmeel-jsoncpp
cmeel-assimp==5.4.3.1
# via coal-library
cmeel-boost==1.87.0.1
# via
# coal-library
# eigenpy
# eiquadprog
# pin
cmeel-console-bridge==1.0.2.3
# via cmeel-urdfdom
cmeel-octomap==1.10.0
# via coal-library
cmeel-qhull==8.0.2.1
# via coal-library
cmeel-tinyxml2==10.0.0
# via cmeel-urdfdom
cmeel-urdfdom==4.0.1
# via pin
cmeel-zlib==1.3.1
# via cmeel-assimp
coal-library==3.0.1
# via pin
contourpy==1.3.3
# via
# lerobot
# matplotlib
coverage[toml]==7.13.4
# via pytest-cov
cycler==0.12.1
# via matplotlib
datasets==4.6.1
# via lerobot
debugpy==1.8.20
# via lerobot
decorator==5.2.1
# via ipython
deepdiff==8.6.1
# via lerobot
diffusers==0.35.2
# via lerobot
dill==0.4.0
# via
# datasets
# multiprocess
distlib==0.4.0
# via virtualenv
dm-control==1.0.37
# via gym-aloha
dm-env==1.6
# via dm-control
dm-tree==0.1.9
# via
# dm-control
# dm-env
docopt==0.6.2
# via num2words
draccus==0.10.0
# via lerobot
dynamixel-sdk==3.8.4
# via lerobot
eigenpy==3.10.3
# via coal-library
einops==0.8.2
# via lerobot
eiquadprog==1.2.9
# via placo
etils[epath,epy]==1.14.0
# via mujoco
executing==2.2.1
# via stack-data
faker==34.0.2
# via lerobot
farama-notifications==0.0.4
# via gymnasium
fastapi==0.135.1
# via
# lerobot
# teleop
feetech-servo-sdk==1.0.0
# via lerobot
filelock==3.25.0
# via
# datasets
# diffusers
# huggingface-hub
# python-discovery
# torch
# virtualenv
fonttools==4.61.1
# via matplotlib
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
fsspec[http]==2026.2.0
# via
# datasets
# etils
# huggingface-hub
# torch
gitdb==4.0.12
# via gitpython
gitpython==3.1.46
# via wandb
glfw==2.10.0
# via
# dm-control
# mujoco
grpcio==1.73.1
# via
# grpcio-tools
# lerobot
# reachy2-sdk
# reachy2-sdk-api
grpcio-tools==1.73.1
# via
# lerobot
# reachy2-sdk-api
gym-aloha==0.1.3
# via lerobot
gym-hil==0.1.13
# via lerobot
gym-pusht==0.1.6
# via lerobot
gymnasium==1.2.3
# via
# gym-aloha
# gym-hil
# gym-pusht
# lerobot
# metaworld
h11==0.16.0
# via
# httpcore
# uvicorn
hebi-py==2.11.0
# via lerobot
hf-xet==1.3.2
# via huggingface-hub
hidapi==0.14.0.post4
# via
# gym-hil
# lerobot
httpcore==1.0.9
# via httpx
httptools==0.7.1
# via uvicorn
httpx==0.28.1
# via
# datasets
# huggingface-hub
huggingface-hub==1.6.0
# via
# accelerate
# datasets
# diffusers
# lerobot
# peft
# tokenizers
# transformers
identify==2.6.17
# via pre-commit
idna==3.11
# via
# anyio
# httpx
# requests
# yarl
imageio[ffmpeg]==2.37.2
# via
# gym-aloha
# gym-hil
# lerobot
# metaworld
# scikit-image
imageio-ffmpeg==0.6.0
# via imageio
importlib-metadata==8.7.1
# via diffusers
iniconfig==2.3.0
# via pytest
ipython==9.11.0
# via meshcat
ipython-pygments-lexers==1.1.1
# via ipython
ischedule==1.2.7
# via placo
jedi==0.19.2
# via ipython
jinja2==3.1.6
# via torch
jsonlines==4.0.0
# via lerobot
kiwisolver==1.4.9
# via matplotlib
labmaze==1.0.6
# via dm-control
lazy-loader==0.5
# via scikit-image
librt==0.8.1
# via mypy
lxml==6.0.2
# via dm-control
markdown-it-py==4.0.0
# via rich
markupsafe==3.0.3
# via jinja2
matplotlib==3.10.8
# via lerobot
matplotlib-inline==0.2.1
# via ipython
mdurl==0.1.2
# via markdown-it-py
mergedeep==1.3.4
# via draccus
meshcat==0.3.2
# via placo
metaworld==3.0.0
# via lerobot
mock-serial==0.0.1
# via lerobot
mpmath==1.3.0
# via sympy
mujoco==3.5.0
# via
# dm-control
# gym-aloha
# gym-hil
# metaworld
multidict==6.7.1
# via
# aiohttp
# yarl
multiprocess==0.70.18
# via datasets
mypy==1.19.1
# via lerobot
mypy-extensions==1.1.0
# via
# mypy
# typing-inspect
networkx==3.6.1
# via
# scikit-image
# torch
nodeenv==1.10.0
# via pre-commit
num2words==0.5.14
# via lerobot
numpy==2.2.6
# via
# accelerate
# cmeel-boost
# contourpy
# datasets
# diffusers
# dm-control
# dm-env
# dm-tree
# gymnasium
# hebi-py
# imageio
# labmaze
# lerobot
# matplotlib
# meshcat
# metaworld
# mujoco
# opencv-python
# opencv-python-headless
# pandas
# peft
# pyquaternion
# reachy2-sdk
# rerun-sdk
# scikit-image
# scipy
# shapely
# teleop
# tifffile
# torchvision
# transformers
# transforms3d
opencv-python==4.13.0.92
# via
# gym-pusht
# reachy2-sdk
opencv-python-headless==4.12.0.88
# via lerobot
orderly-set==5.5.0
# via deepdiff
packaging==25.0
# via
# accelerate
# datasets
# huggingface-hub
# lazy-loader
# lerobot
# matplotlib
# peft
# pytest
# qwen-vl-utils
# reachy2-sdk
# scikit-image
# transformers
# wandb
pandas==2.3.3
# via
# datasets
# lerobot
parso==0.8.6
# via jedi
pathspec==1.0.4
# via mypy
peft==0.18.1
# via lerobot
pexpect==4.9.0
# via ipython
pillow==12.1.1
# via
# diffusers
# imageio
# matplotlib
# meshcat
# qwen-vl-utils
# rerun-sdk
# scikit-image
# torchvision
pin==3.4.0
# via placo
placo==0.9.16
# via lerobot
platformdirs==4.9.4
# via
# python-discovery
# virtualenv
# wandb
pluggy==1.6.0
# via
# pytest
# pytest-cov
pre-commit==4.5.1
# via lerobot
prompt-toolkit==3.0.52
# via ipython
propcache==0.4.1
# via
# aiohttp
# yarl
protobuf==6.31.1
# via
# dm-control
# grpcio-tools
# lerobot
# reachy2-sdk
# reachy2-sdk-api
# wandb
psutil==7.2.2
# via
# accelerate
# imageio
# peft
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.3
# via stack-data
pyarrow==23.0.1
# via
# datasets
# rerun-sdk
pycparser==3.0
# via cffi
pydantic==2.12.5
# via
# fastapi
# wandb
pydantic-core==2.41.5
# via pydantic
pygame==2.6.1
# via
# gym-hil
# gym-pusht
# lerobot
pygments==2.19.2
# via
# ipython
# ipython-pygments-lexers
# pytest
# rich
pymunk==6.11.1
# via
# gym-pusht
# lerobot
pyngrok==7.5.1
# via meshcat
pynput==1.8.1
# via
# gym-hil
# lerobot
pyobjc-core==12.1
# via
# pyobjc-framework-applicationservices
# pyobjc-framework-cocoa
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-applicationservices==12.1
# via pynput
pyobjc-framework-cocoa==12.1
# via
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
# pyobjc-framework-quartz
pyobjc-framework-coretext==12.1
# via pyobjc-framework-applicationservices
pyobjc-framework-quartz==12.1
# via
# pynput
# pyobjc-framework-applicationservices
# pyobjc-framework-coretext
pyopengl==3.1.10
# via
# dm-control
# mujoco
pyparsing==3.3.2
# via
# dm-control
# matplotlib
pyquaternion==0.9.9
# via reachy2-sdk
pyrealsense2-macosx==2.56.5
# via lerobot
pyserial==3.5
# via
# dynamixel-sdk
# feetech-servo-sdk
# lerobot
pytest==8.4.2
# via
# lerobot
# pytest-cov
# pytest-timeout
# teleop
pytest-cov==7.0.0
# via lerobot
pytest-timeout==2.4.0
# via lerobot
python-dateutil==2.9.0.post0
# via
# faker
# matplotlib
# pandas
python-discovery==1.1.1
# via virtualenv
python-dotenv==1.2.2
# via uvicorn
pytz==2026.1.post1
# via pandas
pyyaml==6.0.3
# via
# accelerate
# datasets
# draccus
# hebi-py
# huggingface-hub
# peft
# pre-commit
# pyngrok
# pyyaml-include
# transformers
# uvicorn
# wandb
pyyaml-include==1.4.1
# via draccus
pyzmq==27.1.0
# via
# lerobot
# meshcat
qwen-vl-utils==0.0.14
# via lerobot
reachy2-sdk==1.0.15
# via lerobot
reachy2-sdk-api==1.0.21
# via reachy2-sdk
regex==2026.2.28
# via
# diffusers
# transformers
requests==2.32.5
# via
# datasets
# diffusers
# dm-control
# qwen-vl-utils
# teleop
# wandb
rerun-sdk==0.26.2
# via lerobot
rhoban-cmeel-jsoncpp==1.9.4.9
# via placo
rich==14.3.3
# via typer
safetensors==0.7.0
# via
# accelerate
# diffusers
# lerobot
# peft
# transformers
scikit-image==0.25.2
# via
# gym-pusht
# lerobot
scipy==1.17.1
# via
# dm-control
# lerobot
# metaworld
# scikit-image
# torchdiffeq
sentry-sdk==2.54.0
# via wandb
shapely==2.1.2
# via gym-pusht
shellingham==1.5.4
# via typer
six==1.17.0
# via
# pynput
# python-dateutil
smmap==5.0.3
# via gitdb
stack-data==0.6.3
# via ipython
starlette==0.52.1
# via fastapi
sympy==1.14.0
# via torch
teleop==0.1.4
# via lerobot
termcolor==3.3.0
# via lerobot
tifffile==2026.3.3
# via scikit-image
tokenizers==0.22.2
# via transformers
toml==0.10.2
# via draccus
torch==2.10.0
# via
# accelerate
# lerobot
# peft
# torchdiffeq
# torchvision
torchcodec==0.10.0
# via lerobot
torchdiffeq==0.2.5
# via lerobot
torchvision==0.25.0
# via lerobot
tornado==6.5.4
# via meshcat
tqdm==4.67.3
# via
# datasets
# dm-control
# huggingface-hub
# peft
# transformers
traitlets==5.14.3
# via
# ipython
# matplotlib-inline
transformers==5.3.0
# via
# lerobot
# peft
transforms3d==0.4.2
# via teleop
typer==0.24.1
# via
# huggingface-hub
# transformers
typing-extensions==4.15.0
# via
# aiosignal
# anyio
# etils
# faker
# fastapi
# gymnasium
# huggingface-hub
# mypy
# pydantic
# pydantic-core
# rerun-sdk
# starlette
# torch
# typing-inspect
# typing-inspection
# wandb
typing-inspect==0.9.0
# via draccus
typing-inspection==0.4.2
# via
# fastapi
# pydantic
tzdata==2025.3
# via pandas
u-msgpack-python==2.8.0
# via meshcat
urllib3==2.6.3
# via
# requests
# sentry-sdk
uvicorn[standard]==0.41.0
# via teleop
uvloop==0.22.1
# via uvicorn
virtualenv==21.1.0
# via pre-commit
wandb==0.24.2
# via lerobot
watchfiles==1.1.1
# via uvicorn
wcwidth==0.6.0
# via prompt-toolkit
websocket-client==1.9.0
# via teleop
websockets==16.0
# via uvicorn
wrapt==2.1.2
# via dm-tree
xxhash==3.6.0
# via datasets
yarl==1.23.0
# via aiohttp
zipp==3.23.0
# via
# etils
# importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
# setuptools
-882
View File
@@ -1,882 +0,0 @@
#
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --output-file=requirements-ubuntu.txt requirements.in
#
-e .[all]
# via -[all]
absl-py==2.4.0
# via
# dm-control
# dm-env
# dm-tree
# labmaze
# mujoco
# tensorboard
accelerate==1.13.0
# via
# lerobot
# peft
aiohappyeyeballs==2.6.1
# via aiohttp
aiohttp==3.13.3
# via fsspec
aiosignal==1.4.0
# via aiohttp
annotated-doc==0.0.4
# via
# fastapi
# typer
annotated-types==0.7.0
# via pydantic
antlr4-python3-runtime==4.9.3
# via
# hydra-core
# omegaconf
anyio==4.12.1
# via
# httpx
# starlette
# watchfiles
asttokens==3.0.1
# via stack-data
attrs==25.4.0
# via
# aiohttp
# dm-tree
# jsonlines
# jsonschema
# referencing
# rerun-sdk
av==15.1.0
# via
# lerobot
# qwen-vl-utils
bddl==1.0.1
# via hf-libero
certifi==2026.2.25
# via
# httpcore
# httpx
# requests
# sentry-sdk
cffi==2.0.0
# via pymunk
cfgv==3.5.0
# via pre-commit
charset-normalizer==3.4.5
# via requests
click==8.3.1
# via
# typer
# uvicorn
# wandb
cloudpickle==3.1.2
# via
# gymnasium
# hf-libero
cmake==4.1.3
# via lerobot
cmeel==0.59.0
# via
# cmeel-assimp
# cmeel-boost
# cmeel-console-bridge
# cmeel-octomap
# cmeel-qhull
# cmeel-tinyxml2
# cmeel-urdfdom
# cmeel-zlib
# coal-library
# eigenpy
# eiquadprog
# pin
# placo
# rhoban-cmeel-jsoncpp
cmeel-assimp==5.4.3.1
# via coal-library
cmeel-boost==1.87.0.1
# via
# coal-library
# eigenpy
# eiquadprog
# pin
cmeel-console-bridge==1.0.2.3
# via cmeel-urdfdom
cmeel-octomap==1.10.0
# via coal-library
cmeel-qhull==8.0.2.1
# via coal-library
cmeel-tinyxml2==10.0.0
# via cmeel-urdfdom
cmeel-urdfdom==4.0.1
# via pin
cmeel-zlib==1.3.1
# via cmeel-assimp
coal-library==3.0.1
# via pin
contourpy==1.3.3
# via
# lerobot
# matplotlib
coverage[toml]==7.13.4
# via pytest-cov
cuda-bindings==12.9.4
# via torch
cuda-pathfinder==1.4.1
# via cuda-bindings
cycler==0.12.1
# via matplotlib
datasets==4.6.1
# via lerobot
debugpy==1.8.20
# via lerobot
decorator==5.2.1
# via ipython
deepdiff==8.6.1
# via lerobot
diffusers==0.35.2
# via lerobot
dill==0.4.0
# via
# datasets
# multiprocess
distlib==0.4.0
# via virtualenv
dm-control==1.0.37
# via gym-aloha
dm-env==1.6
# via dm-control
dm-tree==0.1.9
# via
# dm-control
# dm-env
docopt==0.6.2
# via num2words
draccus==0.10.0
# via lerobot
dynamixel-sdk==3.8.4
# via lerobot
easydict==1.13
# via hf-libero
egl-probe==1.0.2
# via robomimic
eigenpy==3.10.3
# via coal-library
einops==0.8.2
# via
# hf-libero
# lerobot
eiquadprog==1.2.9
# via placo
etils[epath,epy]==1.14.0
# via mujoco
evdev==1.9.3
# via pynput
executing==2.2.1
# via stack-data
faker==34.0.2
# via lerobot
farama-notifications==0.0.4
# via gymnasium
fastapi==0.135.1
# via
# lerobot
# teleop
fastjsonschema==2.21.2
# via nbformat
feetech-servo-sdk==1.0.0
# via lerobot
filelock==3.25.0
# via
# datasets
# diffusers
# huggingface-hub
# python-discovery
# torch
# virtualenv
fonttools==4.61.1
# via matplotlib
frozenlist==1.8.0
# via
# aiohttp
# aiosignal
fsspec[http]==2026.2.0
# via
# datasets
# etils
# huggingface-hub
# torch
future==1.0.0
# via hf-libero
gitdb==4.0.12
# via gitpython
gitpython==3.1.46
# via wandb
glfw==2.10.0
# via
# dm-control
# mujoco
grpcio==1.73.1
# via
# grpcio-tools
# lerobot
# reachy2-sdk
# reachy2-sdk-api
# tensorboard
grpcio-tools==1.73.1
# via
# lerobot
# reachy2-sdk-api
gym-aloha==0.1.3
# via lerobot
gym-hil==0.1.13
# via lerobot
gym-pusht==0.1.6
# via lerobot
gymnasium==1.2.3
# via
# gym-aloha
# gym-hil
# gym-pusht
# hf-libero
# lerobot
# metaworld
h11==0.16.0
# via
# httpcore
# uvicorn
h5py==3.16.0
# via robomimic
hebi-py==2.11.0
# via lerobot
hf-egl-probe==1.0.2
# via hf-libero
hf-libero==0.1.3
# via lerobot
hf-xet==1.3.2
# via huggingface-hub
hidapi==0.14.0.post4
# via
# gym-hil
# lerobot
httpcore==1.0.9
# via httpx
httptools==0.7.1
# via uvicorn
httpx==0.28.1
# via
# datasets
# huggingface-hub
huggingface-hub==1.6.0
# via
# accelerate
# datasets
# diffusers
# lerobot
# peft
# tokenizers
# transformers
hydra-core==1.3.2
# via hf-libero
identify==2.6.17
# via pre-commit
idna==3.11
# via
# anyio
# httpx
# requests
# yarl
imageio[ffmpeg]==2.37.2
# via
# gym-aloha
# gym-hil
# lerobot
# metaworld
# robomimic
# scikit-image
imageio-ffmpeg==0.6.0
# via
# imageio
# robomimic
importlib-metadata==8.7.1
# via diffusers
iniconfig==2.3.0
# via pytest
ipython==9.11.0
# via meshcat
ipython-pygments-lexers==1.1.1
# via ipython
ischedule==1.2.7
# via placo
jedi==0.19.2
# via ipython
jinja2==3.1.6
# via torch
jsonlines==4.0.0
# via lerobot
jsonschema==4.26.0
# via nbformat
jsonschema-specifications==2025.9.1
# via jsonschema
jupyter-core==5.9.1
# via nbformat
jupytext==1.19.1
# via bddl
kiwisolver==1.4.9
# via matplotlib
labmaze==1.0.6
# via dm-control
lazy-loader==0.5
# via scikit-image
librt==0.8.1
# via mypy
llvmlite==0.46.0
# via numba
lxml==6.0.2
# via dm-control
markdown==3.10.2
# via tensorboard
markdown-it-py==4.0.0
# via
# jupytext
# mdit-py-plugins
# rich
markupsafe==3.0.3
# via
# jinja2
# werkzeug
matplotlib==3.10.8
# via
# hf-libero
# lerobot
matplotlib-inline==0.2.1
# via ipython
mdit-py-plugins==0.5.0
# via jupytext
mdurl==0.1.2
# via markdown-it-py
mergedeep==1.3.4
# via draccus
meshcat==0.3.2
# via placo
metaworld==3.0.0
# via lerobot
mock-serial==0.0.1
# via lerobot
mpmath==1.3.0
# via sympy
mujoco==3.5.0
# via
# dm-control
# gym-aloha
# gym-hil
# hf-libero
# metaworld
# robosuite
multidict==6.7.1
# via
# aiohttp
# yarl
multiprocess==0.70.18
# via datasets
mypy==1.19.1
# via lerobot
mypy-extensions==1.1.0
# via
# mypy
# typing-inspect
nbformat==5.10.4
# via jupytext
networkx==3.6.1
# via
# bddl
# scikit-image
# torch
nodeenv==1.10.0
# via pre-commit
num2words==0.5.14
# via lerobot
numba==0.64.0
# via robosuite
numpy==2.2.6
# via
# accelerate
# bddl
# cmeel-boost
# contourpy
# datasets
# diffusers
# dm-control
# dm-env
# dm-tree
# gymnasium
# h5py
# hebi-py
# hf-libero
# imageio
# labmaze
# lerobot
# matplotlib
# meshcat
# metaworld
# mujoco
# numba
# opencv-python
# opencv-python-headless
# pandas
# peft
# pyquaternion
# reachy2-sdk
# rerun-sdk
# robomimic
# robosuite
# scikit-image
# scipy
# shapely
# teleop
# tensorboard
# tensorboardx
# tifffile
# torchvision
# transformers
# transforms3d
nvidia-cublas-cu12==12.8.4.1
# via
# nvidia-cudnn-cu12
# nvidia-cusolver-cu12
# torch
nvidia-cuda-cupti-cu12==12.8.90
# via torch
nvidia-cuda-nvrtc-cu12==12.8.93
# via torch
nvidia-cuda-runtime-cu12==12.8.90
# via torch
nvidia-cudnn-cu12==9.10.2.21
# via torch
nvidia-cufft-cu12==11.3.3.83
# via torch
nvidia-cufile-cu12==1.13.1.3
# via torch
nvidia-curand-cu12==10.3.9.90
# via torch
nvidia-cusolver-cu12==11.7.3.90
# via torch
nvidia-cusparse-cu12==12.5.8.93
# via
# nvidia-cusolver-cu12
# torch
nvidia-cusparselt-cu12==0.7.1
# via torch
nvidia-nccl-cu12==2.27.5
# via torch
nvidia-nvjitlink-cu12==12.8.93
# via
# nvidia-cufft-cu12
# nvidia-cusolver-cu12
# nvidia-cusparse-cu12
# torch
nvidia-nvshmem-cu12==3.4.5
# via torch
nvidia-nvtx-cu12==12.8.90
# via torch
omegaconf==2.3.0
# via hydra-core
opencv-python==4.13.0.92
# via
# gym-pusht
# hf-libero
# reachy2-sdk
# robosuite
opencv-python-headless==4.12.0.88
# via lerobot
orderly-set==5.5.0
# via deepdiff
packaging==25.0
# via
# accelerate
# datasets
# huggingface-hub
# hydra-core
# jupytext
# lazy-loader
# lerobot
# matplotlib
# peft
# pytest
# qwen-vl-utils
# reachy2-sdk
# scikit-image
# tensorboard
# tensorboardx
# transformers
# wandb
pandas==2.3.3
# via
# datasets
# lerobot
parso==0.8.6
# via jedi
pathspec==1.0.4
# via mypy
peft==0.18.1
# via lerobot
pexpect==4.9.0
# via ipython
pillow==12.1.1
# via
# diffusers
# imageio
# matplotlib
# meshcat
# qwen-vl-utils
# rerun-sdk
# robosuite
# scikit-image
# tensorboard
# torchvision
pin==3.4.0
# via placo
placo==0.9.16
# via lerobot
platformdirs==4.9.4
# via
# jupyter-core
# python-discovery
# virtualenv
# wandb
pluggy==1.6.0
# via
# pytest
# pytest-cov
pre-commit==4.5.1
# via lerobot
prompt-toolkit==3.0.52
# via ipython
propcache==0.4.1
# via
# aiohttp
# yarl
protobuf==6.31.1
# via
# dm-control
# grpcio-tools
# lerobot
# reachy2-sdk
# reachy2-sdk-api
# tensorboard
# tensorboardx
# wandb
psutil==7.2.2
# via
# accelerate
# imageio
# peft
# robomimic
ptyprocess==0.7.0
# via pexpect
pure-eval==0.2.3
# via stack-data
pyarrow==23.0.1
# via
# datasets
# rerun-sdk
pycparser==3.0
# via cffi
pydantic==2.12.5
# via
# fastapi
# wandb
pydantic-core==2.41.5
# via pydantic
pygame==2.6.1
# via
# gym-hil
# gym-pusht
# lerobot
pygments==2.19.2
# via
# ipython
# ipython-pygments-lexers
# pytest
# rich
pymunk==6.11.1
# via
# gym-pusht
# lerobot
pyngrok==7.5.1
# via meshcat
pynput==1.8.1
# via
# gym-hil
# lerobot
pyopengl==3.1.10
# via
# dm-control
# mujoco
pyparsing==3.3.2
# via
# dm-control
# matplotlib
pyquaternion==0.9.9
# via reachy2-sdk
pyrealsense2==2.56.5.9235
# via lerobot
pyserial==3.5
# via
# dynamixel-sdk
# feetech-servo-sdk
# lerobot
pytest==8.4.2
# via
# bddl
# lerobot
# pytest-cov
# pytest-timeout
# teleop
pytest-cov==7.0.0
# via lerobot
pytest-timeout==2.4.0
# via lerobot
python-dateutil==2.9.0.post0
# via
# faker
# matplotlib
# pandas
python-discovery==1.1.1
# via virtualenv
python-dotenv==1.2.2
# via uvicorn
python-xlib==0.33
# via pynput
pytz==2026.1.post1
# via pandas
pyyaml==6.0.3
# via
# accelerate
# datasets
# draccus
# hebi-py
# huggingface-hub
# jupytext
# omegaconf
# peft
# pre-commit
# pyngrok
# pyyaml-include
# transformers
# uvicorn
# wandb
pyyaml-include==1.4.1
# via draccus
pyzmq==27.1.0
# via
# lerobot
# meshcat
qwen-vl-utils==0.0.14
# via lerobot
reachy2-sdk==1.0.15
# via lerobot
reachy2-sdk-api==1.0.21
# via reachy2-sdk
referencing==0.37.0
# via
# jsonschema
# jsonschema-specifications
regex==2026.2.28
# via
# diffusers
# transformers
requests==2.32.5
# via
# datasets
# diffusers
# dm-control
# qwen-vl-utils
# teleop
# wandb
rerun-sdk==0.26.2
# via lerobot
rhoban-cmeel-jsoncpp==1.9.4.9
# via placo
rich==14.3.3
# via typer
robomimic==0.2.0
# via hf-libero
robosuite==1.4.0
# via hf-libero
rpds-py==0.30.0
# via
# jsonschema
# referencing
safetensors==0.7.0
# via
# accelerate
# diffusers
# lerobot
# peft
# transformers
scikit-image==0.25.2
# via
# gym-pusht
# lerobot
scipy==1.17.1
# via
# dm-control
# lerobot
# metaworld
# robosuite
# scikit-image
# torchdiffeq
sentry-sdk==2.54.0
# via wandb
shapely==2.1.2
# via gym-pusht
shellingham==1.5.4
# via typer
six==1.17.0
# via
# pynput
# python-dateutil
# python-xlib
smmap==5.0.3
# via gitdb
stack-data==0.6.3
# via ipython
starlette==0.52.1
# via fastapi
sympy==1.14.0
# via torch
teleop==0.1.4
# via lerobot
tensorboard==2.20.0
# via robomimic
tensorboard-data-server==0.7.2
# via tensorboard
tensorboardx==2.6.4
# via robomimic
termcolor==3.3.0
# via
# lerobot
# robomimic
thop==0.1.1.post2209072238
# via hf-libero
tifffile==2026.3.3
# via scikit-image
tokenizers==0.22.2
# via transformers
toml==0.10.2
# via draccus
torch==2.10.0
# via
# accelerate
# lerobot
# peft
# robomimic
# thop
# torchdiffeq
# torchvision
torchcodec==0.10.0
# via lerobot
torchdiffeq==0.2.5
# via lerobot
torchvision==0.25.0
# via
# lerobot
# robomimic
tornado==6.5.4
# via meshcat
tqdm==4.67.3
# via
# datasets
# dm-control
# huggingface-hub
# peft
# robomimic
# transformers
traitlets==5.14.3
# via
# ipython
# jupyter-core
# matplotlib-inline
# nbformat
transformers==5.3.0
# via
# hf-libero
# lerobot
# peft
transforms3d==0.4.2
# via teleop
triton==3.6.0
# via torch
typer==0.24.1
# via
# huggingface-hub
# transformers
typing-extensions==4.15.0
# via
# aiosignal
# anyio
# etils
# faker
# fastapi
# gymnasium
# huggingface-hub
# mypy
# pydantic
# pydantic-core
# referencing
# rerun-sdk
# starlette
# torch
# typing-inspect
# typing-inspection
# wandb
typing-inspect==0.9.0
# via draccus
typing-inspection==0.4.2
# via
# fastapi
# pydantic
tzdata==2025.3
# via pandas
u-msgpack-python==2.8.0
# via meshcat
urllib3==2.6.3
# via
# requests
# sentry-sdk
uvicorn[standard]==0.41.0
# via teleop
uvloop==0.22.1
# via uvicorn
virtualenv==21.1.0
# via pre-commit
wandb==0.24.2
# via
# hf-libero
# lerobot
watchfiles==1.1.1
# via uvicorn
wcwidth==0.6.0
# via prompt-toolkit
websocket-client==1.9.0
# via teleop
websockets==16.0
# via uvicorn
werkzeug==3.1.6
# via tensorboard
wrapt==2.1.2
# via dm-tree
xxhash==3.6.0
# via datasets
yarl==1.23.0
# via aiohttp
zipp==3.23.0
# via
# etils
# importlib-metadata
# The following packages are considered to be unsafe in a requirements file:
# setuptools
-9
View File
@@ -1,9 +0,0 @@
# requirements.in
# requirements-macos.txt was generated on macOS and is platform-specific (macOS 26.3.1 25D2128 arm64).
# Darwin MacBook-Pro.local 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:54:55 PST 2026; root:xnu-12377.91.3~2/RELEASE_ARM64_T8132 arm64
# requirements-ubuntu.txt was generated on Linux and is platform-specific (Ubuntu 24.04.4 LTS x86_64).
# Linux lerobot-linux 6.17.0-14-generic #14~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Jan 15 15:52:10 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
-e .[all]
@@ -65,6 +65,14 @@ class PlanConfig:
# invented from the task text (+1 VLM call/episode).
subtask_describe_first: bool = True
# Seeded relabeling: after segmentation, re-label each span with a focused
# pass that sees the previous / current / next segment contact sheets and
# minimally corrects the seed label (macrodata's best end-to-end labeling
# step). Costs +1 VLM call per subtask; off by default.
subtask_seeded_relabel: bool = False
# Frames sampled uniformly per segment sheet in the relabel pass.
subtask_relabel_frames: int = 5
# Emit ``style="plan"`` rows at each boundary; False = subtasks + memory only.
emit_plan: bool = True
@@ -160,6 +168,11 @@ class VlmConfig:
# Forwarded as extra_body.chat_template_kwargs (e.g. {"enable_thinking": false}).
chat_template_kwargs: dict[str, Any] | None = None
# OpenAI-style thinking budget hint ("low"/"medium"/"high"); forwarded to
# the server when set. Used to cap a thinking model's reasoning so it
# leaves tokens for the actual JSON answer on OpenAI-compatible endpoints.
reasoning_effort: str | None = None
@dataclass
class ExecutorConfig:
@@ -413,7 +413,16 @@ def _draw_timestamp_badge(image: PIL.Image.Image, timestamp: float) -> PIL.Image
result = image.copy()
draw = ImageDraw.Draw(result)
font = ImageFont.load_default()
# Scale the timestamp to the tile so it stays legible after the model
# downsamples the full sheet into 768px tiles — a tiny bitmap font blurs
# at contact-sheet resolution and the VLM can no longer read the exact
# source time, which is what the boundary score depends on. ``size=`` is
# supported by Pillow's bitmap default since 10.1; fall back otherwise.
badge_px = max(14, round(image.height * 0.12))
try:
font = ImageFont.load_default(size=badge_px)
except TypeError:
font = ImageFont.load_default()
label = f"{timestamp:06.2f}s"
left, top, right, bottom = draw.textbbox((0, 0), label, font=font)
text_w, text_h = right - left, bottom - top
@@ -116,6 +116,8 @@ class PlanSubtasksMemoryModule:
rows.extend(self._task_aug_rows([effective_task, *variants], t0))
subtask_spans = self._generate_subtasks(record, task=effective_task)
if self.config.subtask_seeded_relabel and subtask_spans:
subtask_spans = self._seeded_relabel(record, subtask_spans, effective_task)
# subtask rows
for span in subtask_spans:
@@ -509,6 +511,51 @@ class PlanSubtasksMemoryModule:
return cleaned
def _seeded_relabel(
self, record: EpisodeRecord, spans: list[dict[str, Any]], task: str
) -> list[dict[str, Any]]:
"""Re-label each span using prev/current/next segment contact sheets.
Boundaries are kept fixed; only ``text`` is refined. The original
("seed") label is passed as a strong prior so the model verifies and
minimally corrects it rather than re-describing from scratch — the
macrodata seeded-relabeling step. One VLM call per span.
"""
n = len(spans)
out: list[dict[str, Any]] = []
for i, span in enumerate(spans):
content: list[dict[str, Any]] = []
if i > 0:
content += self._segment_sheet(record, spans[i - 1])
content += self._segment_sheet(record, span)
if i < n - 1:
content += self._segment_sheet(record, spans[i + 1])
prompt = load_prompt("plan_subtask_relabel").format(
episode_task=task,
seed_label=span["text"],
segment_index=i + 1,
segment_count=n,
start=float(span["start"]),
end=float(span["end"]),
)
content.append({"type": "text", "text": prompt})
label = self._vlm_field([{"role": "user", "content": content}], "label")
text = label.strip() if isinstance(label, str) and label.strip() else span["text"]
out.append({**span, "text": text})
return out
def _segment_sheet(self, record: EpisodeRecord, span: dict[str, Any]) -> list[dict[str, Any]]:
"""Contact-sheet block(s) for one span: up to N frames sampled uniformly."""
s, e = float(span["start"]), float(span["end"])
n = max(1, int(self.config.subtask_relabel_frames))
if e <= s or n == 1:
timestamps = [s]
else:
step = (e - s) / (n - 1)
timestamps = [s + i * step for i in range(n)]
frames = self.frame_provider.frames_at(record, timestamps)
return self._contact_sheet_blocks(frames, timestamps[: len(frames)])
def _generate_subtasks_windowed(
self, record: EpisodeRecord, task: str, window_s: float
) -> list[dict[str, Any]]:
@@ -22,12 +22,23 @@ plain editors and roundtrip cleanly through ``ruff format``.
from __future__ import annotations
import os
from pathlib import Path
_DIR = Path(__file__).parent
def load(name: str) -> str:
"""Read prompt template ``name.txt`` from the ``prompts/`` directory."""
"""Read prompt template ``name.txt`` from the ``prompts/`` directory.
A ``LEROBOT_PROMPT_OVERRIDE_<name>`` environment variable, when set to a
non-empty value, takes precedence over the packaged file. This lets prompt
search (e.g. GEPA) inject candidate templates into a remote job without
rebuilding the package; the override must keep the same ``{placeholder}``
fields the call site formats in.
"""
override = os.environ.get(f"LEROBOT_PROMPT_OVERRIDE_{name}")
if override and override.strip():
return override
path = _DIR / f"{name}.txt"
return path.read_text(encoding="utf-8")
@@ -0,0 +1,35 @@
Annotate one fixed segment from a longer robot demonstration.
Return only JSON:
{{"label": "<short descriptive subtask label>"}}
You are shown up to three timestamped contact sheets, in order:
- The FIRST sheet is the PREVIOUS segment (context only); it may be absent.
- The SECOND sheet is the CURRENT target segment.
- The THIRD sheet is the NEXT segment (context only); it may be absent.
Each tile has its timestamp (seconds, absolute video time) burned into its
top-left corner.
Episode instruction: "{episode_task}"
Target segment: {segment_index} of {segment_count}
Target time: {start:.2f}s to {end:.2f}s
Original predicted label for this exact segment: "{seed_label}"
Rules:
- Label ONLY the current target segment (the second sheet). Use the
previous/next sheets only to disambiguate what changed.
- Treat the original predicted label as a STRONG PRIOR, not ground truth:
verify it against the current segment and correct it minimally.
- If it already names the right action and main object, keep it; only fix
grammar or add a clearly visible essential detail.
- If it is vague but directionally correct, make it more specific.
- If it describes the previous/next segment, the wrong action, wrong
object, wrong destination, or a wrong state change, replace it.
- Do not describe the previous or next segment, and do not split, merge,
or move the fixed segment.
- Do not introduce an action that is not clearly visible in the current
target segment.
- Use one concise imperative phrase. Name the manipulated object and the
action / state change. Include source, destination, side, direction,
final placement, or opened/closed state when visible and central.
- Do not mention timestamps, frame numbers, uncertainty, or intent.
@@ -1,112 +1,68 @@
You are labeling a teleoperated robot demonstration.
You are annotating a teleoperated robot demonstration shown as
timestamped contact sheets (each tile has its time in seconds burned
into the top-left corner). The operator's goal was: "{episode_task}"
The user originally asked: "{episode_task}"
{observation_block}Reconstruct the sequence of COMPLETED manipulation events the robot
performs, in chronological order. Output one segment per event with a
[start, end] time in seconds and a short action label.
You are shown the entire demonstration as a single video. Watch the
whole clip, then segment it into a list of consecutive atomic subtasks
the robot performs.
GROUNDING — read first, it overrides everything below:
- Label ONLY events you can SEE in the frames. The instruction is the
goal; the VIDEO is the ground truth for what actually happened.
- Do NOT invent, anticipate, or pad steps that are not shown.
{observation_block}GROUNDING — read this first, it overrides everything below:
- Label ONLY what the robot actually does in the video. Every subtask
you emit must correspond to motion you can SEE in specific frames.
- Do NOT invent, anticipate, or pad. If the robot only does one thing
(e.g. it just navigates to a location and the clip ends), emit
EXACTLY ONE subtask. Many demonstrations are a single atomic skill.
- ``max_steps`` below is a hard CEILING, not a target. Emitting fewer
subtasks than the ceiling is not just allowed, it is expected for
short / atomic demonstrations. One correct subtask is far better
than several invented ones.
- If the video does not clearly show the action implied by the task,
describe what you actually see — do NOT fabricate the task's steps
from the instruction text. The instruction tells you the goal; the
VIDEO is the ground truth for what happened.
Granularity — segment by completed events, not by motion:
- Start a NEW segment whenever the world state changes: an object is
grasped, lifted, transported, placed, or released; a held object
changes; a drawer/door/lid/container opens or closes; contents move
between containers (poured); a tool starts or stops acting on a
surface. Watch the gripper open/close transitions — they usually mark
boundaries.
- Do NOT split approach, reach, grasp adjustment, small repositioning,
hesitation, or retreat into their own segments. Fold each into the
event it belongs to (the approach is part of the pick; the retreat is
part of the place).
- Do NOT merge separate completed events. Each distinct pick, place,
open, close, pour, push, wipe, or insert is its own segment, even when
they repeat on different objects or locations.
- Most segments last 2-10 seconds. Shorter segments are okay ONLY for
fast pick / place / open / close / release events. Never emit a
segment shorter than {min_subtask_seconds} seconds; merge a too-short
candidate into its neighbour instead.
- Skip idle time, pure camera motion, and tiny hand jitter.
Authoring rules — Hi Robot atom granularity, pi0.7-style short prompts:
Labels — short imperative phrases:
- One concise command naming the action and the manipulated object, e.g.
"pick up the red cup", "put the cup on the shelf", "open the top
drawer", "pour water into the glass", "insert the plug into the
socket".
- Include source, destination, side, direction, or the final
open/closed state when it is visible and central to the event.
- Prefer these verbs (extend only when none fits): pick up, put, place,
push, pull, turn, press, open, close, pour, insert, wipe, stack.
Disambiguate by what you SEE:
* STACK vs PUT: object placed ON TOP OF another object -> "stack".
* INSERT vs PUT: object pushed INTO a fitted slot/hole/socket -> "insert".
* PICK UP vs PUT (direction): gripper CLOSES and object moves WITH
the hand -> "pick up"; gripper OPENS and object stays -> "put".
* POUR vs PUT: source is tilted and contents flow -> "pour".
- Use the exact object nouns implied by the task; stay consistent across
the episode (don't switch "cube" to "block").
- Write imperative commands, never third person ("the robot ..."), and
drop articles/adverbs.
- Each subtask = one COMPOSITE atomic skill the low-level policy can
execute end-to-end. A "skill" bundles its own approach motion with
its terminal action — do NOT split the approach off as its own
subtask. The whole-arm policy already learns to reach as part of
every manipulation primitive.
- Write each subtask as an IMPERATIVE COMMAND, starting with one of
these verbs (extend only when none fits):
pick up <obj> — approach + grasp + lift in one subtask
put <obj> on/in <loc> — transport + release in one subtask
place <obj> on/in <loc> — synonym of "put"; pick one and stay consistent
push <obj> — contact + linear shove
pull <obj> — contact + linear retract
turn <knob/dial/handle> — rotary actuation
press <button> — single-press contact
open <drawer/door/lid> — full open motion
close <drawer/door/lid> — full close motion
pour <src> into <dst> — tilt + flow
insert <obj> into <slot>— alignment + push-fit
go to <loc> — ONLY when no grasp / actuation follows
(e.g. a pure relocation between phases).
If the next subtask grasps something at
that location, drop "go to ..." and just
write "pick up ..." instead.
- Forbidden ultra-fine splits — the VLM is NOT allowed to emit these
as standalone subtasks; fold them into the parent composite:
"move to X" → fold into "pick up X" (or whatever follows)
"reach for X" → fold into "pick up X"
"grasp X" → fold into "pick up X"
"lift X" → fold into "pick up X" (or "put X on Y" if it's
the transport phase of a place)
"release X" → fold into "put X on Y" (or "place X in Y")
- Keep it SHORT — a verb phrase, not a sentence. Drop articles
("the", "a") and adverbs ("carefully", "slowly"). Add a "how"
detail (which hand, which grasp point) ONLY when it is needed to
disambiguate. Every subtask must begin with one of the verbs
above (no leading nouns, no "then", no "first").
- NEVER use third person. Never write "the robot", "the arm", "the
gripper moves", "it picks up" — the robot is implied. Command it,
do not describe it.
- Use the exact object nouns from the task above. If the task says
"cube", every subtask says "cube" — never switch to "block". If it
says "box", never switch to "bin"/"container". Keep vocabulary
consistent across the whole episode.
- Good: "pick up blue cube", "put blue cube in box", "open drawer",
"turn red knob", "press start button", "go to sink".
- Bad: "move to blue cube" (approach as its own subtask — forbidden,
must be folded into "pick up blue cube"); "the robot arm moves
towards the blue cube" (third person, too long); "carefully pick
up the cube" (adverb, article); "release the yellow block"
("block" when the task said "cube", and "release" must be folded
into a "put"/"place" subtask).
- Subtasks are non-overlapping and cover the full episode in order.
Choose the cut points yourself based on what you see in the video
(gripper open/close events, contact, regrasps, transitions).
- Each subtask spans at least {min_subtask_seconds} seconds. If a
candidate span would be shorter, merge it into its neighbour
rather than emitting it.
- Do not exceed {max_steps} subtasks total. Fewer, larger composites
are preferred over many micro-steps.
- Every subtask's [start_time, end_time] must lie within
[0.0, {episode_duration}] seconds.
SPECIAL CASES — verb disambiguation (each rule is narrowly visual and
fires ONLY on the spatial situation it names; it must not change how you
label any other situation):
- STACK vs PUT: if an object is placed ON TOP OF another specific object
(not on a flat table / shelf / counter), use "stack ... on ...", not
"put". "stack blue book on green book", NOT "put blue book on table".
- INSERT vs PUT: if an object goes INTO a fitted slot / hole / socket /
receptacle (push-fit), use "insert ... into ...", not "put".
- RETRIEVE/PICK-UP vs PUT (direction): watch the gripper. If it CLOSES
on the object and the object moves WITH the hand, it is "pick up" /
"retrieve" (object leaves its location). If the gripper OPENS and the
object stays where the hand left it, it is "put" / "place" (object
arrives at a location). Decide by which way the object moves, not by
where the hand ends up.
- POUR vs PUT: only use "pour" when the source is tilted and contents
flow out; moving a full container without tilting is "put"/"place".
Timing:
- Use the burned-in timestamps to set start and end. Boundaries should
land on or near a printed time, and every [start, end] must lie within
[0.0, {episode_duration}] seconds, be non-overlapping, and cover the
episode in order.
- Emit at most {max_steps} segments.
Output strictly valid JSON of shape:
{{
"subtasks": [
{{"text": "<short imperative verb phrase>", "start": <float>, "end": <float>}},
{{"text": "<short imperative action label>", "start": <float>, "end": <float>}},
...
]
}}
@@ -285,6 +285,8 @@ def _make_openai_client(config: VlmConfig) -> VlmClient:
"max_tokens": max_tok,
"temperature": temp,
}
if config.reasoning_effort:
kwargs["reasoning_effort"] = config.reasoning_effort
extra_body: dict[str, Any] = {}
if send_mm_kwargs and mm_kwargs:
extra_body["mm_processor_kwargs"] = {**mm_kwargs, "do_sample_frames": True}
@@ -296,7 +298,13 @@ def _make_openai_client(config: VlmConfig) -> VlmClient:
chosen = clients[rr_counter["i"] % len(clients)]
rr_counter["i"] += 1
response = chosen.chat.completions.create(**kwargs)
return response.choices[0].message.content or ""
# Some OpenAI-compatible servers can return a choice with no message
# (safety filter, or a "thinking" model that spends the whole budget
# before emitting content). Treat that as an empty reply so the
# JSON-retry path handles it instead of crashing the run.
choice = response.choices[0] if response.choices else None
message = choice.message if choice is not None else None
return (message.content if message is not None else None) or ""
def _gen(batch: Sequence[Sequence[dict[str, Any]]], max_tok: int, temp: float) -> list[str]:
if len(batch) <= 1 or config.client_concurrency <= 1:
+6
View File
@@ -34,6 +34,8 @@ from .types import (
)
from .video import (
DEFAULT_DEPTH_UNIT,
DEPTH_METER_UNIT,
DEPTH_MILLIMETER_UNIT,
VALID_VIDEO_CODECS,
VIDEO_ENCODER_INFO_KEYS,
DepthEncoderConfig,
@@ -41,6 +43,7 @@ from .video import (
VideoEncoderConfig,
depth_encoder_defaults,
encoder_config_from_video_info,
infer_depth_unit,
rgb_encoder_defaults,
)
@@ -70,8 +73,11 @@ __all__ = [
"depth_encoder_defaults",
# Factories
"encoder_config_from_video_info",
"infer_depth_unit",
# Constants
"DEFAULT_DEPTH_UNIT",
"DEPTH_METER_UNIT",
"DEPTH_MILLIMETER_UNIT",
"VALID_VIDEO_CODECS",
"VIDEO_ENCODER_INFO_KEYS",
]
+15 -9
View File
@@ -205,24 +205,30 @@ class PreTrainedConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC): # type: igno
f"{CONFIG_NAME} not found on the HuggingFace Hub in {model_id}"
) from e
# HACK: Parse the original config to get the config subclass, so that we can
# apply cli overrides.
# This is very ugly, ideally we'd like to be able to do that natively with draccus
# something like --policy.path (in addition to --policy.type)
with draccus.config_type("json"):
orig_config = draccus.parse(cls, config_file, args=[])
if config_file is None:
raise FileNotFoundError(f"{CONFIG_NAME} not found in {model_id}")
with open(config_file) as f:
config = json.load(f)
config.pop("type")
# Resolve the concrete config subclass from the serialized "type" tag, then parse
# the config (with CLI overrides) directly for that class. The "type" key is
# stripped because draccus only consumes it when parsing the registry base class.
policy_type = config.pop("type", None)
if policy_type is None:
raise ValueError(f"Missing 'type' field in {CONFIG_NAME} of {model_id}")
try:
config_cls = cls.get_choice_class(policy_type)
except Exception as e:
raise ValueError(
f"Policy type '{policy_type}' (from {CONFIG_NAME} of {model_id}) is not registered. "
f"Available policy types: {cls.get_known_choices()}"
) from e
with tempfile.NamedTemporaryFile("w+", delete=False, suffix=".json") as f:
json.dump(config, f)
config_file = f.name
cli_overrides = policy_kwargs.pop("cli_overrides", [])
with draccus.config_type("json"):
return draccus.parse(orig_config.__class__, config_file, args=cli_overrides)
return draccus.parse(config_cls, config_file, args=cli_overrides)
+24 -5
View File
@@ -22,6 +22,8 @@ import logging
from dataclasses import dataclass, field
from typing import Any, ClassVar, Self
import numpy as np
from lerobot.utils.import_utils import require_package
logger = logging.getLogger(__name__)
@@ -36,7 +38,9 @@ HW_VIDEO_CODECS = [
"h264_vaapi", # Linux Intel/AMD
"h264_qsv", # Intel Quick Sync
]
VALID_VIDEO_CODECS: frozenset[str] = frozenset({"h264", "hevc", "libsvtav1", "auto", *HW_VIDEO_CODECS})
VALID_VIDEO_CODECS: frozenset[str] = frozenset(
{"h264", "hevc", "libsvtav1", "libaom-av1", "auto", *HW_VIDEO_CODECS}
)
# Aliases for legacy video codec names.
VIDEO_CODECS_ALIASES: dict[str, str] = {"av1": "libsvtav1"}
@@ -65,6 +69,15 @@ DEPTH_METER_UNIT: str = "m"
DEPTH_MILLIMETER_UNIT: str = "mm"
DEFAULT_DEPTH_UNIT: str = DEPTH_MILLIMETER_UNIT
def infer_depth_unit(dtype: np.dtype | type) -> str:
"""Infer the physical unit of raw depth frames from their dtype.
Floating-point frames are assumed to be in metres, integer frames in millimetres.
"""
return DEPTH_METER_UNIT if np.issubdtype(np.dtype(dtype), np.floating) else DEPTH_MILLIMETER_UNIT
# Depth-specific tuning fields persisted under ``features[*]["info"]`` as ``video.<name>``.
DEPTH_ENCODER_INFO_FIELD_NAMES: frozenset[str] = frozenset({"depth_min", "depth_max", "shift", "use_log"})
@@ -213,18 +226,24 @@ class VideoEncoderConfig:
if encoder_threads is not None:
svtav1_parts.append(f"lp={encoder_threads}")
if svtav1_parts:
opts["svtav1-params"] = ":".join(svtav1_parts)
set_if("svtav1-params", ":".join(svtav1_parts))
elif self.vcodec in ("h264", "hevc"):
set_if("crf", self.crf)
set_if("preset", self.preset)
if self.fast_decode:
opts["tune"] = "fastdecode"
set_if("tune", "fastdecode")
set_if("threads", encoder_threads)
elif self.vcodec == "libaom-av1":
set_if("crf", self.crf)
set_if("preset", self.preset)
if encoder_threads is not None:
set_if("threads", encoder_threads)
set_if("row-mt", 1)
elif self.vcodec in ("h264_videotoolbox", "hevc_videotoolbox"):
if self.crf is not None:
opts["q:v"] = max(1, min(100, 100 - self.crf * 2))
set_if("q:v", max(1, min(100, 100 - self.crf * 2)))
elif self.vcodec in ("h264_nvenc", "hevc_nvenc"):
opts["rc"] = 0
set_if("rc", 0)
set_if("qp", self.crf)
set_if("preset", self.preset)
elif self.vcodec == "h264_vaapi":
+5 -11
View File
@@ -22,7 +22,6 @@ import numpy as np
from lerobot.processor import RelativeActionsProcessorStep
from lerobot.utils.constants import ACTION, OBS_STATE
from .depth_utils import MM_PER_METRE
from .io_utils import load_image_as_numpy
DEFAULT_QUANTILES = [0.01, 0.10, 0.50, 0.90, 0.99]
@@ -509,8 +508,8 @@ def compute_episode_stats(
Note:
For 'image'/'video' features, stats are computed per channel and kept with a
leading channel axis (e.g. shape (3, 1, 1) for RGB). RGB stats are divided by
255 to land in [0, 1]; depth maps (features flagged with ``is_depth_map``) are
instead canonicalized to millimetres regardless of the raw frame unit.
255 to land in [0, 1]; depth maps (features flagged with ``is_depth_map``) skip
this rescaling and remain in their stored units (stored in ``depth_unit``).
"""
if quantile_list is None:
quantile_list = DEFAULT_QUANTILES
@@ -534,14 +533,9 @@ def compute_episode_stats(
)
if features[key]["dtype"] in ["image", "video"]:
if (features[key].get("info") or {}).get("is_depth_map", False):
# Depth stats are canonically stored in millimetres; metre (float) depth is
# scaled up, integer (millimetre) depth is left as-is.
normalization_factor = (
1.0 / MM_PER_METRE if np.issubdtype(ep_ft_array.dtype, np.floating) else 1.0
)
else:
normalization_factor = 255.0
normalization_factor = (
255.0 if not (features[key].get("info") or {}).get("is_depth_map", False) else 1.0
)
ep_stats[key] = {
k: v if k == "count" else np.squeeze(v / normalization_factor, axis=0)
for k, v in ep_stats[key].items()
+31 -1
View File
@@ -26,12 +26,13 @@ import pyarrow as pa
import pyarrow.parquet as pq
from huggingface_hub import snapshot_download
from lerobot.configs import VideoEncoderConfig
from lerobot.configs import DEPTH_METER_UNIT, VideoEncoderConfig
from lerobot.utils.constants import DEFAULT_FEATURES, HF_LEROBOT_HOME, HF_LEROBOT_HUB_CACHE
from lerobot.utils.feature_utils import _validate_feature_names
from lerobot.utils.utils import flatten_dict
from .compute_stats import aggregate_stats
from .depth_utils import MM_PER_METRE
from .feature_utils import create_empty_dataset_info
from .io_utils import (
get_file_size_in_mb,
@@ -358,6 +359,35 @@ class LeRobotDatasetMetadata:
return [key for key, ft in self.features.items() if _is_depth(ft)]
def rescale_depth_stats(self, output_unit: str) -> None:
"""Rescale depth feature stats in place from their recorded unit to ``output_unit``.
Depth stats are stored in the unit the frames were recorded in
(``features[key]["info"]["depth_unit"]``), while frames are returned in
``output_unit`` on read. This converts the unit-bearing stat entries so
stats match the frames consumers see.
"""
missing_unit_keys = [
key for key in self.depth_keys if (self.features[key].get("info") or {}).get("depth_unit") is None
]
if missing_unit_keys:
logging.warning(
f"Depth feature(s) {missing_unit_keys} have no recorded 'depth_unit' in their info. "
f"Depth maps and stats for these keys will be returned AS IS, with no unit conversion "
f"to the requested output unit {output_unit!r}. Re-record the dataset or set 'depth_unit' "
f"in the feature info (meta/info.json) to enable conversion."
)
if self.stats is None:
return
for key in self.depth_keys:
stored_unit = (self.features[key].get("info") or {}).get("depth_unit")
if stored_unit is None or stored_unit == output_unit or key not in self.stats:
continue
factor = MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else 1.0 / MM_PER_METRE
self.stats[key] = {
stat: value if stat == "count" else value * factor for stat, value in self.stats[key].items()
}
@property
def camera_keys(self) -> list[str]:
"""Keys to access visual modalities (regardless of their storage method)."""
+20 -2
View File
@@ -22,10 +22,14 @@ from pathlib import Path
import datasets
import torch
from lerobot.configs import DEFAULT_DEPTH_UNIT, DepthEncoderConfig
from lerobot.configs import (
DEFAULT_DEPTH_UNIT,
DEPTH_METER_UNIT,
DepthEncoderConfig,
)
from .dataset_metadata import LeRobotDatasetMetadata
from .depth_utils import dequantize_depth
from .depth_utils import MM_PER_METRE, dequantize_depth
from .feature_utils import (
check_delta_timestamps,
get_delta_indices,
@@ -102,6 +106,13 @@ class DatasetReader:
for vid_key in self._meta.depth_keys
}
# Get the input unit of each depth feature stored as raw images.
self._image_depth_units: dict[str, str | None] = {
key: (self._meta.features[key].get("info") or {}).get("depth_unit")
for key in self._meta.depth_keys
if key in self._meta.image_keys
}
def set_image_transforms(self, image_transforms: Callable | None) -> None:
"""Replace the transform applied to visual observations."""
if image_transforms is not None and not callable(image_transforms):
@@ -329,6 +340,13 @@ class DatasetReader:
continue
item[cam] = self._image_transforms(item[cam])
# Convert depth features to the output unit.
for key, stored_unit in self._image_depth_units.items():
if key in item and stored_unit is not None and stored_unit != self._depth_output_unit:
item[key] = (
item[key] * MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else item[key] / MM_PER_METRE
)
# Add task as a string
task_idx = item["task_index"].item()
item["task"] = self._meta.tasks.iloc[task_idx].name
+10
View File
@@ -36,6 +36,7 @@ from lerobot.configs import (
RGBEncoderConfig,
VideoEncoderConfig,
depth_encoder_defaults,
infer_depth_unit,
rgb_encoder_defaults,
)
@@ -209,6 +210,15 @@ class DatasetWriter:
self.episode_buffer["timestamp"].append(timestamp)
self.episode_buffer["task"].append(frame.pop("task"))
# Record each depth feature's input unit once, inferred from the first frame's dtype.
if frame_index == 0:
for depth_key in self._meta.depth_keys:
if depth_key not in frame:
continue
info = self._meta.features[depth_key].setdefault("info", {})
if info.get("depth_unit") is None:
info["depth_unit"] = infer_depth_unit(np.asarray(frame[depth_key]).dtype)
# Start streaming encoder on first frame of episode
if frame_index == 0 and self._streaming_encoder is not None:
self._streaming_encoder.start_episode(
+2 -5
View File
@@ -34,6 +34,7 @@ from lerobot.configs.video import (
DEPTH_METER_UNIT,
DEPTH_MILLIMETER_UNIT,
DEPTH_QMAX,
infer_depth_unit,
)
from .image_writer import squeeze_single_channel
@@ -57,11 +58,7 @@ def _depth_input_to_float32_and_unit(
input_unit: Literal["auto", DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT],
) -> tuple[NDArray[np.float32], Literal[DEPTH_METER_UNIT, DEPTH_MILLIMETER_UNIT]]:
"""Convert depth to float32 in the chosen unit, and return the resolved unit."""
resolved_unit = (
(DEPTH_METER_UNIT if np.issubdtype(depth.dtype, np.floating) else DEPTH_MILLIMETER_UNIT)
if input_unit == "auto"
else input_unit
)
resolved_unit = infer_depth_unit(depth.dtype) if input_unit == "auto" else input_unit
return depth.astype(np.float32, order="K"), resolved_unit
+6
View File
@@ -224,6 +224,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
)
self.root = self.meta.root
self.revision = self.meta.revision
self.meta.rescale_depth_stats(self._depth_output_unit)
if episodes is not None and any(
episode >= self.meta.total_episodes or episode < 0 for episode in episodes
@@ -350,6 +351,11 @@ class LeRobotDataset(torch.utils.data.Dataset):
"""Frames per second used during data collection."""
return self.meta.fps
@property
def depth_output_unit(self) -> str:
"""Physical unit (``"m"`` or ``"mm"``) depth maps and statistics are returned in on read."""
return self._depth_output_unit
@property
def num_frames(self) -> int:
"""Number of frames in selected episodes."""
+24 -2
View File
@@ -22,11 +22,11 @@ import numpy as np
import torch
from datasets import load_dataset
from lerobot.configs import DEFAULT_DEPTH_UNIT, DepthEncoderConfig
from lerobot.configs import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DepthEncoderConfig
from lerobot.utils.constants import HF_LEROBOT_HOME, LOOKAHEAD_BACKTRACKTABLE, LOOKBACK_BACKTRACKTABLE
from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata
from .depth_utils import dequantize_depth
from .depth_utils import MM_PER_METRE, dequantize_depth
from .feature_utils import get_delta_indices
from .io_utils import item_to_torch
from .utils import (
@@ -310,6 +310,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
)
self.root = self.meta.root
self.revision = self.meta.revision
self.meta.rescale_depth_stats(self._depth_output_unit)
# Check version
check_version_compatibility(self.repo_id, self.meta._version, CODEBASE_VERSION)
@@ -318,6 +319,13 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
for vid_key in self.meta.depth_keys
}
# Input unit of each depth feature stored as raw images (dequantized separately from videos).
self._image_depth_units: dict[str, str | None] = {
key: (self.meta.features[key].get("info") or {}).get("depth_unit")
for key in self.meta.depth_keys
if key in self.meta.image_keys
}
self.delta_timestamps = None
self.delta_indices = None
@@ -348,6 +356,11 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
def fps(self):
return self.meta.fps
@property
def depth_output_unit(self) -> str:
"""Physical unit (``"m"`` or ``"mm"``) depth maps are returned in on read."""
return self._depth_output_unit
@staticmethod
def _iter_random_indices(
rng: np.random.Generator, buffer_size: int, random_batch_size=100
@@ -530,6 +543,15 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
for update in updates:
result.update(update)
# Convert raw-image depth features to the output unit (video depth is already converted).
for key, stored_unit in self._image_depth_units.items():
if key in result and stored_unit is not None and stored_unit != self._depth_output_unit:
result[key] = (
result[key] * MM_PER_METRE
if stored_unit == DEPTH_METER_UNIT
else result[key] / MM_PER_METRE
)
result["task"] = self.meta.tasks.iloc[item["task_index"]].name
yield result
+1 -4
View File
@@ -47,7 +47,7 @@ from lerobot.configs import (
)
from lerobot.utils.import_utils import get_safe_default_video_backend
from .depth_utils import MM_PER_METRE, quantize_depth
from .depth_utils import quantize_depth
from .pyav_utils import get_pix_fmt_channels
logger = logging.getLogger(__name__)
@@ -848,9 +848,6 @@ class _CameraEncoderThread(threading.Thread):
# Reshape CHW to (H*W, C) for per-channel stats
channels = img_downsampled.shape[0]
img_for_stats = img_downsampled.transpose(1, 2, 0).reshape(-1, channels)
# Depth stats are canonically stored in millimetres; metre (float) depth is scaled up.
if self.is_depth and np.issubdtype(frame_data.dtype, np.floating):
img_for_stats = img_for_stats * MM_PER_METRE
stats_tracker.update(img_for_stats)
frame_count += 1
+7 -1
View File
@@ -757,7 +757,7 @@ class RoboTwinEnvConfig(EnvConfig):
task: str = "beat_block_hammer" # single task or comma-separated list
fps: int = 25
episode_length: int = 300
episode_length: int = 1200
obs_type: str = "pixels_agent_pos"
render_mode: str = "rgb_array"
# Available cameras from RoboTwin's aloha-agilex embodiment: head_camera
@@ -768,6 +768,9 @@ class RoboTwinEnvConfig(EnvConfig):
# must equal what SAPIEN actually renders.
observation_height: int = 240
observation_width: int = 320
# "joint": 14-d joint-space control. "ee": 16-d end-effector-pose deltas executed via CuRobo IK
# (for world-model policies like LingBot-VA that predict per-arm xyz+quaternion+gripper poses).
action_mode: str = "joint"
features: dict[str, PolicyFeature] = field(
default_factory=lambda: {
ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(14,)),
@@ -784,6 +787,8 @@ class RoboTwinEnvConfig(EnvConfig):
)
def __post_init__(self):
if self.action_mode == "ee":
self.features[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(16,))
cam_list = [c.strip() for c in self.camera_names.split(",") if c.strip()]
for cam in cam_list:
self.features[f"pixels/{cam}"] = PolicyFeature(
@@ -826,6 +831,7 @@ class RoboTwinEnvConfig(EnvConfig):
observation_height=self.observation_height,
observation_width=self.observation_width,
episode_length=self.episode_length,
action_mode=self.action_mode,
)
+169 -6
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import importlib
import logging
import os
from collections import defaultdict
from collections.abc import Callable, Sequence
from functools import partial
@@ -28,9 +29,17 @@ import torch
from gymnasium import spaces
from lerobot.types import RobotObservation
from lerobot.utils.import_utils import _scipy_available
from .utils import _LazyAsyncVectorEnv
# scipy is only used for end-effector-pose composition (``--env.action_mode=ee``); guard it so this
# module (and its base-env unit tests, which mock the RoboTwin runtime) imports without scipy installed.
if _scipy_available:
from scipy.spatial.transform import Rotation
else:
Rotation = None
logger = logging.getLogger(__name__)
# Camera names as used by RoboTwin 2.0. The wrapper appends "_rgb" when looking
@@ -41,10 +50,124 @@ ROBOTWIN_CAMERA_NAMES: tuple[str, ...] = (
"right_camera",
)
ACTION_DIM = 14 # 7 DOF × 2 arms
ACTION_DIM = 14 # 7 DOF × 2 arms (joint-space control mode)
# End-effector-pose control mode: per arm [x, y, z, qx, qy, qz, qw, gripper] = 8, dual-arm = 16.
# Used by world-model policies (e.g. LingBot-VA) that predict eef-pose deltas executed via CuRobo IK.
EEF_ACTION_DIM = 16
ACTION_LOW = -1.0
ACTION_HIGH = 1.0
DEFAULT_EPISODE_LENGTH = 300
DEFAULT_EPISODE_LENGTH = 1200
OFFICIAL_INSTRUCTION_ENV = "LEROBOT_ROBOTWIN_OFFICIAL_INSTRUCTION"
OFFICIAL_INSTRUCTION_TYPE_ENV = "LEROBOT_ROBOTWIN_INSTRUCTION_TYPE"
OFFICIAL_INSTRUCTION_MAX_ENV = "LEROBOT_ROBOTWIN_INSTRUCTION_MAX"
def _compose_eef_pose(new_pose: np.ndarray, init_pose: np.ndarray) -> np.ndarray:
"""Compose a single-arm predicted delta pose onto the initial pose.
``new_pose`` / ``init_pose`` are 8-vectors ``[x, y, z, qx, qy, qz, qw, gripper]``. Translation
is added, rotation is composed (``init_R * new_R``), and the gripper is taken from the
prediction. Mirrors ``add_eef_pose`` in the upstream LingBot-VA RoboTwin client.
"""
new_r = Rotation.from_quat(new_pose[3:7])
init_r = Rotation.from_quat(init_pose[3:7])
out_rot = (init_r * new_r).as_quat()
out_trans = new_pose[:3] + init_pose[:3]
return np.concatenate([out_trans, out_rot, new_pose[7:8]])
def _add_init_eef_pose(delta_pose: np.ndarray, init_pose: np.ndarray) -> np.ndarray:
"""Compose a dual-arm (16-d) predicted delta pose onto the initial eef pose, normalizing quats."""
left = _compose_eef_pose(delta_pose[:8], init_pose[:8])
right = _compose_eef_pose(delta_pose[8:], init_pose[8:])
out = np.concatenate([left, right])
# Normalize the two quaternions (indices 3:7 and 11:15) as the upstream client does.
out[3:7] = out[3:7] / (np.linalg.norm(out[3:7]) + 1e-8)
out[11:15] = out[11:15] / (np.linalg.norm(out[11:15]) + 1e-8)
return out
def _env_flag(name: str, default: bool = False) -> bool:
raw = os.environ.get(name)
if raw is None:
return default
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _arm_for_block(block: Any) -> str:
return "left" if float(block.get_pose().p[0]) < 0 else "right"
def _robotwin_blocks_episode_info(task_name: str, env: Any) -> dict[str, str] | None:
"""Infer the episode-info dict used by RoboTwin's official instruction generator for block ranking."""
if task_name == "blocks_ranking_rgb":
return {
"{A}": "red block",
"{B}": "green block",
"{C}": "blue block",
"{a}": _arm_for_block(env.block1),
"{b}": _arm_for_block(env.block2),
"{c}": _arm_for_block(env.block3),
}
if task_name == "blocks_ranking_size":
return {
"{A}": "large block",
"{B}": "medium block",
"{C}": "small block",
"{a}": _arm_for_block(env.block1),
"{b}": _arm_for_block(env.block2),
"{c}": _arm_for_block(env.block3),
}
return None
def _generate_robotwin_official_instruction(task_name: str, env: Any) -> str:
"""Generate language with RoboTwin's official task templates, matching its eval client."""
fallback = task_name.replace("_", " ")
episode_info = _robotwin_blocks_episode_info(task_name, env)
if episode_info is None:
logger.warning(
"Official RoboTwin instruction is not implemented for task=%s; using %r.", task_name, fallback
)
return fallback
try:
# Part of the robotwin simulator repo, this is being pulled by the docker image running robotwin
# see https://github.com/RoboTwin-Platform/RoboTwin/tree/main/description
# Used to generate the official instructions
from description.utils.generate_episode_instructions import generate_episode_descriptions
except Exception:
logger.warning(
"Failed to import RoboTwin official instruction generator; using %r.", fallback, exc_info=True
)
return fallback
instruction_type = os.environ.get(OFFICIAL_INSTRUCTION_TYPE_ENV, "seen")
try:
max_descriptions = int(os.environ.get(OFFICIAL_INSTRUCTION_MAX_ENV, "1000000"))
except ValueError:
max_descriptions = 1000000
results = generate_episode_descriptions(task_name, [episode_info], max_descriptions=max_descriptions)
if not results:
logger.warning(
"RoboTwin generated no official instructions for task=%s; using %r.", task_name, fallback
)
return fallback
options = results[0].get(instruction_type) or results[0].get("seen") or results[0].get("unseen")
if not options:
logger.warning(
"RoboTwin generated no %s official instructions for task=%s; using %r.",
instruction_type,
task_name,
fallback,
)
return fallback
return str(np.random.choice(options))
# D435 dims from task_config/_camera_config.yml (what demo_clean.yml selects).
DEFAULT_CAMERA_H = 240
DEFAULT_CAMERA_W = 320
@@ -234,6 +357,7 @@ class RoboTwinEnv(gym.Env):
observation_width: int | None = None,
episode_length: int = DEFAULT_EPISODE_LENGTH,
render_mode: str = "rgb_array",
action_mode: str = "joint",
):
super().__init__()
self.task_name = task_name
@@ -241,6 +365,13 @@ class RoboTwinEnv(gym.Env):
self.task_description = task_name.replace("_", " ")
self.episode_index = episode_index
self._reset_stride = n_envs
# "joint": 14-d joint-space actions via take_action(action). "ee": 16-d end-effector-pose
# deltas (added onto the episode's initial eef pose) executed via take_action(.., "ee") + IK.
if action_mode not in ("joint", "ee"):
raise ValueError(f"action_mode must be 'joint' or 'ee'; got {action_mode!r}")
self.action_mode = action_mode
self._action_dim = EEF_ACTION_DIM if action_mode == "ee" else ACTION_DIM
self._init_eef_pose: np.ndarray | None = None
self.camera_names = list(camera_names)
# Default to D435 dims (the camera type baked into task_config/demo_clean.yml).
# The YAML-driven lookup is deferred to reset() so construction doesn't
@@ -271,7 +402,7 @@ class RoboTwinEnv(gym.Env):
}
)
self.action_space = spaces.Box(
low=ACTION_LOW, high=ACTION_HIGH, shape=(ACTION_DIM,), dtype=np.float32
low=ACTION_LOW, high=ACTION_HIGH, shape=(self._action_dim,), dtype=np.float32
)
def _ensure_env(self) -> None:
@@ -317,6 +448,18 @@ class RoboTwinEnv(gym.Env):
return {"pixels": images, "agent_pos": joint_state}
def _read_eef_pose(self) -> np.ndarray:
"""Read the current 16-d dual-arm eef pose [left(xyz+quat)+grip, right(xyz+quat)+grip]."""
assert self._env is not None, "_read_eef_pose called before _ensure_env()"
ep = self._env.get_obs()["endpose"]
pose = (
list(ep["left_endpose"])
+ [ep["left_gripper"]]
+ list(ep["right_endpose"])
+ [ep["right_gripper"]]
)
return np.asarray(pose, dtype=np.float64)
def reset(self, seed: int | None = None, **kwargs) -> tuple[RobotObservation, dict]:
self._ensure_env()
super().reset(seed=seed)
@@ -330,16 +473,32 @@ class RoboTwinEnv(gym.Env):
self.episode_index += self._reset_stride
self._step_count = 0
use_official_instruction = self.task_name in {"blocks_ranking_rgb", "blocks_ranking_size"}
if _env_flag(OFFICIAL_INSTRUCTION_ENV, default=use_official_instruction):
self.task_description = _generate_robotwin_official_instruction(self.task_name, self._env)
if hasattr(self._env, "set_instruction"):
self._env.set_instruction(instruction=self.task_description)
logger.info("RoboTwin official instruction | task=%s | %s", self.task_name, self.task_description)
else:
self.task_description = self.task_name.replace("_", " ")
# In eef mode the policy predicts pose deltas relative to the initial eef pose.
if self.action_mode == "ee":
self._init_eef_pose = self._read_eef_pose()
obs = self._get_obs()
return obs, {"is_success": False, "task": self.task_name}
def step(self, action: np.ndarray) -> tuple[RobotObservation, float, bool, bool, dict[str, Any]]:
assert self._env is not None, "step() called before reset()"
if action.ndim != 1 or action.shape[0] != ACTION_DIM:
raise ValueError(f"Expected 1-D action of shape ({ACTION_DIM},), got {action.shape}")
if action.ndim != 1 or action.shape[0] != self._action_dim:
raise ValueError(f"Expected 1-D action of shape ({self._action_dim},), got {action.shape}")
with torch.enable_grad():
if hasattr(self._env, "take_action"):
if self.action_mode == "ee":
ee_action = _add_init_eef_pose(np.asarray(action, dtype=np.float64), self._init_eef_pose)
self._env.take_action(ee_action, action_type="ee")
elif hasattr(self._env, "take_action"):
self._env.take_action(action)
else:
self._env.step(action)
@@ -398,6 +557,7 @@ def _make_env_fns(
observation_height: int,
observation_width: int,
episode_length: int,
action_mode: str = "joint",
) -> list[Callable[[], RoboTwinEnv]]:
"""Return n_envs factory callables for a single task."""
@@ -410,6 +570,7 @@ def _make_env_fns(
observation_height=observation_height,
observation_width=observation_width,
episode_length=episode_length,
action_mode=action_mode,
)
return [partial(_make_one, i) for i in range(n_envs)]
@@ -423,6 +584,7 @@ def create_robotwin_envs(
observation_height: int = DEFAULT_CAMERA_H,
observation_width: int = DEFAULT_CAMERA_W,
episode_length: int = DEFAULT_EPISODE_LENGTH,
action_mode: str = "joint",
) -> dict[str, dict[int, Any]]:
"""Create vectorized RoboTwin 2.0 environments.
@@ -473,6 +635,7 @@ def create_robotwin_envs(
observation_height=observation_height,
observation_width=observation_width,
episode_length=episode_length,
action_mode=action_mode,
)
if is_async:
lazy = _LazyAsyncVectorEnv(fns, cached_obs_space, cached_act_space, cached_metadata)
+117
View File
@@ -0,0 +1,117 @@
# dog-nav on a real Unitree Go2 — bring-up guide
The synthetic scene (`--dry-run`) exists only to test the logic without a
robot. To run for real you need the dog, the GPU host, and the steps
below. Bring it up **in stages** — never start with autonomous motion on
untested hardware.
## 0. Prerequisites
- Unitree Go2 **EDU** (SDK access; the consumer Go2 can't be commanded).
- A GPU host (your 5090) on the **same network as the dog**. Over
Ethernet the dog is on `192.168.123.x`; find your interface with
`ip link` (e.g. `enp2s0`).
- A remote/controller in hand for a hardware e-stop at all times.
## 1. Get the branch onto the 5090
The branch `feat/unitree-go2` is local (not pushed to upstream). Either:
**Option A — your fork:**
```bash
# on the mac, one time:
git remote add fork git@github.com:<you>/lerobot.git
git push fork feat/unitree-go2
# on the 5090:
git clone git@github.com:<you>/lerobot.git && cd lerobot
git checkout feat/unitree-go2
```
**Option B — git bundle (no remote needed):**
```bash
# on the mac:
git bundle create go2-nav.bundle origin/main..feat/unitree-go2
# copy go2-nav.bundle to the 5090, then:
git clone https://github.com/huggingface/lerobot.git && cd lerobot
git fetch ../go2-nav.bundle feat/unitree-go2:feat/unitree-go2
git checkout feat/unitree-go2
```
## 2. Environment on the 5090
```bash
uv venv --python 3.12 .venv
uv pip install -e . # lerobot core (torch, etc.)
uv pip install transformers # SigLIP2
uv pip install unitree_sdk2py # DDS to the dog (Linux only)
# LingBot-Map (geometry) — source install:
pip install -e 'git+https://github.com/robbyant/lingbot-map#egg=lingbot-map'
```
Smoke-test the code path with no dog:
```bash
.venv/bin/python -m lerobot.navigation.dog_cli --dry-run --command "go to the couch"
```
## 3. Stage 1 — verify DDS + sensors (NO motion)
Confirm the host talks to the dog and reads odometry + camera before
anything moves:
```python
from lerobot.robots.unitree_go2 import UnitreeGo2, UnitreeGo2Config
r = UnitreeGo2(UnitreeGo2Config(network_interface="enp2s0", stand_on_connect=False))
r.connect()
obs = r.get_observation()
print({k: (v.shape if hasattr(v, "shape") else v) for k, v in obs.items()})
r.disconnect()
```
You want a real `front` image `(720, 1280, 3)` and non-garbage
`x.pos/y.pos/theta.pos`. If `theta.pos` doesn't change sign the way you
expect when you turn the dog by hand, tell me — the odometry sign
conventions may need a tweak for your firmware.
## 4. Stage 2 — teleop (low speed, hand on e-stop)
```bash
lerobot-teleoperate --robot.type=unitree_go2 \
--robot.network_interface=enp2s0 --teleop.type=gamepad
```
Confirm forward/left/turn go the right way. This validates
`send_action`/`SportClient.Move` before the nav loop drives.
## 5. Stage 3 — MAP-ONLY (still no autonomous motion)
Build the map by teleoperating the dog around while the models run.
Query where things are; the dog never drives itself:
```bash
.venv/bin/python -m lerobot.navigation.dog_cli --map-only \
--network-interface enp2s0 --device cuda --camera-hfov-deg 90
# teleop the dog around the room, then type object names:
# couch -> "couch is at (x, y, z) ..." or "not found yet"
```
Tune `--camera-hfov-deg` to your Go2 front camera so free-space carving
is correct (a wrong value only hurts dynamic removal, not the map).
## 6. Stage 4 — autonomous nav (open space, low speed, e-stop ready)
Only after 13 look right. Start in a clear area:
```bash
.venv/bin/python -m lerobot.navigation.dog_cli --live \
--network-interface enp2s0 --device cuda \
--max-lin-speed 0.3 --max-yaw-rate 0.6
# empty line -> one exploration step; type an object -> navigate to it.
```
`SafeBaseController` clamps speed, refuses moves into obstacle cells, and
latches an e-stop if keyframes go stale (>2 s). Ctrl-C stops the base.
## Known things to expect / tune on first hardware contact
- **Odometry sign conventions** (`position[0/1]`, `imu_state.rpy[2]`):
verified in sim, not yet against live firmware — check in Stage 1.
- **Camera FOV / focal**: set `--camera-hfov-deg` from your camera.
- **Gait bob**: pose is planarized (yaw only); pitch/roll wobble is
ignored for now. Fine at low speed; a full-SE(3) camera pose is the
refinement if the map smears vertically.
- **Keyframe rate**: SAM2 isn't in this path; the per-tick cost is
LingBot-Map + SigLIP2 on the 5090 (~tens of ms each). If ticks lag,
drop camera resolution.
+96
View File
@@ -0,0 +1,96 @@
# `lerobot.navigation` — spatial-memory navigation
Online spatio-semantic mapping (DynaMem-style), A* planning, obstacle
avoidance and open-vocabulary goto/explore for LeRobot mobile bases.
Ported from the dyna360 research stack; the physical robot layer lives in
`lerobot.robots` (e.g. [`unitree_go2`](../robots/unitree_go2)).
## Idea
Drive any LeRobot `Robot` on the standard REP-103 mobile-base contract —
body-velocity actions `x.vel`/`y.vel`/`theta.vel` and planar odometry
`x.pos`/`y.pos`/`theta.pos` — from a spatial memory that is built and
updated online from the robot's camera. With no prompt the base explores
autonomously; given a text prompt it queries the map and navigates to the
matching object, or explores to find it if it isn't there (or has moved).
## Architecture
The navigation layer talks to hardware only through LeRobot's own `Robot`
interface, so it is robot-agnostic and carries no SDK dependency.
```
BaseController (protocol) world-frame move()/pose() seam
├── StubBaseController kinematic integrator (sim, tests)
├── RobotBaseController wraps any Robot; world<->body +
│ odometry<->world frame math
└── SafeBaseController velocity clamp, occupancy gate,
keyframe watchdog, e-stop latch
```
World frame is OpenCV (x right, y down, z forward); the base moves in the
XZ plane. `RobotBaseController.feed_observation(obs)` updates pose from
the observation the navigation loop already fetches (closed-loop
odometry), avoiding an extra camera read; absent odometry it integrates
open-loop so sim matches hardware.
## Status (branch `feat/unitree-go2`)
Implemented:
- `base_controller.py` — the controller seam (protocol, stub, safety
wrapper, robot-backed controller + frame math).
- `voxel_map.py` — 5 cm sparse-hash `VoxelMap`: count-weighted geometry,
free-space `carve` (dynamic updates), per-voxel feature + `query`. No
point-cloud retention.
- `occupancy.py` — 3-class top-down grid + A* (no corner-cutting) +
obstacle inflation + frontier extraction.
- `value_map.py` — DynaMem §3.4 recency (V_T) + similarity (V_S)
exploration scoring.
- `features.py``SiglipFeatureExtractor` (lazy transformers) +
`FeatureExtractor` protocol + `BasisVectorFeatureExtractor` stand-in.
- `geometry.py``GeometryRunner` protocol + `LingBotMapRunner` (lazy) +
`FakeGeometryRunner`; `align_trajectory_to_odometry` (Umeyama) anchors
the monocular scale to sport-mode odometry.
- `pipeline.py` — viz-free `integrate_keyframe` (carve → add) +
feature upsampling.
- `skills.py` / `agent.py``SpatialSkills` (locate/goto/explore) +
`DeterministicAgent` + regex parser.
- `sim.py` — self-contained synthetic scenes for model-free dry-runs.
- `dog_cli.py` — the `dog-nav` REPL (the deliverable).
Everything is model/hardware-free-testable (191 tests across the branch).
The one thing that needs the real dog + GPU models is `--live`.
## Running
```bash
# Synthetic scene, no robot/camera/models:
python -m lerobot.navigation.dog_cli --dry-run
python -m lerobot.navigation.dog_cli --dry-run --command "go to the couch"
# On a real Unitree Go2 (DDS + LingBot-Map + SigLIP2 on the GPU host):
python -m lerobot.navigation.dog_cli --live --network-interface enp2s0 --device cuda
# Add --viz to stream the map into a Rerun viewer as it builds/updates
# (pip install 'lerobot[viz]'). --color-mode recency shows observation age;
# carved voxels (moved/removed objects) flash red then vanish.
python -m lerobot.navigation.dog_cli --dry-run --viz
python -m lerobot.navigation.dog_cli --map-only --viz --color-mode recency \
--network-interface enp2s0 --device cuda
```
Idle (no prompt) ⇒ autonomous exploration; a typed object name ⇒ navigate
to it, exploring to find it if it isn't mapped yet.
## Target platform
Unitree Go2 EDU, no companion computer: the workstation (single RTX 5090)
talks DDS straight to the dog; geometry is monocular LingBot-Map from the
built-in front camera, scale-anchored to sport-mode odometry; the map is
5 cm voxels. See [`robots/unitree_go2`](../robots/unitree_go2).
## Not yet ported (optional enhancement)
`SegmentVoxelMap` (object-centric per-segment features via SAM 2) is a
storage/precision optimization over the plain per-voxel features used
here; the locate/goto/explore stack is fully functional without it.
+118
View File
@@ -0,0 +1,118 @@
#!/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.
"""Spatial-memory navigation for LeRobot mobile bases.
Online spatio-semantic mapping (DynaMem-style), A* planning, obstacle
avoidance and open-vocabulary goto/explore, driving any LeRobot ``Robot``
that exposes body-velocity actions and planar odometry. Ported from the
dyna360 research stack; the physical robot layer lives in
``lerobot.robots`` (e.g. ``unitree_go2``).
"""
from .agent import (
AgentConfig,
AgentResult,
DeterministicAgent,
HardcodedTaskParser,
Task,
TaskParser,
)
from .base_controller import (
BaseController,
RobotBaseController,
SafeBaseController,
StubBaseController,
odometry_to_world_pose,
world_velocity_to_body,
)
from .features import (
BasisVectorFeatureExtractor,
FeatureExtractor,
SiglipFeatureExtractor,
)
from .geometry import (
FakeGeometryRunner,
GeometryOutput,
GeometryRunner,
LingBotMapRunner,
align_trajectory_to_odometry,
)
from .occupancy import (
NAVIGABLE,
OBSTACLE,
UNOBSERVED,
OccupancyGrid,
astar,
find_frontier_cells,
project_voxel_map_to_grid,
)
from .pipeline import KeyframeContext, PipelineConfig, integrate_keyframe
from .skills import (
ExploreResult,
GotoResult,
LocateResult,
SkillsConfig,
SpatialSkills,
)
from .value_map import ValueMapConfig, ValueMaps, compute_value_maps, pick_best_frontier_cell
from .voxel_map import CarveResult, QueryResult, VoxelMap, VoxelSnapshot
__all__ = [
"NAVIGABLE",
"OBSTACLE",
"UNOBSERVED",
"AgentConfig",
"AgentResult",
"BaseController",
"BasisVectorFeatureExtractor",
"CarveResult",
"DeterministicAgent",
"ExploreResult",
"FakeGeometryRunner",
"FeatureExtractor",
"GeometryOutput",
"GeometryRunner",
"GotoResult",
"HardcodedTaskParser",
"KeyframeContext",
"LingBotMapRunner",
"LocateResult",
"OccupancyGrid",
"PipelineConfig",
"QueryResult",
"RobotBaseController",
"SafeBaseController",
"SiglipFeatureExtractor",
"SkillsConfig",
"SpatialSkills",
"StubBaseController",
"Task",
"TaskParser",
"ValueMapConfig",
"ValueMaps",
"VoxelMap",
"VoxelSnapshot",
"align_trajectory_to_odometry",
"astar",
"compute_value_maps",
"integrate_keyframe",
"find_frontier_cells",
"odometry_to_world_pose",
"pick_best_frontier_cell",
"project_voxel_map_to_grid",
"world_velocity_to_body",
]
+262
View File
@@ -0,0 +1,262 @@
#!/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.
"""Deterministic agent wrapper + language-parser interface.
Ported from the dyna360 research stack. The high-level agent is a thin
deterministic wrapper, not LLM-driven: explore-vs-go control lives here
in plain Python. A language model (when wired up) only parses a
natural-language command into a typed :class:`Task`; the deterministic
wrapper then executes it. Swapping the parser (regex vs a real LLM) must
not change the spatial behaviour.
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from lerobot.navigation.skills import SpatialSkills
LOG = logging.getLogger(__name__)
# ============== task data structures ====================================== #
@dataclass(frozen=True)
class Task:
"""Parsed command, ready for the deterministic wrapper to execute.
``go to X`` yields ``Task(targets=['X'])``; ``go to X then Y`` yields
``Task(targets=['X', 'Y'])``, executed sequentially.
"""
targets: list[str]
raw: str = ""
@dataclass(frozen=True)
class TargetResult:
"""Outcome of executing the policy for a single target."""
target: str
reached: bool
final_xyz: tuple[float, float, float] | None
n_explore_iters: int
confidence: float
reason: str
"""'ok' | 'no_path' | 'budget_exhausted' | 'no_frontier' | 'parse_empty'."""
@dataclass(frozen=True)
class AgentResult:
"""Outcome of executing a full Task (one or more sequential targets)."""
task: Task
target_results: list[TargetResult] = field(default_factory=list)
@property
def fully_successful(self) -> bool:
return bool(self.target_results) and all(r.reached for r in self.target_results)
# ============== language parser ========================================== #
@runtime_checkable
class TaskParser(Protocol):
"""Anything that turns a free-text command into a :class:`Task`."""
def parse(self, command: str) -> Task: ...
class HardcodedTaskParser:
"""Regex-only parser — fast, dependency-free, good enough to validate
the deterministic policy without loading a language model.
Handles ``go to (the) X`` / ``find (the) X`` → single target, ``go to
X then Y`` → multi-step, and falls back to "the whole command is the
target" if no pattern matches.
"""
_SINGLE_PATTERNS = (
re.compile(
r"^\s*(?:go to|navigate to|find|locate|look for)\s+(?:the\s+)?(.+?)\s*$",
re.IGNORECASE,
),
)
_SPLIT_PATTERN = re.compile(r"\s+(?:then|and then)\s+|\s*,\s*", re.IGNORECASE)
def parse(self, command: str) -> Task:
raw = command.strip()
if not raw:
return Task(targets=[], raw=raw)
parts = self._SPLIT_PATTERN.split(raw)
targets: list[str] = []
for part in parts:
t = self._extract_target(part)
if t:
targets.append(t)
return Task(targets=targets, raw=raw)
def _extract_target(self, text: str) -> str:
text = text.strip().rstrip(".?!")
for p in self._SINGLE_PATTERNS:
m = p.match(text)
if m:
return m.group(1).strip()
prefix = re.match(r"^\s*(?:the\s+)?(.+)$", text, re.IGNORECASE)
if prefix:
return prefix.group(1).strip()
return text
# ============== deterministic agent ====================================== #
@dataclass(frozen=True)
class AgentConfig:
"""Agent policy knobs."""
max_explore_iters: int = 5
"""How many ``explore → relocate`` loops before giving up on a target."""
explore_step_uses_goto: bool = True
"""Drive to the explore frontier via closed-loop ``goto``. False
teleports instead (fast offline eval)."""
class DeterministicAgent:
"""Executes a :class:`Task` via a fixed policy.
For each target: locate; if found, goto and done; else explore(query),
goto the frontier, and relocate — up to ``max_explore_iters``, then give
up. The control flow is plain Python; no LLM in the loop.
"""
def __init__(self, skills: SpatialSkills, cfg: AgentConfig | None = None) -> None:
self.skills = skills
self.cfg = cfg or AgentConfig()
def execute(self, task: Task) -> AgentResult:
out: list[TargetResult] = []
for target in task.targets:
out.append(self._execute_target(target))
if not out[-1].reached:
# Don't auto-skip after a failed multi-step leg; bail so the
# caller sees the failure clearly.
break
return AgentResult(task=task, target_results=out)
def execute_command(self, command: str, parser: TaskParser) -> AgentResult:
"""Parse a free-text command, then execute."""
task = parser.parse(command)
if not task.targets:
return AgentResult(
task=task,
target_results=[
TargetResult(
target="",
reached=False,
final_xyz=None,
n_explore_iters=0,
confidence=-1.0,
reason="parse_empty",
)
],
)
return self.execute(task)
# ----- single-target inner loop ----------------------------------------
def _execute_target(self, target: str) -> TargetResult:
last_conf = -1.0
for it in range(self.cfg.max_explore_iters + 1):
loc = self.skills.locate(target)
last_conf = loc.confidence
if loc.found and loc.xyz is not None:
LOG.info(
"agent: locate(%r) found at %s (conf %.3f); goto",
target,
loc.xyz,
loc.confidence,
)
gr = self.skills.goto(loc.xyz)
return TargetResult(
target=target,
reached=gr.reached,
final_xyz=gr.final_xyz,
n_explore_iters=it,
confidence=loc.confidence,
reason="ok" if gr.reached else gr.reason,
)
if it >= self.cfg.max_explore_iters:
LOG.info(
"agent: locate(%r) NOT_FOUND (conf %.3f) and explore budget exhausted",
target,
loc.confidence,
)
return TargetResult(
target=target,
reached=False,
final_xyz=None,
n_explore_iters=it,
confidence=loc.confidence,
reason="budget_exhausted",
)
# NOT_FOUND → explore once, then loop and re-locate.
LOG.info(
"agent: locate(%r) NOT_FOUND (conf %.3f) → explore iter %d",
target,
loc.confidence,
it + 1,
)
ex = self.skills.explore(query=target)
if not ex.found_frontier or ex.target_xyz is None:
return TargetResult(
target=target,
reached=False,
final_xyz=None,
n_explore_iters=it,
confidence=loc.confidence,
reason="no_frontier",
)
if self.cfg.explore_step_uses_goto:
self.skills.goto(ex.target_xyz)
else:
# Teleport for offline-eval speed.
self.skills.base.move(0.0, 0.0, dt=0.0)
pose = self.skills.base.pose()
pose[0, 3] = ex.target_xyz[0]
pose[2, 3] = ex.target_xyz[2]
if hasattr(self.skills.base, "_pose"):
self.skills.base._pose = pose # noqa: SLF001
return TargetResult(
target=target,
reached=False,
final_xyz=None,
n_explore_iters=self.cfg.max_explore_iters,
confidence=last_conf,
reason="budget_exhausted",
)
+389
View File
@@ -0,0 +1,389 @@
#!/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.
"""Base controller for spatial-memory navigation.
The navigation/skills layer commands motion in a single **world frame**
(OpenCV convention: x right, y down, z forward — the base lives in the XZ
plane, y is gravity) and reads back an SE(3) pose. :class:`BaseController`
is that seam. Three implementations:
- :class:`StubBaseController` — kinematic integrator, no hardware; sim +
unit tests.
- :class:`RobotBaseController` — drives any LeRobot :class:`Robot` whose
action space is body-frame velocities ``x.vel`` (forward, m/s),
``y.vel`` (left, m/s), ``theta.vel`` (CCW yaw, rad/s) and whose
observation carries planar odometry ``x.pos``/``y.pos``/``theta.pos``
(REP-103: x forward, y left, yaw CCW). The Unitree Go2 satisfies this
out of the box; so would a LeKiwi base.
- :class:`SafeBaseController` — wraps any of the above with velocity
clamping, an optional occupancy gate, a keyframe watchdog and an
e-stop latch.
All frame conversions between the world frame and a robot's body/odometry
frame live in :func:`world_velocity_to_body` and
:func:`odometry_to_world_pose`; nothing else needs to know the mapping.
"""
from __future__ import annotations
import logging
import math
import time
from abc import abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Protocol, runtime_checkable
import numpy as np
if TYPE_CHECKING:
from lerobot.robots import Robot
logger = logging.getLogger(__name__)
# --------------------------------------------------------------------- #
# BaseController protocol
# --------------------------------------------------------------------- #
@runtime_checkable
class BaseController(Protocol):
"""Mobile-base interface used by the navigation/skills layer.
Velocities are in **world** frame XZ (m/s); ``yaw_rate`` is rad/s
about the world's Y axis (turning around the up vector). ``pose``
is 4×4 SE(3) camera-to-world (OpenCV).
"""
@abstractmethod
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None: ...
@abstractmethod
def stop(self) -> None: ...
@abstractmethod
def pose(self) -> np.ndarray: ...
@abstractmethod
def position(self) -> tuple[float, float, float]: ...
# --------------------------------------------------------------------- #
# Frame math (pure functions)
# --------------------------------------------------------------------- #
def world_velocity_to_body(
vx_world: float,
vz_world: float,
yaw_rate_rad_s: float,
heading_rad: float,
) -> tuple[float, float, float]:
"""World-frame velocity → body-frame ``(x.vel, y.vel, theta.vel)``.
Returns ``(vx_forward, vy_left, vyaw)`` in m/s, m/s, rad/s — the
action a REP-103 base expects. At heading ``h`` the body axes in the
world XZ plane are forward = (sin h, cos h), left = (cos h, sin h)
(left = up × forward, up = y). The navigation world's positive yaw
is clockwise about the up vector; a REP-103 base's ``theta.vel`` is
counter-clockwise, hence the sign flip.
"""
s, c = math.sin(heading_rad), math.cos(heading_rad)
vx_fwd = vx_world * s + vz_world * c
vy_left = -vx_world * c + vz_world * s
return vx_fwd, vy_left, -yaw_rate_rad_s
def odometry_to_world_pose(
x_fwd: float,
y_left: float,
yaw: float,
origin: tuple[float, float, float],
) -> tuple[np.ndarray, float]:
"""Planar odometry ``(x_fwd, y_left, yaw)`` → world pose + heading.
``origin`` is the ``(x_fwd, y_left, yaw)`` sample captured when the
controller first saw odometry, so the run starts at identity
regardless of where the robot's odometry origin sits. The result is
the OpenCV world convention, planarized: height Y is 0 and only yaw
survives of the orientation — pitch/roll gait wobble is the camera's
concern, not the base's.
Odometry frame is REP-103 (x forward, y left, yaw CCW about z-up).
Mapping to OpenCV world: ``x_world = y_odom``, ``z_world = x_odom``,
``heading = yaw``.
"""
ox, oy, oyaw = origin
dx, dy = x_fwd - ox, y_left - oy
c0, s0 = math.cos(-oyaw), math.sin(-oyaw)
x_rel = c0 * dx - s0 * dy
y_rel = s0 * dx + c0 * dy
yaw_rel = yaw - oyaw
x_world, z_world = -y_rel, x_rel
heading = -yaw_rel
ch, sh = math.cos(heading), math.sin(heading)
pose = np.eye(4, dtype=np.float64)
pose[0, 0], pose[0, 2] = ch, sh
pose[2, 0], pose[2, 2] = -sh, ch
pose[0, 3], pose[2, 3] = x_world, z_world
return pose, heading
def _heading_pose(x: float, z: float, heading: float) -> np.ndarray:
"""Build a planar world pose from position + heading."""
c, s = math.cos(heading), math.sin(heading)
pose = np.eye(4, dtype=np.float64)
pose[0, 0], pose[0, 2] = c, s
pose[2, 0], pose[2, 2] = -s, c
pose[0, 3], pose[2, 3] = x, z
return pose
# --------------------------------------------------------------------- #
# Stub controller (kinematic, no hardware)
# --------------------------------------------------------------------- #
@dataclass
class StubBaseController:
"""Kinematic stub: integrates each ``move()`` into pose exactly.
No latency, slip or dynamics — for sim and skill-layer unit tests.
"""
initial_pose: np.ndarray | None = None
max_lin_speed: float = 1.0
max_yaw_rate: float = 1.0
def __post_init__(self) -> None:
self._pose = (
np.asarray(self.initial_pose, dtype=np.float64).copy()
if self.initial_pose is not None
else np.eye(4, dtype=np.float64)
)
if self._pose.shape != (4, 4):
raise ValueError(f"initial_pose must be (4, 4); got {self._pose.shape}")
self._heading = 0.0
self._stopped = False
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None:
vx = float(np.clip(vx, -self.max_lin_speed, self.max_lin_speed))
vz = float(np.clip(vz, -self.max_lin_speed, self.max_lin_speed))
yaw_rate = float(np.clip(yaw_rate, -self.max_yaw_rate, self.max_yaw_rate))
if dt <= 0:
return
self._pose[0, 3] += vx * dt
self._pose[2, 3] += vz * dt
if yaw_rate != 0.0:
self._heading += yaw_rate * dt
self._pose = _heading_pose(self._pose[0, 3], self._pose[2, 3], self._heading)
self._stopped = False
def stop(self) -> None:
self._stopped = True
def pose(self) -> np.ndarray:
return self._pose.copy()
def position(self) -> tuple[float, float, float]:
p = self._pose[:3, 3]
return float(p[0]), float(p[1]), float(p[2])
@property
def is_stopped(self) -> bool:
return self._stopped
# --------------------------------------------------------------------- #
# Robot-backed controller
# --------------------------------------------------------------------- #
@dataclass(frozen=True)
class RobotBaseControllerConfig:
"""Behaviour knobs for :class:`RobotBaseController`."""
max_lin_speed: float = 0.6
"""Hard cap on per-axis world linear velocity (m/s)."""
max_yaw_rate: float = 1.2
"""Hard cap on yaw rate (rad/s)."""
pose_from_odometry: bool = True
"""Report pose from the robot's odometry (closed-loop). When False,
integrate pose open-loop from commanded velocities."""
class RobotBaseController(BaseController):
""":class:`BaseController` over any LeRobot :class:`Robot`.
The robot must accept body-velocity actions ``x.vel`` (forward),
``y.vel`` (left), ``theta.vel`` (CCW yaw) and — for closed-loop pose
— report odometry ``x.pos``/``y.pos``/``theta.pos`` in its
observation. This is the standard REP-103 mobile-base contract, which
``UnitreeGo2`` implements.
Pose is refreshed from observations the navigation loop already
fetches: call :meth:`feed_observation` each keyframe rather than
having the controller poll the robot (which would trigger an extra
camera read). Absent any fed observation, pose falls back to
open-loop integration so sim/dry-run behaves like the stub.
"""
def __init__(self, robot: Robot, cfg: RobotBaseControllerConfig | None = None) -> None:
self.robot = robot
self.cfg = cfg or RobotBaseControllerConfig()
self._pose = np.eye(4, dtype=np.float64)
self._heading = 0.0
self._stopped = False
self._odom_origin: tuple[float, float, float] | None = None
self._have_odom = False
# ----- odometry feed --------------------------------------------------
def feed_observation(self, obs: dict) -> None:
"""Update pose from an observation the nav loop already fetched."""
if not self.cfg.pose_from_odometry:
return
if not {"x.pos", "y.pos", "theta.pos"} <= obs.keys():
return
sample = (float(obs["x.pos"]), float(obs["y.pos"]), float(obs["theta.pos"]))
if self._odom_origin is None:
self._odom_origin = sample
self._pose, self._heading = odometry_to_world_pose(*sample, self._odom_origin)
self._have_odom = True
# ----- BaseController API --------------------------------------------
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None:
vx = float(np.clip(vx, -self.cfg.max_lin_speed, self.cfg.max_lin_speed))
vz = float(np.clip(vz, -self.cfg.max_lin_speed, self.cfg.max_lin_speed))
yaw_rate = float(np.clip(yaw_rate, -self.cfg.max_yaw_rate, self.cfg.max_yaw_rate))
if dt <= 0:
return
vx_fwd, vy_left, vyaw = world_velocity_to_body(vx, vz, yaw_rate, self._heading)
self.robot.send_action({"x.vel": vx_fwd, "y.vel": vy_left, "theta.vel": vyaw})
# Open-loop pose only when we have no odometry to trust.
if not (self.cfg.pose_from_odometry and self._have_odom):
self._pose[0, 3] += vx * dt
self._pose[2, 3] += vz * dt
if yaw_rate != 0.0:
self._heading += yaw_rate * dt
self._pose = _heading_pose(self._pose[0, 3], self._pose[2, 3], self._heading)
self._stopped = False
def stop(self) -> None:
self._stopped = True
try:
self.robot.send_action({"x.vel": 0.0, "y.vel": 0.0, "theta.vel": 0.0})
except Exception:
logger.exception("stop(): failed to send zero-velocity action")
def pose(self) -> np.ndarray:
return self._pose.copy()
def position(self) -> tuple[float, float, float]:
p = self._pose[:3, 3]
return float(p[0]), float(p[1]), float(p[2])
@property
def is_stopped(self) -> bool:
return self._stopped
# --------------------------------------------------------------------- #
# Safety wrapper
# --------------------------------------------------------------------- #
@dataclass
class SafeBaseController(BaseController):
"""Wrap any :class:`BaseController` with safety layers:
- **velocity clamp** on every ``move()``;
- **occupancy gate**: when ``occupancy_provider`` is set, predict
the next position and refuse (latch e-stop) if it lands in an
obstacle cell. The provider returns an object exposing
``world_to_cell(x, z) -> (iz, ix)`` and an ``is_obstacle(iz, ix)
-> bool`` predicate; ``None`` means "no map yet, allow";
- **watchdog**: if no keyframe has been fed in
``watchdog_timeout_s`` (caller ticks :meth:`feed_watchdog` per
map update), ``move()`` latches stop until :meth:`reset_watchdog`.
"""
inner: BaseController
max_lin_speed: float = 0.6
max_yaw_rate: float = 1.2
occupancy_provider: object = None # callable[[], grid | None] when set
watchdog_timeout_s: float = 2.0
e_stop_latched: bool = False
_last_keyframe_walltime: float = field(default_factory=time.monotonic, init=False)
def feed_watchdog(self) -> None:
self._last_keyframe_walltime = time.monotonic()
def reset_watchdog(self) -> None:
self.e_stop_latched = False
self._last_keyframe_walltime = time.monotonic()
def latch_estop(self, reason: str = "external") -> None:
logger.warning("SafeBaseController e-stop latched: %s", reason)
self.e_stop_latched = True
self.inner.stop()
def move(self, vx: float, vz: float, yaw_rate: float = 0.0, dt: float = 0.05) -> None:
if self.e_stop_latched:
return
if (time.monotonic() - self._last_keyframe_walltime) > self.watchdog_timeout_s:
self.latch_estop(f"watchdog: no keyframe in last {self.watchdog_timeout_s:.2f}s")
return
vx = float(np.clip(vx, -self.max_lin_speed, self.max_lin_speed))
vz = float(np.clip(vz, -self.max_lin_speed, self.max_lin_speed))
yaw_rate = float(np.clip(yaw_rate, -self.max_yaw_rate, self.max_yaw_rate))
if self.occupancy_provider is not None:
try:
grid = self.occupancy_provider()
except Exception:
logger.exception("occupancy_provider raised; refusing move")
return
if grid is not None and self._would_enter_obstacle(grid, vx, vz, dt):
self.latch_estop("about to enter obstacle cell")
return
self.inner.move(vx, vz, yaw_rate, dt)
def stop(self) -> None:
self.inner.stop()
def pose(self) -> np.ndarray:
return self.inner.pose()
def position(self) -> tuple[float, float, float]:
return self.inner.position()
def _would_enter_obstacle(self, grid, vx: float, vz: float, dt: float) -> bool:
pos = self.inner.position()
next_x = pos[0] + vx * dt
next_z = pos[2] + vz * dt
iz, ix = grid.world_to_cell(next_x, next_z)
return bool(grid.is_obstacle(iz, ix))
+461
View File
@@ -0,0 +1,461 @@
#!/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.
"""``dog-nav`` — interactive spatial-memory navigation REPL.
Behaviour:
- **No prompt** (idle) → the base explores autonomously: value-map
frontier selection, A* on the live occupancy map, obstacle-gated
motion. The map grows/refreshes as it goes.
- **Typed prompt** (e.g. ``find the couch``) → query the map; if a
confident match exists, navigate to it; otherwise explore until it is
found (or the budget is exhausted), then resume idle exploring.
A new prompt preempts the current goal. Ctrl-C latches an e-stop and
exits. ``--dry-run`` runs the whole loop against a synthetic scene with no
robot, camera, or models — the default until the live geometry pipeline
(LingBot-Map) is wired.
Run: ``python -m lerobot.navigation.dog_cli --dry-run`` and type object
names; empty line ⇒ one exploration step; ``quit`` ⇒ exit.
"""
from __future__ import annotations
import argparse
import logging
import select
import sys
from lerobot.navigation.agent import (
AgentConfig,
AgentResult,
DeterministicAgent,
HardcodedTaskParser,
)
from lerobot.navigation.skills import ExploreResult, SkillsConfig, SpatialSkills
LOG = logging.getLogger("dog-nav")
class DogController:
"""The behaviour loop over a :class:`SpatialSkills` toolset.
Construct with a ready ``SpatialSkills`` (real robot or synthetic
scene). :meth:`handle_prompt` runs a full locate/goto/explore task;
:meth:`idle_tick` runs one autonomous exploration step. Both are
plain calls, so the REPL and the tests share the same code.
"""
def __init__(
self,
skills: SpatialSkills,
agent: DeterministicAgent | None = None,
parser: HardcodedTaskParser | None = None,
viz=None,
) -> None:
self.skills = skills
self.agent = agent or DeterministicAgent(skills)
self.parser = parser or HardcodedTaskParser()
self.viz = viz # optional MapVisualizer
def refresh_viz(self, target_xyz=None, path_xyz=None) -> None:
"""Log the current map, occupancy, robot pose (+ optional target/path)."""
if self.viz is None:
return
self.viz.log_map(self.skills.voxel_map.snapshot())
self.viz.log_occupancy(self.skills.occupancy())
self.viz.log_robot(self.skills.base.pose())
self.viz.log_target(target_xyz)
if path_xyz is not None:
self.viz.log_path(path_xyz)
def handle_prompt(self, text: str) -> AgentResult:
"""Query the map and navigate to the target (exploring if needed)."""
LOG.info("prompt: %r", text)
result = self.agent.execute_command(text, self.parser)
for tr in result.target_results:
if tr.reached:
LOG.info(" reached %r at %s (conf %.3f)", tr.target, tr.final_xyz, tr.confidence)
else:
LOG.info(" did not reach %r: %s (conf %.3f)", tr.target, tr.reason, tr.confidence)
last = result.target_results[-1] if result.target_results else None
self.refresh_viz(target_xyz=last.final_xyz if last and last.reached else None)
return result
def report_location(self, text: str):
"""Locate a target and report where it is — no motion commanded.
The safe query for map-only bring-up: build the map by teleop, then
ask where an object is without the dog driving itself.
"""
loc = self.skills.locate(text)
if loc.found:
LOG.info(" %r is at %s (conf %.3f, %d voxels)", text, loc.xyz, loc.confidence, loc.n_voxels)
else:
LOG.info(" %r not found yet (conf %.3f) — map more of the area", text, loc.confidence)
if self.viz is not None:
self.viz.log_target(loc.xyz if loc.found else None)
return loc
def idle_tick(self) -> ExploreResult:
"""One autonomous exploration step: pick a frontier and drive to it."""
ex = self.skills.explore(query=None)
if ex.found_frontier and ex.target_xyz is not None:
LOG.info("idle: exploring toward %s (value %.3f)", ex.target_xyz, ex.value)
self.skills.goto(ex.target_xyz)
else:
LOG.debug("idle: no frontier to explore (%s)", ex.reason)
self.refresh_viz()
return ex
def stop(self) -> None:
self.skills.base.stop()
def _build_dry_run(viz=None) -> DogController:
"""Wire the controller against the synthetic kitchen scene."""
from lerobot.navigation.base_controller import StubBaseController
from lerobot.navigation.sim import kitchen_scene
scene = kitchen_scene()
base = StubBaseController()
siglip = scene.feature_extractor()
skills = SpatialSkills(
scene.voxel_map,
base,
siglip,
SkillsConfig(
cell_size=0.2,
obstacle_inflate_cells=0,
goto_threshold=1.0,
goto_max_steps=300,
locate_threshold=0.5,
),
)
agent = DeterministicAgent(skills, AgentConfig(max_explore_iters=4))
objs = ", ".join(o.name for o in scene.objects)
LOG.info("dry-run kitchen scene ready — try one of: %s", objs)
controller = DogController(skills, agent, viz=viz)
controller.refresh_viz() # show the prebuilt map immediately
return controller
class LiveMapper:
"""One perceive→integrate step of live mapping on the robot.
Each :meth:`tick` reads an observation (front camera + odometry),
updates the base pose from odometry, runs the geometry model + feature
extractor on the frame, and integrates the keyframe.
Frame convention (important): the **odometry frame is the one world
frame**. The geometry model supplies only relative camera-frame
geometry (``local_points``/depth); those points are projected through
the base's odometry pose, so the voxel map and the robot pose live in
the same coordinates and ``goto`` drives to the right place. The
model's own ``camera_poses`` (its internal monocular frame) are not
used as the world frame. Constructed lazily — no SDK/model touched
until the first tick.
"""
def __init__(self, robot, base, geometry, siglip, voxel_map, pcfg=None, viz=None) -> None:
self.robot = robot
self.base = base # RobotBaseController (unwrapped) for feed_observation/pose
self.safe = None # optional SafeBaseController for the watchdog
self.geometry = geometry
self.siglip = siglip
self.voxel_map = voxel_map
self.pcfg = pcfg
self.viz = viz # optional MapVisualizer
self._frame = 0
def tick(self, t_sec: float) -> None:
import numpy as np
from lerobot.navigation.pipeline import (
KeyframeContext,
PipelineConfig,
integrate_keyframe,
local_points_to_world,
upsample_features_to_view,
)
obs = self.robot.get_observation()
self.base.feed_observation(obs) # updates the odometry world pose
pose = self.base.pose() # camera-to-world in the odometry frame
frame = obs.get("front")
if frame is None:
return
views = np.asarray(frame)[None].astype(np.uint8) # (1, H, W, 3)
geo = self.geometry(views)
h, w = frame.shape[:2]
feat_map = None
if self.siglip is not None:
patches = self.siglip.encode_views(views)[0] # (Hp, Wp, D)
feat_map = upsample_features_to_view(patches, h, w)
# World points come from the model's camera-frame geometry projected
# through the odometry pose — NOT the model's own world frame.
points_world = local_points_to_world(geo.local_points[0], pose)
ctx = KeyframeContext(
frame_idx=self._frame,
t_sec=t_sec,
rgb_uint8=views[0],
points_world=points_world,
local_points=geo.local_points[0],
conf=geo.conf[0],
pose=pose,
feat_map=feat_map,
)
carve, _ = integrate_keyframe(self.voxel_map, ctx, self.pcfg or PipelineConfig())
self._frame += 1
if self.safe is not None:
self.safe.feed_watchdog()
if self.viz is not None:
self.viz.set_time(t_sec)
self.viz.log_map(self.voxel_map.snapshot(), now=t_sec)
self.viz.log_removed(carve.removed_xyz) # dynamic: carved voxels flashed red
self.viz.log_robot(pose)
def _build_live(
network_interface: str = "eth0",
device: str = "cuda",
camera_hfov_deg: float = 90.0,
max_lin_speed: float = 0.4,
max_yaw_rate: float = 0.8,
viz=None,
) -> tuple[DogController, LiveMapper]:
"""Wire the controller + live mapper against a real Unitree Go2.
Nothing here touches the SDK or loads a model — construction is lazy;
the DDS connection and model loads happen on first use.
``camera_hfov_deg`` sets the pinhole focal length used for free-space
carving (``focal = W / (2·tan(HFOV/2))``). Calibrate it to the Go2
front camera for correct carving; a wrong value only degrades dynamic
removal, not the additive map. Speed caps are deliberately low for
first bring-up.
"""
import math
from lerobot.navigation.base_controller import (
RobotBaseController,
RobotBaseControllerConfig,
SafeBaseController,
)
from lerobot.navigation.features import SiglipFeatureExtractor
from lerobot.navigation.geometry import LingBotMapRunner
from lerobot.navigation.pipeline import PipelineConfig
from lerobot.navigation.voxel_map import VoxelMap
from lerobot.robots.unitree_go2 import UnitreeGo2, UnitreeGo2Config
robot_cfg = UnitreeGo2Config(network_interface=network_interface)
robot = UnitreeGo2(robot_cfg)
inner = RobotBaseController(
robot, RobotBaseControllerConfig(max_lin_speed=max_lin_speed, max_yaw_rate=max_yaw_rate)
)
safe = SafeBaseController(inner=inner, max_lin_speed=max_lin_speed, max_yaw_rate=max_yaw_rate)
voxel_map = VoxelMap(voxel_size=0.05)
siglip = SiglipFeatureExtractor(device=device)
geometry = LingBotMapRunner(device=device)
w = robot_cfg.front_camera_width
focal_px = w / (2.0 * math.tan(math.radians(camera_hfov_deg) / 2.0))
pcfg = PipelineConfig(focal_px=focal_px)
skills = SpatialSkills(voxel_map, safe, siglip, SkillsConfig(cell_size=0.05))
controller = DogController(skills, DeterministicAgent(skills, AgentConfig()), viz=viz)
mapper = LiveMapper(robot, inner, geometry, siglip, voxel_map, pcfg=pcfg, viz=viz)
mapper.safe = safe
LOG.info(
"live stack wired (iface=%s, device=%s, focal=%.1fpx, vmax=%.2f m/s) — connect the dog and run",
network_interface,
device,
focal_px,
max_lin_speed,
)
return controller, mapper
def _stdin_line_ready(timeout_s: float) -> bool:
"""True when a full line is available on stdin within ``timeout_s``.
Uses ``select`` so idle ticks keep running while we wait for input.
Falls back to blocking reads where ``select`` on stdin isn't supported
(e.g. some Windows terminals).
"""
try:
ready, _, _ = select.select([sys.stdin], [], [], timeout_s)
return bool(ready)
except (OSError, ValueError):
return True
def run_repl(controller: DogController, idle_period_s: float = 0.5) -> int:
"""Interactive loop: explore while idle, run a task on each typed line."""
print("dog-nav ready. Type an object to find it, empty line to explore, 'quit' to exit.")
try:
while True:
if _stdin_line_ready(idle_period_s):
line = sys.stdin.readline()
if not line: # EOF
break
text = line.strip()
if text.lower() in {"quit", "exit"}:
break
if text:
controller.handle_prompt(text) # a new prompt preempts idle
else:
controller.idle_tick()
else:
controller.idle_tick()
except KeyboardInterrupt:
LOG.warning("interrupted — stopping base")
finally:
controller.stop()
return 0
def run_live_repl(
controller: DogController,
mapper: LiveMapper,
idle_period_s: float = 0.2,
map_only: bool = False,
) -> int:
"""Live loop on the robot: map continuously, act on typed lines.
Each iteration integrates one keyframe (perceive → geometry → features →
voxel map). In ``map_only`` mode the dog is never commanded to move —
you teleop it while the map builds, and a typed object name reports
where it is (safe first bring-up). Otherwise a typed name runs a full
locate/goto task and an empty line takes one autonomous exploration
step. The DDS connection is opened here so ``--help`` stays model-free.
"""
import time
mapper.robot.connect()
controller.skills.base.reset_watchdog()
if map_only:
print("dog-nav (live, MAP-ONLY — no autonomous motion). Teleop the dog; type an")
print("object to ask where it is; 'quit' to exit.")
else:
print("dog-nav (live). Type an object to find it, empty line to explore, 'quit' to exit.")
t0 = time.monotonic()
try:
while True:
mapper.tick(time.monotonic() - t0)
if _stdin_line_ready(idle_period_s):
line = sys.stdin.readline()
if not line:
break
text = line.strip()
if text.lower() in {"quit", "exit"}:
break
if text:
controller.report_location(text) if map_only else controller.handle_prompt(text)
elif not map_only:
controller.idle_tick()
elif not map_only:
controller.idle_tick()
except KeyboardInterrupt:
LOG.warning("interrupted — stopping base")
finally:
controller.stop()
mapper.robot.disconnect()
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="dog-nav", description=__doc__)
ap.add_argument(
"--dry-run",
action="store_true",
help="Run against a synthetic scene (no robot/camera/models).",
)
ap.add_argument(
"--live",
action="store_true",
help="Run on a real Unitree Go2 (DDS + LingBot-Map + SigLIP2 on the GPU host).",
)
ap.add_argument(
"--map-only",
action="store_true",
help="Live mode with NO autonomous motion: teleop the dog, build the map, "
"and query where objects are. Recommended for first bring-up.",
)
ap.add_argument("--network-interface", default="eth0", help="Host interface wired to the dog.")
ap.add_argument("--device", default="cuda", help="Torch device for the geometry/feature models.")
ap.add_argument(
"--camera-hfov-deg",
type=float,
default=90.0,
help="Go2 front-camera horizontal FOV, for the carve focal length. Calibrate to your camera.",
)
ap.add_argument("--max-lin-speed", type=float, default=0.4, help="Body linear speed cap (m/s).")
ap.add_argument("--max-yaw-rate", type=float, default=0.8, help="Yaw-rate cap (rad/s).")
ap.add_argument(
"--viz",
action="store_true",
help="Open a Rerun viewer and stream the map live as it builds/updates "
"(needs `pip install 'lerobot[viz]'`).",
)
ap.add_argument(
"--color-mode",
default="rgb",
choices=["rgb", "recency"],
help="Voxel coloring in the viewer: rgb, or recency (recent=cyan, old=red).",
)
ap.add_argument("--command", default=None, help="Run a single command non-interactively, then exit.")
ap.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING"])
args = ap.parse_args(argv)
logging.basicConfig(
level=getattr(logging, args.log_level), format="%(levelname)-7s %(name)s: %(message)s"
)
viz = None
if args.viz:
from lerobot.navigation.viz import MapVisualizer
viz = MapVisualizer(color_mode=args.color_mode)
if args.live or args.map_only:
controller, mapper = _build_live(
args.network_interface,
args.device,
camera_hfov_deg=args.camera_hfov_deg,
max_lin_speed=args.max_lin_speed,
max_yaw_rate=args.max_yaw_rate,
viz=viz,
)
return run_live_repl(controller, mapper, map_only=args.map_only)
if not args.dry_run:
raise SystemExit("Choose a mode: --dry-run (synthetic scene) or --live (real Unitree Go2).")
controller = _build_dry_run(viz=viz)
if args.command is not None:
result = controller.handle_prompt(args.command)
controller.stop()
return 0 if result.fully_successful else 1
return run_repl(controller)
if __name__ == "__main__":
sys.exit(main())
+231
View File
@@ -0,0 +1,231 @@
#!/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.
"""SigLIP2 dense patch features (MaskCLIP-style) + text query encoding.
Ported from the dyna360 research stack. Default checkpoint
``google/siglip2-so400m-patch16-384``. For per-patch dense matching
against text, raw ``last_hidden_state`` is the wrong space: SigLIP2's
image-text matching lives in the MAP (Multihead Attention Pooling) head
output. We use the MaskCLIP recipe — apply the MAP head's value
projection + output projection + LayerNorm + MLP residual to each patch
token, skipping the attention reduction — so each patch lands in
(approximately) the shared text/vision space. Outputs are L2-normalized
fp16.
For dry-run and tests, :class:`BasisVectorFeatureExtractor` provides a
deterministic name→vector stand-in with the same interface, no models
required.
"""
from __future__ import annotations
import logging
from contextlib import nullcontext
from typing import Any, Protocol, runtime_checkable
import numpy as np
LOG = logging.getLogger(__name__)
DEFAULT_CHECKPOINT = "google/siglip2-so400m-patch16-384"
@runtime_checkable
class FeatureExtractor(Protocol):
"""What the navigation stack needs from a vision-language encoder.
``encode_text`` is required (used by ``locate``/``explore`` queries);
``feature_dim`` reports the embedding size. Dense image encoding
(``encode_views``) is only needed by the live mapping pipeline.
"""
@property
def feature_dim(self) -> int: ...
def encode_text(self, text: str) -> np.ndarray: ...
def _select_autocast(device: str) -> tuple[Any, str]:
"""Pick an autocast context + label for the given device."""
import torch
if device != "cuda":
return nullcontext(), "no-autocast"
if not torch.cuda.is_available():
raise RuntimeError("device='cuda' requested but torch.cuda.is_available() is False")
cap = torch.cuda.get_device_capability()[0]
dtype = torch.bfloat16 if cap >= 8 else torch.float16
return torch.amp.autocast("cuda", dtype=dtype), f"cuda/{str(dtype).split('.')[-1]}"
class SiglipFeatureExtractor:
"""Lazy-loaded SigLIP2 wrapper for dense patch features + text query."""
def __init__(
self,
checkpoint: str = DEFAULT_CHECKPOINT,
device: str = "cuda",
max_batch: int = 8,
) -> None:
self.checkpoint = checkpoint
self.device = device
self.max_batch = int(max_batch)
self._model: Any | None = None
self._processor: Any | None = None
self._patch_grid: tuple[int, int] | None = None
self._feature_dim: int | None = None
@property
def feature_dim(self) -> int:
if self._feature_dim is None:
raise RuntimeError("SigLIP2 not loaded yet; call encode_views first")
return self._feature_dim
@property
def patch_grid(self) -> tuple[int, int]:
if self._patch_grid is None:
raise RuntimeError("SigLIP2 not loaded yet; call encode_views first")
return self._patch_grid
def _ensure_loaded(self) -> None:
if self._model is not None:
return
from transformers import AutoModel, AutoProcessor
LOG.info("loading SigLIP2 (%s) on %s ...", self.checkpoint, self.device)
self._processor = AutoProcessor.from_pretrained(self.checkpoint)
self._model = AutoModel.from_pretrained(self.checkpoint).to(self.device).eval()
LOG.info("SigLIP2 loaded")
def _maskclip_project(self, patches):
"""Push raw patch tokens through the MAP head with the attention
reduction removed — value-projects + post-processes each patch so it
lives in the shared text/vision space. ``patches``: (B, P, D)."""
import torch
assert self._model is not None
head = self._model.vision_model.head
mha = head.attention # nn.MultiheadAttention
embed_dim = patches.shape[-1]
# in_proj_weight is concatenated [Q | K | V], (3*D, D). Slice out V.
v_weight = mha.in_proj_weight[2 * embed_dim : 3 * embed_dim]
v_bias = mha.in_proj_bias[2 * embed_dim : 3 * embed_dim] if mha.in_proj_bias is not None else None
v = torch.nn.functional.linear(patches, v_weight, v_bias) # (B, P, D)
v = mha.out_proj(v)
residual = v
v = head.layernorm(v)
v = residual + head.mlp(v)
return v
def encode_views(self, views_rgb_uint8: np.ndarray) -> np.ndarray:
"""Encode ``(N, H, W, 3)`` RGB uint8 views to ``(N, Hp, Wp, D)`` fp16
dense patch features in the shared text/vision space, L2-normalized."""
import torch
if views_rgb_uint8.ndim != 4 or views_rgb_uint8.shape[-1] != 3: # noqa: N806
raise ValueError(f"expected (N, H, W, 3), got {views_rgb_uint8.shape}")
if views_rgb_uint8.dtype != np.uint8:
raise ValueError(f"expected uint8, got {views_rgb_uint8.dtype}")
self._ensure_loaded()
assert self._model is not None and self._processor is not None
autocast_ctx, autocast_label = _select_autocast(self.device)
LOG.info(
"SigLIP2 forward (MaskCLIP-projected patches): N=%d (batched up to %d), %s",
views_rgb_uint8.shape[0],
self.max_batch,
autocast_label,
)
out_list: list[np.ndarray] = []
for s in range(0, views_rgb_uint8.shape[0], self.max_batch):
e = s + self.max_batch
chunk = [views_rgb_uint8[i] for i in range(s, min(e, views_rgb_uint8.shape[0]))]
inputs = self._processor(images=chunk, return_tensors="pt").to(self.device)
with torch.no_grad(), autocast_ctx:
vision = self._model.vision_model(**inputs)
patches = vision.last_hidden_state # (B, P, D)
patches = self._maskclip_project(patches) # (B, P, D) shared-space
patches = torch.nn.functional.normalize(patches.float(), dim=-1)
out_list.append(patches.to(torch.float16).cpu().numpy())
feats = np.concatenate(out_list, axis=0) # (N, P, D)
n, p, d = feats.shape
side = int(round(p**0.5))
if side * side != p:
raise RuntimeError(
f"SigLIP2 returned a non-square patch grid (P={p}); non-square inputs aren't supported yet"
)
self._patch_grid = (side, side)
self._feature_dim = d
return feats.reshape(n, side, side, d)
def encode_text(self, text: str) -> np.ndarray:
"""Encode a text query to a single (D,) fp16 unit vector.
SigLIP2 uses last-token ([EOS]) pooling for text. We extract it
explicitly because ``get_text_features`` behaves differently across
``transformers`` versions.
"""
import torch
self._ensure_loaded()
assert self._model is not None and self._processor is not None
autocast_ctx, _ = _select_autocast(self.device)
inputs = self._processor(text=[text], return_tensors="pt", padding="max_length").to(self.device)
with torch.no_grad(), autocast_ctx:
text_outputs = self._model.text_model(**inputs)
pooled = getattr(text_outputs, "pooler_output", None)
if pooled is not None and pooled.dim() == 2:
feat = pooled[0]
else:
feat = text_outputs.last_hidden_state[0, -1]
feat = feat.float()
feat = torch.nn.functional.normalize(feat, dim=-1)
return feat.to(torch.float16).cpu().numpy()
class BasisVectorFeatureExtractor:
"""Deterministic name→vector stand-in for :class:`SiglipFeatureExtractor`.
Maps known names to their stored feature vectors; unknown queries get a
deterministic per-text pseudo-random unit vector (same string → same
vector), so a locate threshold reliably rejects absent objects. Used by
the synthetic-scene dry-run and by tests — no models required.
"""
def __init__(self, name_to_vec: dict[str, np.ndarray], feature_dim: int) -> None:
self.name_to_vec = name_to_vec
self._feature_dim = int(feature_dim)
@property
def feature_dim(self) -> int:
return self._feature_dim
def encode_text(self, text: str) -> np.ndarray:
v = self.name_to_vec.get(text)
if v is None:
seed = abs(hash(text)) % (2**32)
rng = np.random.default_rng(seed)
v = rng.normal(size=self._feature_dim).astype(np.float32)
v = v.astype(np.float32)
v = v / max(float(np.linalg.norm(v)), 1e-6)
return v
+222
View File
@@ -0,0 +1,222 @@
#!/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.
"""Monocular geometry runners for the mapping pipeline.
A :class:`GeometryRunner` turns a stack of RGB views into per-pixel world
points, camera-frame points (depth), confidence, and camera-to-world
poses — the four arrays the voxel-map pipeline consumes.
:class:`LingBotMapRunner` wraps Ant Group's streaming LingBot-Map model
(feed-forward 3D reconstruction with persistent memory); the SDK import
is lazy so configs/tests/``--help`` don't pay the model cost.
Because LingBot-Map is monocular, its world frame has an unknown metric
scale. On a robot with wheel/leg odometry (the Unitree Go2 sport-mode
state), :func:`align_trajectory_to_odometry` fits a similarity transform
(scale + rotation + translation) from the model's camera trajectory to
the odometry trajectory, so the voxel map comes out metric and A* speeds
are real m/s. :class:`FakeGeometryRunner` produces deterministic planar
geometry for hardware-free tests.
"""
# ruff: noqa: N806 — R, U, S, Vt, D: conventional linear-algebra / array-dimension names
from __future__ import annotations
import logging
from contextlib import nullcontext
from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable
import numpy as np
LOG = logging.getLogger(__name__)
DEFAULT_LINGBOT_CHECKPOINT = "robbyant/lingbot-map"
@dataclass(frozen=True)
class GeometryOutput:
"""Per-view geometry outputs (fp32, on CPU).
The contract every :class:`GeometryRunner` emits and the voxel-map
pipeline consumes.
"""
points: np.ndarray # (N, H, W, 3) world points
local_points: np.ndarray # (N, H, W, 3) camera-frame points; depth = [..., 2]
conf: np.ndarray # (N, H, W) in [0, 1]
camera_poses: np.ndarray # (N, 4, 4) camera-to-world, OpenCV convention
@runtime_checkable
class GeometryRunner(Protocol):
"""Turns ``(N, H, W, 3)`` uint8 RGB views into a :class:`GeometryOutput`."""
def __call__(self, views_rgb_uint8: np.ndarray) -> GeometryOutput: ...
def _select_autocast(device: str) -> tuple[Any, str]:
"""Return (autocast context manager, label for logging)."""
import torch
if device != "cuda":
return nullcontext(), "no-autocast"
if not torch.cuda.is_available():
raise RuntimeError("device='cuda' requested but torch.cuda.is_available() is False")
cap = torch.cuda.get_device_capability()[0]
dtype = torch.bfloat16 if cap >= 8 else torch.float16
return torch.amp.autocast("cuda", dtype=dtype), f"cuda/{str(dtype).split('.')[-1]}"
class LingBotMapRunner:
"""Lazy-loaded LingBot-Map streaming reconstruction runner.
Streaming feed-forward reconstruction with a persistent KV-cache keeps
every view anchored to one consistent world frame — so, unlike
window-based models, no cross-window pose stitching is needed. The
model download/load is deferred to the first call.
"""
def __init__(
self,
device: str = "cuda",
checkpoint: str = DEFAULT_LINGBOT_CHECKPOINT,
) -> None:
self.device = device
self.checkpoint = checkpoint
self._model: Any | None = None
def _ensure_loaded(self) -> None:
if self._model is not None:
return
try:
from lingbot_map import LingBotMap # type: ignore[import-not-found]
except ImportError as exc:
raise RuntimeError(
f"lingbot-map is not importable ({exc}). Install it from "
"github.com/robbyant/lingbot-map on the GPU host."
) from exc
LOG.info("loading LingBot-Map (%s) on %s ...", self.checkpoint, self.device)
self._model = LingBotMap.from_pretrained(self.checkpoint).to(self.device).eval()
LOG.info("LingBot-Map loaded")
def __call__(self, views_rgb_uint8: np.ndarray) -> GeometryOutput:
import torch
if views_rgb_uint8.ndim != 4 or views_rgb_uint8.shape[-1] != 3:
raise ValueError(f"expected (N, H, W, 3), got {views_rgb_uint8.shape}")
if views_rgb_uint8.dtype != np.uint8:
raise ValueError(f"expected uint8, got {views_rgb_uint8.dtype}")
self._ensure_loaded()
assert self._model is not None
imgs = (
torch.from_numpy(views_rgb_uint8)
.to(self.device)
.float()
.div_(255.0)
.permute(0, 3, 1, 2)
.contiguous()
) # (N, 3, H, W)
autocast_ctx, label = _select_autocast(self.device)
LOG.info("LingBot-Map forward: N=%d, %s", views_rgb_uint8.shape[0], label)
with torch.no_grad(), autocast_ctx:
res = self._model(imgs[None]) # (1, N, ...)
def _np(t) -> np.ndarray:
return t.detach().float().cpu().numpy()
points = _np(res["points"][0])
local_points = _np(res["local_points"][0])
conf = _np(res["conf"][0])
if conf.ndim == 4: # (N, H, W, 1) → (N, H, W)
conf = conf[..., 0]
camera_poses = _np(res["camera_poses"][0])
return GeometryOutput(points, local_points, conf, camera_poses)
class FakeGeometryRunner:
"""Deterministic planar geometry for hardware-free tests.
Emits a flat floor at ``depth`` metres in front of the camera with a
pinhole model, unit confidence, and identity (or supplied) poses — no
model required.
"""
def __init__(self, depth: float = 3.0, focal_px: float = 100.0) -> None:
self.depth = float(depth)
self.focal_px = float(focal_px)
def __call__(self, views_rgb_uint8: np.ndarray) -> GeometryOutput:
if views_rgb_uint8.ndim != 4 or views_rgb_uint8.shape[-1] != 3:
raise ValueError(f"expected (N, H, W, 3), got {views_rgb_uint8.shape}")
n, h, w, _ = views_rgb_uint8.shape
cx, cy = (w - 1) / 2.0, (h - 1) / 2.0
us, vs = np.meshgrid(np.arange(w), np.arange(h))
x = (us - cx) * self.depth / self.focal_px
y = (vs - cy) * self.depth / self.focal_px
z = np.full_like(x, self.depth, dtype=np.float64)
local = np.stack([x, y, z], axis=-1).astype(np.float32) # (H, W, 3)
local_points = np.broadcast_to(local, (n, h, w, 3)).copy()
# Identity poses → world == camera frame.
points = local_points.copy()
conf = np.ones((n, h, w), dtype=np.float32)
poses = np.broadcast_to(np.eye(4, dtype=np.float32), (n, 4, 4)).copy()
return GeometryOutput(points, local_points, conf, poses)
def umeyama_similarity(src: np.ndarray, dst: np.ndarray) -> tuple[float, np.ndarray, np.ndarray]:
"""Least-squares similarity (scale s, rotation R, translation t) mapping
``src`` onto ``dst`` such that ``dst ≈ s · R @ src + t``.
``src``/``dst`` are ``(K, 3)``. Returns ``(s, R, t)``. Used to anchor a
monocular trajectory to metric odometry.
"""
src = np.asarray(src, dtype=np.float64)
dst = np.asarray(dst, dtype=np.float64)
if src.shape != dst.shape or src.ndim != 2 or src.shape[1] != 3:
raise ValueError(f"src/dst must be matching (K, 3); got {src.shape}, {dst.shape}")
k = src.shape[0]
mu_src = src.mean(axis=0)
mu_dst = dst.mean(axis=0)
sc = src - mu_src
dc = dst - mu_dst
cov = (dc.T @ sc) / k
U, D, Vt = np.linalg.svd(cov)
S = np.eye(3)
if np.linalg.det(U) * np.linalg.det(Vt) < 0:
S[2, 2] = -1.0
R = U @ S @ Vt
var_src = (sc**2).sum() / k
s = float((D * np.diag(S)).sum() / max(var_src, 1e-12))
t = mu_dst - s * R @ mu_src
return s, R, t
def align_trajectory_to_odometry(
camera_positions: np.ndarray,
odom_positions: np.ndarray,
) -> tuple[float, np.ndarray, np.ndarray]:
"""Fit the similarity transform from a monocular camera trajectory to a
metric odometry trajectory (both ``(K, 3)``, time-aligned).
Returns ``(scale, R, t)`` to apply to model world points/poses so the
voxel map is metric. Needs at least 3 non-degenerate points.
"""
if camera_positions.shape[0] < 3:
raise ValueError("need at least 3 corresponding poses to fit a similarity")
return umeyama_similarity(camera_positions, odom_positions)
+371
View File
@@ -0,0 +1,371 @@
#!/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.
"""2D occupancy projection of the voxel map + A* path planning.
Ported from the dyna360 research stack. Derived, not maintained: every
call to :func:`project_voxel_map_to_grid` rebuilds the 3-class grid from
a fresh ``VoxelMap.snapshot()``, so the projection reflects whatever the
keyframe loop most recently carved or added — no separate obstacle
structure to keep in sync.
Coordinate convention: OpenCV (X right, Y *down*, Z forward), matching
the navigation world frame. "Up" is the Y direction. The top-down grid
indexes the XZ plane; cell ``(iz, ix)`` covers world rectangle
``[origin_x + ix·cell, origin_x + (ix+1)·cell]`` ×
``[origin_z + iz·cell, origin_z + (iz+1)·cell]``.
Classes:
- ``UNOBSERVED`` (0): no voxel projects here. The base must not plan
through it (might be an unseen obstacle), but explorers treat it as
the goal class.
- ``NAVIGABLE`` (1): observed ground / open space.
- ``OBSTACLE`` (2): at least one voxel in the robot-height band
projects here.
"""
# ruff: noqa: N806 — H, W, D are conventional array-dimension names (and appear verbatim in error strings)
from __future__ import annotations
import heapq
import logging
import math
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from lerobot.navigation.voxel_map import VoxelMap
LOG = logging.getLogger(__name__)
# Class constants — picked so a colormap can index directly.
UNOBSERVED = np.int8(0)
NAVIGABLE = np.int8(1)
OBSTACLE = np.int8(2)
@dataclass(frozen=True)
class OccupancyGrid:
"""3-class top-down grid plus its world↔cell mapping."""
classes: np.ndarray # (H, W) int8 — H = z-extent, W = x-extent
cell_size: float # m per cell
origin_x: float # world x of the LEFT edge of column 0
origin_z: float # world z of the TOP edge of row 0
ground_y: float # world y of the (auto-estimated or given) ground plane
@property
def shape(self) -> tuple[int, int]:
return self.classes.shape # (H, W)
def world_to_cell(self, x: float, z: float) -> tuple[int, int]:
"""Return ``(iz, ix)``, clipped to grid extents."""
ix = int(np.clip(math.floor((x - self.origin_x) / self.cell_size), 0, self.shape[1] - 1))
iz = int(np.clip(math.floor((z - self.origin_z) / self.cell_size), 0, self.shape[0] - 1))
return iz, ix
def cell_to_world(self, iz: int, ix: int) -> tuple[float, float]:
"""Return ``(x, z)`` at the *centre* of cell ``(iz, ix)``."""
x = self.origin_x + (ix + 0.5) * self.cell_size
z = self.origin_z + (iz + 0.5) * self.cell_size
return x, z
def is_navigable(self, iz: int, ix: int) -> bool:
H, W = self.shape
return 0 <= iz < H and 0 <= ix < W and self.classes[iz, ix] == NAVIGABLE
def is_obstacle(self, iz: int, ix: int) -> bool:
"""Whether cell ``(iz, ix)`` is a known obstacle. Out-of-bounds is
not an obstacle (it is simply unobservable) — used by
``SafeBaseController``'s occupancy gate."""
H, W = self.shape
return 0 <= iz < H and 0 <= ix < W and self.classes[iz, ix] == OBSTACLE
def is_in_bounds(self, iz: int, ix: int) -> bool:
H, W = self.shape
return 0 <= iz < H and 0 <= ix < W
def nearest_navigable_cell(self, iz: int, ix: int, max_radius: int = 50) -> tuple[int, int] | None:
"""BFS outward until a navigable cell is found, or give up."""
if self.is_navigable(iz, ix):
return iz, ix
for r in range(1, max_radius + 1):
for diz in range(-r, r + 1):
for dix in range(-r, r + 1):
if max(abs(diz), abs(dix)) != r:
continue # ring only, not the interior
if self.is_navigable(iz + diz, ix + dix):
return iz + diz, ix + dix
return None
def estimate_ground_y(xyz: np.ndarray, percentile: float = 95.0) -> float:
"""Estimate the world-frame y of the ground plane.
Y is down (OpenCV), so the ground is at the LARGEST y values. Using a
high percentile (default 95) is robust to outliers below the ground.
"""
if xyz.size == 0:
return 0.0
return float(np.percentile(xyz[:, 1], percentile))
def project_voxel_map_to_grid(
voxel_map: VoxelMap,
*,
cell_size: float = 0.1,
ground_y: float | None = None,
obstacle_y_range: tuple[float, float] = (-2.0, -0.1),
bbox: tuple[float, float, float, float] | None = None,
bbox_pad: float = 1.0,
inflate_cells: int = 0,
) -> OccupancyGrid:
"""Snapshot the voxel map and project it into a 2D occupancy grid.
``obstacle_y_range`` is interpreted *relative* to ``ground_y`` with the
Y-down convention, so the default ``(-2.0, -0.1)`` means "voxels
between 2.0 m and 0.1 m above the ground are obstacles". Anything above
the ceiling band or below ground level is silently ignored.
``inflate_cells`` dilates the OBSTACLE class by N cells of clearance
(square morphology) — a body-radius safety margin for the base without
resampling the voxel map.
"""
snap = voxel_map.snapshot()
xyz = snap.xyz
if xyz.size == 0:
# Empty map → a 1×1 grid of UNOBSERVED at world origin.
return OccupancyGrid(
classes=np.zeros((1, 1), dtype=np.int8),
cell_size=float(cell_size),
origin_x=0.0,
origin_z=0.0,
ground_y=ground_y if ground_y is not None else 0.0,
)
if ground_y is None:
ground_y = estimate_ground_y(xyz)
# Promote to float64 — VoxelMap snapshots are float32, and naive
# ``(float32_array <= float64_scalar)`` lets numpy downcast the scalar
# back to float32, which causes edge-case bugs (e.g. 1.0 <= 0.999999999
# becomes True because the threshold rounds up to 1.0 in float32).
x_arr = xyz[:, 0].astype(np.float64)
y_arr = xyz[:, 1].astype(np.float64)
z_arr = xyz[:, 2].astype(np.float64)
abs_y_top = ground_y + obstacle_y_range[0] # most-negative y (highest above ground)
abs_y_bottom = ground_y + obstacle_y_range[1] # closer to ground
is_obstacle = (y_arr >= abs_y_top) & (y_arr <= abs_y_bottom)
if bbox is None:
x_min = float(x_arr.min()) - bbox_pad
x_max = float(x_arr.max()) + bbox_pad
z_min = float(z_arr.min()) - bbox_pad
z_max = float(z_arr.max()) + bbox_pad
else:
x_min, z_min, x_max, z_max = bbox
W = max(1, int(math.ceil((x_max - x_min) / cell_size)))
H = max(1, int(math.ceil((z_max - z_min) / cell_size)))
classes = np.zeros((H, W), dtype=np.int8) # default UNOBSERVED
# eps absorbs float32→float64 representation drift so points that should
# land exactly on a cell boundary aren't randomly bumped into the
# previous cell. 1e-3 of a cell width is well above float32's ~1e-7
# relative precision and well below the 0.5-cell misclassification
# threshold.
eps = cell_size * 1e-3
ix = np.clip(np.floor((x_arr - x_min) / cell_size + eps).astype(np.int32), 0, W - 1)
iz = np.clip(np.floor((z_arr - z_min) / cell_size + eps).astype(np.int32), 0, H - 1)
# Two-pass labelling: any voxel makes a cell observed (-> NAVIGABLE);
# obstacle voxels then upgrade those cells to OBSTACLE.
classes[iz, ix] = NAVIGABLE
obs_iz = iz[is_obstacle]
obs_ix = ix[is_obstacle]
classes[obs_iz, obs_ix] = OBSTACLE
if inflate_cells > 0:
classes = _inflate_obstacles(classes, inflate_cells)
return OccupancyGrid(
classes=classes,
cell_size=float(cell_size),
origin_x=x_min,
origin_z=z_min,
ground_y=float(ground_y),
)
def _inflate_obstacles(classes: np.ndarray, radius: int) -> np.ndarray:
"""Dilate OBSTACLE cells by `radius` cells (Chebyshev). Pure-numpy
morphological dilation — fine for our grid sizes."""
out = classes.copy()
obs = classes == OBSTACLE
H, W = classes.shape
for diz in range(-radius, radius + 1):
for dix in range(-radius, radius + 1):
if diz == 0 and dix == 0:
continue
sl_src_iz = slice(max(0, -diz), H - max(0, diz))
sl_src_ix = slice(max(0, -dix), W - max(0, dix))
sl_dst_iz = slice(max(0, diz), H - max(0, -diz))
sl_dst_ix = slice(max(0, dix), W - max(0, -dix))
inflated = obs[sl_src_iz, sl_src_ix]
# Only upgrade NAVIGABLE → OBSTACLE; never overwrite UNOBSERVED
# so the frontier (NAVIGABLE↔UNOBSERVED boundary) survives.
target = out[sl_dst_iz, sl_dst_ix]
promote = inflated & (target == NAVIGABLE)
target[promote] = OBSTACLE
out[sl_dst_iz, sl_dst_ix] = target
return out
# --------------------------------------------------------------------- A*
_DIAG_COST = math.sqrt(2.0)
_NEIGHBOURS_ORTHO = ((-1, 0), (1, 0), (0, -1), (0, 1))
_NEIGHBOURS_DIAG = ((-1, -1), (-1, 1), (1, -1), (1, 1))
def astar(
grid: OccupancyGrid,
start_world: tuple[float, float],
goal_world: tuple[float, float],
*,
allow_unobserved_goal: bool = True,
) -> list[tuple[float, float]] | None:
"""Plan a path from ``start_world`` to ``goal_world`` in (x, z) world m.
Returns a list of world ``(x, z)`` waypoints, or ``None`` if no path
exists. Start/goal are snapped to the nearest navigable cell.
"""
H, W = grid.shape
if H == 0 or W == 0:
return None
s_iz, s_ix = grid.world_to_cell(*start_world)
g_iz, g_ix = grid.world_to_cell(*goal_world)
if not grid.is_navigable(s_iz, s_ix):
snapped = grid.nearest_navigable_cell(s_iz, s_ix)
if snapped is None:
return None
s_iz, s_ix = snapped
if not grid.is_navigable(g_iz, g_ix):
if not allow_unobserved_goal:
return None
snapped = grid.nearest_navigable_cell(g_iz, g_ix)
if snapped is None:
return None
g_iz, g_ix = snapped
def heuristic(iz: int, ix: int) -> float:
d_iz = abs(iz - g_iz)
d_ix = abs(ix - g_ix)
return (max(d_iz, d_ix) - min(d_iz, d_ix)) + _DIAG_COST * min(d_iz, d_ix)
open_heap: list[tuple[float, int, tuple[int, int]]] = []
counter = 0 # tiebreaker so heapq doesn't compare tuples on ties
heapq.heappush(open_heap, (0.0, counter, (s_iz, s_ix)))
came_from: dict[tuple[int, int], tuple[int, int]] = {}
g_score: dict[tuple[int, int], float] = {(s_iz, s_ix): 0.0}
while open_heap:
_, _, current = heapq.heappop(open_heap)
if current == (g_iz, g_ix):
return _reconstruct_path(came_from, current, grid)
cur_iz, cur_ix = current
cur_g = g_score[current]
for diz, dix in _NEIGHBOURS_ORTHO:
n = (cur_iz + diz, cur_ix + dix)
if not grid.is_navigable(*n):
continue
tentative = cur_g + 1.0
if tentative < g_score.get(n, float("inf")):
came_from[n] = current
g_score[n] = tentative
counter += 1
heapq.heappush(open_heap, (tentative + heuristic(*n), counter, n))
for diz, dix in _NEIGHBOURS_DIAG:
n = (cur_iz + diz, cur_ix + dix)
if not grid.is_navigable(*n):
continue
# Prevent corner-cutting: both perpendicular neighbours must be
# navigable, or we'd squeeze through an obstacle's diagonal.
if not grid.is_navigable(cur_iz + diz, cur_ix):
continue
if not grid.is_navigable(cur_iz, cur_ix + dix):
continue
tentative = cur_g + _DIAG_COST
if tentative < g_score.get(n, float("inf")):
came_from[n] = current
g_score[n] = tentative
counter += 1
heapq.heappush(open_heap, (tentative + heuristic(*n), counter, n))
return None
def _reconstruct_path(
came_from: dict[tuple[int, int], tuple[int, int]],
end: tuple[int, int],
grid: OccupancyGrid,
) -> list[tuple[float, float]]:
cells = [end]
while cells[-1] in came_from:
cells.append(came_from[cells[-1]])
cells.reverse()
return [grid.cell_to_world(iz, ix) for iz, ix in cells]
# ----------------------------------------------------------------- frontier
def find_frontier_cells(grid: OccupancyGrid) -> np.ndarray:
"""Return cells on the NAVIGABLE↔UNOBSERVED boundary, as ``(K, 2)`` int.
These are the cells exploration aims for: places we already know we
can stand at, but with unknown adjacent territory worth visiting.
"""
nav = grid.classes == NAVIGABLE
unobs = grid.classes == UNOBSERVED
if not nav.any() or not unobs.any():
return np.zeros((0, 2), dtype=np.int32)
boundary = np.zeros_like(nav)
boundary[1:, :] |= nav[1:, :] & unobs[:-1, :]
boundary[:-1, :] |= nav[:-1, :] & unobs[1:, :]
boundary[:, 1:] |= nav[:, 1:] & unobs[:, :-1]
boundary[:, :-1] |= nav[:, :-1] & unobs[:, 1:]
iz, ix = np.where(boundary)
return np.stack([iz, ix], axis=-1).astype(np.int32)
def occupancy_to_rgb(grid: OccupancyGrid) -> np.ndarray:
"""Render the 3-class grid as an (H, W, 3) uint8 image."""
img = np.zeros((*grid.shape, 3), dtype=np.uint8)
img[grid.classes == UNOBSERVED] = (40, 40, 50)
img[grid.classes == NAVIGABLE] = (200, 200, 200)
img[grid.classes == OBSTACLE] = (220, 60, 60)
return img
+133
View File
@@ -0,0 +1,133 @@
#!/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.
"""Keyframe integration loop core.
Ported from the dyna360 research stack (viz-free). One keyframe is
carved then added into the voxel map — carve first so we never remove
voxels we just created this frame. This is the shared step behind live
mapping on the robot.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from lerobot.navigation.voxel_map import CarveResult, VoxelMap, VoxelMapStats
LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class KeyframeContext:
"""Everything one keyframe needs to contribute to the voxel map.
``rgb_uint8`` is RGB order (same layout fed to the geometry model and
the feature extractor). ``points_world`` / ``local_points`` come from
the geometry runner; ``feat_map`` is the bilinearly-upsampled patch
grid at ``(H, W, D)`` fp16, or ``None`` for a geometry-only frame.
"""
frame_idx: int
t_sec: float
rgb_uint8: np.ndarray # (H, W, 3) RGB uint8
points_world: np.ndarray # (H, W, 3) float32
local_points: np.ndarray # (H, W, 3) float32
conf: np.ndarray # (H, W) in [0, 1]
pose: np.ndarray # (4, 4) cam-to-world
feat_map: np.ndarray | None # (H, W, D) fp16, L2-normalized per pixel
@dataclass(frozen=True)
class PipelineConfig:
"""Knobs that change per-run but not per-keyframe."""
conf_thresh: float = 0.5
carve_margin: float = 0.05
focal_px: float = 100.0
def integrate_keyframe(
voxel_map: VoxelMap,
ctx: KeyframeContext,
pcfg: PipelineConfig | None = None,
) -> tuple[CarveResult, VoxelMapStats]:
"""Carve observed free space, then add this keyframe's points.
Carve runs before add. Returns the carve result + add stats so callers
can surface them in their own progress UI.
"""
pcfg = pcfg or PipelineConfig()
carve = voxel_map.carve(
local_points=ctx.local_points,
conf=ctx.conf,
pose=ctx.pose,
focal_px=pcfg.focal_px,
frame=ctx.frame_idx,
t=ctx.t_sec,
conf_thresh=pcfg.conf_thresh,
margin=pcfg.carve_margin,
)
stats = voxel_map.add(
points=ctx.points_world,
rgb=ctx.rgb_uint8,
conf=ctx.conf,
frame=ctx.frame_idx,
t=ctx.t_sec,
conf_thresh=pcfg.conf_thresh,
feat_map=ctx.feat_map,
)
return carve, stats
def local_points_to_world(local_points: np.ndarray, pose: np.ndarray) -> np.ndarray:
"""Transform camera-frame points ``(H, W, 3)`` into the world frame using
a 4×4 camera-to-world ``pose``.
On the robot the world frame is the odometry frame (from the base
controller), and the geometry model supplies only relative
camera-frame geometry — so projecting through the odometry pose keeps
the voxel map and the robot pose in ONE consistent frame. Returns
``(H, W, 3)`` float32.
"""
if local_points.ndim != 3 or local_points.shape[-1] != 3:
raise ValueError(f"expected (H, W, 3), got {local_points.shape}")
if pose.shape != (4, 4):
raise ValueError(f"pose must be (4, 4); got {pose.shape}")
r = pose[:3, :3].astype(np.float64)
t = pose[:3, 3].astype(np.float64)
flat = local_points.reshape(-1, 3).astype(np.float64)
world = flat @ r.T + t
return world.reshape(local_points.shape).astype(np.float32)
def upsample_features_to_view(
patch_feats_one_view: np.ndarray,
view_h: int,
view_w: int,
) -> np.ndarray:
"""Bilinearly upsample one keyframe's ``(Hp, Wp, D)`` patch features to
view resolution ``(H, W, D)`` fp16."""
import torch
fp = torch.from_numpy(patch_feats_one_view).permute(2, 0, 1).unsqueeze(0).float() # (1, D, Hp, Wp)
fp_up = torch.nn.functional.interpolate(fp, size=(view_h, view_w), mode="bilinear", align_corners=False)
return fp_up.squeeze(0).permute(1, 2, 0).to(torch.float16).numpy()
+207
View File
@@ -0,0 +1,207 @@
#!/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.
"""Synthetic scenes for hardware-free dry-runs and tests.
Ported from the dyna360 eval harness. A :class:`SyntheticScene` is a
deterministic hand-crafted :class:`~lerobot.navigation.voxel_map.VoxelMap`
— a navigable floor plus labelled objects each carrying a unit feature
vector — paired with a
:class:`~lerobot.navigation.features.BasisVectorFeatureExtractor` whose
text encodings live in the same space. This lets ``dog_cli --dry-run``
(and the tests) exercise the full locate/goto/explore stack with no
models, camera, or robot.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
from lerobot.navigation.features import BasisVectorFeatureExtractor
from lerobot.navigation.voxel_map import VoxelMap
LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class SyntheticObject:
"""One labelled object. ``feature_vec`` lives in the same space as the
text embeddings fed to ``VoxelMap.query`` (one-hot basis vectors, so a
query hits the right cluster cleanly)."""
name: str
xyz: tuple[float, float, float]
half_extent_m: float
feature_vec: np.ndarray
@dataclass(frozen=True)
class SyntheticScene:
"""A ground-truth scene: voxel map + object metadata."""
voxel_map: VoxelMap
objects: list[SyntheticObject]
floor_extent_m: float
voxel_size: float
feature_dim: int
def name_to_xyz(self) -> dict[str, tuple[float, float, float]]:
return {o.name: o.xyz for o in self.objects}
def object(self, name: str) -> SyntheticObject | None:
for o in self.objects:
if o.name == name:
return o
return None
def feature_extractor(self) -> BasisVectorFeatureExtractor:
"""A text encoder whose vectors match this scene's object features."""
table = {o.name: o.feature_vec for o in self.objects}
return BasisVectorFeatureExtractor(table, self.feature_dim)
@dataclass(frozen=True)
class SceneSpec:
"""Declarative recipe used by :func:`build_scene`."""
objects: list[SyntheticObject]
floor_extent_m: float = 6.0
voxel_size: float = 0.1
feature_dim: int = 8
ground_y: float = 1.0
object_density_per_dim: int = 5
wall_xz_range: tuple[float, float, float, float] | None = None
"""Optional axis-aligned wall ``(x_min, z_min, x_max, z_max)`` of
obstacle voxels at robot height — to test ``goto`` against a block."""
feature_noise: float = 0.0
rng_seed: int = 0
def basis_vec(dim: int, idx: int) -> np.ndarray:
"""A unit basis vector of length ``dim`` with a 1 at ``idx``."""
v = np.zeros(dim, dtype=np.float32)
v[idx] = 1.0
return v
def build_scene(spec: SceneSpec) -> SyntheticScene:
"""Construct a deterministic :class:`SyntheticScene` from a spec."""
rng = np.random.default_rng(spec.rng_seed)
vm = VoxelMap(voxel_size=spec.voxel_size)
# ----- floor (NAVIGABLE) -----
half = spec.voxel_size / 2.0
floor_pts: list[tuple[float, float, float]] = []
for x in np.arange(-spec.floor_extent_m + half, spec.floor_extent_m + half, spec.voxel_size):
for z in np.arange(-spec.floor_extent_m + half, spec.floor_extent_m + half, spec.voxel_size):
floor_pts.append((float(x), spec.ground_y, float(z)))
arr = np.asarray(floor_pts, dtype=np.float64).reshape(-1, 1, 3)
rgb = np.full((len(floor_pts), 1, 3), 180, dtype=np.uint8)
conf = np.ones((len(floor_pts), 1), dtype=np.float32)
if spec.feature_dim >= 1:
floor_vec = np.zeros(spec.feature_dim, dtype=np.float16)
floor_vec[-1] = 1.0
floor_feat = np.tile(floor_vec, (len(floor_pts), 1, 1))
vm.add(arr, rgb, conf, frame=0, t=0.0, feat_map=floor_feat)
else:
vm.add(arr, rgb, conf, frame=0, t=0.0)
# ----- objects -----
for i, obj in enumerate(spec.objects, start=1):
d = obj.half_extent_m
n = spec.object_density_per_dim
coords = np.linspace(-d + half, d - half, n)
pts = np.array(
[
(float(obj.xyz[0] + dx), float(obj.xyz[1] + dy), float(obj.xyz[2] + dz))
for dx in coords
for dy in coords
for dz in coords
],
dtype=np.float64,
).reshape(-1, 1, 3)
rgb_o = np.full((pts.shape[0], 1, 3), 100 + (i * 30) % 156, dtype=np.uint8)
conf_o = np.ones((pts.shape[0], 1), dtype=np.float32)
if obj.feature_vec.shape != (spec.feature_dim,):
raise ValueError(
f"object {obj.name!r} feature_vec has shape {obj.feature_vec.shape}, "
f"expected ({spec.feature_dim},) to match SceneSpec.feature_dim"
)
base = obj.feature_vec.astype(np.float32).reshape(1, 1, -1)
feats = np.tile(base, (pts.shape[0], 1, 1))
if spec.feature_noise > 0:
noise = rng.normal(scale=spec.feature_noise, size=feats.shape).astype(np.float32)
feats = feats + noise
norms = np.linalg.norm(feats, axis=-1, keepdims=True)
feats = feats / np.maximum(norms, 1e-6)
vm.add(pts, rgb_o, conf_o, frame=i, t=float(i), feat_map=feats.astype(np.float16))
# ----- optional wall (OBSTACLE) -----
if spec.wall_xz_range is not None:
wx0, wz0, wx1, wz1 = spec.wall_xz_range
wall_pts = [
(float(x), float(y), float(z))
for x in np.arange(wx0 + half, wx1, spec.voxel_size)
for z in np.arange(wz0 + half, wz1, spec.voxel_size)
for y in np.arange(spec.ground_y - 1.0, spec.ground_y - 0.1, spec.voxel_size)
]
if wall_pts:
pts = np.asarray(wall_pts, dtype=np.float64).reshape(-1, 1, 3)
rgb_w = np.full((len(wall_pts), 1, 3), 80, dtype=np.uint8)
conf_w = np.ones((len(wall_pts), 1), dtype=np.float32)
vm.add(pts, rgb_w, conf_w, frame=99, t=99.0)
LOG.info(
"built scene: %d voxels, %d objects, floor extent %.1f m, D=%d",
len(vm),
len(spec.objects),
spec.floor_extent_m,
spec.feature_dim,
)
return SyntheticScene(
voxel_map=vm,
objects=list(spec.objects),
floor_extent_m=spec.floor_extent_m,
voxel_size=spec.voxel_size,
feature_dim=spec.feature_dim,
)
_KITCHEN_DIM = 64 # Feature dim sized so the random-direction noise floor
# (≈1/sqrt(D) ≈ 0.125) sits well below a sane locate threshold, so an absent
# object reliably ABSTAINS instead of hitting a known basis vector.
def kitchen_scene(wall: tuple[float, float, float, float] | None = None) -> SyntheticScene:
"""A 6×6 m floor with four labelled objects at distinctive corners."""
spec = SceneSpec(
objects=[
SyntheticObject("couch", (3.0, 0.5, 2.0), 0.3, basis_vec(_KITCHEN_DIM, 0)),
SyntheticObject("chair", (-2.0, 0.5, -1.5), 0.2, basis_vec(_KITCHEN_DIM, 1)),
SyntheticObject("lamp", (2.5, 0.5, -2.0), 0.15, basis_vec(_KITCHEN_DIM, 2)),
SyntheticObject("plant", (-2.5, 0.5, 2.5), 0.25, basis_vec(_KITCHEN_DIM, 3)),
],
floor_extent_m=6.0,
voxel_size=0.1,
feature_dim=_KITCHEN_DIM,
ground_y=1.0,
wall_xz_range=wall,
)
return build_scene(spec)
+321
View File
@@ -0,0 +1,321 @@
#!/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.
"""SpatialSkills tool layer.
Ported from the dyna360 research stack. The agent calls these as a fixed
toolset:
- :meth:`SpatialSkills.locate` — text → 3D position (or NOT_FOUND)
- :meth:`SpatialSkills.goto` — base navigation to a 3D target
- :meth:`SpatialSkills.explore` — pick a frontier to drive toward
The skills compose a :class:`~lerobot.navigation.voxel_map.VoxelMap`
(geometry + semantic features) with a
:class:`~lerobot.navigation.base_controller.BaseController` (motion) and a
text encoder. Stateless-per-call: each call snapshots the world, does its
work, and hands control back. The agent decides what to call next.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import numpy as np
from lerobot.navigation.occupancy import (
OccupancyGrid,
astar,
find_frontier_cells,
project_voxel_map_to_grid,
)
from lerobot.navigation.value_map import (
ValueMapConfig,
compute_value_maps,
pick_best_frontier_cell,
)
if TYPE_CHECKING:
from lerobot.navigation.base_controller import BaseController
from lerobot.navigation.features import FeatureExtractor
from lerobot.navigation.voxel_map import VoxelMap
LOG = logging.getLogger(__name__)
# ----- typed results returned to the agent -------------------------------
@dataclass(frozen=True)
class LocateResult:
"""Output of :meth:`SpatialSkills.locate`.
``found=False`` is load-bearing — the signal the agent uses to pick
:meth:`explore` over :meth:`goto`. Don't fabricate an ``xyz`` when
abstaining.
"""
found: bool
xyz: tuple[float, float, float] | None
confidence: float # top cosine score; -1.0 if no features
n_voxels: int # how many voxels supported the cluster
text: str
@dataclass(frozen=True)
class GotoResult:
"""Output of :meth:`SpatialSkills.goto`."""
reached: bool
final_xyz: tuple[float, float, float]
distance_to_target: float
n_steps: int
reason: str # "ok" | "no path" | "max steps" | "blocked"
path_xyz: list[tuple[float, float, float]] # for viz / debugging
@dataclass(frozen=True)
class ExploreResult:
"""Output of :meth:`SpatialSkills.explore`."""
target_xyz: tuple[float, float, float] | None
found_frontier: bool
distance_to_target: float # 0.0 when no frontier
reason: str # "ok" | "no frontier" | ...
value: float = 0.0
"""Combined V_T + α·V_S value of the chosen frontier — useful for
debugging exploration bias and as a give-up signal for the agent."""
# ----- configuration ------------------------------------------------------
@dataclass(frozen=True)
class SkillsConfig:
"""Knobs shared across the skills."""
# Occupancy projection
cell_size: float = 0.1
ground_y: float | None = None # None ⇒ auto-estimate from voxels
obstacle_y_range: tuple[float, float] = (-2.0, -0.1) # m above ground (y-down)
obstacle_inflate_cells: int = 1
# locate()
locate_top_k: int = 128
locate_threshold: float = 0.15 # min cosine for found=True
locate_outlier_quantile: float = 0.5
locate_outlier_scale: float = 2.0
# goto()
goto_threshold: float = 0.3
goto_step_size: float = 0.2 # m advanced per controller tick
goto_max_steps: int = 500
goto_replan_every: int = 5
goto_dt: float = 0.1
# explore()
explore_max_frontiers: int = 256
value_cfg: ValueMapConfig = field(default_factory=ValueMapConfig)
"""DynaMem-style V_T (recency) + V_S (similarity) knobs."""
# ----- the skills layer ---------------------------------------------------
class SpatialSkills:
"""Composes the voxel memory + base + text encoder into the agent toolset."""
def __init__(
self,
voxel_map: VoxelMap,
base: BaseController,
siglip: FeatureExtractor | None = None,
cfg: SkillsConfig | None = None,
) -> None:
self.voxel_map = voxel_map
self.base = base
self.siglip = siglip
self.cfg = cfg or SkillsConfig()
# ----- shared helper ---------------------------------------------------
def occupancy(self) -> OccupancyGrid:
"""Project the *current* voxel map into a 2D occupancy grid."""
return project_voxel_map_to_grid(
self.voxel_map,
cell_size=self.cfg.cell_size,
ground_y=self.cfg.ground_y,
obstacle_y_range=self.cfg.obstacle_y_range,
inflate_cells=self.cfg.obstacle_inflate_cells,
)
# ----- locate(text) ----------------------------------------------------
def locate(self, text: str) -> LocateResult:
text = text.strip()
if not text:
return LocateResult(False, None, -1.0, 0, text)
if self.siglip is None:
return LocateResult(False, None, -1.0, 0, text)
if self.voxel_map.feature_dim is None:
return LocateResult(False, None, -1.0, 0, text)
text_emb = self.siglip.encode_text(text)
qr = self.voxel_map.query(text_emb, top_k=self.cfg.locate_top_k)
if qr.score.size == 0:
return LocateResult(False, None, -1.0, 0, text)
top_score = float(qr.score.max())
if top_score < self.cfg.locate_threshold:
LOG.info(
"locate(%r): top score %.3f < threshold %.3f → NOT_FOUND",
text,
top_score,
self.cfg.locate_threshold,
)
return LocateResult(False, None, top_score, 0, text)
# Score-weighted centroid, then outlier rejection (anchor against the
# cluster median distance so a couple of stray voxels in the top-k
# can't drag the centroid into empty space).
scores = qr.score.astype(np.float64)
weights = scores - scores.min() + 1e-6
centroid = (qr.xyz * weights[:, None]).sum(axis=0) / weights.sum()
d = np.linalg.norm(qr.xyz - centroid, axis=1)
thresh = max(
self.cfg.cell_size * 4,
float(np.quantile(d, self.cfg.locate_outlier_quantile)) * self.cfg.locate_outlier_scale,
)
inliers = d <= thresh
if inliers.sum() >= 3:
inlier_xyz = qr.xyz[inliers]
inlier_w = weights[inliers]
centroid = (inlier_xyz * inlier_w[:, None]).sum(axis=0) / inlier_w.sum()
return LocateResult(
True,
(float(centroid[0]), float(centroid[1]), float(centroid[2])),
top_score,
int(inliers.sum()),
text,
)
# ----- goto(xyz) -------------------------------------------------------
def goto(
self,
target_xyz: tuple[float, float, float],
*,
max_steps: int | None = None,
threshold: float | None = None,
) -> GotoResult:
"""Closed-loop nav: A* → step a few cells → replan → repeat.
The replan cadence makes this a staleness governor — a moving
obstacle (or a previously-mapped one that got carved out) is picked
up at the next replan.
"""
max_steps = max_steps if max_steps is not None else self.cfg.goto_max_steps
threshold = threshold if threshold is not None else self.cfg.goto_threshold
path_xyz_global: list[tuple[float, float, float]] = []
n_steps = 0
last_path: list[tuple[float, float]] = []
for step in range(max_steps):
pos = self.base.position()
d = math.hypot(pos[0] - target_xyz[0], pos[2] - target_xyz[2])
if d <= threshold:
return GotoResult(True, pos, d, n_steps, "ok", path_xyz_global)
if step % self.cfg.goto_replan_every == 0 or not last_path:
grid = self.occupancy()
last_path = (
astar(
grid,
start_world=(pos[0], pos[2]),
goal_world=(target_xyz[0], target_xyz[2]),
)
or []
)
if not last_path or len(last_path) < 2:
return GotoResult(False, pos, d, n_steps, "no path", path_xyz_global)
# Head toward the next-but-one cell to smooth corners.
next_idx = min(2, len(last_path) - 1)
target_xz = last_path[next_idx]
dx = target_xz[0] - pos[0]
dz = target_xz[1] - pos[2]
n = math.hypot(dx, dz)
if n < 1e-6:
last_path.pop(0)
continue
vx = self.cfg.goto_step_size / max(self.cfg.goto_dt, 1e-6) * dx / n
vz = self.cfg.goto_step_size / max(self.cfg.goto_dt, 1e-6) * dz / n
self.base.move(vx=vx, vz=vz, dt=self.cfg.goto_dt)
pos = self.base.position()
path_xyz_global.append(pos)
n_steps += 1
# Pop waypoint when we've crossed it.
if math.hypot(target_xz[0] - pos[0], target_xz[1] - pos[2]) < self.cfg.cell_size:
last_path.pop(0)
if not last_path:
last_path = [] # force replan
pos = self.base.position()
d = math.hypot(pos[0] - target_xyz[0], pos[2] - target_xyz[2])
return GotoResult(False, pos, d, n_steps, "max steps", path_xyz_global)
# ----- explore() -------------------------------------------------------
def explore(self, query: str | None = None) -> ExploreResult:
"""Pick a frontier to drive toward via the DynaMem §3.4 value map.
With no query this is pure recency (visit oldest-observed or
UNOBSERVED frontiers first); with a query + features it biases
toward semantic matches.
"""
grid = self.occupancy()
cells = find_frontier_cells(grid)
if cells.shape[0] == 0:
return ExploreResult(None, False, 0.0, "no frontier")
# Subsample if huge so the loop stays fast even on big maps.
if cells.shape[0] > self.cfg.explore_max_frontiers:
idx = np.random.default_rng(0).choice(
cells.shape[0], self.cfg.explore_max_frontiers, replace=False
)
cells = cells[idx]
text_emb = None
if query is not None and self.siglip is not None and self.voxel_map.feature_dim is not None:
text_emb = self.siglip.encode_text(query)
values = compute_value_maps(self.voxel_map, grid, text_emb=text_emb, cfg=self.cfg.value_cfg)
pos = self.base.position()
_, (xt, zt), dist, score = pick_best_frontier_cell(
grid, cells, values, robot_position_xz=(pos[0], pos[2]), cfg=self.cfg.value_cfg
)
return ExploreResult(
target_xyz=(xt, grid.ground_y, zt),
found_frontier=True,
distance_to_target=dist,
reason="ok",
value=score,
)
+221
View File
@@ -0,0 +1,221 @@
#!/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.
"""DynaMem-style value maps for exploration.
Ported from the dyna360 research stack. Two scalar fields over the same
occupancy grid as :mod:`occupancy`:
- **V_T (time-recency)** — sigmoid of "how long ago was this cell last
observed?" Cells not seen in a while (or never) score high; freshly
observed cells score low. This biases exploration away from
just-covered territory.
- **V_S (query-similarity)** — sigmoid of the cosine between the cell's
aggregated feature and a text query. Only defined when a query is
given AND the voxel map carries features.
Linear combination ``V = (1 α)·V_T + α·V_S`` gates exploration. With no
query it is a pure recency-driven frontier walk; with a query it biases
toward regions semantically consistent with the target (DynaMem §3.4).
Maps are derived per-call from ``VoxelMap.snapshot`` so they inherit
carving for free.
"""
# ruff: noqa: N806 — H, W, D are conventional array-dimension names
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
import numpy as np
from lerobot.navigation.occupancy import OccupancyGrid
LOG = logging.getLogger(__name__)
@dataclass(frozen=True)
class ValueMapConfig:
"""Knobs shared between recency and similarity value maps."""
recency_mid_s: float = 10.0
"""Age (s) at which V_T crosses 0.5 — older = more interesting."""
recency_scale_s: float = 8.0
"""How sharply V_T transitions around the mid age. Smaller = sharper."""
similarity_mid: float = 0.15
"""Cosine score at which V_S crosses 0.5."""
similarity_scale: float = 0.05
"""How sharply V_S transitions around the mid cosine."""
alpha_similarity: float = 0.6
"""Weight of V_S in the combined value when a query is given.
0.0 = pure recency, 1.0 = pure similarity."""
unknown_value: float = 1.0
"""V_T for UNOBSERVED cells — they are maximally interesting."""
distance_discount_per_meter: float = 0.05
"""Multiplicative discount on far frontiers so the base does not
ping-pong across the map. 0 disables."""
@dataclass(frozen=True)
class ValueMaps:
"""The scalar fields, all shaped ``(H, W)`` like the occupancy grid."""
last_time: np.ndarray # float64 — inf where UNOBSERVED
recency: np.ndarray # float32 V_T in [0, 1]
similarity: np.ndarray | None # float32 V_S in [0, 1], None when no query
combined: np.ndarray # float32 V — what explore() optimizes
# --------------------------------------------------------------------- #
def _eps_for_cell(cell_size: float) -> float:
"""Same float32-drift epsilon as :mod:`occupancy` so the two
projections agree on which voxels land in which cells."""
return cell_size * 1e-3
def _project_voxels_to_cells(voxel_map, grid: OccupancyGrid, want_features: bool):
"""Project every voxel into its XZ cell.
Returns ``(last_time_per_cell, feat_per_cell)`` where last_time is
(H, W) float64 (inf for empty cells) and feat_per_cell is
(H, W, D) float32 or None.
"""
snap = voxel_map.snapshot(include_features=want_features)
H, W = grid.shape
last_time = np.full((H, W), -math.inf, dtype=np.float64)
if snap.xyz.size == 0:
return last_time, None
x = snap.xyz[:, 0].astype(np.float64)
z = snap.xyz[:, 2].astype(np.float64)
eps = _eps_for_cell(grid.cell_size)
ix = np.clip(np.floor((x - grid.origin_x) / grid.cell_size + eps).astype(np.int32), 0, W - 1)
iz = np.clip(np.floor((z - grid.origin_z) / grid.cell_size + eps).astype(np.int32), 0, H - 1)
# Per-cell max last_time. `np.maximum.at` is the unbuffered ufunc version,
# which correctly handles duplicate (iz, ix) targets.
np.maximum.at(last_time, (iz, ix), snap.last_time.astype(np.float64))
feat_per_cell: np.ndarray | None = None
if want_features and snap.feat is not None and snap.feat.size > 0:
D = snap.feat.shape[1]
feat_sum = np.zeros((H, W, D), dtype=np.float32)
np.add.at(feat_sum, (iz, ix), snap.feat.astype(np.float32))
counts = np.zeros((H, W), dtype=np.int32)
np.add.at(counts, (iz, ix), 1)
# Normalize per-cell — count is the number of CONTRIBUTING voxels.
denom = np.maximum(counts, 1).astype(np.float32)[..., None]
feat_per_cell = feat_sum / denom
return last_time, feat_per_cell
def _recency_value(last_time_per_cell: np.ndarray, now_t: float, cfg: ValueMapConfig) -> np.ndarray:
"""V_T per cell. Unobserved cells get ``cfg.unknown_value``."""
out = np.full(last_time_per_cell.shape, cfg.unknown_value, dtype=np.float32)
observed = last_time_per_cell > -math.inf
if not observed.any():
return out
age = (now_t - last_time_per_cell[observed]).astype(np.float32)
out[observed] = 1.0 / (1.0 + np.exp(-(age - cfg.recency_mid_s) / cfg.recency_scale_s))
return out
def _similarity_value(
feat_per_cell: np.ndarray | None,
text_emb: np.ndarray | None,
cfg: ValueMapConfig,
) -> np.ndarray | None:
"""V_S per cell. ``None`` when there are no features or no query."""
if feat_per_cell is None or text_emb is None:
return None
text = text_emb.astype(np.float32)
text = text / max(float(np.linalg.norm(text)), 1e-6)
# Per-cell mean feat may not be unit-norm — renormalize so the dot product
# behaves like a cosine. Empty cells stay a 0 vector, so renorm clamps to 0.
norms = np.linalg.norm(feat_per_cell, axis=-1, keepdims=True)
feat_normed = feat_per_cell / np.maximum(norms, 1e-6)
with np.errstate(invalid="ignore", over="ignore", divide="ignore"):
cosine = np.nan_to_num((feat_normed @ text).astype(np.float32))
sim = 1.0 / (1.0 + np.exp(-(cosine - cfg.similarity_mid) / cfg.similarity_scale))
sim = np.where(norms.squeeze(-1) > 1e-6, sim, 0.0).astype(np.float32)
return sim
def compute_value_maps(
voxel_map,
grid: OccupancyGrid,
*,
text_emb: np.ndarray | None = None,
now_t: float | None = None,
cfg: ValueMapConfig | None = None,
) -> ValueMaps:
"""Build the full value-map bundle for one ``explore`` call."""
cfg = cfg or ValueMapConfig()
last_time, feat_per_cell = _project_voxels_to_cells(voxel_map, grid, want_features=(text_emb is not None))
if now_t is None:
observed_mask = last_time > -math.inf
now_t = float(last_time[observed_mask].max()) if observed_mask.any() else 0.0
v_t = _recency_value(last_time, now_t, cfg)
v_s = _similarity_value(feat_per_cell, text_emb, cfg)
if v_s is not None:
combined = ((1.0 - cfg.alpha_similarity) * v_t + cfg.alpha_similarity * v_s).astype(np.float32)
else:
combined = v_t
return ValueMaps(last_time=last_time, recency=v_t, similarity=v_s, combined=combined)
def pick_best_frontier_cell(
grid: OccupancyGrid,
frontier_cells: np.ndarray,
values: ValueMaps,
robot_position_xz: tuple[float, float],
cfg: ValueMapConfig | None = None,
) -> tuple[int, tuple[float, float], float, float]:
"""Score every frontier cell by ``values.combined`` (with a distance
discount) and return the winner.
Returns ``(index_into_frontier_cells, (x, z), distance_m, score)``.
"""
if frontier_cells.shape[0] == 0:
raise ValueError("frontier_cells is empty")
cfg = cfg or ValueMapConfig()
iz_f = frontier_cells[:, 0]
ix_f = frontier_cells[:, 1]
raw = values.combined[iz_f, ix_f]
xs = grid.origin_x + (ix_f.astype(np.float64) + 0.5) * grid.cell_size
zs = grid.origin_z + (iz_f.astype(np.float64) + 0.5) * grid.cell_size
rx, rz = robot_position_xz
d = np.hypot(xs - rx, zs - rz)
discount = 1.0 / (1.0 + cfg.distance_discount_per_meter * d)
scored = raw * discount
best = int(np.argmax(scored))
return best, (float(xs[best]), float(zs[best])), float(d[best]), float(scored[best])
+161
View File
@@ -0,0 +1,161 @@
#!/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.
"""Live Rerun visualization of the spatial-memory map.
Shows the voxel map as it is built and updated: the point cloud (colored
by RGB or by observation recency), the robot pose, the top-down occupancy
grid, the planned path, query hits, and — the dynamic part — voxels that
were carved out this keyframe (moved/removed objects), flashed in red.
Because the full current voxel snapshot is re-logged under one entity path
each keyframe, carved voxels simply disappear from the cloud on the next
frame, so DynaMem-style dynamic updates are visible in real time. Rerun
(`rerun-sdk`) is imported lazily — ``pip install 'lerobot[viz]'`` — so the
rest of the stack never depends on it.
Requires ``rerun-sdk``; install with ``pip install 'lerobot[viz]'``.
"""
from __future__ import annotations
import logging
from typing import Any
import numpy as np
LOG = logging.getLogger(__name__)
_TIMELINE = "t"
def _recency_colors(last_time: np.ndarray, now: float, horizon_s: float = 30.0) -> np.ndarray:
"""Map per-voxel age to an (M, 3) uint8 color: recent = cyan, old = red."""
age = np.clip((now - last_time.astype(np.float64)) / max(horizon_s, 1e-6), 0.0, 1.0)
r = (60 + 195 * age).astype(np.uint8)
g = (200 * (1.0 - age)).astype(np.uint8)
b = (200 * (1.0 - age) + 40).astype(np.uint8)
return np.stack([r, g, b], axis=-1)
class MapVisualizer:
"""Rerun visualizer for the navigation map. Lazily starts the viewer."""
def __init__(
self,
app_id: str = "dog-nav",
spawn: bool = True,
color_mode: str = "rgb",
voxel_radius: float = 0.03,
) -> None:
self.app_id = app_id
self.spawn = spawn
self.color_mode = color_mode # "rgb" | "recency"
self.voxel_radius = float(voxel_radius)
self._rr: Any | None = None
def _ensure_started(self):
if self._rr is not None:
return self._rr
import rerun as rr
rr.init(self.app_id, spawn=self.spawn)
# OpenCV world convention: X right, Y down, Z forward (RDF).
rr.log("world", rr.ViewCoordinates.RDF, static=True)
self._rr = rr
return rr
def set_time(self, t_sec: float) -> None:
rr = self._ensure_started()
rr.set_time(_TIMELINE, timestamp=float(t_sec))
# ----- map + dynamics --------------------------------------------------
def log_map(self, snapshot, now: float | None = None) -> None:
"""Log the current voxel cloud. Re-logging replaces the previous
frame, so carved voxels vanish — that's the dynamic update."""
rr = self._ensure_started()
xyz = snapshot.xyz
if xyz.size == 0:
rr.log("world/map", rr.Clear(recursive=False))
return
if self.color_mode == "recency" and now is not None:
colors = _recency_colors(snapshot.last_time, now)
else:
colors = snapshot.rgb
rr.log(
"world/map",
rr.Points3D(xyz.astype(np.float32), colors=colors, radii=self.voxel_radius),
)
def log_removed(self, xyz: np.ndarray, radius: float | None = None) -> None:
"""Flash this keyframe's carved (removed) voxels in red — the
moved/vanished objects DynaMem carves out."""
rr = self._ensure_started()
r = radius if radius is not None else self.voxel_radius * 1.6
if xyz is None or len(xyz) == 0:
rr.log("world/carved", rr.Clear(recursive=False))
return
red = np.tile(np.array([[230, 40, 40]], dtype=np.uint8), (len(xyz), 1))
rr.log("world/carved", rr.Points3D(xyz.astype(np.float32), colors=red, radii=r))
# ----- robot + planning ------------------------------------------------
def log_robot(self, pose: np.ndarray, body_radius: float = 0.15) -> None:
rr = self._ensure_started()
rr.log(
"world/robot",
rr.Transform3D(
translation=pose[:3, 3].astype(np.float32), mat3x3=pose[:3, :3].astype(np.float32)
),
)
rr.log(
"world/robot/body",
rr.Points3D(
np.zeros((1, 3), dtype=np.float32),
colors=np.array([[60, 140, 255]], dtype=np.uint8),
radii=body_radius,
),
)
def log_occupancy(self, grid) -> None:
rr = self._ensure_started()
from lerobot.navigation.occupancy import occupancy_to_rgb
rr.log("plan/occupancy", rr.Image(occupancy_to_rgb(grid)))
def log_path(self, path_xyz: list[tuple[float, float, float]], radius: float = 0.02) -> None:
rr = self._ensure_started()
if not path_xyz:
rr.log("world/path", rr.Clear(recursive=False))
return
pts = np.asarray(path_xyz, dtype=np.float32)
rr.log("world/path", rr.LineStrips3D([pts], radii=radius, colors=[[255, 210, 60]]))
def log_target(self, xyz: tuple[float, float, float] | None) -> None:
"""Highlight the located target (green) or clear it when not found."""
rr = self._ensure_started()
if xyz is None:
rr.log("world/target", rr.Clear(recursive=False))
return
rr.log(
"world/target",
rr.Points3D(
np.asarray([xyz], dtype=np.float32),
colors=np.array([[40, 230, 90]], dtype=np.uint8),
radii=0.12,
),
)
+511
View File
@@ -0,0 +1,511 @@
#!/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.
"""Sparse-hash voxel memory with free-space carving + semantic features.
Ported from the dyna360 research stack. Per occupied voxel: voxel index,
running-mean xyz (count-weighted), running-mean rgb (count-weighted),
count, last_frame, last_time, and — once vision-language features have
been fed in — a conf-weighted running-mean feature in fp16 plus the
weight sum.
Storage is hybrid: a Python dict maps voxel index ``(ix, iy, iz)`` to a
row in column-stored numpy arrays so lookup is O(1) and bulk arithmetic
stays vectorized. ``carve`` removes voxels that fall inside a view's
observed free space (DynaMem-style dynamic updates); ``query`` returns
the top-k cosine matches against a text embedding.
Default voxel size is 5 cm. The map is geometry-only until
``add(..., feat_map=...)`` supplies per-pixel features; occupancy /
planning use only the geometry, so they work without any features.
"""
# ruff: noqa: N806 — H, W, D are conventional array-dimension names (and appear verbatim in error strings)
from __future__ import annotations
import logging
from dataclasses import dataclass
import numpy as np
LOG = logging.getLogger(__name__)
@dataclass
class VoxelMapStats:
"""Per-keyframe deltas, surfaced to scalar logs."""
n_voxels: int
n_added: int
n_updated: int
n_removed: int = 0
@dataclass(frozen=True)
class VoxelSnapshot:
"""Current voxel map state, materialized for visualization / export."""
xyz: np.ndarray # (M, 3) float32 — count-weighted mean position
rgb: np.ndarray # (M, 3) uint8 — count-weighted mean color (RGB)
count: np.ndarray # (M,) int64
last_frame: np.ndarray # (M,) int64
last_time: np.ndarray # (M,) float64
feat: np.ndarray | None = None # (M, D) fp16 — L2-normalized per-voxel mean
@dataclass(frozen=True)
class CarveResult:
"""Output of one ``carve`` pass."""
n_removed: int
removed_xyz: np.ndarray # (K, 3) float32 — centres of removed voxels, for viz
@dataclass(frozen=True)
class QueryResult:
"""Top-k cosine matches against a text embedding."""
xyz: np.ndarray # (k, 3) float32
score: np.ndarray # (k,) float32 — cosine similarity in [-1, 1]
voxel_indices: np.ndarray # (k,) int64 — row indices into the map
_MAX_ABS_VOXEL_INDEX = 1 << 20
_FEAT_CHUNK_PIXELS = 16384 # bound peak per-keyframe feature contribution memory
class VoxelMap:
"""Sparse-hash voxel grid with count-weighted means and semantic features."""
def __init__(self, voxel_size: float = 0.05) -> None:
if voxel_size <= 0:
raise ValueError("voxel_size must be > 0")
self.voxel_size = float(voxel_size)
self._lookup: dict[tuple[int, int, int], int] = {}
self._idx = np.zeros((0, 3), dtype=np.int64)
self._count = np.zeros(0, dtype=np.int64)
self._xyz_sum = np.zeros((0, 3), dtype=np.float64)
self._rgb_sum = np.zeros((0, 3), dtype=np.float64)
self._last_frame = np.zeros(0, dtype=np.int64)
self._last_time = np.zeros(0, dtype=np.float64)
# Lazily allocated on first add() with feat_map.
self._feature_dim: int | None = None
self._feat_sum: np.ndarray | None = None # (M, D) fp16
self._feat_weight: np.ndarray | None = None # (M,) fp32
def __len__(self) -> int:
return int(self._count.shape[0])
@property
def feature_dim(self) -> int | None:
return self._feature_dim
# ------------------------------------------------------------------ add
def add(
self,
points: np.ndarray,
rgb: np.ndarray,
conf: np.ndarray,
frame: int,
t: float,
conf_thresh: float = 0.5,
feat_map: np.ndarray | None = None,
) -> VoxelMapStats:
"""Insert / update voxels from a per-pixel observation.
``points``: ``(..., 3)`` world xyz, fp32.
``rgb``: ``(..., 3)`` uint8 (RGB order).
``conf``: ``(...,)`` in [0, 1].
``feat_map``: optional ``(..., D)`` fp16 per-pixel feature, already
bilinearly upsampled to the points/conf grid. First
call with features locks the feature dimension;
subsequent calls must match.
"""
pts = np.asarray(points).reshape(-1, 3)
cols = np.asarray(rgb).reshape(-1, 3)
cnf = np.asarray(conf).reshape(-1)
if not (len(pts) == len(cols) == len(cnf)):
raise ValueError(f"length mismatch: points={len(pts)}, rgb={len(cols)}, conf={len(cnf)}")
features: np.ndarray | None = None
if feat_map is not None:
features = np.asarray(feat_map).reshape(-1, feat_map.shape[-1])
if len(features) != len(pts):
raise ValueError(f"feat_map length {len(features)} != points length {len(pts)}")
D = features.shape[-1]
if self._feature_dim is None:
self._feature_dim = int(D)
# Pad pre-existing voxels (added before features arrived) with zeros.
self._feat_sum = np.zeros((len(self), D), dtype=np.float16)
self._feat_weight = np.zeros(len(self), dtype=np.float32)
LOG.info("VoxelMap features enabled: D=%d (fp16 storage)", D)
elif self._feature_dim != D:
raise ValueError(f"feature dim mismatch: existing={self._feature_dim}, got={D}")
mask = (cnf >= conf_thresh) & np.isfinite(pts).all(axis=1)
pts = pts[mask]
cols = cols[mask]
cnf_kept = cnf[mask]
if features is not None:
features = features[mask]
if pts.size == 0:
return VoxelMapStats(n_voxels=len(self), n_added=0, n_updated=0)
idx = np.floor(pts / self.voxel_size).astype(np.int64)
sane = (np.abs(idx) < _MAX_ABS_VOXEL_INDEX).all(axis=1)
if not sane.all():
n_drop = int((~sane).sum())
LOG.debug("dropping %d points with extreme voxel index", n_drop)
idx = idx[sane]
pts = pts[sane]
cols = cols[sane]
cnf_kept = cnf_kept[sane]
if features is not None:
features = features[sane]
if idx.size == 0:
return VoxelMapStats(n_voxels=len(self), n_added=0, n_updated=0)
unique_idx, inverse = np.unique(idx, axis=0, return_inverse=True)
inverse = inverse.reshape(-1)
n_unique = unique_idx.shape[0]
kf_count = np.bincount(inverse, minlength=n_unique).astype(np.int64)
kf_xyz_sum = np.zeros((n_unique, 3), dtype=np.float64)
kf_rgb_sum = np.zeros((n_unique, 3), dtype=np.float64)
np.add.at(kf_xyz_sum, inverse, pts.astype(np.float64))
np.add.at(kf_rgb_sum, inverse, cols.astype(np.float64))
kf_feat_sum: np.ndarray | None = None
kf_feat_weight: np.ndarray | None = None
if features is not None:
kf_feat_sum = np.zeros((n_unique, self._feature_dim), dtype=np.float32)
kf_feat_weight = np.zeros(n_unique, dtype=np.float32)
cnf_f = cnf_kept.astype(np.float32)
# Chunked accumulation — keeps the (chunk, D) intermediate small.
for s in range(0, features.shape[0], _FEAT_CHUNK_PIXELS):
e = s + _FEAT_CHUNK_PIXELS
w = cnf_f[s:e]
contrib = w[:, None] * features[s:e].astype(np.float32)
np.add.at(kf_feat_sum, inverse[s:e], contrib)
np.add.at(kf_feat_weight, inverse[s:e], w)
existing_rows: list[int] = []
existing_local: list[int] = []
new_local: list[int] = []
new_keys: list[tuple[int, int, int]] = []
for i in range(n_unique):
key = (int(unique_idx[i, 0]), int(unique_idx[i, 1]), int(unique_idx[i, 2]))
row = self._lookup.get(key)
if row is None:
new_local.append(i)
new_keys.append(key)
else:
existing_rows.append(row)
existing_local.append(i)
if existing_rows:
rows = np.asarray(existing_rows, dtype=np.int64)
local = np.asarray(existing_local, dtype=np.int64)
self._count[rows] += kf_count[local]
self._xyz_sum[rows] += kf_xyz_sum[local]
self._rgb_sum[rows] += kf_rgb_sum[local]
self._last_frame[rows] = frame
self._last_time[rows] = t
if kf_feat_sum is not None:
assert self._feat_sum is not None and self._feat_weight is not None
# fp32 accumulator -> fp16 storage; cast on store to match storage dtype.
self._feat_sum[rows] = (self._feat_sum[rows].astype(np.float32) + kf_feat_sum[local]).astype(
np.float16
)
self._feat_weight[rows] += kf_feat_weight[local]
if new_local:
base = len(self)
local = np.asarray(new_local, dtype=np.int64)
self._idx = np.concatenate([self._idx, unique_idx[local]], axis=0)
self._count = np.concatenate([self._count, kf_count[local]])
self._xyz_sum = np.concatenate([self._xyz_sum, kf_xyz_sum[local]], axis=0)
self._rgb_sum = np.concatenate([self._rgb_sum, kf_rgb_sum[local]], axis=0)
self._last_frame = np.concatenate(
[self._last_frame, np.full(len(new_local), frame, dtype=np.int64)]
)
self._last_time = np.concatenate([self._last_time, np.full(len(new_local), t, dtype=np.float64)])
if self._feature_dim is not None:
assert self._feat_sum is not None and self._feat_weight is not None
if kf_feat_sum is not None:
new_feats = kf_feat_sum[local].astype(np.float16)
new_weights = kf_feat_weight[local]
else:
# Features enabled, but this call didn't bring any — pad zeros
# so array sizes stay aligned with _count.
new_feats = np.zeros((len(new_local), self._feature_dim), dtype=np.float16)
new_weights = np.zeros(len(new_local), dtype=np.float32)
self._feat_sum = np.concatenate([self._feat_sum, new_feats], axis=0)
self._feat_weight = np.concatenate([self._feat_weight, new_weights])
for offset, key in enumerate(new_keys):
self._lookup[key] = base + offset
return VoxelMapStats(
n_voxels=len(self),
n_added=len(new_local),
n_updated=len(existing_rows),
)
# ------------------------------------------------------- hard-delete
def remove_voxels_in_box(
self,
xyz_min: tuple[float, float, float],
xyz_max: tuple[float, float, float],
) -> int:
"""Surgical hard-delete of every voxel whose mean position lies inside
the axis-aligned bounding box.
Different from :meth:`carve` (DynaMem-style free-space removal from a
camera frustum + depth). This one is for simulated scene mutation:
"the couch moved away" is removing the box around the old couch then
``add()``-ing one at the new position.
"""
if len(self) == 0:
return 0
cnt = self._count.astype(np.float64).reshape(-1, 1)
means = self._xyz_sum / cnt
in_box = (
(means[:, 0] >= xyz_min[0])
& (means[:, 0] <= xyz_max[0])
& (means[:, 1] >= xyz_min[1])
& (means[:, 1] <= xyz_max[1])
& (means[:, 2] >= xyz_min[2])
& (means[:, 2] <= xyz_max[2])
)
if not in_box.any():
return 0
keep = ~in_box
n_removed = int(in_box.sum())
for k in self._idx[in_box]:
del self._lookup[(int(k[0]), int(k[1]), int(k[2]))]
self._idx = self._idx[keep]
self._count = self._count[keep]
self._xyz_sum = self._xyz_sum[keep]
self._rgb_sum = self._rgb_sum[keep]
self._last_frame = self._last_frame[keep]
self._last_time = self._last_time[keep]
if self._feat_sum is not None and self._feat_weight is not None:
self._feat_sum = self._feat_sum[keep]
self._feat_weight = self._feat_weight[keep]
# Row indices shifted — rebuild the lookup.
self._lookup = {
(int(self._idx[i, 0]), int(self._idx[i, 1]), int(self._idx[i, 2])): i
for i in range(len(self._idx))
}
return n_removed
# ---------------------------------------------------------------- carve
def carve(
self,
local_points: np.ndarray,
conf: np.ndarray,
pose: np.ndarray,
focal_px: float,
frame: int,
t: float,
conf_thresh: float = 0.5,
margin: float = 0.05,
) -> CarveResult:
"""Remove voxels inside this view's observed free space.
A voxel is carved when it projects into the image, sits in front of
the camera, and lies closer than the observed depth (minus a margin)
at that pixel — i.e. we can see through where it claims to be. Carve
runs before ``add`` each keyframe so moved/removed objects disappear.
"""
if len(self) == 0:
return CarveResult(0, np.zeros((0, 3), dtype=np.float32))
if local_points.ndim != 3 or local_points.shape[-1] != 3:
raise ValueError(f"expected (H, W, 3), got {local_points.shape}")
if conf.shape != local_points.shape[:2]:
raise ValueError(f"conf shape {conf.shape} != local_points (H, W) {local_points.shape[:2]}")
if pose.shape != (4, 4):
raise ValueError(f"pose must be (4, 4); got {pose.shape}")
H, W = local_points.shape[:2]
cx = (W - 1) / 2.0
cy = (H - 1) / 2.0
depth_map = local_points[..., 2]
cnt = self._count.astype(np.float64).reshape(-1, 1)
xyz_world = self._xyz_sum / cnt
R = pose[:3, :3].astype(np.float64)
t_vec = pose[:3, 3].astype(np.float64)
xyz_cam = (xyz_world - t_vec[None, :]) @ R
d_voxel = xyz_cam[:, 2]
front = d_voxel > 1e-3
d_safe = np.where(front, d_voxel, 1.0)
u = focal_px * xyz_cam[:, 0] / d_safe + cx
v = focal_px * xyz_cam[:, 1] / d_safe + cy
in_bounds = (u >= 0.0) & (u < W) & (v >= 0.0) & (v < H)
valid = front & in_bounds
u_i = np.clip(np.floor(u).astype(np.int64), 0, W - 1)
v_i = np.clip(np.floor(v).astype(np.int64), 0, H - 1)
D_at = depth_map[v_i, u_i]
C_at = conf[v_i, u_i]
finite_D = np.isfinite(D_at) & (D_at > 0.0)
free_space = valid & finite_D & (C_at >= conf_thresh) & (d_voxel < (D_at - margin))
n_removed = int(free_space.sum())
if n_removed == 0:
return CarveResult(0, np.zeros((0, 3), dtype=np.float32))
removed_xyz = xyz_world[free_space].astype(np.float32)
removed_keys = self._idx[free_space]
for k in removed_keys:
del self._lookup[(int(k[0]), int(k[1]), int(k[2]))]
keep = ~free_space
self._idx = self._idx[keep]
self._count = self._count[keep]
self._xyz_sum = self._xyz_sum[keep]
self._rgb_sum = self._rgb_sum[keep]
self._last_frame = self._last_frame[keep]
self._last_time = self._last_time[keep]
if self._feat_sum is not None and self._feat_weight is not None:
self._feat_sum = self._feat_sum[keep]
self._feat_weight = self._feat_weight[keep]
self._lookup = {
(int(self._idx[i, 0]), int(self._idx[i, 1]), int(self._idx[i, 2])): i
for i in range(len(self._idx))
}
LOG.debug("carve frame=%d t=%.3fs removed=%d", frame, t, n_removed)
return CarveResult(n_removed=n_removed, removed_xyz=removed_xyz)
# ------------------------------------------------------------- snapshot
def snapshot(self, include_features: bool = False) -> VoxelSnapshot:
"""Materialize the current map.
``include_features``: pay the cost of normalizing the per-voxel
feature mean. Off by default — visualization doesn't need features.
"""
if len(self) == 0:
return VoxelSnapshot(
xyz=np.zeros((0, 3), dtype=np.float32),
rgb=np.zeros((0, 3), dtype=np.uint8),
count=np.zeros(0, dtype=np.int64),
last_frame=np.zeros(0, dtype=np.int64),
last_time=np.zeros(0, dtype=np.float64),
feat=None,
)
cnt = self._count.astype(np.float64).reshape(-1, 1)
xyz = (self._xyz_sum / cnt).astype(np.float32)
rgb = np.clip(self._rgb_sum / cnt, 0, 255).astype(np.uint8)
feat = None
if include_features and self._feat_sum is not None and self._feat_weight is not None:
feat = self._normalized_features()
return VoxelSnapshot(
xyz=xyz,
rgb=rgb,
count=self._count.copy(),
last_frame=self._last_frame.copy(),
last_time=self._last_time.copy(),
feat=feat,
)
def _normalized_features(self) -> np.ndarray:
"""Per-voxel L2-normalized feature mean. (M, D) fp16."""
assert self._feat_sum is not None and self._feat_weight is not None
w = np.maximum(self._feat_weight, 1e-6).reshape(-1, 1)
mean = self._feat_sum.astype(np.float32) / w
norms = np.linalg.norm(mean, axis=1, keepdims=True)
mean = mean / np.maximum(norms, 1e-6)
return mean.astype(np.float16)
# ----------------------------------------------------------------- query
def query(self, text_embedding: np.ndarray, top_k: int = 32) -> QueryResult:
"""Top-k cosine matches against ``text_embedding``.
``text_embedding``: ``(D,)`` array — does NOT need to be unit norm;
we re-normalize.
"""
if self._feat_sum is None or self._feature_dim is None:
raise RuntimeError("VoxelMap has no semantic features yet — call add(..., feat_map=...) first")
if len(self) == 0:
return QueryResult(
xyz=np.zeros((0, 3), dtype=np.float32),
score=np.zeros(0, dtype=np.float32),
voxel_indices=np.zeros(0, dtype=np.int64),
)
if text_embedding.shape != (self._feature_dim,):
raise ValueError(f"text_embedding shape {text_embedding.shape} != ({self._feature_dim},)")
voxel_feat = self._normalized_features().astype(np.float32)
text_unit = text_embedding.astype(np.float32)
text_unit = text_unit / max(float(np.linalg.norm(text_unit)), 1e-6)
# fp16 feature storage can carry the odd inf/nan from a saturated
# running sum; the cosine stays well-defined, so don't warn on it.
with np.errstate(invalid="ignore", over="ignore", divide="ignore"):
scores = np.nan_to_num(voxel_feat @ text_unit) # (M,)
k = min(int(top_k), len(scores))
# Partition-and-sort for the top-k.
top_idx = np.argpartition(scores, -k)[-k:]
order = np.argsort(-scores[top_idx])
top_idx = top_idx[order]
snap_xyz = (self._xyz_sum[top_idx] / self._count[top_idx].astype(np.float64).reshape(-1, 1)).astype(
np.float32
)
return QueryResult(
xyz=snap_xyz,
score=scores[top_idx].astype(np.float32),
voxel_indices=top_idx.astype(np.int64),
)
# --------------------------------------------------------- introspection
def memory_bytes(self) -> dict[str, int]:
"""Return per-array memory footprint."""
out = {
"idx": self._idx.nbytes,
"count": self._count.nbytes,
"xyz_sum": self._xyz_sum.nbytes,
"rgb_sum": self._rgb_sum.nbytes,
"last_frame": self._last_frame.nbytes,
"last_time": self._last_time.nbytes,
"lookup_dict": _approx_dict_bytes(self._lookup),
}
if self._feat_sum is not None:
out["feat_sum"] = self._feat_sum.nbytes
assert self._feat_weight is not None
out["feat_weight"] = self._feat_weight.nbytes
out["total"] = sum(v for k, v in out.items() if k != "total")
return out
def _approx_dict_bytes(d: dict) -> int:
"""Rough lower-bound estimate; ~100 bytes/entry is a fine ballpark."""
return 100 * len(d)
+44
View File
@@ -83,6 +83,50 @@ class VQBeTSchedulerConfig(LRSchedulerConfig):
return LambdaLR(optimizer, lr_lambda, -1)
@LRSchedulerConfig.register_subclass("constant_with_warmup")
@dataclass
class ConstantWithWarmupSchedulerConfig(LRSchedulerConfig):
"""Linear warmup followed by a constant learning rate.
Mirrors the ``warmup_constant_lambda`` used by LingBot-VA (upstream ``wan_va/train.py``):
the LR ramps linearly from 0 to the peak over ``num_warmup_steps`` steps, then stays flat.
"""
num_warmup_steps: int = 1000
def build(self, optimizer: Optimizer, num_training_steps: int) -> LambdaLR:
warmup_steps = self.num_warmup_steps or 0
def lr_lambda(current_step):
if current_step < warmup_steps:
return float(current_step) / float(max(1, warmup_steps))
return 1.0
return LambdaLR(optimizer, lr_lambda, -1)
@LRSchedulerConfig.register_subclass("cosine_annealing_with_warmup")
@dataclass
class CosineAnnealingWithWarmupSchedulerConfig(LRSchedulerConfig):
"""Linear warmup followed by cosine annealing from the peak LR to zero.
Used by EVO1; the annealing phase always spans the remaining training steps.
"""
num_warmup_steps: int
def build(self, optimizer: Optimizer, num_training_steps: int) -> LambdaLR:
def lr_lambda(current_step: int) -> float:
if current_step < self.num_warmup_steps:
return current_step / max(1, self.num_warmup_steps)
progress = (current_step - self.num_warmup_steps) / max(
1, num_training_steps - self.num_warmup_steps
)
return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))
return LambdaLR(optimizer, lr_lambda, -1)
@LRSchedulerConfig.register_subclass("cosine_decay_with_warmup")
@dataclass
class CosineDecayWithWarmupSchedulerConfig(LRSchedulerConfig):
+6
View File
@@ -17,10 +17,12 @@ from lerobot.utils.action_interpolator import ActionInterpolator as ActionInterp
from .act.configuration_act import ACTConfig as ACTConfig
from .diffusion.configuration_diffusion import DiffusionConfig as DiffusionConfig
from .eo1.configuration_eo1 import EO1Config as EO1Config
from .evo1.configuration_evo1 import Evo1Config as Evo1Config
from .factory import get_policy_class, make_policy, make_policy_config, make_pre_post_processors
from .fastwam.configuration_fastwam import FastWAMConfig as FastWAMConfig
from .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig as GaussianActorConfig
from .groot.configuration_groot import GrootConfig as GrootConfig
from .lingbot_va.configuration_lingbot_va import LingBotVAConfig as LingBotVAConfig
from .molmoact2.configuration_molmoact2 import MolmoAct2Config as MolmoAct2Config
from .multi_task_dit.configuration_multi_task_dit import MultiTaskDiTConfig as MultiTaskDiTConfig
from .pi0.configuration_pi0 import PI0Config as PI0Config
@@ -30,6 +32,7 @@ from .pretrained import PreTrainedPolicy as PreTrainedPolicy
from .smolvla.configuration_smolvla import SmolVLAConfig as SmolVLAConfig
from .tdmpc.configuration_tdmpc import TDMPCConfig as TDMPCConfig
from .utils import make_robot_action, prepare_observation_for_inference
from .vla_jepa.configuration_vla_jepa import VLAJEPAConfig as VLAJEPAConfig
from .vqbet.configuration_vqbet import VQBeTConfig as VQBeTConfig
from .wall_x.configuration_wall_x import WallXConfig as WallXConfig
from .xvla.configuration_xvla import XVLAConfig as XVLAConfig
@@ -45,7 +48,9 @@ __all__ = [
"EO1Config",
"FastWAMConfig",
"GaussianActorConfig",
"Evo1Config",
"GrootConfig",
"LingBotVAConfig",
"MolmoAct2Config",
"MultiTaskDiTConfig",
"PI0Config",
@@ -53,6 +58,7 @@ __all__ = [
"PI05Config",
"SmolVLAConfig",
"TDMPCConfig",
"VLAJEPAConfig",
"VQBeTConfig",
"WallXConfig",
"XVLAConfig",
+2 -39
View File
@@ -18,17 +18,10 @@ from typing import Any
import torch
from lerobot.processor import (
AddBatchDimensionProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
PolicyAction,
PolicyProcessorPipeline,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
policy_action_to_transition,
transition_to_policy_action,
make_default_pre_post_processors,
)
from lerobot.utils.constants import POLICY_POSTPROCESSOR_DEFAULT_NAME, POLICY_PREPROCESSOR_DEFAULT_NAME
from .configuration_act import ACTConfig
@@ -54,34 +47,4 @@ def make_act_pre_post_processors(
tuple[PolicyProcessorPipeline[dict[str, Any], dict[str, Any]], PolicyProcessorPipeline[PolicyAction, PolicyAction]]: A tuple containing the
pre-processor pipeline and the post-processor pipeline.
"""
input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=dataset_stats,
device=config.device,
),
]
output_steps = [
UnnormalizerProcessorStep(
features=config.output_features, norm_map=config.normalization_mapping, stats=dataset_stats
),
DeviceProcessorStep(device="cpu"),
]
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
return make_default_pre_post_processors(config, dataset_stats, normalizer_device=config.device)
@@ -0,0 +1,122 @@
#!/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.
"""Flow-matching sampling primitives shared across policies.
Canonical versions of the beta-distributed timestep sampler and the forward-Euler
denoising loop (with its real-time-chunking hook) that the openpi-derived policies
(pi0, pi05, smolvla, eo1) historically each carried a copy of. All functions are
stateless; adopting them does not affect checkpoints.
"""
from collections.abc import Callable
from typing import TYPE_CHECKING
import torch
from torch import Tensor
if TYPE_CHECKING:
from lerobot.policies.rtc.modeling_rtc import RTCProcessor
def sample_beta(alpha: float, beta: float, bsize: int, device) -> Tensor: # see openpi (exact copy)
# Beta sampling uses _sample_dirichlet which isn't implemented for MPS, so sample on CPU
alpha_t = torch.tensor(alpha, dtype=torch.float32)
beta_t = torch.tensor(beta, dtype=torch.float32)
dist = torch.distributions.Beta(alpha_t, beta_t)
return dist.sample((bsize,)).to(device)
def sample_noise(shape, device) -> Tensor:
"""Standard-normal float32 noise, the flow-matching x_1 sample."""
return torch.normal(
mean=0.0,
std=1.0,
size=shape,
dtype=torch.float32,
device=device,
)
def sample_time_beta(bsize: int, device, *, alpha: float, beta: float, scale: float, offset: float) -> Tensor:
"""Beta-distributed flow-matching timesteps: ``Beta(alpha, beta) * scale + offset`` (openpi convention)."""
time_beta = sample_beta(alpha, beta, bsize, device)
time = time_beta * scale + offset
return time.to(dtype=torch.float32, device=device)
def euler_integrate(
denoise_fn: Callable[[Tensor, Tensor], Tensor],
noise: Tensor,
num_steps: int,
*,
rtc_processor: "RTCProcessor | None" = None,
rtc_enabled: bool = False,
inference_delay: int | None = None,
prev_chunk_left_over: Tensor | None = None,
execution_horizon: int | None = None,
) -> Tensor:
"""Forward-Euler integration of a velocity field from t=1 (noise) to t=0 (actions).
This is the openpi sampling loop: ``dt = -1/num_steps``, ``time = 1.0 + step*dt``,
``x_t <- x_t + dt * v_t``, with the optional real-time-chunking (RTC) guidance hook
wrapping the velocity computation and debug tracking after each step.
Args:
denoise_fn: Computes the velocity ``v_t`` from ``(x_t, time_tensor)`` where
``time_tensor`` is a float32 tensor of shape ``(batch_size,)``. The returned
velocity must have the same shape and dtype as ``x_t``.
noise: Initial sample ``x_1`` of shape ``(batch_size, ...)``.
num_steps: Number of Euler steps.
rtc_processor: Optional RTC processor. Debug tracking fires whenever it is set and
has debugging enabled, even if RTC guidance itself is disabled (this mirrors
the historical per-policy loops).
rtc_enabled: Whether to route the velocity computation through
``rtc_processor.denoise_step`` (requires ``rtc_processor``).
inference_delay: RTC guidance parameter, forwarded verbatim.
prev_chunk_left_over: RTC guidance parameter, forwarded verbatim.
execution_horizon: RTC guidance parameter, forwarded verbatim.
"""
bsize = noise.shape[0]
device = noise.device
dt = -1.0 / num_steps
x_t = noise
for step in range(num_steps):
time = 1.0 + step * dt
time_tensor = torch.tensor(time, dtype=torch.float32, device=device).expand(bsize)
def denoise_step_partial_call(input_x_t, current_timestep=time_tensor):
return denoise_fn(input_x_t, current_timestep)
if rtc_enabled:
v_t = rtc_processor.denoise_step(
x_t=x_t,
prev_chunk_left_over=prev_chunk_left_over,
inference_delay=inference_delay,
time=time,
original_denoise_step_partial=denoise_step_partial_call,
execution_horizon=execution_horizon,
)
else:
v_t = denoise_step_partial_call(x_t)
x_t = x_t + dt * v_t
if rtc_processor is not None and rtc_processor.is_debug_enabled():
rtc_processor.track(time=time, x_t=x_t, v_t=v_t)
return x_t
+243
View File
@@ -0,0 +1,243 @@
#!/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.
"""Helpers shared by the openpi-derived VLA policies (pi0, pi05, pi0_fast, smolvla, eo1, xvla).
These are the canonical versions of functions that historically were copy-pasted per
policy. They are pure (no parameters, no module state), so importing them from here
instead of a policy-local copy has no effect on checkpoints.
"""
import math
from typing import TYPE_CHECKING
import torch
import torch.nn.functional as F # noqa: N812
from torch import Tensor
from lerobot.utils.constants import OPENPI_ATTENTION_MASK_VALUE
from lerobot.utils.device_utils import get_safe_dtype
from lerobot.utils.import_utils import _transformers_available, require_package
if TYPE_CHECKING or _transformers_available:
from transformers import DynamicCache
else:
DynamicCache = None
def create_sinusoidal_pos_embedding( # see openpi `create_sinusoidal_pos_embedding` (exact copy)
time: torch.Tensor, dimension: int, min_period: float, max_period: float, device="cpu"
) -> Tensor:
"""Computes sine-cosine positional embedding vectors for scalar positions."""
if dimension % 2 != 0:
raise ValueError(f"dimension ({dimension}) must be divisible by 2")
if time.ndim != 1:
raise ValueError("The time tensor is expected to be of shape `(batch_size, )`.")
dtype = get_safe_dtype(torch.float64, device.type)
fraction = torch.linspace(0.0, 1.0, dimension // 2, dtype=dtype, device=device)
period = min_period * (max_period / min_period) ** fraction
# Compute the outer product
scaling_factor = 1.0 / period * 2 * math.pi
sin_input = scaling_factor[None, :] * time[:, None]
return torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=1)
def make_att_2d_masks(pad_masks: Tensor, att_masks: Tensor) -> Tensor: # see openpi (exact copy)
"""Copied from big_vision.
Tokens can attend to valid inputs tokens which have a cumulative mask_ar
smaller or equal to theirs. This way `mask_ar` int[B, N] can be used to
setup several types of attention, for example:
[[1 1 1 1 1 1]]: pure causal attention.
[[0 0 0 1 1 1]]: prefix-lm attention. The first 3 tokens can attend between
themselves and the last 3 tokens have a causal attention. The first
entry could also be a 1 without changing behaviour.
[[1 0 1 0 1 0 0 1 0 0]]: causal attention between 4 blocks. Tokens of a
block can attend all previous blocks and all tokens on the same block.
Args:
input_mask: bool[B, N] true if its part of the input, false if padding.
mask_ar: int32[B, N] mask that's 1 where previous tokens cannot depend on
it and 0 where it shares the same attention mask as the previous token.
"""
if att_masks.ndim != 2:
raise ValueError(att_masks.ndim)
if pad_masks.ndim != 2:
raise ValueError(pad_masks.ndim)
cumsum = torch.cumsum(att_masks, dim=1)
att_2d_masks = cumsum[:, None, :] <= cumsum[:, :, None]
pad_2d_masks = pad_masks[:, None, :] * pad_masks[:, :, None]
return att_2d_masks & pad_2d_masks
def prepare_attention_masks_4d(att_2d_masks: Tensor, dtype: torch.dtype | None = None) -> Tensor:
"""Expand boolean 2D attention masks to the additive 4D layout expected by transformers.
Valid positions become 0.0 and masked positions the large negative openpi constant.
"""
att_2d_masks_4d = att_2d_masks[:, None, :, :]
result = torch.where(att_2d_masks_4d, 0.0, OPENPI_ATTENTION_MASK_VALUE)
if dtype is not None:
result = result.to(dtype=dtype)
return result
def clone_past_key_values(past_key_values):
"""Clone the DynamicCache returned by prefix prefill for compiled denoising."""
if DynamicCache is None:
require_package("transformers", extra="transformers-dep")
return DynamicCache(
tuple(
(keys.clone(), values.clone(), sliding_window) for keys, values, sliding_window in past_key_values
)
)
def pad_vector(vector: Tensor, new_dim: int, *, truncate: bool = False) -> Tensor:
"""Pad the last dimension of a vector to new_dim with zeros.
Can be (batch_size x sequence_length x features_dimension)
or (batch_size x features_dimension)
With ``truncate=False`` (openpi behavior), vectors whose last dimension is already
>= new_dim are returned unchanged. With ``truncate=True`` (xVLA behavior), the last
dimension is truncated to exactly ``new_dim`` (which may be 0).
"""
if vector.shape[-1] == new_dim:
return vector
if not truncate:
if vector.shape[-1] >= new_dim:
return vector
return F.pad(vector, (0, new_dim - vector.shape[-1]))
shape = list(vector.shape)
current_dim = shape[-1]
shape[-1] = new_dim
new_vector = vector.new_zeros(*shape)
length = min(current_dim, new_dim)
new_vector[..., :length] = vector[..., :length]
return new_vector
def resize_with_pad_torch( # see openpi `resize_with_pad_torch` (exact copy)
images: torch.Tensor,
height: int,
width: int,
mode: str = "bilinear",
) -> torch.Tensor:
"""PyTorch version of resize_with_pad. Resizes an image to a target height and width without distortion
by padding with black. If the image is float32, it must be in the range [-1, 1].
Padding is centered (openpi convention). For the top-left-padding variant used by
smolvla/xvla, see :func:`resize_with_pad`.
Args:
images: Tensor of shape [*b, h, w, c] or [*b, c, h, w]
height: Target height
width: Target width
mode: Interpolation mode ('bilinear', 'nearest', etc.)
Returns:
Resized and padded tensor with same shape format as input
"""
# Check if input is in channels-last format [*b, h, w, c] or channels-first [*b, c, h, w]
if images.shape[-1] <= 4: # Assume channels-last format
channels_last = True
if images.dim() == 3:
images = images.unsqueeze(0) # Add batch dimension
images = images.permute(0, 3, 1, 2) # [b, h, w, c] -> [b, c, h, w]
else:
channels_last = False
if images.dim() == 3:
images = images.unsqueeze(0) # Add batch dimension
batch_size, channels, cur_height, cur_width = images.shape
# Calculate resize ratio
ratio = max(cur_width / width, cur_height / height)
resized_height = int(cur_height / ratio)
resized_width = int(cur_width / ratio)
# Resize
resized_images = F.interpolate(
images,
size=(resized_height, resized_width),
mode=mode,
align_corners=False if mode == "bilinear" else None,
)
# Handle dtype-specific clipping
if images.dtype == torch.uint8:
resized_images = torch.round(resized_images).clamp(0, 255).to(torch.uint8)
elif images.dtype == torch.float32:
resized_images = resized_images.clamp(0.0, 1.0)
else:
raise ValueError(f"Unsupported image dtype: {images.dtype}")
# Calculate padding
pad_h0, remainder_h = divmod(height - resized_height, 2)
pad_h1 = pad_h0 + remainder_h
pad_w0, remainder_w = divmod(width - resized_width, 2)
pad_w1 = pad_w0 + remainder_w
# Pad
constant_value = 0 if images.dtype == torch.uint8 else 0.0
padded_images = F.pad(
resized_images,
(pad_w0, pad_w1, pad_h0, pad_h1), # left, right, top, bottom
mode="constant",
value=constant_value,
)
# Convert back to original format if needed
if channels_last:
padded_images = padded_images.permute(0, 2, 3, 1) # [b, c, h, w] -> [b, h, w, c]
return padded_images
def resize_with_pad(img: torch.Tensor, height: int, width: int, *, pad_value: float) -> torch.Tensor:
"""Resize a (b, c, h, w) image without distortion, padding on the LEFT and TOP.
This is the smolvla/xvla convention. For the centered-padding openpi variant, see
:func:`resize_with_pad_torch`. ``pad_value`` is keyword-only on purpose: callers
historically used different values (0, -1) and must state their choice explicitly.
"""
if img.ndim != 4:
raise ValueError(f"(b,c,h,w) expected, but got {img.shape}")
current_height, current_width = img.shape[2:]
if current_height == height and current_width == width:
return img
ratio = max(current_width / width, current_height / height)
resized_height = int(current_height / ratio)
resized_width = int(current_width / ratio)
resized_img = F.interpolate(
img, size=(resized_height, resized_width), mode="bilinear", align_corners=False
)
pad_height = max(0, height - resized_height)
pad_width = max(0, width - resized_width)
padded_img = F.pad(resized_img, (pad_width, 0, pad_height, 0), value=pad_value)
return padded_img
@@ -19,17 +19,10 @@ from typing import Any
import torch
from lerobot.processor import (
AddBatchDimensionProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
PolicyAction,
PolicyProcessorPipeline,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
policy_action_to_transition,
transition_to_policy_action,
make_default_pre_post_processors,
)
from lerobot.utils.constants import POLICY_POSTPROCESSOR_DEFAULT_NAME, POLICY_PREPROCESSOR_DEFAULT_NAME
from .configuration_diffusion import DiffusionConfig
@@ -63,32 +56,4 @@ def make_diffusion_pre_post_processors(
Returns:
A tuple containing the configured pre-processor and post-processor pipelines.
"""
input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=dataset_stats,
),
]
output_steps = [
UnnormalizerProcessorStep(
features=config.output_features, norm_map=config.normalization_mapping, stats=dataset_stats
),
DeviceProcessorStep(device="cpu"),
]
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
return make_default_pre_post_processors(config, dataset_stats)
+14 -76
View File
@@ -18,7 +18,6 @@ from __future__ import annotations
import contextlib
import logging
import math
from collections import deque
from typing import TYPE_CHECKING, Any
@@ -31,6 +30,8 @@ from torch import Tensor
from lerobot.utils.constants import ACTION, OBS_STATE
from lerobot.utils.import_utils import _transformers_available, require_package
from ..common.flow_matching import euler_integrate, sample_noise, sample_time_beta
from ..common.vla_utils import create_sinusoidal_pos_embedding, pad_vector
from ..pretrained import PreTrainedPolicy
from .configuration_eo1 import EO1Config
@@ -46,17 +47,6 @@ else:
logger = logging.getLogger(__name__)
def pad_vector(vector, new_dim):
"""Pad the last dimension of a vector to new_dim with zeros.
Can be (batch_size x sequence_length x features_dimension)
or (batch_size x features_dimension)
"""
if vector.shape[-1] >= new_dim:
return vector
return F.pad(vector, (0, new_dim - vector.shape[-1]))
class EO1Policy(PreTrainedPolicy):
"""EO1 policy wrapper for LeRobot robot-only training/evaluation."""
@@ -136,47 +126,6 @@ class EO1Policy(PreTrainedPolicy):
return self.parameters()
def get_safe_dtype(target_dtype, device_type):
"""Get a safe dtype for the given device type."""
if device_type == "mps" and target_dtype == torch.float64:
return torch.float32
if device_type == "cpu":
# CPU doesn't support bfloat16, use float32 instead
if target_dtype == torch.bfloat16:
return torch.float32
if target_dtype == torch.float64:
return torch.float64
return target_dtype
def create_sinusoidal_pos_embedding( # see openpi `create_sinusoidal_pos_embedding` (exact copy)
time: torch.Tensor, dimension: int, min_period: float, max_period: float, device="cpu"
) -> Tensor:
"""Computes sine-cosine positional embedding vectors for scalar positions."""
if dimension % 2 != 0:
raise ValueError(f"dimension ({dimension}) must be divisible by 2")
if time.ndim != 1:
raise ValueError("The time tensor is expected to be of shape `(batch_size, )`.")
dtype = get_safe_dtype(torch.float64, device.type)
fraction = torch.linspace(0.0, 1.0, dimension // 2, dtype=dtype, device=device)
period = min_period * (max_period / min_period) ** fraction
# Compute the outer product
scaling_factor = 1.0 / period * 2 * math.pi
sin_input = scaling_factor[None, :] * time[:, None]
return torch.cat([torch.sin(sin_input), torch.cos(sin_input)], dim=1)
def sample_beta(alpha, beta, bsize, device): # see openpi `sample_beta` (exact copy)
# Beta sampling uses _sample_dirichlet which isn't implemented for MPS, so sample on CPU
alpha_t = torch.tensor(alpha, dtype=torch.float32)
beta_t = torch.tensor(beta, dtype=torch.float32)
dist = torch.distributions.Beta(alpha_t, beta_t)
return dist.sample((bsize,)).to(device)
class EO1VisionActionProjector(torch.nn.Sequential):
"""This block implements the multi-layer perceptron (MLP) module."""
@@ -267,21 +216,17 @@ class EO1VisionFlowMatchingModel(nn.Module):
return func(*args, **kwargs)
def sample_noise(self, shape, device):
noise = torch.normal(
mean=0.0,
std=1.0,
size=shape,
dtype=torch.float32,
device=device,
)
return noise
return sample_noise(shape, device)
def sample_time(self, bsize, device):
time_beta = sample_beta(
self.config.time_sampling_beta_alpha, self.config.time_sampling_beta_beta, bsize, device
return sample_time_beta(
bsize,
device,
alpha=self.config.time_sampling_beta_alpha,
beta=self.config.time_sampling_beta_beta,
scale=self.config.time_sampling_scale,
offset=self.config.time_sampling_offset,
)
time = time_beta * self.config.time_sampling_scale + self.config.time_sampling_offset
return time.to(dtype=torch.float32, device=device)
def get_placeholder_mask(
self,
@@ -587,18 +532,11 @@ class EO1VisionFlowMatchingModel(nn.Module):
(batch_size, chunk_size, self.config.max_action_dim),
device,
).to(dtype=self.action_in_proj.weight.dtype)
dt = -1.0 / self.config.num_denoise_steps
past_key_values = outputs.past_key_values
# 3. Denoise only the action chunk while keeping the prefix cache invariant.
for step in range(self.config.num_denoise_steps):
time = torch.full(
(batch_size,),
1.0 + step * dt,
device=device,
dtype=torch.float32,
)
action_time_embs = self.embed_suffix(time, x_t)
def denoise_fn(input_x_t, current_timestep):
action_time_embs = self.embed_suffix(current_timestep, input_x_t)
inputs_embeds[:, act_slice] = action_time_embs.to(inputs_embeds.dtype)
# Keep the prefix KV cache invariant across denoising steps.
@@ -615,7 +553,7 @@ class EO1VisionFlowMatchingModel(nn.Module):
hidden_states = outputs.last_hidden_state[:, :chunk_size]
hidden_states = hidden_states.to(dtype=self.action_out_proj.dtype)
v_t = self.action_out_proj(hidden_states)
return v_t.reshape(input_x_t.shape).to(input_x_t.dtype)
x_t += dt * v_t.reshape(x_t.shape)
x_t = euler_integrate(denoise_fn, x_t, self.config.num_denoise_steps)
return x_t
+12 -37
View File
@@ -23,24 +23,16 @@ import torch
from lerobot.configs.types import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.processor import (
AddBatchDimensionProcessorStep,
ComplementaryDataProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
PolicyAction,
PolicyProcessorPipeline,
ProcessorStep,
ProcessorStepRegistry,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
make_default_policy_processor_steps,
make_policy_processor_pipelines,
)
from lerobot.processor.converters import policy_action_to_transition, transition_to_policy_action
from lerobot.types import TransitionKey
from lerobot.utils.constants import (
OBS_STATE,
POLICY_POSTPROCESSOR_DEFAULT_NAME,
POLICY_PREPROCESSOR_DEFAULT_NAME,
)
from lerobot.utils.constants import OBS_STATE
from lerobot.utils.import_utils import _transformers_available, require_package
from .configuration_eo1 import EO1Config
@@ -242,14 +234,12 @@ def make_eo1_pre_post_processors(
]:
"""Build pre/post processor pipelines for EO1."""
steps = make_default_policy_processor_steps(config, dataset_stats)
input_steps: list[ProcessorStep] = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=dataset_stats,
),
steps.rename_observations,
steps.add_batch_dim,
steps.normalize,
EO1ConversationTemplateStep(input_features=config.input_features, chunk_size=config.chunk_size),
EO1QwenProcessorStep(
processor_name=config.vlm_base,
@@ -257,27 +247,12 @@ def make_eo1_pre_post_processors(
image_max_pixels=config.image_max_pixels,
use_fast_processor=config.use_fast_processor,
),
DeviceProcessorStep(device=config.device),
steps.to_device,
]
output_steps: list[ProcessorStep] = [
UnnormalizerProcessorStep(
features=config.output_features,
norm_map=config.normalization_mapping,
stats=dataset_stats,
),
DeviceProcessorStep(device="cpu"),
steps.unnormalize,
steps.to_cpu,
]
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
return make_policy_processor_pipelines(input_steps=input_steps, output_steps=output_steps)
+1
View File
@@ -0,0 +1 @@
../../../../docs/source/policy_evo1_README.md
+19
View File
@@ -0,0 +1,19 @@
# 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.
from .configuration_evo1 import Evo1Config
from .modeling_evo1 import Evo1Policy
from .processor_evo1 import make_evo1_pre_post_processors
__all__ = ["Evo1Config", "Evo1Policy", "make_evo1_pre_post_processors"]
@@ -0,0 +1,252 @@
# 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.
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature
from lerobot.optim.optimizers import AdamWConfig
from lerobot.optim.schedulers import CosineAnnealingWithWarmupSchedulerConfig
from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE
from ..rtc.configuration_rtc import RTCConfig
logger = logging.getLogger(__name__)
@PreTrainedConfig.register_subclass("evo1")
@dataclass
class Evo1Config(PreTrainedConfig):
training_stage: str = "stage1"
# When True and the policy runs on CUDA, EVO1 wraps its own forward passes (training and
# inference) in a bfloat16 autocast block, so its numerics do not depend on the dtype of any
# outer autocast context opened by lerobot-train/lerobot-eval.
use_amp: bool = True
n_obs_steps: int = 1
chunk_size: int = 50
n_action_steps: int = 50
max_state_dim: int = 24
max_action_dim: int = 24
max_views: int = 3
image_resolution: tuple[int, int] = (448, 448)
empty_cameras: int = 0
postprocess_action_dim: int | None = None
binarize_gripper: bool = False
gripper_index: int = 6
gripper_threshold: float = 0.5
gripper_below_threshold_value: float = 1.0
gripper_above_threshold_value: float = -1.0
normalization_mapping: dict[str, NormalizationMode] = field(
default_factory=lambda: {
"VISUAL": NormalizationMode.IDENTITY,
"STATE": NormalizationMode.MIN_MAX,
"ACTION": NormalizationMode.MIN_MAX,
}
)
vlm_model_name: str = "OpenGVLab/InternVL3-1B-hf"
vlm_num_layers: int | None = 14
vlm_dtype: str = "bfloat16"
# Max token length for tokenizing the (image placeholders + instruction) prompt. Prompts longer
# than this are right-truncated, so raise it for tasks with long language instructions or many views.
max_text_length: int = 1024
use_flash_attn: bool = True
action_head: str = "flowmatching"
embed_dim: int = 896
hidden_dim: int = 1024
state_hidden_dim: int = 1024
num_heads: int = 8
num_layers: int = 8
dropout: float = 0.0
num_inference_timesteps: int = 32
num_categories: int = 1
# When True, the action head is conditioned on a single pooled VL token (the last non-padding
# token of the causal decoder) instead of the full fused token sequence.
return_cls_only: bool = False
enable_gradient_checkpointing: bool = True
gradient_checkpointing_use_reentrant: bool = False
finetune_vlm: bool | None = None
finetune_language_model: bool | None = None
finetune_vision_model: bool | None = None
finetune_action_head: bool | None = None
# Reapply stage defaults after loading checkpoint configs so stage2 cannot
# accidentally inherit the frozen VLM flags stored by a stage1 checkpoint.
apply_training_stage_defaults: bool = True
task_field: str = "task"
embodiment_id_field: str | None = None
default_embodiment_id: int = 0
# Real-Time Chunking guidance for asynchronous inference (lerobot-rollout --inference.type=rtc
# sets this and calls init_rtc_processor()); None disables RTC.
rtc_config: RTCConfig | None = None
optimizer_lr: float = 1e-5
optimizer_betas: tuple[float, float] = (0.9, 0.999)
optimizer_eps: float = 1e-8
optimizer_weight_decay: float = 1e-5
optimizer_grad_clip_norm: float = 1.0
scheduler_warmup_steps: int = 300
def __post_init__(self):
super().__post_init__()
if self.training_stage not in {"stage1", "stage2"}:
raise ValueError(
f"Unsupported EVO1 training_stage '{self.training_stage}', expected 'stage1' or 'stage2'"
)
if self.apply_training_stage_defaults:
stage_defaults = {
"stage1": {
"finetune_vlm": False,
"finetune_language_model": False,
"finetune_vision_model": False,
"finetune_action_head": True,
},
"stage2": {
"finetune_vlm": True,
"finetune_language_model": True,
"finetune_vision_model": True,
"finetune_action_head": True,
},
}[self.training_stage]
for flag_name, default_value in stage_defaults.items():
current_value = getattr(self, flag_name)
if current_value is not None and current_value != default_value:
logger.warning(
"EVO1 %s=%s is overridden by training_stage=%s default %s. "
"Set apply_training_stage_defaults=false to keep explicit finetuning flags.",
flag_name,
current_value,
self.training_stage,
default_value,
)
setattr(self, flag_name, default_value)
elif self.training_stage == "stage1":
if self.finetune_vlm is None:
self.finetune_vlm = False
if self.finetune_language_model is None:
self.finetune_language_model = False
if self.finetune_vision_model is None:
self.finetune_vision_model = False
if self.finetune_action_head is None:
self.finetune_action_head = True
elif self.training_stage == "stage2":
has_explicit_branch_flags = any(
flag is not None for flag in (self.finetune_language_model, self.finetune_vision_model)
)
if not has_explicit_branch_flags:
# An explicit finetune_vlm decides both branches; otherwise stage2 defaults to a
# full-VLM finetune.
vlm_finetune = self.finetune_vlm if self.finetune_vlm is not None else True
self.finetune_vlm = vlm_finetune
self.finetune_language_model = vlm_finetune
self.finetune_vision_model = vlm_finetune
elif self.finetune_vlm is None:
self.finetune_vlm = bool(self.finetune_language_model or self.finetune_vision_model)
if self.finetune_action_head is None:
self.finetune_action_head = True
if self.finetune_vlm is None:
self.finetune_vlm = False
if self.finetune_language_model is None:
self.finetune_language_model = False
if self.finetune_vision_model is None:
self.finetune_vision_model = False
if self.finetune_action_head is None:
self.finetune_action_head = False
branch_vlm = self.finetune_language_model or self.finetune_vision_model
if self.finetune_vlm != branch_vlm:
raise ValueError(
"Inconsistent EVO1 finetune config: "
f"finetune_vlm={self.finetune_vlm} but "
f"(finetune_language_model or finetune_vision_model)={branch_vlm}. "
"When branch-level flags are used, finetune_vlm must match their effective union."
)
if self.n_action_steps > self.chunk_size:
raise ValueError(
f"n_action_steps ({self.n_action_steps}) must be <= chunk_size ({self.chunk_size})"
)
if len(self.image_resolution) != 2 or self.image_resolution[0] != self.image_resolution[1]:
raise ValueError(
"EVO1 currently expects a square image_resolution because InternVL3 preprocessing "
f"uses a scalar image_size, got {self.image_resolution}."
)
if not 0 <= self.default_embodiment_id < self.num_categories:
raise ValueError(
f"default_embodiment_id ({self.default_embodiment_id}) must be in "
f"[0, num_categories={self.num_categories})"
)
def validate_features(self) -> None:
if self.input_features is None:
self.input_features = {}
if self.output_features is None:
self.output_features = {}
for i in range(self.empty_cameras):
key = OBS_IMAGES + f".empty_camera_{i}"
if key not in self.input_features:
self.input_features[key] = PolicyFeature(
type=FeatureType.VISUAL,
shape=(3, *self.image_resolution),
)
if OBS_STATE not in self.input_features:
self.input_features[OBS_STATE] = PolicyFeature(
type=FeatureType.STATE,
shape=(self.max_state_dim,),
)
if ACTION not in self.output_features:
self.output_features[ACTION] = PolicyFeature(
type=FeatureType.ACTION,
shape=(self.max_action_dim,),
)
def get_optimizer_preset(self) -> AdamWConfig:
return AdamWConfig(
lr=self.optimizer_lr,
betas=self.optimizer_betas,
eps=self.optimizer_eps,
weight_decay=self.optimizer_weight_decay,
grad_clip_norm=self.optimizer_grad_clip_norm,
)
def get_scheduler_preset(self):
return CosineAnnealingWithWarmupSchedulerConfig(
num_warmup_steps=self.scheduler_warmup_steps,
)
@property
def observation_delta_indices(self) -> list[int]:
return [0]
@property
def action_delta_indices(self) -> list[int]:
return list(range(self.chunk_size))
@property
def reward_delta_indices(self) -> None:
return None
+210
View File
@@ -0,0 +1,210 @@
# 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.
from __future__ import annotations
import torch
import torch.nn as nn
from .configuration_evo1 import Evo1Config
from .flow_matching import FlowmatchingActionHead
from .internvl3_embedder import InternVL3Embedder
class Evo1Model(nn.Module):
def __init__(self, config: Evo1Config, vlm_hub_kwargs: dict | None = None):
super().__init__()
self.config = config
self._device = config.device
self.return_cls_only = config.return_cls_only
# Set by Evo1Policy.init_rtc_processor() when config.rtc_config is provided.
self.rtc_processor = None
# Gradient checkpointing only pays off when the VLM is actually being trained; keep it off
# whenever every VLM branch is frozen so the frozen forward stays cheap.
tracks_vlm_gradients = bool(
config.finetune_vlm or config.finetune_language_model or config.finetune_vision_model
)
enable_gradient_checkpointing = config.enable_gradient_checkpointing and tracks_vlm_gradients
self.embedder = InternVL3Embedder(
model_name=config.vlm_model_name,
image_size=int(config.image_resolution[0]),
device=self._device,
num_language_layers=config.vlm_num_layers,
model_dtype=config.vlm_dtype,
use_flash_attn=config.use_flash_attn,
max_text_length=config.max_text_length,
enable_gradient_checkpointing=enable_gradient_checkpointing,
gradient_checkpointing_use_reentrant=config.gradient_checkpointing_use_reentrant,
hub_kwargs=vlm_hub_kwargs,
)
action_head_type = config.action_head.lower()
if action_head_type != "flowmatching":
raise NotImplementedError(f"Unknown action_head: {action_head_type}")
horizon = config.chunk_size
per_action_dim = config.max_action_dim
action_dim = horizon * per_action_dim
self.horizon = horizon
self.per_action_dim = per_action_dim
self.action_head = FlowmatchingActionHead(
embed_dim=config.embed_dim,
hidden_dim=config.hidden_dim,
action_dim=action_dim,
horizon=horizon,
per_action_dim=per_action_dim,
num_heads=config.num_heads,
num_layers=config.num_layers,
dropout=config.dropout,
num_inference_timesteps=config.num_inference_timesteps,
num_categories=config.num_categories,
state_dim=config.max_state_dim,
state_hidden_dim=config.state_hidden_dim,
).to(self._device)
def get_vl_embeddings(
self,
images: list[torch.Tensor],
image_mask: torch.Tensor,
prompt: str | list[str] | None = None,
return_cls_only: bool | None = None,
) -> tuple[torch.Tensor, torch.Tensor | None]:
"""Fused VL embeddings from per-camera image batches.
Args:
images: list of per-camera tensors, each shaped ``(B, C, H, W)`` with values in ``[0, 1]``.
image_mask: bool tensor ``(B, max_views)`` marking present views.
Returns:
``(embeddings, valid_mask)``: the fused tokens and the bool mask of attendable context
positions (None when a single pooled token is returned).
"""
if return_cls_only is None:
return_cls_only = self.return_cls_only
if not images:
raise ValueError("EVO1 expects at least one image per sample.")
batch_size = images[0].shape[0]
if prompt is None:
prompts = [""] * batch_size
elif isinstance(prompt, str):
prompts = [prompt] * batch_size
else:
prompts = [str(p) for p in prompt]
if len(prompts) != batch_size:
raise ValueError(
f"Prompt batch size {len(prompts)} does not match image batch size {batch_size}"
)
if image_mask.dim() == 1:
image_mask = image_mask.unsqueeze(0)
if image_mask.shape[0] != batch_size:
raise ValueError(
f"image_mask batch size {image_mask.shape[0]} does not match image batch size {batch_size}"
)
return self.embedder.get_fused_image_text_embedding_batched(
camera_images=images,
image_masks=image_mask,
text_prompts=prompts,
return_cls_only=return_cls_only,
)
def predict_action(
self,
fused_tokens: torch.Tensor,
state: torch.Tensor,
actions_gt: torch.Tensor | None = None,
action_mask: torch.Tensor | None = None,
embodiment_ids: torch.Tensor | None = None,
context_mask: torch.Tensor | None = None,
inference_delay: int | None = None,
prev_chunk_left_over: torch.Tensor | None = None,
execution_horizon: int | None = None,
):
if actions_gt is None:
return self.action_head.get_action(
fused_tokens,
state=state,
action_mask=action_mask,
embodiment_id=embodiment_ids,
context_mask=context_mask,
inference_delay=inference_delay,
prev_chunk_left_over=prev_chunk_left_over,
execution_horizon=execution_horizon,
rtc_processor=self.rtc_processor,
)
return self.action_head(
fused_tokens,
state=state,
actions_gt=actions_gt,
action_mask=action_mask,
embodiment_id=embodiment_ids,
context_mask=context_mask,
)
def forward(
self,
fused_tokens: torch.Tensor,
state: torch.Tensor | None = None,
actions_gt: torch.Tensor | None = None,
action_mask: torch.Tensor | None = None,
embodiment_ids: torch.Tensor | None = None,
context_mask: torch.Tensor | None = None,
inference_delay: int | None = None,
prev_chunk_left_over: torch.Tensor | None = None,
execution_horizon: int | None = None,
):
return self.predict_action(
fused_tokens,
state,
actions_gt,
action_mask,
embodiment_ids,
context_mask,
inference_delay,
prev_chunk_left_over,
execution_horizon,
)
def _set_module_trainable(self, module: nn.Module, trainable: bool):
for param in module.parameters():
param.requires_grad = trainable
def _vlm_submodule(self, name: str) -> nn.Module:
module = getattr(self.embedder.model, name, None)
if not isinstance(module, nn.Module):
raise AttributeError(
f"InternVL model {type(self.embedder.model).__name__} has no '{name}' submodule; "
"the native HF InternVL layout (language_model / vision_tower / "
"multi_modal_projector) is required to apply the EVO1 finetune flags."
)
return module
def set_finetune_flags(self):
# __post_init__ resolves every finetune flag to a concrete boolean, so branch-level flags
# are authoritative here. Freeze everything first, then re-enable the requested branches.
self._set_module_trainable(self.embedder, False)
self._set_module_trainable(
self._vlm_submodule("language_model"), bool(self.config.finetune_language_model)
)
finetune_vision = bool(self.config.finetune_vision_model)
self._set_module_trainable(self._vlm_submodule("vision_tower"), finetune_vision)
self._set_module_trainable(self._vlm_submodule("multi_modal_projector"), finetune_vision)
if not self.config.finetune_action_head:
self._set_module_trainable(self.action_head, False)
+483
View File
@@ -0,0 +1,483 @@
# 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.
from __future__ import annotations
import logging
import math
import torch
import torch.nn as nn
logger = logging.getLogger(__name__)
class SinusoidalPositionalEncoding(nn.Module):
def __init__(self, dim: int, max_len: int = 1000):
super().__init__()
pe = torch.zeros(max_len, dim)
position = torch.arange(0, max_len).unsqueeze(1)
div_term = torch.exp(torch.arange(0, dim, 2) * -(math.log(10000.0) / dim))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0)
self.register_buffer("pe", pe)
def forward(self, seq_len: int):
if seq_len > self.pe.size(1):
self._extend_pe(seq_len)
return self.pe[:, :seq_len, :]
def _extend_pe(self, new_max_len):
old_max_len, dim = self.pe.size(1), self.pe.size(2)
if new_max_len <= old_max_len:
return
extra_positions = torch.arange(old_max_len, new_max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, dim, 2, dtype=torch.float) * -(math.log(10000.0) / dim))
extra_pe = torch.zeros(new_max_len - old_max_len, dim)
extra_pe[:, 0::2] = torch.sin(extra_positions * div_term)
extra_pe[:, 1::2] = torch.cos(extra_positions * div_term)
extra_pe = extra_pe.unsqueeze(0)
new_pe = torch.cat([self.pe, extra_pe.to(self.pe.device)], dim=1)
self.pe = new_pe
class CategorySpecificLinear(nn.Module):
def __init__(self, in_dim: int, out_dim: int, num_categories: int = 1):
super().__init__()
self.num_categories = num_categories
if num_categories <= 1:
self.linear = nn.Linear(in_dim, out_dim)
else:
self.weight = nn.Parameter(torch.empty(num_categories, in_dim, out_dim))
self.bias = nn.Parameter(torch.zeros(num_categories, out_dim))
# Initialize each per-category (in_dim, out_dim) matrix separately: xavier on the full
# 3D tensor would compute fan_in = in_dim * out_dim and badly under-scale the weights.
for category in range(num_categories):
nn.init.xavier_uniform_(self.weight[category])
def forward(self, x: torch.Tensor, category_id: torch.LongTensor):
if self.num_categories <= 1:
if x.dtype != self.linear.weight.dtype:
x = x.to(dtype=self.linear.weight.dtype)
return self.linear(x)
if x.dtype != self.weight.dtype:
x = x.to(dtype=self.weight.dtype)
orig_shape = x.shape
x_flat = x.reshape(-1, orig_shape[-1])
if category_id.dim() == 0:
cid = category_id.item()
out = x_flat @ self.weight[cid] + self.bias[cid]
else:
category_id = category_id.reshape(-1)
if category_id.numel() != x_flat.size(0):
raise ValueError(
f"category_id length {category_id.numel()} does not match flattened batch {x_flat.size(0)}"
)
weight_selected = self.weight[category_id]
bias_selected = self.bias[category_id]
out = torch.bmm(x_flat.unsqueeze(1), weight_selected).squeeze(1) + bias_selected
out_shape = orig_shape[:-1] + (out.shape[-1],)
return out.view(out_shape)
class CategorySpecificMLP(nn.Module):
def __init__(self, input_dim: int, hidden_dim: int, output_dim: int, num_categories: int = 1):
super().__init__()
self.fc1 = CategorySpecificLinear(input_dim, hidden_dim, num_categories)
self.fc2 = CategorySpecificLinear(hidden_dim, output_dim, num_categories)
self.activation = nn.ReLU(inplace=True)
def forward(self, x: torch.Tensor, category_id: torch.LongTensor):
out = self.activation(self.fc1(x, category_id))
out = self.fc2(out, category_id)
return out
class MultiEmbodimentActionEncoder(nn.Module):
def __init__(
self, action_dim: int, embed_dim: int, hidden_dim: int, horizon: int, num_categories: int = 1
):
super().__init__()
self.horizon = horizon
self.embed_dim = embed_dim
self.num_categories = num_categories
self.W1 = CategorySpecificLinear(action_dim, hidden_dim, num_categories)
self.W2 = CategorySpecificLinear(hidden_dim, hidden_dim, num_categories)
self.W3 = CategorySpecificLinear(hidden_dim, embed_dim, num_categories)
self.pos_encoding = SinusoidalPositionalEncoding(hidden_dim, max_len=horizon)
self.activation = nn.ReLU(inplace=True)
def forward(self, action_seq: torch.Tensor, category_id: torch.LongTensor):
batch_size, horizon, action_dim = action_seq.shape
if self.horizon != horizon:
raise ValueError(
f"Action sequence length must match horizon: got {horizon}, expected {self.horizon}."
)
x = action_seq.reshape(batch_size * horizon, action_dim)
if category_id.dim() == 0:
cat_ids = category_id.expand(horizon * batch_size)
else:
cat_ids = category_id.unsqueeze(1).expand(batch_size, horizon).reshape(batch_size * horizon)
out = self.activation(self.W1(x, cat_ids))
pos_enc = self.pos_encoding(horizon).to(device=out.device, dtype=out.dtype)
out = out.view(batch_size, horizon, -1) + pos_enc
out = out.view(batch_size * horizon, -1)
out = self.activation(self.W2(out, cat_ids))
out = self.W3(out, cat_ids)
return out.view(batch_size, horizon, self.embed_dim)
class BasicTransformerBlock(nn.Module):
def __init__(self, embed_dim: int, num_heads: int, hidden_dim: int, dropout: float = 0.0):
super().__init__()
self.attn = nn.MultiheadAttention(embed_dim, num_heads, dropout=dropout, batch_first=True)
self.norm1 = nn.LayerNorm(embed_dim)
self.norm2 = nn.LayerNorm(embed_dim)
self.ff = nn.Sequential(nn.Linear(embed_dim, hidden_dim), nn.GELU(), nn.Linear(hidden_dim, embed_dim))
def forward(
self,
action_tokens: torch.Tensor,
context_tokens: torch.Tensor,
time_emb: torch.Tensor,
context_key_padding_mask: torch.Tensor | None = None,
):
x = self.norm1(action_tokens)
attn_out, _ = self.attn(x, context_tokens, context_tokens, key_padding_mask=context_key_padding_mask)
x = action_tokens + attn_out
x2 = self.norm2(x)
if time_emb is not None:
x2 = x2 + time_emb.unsqueeze(1)
ff_out = self.ff(x2)
return x + ff_out
class FlowmatchingActionHead(nn.Module):
def __init__(
self,
embed_dim: int = 896,
hidden_dim: int = 1024,
action_dim: int = 16 * 7,
horizon: int = 16,
per_action_dim: int = 7,
num_heads: int = 8,
num_layers: int = 8,
dropout: float = 0.0,
num_inference_timesteps: int = 20,
num_categories: int = 1,
state_dim: int | None = None,
state_hidden_dim: int | None = None,
):
super().__init__()
logger.info("FlowmatchingActionHead num_inference_timesteps=%s", num_inference_timesteps)
self.embed_dim = embed_dim
self.horizon = horizon
self.per_action_dim = per_action_dim
self.action_dim = action_dim
self.num_inference_timesteps = num_inference_timesteps
self.num_categories = num_categories
self.time_pos_enc = SinusoidalPositionalEncoding(embed_dim, max_len=1000)
self.transformer_blocks = nn.ModuleList(
[
BasicTransformerBlock(
embed_dim=embed_dim,
num_heads=num_heads,
hidden_dim=embed_dim * 4,
dropout=dropout,
)
for _ in range(num_layers)
]
)
self.norm_out = nn.LayerNorm(embed_dim)
self.seq_pool_proj = nn.Linear(self.horizon * self.embed_dim, self.embed_dim)
self.mlp_head = CategorySpecificMLP(
input_dim=embed_dim,
hidden_dim=hidden_dim,
output_dim=action_dim,
num_categories=num_categories,
)
self.state_encoder = None
if state_dim is not None:
state_hidden = state_hidden_dim if state_hidden_dim is not None else embed_dim
self.state_encoder = CategorySpecificMLP(
input_dim=state_dim,
hidden_dim=state_hidden,
output_dim=embed_dim,
num_categories=num_categories,
)
if horizon > 1:
self.action_encoder = MultiEmbodimentActionEncoder(
action_dim=self.per_action_dim,
embed_dim=embed_dim,
hidden_dim=embed_dim,
horizon=horizon,
num_categories=num_categories,
)
self.single_action_proj = None
else:
self.action_encoder = None
self.single_action_proj = nn.Linear(self.per_action_dim, self.embed_dim)
def _project_actions(self, action_seq: torch.Tensor, embodiment_id: torch.LongTensor) -> torch.Tensor:
if self.horizon > 1 and self.action_encoder is not None:
return self.action_encoder(action_seq, embodiment_id)
if self.single_action_proj is None:
raise RuntimeError("single_action_proj is not initialized for horizon <= 1.")
return self.single_action_proj(action_seq)
def _expand_action_mask(
self,
action_mask: torch.Tensor,
batch_size: int,
per_action_dim: int,
device: torch.device,
dtype: torch.dtype,
) -> torch.Tensor:
if action_mask is None:
raise ValueError("action_mask must be provided for flow matching inference.")
if action_mask.dim() == 2:
expected_last_dim = self.horizon * per_action_dim
if action_mask.shape == (batch_size, expected_last_dim):
expanded_mask = action_mask.reshape(batch_size, self.horizon, per_action_dim)
elif action_mask.shape == (batch_size, per_action_dim):
expanded_mask = action_mask.unsqueeze(1).expand(batch_size, self.horizon, per_action_dim)
else:
raise ValueError(
f"Expected action_mask shape {(batch_size, expected_last_dim)} or "
f"{(batch_size, per_action_dim)}, got {tuple(action_mask.shape)}"
)
elif action_mask.dim() == 3:
expected_shape = (batch_size, self.horizon, per_action_dim)
if tuple(action_mask.shape) != expected_shape:
raise ValueError(
f"Expected action_mask shape {expected_shape}, got {tuple(action_mask.shape)}"
)
expanded_mask = action_mask
else:
raise ValueError(f"Unsupported action_mask rank: {action_mask.dim()}")
return expanded_mask.to(device=device, dtype=dtype)
def _prepare_context(
self,
fused_tokens: torch.Tensor,
state: torch.Tensor | None,
embodiment_id: torch.LongTensor | None,
context_mask: torch.Tensor | None,
) -> tuple[torch.Tensor, torch.Tensor | None, torch.LongTensor]:
"""Normalize the VL context and embodiment ids shared by training and inference.
Returns the context tokens ``(B, S, E)``, a key_padding_mask for
``nn.MultiheadAttention`` (True = ignore) or None, and the resolved embodiment ids.
"""
batch_size = fused_tokens.size(0)
device = fused_tokens.device
if embodiment_id is None:
embodiment_id = torch.zeros(batch_size, dtype=torch.long, device=device)
elif self.num_categories > 1 and (
int(embodiment_id.min()) < 0 or int(embodiment_id.max()) >= self.num_categories
):
raise ValueError(
f"embodiment ids must be in [0, num_categories={self.num_categories}), "
f"got range [{int(embodiment_id.min())}, {int(embodiment_id.max())}]"
)
context_tokens = fused_tokens
if context_tokens.dim() == 2:
# A single pooled VL token (return_cls_only): give it a sequence dim of 1.
context_tokens = context_tokens.unsqueeze(1)
context_mask = None
if state is not None and self.state_encoder is not None:
state_emb = self.state_encoder(state, embodiment_id).unsqueeze(1)
context_tokens = torch.cat([context_tokens, state_emb], dim=1)
if context_mask is not None:
state_valid = torch.ones(batch_size, 1, dtype=torch.bool, device=context_mask.device)
context_mask = torch.cat([context_mask.to(torch.bool), state_valid], dim=1)
key_padding_mask = None if context_mask is None else ~context_mask.to(torch.bool)
return context_tokens, key_padding_mask, embodiment_id
def forward(
self,
fused_tokens: torch.Tensor,
state: torch.Tensor = None,
actions_gt: torch.Tensor = None,
embodiment_id: torch.LongTensor = None,
action_mask: torch.Tensor = None,
context_mask: torch.Tensor = None,
):
if actions_gt is None:
return self.get_action(
fused_tokens,
state=state,
embodiment_id=embodiment_id,
action_mask=action_mask,
context_mask=context_mask,
)
batch_size = fused_tokens.size(0)
device = fused_tokens.device
context_tokens, key_padding_mask, embodiment_id = self._prepare_context(
fused_tokens, state, embodiment_id, context_mask
)
t = (
torch.distributions.Beta(2, 2)
.sample((batch_size,))
.clamp(0.02, 0.98)
.to(device)
.to(dtype=self.dtype)
)
time_index = (t * 999).long().clamp_(0, 999)
time_emb = self.time_pos_enc(1000)[:, time_index, :].squeeze(0).to(dtype=context_tokens.dtype)
actions_gt_seq = actions_gt
noise = torch.rand_like(actions_gt) * 2 - 1
if action_mask is not None:
action_mask = action_mask.to(dtype=noise.dtype, device=noise.device)
if action_mask.shape != noise.shape:
raise ValueError(f"action_mask shape {action_mask.shape} != noise shape {noise.shape}")
actions_gt_seq = actions_gt_seq * action_mask
noise = noise * action_mask
if self.horizon > 1:
noise_seq = noise.view(batch_size, self.horizon, self.per_action_dim)
else:
noise_seq = noise if noise.dim() == 3 else noise.unsqueeze(1)
t_broadcast = t.view(batch_size, 1, 1)
action_intermediate_seq = (1 - t_broadcast) * noise_seq + t_broadcast * actions_gt_seq
action_tokens = self._project_actions(action_intermediate_seq, embodiment_id)
target_dtype = self.dtype
action_tokens = action_tokens.to(dtype=target_dtype)
context_tokens = context_tokens.to(dtype=target_dtype)
time_emb = time_emb.to(dtype=target_dtype)
x = action_tokens
for block in self.transformer_blocks:
x = block(x, context_tokens, time_emb, key_padding_mask)
x = self.norm_out(x)
if self.horizon > 1:
x_flat = x.reshape(batch_size, -1)
x_pooled = self.seq_pool_proj(x_flat)
else:
x_pooled = x.squeeze(1)
pred_velocity = self.mlp_head(x_pooled, embodiment_id)
return pred_velocity, noise
def get_action(
self,
fused_tokens: torch.Tensor,
state: torch.Tensor = None,
embodiment_id: torch.LongTensor = None,
action_mask: torch.Tensor = None,
context_mask: torch.Tensor = None,
inference_delay: int | None = None,
prev_chunk_left_over: torch.Tensor | None = None,
execution_horizon: int | None = None,
rtc_processor=None,
):
batch_size = fused_tokens.size(0)
device = fused_tokens.device
context_tokens, key_padding_mask, embodiment_id = self._prepare_context(
fused_tokens, state, embodiment_id, context_mask
)
action_dim_total = self.action_dim
per_action_dim = self.per_action_dim
action = torch.rand(batch_size, action_dim_total, device=device, dtype=context_tokens.dtype) * 2 - 1
action_seq = action.view(batch_size, self.horizon, per_action_dim)
action_mask = self._expand_action_mask(
action_mask,
batch_size=batch_size,
per_action_dim=per_action_dim,
device=action_seq.device,
dtype=action_seq.dtype,
)
action_seq = action_seq * action_mask
target_dtype = self.dtype
context_tokens = context_tokens.to(dtype=target_dtype)
num_steps = int(self.num_inference_timesteps)
if num_steps <= 0:
raise ValueError(f"num_inference_timesteps must be positive, got {num_steps}")
dt = 1.0 / num_steps
use_rtc = rtc_processor is not None and (
inference_delay is not None or prev_chunk_left_over is not None
)
def predict_velocity(seq: torch.Tensor, step_time_emb: torch.Tensor) -> torch.Tensor:
"""Predict the masked flow velocity (x1 - x0 convention) for one integration step."""
seq = seq * action_mask
action_tokens = self._project_actions(seq, embodiment_id).to(dtype=target_dtype)
x = action_tokens
for block in self.transformer_blocks:
x = block(x, context_tokens, step_time_emb, key_padding_mask)
x = self.norm_out(x)
x_pooled = self.seq_pool_proj(x.reshape(batch_size, -1)) if self.horizon > 1 else x.squeeze(1)
pred = self.mlp_head(x_pooled, embodiment_id)
return pred.view(batch_size, self.horizon, per_action_dim) * action_mask
for i in range(num_steps):
t = i / num_steps
time_index = min(int(t * 999), 999)
time_emb = self.time_pos_enc(1000)[:, time_index, :].to(device).squeeze(0).to(dtype=target_dtype)
time_emb = time_emb.unsqueeze(0).repeat(batch_size, 1)
if use_rtc:
# RTCProcessor assumes the pi0 flow convention: its `time` runs 1 -> 0 and the
# clean-action estimate is x1 = x_t - time * v. EVO1 integrates t: 0 -> 1 with
# velocity v = x1 - x0 (so x1 = x_t + (1 - t) * v); passing time = 1 - t and
# flipping the velocity sign in both directions maps one convention onto the other.
guided = rtc_processor.denoise_step(
x_t=action_seq,
prev_chunk_left_over=prev_chunk_left_over,
inference_delay=inference_delay,
time=1.0 - t,
original_denoise_step_partial=lambda seq, emb=time_emb: -predict_velocity(seq, emb),
execution_horizon=execution_horizon,
)
velocity = -guided
else:
velocity = predict_velocity(action_seq, time_emb)
action_seq = action_seq + dt * velocity
action_seq = action_seq * action_mask
return action_seq.reshape(batch_size, -1)
@property
def device(self):
return next(self.parameters()).device
@property
def dtype(self):
return next(self.parameters()).dtype
@@ -0,0 +1,367 @@
# 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.
from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import TYPE_CHECKING
import torch
import torch.nn as nn
import torchvision.transforms.functional as tvf
from torchvision.transforms.functional import InterpolationMode
from lerobot.utils.import_utils import _transformers_available, require_package
if TYPE_CHECKING or _transformers_available:
from transformers import AutoModel, AutoTokenizer
from transformers.utils import is_flash_attn_2_available
else:
AutoModel = None
AutoTokenizer = None
is_flash_attn_2_available = None
IMAGENET_MEAN = (0.485, 0.456, 0.406)
IMAGENET_STD = (0.229, 0.224, 0.225)
IMG_CONTEXT_TOKEN = "<IMG_CONTEXT>" # nosec B105
IMG_START_TOKEN = "<img>" # nosec B105
IMG_END_TOKEN = "</img>" # nosec B105
logger = logging.getLogger(__name__)
def _batched_resize_01(images: torch.Tensor, image_size: int) -> torch.Tensor:
"""Resize a batch of ``[0, 1]`` images to ``(image_size, image_size)`` on-device.
Numerically mirrors InternVL3's reference PIL preprocessing
(``to_pil_image`` -> ``Image.resize`` -> ``to_tensor``): the float input is quantized to uint8
exactly as ``to_pil_image`` does, then resized with bicubic interpolation and antialiasing,
which matches PIL's default resampler. Matching the reference pixel-for-pixel keeps the policy
interchangeable with checkpoints produced by the upstream EVO1 preprocessing.
Args:
images: float tensor of shape ``(N, C, H, W)`` with values in ``[0, 1]``.
Returns:
float32 tensor of shape ``(N, C, image_size, image_size)`` with values in ``[0, 1]``.
"""
# to_pil_image() quantizes float [0, 1] to uint8 (x * 255, truncated); replicate that so the
# bicubic resample sees the same integer pixels PIL would.
pixels_u8 = (images * 255.0).clamp(0, 255).to(torch.uint8)
resized = tvf.resize(
pixels_u8, [image_size, image_size], interpolation=InterpolationMode.BICUBIC, antialias=True
)
return resized.to(torch.float32) / 255.0
def _batched_pixel_values(
camera_images: Sequence[torch.Tensor],
max_views: int,
image_size: int,
mean: torch.Tensor,
std: torch.Tensor,
dtype: torch.dtype,
device: torch.device | str,
) -> torch.Tensor:
"""Build InternVL3 ``pixel_values`` from per-camera ``[0, 1]`` image batches without leaving the device.
Each image is resized, converted to ``dtype``, and ImageNet-normalized (a single tile per
image), batched across the whole minibatch. Absent views (fewer cameras than ``max_views``)
are filled with zero images; their placeholder tokens are masked out of attention downstream
via ``_mask_absent_image_tokens``.
Returns:
``pixel_values`` of shape ``(B * max_views, C, image_size, image_size)``, ordered row-major
over ``(sample, view)`` to line up with the per-view image placeholders in the prompt.
"""
resized: list[torch.Tensor] = []
for image in camera_images:
resized.append(_batched_resize_01(image.to(device=device), image_size).to(dtype))
batch_size = resized[0].shape[0]
channels = resized[0].shape[1]
while len(resized) < max_views:
resized.append(torch.zeros(batch_size, channels, image_size, image_size, dtype=dtype, device=device))
stacked = torch.stack(resized[:max_views], dim=1) # (B, V, C, H, W)
mean = mean.to(device=device, dtype=dtype).view(1, 1, -1, 1, 1)
std = std.to(device=device, dtype=dtype).view(1, 1, -1, 1, 1)
normalized = (stacked - mean) / std
return normalized.reshape(batch_size * max_views, channels, image_size, image_size)
class InternVL3Embedder(nn.Module):
"""Vision-language embedder using the native HF InternVL3 model (no trust_remote_code)."""
def __init__(
self,
model_name="OpenGVLab/InternVL3-1B-hf",
image_size=448,
device="cuda",
num_language_layers: int | None = 14,
model_dtype: str | torch.dtype = "bfloat16",
use_flash_attn: bool = True,
max_text_length: int = 1024,
enable_gradient_checkpointing: bool = True,
gradient_checkpointing_use_reentrant: bool = False,
hub_kwargs: dict | None = None,
):
super().__init__()
self._requested_device = device
self.image_size = image_size
self.num_language_layers = num_language_layers
self.max_text_length = max_text_length
self.enable_gradient_checkpointing = bool(enable_gradient_checkpointing)
self.gradient_checkpointing_use_reentrant = bool(gradient_checkpointing_use_reentrant)
hub_kwargs = hub_kwargs or {}
require_package("transformers", extra="evo1")
self.tokenizer = AutoTokenizer.from_pretrained(model_name, **hub_kwargs)
if isinstance(model_dtype, str):
try:
model_dtype = getattr(torch, model_dtype)
except AttributeError as exc:
raise ValueError(f"Unsupported EVO1 vlm_dtype '{model_dtype}'") from exc
self.model_dtype = model_dtype
attn_implementation = (
"flash_attention_2" if (use_flash_attn and is_flash_attn_2_available()) else "eager"
)
if use_flash_attn and attn_implementation == "eager":
logger.warning(
"Flash Attention 2 is unavailable on this runtime. Falling back to eager attention."
)
self.model = AutoModel.from_pretrained(
model_name,
torch_dtype=model_dtype,
attn_implementation=attn_implementation,
low_cpu_mem_usage=True,
**hub_kwargs,
).to(self._requested_device)
checkpoint_image_size = getattr(self.model.config.vision_config, "image_size", None)
if isinstance(checkpoint_image_size, (list, tuple)):
checkpoint_image_size = checkpoint_image_size[0]
if checkpoint_image_size is not None and int(checkpoint_image_size) != int(image_size):
raise ValueError(
f"EVO1 image_resolution ({image_size}) must match the InternVL checkpoint's native "
f"image size ({checkpoint_image_size}): the checkpoint's image_seq_length assumes "
"its native resolution, so other sizes would desync the image placeholder tokens "
"from the vision features."
)
self.num_image_token = self.model.config.image_seq_length
# Truncate language model to the requested number of layers
layers = self.model.language_model.layers
if self.num_language_layers is not None:
layers = layers[: self.num_language_layers]
self.model.language_model.layers = torch.nn.ModuleList(layers)
self._configure_memory_features()
self.img_context_token_id = self.tokenizer.convert_tokens_to_ids(IMG_CONTEXT_TOKEN)
def _configure_memory_features(self) -> None:
checkpoint_kwargs = {"use_reentrant": self.gradient_checkpointing_use_reentrant}
if not self.enable_gradient_checkpointing:
language_model = self.model.language_model
if hasattr(language_model, "gradient_checkpointing_disable"):
language_model.gradient_checkpointing_disable()
vision_tower = getattr(self.model, "vision_tower", None)
if vision_tower is not None and hasattr(vision_tower, "encoder"):
vision_tower.encoder.gradient_checkpointing = False
return
def _enable_ckpt(module: nn.Module | None) -> bool:
if module is None:
return False
if hasattr(module, "gradient_checkpointing_enable"):
try:
module.gradient_checkpointing_enable(gradient_checkpointing_kwargs=checkpoint_kwargs)
except TypeError:
module.gradient_checkpointing_enable()
return True
if hasattr(module, "gradient_checkpointing"):
module.gradient_checkpointing = True
return True
return False
enabled_any = _enable_ckpt(self.model)
vision_tower = getattr(self.model, "vision_tower", None)
if vision_tower is not None:
enabled_any = _enable_ckpt(vision_tower) or enabled_any
language_model = self.model.language_model
enabled_any = _enable_ckpt(language_model) or enabled_any
if hasattr(language_model, "config"):
language_model.config.use_cache = False
if hasattr(self.model, "config"):
self.model.config.use_cache = False
if hasattr(self.model, "enable_input_require_grads"):
self.model.enable_input_require_grads()
if enabled_any:
logger.info("Gradient checkpointing enabled for InternVL3 embedder.")
else:
logger.warning(
"Requested gradient checkpointing, but model does not expose checkpointing controls."
)
def _build_multimodal_prompts(
self,
batch_num_tiles_list: list[list[int]],
text_prompts: Sequence[str],
) -> list[str]:
prompts = []
for num_tiles_list, text_prompt in zip(batch_num_tiles_list, text_prompts, strict=True):
prompt_segments = []
for i, tile_count in enumerate(num_tiles_list):
token_count = self.num_image_token * tile_count
image_tokens = IMG_START_TOKEN + IMG_CONTEXT_TOKEN * token_count + IMG_END_TOKEN
prompt_segments.append(f"Image-{i + 1}: {image_tokens}\n")
prompts.append("".join(prompt_segments) + text_prompt.strip())
return prompts
def get_fused_image_text_embedding_batched(
self,
camera_images: Sequence[torch.Tensor],
image_masks: torch.Tensor,
text_prompts: Sequence[str],
return_cls_only: bool = True,
):
"""Fused VL embedding from per-camera ``[0, 1]`` image batches (no PIL, no host round-trip).
Args:
camera_images: list of per-camera tensors, each shaped ``(B, C, H, W)`` in ``[0, 1]``.
image_masks: bool tensor ``(B, max_views)`` marking present views.
Returns:
A ``(embeddings, valid_mask)`` tuple. With ``return_cls_only=False``, ``embeddings`` is
``(B, L, H)`` and ``valid_mask`` is a ``(B, L)`` bool tensor marking tokens downstream
attention may attend to (padding and absent-view tokens are False). With
``return_cls_only=True``, ``embeddings`` is the pooled ``(B, H)`` last-valid-token state
and ``valid_mask`` is None.
"""
max_views = int(image_masks.shape[1])
batch_size = int(image_masks.shape[0])
mean = torch.tensor(IMAGENET_MEAN, device=self.device, dtype=self.model_dtype)
std = torch.tensor(IMAGENET_STD, device=self.device, dtype=self.model_dtype)
pixel_values = _batched_pixel_values(
camera_images, max_views, self.image_size, mean, std, self.model_dtype, self.device
)
# InternVL3 preprocessing uses a single tile per image (max_num=1).
batch_num_tiles_list = [[1] * max_views for _ in range(batch_size)]
return self._forward_vlm(
pixel_values, batch_num_tiles_list, image_masks, text_prompts, return_cls_only
)
def _mask_absent_image_tokens(
self,
input_ids: torch.Tensor,
attention_mask: torch.Tensor,
image_masks: torch.Tensor,
batch_num_tiles_list: list[list[int]],
) -> torch.Tensor:
"""Zero attention over the image-context tokens of absent (zero-padded) views.
Fully vectorized: runs without any host<->device synchronization.
"""
# A single tile per image (max_num=1), so every image occupies the same number of
# context tokens.
tiles_per_image = (
batch_num_tiles_list[0][0] if batch_num_tiles_list and batch_num_tiles_list[0] else 1
)
tokens_per_image = self.num_image_token * tiles_per_image
image_masks = image_masks.to(device=input_ids.device).bool()
img_token_mask = input_ids == self.img_context_token_id # (B, L)
# keep[b, k] tells whether the k-th image-context token (ordered view0, view1, ...) survives.
per_token_keep = image_masks.repeat_interleave(tokens_per_image, dim=1) # (B, V * tokens_per_image)
# Rank each context token by its running position among the row's context tokens.
ctx_index = img_token_mask.to(torch.long).cumsum(dim=1) - 1
ctx_index = ctx_index.clamp(min=0, max=per_token_keep.shape[1] - 1)
keep_here = torch.gather(per_token_keep, 1, ctx_index) # (B, L)
drop = img_token_mask & ~keep_here
return attention_mask.masked_fill(drop, 0)
def _forward_vlm(
self,
pixel_values: torch.Tensor,
batch_num_tiles_list: list[list[int]],
image_masks: torch.Tensor,
text_prompts: Sequence[str],
return_cls_only: bool,
):
if pixel_values.shape[0] == 0:
logger.warning("InternVL3 received an empty image batch after preprocessing.")
hidden_size = getattr(self.model.config, "hidden_size", None)
if hidden_size is None:
hidden_size = getattr(self.model.config.text_config, "hidden_size", None)
if hidden_size is None:
raise RuntimeError("Unable to infer hidden size for empty InternVL3 batch.")
return torch.empty(0, hidden_size, device=self.device, dtype=torch.float32), None
prompts = self._build_multimodal_prompts(batch_num_tiles_list, text_prompts)
model_inputs = self.tokenizer(
list(prompts),
return_tensors="pt",
padding=True,
truncation=True,
max_length=self.max_text_length,
).to(self.device)
input_ids = model_inputs["input_ids"]
if input_ids.shape[1] >= self.max_text_length:
# Truncation cuts from the right, so text is dropped before image placeholders — but a
# large max_views * image_seq_length budget can still eat into them. Fail loudly instead
# of letting the VLM crash on a placeholder/vision-feature count mismatch.
expected_image_tokens = self.num_image_token * sum(batch_num_tiles_list[0])
image_token_counts = (input_ids == self.img_context_token_id).sum(dim=1)
if not bool((image_token_counts == expected_image_tokens).all()):
raise ValueError(
f"Prompt truncation at max_text_length={self.max_text_length} cut into the "
f"image placeholder tokens ({expected_image_tokens} expected per sample). "
"Increase max_text_length or reduce max_views."
)
attention_mask = self._mask_absent_image_tokens(
input_ids, model_inputs["attention_mask"], image_masks, batch_num_tiles_list
)
outputs = self.model(
input_ids=input_ids,
pixel_values=pixel_values,
attention_mask=attention_mask,
output_hidden_states=True,
return_dict=True,
)
fused_hidden = outputs.hidden_states[-1].to(torch.float32)
valid_mask = attention_mask.to(torch.bool)
if return_cls_only:
# Right-padded causal decoder: the last valid token is the only one that has attended
# to the full image + text prompt.
positions = torch.arange(valid_mask.shape[1], device=valid_mask.device)
last_valid = (valid_mask.long() * positions).argmax(dim=1)
batch_index = torch.arange(fused_hidden.shape[0], device=fused_hidden.device)
return fused_hidden[batch_index, last_valid], None
return fused_hidden, valid_mask
@property
def device(self) -> torch.device:
return next(self.model.parameters()).device
+532
View File
@@ -0,0 +1,532 @@
# 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.
from __future__ import annotations
import builtins
from collections import deque
from contextlib import nullcontext
from pathlib import Path
from typing import TypedDict, Unpack
import torch
from torch import Tensor
from lerobot.configs.policies import PreTrainedConfig
from lerobot.policies.pretrained import PreTrainedPolicy, T
from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE
from ..rtc.modeling_rtc import RTCProcessor
from .configuration_evo1 import Evo1Config
from .evo1_model import Evo1Model
class ActionSelectKwargs(TypedDict, total=False):
inference_delay: int | None
prev_chunk_left_over: Tensor | None
execution_horizon: int | None
class Evo1Policy(PreTrainedPolicy):
config_class = Evo1Config
name = "evo1"
def __init__(self, config: Evo1Config, *, vlm_hub_kwargs: dict | None = None, **kwargs):
super().__init__(config)
config.validate_features()
if len(config.image_features) > config.max_views:
raise ValueError(
f"EVO1 supports at most {config.max_views} camera streams, got {len(config.image_features)}"
)
self.config = config
self.model = Evo1Model(config, vlm_hub_kwargs=vlm_hub_kwargs)
self.model.set_finetune_flags()
self._keep_frozen_embedder_eval()
self.init_rtc_processor()
self.reset()
def init_rtc_processor(self):
"""Create the RTC processor when config.rtc_config is set.
The RTC rollout backend assigns config.rtc_config after loading the policy and re-invokes
this method.
"""
self.rtc_processor = None
if self.config.rtc_config is not None:
self.rtc_processor = RTCProcessor(self.config.rtc_config)
model = getattr(self, "model", None)
if model is not None:
model.rtc_processor = self.rtc_processor
def _rtc_enabled(self) -> bool:
return self.config.rtc_config is not None and self.config.rtc_config.enabled
@classmethod
def from_pretrained(
cls: builtins.type[T],
pretrained_name_or_path: str | Path,
*,
config: PreTrainedConfig | None = None,
force_download: bool = False,
resume_download: bool | None = None,
proxies: dict | None = None,
token: str | bool | None = None,
cache_dir: str | Path | None = None,
local_files_only: bool = False,
revision: str | None = None,
strict: bool | None = None,
**kwargs,
) -> T:
if strict is None:
strict = True
vlm_hub_kwargs = kwargs.pop("vlm_hub_kwargs", None)
if config is None:
config = PreTrainedConfig.from_pretrained(
pretrained_name_or_path=pretrained_name_or_path,
force_download=force_download,
resume_download=resume_download,
proxies=proxies,
token=token,
cache_dir=cache_dir,
local_files_only=local_files_only,
revision=revision,
**kwargs,
)
if vlm_hub_kwargs is None:
# Forward the hub download options to the base-VLM download as well; `revision` is not
# forwarded because it identifies the policy repo, not the VLM repo.
vlm_hub_kwargs = {
key: value
for key, value in (
("token", token),
("cache_dir", cache_dir),
("local_files_only", local_files_only),
("proxies", proxies),
)
if value not in (None, False)
}
kwargs["vlm_hub_kwargs"] = vlm_hub_kwargs
return super().from_pretrained(
pretrained_name_or_path=pretrained_name_or_path,
config=config,
force_download=force_download,
resume_download=resume_download,
proxies=proxies,
token=token,
cache_dir=cache_dir,
local_files_only=local_files_only,
revision=revision,
strict=strict,
**kwargs,
)
@property
def _camera_keys(self) -> list[str]:
return list(self.config.image_features)
@property
def _env_action_dim(self) -> int:
action_feature = self.config.action_feature
if action_feature is None:
return self.config.max_action_dim
return int(action_feature.shape[0])
@property
def _compute_dtype(self) -> torch.dtype:
return next(self.model.action_head.parameters()).dtype
@property
def _device(self) -> torch.device:
# The device the policy actually lives on. Derived from the parameters rather than
# config.device so the policy keeps working after accelerate (or a plain .to()) moves it.
return next(self.model.action_head.parameters()).device
@property
def _amp_enabled(self) -> bool:
return bool(self.config.use_amp) and self._device.type == "cuda"
def _maybe_autocast(self):
# EVO1 manages its own mixed precision: an explicit bf16 autocast that also overrides any
# outer autocast context (e.g. lerobot-eval's fp16 default), keeping train and eval
# numerics identical.
if self._amp_enabled:
return torch.autocast(device_type="cuda", dtype=torch.bfloat16)
return nullcontext()
def get_optim_params(self) -> list[dict]:
decay, no_decay = [], []
for name, param in self.named_parameters():
if not param.requires_grad:
continue
is_bias = name.endswith("bias") or ".bias" in name
is_norm = param.dim() == 1 or "norm" in name.lower()
if is_bias or is_norm:
no_decay.append(param)
else:
decay.append(param)
return [
{"params": decay, "weight_decay": self.config.optimizer_weight_decay},
{"params": no_decay, "weight_decay": 0.0},
]
def reset(self):
self._action_queue = deque([], maxlen=self.config.n_action_steps)
def _normalize_task_batch(self, batch: dict[str, Tensor | list[str] | str]) -> list[str]:
prompts = batch.get(self.config.task_field)
if prompts is None and self.config.task_field != "task":
prompts = batch.get("task")
if prompts is None:
raise ValueError(f"EVO1 expects a '{self.config.task_field}' text field in the batch.")
if isinstance(prompts, str):
return [prompts]
if isinstance(prompts, (list, tuple)):
return [str(prompt) for prompt in prompts]
raise TypeError(f"Unsupported prompt batch type: {type(prompts)}")
def _prepare_state(self, batch: dict[str, Tensor]) -> tuple[Tensor, Tensor]:
if OBS_STATE not in batch:
raise ValueError(f"EVO1 requires '{OBS_STATE}' in the batch.")
state = batch[OBS_STATE]
if state.dim() == 1:
state = state.unsqueeze(0)
elif state.dim() == 3:
state = state[:, -1]
elif state.dim() != 2:
raise ValueError(f"Unsupported state tensor shape for EVO1: {tuple(state.shape)}")
batch_size, state_dim = state.shape
if state_dim > self.config.max_state_dim:
raise ValueError(
f"State dim {state_dim} exceeds configured max_state_dim {self.config.max_state_dim}"
)
explicit_mask = batch.get("state_mask")
if explicit_mask is not None:
if explicit_mask.dim() == 1:
explicit_mask = explicit_mask.unsqueeze(0)
elif explicit_mask.dim() == 3:
explicit_mask = explicit_mask[:, -1]
elif explicit_mask.dim() != 2:
raise ValueError(
f"Unsupported state_mask tensor shape for EVO1: {tuple(explicit_mask.shape)}"
)
if explicit_mask.shape != (batch_size, state_dim):
raise ValueError(
f"state_mask shape {tuple(explicit_mask.shape)} does not match state shape {(batch_size, state_dim)}"
)
device = self._device
padded = torch.zeros(
batch_size,
self.config.max_state_dim,
dtype=state.dtype,
device=device,
)
padded[:, :state_dim] = state.to(device=device)
mask = torch.zeros(
batch_size,
self.config.max_state_dim,
dtype=torch.bool,
device=device,
)
if explicit_mask is None:
mask[:, :state_dim] = True
else:
mask[:, :state_dim] = explicit_mask.to(device=device, dtype=torch.bool)
# Zero out masked state dims so an explicit state_mask actually affects the model input
# (the state encoder has no mask argument of its own).
padded = padded * mask.to(dtype=padded.dtype)
return padded.to(dtype=self._compute_dtype), mask
def _prepare_actions(self, batch: dict[str, Tensor]) -> tuple[Tensor, Tensor]:
if ACTION not in batch:
raise ValueError(f"EVO1 requires '{ACTION}' in the batch for training.")
action = batch[ACTION]
if action.dim() == 2:
action = action.unsqueeze(1)
batch_size, horizon, action_dim = action.shape
if horizon != self.config.chunk_size:
raise ValueError(
f"EVO1 expects chunk_size={self.config.chunk_size}, got action horizon {horizon}"
)
if action_dim > self.config.max_action_dim:
raise ValueError(
f"Action dim {action_dim} exceeds configured max_action_dim {self.config.max_action_dim}"
)
explicit_mask = batch.get("action_mask")
if explicit_mask is not None:
if explicit_mask.dim() == 2:
if horizon == 1:
explicit_mask = explicit_mask.unsqueeze(1)
else:
raise ValueError(
f"2D action_mask is only supported when chunk_size=1, got action horizon {horizon}"
)
elif explicit_mask.dim() != 3:
raise ValueError(
f"Unsupported action_mask tensor shape for EVO1: {tuple(explicit_mask.shape)}"
)
if explicit_mask.shape != (batch_size, horizon, action_dim):
raise ValueError(
"action_mask shape "
f"{tuple(explicit_mask.shape)} does not match action shape {(batch_size, horizon, action_dim)}"
)
device = self._device
padded = torch.zeros(
batch_size,
horizon,
self.config.max_action_dim,
dtype=action.dtype,
device=device,
)
padded[:, :, :action_dim] = action.to(device=device)
mask = torch.zeros(
batch_size,
horizon,
self.config.max_action_dim,
dtype=torch.bool,
device=device,
)
if explicit_mask is None:
mask[:, :, :action_dim] = True
else:
mask[:, :, :action_dim] = explicit_mask.to(device=device, dtype=torch.bool)
# Timesteps beyond the episode end hold fabricated (repeated) actions; exclude them from
# the loss like the other chunked policies do.
action_is_pad = batch.get("action_is_pad")
if action_is_pad is not None:
if action_is_pad.shape != (batch_size, horizon):
raise ValueError(
f"action_is_pad shape {tuple(action_is_pad.shape)} does not match "
f"(batch_size, chunk_size)={(batch_size, horizon)}"
)
in_episode = ~action_is_pad.to(device=device, dtype=torch.bool)
mask = mask & in_episode.unsqueeze(-1)
return padded.to(dtype=self._compute_dtype), mask
def _prepare_inference_action_mask(self, batch_size: int) -> Tensor:
mask = torch.zeros(
batch_size,
self.config.max_action_dim,
dtype=torch.bool,
device=self._device,
)
mask[:, : self._env_action_dim] = True
return mask
def _get_embodiment_ids(self, batch: dict[str, Tensor], batch_size: int) -> Tensor:
embodiment_ids = batch.get("embodiment_id")
if embodiment_ids is None and self.config.embodiment_id_field:
embodiment_ids = batch.get(self.config.embodiment_id_field)
if embodiment_ids is None:
return torch.full(
(batch_size,),
self.config.default_embodiment_id,
dtype=torch.long,
device=self._device,
)
if embodiment_ids.dim() == 0:
embodiment_ids = embodiment_ids.unsqueeze(0)
elif embodiment_ids.dim() > 1:
embodiment_ids = embodiment_ids[:, -1]
return embodiment_ids.to(device=self._device, dtype=torch.long)
@property
def _tracks_vlm_gradients(self) -> bool:
return bool(
self.config.finetune_vlm
or self.config.finetune_language_model
or self.config.finetune_vision_model
)
def _keep_frozen_embedder_eval(self) -> None:
if self._tracks_vlm_gradients:
return
embedder = getattr(self.model, "embedder", None)
if embedder is not None:
embedder.eval()
def train(self, mode: bool = True):
super().train(mode)
self._keep_frozen_embedder_eval()
return self
def _collect_image_batches(self, batch: dict[str, Tensor]) -> tuple[list[Tensor], Tensor]:
camera_keys = self._camera_keys or sorted(key for key in batch if key.startswith(f"{OBS_IMAGES}."))
if not camera_keys:
raise ValueError("EVO1 requires at least one visual observation feature.")
camera_keys = list(camera_keys)[: self.config.max_views]
# Configured cameras may be absent from the batch up to the empty_cameras budget (e.g. the
# placeholder features added by validate_features); they become masked-out views that the
# embedder zero-pads. Any other absent camera is an error.
present_keys = [key for key in camera_keys if key in batch]
missing_keys = [key for key in camera_keys if key not in batch]
if len(missing_keys) > self.config.empty_cameras:
raise ValueError(
f"Missing camera features {missing_keys} in batch; at most "
f"empty_cameras={self.config.empty_cameras} may be absent."
)
if not present_keys:
raise ValueError("EVO1 requires at least one visual observation in the batch.")
# Keep each present camera as a batched (B, C, H, W) tensor on its current (GPU) device.
# Resizing/normalization and zero-padding of absent views happen batched inside the
# embedder, so images never leave the device here.
camera_images: list[Tensor] = []
for camera_key in present_keys:
image = batch[camera_key]
if image.dim() == 3:
# Promote an unbatched (C, H, W) frame so batch_size is read from a real batch dim.
image = image.unsqueeze(0)
elif image.dim() == 5:
image = image[:, -1]
elif image.dim() != 4:
raise ValueError(
f"Unsupported image tensor shape for EVO1: key={camera_key} shape={tuple(image.shape)}"
)
camera_images.append(image)
batch_size = camera_images[0].shape[0]
n_present = len(camera_images)
image_masks = torch.zeros(
batch_size, self.config.max_views, dtype=torch.bool, device=camera_images[0].device
)
image_masks[:, :n_present] = True
return camera_images, image_masks
def _compute_fused_tokens(
self,
prompts: list[str],
image_batches: list[Tensor],
image_masks: Tensor,
) -> tuple[Tensor, Tensor | None]:
track_vlm_gradients = self._tracks_vlm_gradients
grad_context = nullcontext() if track_vlm_gradients else torch.no_grad()
with grad_context:
fused_tokens, context_mask = self.model.get_vl_embeddings(
images=image_batches,
image_mask=image_masks,
prompt=prompts,
return_cls_only=self.config.return_cls_only,
)
if not track_vlm_gradients:
fused_tokens = fused_tokens.detach()
fused_tokens = fused_tokens.to(device=self._device, dtype=self._compute_dtype)
if context_mask is not None:
context_mask = context_mask.to(device=self._device)
return fused_tokens, context_mask
def _compute_masked_loss(
self,
pred_velocity: Tensor,
target_velocity: Tensor,
action_mask: Tensor,
reduction: str,
) -> Tensor:
flat_mask = action_mask.view(action_mask.shape[0], -1).to(dtype=pred_velocity.dtype)
sq_error = ((pred_velocity - target_velocity) * flat_mask).pow(2)
active = flat_mask.sum(dim=1).clamp_min(1.0)
per_sample_loss = sq_error.sum(dim=1) / active
if reduction == "none":
return per_sample_loss
if reduction != "mean":
raise ValueError(f"Unsupported reduction '{reduction}'")
return sq_error.sum() / active.sum()
def forward(self, batch: dict[str, Tensor], reduction: str = "mean") -> tuple[Tensor, dict]:
prompts = self._normalize_task_batch(batch)
image_batches, image_masks = self._collect_image_batches(batch)
states, _state_mask = self._prepare_state(batch)
actions_gt, action_mask = self._prepare_actions(batch)
embodiment_ids = self._get_embodiment_ids(batch, states.shape[0])
with self._maybe_autocast():
fused_tokens, context_mask = self._compute_fused_tokens(prompts, image_batches, image_masks)
pred_velocity, noise = self.model(
fused_tokens,
state=states,
actions_gt=actions_gt,
action_mask=action_mask.to(device=self._device, dtype=self._compute_dtype),
embodiment_ids=embodiment_ids,
context_mask=context_mask,
)
# Compute the flow-matching regression loss in fp32, outside the autocast block.
pred_velocity = pred_velocity.float()
noise = noise.float()
flat_action_mask = action_mask.view(action_mask.shape[0], -1).to(dtype=torch.float32)
# Flow-matching velocity target. Padded (masked-out) action dims are already zero on both sides
# here (`actions_gt` is zero-padded in `_prepare_actions`, and `noise` is masked inside the head),
# and the whole difference is multiplied by `flat_action_mask`, so padded dims contribute nothing.
target_velocity = (actions_gt.float() - noise).view(actions_gt.shape[0], -1) * flat_action_mask
loss = self._compute_masked_loss(pred_velocity, target_velocity, action_mask, reduction)
loss_mean = loss.mean().item() if loss.ndim > 0 else loss.item()
return loss, {
"loss": loss_mean,
"active_action_dims": float(action_mask.sum(dim=(1, 2)).float().mean().item()),
}
@torch.no_grad()
def predict_action_chunk(self, batch: dict[str, Tensor], **kwargs: Unpack[ActionSelectKwargs]) -> Tensor:
inference_delay = kwargs.get("inference_delay")
prev_chunk_left_over = kwargs.get("prev_chunk_left_over")
execution_horizon = kwargs.get("execution_horizon")
if (inference_delay is not None or prev_chunk_left_over is not None) and not self._rtc_enabled():
raise RuntimeError(
"Received RTC arguments but RTC is not configured for this EVO1 policy: set "
"config.rtc_config and call init_rtc_processor() (lerobot-rollout does this for "
"--inference.type=rtc)."
)
self.eval()
prompts = self._normalize_task_batch(batch)
image_batches, image_masks = self._collect_image_batches(batch)
states, _state_mask = self._prepare_state(batch)
embodiment_ids = self._get_embodiment_ids(batch, states.shape[0])
action_mask = self._prepare_inference_action_mask(states.shape[0])
if prev_chunk_left_over is not None:
prev_chunk_left_over = prev_chunk_left_over.to(device=self._device)
with self._maybe_autocast():
fused_tokens, context_mask = self._compute_fused_tokens(prompts, image_batches, image_masks)
actions = self.model(
fused_tokens,
state=states,
action_mask=action_mask,
embodiment_ids=embodiment_ids,
context_mask=context_mask,
inference_delay=inference_delay,
prev_chunk_left_over=prev_chunk_left_over,
execution_horizon=execution_horizon,
)
actions = actions.view(states.shape[0], self.config.chunk_size, self.config.max_action_dim)
return actions.to(dtype=torch.float32)
@torch.no_grad()
def select_action(self, batch: dict[str, Tensor], **kwargs) -> Tensor:
assert not self._rtc_enabled(), (
"RTC is not supported for select_action, use it with predict_action_chunk"
)
self.eval()
if len(self._action_queue) == 0:
action_chunk = self.predict_action_chunk(batch)[:, : self.config.n_action_steps]
self._action_queue.extend(action_chunk.transpose(0, 1))
# Returns one step of shape (B, max_action_dim): actions are emitted at the padded max_action_dim
# width and cropped to the real action dim downstream by the postprocessor (Evo1ActionProcessorStep).
# Callers that bypass the postprocessor receive the padded width.
return self._action_queue.popleft()
+400
View File
@@ -0,0 +1,400 @@
# 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.
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
import torch
from lerobot.configs import FeatureType, PipelineFeatureType, PolicyFeature
from lerobot.processor import (
AddBatchDimensionProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
ObservationProcessorStep,
PolicyAction,
PolicyActionProcessorStep,
PolicyProcessorPipeline,
ProcessorStep,
ProcessorStepRegistry,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
)
from lerobot.processor.converters import (
batch_to_transition,
create_transition,
policy_action_to_transition,
transition_to_policy_action,
)
from lerobot.types import EnvTransition, TransitionKey
from lerobot.utils.constants import (
ACTION,
DONE,
INFO,
OBS_PREFIX,
OBS_STATE,
POLICY_POSTPROCESSOR_DEFAULT_NAME,
POLICY_PREPROCESSOR_DEFAULT_NAME,
REWARD,
TRUNCATED,
)
from .configuration_evo1 import Evo1Config
def evo1_batch_to_transition(batch: dict[str, Any]):
transition = batch_to_transition(batch)
complementary_data = dict(transition.get("complementary_data") or {})
reserved = {ACTION, REWARD, DONE, TRUNCATED, INFO}
for key, value in batch.items():
if key in reserved or key.startswith(OBS_PREFIX):
continue
complementary_data.setdefault(key, value)
return create_transition(
observation=transition.get("observation"),
action=transition.get("action"),
reward=transition.get("reward", 0.0),
done=transition.get("done", False),
truncated=transition.get("truncated", False),
info=transition.get("info", {}),
complementary_data=complementary_data,
)
@dataclass
@ProcessorStepRegistry.register(name="evo1_pad_state_processor")
class Evo1PadStateProcessorStep(ObservationProcessorStep):
"""Pad policy observations to EVO1's fixed state width before normalization."""
max_state_dim: int = 24
def observation(self, observation: dict[str, Any]) -> dict[str, Any]:
if OBS_STATE not in observation:
return observation
state = observation[OBS_STATE]
state_dim = state.shape[-1]
if state_dim > self.max_state_dim:
raise ValueError(
f"EVO1 state has {state_dim} dims, which exceeds max_state_dim={self.max_state_dim}."
)
if state_dim < self.max_state_dim:
observation = observation.copy()
observation[OBS_STATE] = torch.nn.functional.pad(state, (0, self.max_state_dim - state_dim))
return observation
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
new_features = {ft: feats.copy() for ft, feats in features.items()}
obs_feats = new_features.setdefault(PipelineFeatureType.OBSERVATION, {})
if OBS_STATE in obs_feats:
obs_feats[OBS_STATE] = PolicyFeature(type=FeatureType.STATE, shape=(self.max_state_dim,))
return new_features
def get_config(self) -> dict[str, Any]:
return {"max_state_dim": self.max_state_dim}
@dataclass
@ProcessorStepRegistry.register(name="evo1_pad_action_processor")
class Evo1PadActionProcessorStep(ProcessorStep):
"""Pad training actions and preserve the active action dimensions with action_mask."""
max_action_dim: int = 24
def __call__(self, transition: EnvTransition) -> EnvTransition:
action = transition.get(TransitionKey.ACTION)
if action is None:
return transition
if not isinstance(action, PolicyAction):
raise ValueError(f"EVO1 action should be a PolicyAction tensor, but got {type(action)}.")
action_dim = action.shape[-1]
if action_dim > self.max_action_dim:
raise ValueError(
f"EVO1 action has {action_dim} dims, which exceeds max_action_dim={self.max_action_dim}."
)
new_transition = transition.copy()
new_action = action
if action_dim < self.max_action_dim:
new_action = torch.nn.functional.pad(action, (0, self.max_action_dim - action_dim))
complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
action_mask = complementary_data.get("action_mask")
if action_mask is None:
action_mask = torch.ones(action.shape, dtype=torch.bool, device=action.device)
else:
action_mask = torch.as_tensor(action_mask, dtype=torch.bool, device=action.device)
if action_mask.shape != action.shape:
raise ValueError(
f"action_mask shape {tuple(action_mask.shape)} does not match action shape {tuple(action.shape)}."
)
if action_dim < self.max_action_dim:
action_mask = torch.nn.functional.pad(action_mask, (0, self.max_action_dim - action_dim))
complementary_data["action_mask"] = action_mask
new_transition[TransitionKey.ACTION] = new_action
new_transition[TransitionKey.COMPLEMENTARY_DATA] = complementary_data
return new_transition
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
new_features = {ft: feats.copy() for ft, feats in features.items()}
action_feats = new_features.setdefault(PipelineFeatureType.ACTION, {})
action_feats[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(self.max_action_dim,))
return new_features
def get_config(self) -> dict[str, Any]:
return {"max_action_dim": self.max_action_dim}
@dataclass
@ProcessorStepRegistry.register(name="evo1_action_processor")
class Evo1ActionProcessorStep(PolicyActionProcessorStep):
"""Crop padded EVO1 actions and optionally binarize the LIBERO gripper channel."""
action_dim: int
binarize_gripper: bool = False
gripper_index: int = 6
gripper_threshold: float = 0.5
gripper_below_threshold_value: float = 1.0
gripper_above_threshold_value: float = -1.0
def action(self, action: PolicyAction) -> PolicyAction:
if action.shape[-1] < self.action_dim:
raise ValueError(
f"EVO1 action has {action.shape[-1]} dims, which is smaller than action_dim={self.action_dim}."
)
action = action[..., : self.action_dim]
if not self.binarize_gripper:
return action
if not 0 <= self.gripper_index < self.action_dim:
raise ValueError(
f"gripper_index={self.gripper_index} must be within action_dim={self.action_dim}."
)
action = action.clone()
below = torch.as_tensor(
self.gripper_below_threshold_value,
dtype=action.dtype,
device=action.device,
)
above = torch.as_tensor(
self.gripper_above_threshold_value,
dtype=action.dtype,
device=action.device,
)
action[..., self.gripper_index] = torch.where(
action[..., self.gripper_index] > self.gripper_threshold,
above,
below,
)
return action
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
new_features = {ft: feats.copy() for ft, feats in features.items()}
action_feats = new_features.setdefault(PipelineFeatureType.ACTION, {})
action_feats[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(self.action_dim,))
return new_features
def get_config(self) -> dict[str, Any]:
return {
"action_dim": self.action_dim,
"binarize_gripper": self.binarize_gripper,
"gripper_index": self.gripper_index,
"gripper_threshold": self.gripper_threshold,
"gripper_below_threshold_value": self.gripper_below_threshold_value,
"gripper_above_threshold_value": self.gripper_above_threshold_value,
}
def _evo1_action_dim(config: Evo1Config) -> int:
if config.postprocess_action_dim is not None:
return config.postprocess_action_dim
action_feature = config.action_feature
if action_feature is None:
return config.max_action_dim
return int(action_feature.shape[0])
def _evo1_normalization_features(config: Evo1Config) -> dict[str, PolicyFeature]:
features = {**config.input_features, **config.output_features}
features[OBS_STATE] = PolicyFeature(type=FeatureType.STATE, shape=(config.max_state_dim,))
features[ACTION] = PolicyFeature(type=FeatureType.ACTION, shape=(config.max_action_dim,))
return features
def _evo1_action_features(config: Evo1Config) -> dict[str, PolicyFeature]:
return {ACTION: PolicyFeature(type=FeatureType.ACTION, shape=(config.max_action_dim,))}
_STAT_PAD_VALUES = {
"mean": 0.0,
"std": 1.0,
"min": -1.0,
"max": 1.0,
"q01": -1.0,
"q99": 1.0,
"q10": -1.0,
"q90": 1.0,
}
def _pad_stat_value(value: Any, target_dim: int, stat_name: str) -> torch.Tensor:
tensor = torch.as_tensor(value)
if not tensor.is_floating_point():
tensor = tensor.to(dtype=torch.float32)
if tensor.ndim == 0 or tensor.shape[-1] >= target_dim:
return tensor
pad_shape = (*tensor.shape[:-1], target_dim - tensor.shape[-1])
pad_value = _STAT_PAD_VALUES.get(stat_name, 0.0)
padding = torch.full(pad_shape, pad_value, dtype=tensor.dtype, device=tensor.device)
return torch.cat([tensor, padding], dim=-1)
def _pad_feature_stats(
stats: dict[str, dict[str, Any]],
feature_key: str,
target_dim: int,
) -> None:
if feature_key not in stats:
return
stats[feature_key] = {
stat_name: _pad_stat_value(stat_value, target_dim, stat_name)
for stat_name, stat_value in stats[feature_key].items()
}
def _pad_evo1_stats(
config: Evo1Config,
stats: dict[str, dict[str, Any]] | None,
) -> dict[str, dict[str, Any]] | None:
if stats is None:
return None
padded_stats = deepcopy(stats)
# Added dimensions represent zero-padding inside EVO1. These neutral stats keep
# padded observations at normalized zero and only provide shape compatibility.
_pad_feature_stats(padded_stats, OBS_STATE, config.max_state_dim)
_pad_feature_stats(padded_stats, ACTION, config.max_action_dim)
return padded_stats
def reconcile_evo1_processors(
config: Evo1Config,
preprocessor: PolicyProcessorPipeline,
postprocessor: PolicyProcessorPipeline,
) -> tuple[PolicyProcessorPipeline, PolicyProcessorPipeline]:
"""Reconcile checkpoint-loaded pipelines with the current EVO1 config.
Two things cannot be restored from a serialized pipeline alone: the EVO1 batch converter
(converters are plain functions and are never serialized), and eval-time CLI overrides of the
action postprocessing flags (`postprocess_action_dim`, `binarize_gripper`, `gripper_*`). This
restores the converter and rebuilds the action step from the current config so those overrides
take effect.
"""
# Pipelines reloaded from a checkpoint come back with the default batch converter, which drops
# non-observation extras (embodiment_id, state_mask, custom task fields) needed by EVO1.
preprocessor.to_transition = evo1_batch_to_transition
action_step = Evo1ActionProcessorStep(
action_dim=_evo1_action_dim(config),
binarize_gripper=config.binarize_gripper,
gripper_index=config.gripper_index,
gripper_threshold=config.gripper_threshold,
gripper_below_threshold_value=config.gripper_below_threshold_value,
gripper_above_threshold_value=config.gripper_above_threshold_value,
)
steps = list(postprocessor.steps)
action_step_idx = next(
(idx for idx, step in enumerate(steps) if isinstance(step, Evo1ActionProcessorStep)), None
)
if action_step_idx is None:
insert_idx = next(
(idx + 1 for idx, step in enumerate(steps) if isinstance(step, UnnormalizerProcessorStep)),
0,
)
steps.insert(insert_idx, action_step)
else:
steps[action_step_idx] = action_step
postprocessor.steps = steps
return preprocessor, postprocessor
def make_evo1_pre_post_processors(
config: Evo1Config,
dataset_stats: dict[str, dict[str, torch.Tensor]] | None = None,
) -> tuple[
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]],
PolicyProcessorPipeline[PolicyAction, PolicyAction],
]:
normalization_features = _evo1_normalization_features(config)
action_features = _evo1_action_features(config)
normalization_stats = _pad_evo1_stats(config, dataset_stats)
input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
Evo1PadStateProcessorStep(max_state_dim=config.max_state_dim),
Evo1PadActionProcessorStep(max_action_dim=config.max_action_dim),
NormalizerProcessorStep(
features=normalization_features,
norm_map=config.normalization_mapping,
stats=normalization_stats,
),
DeviceProcessorStep(device=config.device),
]
output_steps = [
UnnormalizerProcessorStep(
features=action_features,
norm_map=config.normalization_mapping,
stats=normalization_stats,
),
Evo1ActionProcessorStep(
action_dim=_evo1_action_dim(config),
binarize_gripper=config.binarize_gripper,
gripper_index=config.gripper_index,
gripper_threshold=config.gripper_threshold,
gripper_below_threshold_value=config.gripper_below_threshold_value,
gripper_above_threshold_value=config.gripper_above_threshold_value,
),
# float32 so downstream numpy conversion works even when the policy computes in bf16.
DeviceProcessorStep(device="cpu", float_dtype="float32"),
]
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
to_transition=evo1_batch_to_transition,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
+91 -307
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import importlib
import inspect
import logging
from typing import TYPE_CHECKING, Any, TypedDict, Unpack
@@ -44,24 +45,10 @@ from lerobot.utils.constants import (
)
from lerobot.utils.feature_utils import dataset_to_policy_features
from .act.configuration_act import ACTConfig
from .diffusion.configuration_diffusion import DiffusionConfig
from .eo1.configuration_eo1 import EO1Config
from .fastwam.configuration_fastwam import FastWAMConfig
from .gaussian_actor.configuration_gaussian_actor import GaussianActorConfig
from .evo1.configuration_evo1 import Evo1Config
from .groot.configuration_groot import GrootConfig
from .molmoact2.configuration_molmoact2 import MolmoAct2Config
from .multi_task_dit.configuration_multi_task_dit import MultiTaskDiTConfig
from .pi0.configuration_pi0 import PI0Config
from .pi05.configuration_pi05 import PI05Config
from .pretrained import PreTrainedPolicy
from .smolvla.configuration_smolvla import SmolVLAConfig
from .tdmpc.configuration_tdmpc import TDMPCConfig
from .utils import validate_visual_features_consistency
from .vla_jepa.configuration_vla_jepa import VLAJEPAConfig
from .vqbet.configuration_vqbet import VQBeTConfig
from .wall_x.configuration_wall_x import WallXConfig
from .xvla.configuration_xvla import XVLAConfig
def _reconnect_relative_absolute_steps(
@@ -86,92 +73,23 @@ def get_policy_class(name: str) -> type[PreTrainedPolicy]:
"""
Retrieves a policy class by its registered name.
This function uses dynamic imports to avoid loading all policy classes into memory
at once, improving startup time and reducing dependencies.
Resolution is convention-based: the draccus-registered config class of ``name`` is
looked up, its ``configuration_*`` module path is rewritten to ``modeling_*``, and
the ``<X>Policy`` class is imported from there. The modeling module is only imported
at call time, keeping heavy optional dependencies lazy. This works for both built-in
policies and third-party lerobot plugins (anything registered via
``@PreTrainedConfig.register_subclass``).
Args:
name: The name of the policy. Supported names are "tdmpc", "diffusion", "act",
"multi_task_dit", "vqbet", "pi0", "pi05", "gaussian_actor", "smolvla", "wall_x",
"molmoact2".
name: The registered name of the policy (e.g. "act", "diffusion", "pi0").
Returns:
The policy class corresponding to the given name.
Raises:
NotImplementedError: If the policy name is not recognized.
ValueError: If the policy name is not registered.
ImportError: If the policy's optional dependencies are not installed.
"""
if name == "tdmpc":
from .tdmpc.modeling_tdmpc import TDMPCPolicy
return TDMPCPolicy
elif name == "diffusion":
from .diffusion.modeling_diffusion import DiffusionPolicy
return DiffusionPolicy
elif name == "act":
from .act.modeling_act import ACTPolicy
return ACTPolicy
elif name == "multi_task_dit":
from .multi_task_dit.modeling_multi_task_dit import MultiTaskDiTPolicy
return MultiTaskDiTPolicy
elif name == "vqbet":
from .vqbet.modeling_vqbet import VQBeTPolicy
return VQBeTPolicy
elif name == "pi0":
from .pi0.modeling_pi0 import PI0Policy
return PI0Policy
elif name == "pi0_fast":
from .pi0_fast.modeling_pi0_fast import PI0FastPolicy
return PI0FastPolicy
elif name == "pi05":
from .pi05.modeling_pi05 import PI05Policy
return PI05Policy
elif name == "gaussian_actor":
from .gaussian_actor.modeling_gaussian_actor import GaussianActorPolicy
return GaussianActorPolicy
elif name == "smolvla":
from .smolvla.modeling_smolvla import SmolVLAPolicy
return SmolVLAPolicy
elif name == "groot":
from .groot.modeling_groot import GrootPolicy
return GrootPolicy
elif name == "xvla":
from .xvla.modeling_xvla import XVLAPolicy
return XVLAPolicy
elif name == "wall_x":
from .wall_x.modeling_wall_x import WallXPolicy
return WallXPolicy
elif name == "eo1":
from .eo1.modeling_eo1 import EO1Policy
return EO1Policy
elif name == "molmoact2":
from .molmoact2.modeling_molmoact2 import MolmoAct2Policy
return MolmoAct2Policy
elif name == "vla_jepa":
from .vla_jepa.modeling_vla_jepa import VLAJEPAPolicy
return VLAJEPAPolicy
elif name == "fastwam":
from .fastwam.modeling_fastwam import FastWAMPolicy
return FastWAMPolicy
else:
try:
return _get_policy_cls_from_policy_name(name=name)
except Exception as e:
raise ValueError(f"Policy type '{name}' is not available.") from e
return _get_policy_cls_from_policy_name(name=name)
def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
@@ -182,9 +100,8 @@ def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
mapping a string identifier to the corresponding config class.
Args:
policy_type: The type of the policy. Supported types include "tdmpc",
"multi_task_dit", "diffusion", "act", "vqbet", "pi0", "pi05", "gaussian_actor",
"smolvla", "wall_x", "molmoact2".
policy_type: The registered type of the policy (any name registered via
``@PreTrainedConfig.register_subclass``, e.g. "act", "diffusion", "pi0").
**kwargs: Keyword arguments to be passed to the configuration class constructor.
Returns:
@@ -193,44 +110,11 @@ def make_policy_config(policy_type: str, **kwargs) -> PreTrainedConfig:
Raises:
ValueError: If the `policy_type` is not recognized.
"""
if policy_type == "tdmpc":
return TDMPCConfig(**kwargs)
elif policy_type == "diffusion":
return DiffusionConfig(**kwargs)
elif policy_type == "act":
return ACTConfig(**kwargs)
elif policy_type == "multi_task_dit":
return MultiTaskDiTConfig(**kwargs)
elif policy_type == "vqbet":
return VQBeTConfig(**kwargs)
elif policy_type == "pi0":
return PI0Config(**kwargs)
elif policy_type == "pi05":
return PI05Config(**kwargs)
elif policy_type == "gaussian_actor":
return GaussianActorConfig(**kwargs)
elif policy_type == "smolvla":
return SmolVLAConfig(**kwargs)
elif policy_type == "groot":
return GrootConfig(**kwargs)
elif policy_type == "xvla":
return XVLAConfig(**kwargs)
elif policy_type == "wall_x":
return WallXConfig(**kwargs)
elif policy_type == "eo1":
return EO1Config(**kwargs)
elif policy_type == "molmoact2":
return MolmoAct2Config(**kwargs)
elif policy_type == "vla_jepa":
return VLAJEPAConfig(**kwargs)
elif policy_type == "fastwam":
return FastWAMConfig(**kwargs)
else:
try:
config_cls = PreTrainedConfig.get_choice_class(policy_type)
return config_cls(**kwargs)
except Exception as e:
raise ValueError(f"Policy type '{policy_type}' is not available.") from e
try:
config_cls = PreTrainedConfig.get_choice_class(policy_type)
except Exception as e:
raise ValueError(f"Policy type '{policy_type}' is not available.") from e
return config_cls(**kwargs)
class ProcessorConfigKwargs(TypedDict, total=False):
@@ -284,30 +168,26 @@ def make_pre_post_processors(
A tuple containing the input (pre-processor) and output (post-processor) pipelines.
Raises:
NotImplementedError: If a processor factory is not implemented for the given
policy configuration type.
ValueError: If no processor factory exists for the given policy configuration type.
"""
if pretrained_path:
# TODO(Steven): Temporary patch, implement correctly the processors for Gr00t
if isinstance(policy_cfg, GrootConfig):
# GROOT handles normalization in groot_pack_inputs_v3 step
# Need to override both stats AND normalize_min_max since saved config might be empty
preprocessor_overrides = {}
postprocessor_overrides = {}
preprocessor_overrides["groot_pack_inputs_v3"] = {
"stats": kwargs.get("dataset_stats"),
"normalize_min_max": True,
}
from .groot.processor_groot import make_groot_pre_post_processors_from_pretrained
# Also ensure postprocessing slices to env action dim and unnormalizes with dataset stats
env_action_dim = policy_cfg.output_features[ACTION].shape[0]
postprocessor_overrides["groot_action_unpack_unnormalize_v1"] = {
"stats": kwargs.get("dataset_stats"),
"normalize_min_max": True,
"env_action_dim": env_action_dim,
}
kwargs["preprocessor_overrides"] = preprocessor_overrides
kwargs["postprocessor_overrides"] = postprocessor_overrides
return make_groot_pre_post_processors_from_pretrained(
config=policy_cfg,
pretrained_path=pretrained_path,
dataset_stats=kwargs.get("dataset_stats"),
dataset_meta=kwargs.get("dataset_meta"),
preprocessor_overrides=kwargs.get("preprocessor_overrides"),
postprocessor_overrides=kwargs.get("postprocessor_overrides"),
preprocessor_config_filename=kwargs.get(
"preprocessor_config_filename", f"{POLICY_PREPROCESSOR_DEFAULT_NAME}.json"
),
postprocessor_config_filename=kwargs.get(
"postprocessor_config_filename", f"{POLICY_POSTPROCESSOR_DEFAULT_NAME}.json"
),
)
preprocessor = PolicyProcessorPipeline.from_pretrained(
pretrained_model_name_or_path=pretrained_path,
@@ -330,152 +210,23 @@ def make_pre_post_processors(
revision=pretrained_revision,
)
_reconnect_relative_absolute_steps(preprocessor, postprocessor)
if isinstance(policy_cfg, Evo1Config):
from .evo1.processor_evo1 import reconcile_evo1_processors
preprocessor, postprocessor = reconcile_evo1_processors(
policy_cfg,
preprocessor,
postprocessor,
)
return preprocessor, postprocessor
# Create a new processor based on policy type
if isinstance(policy_cfg, TDMPCConfig):
from .tdmpc.processor_tdmpc import make_tdmpc_pre_post_processors
processors = make_tdmpc_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, DiffusionConfig):
from .diffusion.processor_diffusion import make_diffusion_pre_post_processors
processors = make_diffusion_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, ACTConfig):
from .act.processor_act import make_act_pre_post_processors
processors = make_act_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, MultiTaskDiTConfig):
from .multi_task_dit.processor_multi_task_dit import (
make_multi_task_dit_pre_post_processors,
)
processors = make_multi_task_dit_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, VQBeTConfig):
from .vqbet.processor_vqbet import make_vqbet_pre_post_processors
processors = make_vqbet_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, PI0Config):
from .pi0.processor_pi0 import make_pi0_pre_post_processors
processors = make_pi0_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, PI05Config):
from .pi05.processor_pi05 import make_pi05_pre_post_processors
processors = make_pi05_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, GaussianActorConfig):
from .gaussian_actor.processor_gaussian_actor import make_gaussian_actor_pre_post_processors
processors = make_gaussian_actor_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, SmolVLAConfig):
from .smolvla.processor_smolvla import make_smolvla_pre_post_processors
processors = make_smolvla_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, GrootConfig):
from .groot.processor_groot import make_groot_pre_post_processors
processors = make_groot_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, XVLAConfig):
from .xvla.processor_xvla import (
make_xvla_pre_post_processors,
)
processors = make_xvla_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, WallXConfig):
from .wall_x.processor_wall_x import make_wall_x_pre_post_processors
processors = make_wall_x_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, EO1Config):
from .eo1.processor_eo1 import make_eo1_pre_post_processors
processors = make_eo1_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, MolmoAct2Config):
from .molmoact2.processor_molmoact2 import make_molmoact2_pre_post_processors
processors = make_molmoact2_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
dataset_meta=kwargs.get("dataset_meta"),
)
elif isinstance(policy_cfg, VLAJEPAConfig):
from .vla_jepa.processor_vla_jepa import make_vla_jepa_pre_post_processors
processors = make_vla_jepa_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
elif isinstance(policy_cfg, FastWAMConfig):
from .fastwam.processor_fastwam import make_fastwam_pre_post_processors
processors = make_fastwam_pre_post_processors(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
else:
try:
processors = _make_processors_from_policy_config(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
)
except Exception as e:
raise ValueError(f"Processor for policy type '{policy_cfg.type}' is not implemented.") from e
return processors
# Create new processors from the policy config, resolving the per-policy factory
# function by naming convention (lazy import keeps optional dependencies optional).
return _make_processors_from_policy_config(
config=policy_cfg,
dataset_stats=kwargs.get("dataset_stats"),
dataset_meta=kwargs.get("dataset_meta"),
)
def make_policy(
@@ -555,6 +306,7 @@ def make_policy(
set_dataset_feature_metadata = getattr(cfg, "set_dataset_feature_metadata", None)
if callable(set_dataset_feature_metadata):
set_dataset_feature_metadata(ds_meta.features)
cfg._runtime_dataset_meta = ds_meta
kwargs["config"] = cfg
@@ -618,10 +370,12 @@ def make_policy(
return policy
def _get_policy_cls_from_policy_name(name: str) -> type[PreTrainedConfig]:
def _get_policy_cls_from_policy_name(name: str) -> type[PreTrainedPolicy]:
"""Get policy class from its registered name using dynamic imports.
This is used as a helper function to import policies from 3rd party lerobot plugins.
Works for built-in policies and 3rd party lerobot plugins alike: the config class
registered under ``name`` is resolved via the draccus ChoiceRegistry, and the policy
class is imported from the sibling ``modeling_*`` module by naming convention.
Args:
name: The name of the policy.
@@ -647,22 +401,39 @@ def _get_policy_cls_from_policy_name(name: str) -> type[PreTrainedConfig]:
"configuration_", "modeling_"
) # e.g., configuration_diffusion -> modeling_diffusion
module = importlib.import_module(module_path)
policy_cls = getattr(module, cls_name)
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as e:
if e.name == module_path:
# The modeling_* module itself does not exist for this policy type. A missing
# optional dependency inside an existing module propagates unchanged instead,
# so its actionable install hint stays visible.
raise ValueError(f"Policy class for '{name}' is not implemented.") from e
raise
policy_cls = getattr(module, cls_name, None)
if policy_cls is None:
raise ValueError(
f"Policy class '{cls_name}' not found in '{module_path}'. "
f"Policies must expose '<Name>Policy' in the sibling 'modeling_*' module by naming convention."
)
return policy_cls
def _make_processors_from_policy_config(
config: PreTrainedConfig,
dataset_stats: dict[str, dict[str, torch.Tensor]] | None = None,
dataset_meta: Any | None = None,
) -> tuple[Any, Any]:
"""Create pre- and post-processors from a policy configuration using dynamic imports.
This is used as a helper function to import processor factories from 3rd party lerobot plugins.
Resolves ``make_{type}_pre_post_processors`` from the policy's ``processor_*`` module
by naming convention. Works for built-in policies and 3rd party lerobot plugins.
Args:
config: The policy configuration object.
dataset_stats: Dataset statistics for normalization.
dataset_meta: Dataset metadata, forwarded only to factories that declare a
``dataset_meta`` parameter (e.g. groot, molmoact2).
Returns:
A tuple containing the input (pre-processor) and output (post-processor) pipelines.
"""
@@ -675,6 +446,19 @@ def _make_processors_from_policy_config(
logging.debug(
f"Instantiating pre/post processors using function '{function_name}' from module '{module_path}'"
)
module = importlib.import_module(module_path)
function = getattr(module, function_name)
return function(config, dataset_stats=dataset_stats)
try:
module = importlib.import_module(module_path)
except ModuleNotFoundError as e:
if e.name == module_path:
# The processor_* module itself does not exist for this policy type. A missing
# optional dependency inside an existing module propagates unchanged instead,
# so its actionable install hint stays visible.
raise ValueError(f"Processor for policy type '{policy_type}' is not implemented.") from e
raise
function = getattr(module, function_name, None)
if function is None:
raise ValueError(f"Processor for policy type '{policy_type}' is not implemented.")
call_kwargs: dict[str, Any] = {"dataset_stats": dataset_stats}
if "dataset_meta" in inspect.signature(function).parameters:
call_kwargs["dataset_meta"] = dataset_meta
return function(config, **call_kwargs)
@@ -22,20 +22,11 @@ import torch
from lerobot.configs import PipelineFeatureType, PolicyFeature
from lerobot.processor import (
ActionProcessorStep,
AddBatchDimensionProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
PolicyAction,
PolicyProcessorPipeline,
ProcessorStepRegistry,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
policy_action_to_transition,
transition_to_policy_action,
)
from lerobot.utils.constants import (
POLICY_POSTPROCESSOR_DEFAULT_NAME,
POLICY_PREPROCESSOR_DEFAULT_NAME,
make_default_policy_processor_steps,
make_policy_processor_pipelines,
)
from .configuration_fastwam import FastWAMConfig
@@ -105,38 +96,20 @@ def make_fastwam_pre_post_processors(
# anyway) and unsafe across fine-tuning: its `resize_size` would be inherited from the base
# checkpoint's camera geometry, not this dataset's, making the concatenation N_cameras x too wide.
steps = make_default_policy_processor_steps(config, normalization_stats, normalizer_device=config.device)
input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=normalization_stats,
device=config.device,
),
steps.rename_observations,
steps.add_batch_dim,
steps.to_device,
steps.normalize,
]
output_steps = [
UnnormalizerProcessorStep(
features=config.output_features,
norm_map=config.normalization_mapping,
stats=normalization_stats,
),
steps.unnormalize,
]
if config.toggle_action_dimensions:
output_steps.append(
FastWAMActionToggleProcessorStep(toggle_dimensions=config.toggle_action_dimensions)
)
output_steps.append(DeviceProcessorStep(device="cpu"))
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
output_steps.append(steps.to_cpu)
return make_policy_processor_pipelines(input_steps=input_steps, output_steps=output_steps)
@@ -20,17 +20,10 @@ from typing import Any
import torch
from lerobot.processor import (
AddBatchDimensionProcessorStep,
DeviceProcessorStep,
NormalizerProcessorStep,
PolicyAction,
PolicyProcessorPipeline,
RenameObservationsProcessorStep,
UnnormalizerProcessorStep,
policy_action_to_transition,
transition_to_policy_action,
make_default_pre_post_processors,
)
from lerobot.utils.constants import POLICY_POSTPROCESSOR_DEFAULT_NAME, POLICY_PREPROCESSOR_DEFAULT_NAME
from .configuration_gaussian_actor import GaussianActorConfig
@@ -62,33 +55,4 @@ def make_gaussian_actor_pre_post_processors(
Returns:
A tuple containing the configured pre-processor and post-processor pipelines.
"""
# Add remaining processors
input_steps = [
RenameObservationsProcessorStep(rename_map={}),
AddBatchDimensionProcessorStep(),
DeviceProcessorStep(device=config.device),
NormalizerProcessorStep(
features={**config.input_features, **config.output_features},
norm_map=config.normalization_mapping,
stats=dataset_stats,
),
]
output_steps = [
UnnormalizerProcessorStep(
features=config.output_features, norm_map=config.normalization_mapping, stats=dataset_stats
),
DeviceProcessorStep(device="cpu"),
]
return (
PolicyProcessorPipeline[dict[str, Any], dict[str, Any]](
steps=input_steps,
name=POLICY_PREPROCESSOR_DEFAULT_NAME,
),
PolicyProcessorPipeline[PolicyAction, PolicyAction](
steps=output_steps,
name=POLICY_POSTPROCESSOR_DEFAULT_NAME,
to_transition=policy_action_to_transition,
to_output=transition_to_policy_action,
),
)
return make_default_pre_post_processors(config, dataset_stats)
@@ -1,54 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
import torch
import torch.nn as nn
def swish(x):
return x * torch.sigmoid(x)
class SinusoidalPositionalEncoding(nn.Module):
"""
Produces a sinusoidal encoding of shape (B, T, w)
given timesteps of shape (B, T).
"""
def __init__(self, embedding_dim):
super().__init__()
self.embedding_dim = embedding_dim
def forward(self, timesteps):
# timesteps: shape (B, T)
# We'll compute sin/cos frequencies across dim T
timesteps = timesteps.float() # ensure float
b, t = timesteps.shape
device = timesteps.device
half_dim = self.embedding_dim // 2
# typical log space frequencies for sinusoidal encoding
exponent = -torch.arange(half_dim, dtype=torch.float, device=device) * (
torch.log(torch.tensor(10000.0)) / half_dim
)
# Expand timesteps to (B, T, 1) then multiply
freqs = timesteps.unsqueeze(-1) * exponent.exp() # (B, T, half_dim)
sin = torch.sin(freqs)
cos = torch.cos(freqs)
enc = torch.cat([sin, cos], dim=-1) # (B, T, w)
return enc
@@ -1,11 +1,12 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#!/usr/bin/env python
# Copyright 2025 NVIDIA Corporation and 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
# 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,
@@ -14,6 +15,7 @@
# limitations under the License.
import logging
from typing import TYPE_CHECKING
import torch
@@ -42,6 +44,9 @@ else:
Timesteps = None
logger = logging.getLogger(__name__)
class TimestepEncoder(nn.Module):
def __init__(self, embedding_dim, compute_dtype=torch.float32):
require_package("diffusers", extra="groot")
@@ -181,8 +186,7 @@ class BasicTransformerBlock(nn.Module):
attn_output = self.attn1(
norm_hidden_states,
encoder_hidden_states=encoder_hidden_states,
attention_mask=attention_mask,
# encoder_attention_mask=encoder_attention_mask,
attention_mask=encoder_attention_mask if encoder_hidden_states is not None else attention_mask,
)
if self.final_dropout:
attn_output = self.final_dropout(attn_output)
@@ -266,8 +270,8 @@ class DiT(ModelMixin, ConfigMixin):
self.norm_out = nn.LayerNorm(self.inner_dim, elementwise_affine=False, eps=1e-6)
self.proj_out_1 = nn.Linear(self.inner_dim, 2 * self.inner_dim)
self.proj_out_2 = nn.Linear(self.inner_dim, self.config.output_dim)
print(
"Total number of DiT parameters: ",
logger.debug(
"Total number of DiT parameters: %d",
sum(p.numel() for p in self.parameters() if p.requires_grad),
)
@@ -318,6 +322,71 @@ class DiT(ModelMixin, ConfigMixin):
return self.proj_out_2(hidden_states)
class AlternateVLDiT(DiT):
"""N1.7 DiT variant that alternates cross-attention over image and text tokens."""
def __init__(self, *args, attend_text_every_n_blocks: int = 2, **kwargs):
super().__init__(*args, **kwargs)
self.attend_text_every_n_blocks = attend_text_every_n_blocks
def forward(
self,
hidden_states: torch.Tensor,
encoder_hidden_states: torch.Tensor,
timestep: torch.LongTensor | None = None,
encoder_attention_mask: torch.Tensor | None = None,
return_all_hidden_states: bool = False,
image_mask: torch.Tensor | None = None,
backbone_attention_mask: torch.Tensor | None = None,
):
if image_mask is None:
raise ValueError("image_mask is required for AlternateVLDiT.")
if backbone_attention_mask is None:
raise ValueError("backbone_attention_mask is required for AlternateVLDiT.")
temb = self.timestep_encoder(timestep)
hidden_states = hidden_states.contiguous()
encoder_hidden_states = encoder_hidden_states.contiguous()
image_attention_mask = image_mask & backbone_attention_mask
non_image_attention_mask = (~image_mask) & backbone_attention_mask
all_hidden_states = [hidden_states]
if not self.config.interleave_self_attention:
raise ValueError("AlternateVLDiT requires interleave_self_attention=True.")
for idx, block in enumerate(self.transformer_blocks):
if idx % 2 == 1:
hidden_states = block(
hidden_states,
attention_mask=None,
encoder_hidden_states=None,
encoder_attention_mask=None,
temb=temb,
)
else:
curr_encoder_attention_mask = (
non_image_attention_mask
if idx % (2 * self.attend_text_every_n_blocks) == 0
else image_attention_mask
)
hidden_states = block(
hidden_states,
attention_mask=None,
encoder_hidden_states=encoder_hidden_states,
encoder_attention_mask=curr_encoder_attention_mask,
temb=temb,
)
all_hidden_states.append(hidden_states)
conditioning = temb
shift, scale = self.proj_out_1(F.silu(conditioning)).chunk(2, dim=1)
hidden_states = self.norm_out(hidden_states) * (1 + scale[:, None]) + shift[:, None]
if return_all_hidden_states:
return self.proj_out_2(hidden_states), all_hidden_states
return self.proj_out_2(hidden_states)
class SelfAttentionTransformer(ModelMixin, ConfigMixin):
_supports_gradient_checkpointing = True
@@ -362,8 +431,8 @@ class SelfAttentionTransformer(ModelMixin, ConfigMixin):
for _ in range(self.config.num_layers)
]
)
print(
"Total number of SelfAttentionTransformer parameters: ",
logger.debug(
"Total number of SelfAttentionTransformer parameters: %d",
sum(p.numel() for p in self.parameters() if p.requires_grad),
)
@@ -1,408 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
from dataclasses import field
from typing import TYPE_CHECKING
import torch
import torch.nn.functional as F # noqa: N812
from torch import nn
from torch.distributions import Beta
from lerobot.utils.import_utils import _transformers_available
# Conditional import for type checking and lazy loading
if TYPE_CHECKING or _transformers_available:
from transformers import PretrainedConfig
from transformers.feature_extraction_utils import BatchFeature
else:
PretrainedConfig = object
BatchFeature = None
from .action_encoder import (
SinusoidalPositionalEncoding,
swish,
)
from .cross_attention_dit import DiT, SelfAttentionTransformer
class CategorySpecificLinear(nn.Module):
def __init__(self, num_categories, input_dim, hidden_dim):
super().__init__()
self.num_categories = num_categories
# For each category, we have separate weights and biases.
self.W = nn.Parameter(0.02 * torch.randn(num_categories, input_dim, hidden_dim))
self.b = nn.Parameter(torch.zeros(num_categories, hidden_dim))
def forward(self, x, cat_ids):
selected_w = self.W[cat_ids]
selected_b = self.b[cat_ids]
return torch.bmm(x, selected_w) + selected_b.unsqueeze(1)
class CategorySpecificMLP(nn.Module):
def __init__(self, num_categories, input_dim, hidden_dim, output_dim):
super().__init__()
self.num_categories = num_categories
self.layer1 = CategorySpecificLinear(num_categories, input_dim, hidden_dim)
self.layer2 = CategorySpecificLinear(num_categories, hidden_dim, output_dim)
def forward(self, x, cat_ids):
hidden = F.relu(self.layer1(x, cat_ids))
return self.layer2(hidden, cat_ids)
class MultiEmbodimentActionEncoder(nn.Module):
def __init__(self, action_dim, hidden_size, num_embodiments):
super().__init__()
self.hidden_size = hidden_size
self.num_embodiments = num_embodiments
# W1: R^{w x d}, W2: R^{w x 2w}, W3: R^{w x w}
self.W1 = CategorySpecificLinear(num_embodiments, action_dim, hidden_size) # (d -> w)
self.W2 = CategorySpecificLinear(num_embodiments, 2 * hidden_size, hidden_size) # (2w -> w)
self.W3 = CategorySpecificLinear(num_embodiments, hidden_size, hidden_size) # (w -> w)
self.pos_encoding = SinusoidalPositionalEncoding(hidden_size)
def forward(self, actions, timesteps, cat_ids):
"""
actions: shape (B, T, action_dim)
timesteps: shape (B,) -- a single scalar per batch item
cat_ids: shape (B,)
returns: shape (B, T, hidden_size)
"""
b, t, _ = actions.shape
# 1) Expand each batch's single scalar time 'tau' across all T steps
# so that shape => (B, T)
# e.g. if timesteps is (B,), replicate across T
if timesteps.dim() == 1 and timesteps.shape[0] == b:
# shape (B,) => (B,T)
timesteps = timesteps.unsqueeze(1).expand(-1, t)
else:
raise ValueError("Expected `timesteps` to have shape (B,) so we can replicate across T.")
# 2) Standard action MLP step for shape => (B, T, w)
a_emb = self.W1(actions, cat_ids)
# 3) Get the sinusoidal encoding (B, T, w)
tau_emb = self.pos_encoding(timesteps).to(dtype=a_emb.dtype)
# 4) Concat along last dim => (B, T, 2w), then W2 => (B, T, w), swish
x = torch.cat([a_emb, tau_emb], dim=-1)
x = swish(self.W2(x, cat_ids))
# 5) Finally W3 => (B, T, w)
x = self.W3(x, cat_ids)
return x
class FlowmatchingActionHeadConfig(PretrainedConfig):
"""NOTE: N1.5 uses XEmbFlowmatchingPolicyHeadConfig as action head"""
add_pos_embed: bool = field(default=True, metadata={"help": "Whether to add positional embedding"})
model_dtype: str = field(default="float32", metadata={"help": "Model data type."})
diffusion_model_cfg: dict = field(default=None, metadata={"help": "Diffusion model configuration."})
input_embedding_dim: int = field(default=1536, metadata={"help": "Input embedding channel dimension."})
backbone_embedding_dim: int = field(
default=1536, metadata={"help": "Backbone embedding channel dimension."}
)
hidden_size: int = field(default=1024, metadata={"help": "Input embedding dimension."})
max_seq_len: int = field(default=1024, metadata={"help": "Maximum Sequence Length"})
action_dim: int = field(default=None, metadata={"help": "Action dimension."})
action_horizon: int = field(default=None, metadata={"help": "Action horizon."})
noise_beta_alpha: float = field(default=1.5, metadata={"help": ""})
noise_beta_beta: float = field(default=1.0, metadata={"help": ""})
noise_s: float = field(default=0.999, metadata={"help": "Flow matching noise Beta distribution s."})
num_timestep_buckets: int = field(
default=1000, metadata={"help": "Number of timestep discretization buckets."}
)
num_inference_timesteps: int = field(
default=None,
metadata={"help": "Number of inference steps for noise diffusion."},
)
max_num_embodiments: int = field(default=32, metadata={"help": "Number of embodiments."})
tune_projector: bool = field(default=True, metadata={"help": "Whether to tune the projector."})
tune_diffusion_model: bool = field(
default=True, metadata={"help": "Whether to tune the diffusion model."}
)
load_pretrained_det_decode_layer_path: str = field(
default=None, metadata={"help": "Path to pretrained detection model."}
)
detection_coeff: float = field(default=1.0, metadata={"help": "Detection coefficient."})
freeze_decode_layer: bool = field(default=False)
expand_batch: int = field(default=None)
use_vlln: bool = field(default=True)
vl_self_attention_cfg: dict = field(default=None)
num_target_vision_tokens: int = field(default=32, metadata={"help": "Number of target vision tokens."})
def __init__(self, **kwargs):
super().__init__(**kwargs)
for key, value in kwargs.items():
setattr(self, key, value)
class FlowmatchingActionHead(nn.Module):
config_class = FlowmatchingActionHeadConfig
supports_gradient_checkpointing = True
def __init__(
self,
config: FlowmatchingActionHeadConfig,
):
super().__init__()
self.hidden_size = config.hidden_size
self.input_embedding_dim = config.input_embedding_dim
self.model = DiT(**config.diffusion_model_cfg)
self.action_dim = config.action_dim
self.action_horizon = config.action_horizon
self.num_inference_timesteps = config.num_inference_timesteps
self.state_encoder = CategorySpecificMLP(
num_categories=config.max_num_embodiments,
input_dim=config.max_state_dim,
hidden_dim=self.hidden_size,
output_dim=self.input_embedding_dim,
)
self.action_encoder = MultiEmbodimentActionEncoder(
action_dim=config.action_dim,
hidden_size=self.input_embedding_dim,
num_embodiments=config.max_num_embodiments,
)
self.action_decoder = CategorySpecificMLP(
num_categories=config.max_num_embodiments,
input_dim=self.hidden_size,
hidden_dim=self.hidden_size,
output_dim=self.action_dim,
)
self.future_tokens = nn.Embedding(config.num_target_vision_tokens, self.input_embedding_dim)
nn.init.normal_(self.future_tokens.weight, mean=0.0, std=0.02)
self.vlln = nn.LayerNorm(config.backbone_embedding_dim) if config.use_vlln else nn.Identity()
self.vl_self_attention = (
SelfAttentionTransformer(**config.vl_self_attention_cfg) if config.use_vlln else nn.Identity()
)
if config.add_pos_embed:
self.position_embedding = nn.Embedding(config.max_seq_len, self.input_embedding_dim)
nn.init.normal_(self.position_embedding.weight, mean=0.0, std=0.02)
self._noise_beta_alpha = config.noise_beta_alpha
self._noise_beta_beta = config.noise_beta_beta
self._beta_dist = None
self.num_timestep_buckets = config.num_timestep_buckets
self.config = config
self.set_trainable_parameters(config.tune_projector, config.tune_diffusion_model)
def set_trainable_parameters(self, tune_projector: bool, tune_diffusion_model: bool):
self.tune_projector = tune_projector
self.tune_diffusion_model = tune_diffusion_model
for p in self.parameters():
p.requires_grad = True
if not tune_projector:
self.state_encoder.requires_grad_(False)
self.action_encoder.requires_grad_(False)
self.action_decoder.requires_grad_(False)
if self.config.add_pos_embed:
self.position_embedding.requires_grad_(False)
if not tune_diffusion_model:
self.model.requires_grad_(False)
print(f"Tune action head projector: {self.tune_projector}")
print(f"Tune action head diffusion model: {self.tune_diffusion_model}")
# Check if any parameters are still trainable. If not, print a warning.
if not tune_projector and not tune_diffusion_model:
for name, p in self.named_parameters():
if p.requires_grad:
print(f"Action head trainable parameter: {name}")
if not any(p.requires_grad for p in self.parameters()):
print("Warning: No action head trainable parameters found.")
def set_frozen_modules_to_eval_mode(self):
"""
Huggingface will call model.train() at each training_step. To ensure
the expected behaviors for modules like dropout, batchnorm, etc., we
need to call model.eval() for the frozen modules.
"""
if self.training:
if not self.tune_projector:
self.state_encoder.eval()
self.action_encoder.eval()
self.action_decoder.eval()
if self.config.add_pos_embed:
self.position_embedding.eval()
if not self.tune_diffusion_model:
self.model.eval()
def sample_time(self, batch_size, device, dtype):
if self._beta_dist is None:
self._beta_dist = Beta(self._noise_beta_alpha, self._noise_beta_beta, validate_args=False)
sample = self._beta_dist.sample([batch_size]).to(device, dtype=dtype)
return (self.config.noise_s - sample) / self.config.noise_s
def prepare_input(self, batch: dict) -> BatchFeature:
return BatchFeature(data=batch)
def process_backbone_output(self, backbone_output: BatchFeature) -> BatchFeature:
backbone_features = backbone_output["backbone_features"]
backbone_features = self.vlln(backbone_features)
backbone_features = self.vl_self_attention(backbone_features)
backbone_output["backbone_features"] = backbone_features
return backbone_output
def forward(self, backbone_output: BatchFeature, action_input: BatchFeature) -> BatchFeature:
# Set frozen modules to eval
self.set_frozen_modules_to_eval_mode()
backbone_output = self.process_backbone_output(backbone_output)
if self.config.expand_batch is not None:
for k, v in backbone_output.items():
ndim = len(v.shape)
factors = [self.config.expand_batch]
while len(factors) < ndim:
factors.append(1)
factors = tuple(factors)
expanded = v.repeat(*factors)
backbone_output[k] = expanded
for k, v in action_input.items():
ndim = len(v.shape)
factors = [self.config.expand_batch]
while len(factors) < ndim:
factors.append(1)
factors = tuple(factors)
expanded = v.repeat(*factors)
action_input[k] = expanded
# Get vision and language embeddings.
vl_embs = backbone_output.backbone_features
device = vl_embs.device
# Get embodiment ID.
embodiment_id = action_input.embodiment_id
# Embed state.
state_features = self.state_encoder(action_input.state, embodiment_id)
# Embed noised action trajectory.
actions = action_input.action
noise = torch.randn(actions.shape, device=actions.device, dtype=actions.dtype)
t = self.sample_time(actions.shape[0], device=actions.device, dtype=actions.dtype)
t = t[:, None, None] # shape (B,1,1) for broadcast
noisy_trajectory = (1 - t) * noise + t * actions
velocity = actions - noise
# Convert (continuous) t -> discrete if needed
t_discretized = (t[:, 0, 0] * self.num_timestep_buckets).long()
action_features = self.action_encoder(noisy_trajectory, t_discretized, embodiment_id)
# Maybe add position embedding.
if self.config.add_pos_embed:
pos_ids = torch.arange(action_features.shape[1], dtype=torch.long, device=device)
pos_embs = self.position_embedding(pos_ids).unsqueeze(0)
action_features = action_features + pos_embs
# Join vision, language, state and action embedding along sequence dimension.
future_tokens = self.future_tokens.weight.unsqueeze(0).expand(vl_embs.shape[0], -1, -1)
sa_embs = torch.cat((state_features, future_tokens, action_features), dim=1)
vl_attn_mask = backbone_output.backbone_attention_mask
model_output = self.model(
hidden_states=sa_embs,
encoder_hidden_states=vl_embs,
encoder_attention_mask=vl_attn_mask,
timestep=t_discretized,
return_all_hidden_states=False, # NOTE (YL): not using flare now
)
pred = self.action_decoder(model_output, embodiment_id)
pred_actions = pred[:, -actions.shape[1] :]
# Slice out only the action portion of pred and target.
action_mask = action_input.action_mask
loss = F.mse_loss(pred_actions, velocity, reduction="none") * action_mask
loss = loss.sum() / action_mask.sum()
output_dict = {
"loss": loss,
}
return BatchFeature(data=output_dict)
@torch.no_grad()
def get_action(self, backbone_output: BatchFeature, action_input: BatchFeature) -> BatchFeature:
backbone_output = self.process_backbone_output(backbone_output)
# Get vision and language embeddings.
vl_embs = backbone_output.backbone_features
embodiment_id = action_input.embodiment_id
# Embed state.
state_features = self.state_encoder(action_input.state, embodiment_id)
# Set initial actions as the sampled noise.
batch_size = vl_embs.shape[0]
device = vl_embs.device
actions = torch.randn(
size=(batch_size, self.config.action_horizon, self.config.action_dim),
dtype=vl_embs.dtype,
device=device,
)
num_steps = self.num_inference_timesteps
dt = 1.0 / num_steps
# Run denoising steps.
for t in range(num_steps):
t_cont = t / float(num_steps) # e.g. goes 0, 1/N, 2/N, ...
t_discretized = int(t_cont * self.num_timestep_buckets)
# Embed noised action trajectory.
timesteps_tensor = torch.full(size=(batch_size,), fill_value=t_discretized, device=device)
action_features = self.action_encoder(actions, timesteps_tensor, embodiment_id)
# Maybe add position embedding.
if self.config.add_pos_embed:
pos_ids = torch.arange(action_features.shape[1], dtype=torch.long, device=device)
pos_embs = self.position_embedding(pos_ids).unsqueeze(0)
action_features = action_features + pos_embs
# Join vision, language, state and action embedding along sequence dimension.
future_tokens = self.future_tokens.weight.unsqueeze(0).expand(vl_embs.shape[0], -1, -1)
sa_embs = torch.cat((state_features, future_tokens, action_features), dim=1)
# Run model forward.
model_output = self.model(
hidden_states=sa_embs,
encoder_hidden_states=vl_embs,
timestep=timesteps_tensor,
)
pred = self.action_decoder(model_output, embodiment_id)
pred_velocity = pred[:, -self.action_horizon :]
# Update actions using euler integration.
actions = actions + dt * pred_velocity
return BatchFeature(data={"action_pred": actions})
@property
def device(self):
return next(iter(self.parameters())).device
@property
def dtype(self):
return next(iter(self.parameters())).dtype
+370 -46
View File
@@ -14,12 +14,229 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import math
from dataclasses import dataclass, field
from pathlib import Path
from lerobot.configs import FeatureType, NormalizationMode, PolicyFeature, PreTrainedConfig
from lerobot.optim import AdamWConfig, CosineDecayWithWarmupSchedulerConfig
from lerobot.optim import AdamWConfig, DiffuserSchedulerConfig
from lerobot.utils.constants import ACTION, OBS_STATE
from .utils import read_json
logger = logging.getLogger(__name__)
GROOT_N1_7 = "n1.7"
# Legacy GR00T N1.5 identifier. N1.5 is NOT a supported model_version (it is
# intentionally absent from _GROOT_MODEL_VERSION_ALIASES so normalize_groot_model_version
# still rejects it). It is retained only so that infer_groot_model_version can recognise
# an N1.5 base path/checkpoint and the N1.7 config/loader can reject the mismatch.
GROOT_N1_5 = "n1.5"
# Canonical guidance appended to every error raised when an N1.5 checkpoint, config,
# or processor pipeline is detected. Keep this message in sync with docs/source/groot.mdx.
GROOT_N1_5_REMOVAL_GUIDANCE = (
"GR00T N1.5 support was removed from LeRobot. "
"To keep using an N1.5 checkpoint, pin the last release that supports it: "
"`pip install 'lerobot==0.5.1'`. To use the current release, migrate to GR00T N1.7 "
"(model_version='n1.7', base model nvidia/GR00T-N1.7-3B)."
)
GROOT_N1_7_BASE_MODEL = "nvidia/GR00T-N1.7-3B"
GROOT_N1_7_BACKBONE_MODEL = "nvidia/Cosmos-Reason2-2B"
# Default GR00T N1.7 training resolution. Fallback if processor_config lacks sizing. Prevents mismatched
# full-res patchification by forcing a resize. Mirrored by GR00T_N1_7_DEFAULTS in groot_n1_7.py.
N1_7_DEFAULT_IMAGE_TARGET_SIZE = (256, 256)
N1_7_DEFAULT_IMAGE_CROP_SIZE = (230, 230)
GROOT_ACTION_DECODE_TRANSFORM_LIBERO = "libero"
# Sentinel meaning "the user did not pick an action decode transform": __post_init__ resolves it
# to the embodiment default ('libero' for 'libero_sim', otherwise None). It is distinct from an
# explicit 'none' (resolved to None) so an opt-out survives a draccus save/load round-trip.
GROOT_ACTION_DECODE_TRANSFORM_AUTO = "auto"
_GROOT_MODEL_VERSION_ALIASES = {
"n1.7": GROOT_N1_7,
"n1_7": GROOT_N1_7,
"n1d7": GROOT_N1_7,
"n17": GROOT_N1_7,
"1.7": GROOT_N1_7,
}
# Legacy N1.5 spellings, kept ONLY so they can be detected and rejected with
# GROOT_N1_5_REMOVAL_GUIDANCE (see GROOT_N1_5 above). Never map these to a supported version.
_GROOT_N1_5_VERSION_ALIASES = {"n1.5", "n1_5", "n1d5", "n15", "1.5"}
_GROOT_ACTION_DECODE_TRANSFORM_ALIASES = {
GROOT_ACTION_DECODE_TRANSFORM_AUTO: GROOT_ACTION_DECODE_TRANSFORM_AUTO,
"none": None,
"": None,
GROOT_ACTION_DECODE_TRANSFORM_LIBERO: GROOT_ACTION_DECODE_TRANSFORM_LIBERO,
}
def normalize_groot_model_version(model_version: str) -> str:
normalized = _GROOT_MODEL_VERSION_ALIASES.get(model_version.lower())
if normalized is None:
supported = GROOT_N1_7
message = f"Unsupported GR00T model_version '{model_version}'. Supported versions: {supported}."
if model_version.lower() in _GROOT_N1_5_VERSION_ALIASES:
message = f"{message} {GROOT_N1_5_REMOVAL_GUIDANCE}"
raise ValueError(message)
return normalized
def normalize_groot_action_decode_transform(transform: str | None) -> str | None:
if transform is None:
return None
normalized = _GROOT_ACTION_DECODE_TRANSFORM_ALIASES.get(transform.lower())
if normalized is None and transform.lower() not in _GROOT_ACTION_DECODE_TRANSFORM_ALIASES:
supported = ", ".join(
sorted(key for key, value in _GROOT_ACTION_DECODE_TRANSFORM_ALIASES.items() if value is not None)
)
raise ValueError(
f"Unsupported GR00T N1.7 action decode transform '{transform}'. "
f"Supported transforms: none, {supported}."
)
return normalized
def infer_groot_model_version(model_path: str | None) -> str | None:
if not model_path:
return None
model_path_lower = model_path.lower()
if "gr00t-n1.7" in model_path_lower or "gr00t_n1.7" in model_path_lower:
return GROOT_N1_7
# Detect legacy N1.5 paths so the N1.7 config/loader can reject the mismatch.
# N1.5 is unsupported, but it must still be recognised here to fail loudly
# rather than silently treating an N1.5 checkpoint as N1.7.
if "gr00t-n1.5" in model_path_lower or "gr00t_n1.5" in model_path_lower:
return GROOT_N1_5
config_version = _infer_groot_model_version_from_local_config(model_path)
if config_version is not None:
return config_version
return None
def is_raw_groot_n1_7_checkpoint(model_path: str | Path | None) -> bool:
if model_path is None:
return False
path = Path(model_path).expanduser()
if path.is_dir():
config_path = path / "config.json"
elif path.name == "config.json":
config_path = path
else:
return False
config = read_json(config_path)
return "type" not in config and _infer_groot_model_version_from_config(config) == GROOT_N1_7
def infer_groot_n1_7_embodiment_tag(model_path: str | Path | None) -> str | None:
if model_path is None:
return None
processor_config_path = Path(model_path).expanduser() / "processor_config.json"
processor_config = read_json(processor_config_path)
modality_configs = processor_config.get("processor_kwargs", {}).get("modality_configs", {})
if not isinstance(modality_configs, dict):
return None
if "libero_sim" in modality_configs:
return "libero_sim"
if len(modality_configs) == 1:
return next(iter(modality_configs))
return None
def infer_groot_n1_7_action_horizon(
model_path: str | Path | None, embodiment_tag: str | None = None
) -> int | None:
if model_path is None:
return None
processor_config_path = Path(model_path).expanduser() / "processor_config.json"
processor_config = read_json(processor_config_path)
processor_kwargs = processor_config.get("processor_kwargs", {})
if not isinstance(processor_kwargs, dict):
return None
modality_configs = processor_kwargs.get("modality_configs", {})
if not isinstance(modality_configs, dict):
return None
if embodiment_tag is None:
embodiment_tag = infer_groot_n1_7_embodiment_tag(model_path)
if embodiment_tag is None:
return None
embodiment_config = modality_configs.get(embodiment_tag, {})
if not isinstance(embodiment_config, dict):
return None
action_config = embodiment_config.get("action", {})
if not isinstance(action_config, dict):
return None
delta_indices = action_config.get("delta_indices", [])
if not isinstance(delta_indices, list):
return None
return len(delta_indices) or None
def infer_groot_n1_7_action_execution_horizon(
model_path: str | Path | None, embodiment_tag: str | None = None
) -> int | None:
action_horizon = infer_groot_n1_7_action_horizon(model_path, embodiment_tag)
if action_horizon is None:
return None
if embodiment_tag is None:
embodiment_tag = infer_groot_n1_7_embodiment_tag(model_path)
if embodiment_tag == "libero_sim":
# NVIDIA's N1.7 LIBERO rollout wrapper replans after 8 of the 16 decoded
# actions. Keeping that execution cadence avoids stale open-loop chunks.
return min(action_horizon, 8)
return action_horizon
def _infer_groot_model_version_from_local_config(model_path: str) -> str | None:
path = Path(model_path).expanduser()
if path.is_dir():
config_path = path / "config.json"
elif path.name == "config.json":
config_path = path
else:
return None
return _infer_groot_model_version_from_config(read_json(config_path))
def _infer_groot_model_version_from_config(config: dict) -> str | None:
model_version = config.get("model_version")
if isinstance(model_version, str):
if model_version.lower() in _GROOT_N1_5_VERSION_ALIASES:
return GROOT_N1_5
try:
return normalize_groot_model_version(model_version)
except ValueError:
return None
candidates = [config.get("model_type"), *(config.get("architectures") or [])]
for candidate in candidates:
if not isinstance(candidate, str):
continue
normalized = candidate.lower().replace("-", "_")
if normalized in {"gr00tn1d7", "gr00t_n1d7", "gr00t_n1_7"}:
return GROOT_N1_7
if normalized in {"gr00t_n1_5", "gr00tn1_5", "gr00t_n15", "gr00t_n1d5", "gr00tn1d5"}:
return GROOT_N1_5
if config.get("model_name") == GROOT_N1_7_BACKBONE_MODEL:
return GROOT_N1_7
# The Eagle VLM backbone is specific to pre-N1.7 GR00T checkpoints (N1.7 uses Cosmos/Qwen3-VL).
backbone_cfg = config.get("backbone_cfg")
if isinstance(backbone_cfg, dict) and "eagle_path" in backbone_cfg:
return GROOT_N1_5
return None
@PreTrainedConfig.register_subclass("groot")
@dataclass
@@ -28,35 +245,44 @@ class GrootConfig(PreTrainedConfig):
# Basic policy settings
n_obs_steps: int = 1
chunk_size: int = 50
n_action_steps: int = 50
chunk_size: int = 40
n_action_steps: int = 40
# Dimension settings (must match pretrained GR00T model expectations)
# Maximum state dimension. Shorter states will be zero-padded.
max_state_dim: int = 64
max_state_dim: int = 132
# Maximum action dimension. Shorter actions will be zero-padded.
max_action_dim: int = 32
max_action_dim: int = 132
# Normalization (start with identity, adjust as needed)
# GR00T normalizes state/action internally in its processor steps (min/max with
# q01/q99 percentiles, per embodiment), and the Qwen3-VL backbone's image processor
# handles image normalization. The policy therefore does NOT use LeRobot's
# NormalizerProcessorStep/UnnormalizerProcessorStep, so this mapping is intentionally
# IDENTITY for every feature and is not consulted by make_groot_pre_post_processors.
normalization_mapping: dict[str, NormalizationMode] = field(
default_factory=lambda: {
"VISUAL": NormalizationMode.IDENTITY,
"STATE": NormalizationMode.MEAN_STD,
"ACTION": NormalizationMode.MEAN_STD,
"STATE": NormalizationMode.IDENTITY,
"ACTION": NormalizationMode.IDENTITY,
}
)
# Image preprocessing (adjust to match Groot's expected input)
image_size: tuple[int, int] = (224, 224)
# Groot-specific model parameters
# Groot-specific model parameters (from groot_finetune_script.py)
# Path or HuggingFace model ID for the base GR00T N1.7 model whose backbone weights and
# checkpoint sidecars (statistics.json, processor_config.json, ...) are loaded. This is the
# model *source*, and is intentionally distinct from the inherited `pretrained_path`:
# `pretrained_path` (`--policy.path`) points at a saved LeRobot checkpoint directory whose
# `config.json` carries a `type` field, whereas a raw NVIDIA GR00T checkpoint has no such
# field and so can only be loaded through `base_model_path` (`--policy.base_model_path`).
# Defaults to GROOT_N1_7_BASE_MODEL when unset (resolved in __post_init__).
base_model_path: str | None = None
# Path or HuggingFace model ID for the base Groot model
base_model_path: str = "nvidia/GR00T-N1.5-3B"
# HF repo ID (or local path) that hosts vocab.json and merges.txt for Eagle tokenizer.
tokenizer_assets_repo: str = "lerobot/eagle2hg-processor-groot-n1p5"
# Optional named action transform applied after raw N1.7 checkpoint decoding and before env.step().
# 'auto' (default) resolves to the embodiment default ('libero' for 'libero_sim', otherwise no
# transform). Pass 'none' to explicitly disable the transform, including for 'libero_sim'.
action_decode_transform: str | None = GROOT_ACTION_DECODE_TRANSFORM_AUTO
# Embodiment tag to use for training (e.g. 'new_embodiment', 'gr1')
embodiment_tag: str = "new_embodiment"
@@ -75,38 +301,67 @@ class GrootConfig(PreTrainedConfig):
# Whether to fine-tune the diffusion model
tune_diffusion_model: bool = True
# LoRA parameters (from groot_finetune_script.py)
# Rank for the LORA model. If 0, no LORA will be used.
lora_rank: int = 0
# Whether to fine-tune the VL LayerNorm + VL self-attention projector in the action head.
tune_vlln: bool = True
# Alpha value for the LORA model
lora_alpha: int = 16
# Number of top LLM backbone layers to fine-tune (0 = none). Lets you adapt just the final
# language layers without unfreezing the whole backbone; independent of `tune_llm`, which tunes
# the entire LLM.
tune_top_llm_layers: int = 0
# Dropout rate for the LORA model
lora_dropout: float = 0.1
# Inference-time knob: Number of flow-matching denoising steps used to decode an action chunk.
# Trades inference latency for action quality.
# None keeps the checkpoint value (GR00T N1.7 default: 4).
num_inference_timesteps: int | None = None
# Whether to use the full model for LORA
lora_full_model: bool = False
# Inference-time knob: Real-Time Chunking (RTC) overlap-blend ramp rate, used when the RTC engine
# supplies a previous-chunk prefix. Higher values blend the overlapping prefix more aggressively.
# None keeps the checkpoint value (GR00T N1.7 default: 6.0).
rtc_ramp_rate: float | None = None
# Training parameters (matching groot_finetune_script.py)
# Inference-time knob: Whether to request the flash-attention-2 kernel for the Qwen3-VL backbone.
# flash-attn is an optional, user-managed optimization; when it is absent (the default),
# the backbone transparently falls back to SDPA, which is numerically equivalent.
# Set to True only after installing a flash-attn build matching your torch/CUDA env.
use_flash_attention: bool = False
# Enable GR00T-style state-relative action chunks (action chunk expressed relative to the current
# observation state).
use_relative_actions: bool = False
# relative_exclude_joints names the action dimensions that stay absolute; the
# match is substring/case-insensitive against the dataset action feature names. With the empty
# default every dimension is treated as relative, including the gripper -- set e.g. ["gripper"] to
# keep the gripper absolute, matching the Isaac-GR00T single-arm + absolute-gripper convention.
relative_exclude_joints: list[str] = field(default_factory=list)
# Training parameters
optimizer_lr: float = 1e-4
optimizer_betas: tuple[float, float] = (0.95, 0.999)
# Isaac-GR00T N1.7 fine-tunes with AdamW betas (0.9, 0.999).
optimizer_betas: tuple[float, float] = (0.9, 0.999)
optimizer_eps: float = 1e-8
optimizer_weight_decay: float = 1e-5
warmup_ratio: float = 0.05
use_bf16: bool = True
# The native N1.7 fine-tuning recipe keeps model parameters in FP32 and computes under BF16 autocast.
model_params_fp32: bool = True
# Dataset parameters
# Video backend to use for training ('decord' or 'torchvision_av')
# TODO(Steven): Remove these deprecated fields in a future release.
# Deprecated Isaac-GR00T runner / GR00T N1.5 fields, plus the (never-wired) LoRA fields — all
# unused by the LeRobot N1.7 implementation except the `tokenizer_assets_repo` N1.5 tripwire and
# the `image_size` legacy remap in __post_init__. They are kept ONLY so a config.json saved by an
# earlier lerobot release (notably a GR00T N1.5 checkpoint) still parses under draccus — which
# rejects unknown fields — and is then rejected with a clear N1.5 removal message rather than an
# opaque draccus decoding error.
image_size: tuple[int, int] = (256, 256) # image sizing is handled by the backbone's image processor.
tokenizer_assets_repo: str | None = None
lora_rank: int = 0
lora_alpha: int = 16
lora_dropout: float = 0.1
lora_full_model: bool = False
video_backend: str = "decord"
# Whether to balance dataset weights in mixture datasets
balance_dataset_weights: bool = True
# Whether to sample trajectories weighted by their length
balance_trajectory_weights: bool = True
# Optional dataset paths for delegating training to Isaac-GR00T runner
dataset_paths: list[str] | None = None
output_dir: str = "./tmp/gr00t"
save_steps: int = 1000
@@ -117,6 +372,65 @@ class GrootConfig(PreTrainedConfig):
resume: bool = False
def __post_init__(self):
if self.tokenizer_assets_repo is not None:
raise ValueError(
"Config sets 'tokenizer_assets_repo', which only existed for GR00T N1.5; this looks "
f"like a legacy GR00T N1.5 checkpoint or config. {GROOT_N1_5_REMOVAL_GUIDANCE}"
)
self.action_decode_transform = normalize_groot_action_decode_transform(self.action_decode_transform)
if self.base_model_path is None:
self.base_model_path = GROOT_N1_7_BASE_MODEL
# The N1.7 LIBERO checkpoints emit a [0, 1] gripper action, but the LIBERO
# simulator expects the OpenVLA/[-1, 1] sign convention. NVIDIA's rollout
# wrapper applies this conversion; mirror it here so eval on the
# 'libero_sim' embodiment grasps correctly instead of scoring 0% success.
# This matches the embodiment-specific handling already done for the
# action execution horizon (see infer_groot_n1_7_action_execution_horizon).
# Only the 'auto' sentinel resolves to the embodiment default; an explicit
# 'none' (normalized to None above) keeps the transform disabled.
if self.action_decode_transform == GROOT_ACTION_DECODE_TRANSFORM_AUTO:
self.action_decode_transform = (
GROOT_ACTION_DECODE_TRANSFORM_LIBERO if self.embodiment_tag == "libero_sim" else None
)
# GR00T N1.5-era default values (e.g. --policy.chunk_size=50 from old commands or
# stale configs) are migrated to the values the N1.7 checkpoints expect, with a
# warning. The dataclass defaults are already the N1.7 values, so a plain
# GrootConfig() never triggers this.
legacy_default_remaps = (
("max_state_dim", 64, 132),
("max_action_dim", 32, 132),
("chunk_size", 50, 40),
("n_action_steps", 50, 40),
("image_size", (224, 224), (256, 256)),
)
for field_name, legacy_value, n1_7_value in legacy_default_remaps:
current_value = getattr(self, field_name)
if isinstance(legacy_value, tuple):
current_value = tuple(current_value)
if current_value == legacy_value:
logger.warning(
"GrootConfig.%s=%s matches a legacy GR00T N1.5-era default; remapping it to %s, "
"the value expected by GR00T N1.7 checkpoints. Set a different value explicitly "
"if this is not what you want.",
field_name,
legacy_value,
n1_7_value,
)
setattr(self, field_name, n1_7_value)
inferred_version = infer_groot_model_version(self.base_model_path)
if inferred_version is not None and inferred_version != GROOT_N1_7:
message = (
f"GR00T model_version '{GROOT_N1_7}' does not match base_model_path "
f"'{self.base_model_path}', which looks like '{inferred_version}'."
)
if inferred_version == GROOT_N1_5:
message = f"{message} {GROOT_N1_5_REMOVAL_GUIDANCE}"
raise ValueError(message)
super().__post_init__()
if self.n_action_steps > self.chunk_size:
@@ -124,9 +438,6 @@ class GrootConfig(PreTrainedConfig):
f"n_action_steps ({self.n_action_steps}) cannot exceed chunk_size ({self.chunk_size})"
)
# groot_repo_path is now optional since we ported the components
# No validation needed
def validate_features(self) -> None:
"""Validate and set up input/output features for Groot."""
image_features = [key for key, feat in self.input_features.items() if feat.type == FeatureType.VISUAL]
@@ -173,15 +484,20 @@ class GrootConfig(PreTrainedConfig):
betas=self.optimizer_betas,
eps=self.optimizer_eps,
weight_decay=self.optimizer_weight_decay,
grad_clip_norm=1.0,
)
def get_scheduler_preset(self) -> CosineDecayWithWarmupSchedulerConfig:
"""Return scheduler configuration."""
return CosineDecayWithWarmupSchedulerConfig(
num_warmup_steps=int(10000 * self.warmup_ratio), # 5% warmup by default
num_decay_steps=10000, # Adjust based on training steps
peak_lr=self.optimizer_lr,
decay_lr=self.optimizer_lr * 0.1,
def get_scheduler_preset(self) -> DiffuserSchedulerConfig:
"""Return scheduler configuration.
Isaac-GR00T uses the HF Trainer cosine schedule with ~5% warmup over the
actual training update count; DiffuserSchedulerConfig wraps the same
diffusers/transformers `get_scheduler("cosine")` implementation and
derives num_training_steps from the outer --steps value at runtime.
"""
return DiffuserSchedulerConfig(
name="cosine",
num_warmup_steps=math.ceil(self.max_steps * self.warmup_ratio),
)
@property
@@ -192,7 +508,15 @@ class GrootConfig(PreTrainedConfig):
@property
def action_delta_indices(self) -> list[int]:
"""Return indices for delta actions."""
return list(range(min(self.chunk_size, 16)))
model_action_horizon = (
infer_groot_n1_7_action_horizon(self.base_model_path, self.embodiment_tag) or 40
)
return list(range(min(self.chunk_size, model_action_horizon)))
@property
def drop_n_last_frames(self) -> int:
"""Exclude episode tails that cannot supply a complete N1.7 action chunk."""
return max(0, len(self.action_delta_indices) - 1)
@property
def reward_delta_indices(self) -> None:
@@ -1,135 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
import copy
from transformers.configuration_utils import PretrainedConfig
from transformers.models.llama.configuration_llama import LlamaConfig
from transformers.models.qwen2.configuration_qwen2 import Qwen2Config
from transformers.models.qwen3.configuration_qwen3 import Qwen3Config
from transformers.models.siglip.configuration_siglip import SiglipVisionConfig
from transformers.utils import logging
logger = logging.get_logger(__name__)
class Eagle25VLConfig(PretrainedConfig):
model_type = "eagle_2_5_vl"
is_composition = True
sub_configs = {"vision_config": SiglipVisionConfig, "text_config": Qwen2Config}
def __init__(
self,
vision_config=None,
text_config=None,
use_backbone_lora=0,
use_llm_lora=0,
pad2square=False,
select_layer=-4,
force_image_size=None,
downsample_ratio=0.5,
template=None,
dynamic_image_size=False,
use_thumbnail=False,
loss_version="v1",
min_dynamic_tiles=1,
max_dynamic_tiles=6,
mlp_checkpoint=False,
initializer_range=0.02,
_attn_implementation="flash_attention_2",
_attn_implementation_autoset=False,
llm_config=None,
image_token_index=None,
use_pixel_shuffle=True,
mlp_connector_layers=2,
**kwargs,
):
super().__init__(**kwargs)
if vision_config is None:
vision_config = {"model_type": "siglip_vision_model"}
logger.info("vision_config is None. Initializing the InternVisionConfig with default values.")
if text_config is None:
text_config = {"architectures": ["Qwen2ForCausalLM"]}
logger.info(
"text_config is None. Initializing the LlamaConfig config with default values (`LlamaConfig`)."
)
if vision_config["model_type"] == "siglip_vision_model":
self.vision_config = SiglipVisionConfig(**vision_config)
else:
raise ValueError("Unsupported model_type: {}".format(vision_config["model_type"]))
if text_config["architectures"][0] == "LlamaForCausalLM":
self.text_config = LlamaConfig(**text_config)
elif text_config["architectures"][0] == "Qwen2ForCausalLM":
self.text_config = Qwen2Config(**text_config)
elif text_config["architectures"][0] == "Qwen3ForCausalLM":
self.text_config = Qwen3Config(**text_config)
else:
raise ValueError("Unsupported architecture: {}".format(text_config["architectures"][0]))
self.use_backbone_lora = use_backbone_lora
self.use_llm_lora = use_llm_lora
self.mlp_checkpoint = mlp_checkpoint
self.pad2square = pad2square
self.select_layer = select_layer
self.force_image_size = force_image_size
self.downsample_ratio = downsample_ratio
self.template = template
self.dynamic_image_size = dynamic_image_size
self.use_thumbnail = use_thumbnail
self.loss_version = loss_version
self.initializer_range = initializer_range
self.min_dynamic_tiles = min_dynamic_tiles
self.max_dynamic_tiles = max_dynamic_tiles
self.tie_word_embeddings = self.text_config.tie_word_embeddings
self._attn_implementation = _attn_implementation
self._attn_implementation_autoset = _attn_implementation_autoset
self.image_token_index = image_token_index
self.use_pixel_shuffle = use_pixel_shuffle
self.mlp_connector_layers = mlp_connector_layers
logger.info(f"min_dynamic_tiles: {self.min_dynamic_tiles}")
logger.info(f"max_dynamic_tiles: {self.max_dynamic_tiles}")
def to_dict(self):
"""
Serializes this instance to a Python dictionary. Override the default [`~PretrainedConfig.to_dict`].
Returns:
`Dict[str, any]`: Dictionary of all the attributes that make up this configuration instance,
"""
output = copy.deepcopy(self.__dict__)
output["vision_config"] = self.vision_config.to_dict()
output["text_config"] = self.text_config.to_dict()
output["model_type"] = self.__class__.model_type
output["use_backbone_lora"] = self.use_backbone_lora
output["use_llm_lora"] = self.use_llm_lora
output["pad2square"] = self.pad2square
output["select_layer"] = self.select_layer
output["force_image_size"] = self.force_image_size
output["downsample_ratio"] = self.downsample_ratio
output["template"] = self.template
output["dynamic_image_size"] = self.dynamic_image_size
output["use_thumbnail"] = self.use_thumbnail
output["min_dynamic_tiles"] = self.min_dynamic_tiles
output["max_dynamic_tiles"] = self.max_dynamic_tiles
output["tie_word_embeddings"] = self.tie_word_embeddings
output["_attn_implementation"] = self._attn_implementation
output["_attn_implementation_autoset"] = self._attn_implementation_autoset
output["use_pixel_shuffle"] = self.use_pixel_shuffle
output["mlp_connector_layers"] = self.mlp_connector_layers
return output
@@ -1,503 +0,0 @@
# --------------------------------------------------------
# NVIDIA
# Copyright (c) 2025 NVIDIA
# Licensed under The MIT License [see LICENSE for details]
# --------------------------------------------------------
from __future__ import annotations
# copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/image_processing_llava_onevision_fast.py
from transformers.image_processing_utils import (
BatchFeature,
get_patch_output_size,
)
from transformers.image_processing_utils_fast import (
BaseImageProcessorFast,
ImagesKwargs,
group_images_by_shape,
reorder_images,
)
from transformers.image_utils import (
IMAGENET_STANDARD_MEAN, # 0.5, 0.5, 0.5
IMAGENET_STANDARD_STD, # 0.5, 0.5, 0.5
ChannelDimension,
ImageInput,
PILImageResampling,
SizeDict,
get_image_size,
make_flat_list_of_images,
validate_kwargs,
)
from transformers.processing_utils import Unpack
from transformers.utils import (
TensorType,
add_start_docstrings,
is_torch_available,
is_torchvision_v2_available,
)
from transformers.video_utils import VideoInput
if is_torch_available():
import torch
if is_torchvision_v2_available():
from torchvision.transforms.v2 import functional as F # noqa: N812
from transformers.image_utils import pil_torch_interpolation_mapping
else:
from torchvision.transforms import functional as F # noqa: N812
def crop(img: torch.Tensor, left: int, top: int, right: int, bottom: int) -> torch.Tensor:
"""Crop the given numpy array.
Args:
img (torch.Tensor): Image to be cropped. Format should be (C, H, W).
left (int): The left coordinate of the crop box.
top (int): The top coordinate of the crop box.
right (int): The right coordinate of the crop box.
bottom (int): The bottom coordinate of the crop box.
Returns:
torch.Tensor: Cropped image.
"""
if not isinstance(img, torch.Tensor):
raise TypeError(f"img should be torch.Tensor. Got {type(img)}")
if img.ndim not in [2, 3]:
raise ValueError(f"Image should have 2 or 3 dimensions. Got {img.ndim}")
img_height = img.shape[1]
img_width = img.shape[2]
if top < 0 or left < 0 or bottom > img_height or right > img_width:
raise ValueError("Crop coordinates out of bounds")
if top >= bottom or left >= right:
raise ValueError("Invalid crop coordinates")
return img[:, top:bottom, left:right]
class Eagle25VLFastImageProcessorKwargs(ImagesKwargs):
max_dynamic_tiles: int | None
min_dynamic_tiles: int | None
use_thumbnail: bool | None
pad_during_tiling: bool | None
do_pad: bool | None
@add_start_docstrings(
"Constructs a fast ConvNeXT image processor. Based on [`SiglipImageProcessor`] with incorporation of processing each video frame.",
# BASE_IMAGE_PROCESSOR_FAST_DOCSTRING, TODO: this was depreciated from transformers remove!
"""
image_grid_pinpoints (`List[List[int]]`, *optional*):
A list of possible resolutions to use for processing high resolution images. The best resolution is selected
based on the original size of the image. Can be overridden by `image_grid_pinpoints` in the `preprocess`
method. Not used for processing videos.
do_pad (`bool`, *optional*):
Whether to pad the image. If `True`, will pad the patch dimension of the images in the batch to the largest
number of patches in the batch. Padding will be applied to the bottom and right with zeros.
""",
)
class Eagle25VLImageProcessorFast(BaseImageProcessorFast):
resample = PILImageResampling.BICUBIC
image_mean = IMAGENET_STANDARD_MEAN
image_std = IMAGENET_STANDARD_STD
size = {"height": 448, "width": 448}
default_to_square = False
crop_size = None
do_resize = True
do_center_crop = None
do_rescale = True
do_normalize = True
do_convert_rgb = True
do_pad = True
max_dynamic_tiles = 12
min_dynamic_tiles = 1
use_thumbnail = True
pad_during_tiling = False
valid_kwargs = Eagle25VLFastImageProcessorKwargs
model_input_names = ["pixel_values_videos"]
def __init__(self, **kwargs: Unpack[Eagle25VLFastImageProcessorKwargs]):
super().__init__(**kwargs)
@add_start_docstrings(
# BASE_IMAGE_PROCESSOR_FAST_DOCSTRING_PREPROCESS, TODO: this was depreciated from transformers remove!
"""
max_dynamic_tiles (`int`, *optional*):
The maximum number of dynamic tiles to use for processing high resolution images.
min_dynamic_tiles (`int`, *optional*):
The minimum number of dynamic tiles to use for processing high resolution images.
use_thumbnail (`bool`, *optional*):
Whether to use a thumbnail for processing high resolution images.
pad_during_tiling (`bool`, *optional*):
Whether to pad the image during tiling.
do_pad (`bool`, *optional*):
Whether to pad the image. If `True`, will pad the patch dimension of the images in the batch to the largest
number of patches in the batch. Padding will be applied to the bottom and right with zeros.
""",
)
# NOTE(YL): we will overload the preprocess method to add the image_flags
# def preprocess(
# self, images: ImageInput, **kwargs: Unpack[Eagle25VLFastImageProcessorKwargs]
# ) -> BatchFeature:
# return super().preprocess(images, **kwargs)
def _prepare_images_structure(
self,
images: ImageInput,
expected_ndims: int = 3,
) -> ImageInput:
"""
Prepare the images structure for processing.
Args:
images (`ImageInput`):
The input images to process.
expected_ndims (`int`, *optional*, defaults to 3):
Expected number of dimensions for the images (added for transformers >=4.53.0 compatibility).
Returns:
`ImageInput`: The images with a valid nesting.
"""
return make_flat_list_of_images(images)
def _resize_for_patching(
self,
image: torch.Tensor,
target_resolution: tuple,
interpolation: F.InterpolationMode,
input_data_format: ChannelDimension,
) -> torch.Tensor:
"""
Resizes an image to a target resolution while maintaining aspect ratio.
Args:
image ("torch.Tensor"):
The input image.
target_resolution (tuple):
The target resolution (height, width) of the image.
interpolation (`InterpolationMode`):
Resampling filter to use if resizing the image.
input_data_format (`ChannelDimension` or `str`):
The channel dimension format of the input image.
Returns:
"torch.Tensor": The resized and padded image.
"""
new_height, new_width = get_patch_output_size(image, target_resolution, input_data_format)
# Resize the image
resized_image = F.resize(image, (new_height, new_width), interpolation=interpolation)
return resized_image
def find_closest_aspect_ratio(self, aspect_ratio, target_ratios, width, height, image_size):
"""
previous version mainly focus on ratio.
We also consider area ratio here.
"""
best_factor = float("-inf")
best_ratio = (1, 1)
area = width * height
for ratio in target_ratios:
target_aspect_ratio = ratio[0] / ratio[1]
# ratio_diff = abs(aspect_ratio - target_aspect_ratio)
# area_ratio = (ratio[0] * ratio[1] * image_size * image_size) / area
"""
new area > 60% of original image area is enough.
"""
factor_based_on_area_n_ratio = min(
(ratio[0] * ratio[1] * image_size * image_size) / area, 0.6
) * min(target_aspect_ratio / aspect_ratio, aspect_ratio / target_aspect_ratio)
if factor_based_on_area_n_ratio > best_factor:
best_factor = factor_based_on_area_n_ratio
best_ratio = ratio
return best_ratio
def _pad_for_patching(
self, image: torch.Tensor, target_resolution: tuple, input_data_format: ChannelDimension
) -> torch.Tensor:
"""
Pad an image to a target resolution while maintaining aspect ratio.
"""
target_height, target_width = target_resolution
new_height, new_width = get_patch_output_size(image, target_resolution, input_data_format)
paste_x = (target_width - new_width) // 2
paste_y = (target_height - new_height) // 2
padded_image = F.pad(image, padding=[paste_x, paste_y, paste_x, paste_y])
return padded_image
def _get_image_patches(
self,
image: torch.Tensor,
min_num: int,
max_num: int,
size: tuple,
tile_size: int,
use_thumbnail: bool,
interpolation: F.InterpolationMode,
pad_during_tiling: bool,
) -> list[torch.Tensor]:
image_size = get_image_size(image, channel_dim=ChannelDimension.FIRST)
orig_height, orig_width = image_size
aspect_ratio = orig_width / orig_height
# calculate the existing image aspect ratio
target_ratios = {
(i, j)
for n in range(min_num, max_num + 1)
for i in range(1, n + 1)
for j in range(1, n + 1)
if i * j <= max_num and i * j >= min_num
}
target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
# find the closest aspect ratio to the target
target_aspect_ratio = self.find_closest_aspect_ratio(
aspect_ratio, target_ratios, orig_width, orig_height, tile_size
)
# calculate the target width and height
target_width = tile_size * target_aspect_ratio[0]
target_height = tile_size * target_aspect_ratio[1]
blocks = target_aspect_ratio[0] * target_aspect_ratio[1]
if pad_during_tiling:
resized_image = self._resize_for_patching(
image,
(target_height, target_width),
interpolation=interpolation,
input_data_format=ChannelDimension.FIRST,
)
padded_image = self._pad_for_patching(
resized_image,
(target_height, target_width),
input_data_format=ChannelDimension.FIRST,
)
image_used_to_split = padded_image
else:
image_used_to_split = F.resize(image, (target_height, target_width), interpolation=interpolation)
processed_tiles = []
for i in range(blocks):
box = (
(i % (target_width // tile_size)) * tile_size,
(i // (target_width // tile_size)) * tile_size,
((i % (target_width // tile_size)) + 1) * tile_size,
((i // (target_width // tile_size)) + 1) * tile_size,
)
# split the image
split_img = crop(image_used_to_split, box[0], box[1], box[2], box[3])
processed_tiles.append(split_img)
assert len(processed_tiles) == blocks
if use_thumbnail and len(processed_tiles) != 1:
thumbnail_img = F.resize(image, (tile_size, tile_size), interpolation=interpolation)
processed_tiles.append(thumbnail_img)
return processed_tiles
def _pad_for_batching(
self,
pixel_values: list[torch.Tensor],
) -> list[torch.Tensor]:
"""
Pads images on the `num_of_patches` dimension with zeros to form a batch of same number of patches.
Args:
pixel_values (`List[torch.Tensor]`):
An array of pixel values of each images of shape (`batch_size`, `num_patches`, `image_in_3D`)
Returns:
List[`torch.Tensor`]: The padded images.
"""
max_patch = max(len(x) for x in pixel_values)
pixel_values = [
torch.nn.functional.pad(image, pad=[0, 0, 0, 0, 0, 0, 0, max_patch - image.shape[0]])
for image in pixel_values
]
return pixel_values
def _preprocess(
self,
images: list[torch.Tensor],
do_resize: bool,
size: SizeDict,
max_dynamic_tiles: int,
min_dynamic_tiles: int,
use_thumbnail: bool,
pad_during_tiling: bool,
interpolation: F.InterpolationMode | None,
do_center_crop: bool,
crop_size: SizeDict,
do_rescale: bool,
rescale_factor: float,
do_normalize: bool,
image_mean: float | list[float] | None,
image_std: float | list[float] | None,
do_pad: bool,
return_tensors: str | TensorType | None,
pad_size: SizeDict | None = None, # Added for transformers >=4.53.0 compatibility
disable_grouping: bool | None = None, # Added for transformers >=4.53.0 compatibility
) -> BatchFeature:
processed_images = []
image_sizes = []
# Determine the size tuple
if size and size.height and size.width:
size_tuple = (size.height, size.width)
else:
size_tuple = (size.shortest_edge, size.shortest_edge)
# Determine the patch size
if crop_size and crop_size.height:
tile_size = crop_size.height
elif size and size.height:
tile_size = size.height
else:
tile_size = size.shortest_edge
for image in images:
image_patches = self._get_image_patches(
image,
min_num=min_dynamic_tiles,
max_num=max_dynamic_tiles,
size=size_tuple,
tile_size=tile_size,
use_thumbnail=use_thumbnail,
interpolation=interpolation,
pad_during_tiling=pad_during_tiling,
)
# Group images by size for batched processing
processed_image_patches_grouped = {}
# Added for transformers >=4.53.0 compatibility
grouped_image_patches, grouped_image_patches_index = group_images_by_shape(
image_patches,
disable_grouping=disable_grouping,
)
for shape, stacked_image_patches in grouped_image_patches.items():
if do_resize:
stacked_image_patches = self.resize(
image=stacked_image_patches,
size=size,
interpolation=interpolation,
)
if do_center_crop:
stacked_image_patches = self.center_crop(stacked_image_patches, crop_size)
# Fused rescale and normalize
stacked_image_patches = self.rescale_and_normalize(
stacked_image_patches,
do_rescale,
rescale_factor,
do_normalize,
image_mean,
image_std,
)
processed_image_patches_grouped[shape] = stacked_image_patches
processed_image_patches = reorder_images(
processed_image_patches_grouped, grouped_image_patches_index
)
processed_image_patches = (
torch.stack(processed_image_patches, dim=0) if return_tensors else processed_image_patches
)
processed_images.append(processed_image_patches)
image_sizes.append(get_image_size(image, ChannelDimension.FIRST))
if do_pad:
processed_images = self._pad_for_batching(processed_images)
# processed_images = torch.stack(processed_images, dim=0) if return_tensors else processed_images
processed_images = torch.cat(processed_images, dim=0) if return_tensors else processed_images
return BatchFeature(
data={"pixel_values": processed_images, "image_sizes": image_sizes},
tensor_type=return_tensors,
)
def preprocess(
self,
images: ImageInput,
videos: VideoInput = None,
**kwargs: Unpack[Eagle25VLFastImageProcessorKwargs],
) -> BatchFeature:
validate_kwargs(
captured_kwargs=kwargs.keys(),
valid_processor_keys=self.valid_kwargs.__annotations__.keys(),
)
# Set default kwargs from self. This ensures that if a kwarg is not provided
# by the user, it gets its default value from the instance, or is set to None.
for kwarg_name in self.valid_kwargs.__annotations__:
kwargs.setdefault(kwarg_name, getattr(self, kwarg_name, None))
# Extract parameters that are only used for preparing the input images
do_convert_rgb = kwargs.pop("do_convert_rgb")
input_data_format = kwargs.pop("input_data_format")
device = kwargs.pop("device")
# Prepare input images
# transformers >= 4.53.0: uses _prepare_image_like_inputs instead of _prepare_input_images
if images is not None:
images = self._prepare_image_like_inputs(
images=images,
do_convert_rgb=do_convert_rgb,
input_data_format=input_data_format,
device=device,
)
if videos is not None:
videos = self._prepare_image_like_inputs(
images=videos,
do_convert_rgb=do_convert_rgb,
input_data_format=input_data_format,
device=device,
)
# Update kwargs that need further processing before being validated
kwargs = self._further_process_kwargs(**kwargs)
# Validate kwargs
self._validate_preprocess_kwargs(**kwargs)
# torch resize uses interpolation instead of resample
# Added for transformers >=4.53.0 compatibility
resample = kwargs.pop("resample", self.resample)
kwargs["interpolation"] = (
pil_torch_interpolation_mapping[resample]
if isinstance(resample, PILImageResampling | int)
else resample
)
# Filter kwargs to only include those accepted by _preprocess
valid_preprocess_kwargs = {
"do_resize",
"size",
"max_dynamic_tiles",
"min_dynamic_tiles",
"use_thumbnail",
"pad_during_tiling",
"interpolation",
"do_center_crop",
"crop_size",
"do_rescale",
"rescale_factor",
"do_normalize",
"image_mean",
"image_std",
"do_pad",
"return_tensors",
"pad_size",
"disable_grouping",
}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_preprocess_kwargs}
if images is not None:
return self._preprocess(images, **filtered_kwargs)
elif videos is not None:
return self._preprocess(videos, **filtered_kwargs)
__all__ = ["Eagle25VLImageProcessorFast"]
@@ -1,396 +0,0 @@
# --------------------------------------------------------
# NVIDIA
# Copyright (c) 2025 NVIDIA
# Licensed under The MIT License [see LICENSE for details]
# --------------------------------------------------------
import inspect
import torch
import torch.utils.checkpoint as cp
from peft import LoraConfig, get_peft_model
from torch import nn
from torch.nn import CrossEntropyLoss
from transformers import GenerationConfig
from transformers.generation import GenerationMixin
from transformers.modeling_outputs import CausalLMOutputWithPast
from transformers.modeling_utils import PreTrainedModel
from transformers.models.llama.modeling_llama import LlamaForCausalLM
from transformers.models.qwen2.modeling_qwen2 import Qwen2ForCausalLM
from transformers.models.qwen3.modeling_qwen3 import Qwen3ForCausalLM
from transformers.models.siglip.modeling_siglip import SiglipVisionModel
from transformers.utils import add_start_docstrings, logging
from .configuration_eagle2_5_vl import Eagle25VLConfig
logger = logging.get_logger(__name__)
# copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/modeling_llava_onevision.py#L241C1-L280C1
EAGLE2_5_VL_START_DOCSTRING = r"""
This model inherits from [`PreTrainedModel`]. Check the superclass documentation for the generic methods the
library implements for all its model (such as downloading or saving, resizing the input embeddings, pruning heads
etc.)
This model is also a PyTorch [torch.nn.Module](https://pytorch.org/docs/stable/nn.html#torch.nn.Module) subclass.
Use it as a regular PyTorch Module and refer to the PyTorch documentation for all matter related to general usage
and behavior.
Parameters:
config ([`Eagle25VLConfig`]):
Model configuration class with all the parameters of the model. Initializing with a config file does not
load the weights associated with the model, only the configuration. Check out the
[`~PreTrainedModel.from_pretrained`] method to load the model weights.
"""
@add_start_docstrings(
"The bare Eagle2_5_VL Model outputting raw hidden-states without any specific head on top.",
EAGLE2_5_VL_START_DOCSTRING,
)
class Eagle25VLPreTrainedModel(PreTrainedModel):
config_class = Eagle25VLConfig
base_model_prefix = "model"
main_input_name = "input_ids"
supports_gradient_checkpointing = True
_no_split_modules = [
"Qwen2DecoderLayer",
"LlamaDecoderLayer",
"Siglip2EncoderLayer",
"SiglipEncoderLayer",
]
_skip_keys_device_placement = "past_key_values"
_supports_flash_attn = True
_supports_flash_attn_2 = True
_supports_cache_class = True
_supports_static_cache = True
_supports_quantized_cache = True
_supports_sdpa = True
def _init_weights(self, module):
std = self.config.initializer_range
if isinstance(module, nn.Linear | nn.Conv2d):
module.weight.data.normal_(mean=0.0, std=std)
if module.bias is not None:
module.bias.data.zero_()
elif isinstance(module, nn.Embedding):
module.weight.data.normal_(mean=0.0, std=std)
if module.padding_idx is not None:
module.weight.data[module.padding_idx].zero_()
class Eagle25VLForConditionalGeneration(Eagle25VLPreTrainedModel, GenerationMixin):
config_class = Eagle25VLConfig
def __init__(self, config: Eagle25VLConfig, vision_model=None, language_model=None):
super().__init__(config)
image_size = config.force_image_size or config.vision_config.image_size
patch_size = config.vision_config.patch_size
self.patch_size = patch_size
if config.use_pixel_shuffle:
self.num_image_token = int((image_size // patch_size) ** 2 * (config.downsample_ratio**2))
else:
self.num_image_token = int((image_size // patch_size) ** 2)
self.select_layer = config.select_layer
self.downsample_ratio = config.downsample_ratio
self.loss_version = config.loss_version
self.mlp_checkpoint = config.mlp_checkpoint
self.use_pixel_shuffle = config.use_pixel_shuffle
self.mlp_connector_layers = config.mlp_connector_layers
logger.info(f"num_image_token: {self.num_image_token}")
logger.info(f"mlp_checkpoint: {self.mlp_checkpoint}")
if vision_model is not None:
self.vision_model = vision_model
else:
if config.vision_config.model_type == "siglip_vision_model":
config.vision_config._attn_implementation = "flash_attention_2"
self.vision_model = SiglipVisionModel(config.vision_config)
else:
raise NotImplementedError(f"{config.vision_config.model_type} is not implemented.")
if language_model is not None:
self.language_model = language_model
else:
if config.text_config.architectures[0] == "LlamaForCausalLM":
self.language_model = LlamaForCausalLM(config.text_config)
elif config.text_config.architectures[0] == "Phi3ForCausalLM":
raise NotImplementedError("Phi3 is not implemented.")
# self.language_model = Phi3ForCausalLM(config.text_config)
elif config.text_config.architectures[0] == "Qwen2ForCausalLM":
assert config.text_config._attn_implementation == "flash_attention_2", (
f"Qwen2 must use flash_attention_2 but got {config.text_config._attn_implementation}"
)
self.language_model = Qwen2ForCausalLM(config.text_config)
elif config.text_config.architectures[0] == "Qwen3ForCausalLM":
self.language_model = Qwen3ForCausalLM(config.text_config)
else:
raise NotImplementedError(f"{config.text_config.architectures[0]} is not implemented.")
vit_hidden_size = config.vision_config.hidden_size
llm_hidden_size = config.text_config.hidden_size
if config.mlp_connector_layers == 2:
self.mlp1 = nn.Sequential(
nn.LayerNorm(vit_hidden_size * int(1 / self.downsample_ratio) ** 2),
nn.Linear(vit_hidden_size * int(1 / self.downsample_ratio) ** 2, llm_hidden_size),
nn.GELU(),
nn.Linear(llm_hidden_size, llm_hidden_size),
)
elif config.mlp_connector_layers == 1 and config.use_pixel_shuffle:
self.mlp1 = nn.Sequential(
nn.Linear(vit_hidden_size * int(1 / self.downsample_ratio) ** 2, llm_hidden_size),
)
elif config.mlp_connector_layers == 1 and not config.use_pixel_shuffle:
self.mlp1 = nn.Sequential(
nn.Linear(vit_hidden_size, llm_hidden_size),
)
else:
raise NotImplementedError(f"{config.mlp_connector_layers} is not implemented.")
self.image_token_index = config.image_token_index
self.neftune_alpha = None
if config.use_backbone_lora:
self.wrap_backbone_lora(r=config.use_backbone_lora, lora_alpha=2 * config.use_backbone_lora)
self.use_llm_lora = config.use_llm_lora
if config.use_llm_lora:
self.wrap_llm_lora(r=config.use_llm_lora, lora_alpha=2 * config.use_llm_lora)
self.check_forward_kwargs()
def check_forward_kwargs(self):
# We intentionally avoid using **kwargs in forward because Hugging Face Transformers
# has special handling for functions with **kwargs parameters that would affect
# how our model is processed during training and inference.
forward_params = inspect.signature(self.forward).parameters
assert not any(k.kind == inspect.Parameter.VAR_KEYWORD for k in forward_params.values())
def wrap_backbone_lora(self, r=128, lora_alpha=256, lora_dropout=0.05):
lora_config = LoraConfig(
r=r,
target_modules=[
"self_attn.q_proj",
"self_attn.k_proj",
"self_attn.v_proj",
"self_attn.out_proj",
"mlp.fc1",
"mlp.fc2",
],
lora_alpha=lora_alpha,
lora_dropout=lora_dropout,
)
self.vision_model = get_peft_model(self.vision_model, lora_config)
self.vision_model.print_trainable_parameters()
def wrap_llm_lora(self, r=128, lora_alpha=256, lora_dropout=0.05):
lora_config = LoraConfig(
r=r,
target_modules=[
"self_attn.q_proj",
"self_attn.k_proj",
"self_attn.v_proj",
"self_attn.o_proj",
"mlp.gate_proj",
"mlp.down_proj",
"mlp.up_proj",
],
lora_alpha=lora_alpha,
lora_dropout=lora_dropout,
task_type="CAUSAL_LM",
)
self.language_model = get_peft_model(self.language_model, lora_config)
self.language_model.enable_input_require_grads()
self.language_model.print_trainable_parameters()
self.use_llm_lora = True
def forward(
self,
pixel_values: torch.FloatTensor,
input_ids: torch.LongTensor = None,
attention_mask: torch.Tensor | None = None,
position_ids: torch.LongTensor | None = None,
image_flags: torch.LongTensor | None = None,
past_key_values: list[torch.FloatTensor] | None = None,
labels: torch.LongTensor | None = None,
use_cache: bool | None = None,
output_attentions: bool | None = None,
output_hidden_states: bool | None = None,
return_dict: bool | None = None,
num_tiles_list: list[torch.Tensor] | None = None,
) -> tuple | CausalLMOutputWithPast:
return_dict = return_dict if return_dict is not None else self.config.use_return_dict
input_embeds = self.language_model.get_input_embeddings()(input_ids)
vit_embeds = self.extract_feature(pixel_values)
if image_flags is not None:
image_flags = image_flags.view(-1)
vit_embeds = vit_embeds[image_flags == 1]
b, n, c = input_embeds.shape
input_embeds = input_embeds.reshape(b * n, c)
input_ids = input_ids.reshape(b * n)
selected = input_ids == self.image_token_index
try:
input_embeds[selected] = input_embeds[selected] * 0.0 + vit_embeds.reshape(-1, c)
except Exception as e:
vit_embeds = vit_embeds.reshape(-1, c)
print(
f"warning: {e}, input_embeds[selected].shape={input_embeds[selected].shape}, "
f"vit_embeds.shape={vit_embeds.shape}"
)
n_token = selected.sum()
input_embeds[selected] = input_embeds[selected] * 0.0 + vit_embeds[:n_token]
input_embeds = input_embeds.reshape(b, n, c)
outputs = self.language_model(
inputs_embeds=input_embeds,
attention_mask=attention_mask,
position_ids=position_ids,
past_key_values=past_key_values,
use_cache=use_cache,
output_attentions=output_attentions,
output_hidden_states=output_hidden_states,
)
logits = outputs.logits
loss = None
if labels is not None:
# Shift so that tokens < n predict n
shift_logits = logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
# Flatten the tokens
loss_fct = CrossEntropyLoss()
shift_logits = shift_logits.view(-1, self.language_model.config.vocab_size)
shift_labels = shift_labels.view(-1)
# Enable model parallelism
shift_labels = shift_labels.to(shift_logits.device)
loss = loss_fct(shift_logits, shift_labels)
if not return_dict:
output = (logits,) + outputs[1:]
return (loss,) + output if loss is not None else output
return CausalLMOutputWithPast(
loss=loss,
logits=logits,
past_key_values=outputs.past_key_values,
hidden_states=outputs.hidden_states,
attentions=outputs.attentions,
)
def pixel_shuffle(self, x, scale_factor=0.5):
n, w, h, c = x.size()
# N, W, H, C --> N, W, H * scale, C // scale
x = x.view(n, w, int(h * scale_factor), int(c / scale_factor))
# N, W, H * scale, C // scale --> N, H * scale, W, C // scale
x = x.permute(0, 2, 1, 3).contiguous()
# N, H * scale, W, C // scale --> N, H * scale, W * scale, C // (scale ** 2)
x = x.view(n, int(h * scale_factor), int(w * scale_factor), int(c / (scale_factor * scale_factor)))
x = x.permute(0, 2, 1, 3).contiguous()
return x
def extract_feature(self, pixel_values):
if self.select_layer == -1:
vit_embeds = self.vision_model(
pixel_values=pixel_values, output_hidden_states=False, return_dict=True
)
if hasattr(vit_embeds, "last_hidden_state"):
vit_embeds = vit_embeds.last_hidden_state
else:
vit_embeds = self.vision_model(
pixel_values=pixel_values, output_hidden_states=True, return_dict=True
).hidden_states[self.select_layer]
if self.use_pixel_shuffle:
h = w = int(vit_embeds.shape[1] ** 0.5)
vit_embeds = vit_embeds.reshape(vit_embeds.shape[0], h, w, -1)
vit_embeds = self.pixel_shuffle(
vit_embeds, scale_factor=self.downsample_ratio
) # torch.Size([B, 1024, 1024]) -> torch.Size([B, 16, 16, 4096])
vit_embeds = vit_embeds.reshape(
vit_embeds.shape[0], -1, vit_embeds.shape[-1]
) # torch.Size([B, 16, 16, 4096]) -> torch.Size([B, 256, 4096])
if self.mlp_checkpoint and vit_embeds.requires_grad:
vit_embeds = cp.checkpoint(self.mlp1, vit_embeds)
else:
vit_embeds = self.mlp1(vit_embeds)
return vit_embeds
@torch.no_grad()
def generate(
self,
pixel_values: torch.FloatTensor | None = None,
input_ids: torch.FloatTensor | None = None,
attention_mask: torch.LongTensor | None = None,
visual_features: torch.FloatTensor | None = None,
generation_config: GenerationConfig | None = None,
output_hidden_states: bool | None = None,
image_sizes: list[tuple[int, int]] | None = None,
**generate_kwargs,
) -> torch.LongTensor:
if pixel_values is not None:
if visual_features is not None:
vit_embeds = visual_features
else:
vit_embeds = self.extract_feature(pixel_values)
input_embeds = self.language_model.get_input_embeddings()(input_ids)
b, n, c = input_embeds.shape
input_embeds = input_embeds.reshape(b * n, c)
input_ids = input_ids.reshape(b * n)
selected = input_ids == self.config.image_token_index
assert selected.sum() != 0
input_embeds[selected] = vit_embeds.reshape(-1, c).to(input_embeds.device)
input_embeds = input_embeds.reshape(b, n, c)
else:
input_embeds = self.language_model.get_input_embeddings()(input_ids)
if "use_cache" not in generate_kwargs:
generate_kwargs["use_cache"] = True
outputs = self.language_model.generate(
inputs_embeds=input_embeds,
attention_mask=attention_mask,
generation_config=generation_config,
output_hidden_states=output_hidden_states,
**generate_kwargs,
)
return outputs
# Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.get_input_embeddings
def get_input_embeddings(self):
return self.language_model.get_input_embeddings()
# Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.set_input_embeddings
def set_input_embeddings(self, value):
self.language_model.set_input_embeddings(value)
# Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.get_output_embeddings
def get_output_embeddings(self):
return self.language_model.get_output_embeddings()
# Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.set_output_embeddings
def set_output_embeddings(self, new_embeddings):
self.language_model.set_output_embeddings(new_embeddings)
# Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.set_decoder
def set_decoder(self, decoder):
self.language_model.set_decoder(decoder)
# Copied from transformers.models.llava_next.modeling_llava_next.LlavaNextForConditionalGeneration.get_decoder
def get_decoder(self):
return self.language_model.get_decoder()
@@ -1,541 +0,0 @@
# Copyright 2024 The HuggingFace Inc. team.
#
# 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.
"""
Processor class for Eagle25VL.
copy from https://github.com/huggingface/transformers/blob/main/src/transformers/models/llava_onevision/processing_llava_onevision.py
"""
import base64
import os
import re
from io import BytesIO
import requests
import torch
from PIL import Image
from transformers.feature_extraction_utils import BatchFeature
from transformers.image_utils import ImageInput
from transformers.processing_utils import ProcessingKwargs, ProcessorMixin, Unpack
from transformers.tokenization_utils_base import PreTokenizedInput, TextInput
from transformers.utils import logging
from transformers.video_utils import VideoInput
logger = logging.get_logger(__name__)
FRAME_FACTOR = 2
FPS = 2.0
FPS_MIN_FRAMES = 4
FPS_MAX_FRAMES = 256
def to_rgb(pil_image: Image.Image) -> Image.Image:
if pil_image.mode == "RGBA":
white_background = Image.new("RGB", pil_image.size, (255, 255, 255))
white_background.paste(pil_image, mask=pil_image.split()[3]) # Use alpha channel as mask
return white_background
else:
return pil_image.convert("RGB")
def fetch_image(ele: dict[str, str | Image.Image]) -> Image.Image:
image = ele["image"] if "image" in ele else ele["image_url"]
image_obj = None
if isinstance(image, Image.Image):
image_obj = image
elif image.startswith("http://") or image.startswith("https://"):
response = requests.get(image, stream=True, timeout=10)
image_obj = Image.open(BytesIO(response.content))
elif image.startswith("file://"):
image_obj = Image.open(image[7:])
elif image.startswith("data:image"):
if "base64," in image:
_, base64_data = image.split("base64,", 1)
data = base64.b64decode(base64_data)
image_obj = Image.open(BytesIO(data))
else:
image_obj = Image.open(image)
if image_obj is None:
raise ValueError(
f"Unrecognized image input, support local path, http url, base64 and PIL.Image, got {image}"
)
image = to_rgb(image_obj)
if "scale_factor" in ele:
scale_factor = ele["scale_factor"]
image = image.resize((image.width * scale_factor, image.height * scale_factor), Image.BILINEAR)
return image
class Eagle25VLProcessorKwargs(ProcessingKwargs, total=False):
# see processing_utils.ProcessingKwargs documentation for usage.
_defaults = {
"text_kwargs": {
"padding": False,
},
"images_kwargs": {},
"videos_kwargs": {"max_dynamic_tiles": 1},
}
class Eagle25VLProcessor(ProcessorMixin):
r"""
Constructs a Eagle25VL processor which wraps a Eagle25VL video processor, Eagle25VL image processor and a Eagle25VL tokenizer into a single processor.
[`Eagle25VLProcessor`] offers all the functionalities of [`Eagle25VLVideoProcessor`], [`Eagle25VLImageProcessor`] and [`Eagle25VLTokenizer`]. See the
[`~Eagle25VLVideoProcessor.__call__`], [`~Eagle25VLProcessor.__call__`] and [`~Eagle25VLProcessor.decode`] for more information.
Args:
image_processor ([`LlavaOnevisionImageProcessor`], *optional*):
The image processor is a required input.
tokenizer ([`LlamaTokenizerFast`], *optional*):
The tokenizer is a required input.
num_image_tokens (`int`, *optional*):
Number of image tokens for one imagethat will be returned by vision tower.
vision_feature_select_strategy (`str`, *optional*):
The feature selection strategy used to select the vision feature from the vision backbone.
Should be same as in model's config
chat_template (`str`, *optional*): A Jinja template which will be used to convert lists of messages
in a chat into a tokenizable string.
image_token (`str`, *optional*, defaults to `"<image>"`):
Special token used to denote image location.
video_token (`str`, *optional*, defaults to `"<video>"`):
Special token used to denote video location.
"""
attributes = ["image_processor", "tokenizer"]
valid_kwargs = [
"chat_template",
"num_image_tokens",
"vision_feature_select_strategy",
"image_token",
"video_token",
"images_kwargs",
"videos_kwargs",
"text_kwargs",
]
tokenizer_class = "AutoTokenizer"
def __init__(
self,
image_processor=None,
tokenizer=None,
vision_feature_select_strategy=None,
chat_template=None,
image_token="<IMG_CONTEXT>", # nosec: B107
video_token="<IMG_CONTEXT>", # nosec: B107
tokens_per_tile=256,
image_placeholder="image",
video_placeholder="video",
image_start_token="<img>",
image_end_token="</img>",
**kwargs,
):
self.vision_feature_select_strategy = vision_feature_select_strategy
self.image_token = tokenizer.image_token if hasattr(tokenizer, "image_token") else image_token
self.video_token = tokenizer.video_token if hasattr(tokenizer, "video_token") else video_token
self.image_token_id = (
tokenizer.image_token_id
if getattr(tokenizer, "image_token_id", None)
else tokenizer.convert_tokens_to_ids(self.image_token)
)
self.video_token_id = (
tokenizer.video_token_id
if getattr(tokenizer, "video_token_id", None)
else tokenizer.convert_tokens_to_ids(self.video_token)
)
self.image_placeholder = image_placeholder
self.video_placeholder = video_placeholder
self.tokens_per_tile = tokens_per_tile
self.image_start_token = image_start_token
self.image_end_token = image_end_token
if "auto_map" in kwargs:
self.auto_map = kwargs["auto_map"]
super().__init__(image_processor, tokenizer, chat_template=chat_template)
def replace_media_placeholder(
self, text, image_list, video_list, timestamps_list, fps_list, **output_kwargs
):
num_of_images_in_this_sample = 0
num_of_videos_in_this_sample = 0
# Regular expression pattern to match formats like <image-1> or <video-2>
pattern = re.compile(rf"<({self.image_placeholder}|{self.video_placeholder})-(\d+)>")
unified_frame_list = []
# image_min_dynamic_tiles = output_kwargs["images_kwargs"].get(
# "min_dynamic_tiles", self.image_processor.min_dynamic_tiles
# )
# image_max_dynamic_tiles = output_kwargs["images_kwargs"].get(
# "max_dynamic_tiles", self.image_processor.max_dynamic_tiles
# )
# image_use_thumbnail = output_kwargs["images_kwargs"].get(
# "use_thumbnail", self.image_processor.use_thumbnail
# )
video_min_dynamic_tiles = output_kwargs["videos_kwargs"].get(
"min_dynamic_tiles", self.image_processor.min_dynamic_tiles
)
video_max_dynamic_tiles = output_kwargs["videos_kwargs"].get(
"max_dynamic_tiles", self.image_processor.max_dynamic_tiles
)
video_use_thumbnail = output_kwargs["videos_kwargs"].get(
"use_thumbnail", self.image_processor.use_thumbnail
)
tile_size = self.image_processor.size.get("height", 448)
# Function to replace tags in a single text
def replace_in_text(text):
# repl callback function for each match replacement operation
def repl(match):
nonlocal unified_frame_list
nonlocal num_of_images_in_this_sample
nonlocal num_of_videos_in_this_sample
media_type = match.group(1) # 'image' or 'video'
idx_in_list = int(match.group(2)) - 1 # Convert to list index (0-based)
# Select the corresponding path based on media type
idx_mapper = {
0: "first",
1: "second",
2: "third",
3: "fourth",
4: "fifth",
5: "sixth",
6: "seventh",
7: "eighth",
8: "ninth",
9: "tenth",
}
if media_type == "image":
image_inputs = self.image_processor(
images=[image_list[idx_in_list]],
videos=None,
**output_kwargs["images_kwargs"],
)
if isinstance(image_inputs["pixel_values"], list):
_pv = image_inputs["pixel_values"]
if _pv and isinstance(_pv[0], list):
_pv = [t for sub in _pv for t in sub]
image_inputs["pixel_values"] = torch.stack(
[t if isinstance(t, torch.Tensor) else torch.as_tensor(t) for t in _pv]
)
num_all_tiles = image_inputs["pixel_values"].shape[0]
special_placeholder = f"<image {idx_in_list + 1}>{self.image_start_token}{self.image_token * num_all_tiles * self.tokens_per_tile}{self.image_end_token}"
unified_frame_list.append(image_inputs)
num_of_images_in_this_sample += 1
elif media_type == "video":
video_inputs = self.image_processor(
images=None,
videos=[video_list[idx_in_list]],
**output_kwargs["videos_kwargs"],
)
if isinstance(video_inputs["pixel_values"], list):
_pv = video_inputs["pixel_values"]
if _pv and isinstance(_pv[0], list):
_pv = [t for sub in _pv for t in sub]
video_inputs["pixel_values"] = torch.stack(
[t if isinstance(t, torch.Tensor) else torch.as_tensor(t) for t in _pv]
)
num_all_tiles = video_inputs["pixel_values"].shape[0]
image_sizes = video_inputs["image_sizes"]
if timestamps_list is not None and -1 not in timestamps_list:
frame_timestamps = timestamps_list[idx_in_list]
else:
frame_timestamps = None
sampled_fps = fps_list[idx_in_list] if fps_list is not None else None
num_of_tiles_each_frame = [
self.get_number_tiles_based_on_image_size(
image_size,
video_min_dynamic_tiles,
video_max_dynamic_tiles,
video_use_thumbnail,
tile_size,
)
for image_size in image_sizes
]
assert sum(num_of_tiles_each_frame) == num_all_tiles, (
f"The number of tiles in each frame is not equal to the total number of tiles: {sum(num_of_tiles_each_frame)} != {num_all_tiles}"
)
if frame_timestamps is not None:
assert len(frame_timestamps) == len(num_of_tiles_each_frame), (
f"The number of timestamps is not equal to the number of frames: {len(frame_timestamps)} != {len(num_of_tiles_each_frame)}"
)
special_placeholder = [
f"Frame {i + 1} sample at {frame_timestamps[i]:.2f}s: {self.image_start_token}{self.image_token * num_of_tiles * self.tokens_per_tile}{self.image_end_token}"
for i, num_of_tiles in enumerate(num_of_tiles_each_frame)
]
else:
special_placeholder = [
f"Frame {i + 1}: {self.image_start_token}{self.image_token * num_of_tiles * self.tokens_per_tile}{self.image_end_token}"
for i, num_of_tiles in enumerate(num_of_tiles_each_frame)
]
if sampled_fps is not None:
special_placeholder = (
f"The {idx_mapper[idx_in_list]} video sampled with {sampled_fps:.2f} fps: "
+ "".join(special_placeholder)
)
else:
special_placeholder = f"The {idx_mapper[idx_in_list]} video: " + "".join(
special_placeholder
)
unified_frame_list.append(video_inputs)
num_of_videos_in_this_sample += 1
else:
raise ValueError(f"Unknown media type: {media_type}")
return special_placeholder
return pattern.sub(repl, text)
text = replace_in_text(text)
if len(unified_frame_list) > 0:
def _to_tensor(v):
if isinstance(v, torch.Tensor):
return v
if isinstance(v, list):
if v and isinstance(v[0], list):
v = [t for sub in v for t in sub]
return torch.stack([t if isinstance(t, torch.Tensor) else torch.as_tensor(t) for t in v])
return torch.as_tensor(v)
pixel_values = torch.cat([_to_tensor(frame["pixel_values"]) for frame in unified_frame_list])
image_sizes = torch.cat([_to_tensor(frame["image_sizes"]) for frame in unified_frame_list])
else:
pixel_values = None
image_sizes = None
return (
text,
pixel_values,
image_sizes,
num_of_images_in_this_sample,
num_of_videos_in_this_sample,
)
def __call__(
self,
images: ImageInput = None,
text: TextInput | PreTokenizedInput | list[TextInput] | list[PreTokenizedInput] = None,
audio=None,
videos: VideoInput = None,
**kwargs: Unpack[Eagle25VLProcessorKwargs],
) -> BatchFeature:
"""
Main method to prepare for the model one or several sequences(s) and image(s). This method forwards the `text`
and `kwargs` arguments to LlamaTokenizerFast's [`~LlamaTokenizerFast.__call__`] if `text` is not `None` to encode
the text. To prepare the image(s), this method forwards the `images` and `kwrags` arguments to
LlavaNextImageProcessor's [`~LlavaNextImageProcessor.__call__`] if `images` is not `None`. Please refer to the docstring
of the above two methods for more information.
Args:
images (`PIL.Image.Image`, `np.ndarray`, `torch.Tensor`, `List[PIL.Image.Image]`, `List[np.ndarray]`, `List[torch.Tensor]`):
The image or batch of images to be prepared. Each image can be a PIL image, NumPy array or PyTorch
tensor. Both channels-first and channels-last formats are supported.
text (`str`, `List[str]`, `List[List[str]]`):
The sequence or batch of sequences to be encoded. Each sequence can be a string or a list of strings
(pretokenized string). If the sequences are provided as list of strings (pretokenized), you must set
`is_split_into_words=True` (to lift the ambiguity with a batch of sequences).
videos (`np.ndarray`, `torch.Tensor`, `List[np.ndarray]`, `List[torch.Tensor]`):
The image or batch of videos to be prepared. Each video can be a 4D NumPy array or PyTorch
Returns:
[`BatchFeature`]: A [`BatchFeature`] with the following fields:
- **input_ids** -- List of token ids to be fed to a model. Returned when `text` is not `None`.
- **attention_mask** -- List of indices specifying which tokens should be attended to by the model (when
`return_attention_mask=True` or if *"attention_mask"* is in `self.model_input_names` and if `text` is not
`None`).
- **pixel_values** -- Pixel values to be fed to a model. Returned when `images` is not `None`.
- **pixel_values_videos** -- Pixel values of a video input to be fed to a model. Returned when `videos` is not `None`.
- **image_sizes** -- Size of each image that will be used to unpad an image. Returned when `images` is not `None`.
"""
output_kwargs = self._merge_kwargs(
Eagle25VLProcessorKwargs,
tokenizer_init_kwargs=self.tokenizer.init_kwargs,
**kwargs,
)
if isinstance(text, str):
text_list = [text]
elif not isinstance(text, list) and not isinstance(text[0], str):
raise ValueError("Invalid input text. Please provide a string, or a list of strings")
elif isinstance(text, list) and isinstance(text[0], str):
text_list = text
if images is None:
images = []
if videos is None:
videos = []
pixel_values_list = []
image_sizes_list = []
new_sample_list = []
image_start_idx = 0
video_start_idx = 0
timestamps_batch = output_kwargs["videos_kwargs"].pop("timestamps", None)
fps_batch = output_kwargs["videos_kwargs"].pop("fps", None)
for sample in text_list:
timestamps_list = timestamps_batch[video_start_idx:] if timestamps_batch is not None else None
fps_list = fps_batch[video_start_idx:] if fps_batch is not None else None
(
sample,
pixel_values,
image_sizes,
num_of_images_in_this_sample,
num_of_videos_in_this_sample,
) = self.replace_media_placeholder(
sample,
images[image_start_idx:],
videos[video_start_idx:],
timestamps_list,
fps_list,
**output_kwargs,
)
new_sample_list.append(sample)
if pixel_values is not None:
pixel_values_list.append(pixel_values)
image_sizes_list.append(image_sizes)
image_start_idx += num_of_images_in_this_sample
video_start_idx += num_of_videos_in_this_sample
if len(pixel_values_list) > 0:
image_inputs = {
"pixel_values": torch.cat(pixel_values_list),
"image_sizes": torch.cat(image_sizes_list),
}
else:
image_inputs = {}
video_inputs = {}
text_inputs = self.tokenizer(new_sample_list, **output_kwargs["text_kwargs"])
return BatchFeature(data={**text_inputs, **image_inputs, **video_inputs})
def get_number_tiles_based_on_image_size(
self, image_size: tuple, min_num: int, max_num: int, use_thumbnail: bool, tile_size: int
) -> int:
"""
Get the number of tiles based on the image size.
"""
orig_height, orig_width = image_size
aspect_ratio = orig_width / orig_height
# calculate the existing image aspect ratio
target_ratios = {
(i, j)
for n in range(min_num, max_num + 1)
for i in range(1, n + 1)
for j in range(1, n + 1)
if i * j <= max_num and i * j >= min_num
}
target_ratios = sorted(target_ratios, key=lambda x: x[0] * x[1])
# find the closest aspect ratio to the target
target_aspect_ratio = self.image_processor.find_closest_aspect_ratio(
aspect_ratio, target_ratios, orig_width, orig_height, tile_size
)
tiles_num = target_aspect_ratio[0] * target_aspect_ratio[1]
if use_thumbnail and tiles_num > 1:
tiles_num += 1
return tiles_num
# Copied from transformers.models.clip.processing_clip.CLIPProcessor.batch_decode with CLIP->Llama
def batch_decode(self, *args, **kwargs):
"""
This method forwards all its arguments to LlamaTokenizerFast's [`~PreTrainedTokenizer.batch_decode`]. Please
refer to the docstring of this method for more information.
"""
return self.tokenizer.batch_decode(*args, **kwargs)
# Copied from transformers.models.clip.processing_clip.CLIPProcessor.decode with CLIP->Llama
def decode(self, *args, **kwargs):
"""
This method forwards all its arguments to LlamaTokenizerFast's [`~PreTrainedTokenizer.decode`]. Please refer to
the docstring of this method for more information.
"""
return self.tokenizer.decode(*args, **kwargs)
@property
# Copied from transformers.models.clip.processing_clip.CLIPProcessor.model_input_names
def model_input_names(self):
tokenizer_input_names = self.tokenizer.model_input_names
image_processor_input_names = self.image_processor.model_input_names
return list(dict.fromkeys(tokenizer_input_names + image_processor_input_names))
# override to save video-config in a separate config file
def save_pretrained(self, save_directory, **kwargs):
if os.path.isfile(save_directory):
raise ValueError(f"Provided path ({save_directory}) should be a directory, not a file")
os.makedirs(save_directory, exist_ok=True)
outputs = super().save_pretrained(save_directory, **kwargs)
return outputs
# override to load video-config from a separate config file
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path, **kwargs):
processor = super().from_pretrained(pretrained_model_name_or_path, **kwargs)
# if return_unused_kwargs a tuple is returned where the second element is 'unused_kwargs'
if isinstance(processor, tuple):
processor = processor[0]
return processor
# Copy from https://github.com/QwenLM/Qwen2.5-VL/blob/main/qwen-vl-utils/src/qwen_vl_utils/vision_process.py
def process_vision_info(
self,
conversations: list[dict] | list[list[dict]],
return_video_kwargs: bool = False,
) -> tuple[list[Image.Image] | None, list[torch.Tensor | list[Image.Image]] | None, dict | None]:
vision_infos = self.extract_vision_info(conversations)
## Read images or videos
image_inputs = []
video_inputs = []
video_sample_fps_list = []
video_timestamps_list = []
for vision_info in vision_infos:
if "image" in vision_info or "image_url" in vision_info:
image_inputs.append(fetch_image(vision_info))
else:
raise ValueError("image, image_url or video should in content.")
if len(image_inputs) == 0:
image_inputs = None
if len(video_inputs) == 0:
video_inputs = None
if return_video_kwargs:
return (
image_inputs,
video_inputs,
{"fps": video_sample_fps_list, "timestamps": video_timestamps_list},
)
return image_inputs, video_inputs
def extract_vision_info(self, conversations: list[dict] | list[list[dict]]) -> list[dict]:
vision_infos = []
if isinstance(conversations[0], dict):
conversations = [conversations]
for conversation in conversations:
for message in conversation:
if isinstance(message["content"], list):
for ele in message["content"]:
if (
"image" in ele
or "image_url" in ele
or "video" in ele
or ele["type"] in ("image", "image_url", "video")
):
vision_infos.append(ele)
return vision_infos
__all__ = ["Eagle25VLProcessor"]
-380
View File
@@ -1,380 +0,0 @@
# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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.
from pathlib import Path
from typing import TYPE_CHECKING, Any
import numpy as np
import torch
import torch.nn as nn
from huggingface_hub import snapshot_download
from huggingface_hub.errors import HFValidationError, RepositoryNotFoundError
from lerobot.utils.import_utils import _transformers_available
# Conditional import for type checking and lazy loading
if TYPE_CHECKING or _transformers_available:
from huggingface_hub.dataclasses import strict
from transformers import AutoConfig, AutoModel, PretrainedConfig, PreTrainedModel
from transformers.feature_extraction_utils import BatchFeature
else:
def strict(cls):
return cls
AutoConfig = None
AutoModel = None
PretrainedConfig = object
PreTrainedModel = object
BatchFeature = None
try:
import tree
except ImportError:
tree = None
from lerobot.utils.constants import ACTION, HF_LEROBOT_HOME
from .action_head.flow_matching_action_head import (
FlowmatchingActionHead,
FlowmatchingActionHeadConfig,
)
from .utils import ensure_eagle_cache_ready
DEFAULT_VENDOR_EAGLE_PATH = str((Path(__file__).resolve().parent / "eagle2_hg_model").resolve())
DEFAULT_TOKENIZER_ASSETS_REPO = "lerobot/eagle2hg-processor-groot-n1p5"
class EagleBackbone(nn.Module):
def __init__(
self,
tune_llm: bool = False,
tune_visual: bool = False,
select_layer: int = -1,
reproject_vision: bool = False,
use_flash_attention: bool = False,
load_bf16: bool = False,
eagle_path: str = DEFAULT_VENDOR_EAGLE_PATH,
tokenizer_assets_repo: str = DEFAULT_TOKENIZER_ASSETS_REPO,
project_to_dim: int = 1536,
):
"""
Args:
tune_llm: whether to tune the LLM model (default: True)
tune_visual: whether to tune the visual model (default: False)
"""
super().__init__()
assert not reproject_vision, "Reproject vision is not implemented here, set to False"
# Prefer loading Eagle model config from the cache directory where vendor files were copied.
vendor_dir = DEFAULT_VENDOR_EAGLE_PATH
cache_dir = HF_LEROBOT_HOME / tokenizer_assets_repo
try:
ensure_eagle_cache_ready(vendor_dir, cache_dir, tokenizer_assets_repo)
except Exception as exc: # nosec: B110
print(f"[GROOT] Warning: failed to prepare Eagle cache for backbone: {exc}")
config = AutoConfig.from_pretrained(str(cache_dir), trust_remote_code=True)
self.eagle_model = AutoModel.from_config(config, trust_remote_code=True)
if project_to_dim is not None:
self.eagle_linear = torch.nn.Linear(2048, project_to_dim)
else:
self.eagle_linear = torch.nn.Identity()
# needed since we don't use these layers. Also saves compute
while len(self.eagle_model.language_model.model.layers) > select_layer:
self.eagle_model.language_model.model.layers.pop(-1)
self.select_layer = select_layer
self.set_trainable_parameters(tune_llm, tune_visual)
def set_trainable_parameters(self, tune_llm: bool, tune_visual: bool):
self.tune_llm = tune_llm
self.tune_visual = tune_visual
for p in self.parameters():
p.requires_grad = True
if not tune_llm:
self.eagle_model.language_model.requires_grad_(False)
if not tune_visual:
self.eagle_model.vision_model.requires_grad_(False)
self.eagle_model.mlp1.requires_grad_(False)
print(f"Tune backbone llm: {self.tune_llm}")
print(f"Tune backbone visual: {self.tune_visual}")
# Check if any parameters are still trainable. If not, print a warning.
if not tune_llm and not tune_visual:
for name, p in self.named_parameters():
if p.requires_grad:
print(f"Backbone trainable parameter: {name}")
if not any(p.requires_grad for p in self.parameters()):
print("Warning: No backbone trainable parameters found.")
def set_frozen_modules_to_eval_mode(self):
"""
Huggingface will call model.train() at each training_step. To ensure
the expected behaviors for modules like dropout, batchnorm, etc., we
need to call model.eval() for the frozen modules.
"""
if self.training:
if self.eagle_model.language_model and not self.tune_llm:
self.eagle_model.language_model.eval()
if self.eagle_model.vision_model and not self.tune_visual:
self.eagle_model.vision_model.eval()
def prepare_input(self, batch: dict) -> BatchFeature:
return BatchFeature(data=batch)
def forward_eagle(self, vl_input: BatchFeature) -> BatchFeature:
eagle_prefix = "eagle_"
eagle_input = {
k.removeprefix(eagle_prefix): v for k, v in vl_input.items() if k.startswith(eagle_prefix)
}
del eagle_input["image_sizes"]
eagle_output = self.eagle_model(**eagle_input, output_hidden_states=True, return_dict=True)
eagle_features = eagle_output.hidden_states[self.select_layer]
eagle_features = self.eagle_linear(eagle_features)
return eagle_features, eagle_input["attention_mask"]
def forward(self, vl_input: BatchFeature) -> BatchFeature:
self.set_frozen_modules_to_eval_mode()
eagle_embeds, eagle_mask = self.forward_eagle(vl_input)
# YL (TODO HACK): to resolve DDP issue when tune_visual=True
# Ensure all trainable parameters in vision_model are used in the forward pass for DDP compatibility
if self.training and self.tune_visual:
dummy_term = torch.tensor(
0.0, device=eagle_embeds.device, dtype=eagle_embeds.dtype, requires_grad=True
)
for param in self.eagle_model.vision_model.parameters():
if param.requires_grad:
dummy_term = dummy_term + 0.0 * param.sum()
eagle_embeds = eagle_embeds + dummy_term
return BatchFeature(
data={"backbone_features": eagle_embeds, "backbone_attention_mask": eagle_mask}
) # [B, T2, hidden_size]
BACKBONE_FEATURE_KEY = "backbone_features"
ACTION_KEY = "action_pred"
LOSS_KEY = "loss"
ERROR_MSG = "Error: unexpected input/output"
N_COLOR_CHANNELS = 3
# config
@strict
class GR00TN15Config(PretrainedConfig):
model_type = "gr00t_n1_5"
backbone_cfg: dict[str, Any] | None = None
action_head_cfg: dict[str, Any] | None = None
action_horizon: int = 0
action_dim: int = 0
compute_dtype: str = "float32"
def __post_init__(self, **kwargs):
self.backbone_cfg = {} if self.backbone_cfg is None else self.backbone_cfg
self.action_head_cfg = {} if self.action_head_cfg is None else self.action_head_cfg
super().__post_init__(**kwargs)
# real model
class GR00TN15(PreTrainedModel):
supports_gradient_checkpointing = True
config_class = GR00TN15Config
"""
we expect the backbone output to have a key 'backbone_features' with shape (batch_size, n, hidden_size)
here n is variable and can be e.g. time, 1 or user specified
we expect the action head output to have a key 'action_pred' with shape (batch_size, time, action_dim) during inference time
we expect these to have type BatchFeature, and they can of course have many other user specified keys too
"""
def __init__(
self,
config: GR00TN15Config,
local_model_path: str,
):
assert isinstance(config.backbone_cfg, dict)
assert isinstance(config.action_head_cfg, dict)
super().__init__(config)
self.local_model_path = local_model_path
self.backbone = EagleBackbone(**config.backbone_cfg)
action_head_cfg = FlowmatchingActionHeadConfig(**config.action_head_cfg)
self.action_head = FlowmatchingActionHead(action_head_cfg)
self.action_horizon = config.action_horizon
self.action_dim = config.action_dim
self.compute_dtype = config.compute_dtype
self.post_init()
def validate_inputs(self, inputs):
# NOTE -- this should be handled internally by the model
# however, doing that will likely be breaking changes -- so we'll need to do it after the deadline
detected_error = False
error_msg = ERROR_MSG
if ACTION in inputs:
action = inputs[ACTION]
# In inference, action may be omitted or None; validate only when it's a tensor.
if action is None:
pass # allow None during inference
elif isinstance(action, torch.Tensor):
shape_ok = (
len(action.shape) == 3
and action.shape[1] == self.action_horizon
and action.shape[2] == self.action_dim
)
if not shape_ok:
error_msg += f"\n{action.shape=}"
detected_error = True
else:
# Unexpected non-tensor type provided for action
error_msg += f"\nInvalid type for action: {type(action)}"
detected_error = True
if "video" in inputs:
video = inputs["video"]
type_ok = isinstance(video, np.ndarray)
dtype_ok = video.dtype == np.uint8
shape_ok = len(video.shape) == 6 and video.shape[3] == N_COLOR_CHANNELS
if not type_ok:
error_msg += f"\n{type(video)=}"
detected_error = True
if not dtype_ok:
error_msg += f"\n{video.dtype=}"
detected_error = True
if not shape_ok:
error_msg += f"\n{video.shape=}"
detected_error = True
if detected_error:
raise ValueError(error_msg)
def validate_data(self, action_head_outputs, backbone_outputs, is_training):
fail_backbone = (
not isinstance(backbone_outputs, BatchFeature) or BACKBONE_FEATURE_KEY not in backbone_outputs
)
if fail_backbone:
error_msg = ERROR_MSG
error_msg += f"\n{isinstance(backbone_outputs, BatchFeature)=}"
error_msg += f"\n{BACKBONE_FEATURE_KEY in backbone_outputs=}"
error_msg += f"\n{backbone_outputs[BACKBONE_FEATURE_KEY].shape=}"
raise ValueError(error_msg)
fail_action_head = (not isinstance(action_head_outputs, BatchFeature)) or not (
(
LOSS_KEY in action_head_outputs and is_training
) # there might not be an action prediction during training
or (
ACTION_KEY in action_head_outputs
and action_head_outputs[ACTION_KEY].shape[1] == self.action_horizon
and action_head_outputs[ACTION_KEY].shape[2] == self.action_dim
)
)
if fail_action_head:
error_msg = ERROR_MSG
error_msg += f"\n{isinstance(action_head_outputs, BatchFeature)=}"
error_msg += f"\n{LOSS_KEY in action_head_outputs=}"
error_msg += f"\n{action_head_outputs[ACTION_KEY].shape=}"
error_msg += f"\n{self.action_horizon=}"
error_msg += f"\n{self.action_dim=}"
raise ValueError(error_msg)
def forward(
self,
inputs: dict,
) -> BatchFeature:
backbone_inputs, action_inputs = self.prepare_input(inputs)
backbone_outputs = self.backbone(backbone_inputs)
action_head_outputs = self.action_head(backbone_outputs, action_inputs)
self.validate_data(action_head_outputs, backbone_outputs, is_training=True)
return action_head_outputs
def get_action(
self,
inputs: dict,
) -> BatchFeature:
backbone_inputs, action_inputs = self.prepare_input(inputs)
# Because the behavior of backbones remains the same for training and inference, we can use `forward` for backbones.
backbone_outputs = self.backbone(backbone_inputs)
action_head_outputs = self.action_head.get_action(backbone_outputs, action_inputs)
self.validate_data(action_head_outputs, backbone_outputs, is_training=False)
return action_head_outputs
def prepare_input(self, inputs) -> tuple[BatchFeature, BatchFeature]:
self.validate_inputs(inputs)
backbone_inputs = self.backbone.prepare_input(inputs)
action_inputs = self.action_head.prepare_input(inputs)
def to_device_with_maybe_dtype(x):
# Cast floating tensors to a memory-efficient compute dtype when requested.
# Rationale: Upcasting backbone activations to fp32 significantly increases VRAM.
# When compute_dtype is bfloat16, prefer bf16 for activations to match AMP behavior.
if not isinstance(x, torch.Tensor):
return x
if torch.is_floating_point(x):
if getattr(self, "compute_dtype", None) == "bfloat16":
return x.to(self.device, dtype=torch.bfloat16)
# Fallback: preserve previous behavior if not using bf16 compute
return x.to(self.device, dtype=self.action_head.dtype)
# Non-floating tensors: move device only
return x.to(self.device)
backbone_inputs = tree.map_structure(to_device_with_maybe_dtype, backbone_inputs)
action_inputs = tree.map_structure(to_device_with_maybe_dtype, action_inputs)
return backbone_inputs, action_inputs
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path: str, **kwargs):
tune_visual = kwargs.pop("tune_visual", True)
tune_llm = kwargs.pop("tune_llm", False)
tune_projector = kwargs.pop("tune_projector", True)
tune_diffusion_model = kwargs.pop("tune_diffusion_model", True)
print(f"Loading pretrained dual brain from {pretrained_model_name_or_path}")
print(f"Tune backbone vision tower: {tune_visual}")
print(f"Tune backbone LLM: {tune_llm}")
print(f"Tune action head projector: {tune_projector}")
print(f"Tune action head DiT: {tune_diffusion_model}")
# get the current model path being downloaded
try:
# NOTE(YL) This downloads the model to the local cache and returns the local path to the model
# saved in ~/.cache/huggingface/hub/
local_model_path = snapshot_download(pretrained_model_name_or_path, repo_type="model")
# HFValidationError, RepositoryNotFoundError
except (HFValidationError, RepositoryNotFoundError):
print(
f"Model not found or avail in the huggingface hub. Loading from local path: {pretrained_model_name_or_path}"
)
local_model_path = pretrained_model_name_or_path
pretrained_model = super().from_pretrained(
local_model_path, local_model_path=local_model_path, **kwargs
)
pretrained_model.backbone.set_trainable_parameters(tune_visual=tune_visual, tune_llm=tune_llm)
pretrained_model.action_head.set_trainable_parameters(
tune_projector=tune_projector, tune_diffusion_model=tune_diffusion_model
)
return pretrained_model
+951
View File
@@ -0,0 +1,951 @@
#!/usr/bin/env python
# Copyright 2026 NVIDIA Corporation and 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.
from __future__ import annotations
import logging
from contextlib import suppress
from copy import deepcopy
from typing import TYPE_CHECKING, Any
import torch
import torch.nn.functional as F # noqa: N812
from huggingface_hub import snapshot_download
from huggingface_hub.errors import HFValidationError, RepositoryNotFoundError
from torch import nn
from torch.distributions import Beta
from lerobot.utils.import_utils import _transformers_available, require_package
from .action_head.cross_attention_dit import AlternateVLDiT, DiT, SelfAttentionTransformer
from .configuration_groot import N1_7_DEFAULT_IMAGE_CROP_SIZE, N1_7_DEFAULT_IMAGE_TARGET_SIZE
if TYPE_CHECKING or _transformers_available:
from transformers import (
AutoConfig,
AutoModel,
PretrainedConfig,
PreTrainedModel,
Qwen3VLConfig,
Qwen3VLForConditionalGeneration,
)
from transformers.feature_extraction_utils import BatchFeature
else:
AutoConfig = None
AutoModel = None
PretrainedConfig = object
PreTrainedModel = object
BatchFeature = None
Qwen3VLConfig = None
Qwen3VLForConditionalGeneration = None
try:
import tree
except ImportError:
tree = None
logger = logging.getLogger(__name__)
def _tie_unused_qwen_lm_head(model: nn.Module) -> None:
"""Restore the TF4 weight tie so the unused LM head stays frozen and is omitted on save."""
lm_head = getattr(model, "lm_head", None)
get_input_embeddings = getattr(model, "get_input_embeddings", None)
if lm_head is None or not callable(get_input_embeddings):
return
input_embeddings = get_input_embeddings()
embedding_weight = getattr(input_embeddings, "weight", None)
if embedding_weight is None:
return
lm_head.weight = embedding_weight
GR00T_N1_7_DEFAULTS: dict[str, Any] = {
"model_dtype": "bfloat16",
"dtype": "bfloat16",
"model_name": "nvidia/Cosmos-Reason2-2B",
"backbone_model_type": "qwen",
"model_revision": None,
"tune_top_llm_layers": 0,
"backbone_embedding_dim": 2048,
"tune_llm": False,
"tune_visual": False,
"select_layer": 16,
"reproject_vision": False,
"use_flash_attention": False,
"load_bf16": False,
"backbone_trainable_params_fp32": True,
"image_crop_size": N1_7_DEFAULT_IMAGE_CROP_SIZE,
"image_target_size": N1_7_DEFAULT_IMAGE_TARGET_SIZE,
"shortest_image_edge": None,
"crop_fraction": None,
"random_rotation_angle": None,
"color_jitter_params": None,
"use_albumentations_transforms": True,
"extra_augmentation_config": None,
"formalize_language": True,
"apply_sincos_state_encoding": False,
"use_percentiles": True,
"use_relative_action": False,
"max_state_dim": 132,
"max_action_dim": 132,
"action_horizon": 40,
"hidden_size": 1024,
"input_embedding_dim": 1536,
"state_history_length": 1,
"add_pos_embed": True,
"attn_dropout": 0.2,
"use_vlln": True,
"max_seq_len": 1024,
"use_alternate_vl_dit": True,
"attend_text_every_n_blocks": 2,
"diffusion_model_cfg": {
"positional_embeddings": None,
"num_layers": 32,
"num_attention_heads": 32,
"attention_head_dim": 48,
"norm_type": "ada_norm",
"dropout": 0.2,
"final_dropout": True,
"output_dim": 1024,
"interleave_self_attention": True,
},
"vl_self_attention_cfg": {
"positional_embeddings": None,
"num_layers": 4,
"num_attention_heads": 32,
"attention_head_dim": 64,
"dropout": 0.2,
"final_dropout": True,
},
"num_inference_timesteps": 4,
"noise_beta_alpha": 1.5,
"noise_beta_beta": 1.0,
"noise_s": 0.999,
"num_timestep_buckets": 1000,
"tune_projector": True,
"tune_diffusion_model": True,
"tune_vlln": True,
"state_dropout_prob": 0.2,
"exclude_state": False,
"use_mean_std": False,
"max_num_embodiments": 32,
"rtc_ramp_rate": 6.0,
}
class GR00TN17Config(PretrainedConfig):
"""Configuration for NVIDIA GR00T N1.7.
N1.7 uses the Cosmos-Reason2-2B / Qwen3-VL backbone and a multi-embodiment
flow-matching action head. This mirrors the public N1.7 checkpoint config
while keeping it local to LeRobot and independent from the external
Isaac-GR00T ``gr00t`` Python package.
"""
model_type = "Gr00tN1d7"
_defaults = GR00T_N1_7_DEFAULTS
def __init__(self, **kwargs):
super().__init__(**kwargs)
for key, value in GR00T_N1_7_DEFAULTS.items():
setattr(self, key, deepcopy(kwargs.pop(key, value)))
for key, value in kwargs.items():
setattr(self, key, value)
class CategorySpecificLinear(nn.Module):
"""Linear layer with category-specific weights for multi-embodiment support."""
def __init__(self, num_categories: int, input_dim: int, hidden_dim: int):
super().__init__()
self.num_categories = num_categories
self.W = nn.Parameter(0.02 * torch.randn(num_categories, input_dim, hidden_dim))
self.b = nn.Parameter(torch.zeros(num_categories, hidden_dim))
def forward(self, x: torch.Tensor, cat_ids: torch.Tensor) -> torch.Tensor:
selected_w = self.W[cat_ids]
selected_b = self.b[cat_ids]
return torch.bmm(x, selected_w) + selected_b.unsqueeze(1)
class CategorySpecificMLP(nn.Module):
"""Two-layer MLP with category-specific weights."""
def __init__(self, num_categories: int, input_dim: int, hidden_dim: int, output_dim: int):
super().__init__()
self.layer1 = CategorySpecificLinear(num_categories, input_dim, hidden_dim)
self.layer2 = CategorySpecificLinear(num_categories, hidden_dim, output_dim)
def forward(self, x: torch.Tensor, cat_ids: torch.Tensor) -> torch.Tensor:
hidden = F.relu(self.layer1(x, cat_ids))
return self.layer2(hidden, cat_ids)
class SinusoidalPositionalEncoding(nn.Module):
"""Sinusoidal encoding of shape ``(B, T, D)`` for timestep tensors ``(B, T)``.
The frequency scalar is intentionally created on CPU and then broadcast with
the device-local arange result. That mirrors Isaac-GR00T's N1.7 timestep
embedding and avoids tiny dtype/device construction differences in parity
tests.
"""
def __init__(self, embedding_dim: int):
super().__init__()
self.embedding_dim = embedding_dim
def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
timesteps = timesteps.float()
half_dim = self.embedding_dim // 2
exponent = -torch.arange(half_dim, dtype=torch.float, device=timesteps.device) * (
torch.log(torch.tensor(10000.0)) / half_dim
)
freqs = timesteps.unsqueeze(-1) * exponent.exp()
return torch.cat([torch.sin(freqs), torch.cos(freqs)], dim=-1)
def swish(x: torch.Tensor) -> torch.Tensor:
return x * torch.sigmoid(x)
class MultiEmbodimentActionEncoder(nn.Module):
"""Action encoder with category-specific projections and sinusoidal time encoding."""
def __init__(self, action_dim: int, hidden_size: int, num_embodiments: int):
super().__init__()
self.W1 = CategorySpecificLinear(num_embodiments, action_dim, hidden_size)
self.W2 = CategorySpecificLinear(num_embodiments, 2 * hidden_size, hidden_size)
self.W3 = CategorySpecificLinear(num_embodiments, hidden_size, hidden_size)
self.pos_encoding = SinusoidalPositionalEncoding(hidden_size)
def forward(self, actions: torch.Tensor, timesteps: torch.Tensor, cat_ids: torch.Tensor) -> torch.Tensor:
batch_size, horizon, _ = actions.shape
if timesteps.dim() != 1 or timesteps.shape[0] != batch_size:
raise ValueError("Expected `timesteps` to have shape (B,).")
timesteps = timesteps.unsqueeze(1).expand(-1, horizon)
action_emb = self.W1(actions, cat_ids)
time_emb = self.pos_encoding(timesteps).to(dtype=action_emb.dtype)
x = swish(self.W2(torch.cat([action_emb, time_emb], dim=-1), cat_ids))
return self.W3(x, cat_ids)
class Qwen3Backbone(nn.Module):
"""Cosmos-Reason2/Qwen3-VL backbone used by GR00T N1.7.
The public checkpoint stores the action head in the GR00T checkpoint but
uses a Hugging Face Qwen3-VL-compatible backbone interface. This wrapper
keeps the nested HF module layout compatible across transformer versions
and exposes the hidden states consumed by the action head.
"""
def __init__(
self,
model_name: str = "nvidia/Cosmos-Reason2-2B",
tune_llm: bool = False,
tune_visual: bool = False,
select_layer: int = -1,
reproject_vision: bool = False,
use_flash_attention: bool = False,
load_bf16: bool = False,
tune_top_llm_layers: int = 0,
trainable_params_fp32: bool = False,
transformers_loading_kwargs: dict[str, Any] | None = None,
load_pretrained_weights: bool = True,
):
require_package("transformers", extra="groot")
if Qwen3VLForConditionalGeneration is None:
raise ImportError(
"Qwen3VLForConditionalGeneration is required for GR00T N1.7. "
"Install a transformers version with Qwen3-VL support."
)
super().__init__()
transformers_loading_kwargs = transformers_loading_kwargs or {"trust_remote_code": True}
extra_kwargs: dict[str, Any] = {}
if use_flash_attention:
try:
import flash_attn # noqa: F401
extra_kwargs["attn_implementation"] = "flash_attention_2"
except ImportError:
logger.warning("flash_attn is not installed. Falling back to SDPA attention.")
extra_kwargs["attn_implementation"] = "sdpa"
if load_bf16:
extra_kwargs["torch_dtype"] = torch.bfloat16
if load_pretrained_weights:
self.model = Qwen3VLForConditionalGeneration.from_pretrained(
model_name,
**extra_kwargs,
**transformers_loading_kwargs,
).eval()
else:
self.model = self._from_backbone_config(
model_name=model_name,
model_kwargs=extra_kwargs,
config_kwargs=transformers_loading_kwargs,
).eval()
_tie_unused_qwen_lm_head(self.model)
while len(self.language_model.layers) > select_layer:
self.language_model.layers.pop(-1)
self.select_layer = select_layer
self.set_trainable_parameters(tune_llm, tune_visual, tune_top_llm_layers)
if load_bf16 and trainable_params_fp32:
for parameter in self.parameters():
if parameter.requires_grad:
parameter.data = parameter.data.to(torch.float32)
def set_trainable_parameters(
self, tune_llm: bool, tune_visual: bool, tune_top_llm_layers: int = 0
) -> None:
self.tune_llm = tune_llm
self.tune_visual = tune_visual
for parameter in self.parameters():
parameter.requires_grad = True
if not tune_llm:
self.language_model.requires_grad_(False)
if not tune_visual:
self.visual.requires_grad_(False)
if tune_top_llm_layers > 0:
for layer in self.language_model.layers[-tune_top_llm_layers:]:
for parameter in layer.parameters():
parameter.requires_grad = True
def set_frozen_modules_to_eval_mode(self) -> None:
if self.training:
if self.language_model and not self.tune_llm:
self.language_model.eval()
if self.visual and not self.tune_visual:
self.visual.eval()
@property
def language_model(self) -> nn.Module:
return getattr(self.model, "model", self.model).language_model
@property
def visual(self) -> nn.Module:
return getattr(self.model, "model", self.model).visual
def _from_backbone_config(
self,
*,
model_name: str,
model_kwargs: dict[str, Any],
config_kwargs: dict[str, Any],
) -> nn.Module:
if _is_cosmos_reason2_backbone(model_name):
backbone_config = _cosmos_reason2_qwen3_vl_config()
else:
backbone_config = AutoConfig.from_pretrained(model_name, **config_kwargs)
return Qwen3VLForConditionalGeneration._from_config(backbone_config, **model_kwargs)
def prepare_input(self, batch: dict[str, Any]) -> BatchFeature:
return BatchFeature(data=batch)
def _ensure_mm_token_type_ids(self, model_input: dict[str, torch.Tensor]) -> None:
if "mm_token_type_ids" in model_input:
return
if "image_grid_thw" not in model_input and "video_grid_thw" not in model_input:
return
input_ids = model_input.get("input_ids")
if input_ids is None:
return
mm_token_type_ids = torch.zeros(input_ids.shape, dtype=torch.int32, device=input_ids.device)
image_token_id = getattr(self.model.config, "image_token_id", None)
video_token_id = getattr(self.model.config, "video_token_id", None)
if image_token_id is not None:
mm_token_type_ids[input_ids == image_token_id] = 1
if video_token_id is not None:
mm_token_type_ids[input_ids == video_token_id] = 2
model_input["mm_token_type_ids"] = mm_token_type_ids
def _ensure_legacy_qwen3_position_ids(self, model_input: dict[str, torch.Tensor]) -> None:
"""Restore the Qwen3-VL text position ids used by older Transformers releases.
Transformers 5.x computes 3-row multimodal RoPE ids for Qwen3-VL and then
drops text position ids before calling text-layer flash attention. GR00T
N1.7 was aligned against the older Transformers path, where a fourth text
position row is forwarded alongside the temporal/height/width rows. Adding
the row here preserves the newer multimodal position computation while
keeping flash attention on the legacy code path.
"""
if "position_ids" in model_input:
return
qwen3_model = getattr(self.model, "model", self.model)
compute_3d_position_ids = getattr(qwen3_model, "compute_3d_position_ids", None)
if compute_3d_position_ids is None:
return
position_ids = compute_3d_position_ids(
input_ids=model_input.get("input_ids"),
image_grid_thw=model_input.get("image_grid_thw"),
video_grid_thw=model_input.get("video_grid_thw"),
inputs_embeds=None,
attention_mask=model_input.get("attention_mask"),
past_key_values=None,
mm_token_type_ids=model_input.get("mm_token_type_ids"),
)
if position_ids.ndim == 3 and position_ids.shape[0] == 3:
position_ids = torch.cat([position_ids[:1], position_ids], dim=0)
model_input["position_ids"] = position_ids
def _last_decoder_layer_output(self, model_input: dict[str, torch.Tensor]) -> torch.Tensor:
"""Return the pre-final-norm decoder output consumed by the N1.7 action head.
Older Transformers releases exposed this tensor as ``hidden_states[-1]``.
Newer releases expose the post-final-norm tensor there instead. Capturing
the last decoder layer output directly keeps the N1.7 action head input
stable across Transformers versions.
"""
captured: dict[str, torch.Tensor] = {}
def capture_output(_module: nn.Module, _inputs: tuple[Any, ...], output: Any) -> None:
if isinstance(output, torch.Tensor):
captured["features"] = output
elif isinstance(output, (tuple, list)) and output:
captured["features"] = output[0]
elif hasattr(output, "last_hidden_state"):
captured["features"] = output.last_hidden_state
hook = self.language_model.layers[-1].register_forward_hook(capture_output)
try:
outputs = self.model(**model_input, output_hidden_states=True)
finally:
hook.remove()
return captured.get("features", outputs.hidden_states[-1])
def forward(self, vl_input: BatchFeature) -> BatchFeature:
self.set_frozen_modules_to_eval_mode()
keys_to_use = ["input_ids", "attention_mask", "pixel_values", "image_grid_thw"]
optional_keys = ["mm_token_type_ids", "pixel_values_videos", "video_grid_thw"]
model_input = {key: vl_input[key] for key in keys_to_use}
model_input.update({key: vl_input[key] for key in optional_keys if key in vl_input})
self._ensure_mm_token_type_ids(model_input)
self._ensure_legacy_qwen3_position_ids(model_input)
features = self._last_decoder_layer_output(model_input)
image_mask = model_input["input_ids"] == self.model.config.image_token_id
attention_mask = model_input["attention_mask"] == 1
return BatchFeature(
data={
"backbone_features": features,
"backbone_attention_mask": attention_mask,
"image_mask": image_mask,
}
)
class GR00TN17ActionHead(nn.Module):
supports_gradient_checkpointing = True
def __init__(self, config: GR00TN17Config):
require_package("diffusers", extra="groot")
super().__init__()
self.config = config
self.hidden_size = config.hidden_size
self.input_embedding_dim = config.input_embedding_dim
if config.use_alternate_vl_dit:
self.model = AlternateVLDiT(
**config.diffusion_model_cfg,
cross_attention_dim=config.backbone_embedding_dim,
attend_text_every_n_blocks=config.attend_text_every_n_blocks,
)
else:
self.model = DiT(
**config.diffusion_model_cfg,
cross_attention_dim=config.backbone_embedding_dim,
)
self.action_dim = config.max_action_dim
self.action_horizon = config.action_horizon
self.num_inference_timesteps = config.num_inference_timesteps
self.state_encoder = CategorySpecificMLP(
num_categories=config.max_num_embodiments,
input_dim=config.max_state_dim * config.state_history_length,
hidden_dim=self.hidden_size,
output_dim=self.input_embedding_dim,
)
self.action_encoder = MultiEmbodimentActionEncoder(
action_dim=self.action_dim,
hidden_size=self.input_embedding_dim,
num_embodiments=config.max_num_embodiments,
)
self.action_decoder = CategorySpecificMLP(
num_categories=config.max_num_embodiments,
input_dim=self.hidden_size,
hidden_dim=self.hidden_size,
output_dim=self.action_dim,
)
self.vlln = nn.LayerNorm(config.backbone_embedding_dim) if config.use_vlln else nn.Identity()
vl_self_attention_cfg = getattr(config, "vl_self_attention_cfg", None)
if vl_self_attention_cfg and vl_self_attention_cfg.get("num_layers", 0) > 0:
self.vl_self_attention = SelfAttentionTransformer(**vl_self_attention_cfg)
else:
self.vl_self_attention = nn.Identity()
if config.add_pos_embed:
self.position_embedding = nn.Embedding(config.max_seq_len, self.input_embedding_dim)
nn.init.normal_(self.position_embedding.weight, mean=0.0, std=0.02)
self.state_dropout_prob = config.state_dropout_prob
self._noise_beta_alpha = config.noise_beta_alpha
self._noise_beta_beta = config.noise_beta_beta
self._beta_dist = None
self.num_timestep_buckets = config.num_timestep_buckets
self.set_trainable_parameters(config.tune_projector, config.tune_diffusion_model, config.tune_vlln)
def set_trainable_parameters(
self, tune_projector: bool, tune_diffusion_model: bool, tune_vlln: bool
) -> None:
self.tune_projector = tune_projector
self.tune_diffusion_model = tune_diffusion_model
self.tune_vlln = tune_vlln
for parameter in self.parameters():
parameter.requires_grad = True
if not tune_projector:
self.state_encoder.requires_grad_(False)
self.action_encoder.requires_grad_(False)
self.action_decoder.requires_grad_(False)
if self.config.add_pos_embed:
self.position_embedding.requires_grad_(False)
if not tune_diffusion_model:
self.model.requires_grad_(False)
if not tune_vlln:
self.vlln.requires_grad_(False)
self.vl_self_attention.requires_grad_(False)
def set_frozen_modules_to_eval_mode(self) -> None:
if self.training:
if not self.tune_projector:
self.state_encoder.eval()
self.action_encoder.eval()
self.action_decoder.eval()
if self.config.add_pos_embed:
self.position_embedding.eval()
if not self.tune_diffusion_model:
self.model.eval()
if not self.tune_vlln:
self.vlln.eval()
self.vl_self_attention.eval()
def sample_time(self, batch_size: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
if self._beta_dist is None:
beta_alpha = torch.tensor(self._noise_beta_alpha, device="cpu", dtype=torch.float32)
beta_beta = torch.tensor(self._noise_beta_beta, device="cpu", dtype=torch.float32)
self._beta_dist = Beta(beta_alpha, beta_beta, validate_args=False)
sample = self._beta_dist.sample([batch_size]).to(device, dtype=dtype)
return (1 - sample) * self.config.noise_s
def process_backbone_output(self, backbone_output: BatchFeature) -> BatchFeature:
backbone_features = self.vlln(backbone_output["backbone_features"])
backbone_output["backbone_features"] = self.vl_self_attention(backbone_features)
return backbone_output
def forward(self, backbone_output: BatchFeature, action_input: BatchFeature) -> BatchFeature:
self.set_frozen_modules_to_eval_mode()
backbone_output = self.process_backbone_output(backbone_output)
vl_embeds = backbone_output.backbone_features
device = vl_embeds.device
embodiment_id = action_input.embodiment_id
if action_input.state.shape[1] != self.config.state_history_length:
raise ValueError("state history length does not match GR00T N1.7 config.")
state = action_input.state.view(action_input.state.shape[0], 1, -1)
state_features = self.state_encoder(state, embodiment_id)
if self.training and self.state_dropout_prob > 0:
do_dropout = (
torch.rand(state_features.shape[0], device=state_features.device) < self.state_dropout_prob
)
state_features = state_features * (1 - do_dropout[:, None, None].to(dtype=state_features.dtype))
actions = action_input.action
noise = torch.randn(actions.shape, device=actions.device, dtype=actions.dtype)
t = self.sample_time(actions.shape[0], device=actions.device, dtype=actions.dtype)
t = t[:, None, None]
noisy_trajectory = (1 - t) * noise + t * actions
velocity = actions - noise
t_discretized = (t[:, 0, 0] * self.num_timestep_buckets).long()
action_features = self.action_encoder(noisy_trajectory, t_discretized, embodiment_id)
if self.config.add_pos_embed:
pos_ids = torch.arange(action_features.shape[1], dtype=torch.long, device=device)
action_features = action_features + self.position_embedding(pos_ids).unsqueeze(0)
sa_embs = torch.cat((state_features, action_features), dim=1)
if self.config.use_alternate_vl_dit:
model_output, _ = self.model(
hidden_states=sa_embs,
encoder_hidden_states=vl_embeds,
encoder_attention_mask=backbone_output.backbone_attention_mask,
timestep=t_discretized,
return_all_hidden_states=True,
image_mask=backbone_output.image_mask,
backbone_attention_mask=backbone_output.backbone_attention_mask,
)
else:
model_output, _ = self.model(
hidden_states=sa_embs,
encoder_hidden_states=vl_embeds,
encoder_attention_mask=backbone_output.backbone_attention_mask,
timestep=t_discretized,
return_all_hidden_states=True,
)
pred = self.action_decoder(model_output, embodiment_id)
pred_actions = pred[:, -actions.shape[1] :]
action_mask = action_input.action_mask
action_loss = F.mse_loss(pred_actions, velocity, reduction="none") * action_mask
loss = action_loss.sum() / (action_mask.sum() + 1e-6)
return BatchFeature(
data={
"loss": loss,
"action_loss": action_loss,
"action_mask": action_mask,
"backbone_features": vl_embeds,
"state_features": state_features,
}
)
def _encode_features(self, backbone_output: BatchFeature, action_input: BatchFeature) -> BatchFeature:
backbone_output = self.process_backbone_output(backbone_output)
state = action_input.state
if state.shape[1] != self.config.state_history_length:
raise ValueError("state history length does not match GR00T N1.7 config.")
state = state.view(state.shape[0], 1, -1)
state_features = self.state_encoder(state, action_input.embodiment_id)
return BatchFeature(
data={"backbone_features": backbone_output.backbone_features, "state_features": state_features}
)
@torch.no_grad()
def get_action_with_features(
self,
backbone_features: torch.Tensor,
state_features: torch.Tensor,
embodiment_id: torch.Tensor,
backbone_output: BatchFeature,
action_input: BatchFeature,
options: dict[str, Any] | None = None,
) -> BatchFeature:
vl_embeds = backbone_features
batch_size = vl_embeds.shape[0]
device = vl_embeds.device
actions = torch.randn(
size=(batch_size, self.config.action_horizon, self.action_dim),
dtype=vl_embeds.dtype,
device=device,
)
dt = 1.0 / self.num_inference_timesteps
vel_strength = torch.ones_like(actions)
if "action" in action_input:
if options is None:
raise ValueError("RTC options are required when action is provided to get_action.")
action_horizon_before_padding = options["action_horizon"]
actions[:, : options["rtc_overlap_steps"], :] = action_input["action"][
:,
action_horizon_before_padding - options["rtc_overlap_steps"] : action_horizon_before_padding,
:,
]
vel_strength[:, : options["rtc_frozen_steps"], :] = 0.0
intermediate_steps = options["rtc_overlap_steps"] - options["rtc_frozen_steps"]
t = torch.linspace(0.0, 1.0, intermediate_steps + 2, device=device)
ramp = 1 - torch.exp(-options["rtc_ramp_rate"] * t)
ramp = ramp / ramp[-1].clamp_min(1e-8)
vel_strength[:, options["rtc_frozen_steps"] : options["rtc_overlap_steps"], :] = ramp[1:-1][
None, :, None
].to(device)
for t_step in range(self.num_inference_timesteps):
t_cont = t_step / float(self.num_inference_timesteps)
t_discretized = int(t_cont * self.num_timestep_buckets)
timesteps_tensor = torch.full(size=(batch_size,), fill_value=t_discretized, device=device)
action_features = self.action_encoder(actions, timesteps_tensor, embodiment_id)
if self.config.add_pos_embed:
pos_ids = torch.arange(action_features.shape[1], dtype=torch.long, device=device)
action_features = action_features + self.position_embedding(pos_ids).unsqueeze(0)
sa_embs = torch.cat((state_features, action_features), dim=1)
if self.config.use_alternate_vl_dit:
model_output = self.model(
hidden_states=sa_embs,
encoder_hidden_states=vl_embeds,
timestep=timesteps_tensor,
image_mask=backbone_output.image_mask,
backbone_attention_mask=backbone_output.backbone_attention_mask,
)
else:
model_output = self.model(
hidden_states=sa_embs,
encoder_hidden_states=vl_embeds,
timestep=timesteps_tensor,
)
pred = self.action_decoder(model_output, embodiment_id)
actions = actions + dt * pred[:, -self.action_horizon :] * vel_strength
return BatchFeature(
data={
"action_pred": actions,
"backbone_features": vl_embeds,
"state_features": state_features,
}
)
@torch.no_grad()
def get_action(
self,
backbone_output: BatchFeature,
action_input: BatchFeature,
options: dict[str, Any] | None = None,
) -> BatchFeature:
features = self._encode_features(backbone_output, action_input)
return self.get_action_with_features(
backbone_features=features.backbone_features,
state_features=features.state_features,
embodiment_id=action_input.embodiment_id,
backbone_output=backbone_output,
action_input=action_input,
options=options,
)
@property
def device(self) -> torch.device:
return next(iter(self.parameters())).device
@property
def dtype(self) -> torch.dtype:
return next(iter(self.parameters())).dtype
def prepare_input(self, batch: dict[str, Any]) -> BatchFeature:
return BatchFeature(data=batch)
def _is_cosmos_reason2_backbone(model_name: str) -> bool:
return str(model_name).rstrip("/") == "nvidia/Cosmos-Reason2-2B"
def _cosmos_reason2_qwen3_vl_config() -> PretrainedConfig:
"""Hard-coded copy of the nvidia/Cosmos-Reason2-2B config.json (a Qwen3-VL-2B-Instruct layout)."""
return Qwen3VLConfig(
image_token_id=151655,
video_token_id=151656,
vision_start_token_id=151652,
vision_end_token_id=151653,
tie_word_embeddings=True,
text_config={
"attention_bias": False,
"attention_dropout": 0.0,
"bos_token_id": 151643,
"dtype": "bfloat16",
"eos_token_id": 151645,
"head_dim": 128,
"hidden_act": "silu",
"hidden_size": 2048,
"initializer_range": 0.02,
"intermediate_size": 6144,
"max_position_embeddings": 262144,
"model_type": "qwen3_vl_text",
"num_attention_heads": 16,
"num_hidden_layers": 28,
"num_key_value_heads": 8,
"rms_norm_eps": 1e-6,
"rope_scaling": {
"mrope_interleaved": True,
"mrope_section": [24, 20, 20],
"rope_type": "default",
},
"rope_theta": 5000000,
"tie_word_embeddings": True,
"use_cache": True,
"vocab_size": 151936,
},
vision_config={
"deepstack_visual_indexes": [5, 11, 17],
"depth": 24,
"hidden_act": "gelu_pytorch_tanh",
"hidden_size": 1024,
"in_channels": 3,
"initializer_range": 0.02,
"intermediate_size": 4096,
"model_type": "qwen3_vl",
"num_heads": 16,
"num_position_embeddings": 2304,
"out_hidden_size": 2048,
"patch_size": 16,
"spatial_merge_size": 2,
"temporal_patch_size": 2,
},
)
def get_backbone_cls(config: GR00TN17Config):
if "nvidia/Cosmos-Reason2" in config.model_name or "Qwen/Qwen3-VL" in config.model_name:
return Qwen3Backbone
if config.backbone_model_type == "qwen":
logger.warning(
"Unrecognized GR00T N1.7 backbone model name '%s'; assuming a Qwen3-VL-compatible "
"backbone because backbone_model_type='qwen'.",
config.model_name,
)
return Qwen3Backbone
raise ValueError(f"Unsupported GR00T N1.7 backbone model: {config.model_name}")
class GR00TN17(PreTrainedModel):
"""GR00T N1.7 model with a Cosmos-Reason2/Qwen3-VL backbone."""
config_class = GR00TN17Config
supports_gradient_checkpointing = True
def __init__(
self,
config: GR00TN17Config,
transformers_loading_kwargs: dict[str, Any] | None = None,
load_backbone_weights: bool = True,
):
_register_with_transformers()
super().__init__(config)
transformers_loading_kwargs = transformers_loading_kwargs or {"trust_remote_code": True}
self.config = config
backbone_cls = get_backbone_cls(config)
self.backbone = backbone_cls(
model_name=config.model_name,
tune_llm=config.tune_llm,
tune_visual=config.tune_visual,
select_layer=config.select_layer,
reproject_vision=config.reproject_vision,
use_flash_attention=config.use_flash_attention,
load_bf16=config.load_bf16,
tune_top_llm_layers=config.tune_top_llm_layers,
trainable_params_fp32=config.backbone_trainable_params_fp32,
transformers_loading_kwargs=transformers_loading_kwargs,
load_pretrained_weights=load_backbone_weights,
)
self.action_head = GR00TN17ActionHead(config)
self.post_init()
def prepare_input(self, inputs: dict[str, Any]) -> tuple[BatchFeature, BatchFeature]:
require_package("dm-tree", extra="groot", import_name="tree")
backbone_inputs = self.backbone.prepare_input(inputs)
action_inputs = self.action_head.prepare_input(inputs)
def to_device_with_dtype(x):
if not isinstance(x, torch.Tensor):
return x
if torch.is_floating_point(x):
return x.to(self.device, dtype=self.dtype)
return x.to(self.device)
return (
tree.map_structure(to_device_with_dtype, backbone_inputs),
tree.map_structure(to_device_with_dtype, action_inputs),
)
def forward(self, inputs: dict[str, Any]) -> BatchFeature:
backbone_inputs, action_inputs = self.prepare_input(inputs)
backbone_outputs = self.backbone(backbone_inputs)
return self.action_head(backbone_outputs, action_inputs)
def get_action(self, inputs: dict[str, Any], options: dict[str, Any] | None = None) -> BatchFeature:
backbone_inputs, action_inputs = self.prepare_input(inputs)
backbone_outputs = self.backbone(backbone_inputs)
return self.action_head.get_action(backbone_outputs, action_inputs, options)
@property
def device(self) -> torch.device:
return next(iter(self.parameters())).device
@property
def dtype(self) -> torch.dtype:
return next(iter(self.parameters())).dtype
@classmethod
def from_pretrained(cls, pretrained_model_name_or_path: str, **kwargs):
tune_visual = kwargs.pop("tune_visual", True)
tune_llm = kwargs.pop("tune_llm", False)
tune_projector = kwargs.pop("tune_projector", True)
tune_diffusion_model = kwargs.pop("tune_diffusion_model", True)
tune_vlln = kwargs.pop("tune_vlln", True)
transformers_loading_kwargs = kwargs.pop("transformers_loading_kwargs", None) or {
"trust_remote_code": True
}
load_backbone_weights = kwargs.pop("load_backbone_weights", False)
for key in ("cache_dir", "local_files_only", "token"):
if key in kwargs:
transformers_loading_kwargs.setdefault(key, kwargs[key])
try:
local_model_path = snapshot_download(
pretrained_model_name_or_path,
repo_type="model",
revision=kwargs.get("revision"),
cache_dir=kwargs.get("cache_dir"),
local_files_only=kwargs.get("local_files_only", False),
token=kwargs.get("token"),
)
except (HFValidationError, RepositoryNotFoundError):
local_model_path = pretrained_model_name_or_path
pretrained_model = super().from_pretrained(
local_model_path,
transformers_loading_kwargs=transformers_loading_kwargs,
load_backbone_weights=load_backbone_weights,
**kwargs,
)
pretrained_model.backbone.set_trainable_parameters(
tune_visual=tune_visual,
tune_llm=tune_llm,
tune_top_llm_layers=pretrained_model.config.tune_top_llm_layers,
)
pretrained_model.action_head.set_trainable_parameters(
tune_projector=tune_projector,
tune_diffusion_model=tune_diffusion_model,
tune_vlln=tune_vlln,
)
return pretrained_model
def _register_with_transformers() -> None:
"""Register GR00T N1.7 with transformers' Auto* factories.
Idempotent: ``register(..., exist_ok=True)`` makes repeat calls no-ops (with a fallback that
suppresses the already-registered error on transformers builds whose ``register()`` predates
``exist_ok``), so no run-once guard is needed.
"""
if AutoConfig is None or AutoModel is None:
return
try:
AutoConfig.register(GR00TN17Config.model_type, GR00TN17Config, exist_ok=True)
except TypeError:
with suppress(ValueError):
AutoConfig.register(GR00TN17Config.model_type, GR00TN17Config)
try:
AutoModel.register(GR00TN17Config, GR00TN17, exist_ok=True)
except TypeError:
with suppress(ValueError):
AutoModel.register(GR00TN17Config, GR00TN17)

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