mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-15 14:02:14 +00:00
293a8d9a77
* 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>
322 lines
12 KiB
Python
322 lines
12 KiB
Python
#!/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()
|