π€ This repository is a fork of lerobot
by XenseRobotics, used for Xense's multimodal tactile data acquisition system.
This branch tracks upstream lerobot v5.1, with Xense-specific robots
(Flexiv Rizon4 RT, Elite CS66 RT, and ARX5 β each single-arm and bimanual;
plus TacCap tactile grippers), teleoperators (Pico4 VR,
dual SpaceMouse, Vive tracker, TRLC leader, gamepad) and tactile cameras
layered on top. For generic lerobot usage (datasets, policies, training
scripts) refer to the
upstream README.
Tested on Ubuntu 22.04 and 24.04, NVIDIA driver β₯ 570.144. Use
Mamba
(strongly recommended over plain conda β it's much faster on the
robostack-staging channel that ships ROS Humble + SOEM). v5.1 pins
Python 3.12 and PyTorch β₯ 2.2 with CUDA 12.8.
curl -L -O "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
bash Miniforge3-$(uname)-$(uname -m).shStep 1: π Clone the repository with all submodules:
git clone \
--recurse-submodules \
https://github.com/Vertax42/lerobot-xense.git
cd lerobot-xenseIf you already cloned without submodules, initialize them manually:
git submodule update --init --recursive --progress
Optional β internal network (GitLab mirror). The default clone above uses the public GitHub mirrors (
git@github.com:Vertax42/*). Company-network developers can instead point the submodules at the internal GitLab server; the internal URLs live in.gitmodules.gitlaband a helper repoints your local remotes without touching the committed.gitmodules(every submodule pin exists on both remotes):XENSE_GITLAB_HOST=<host> scripts/submodule-remote.sh gitlab # `github` switches back git submodule update --init --recursiveThe GitLab host is not committed β this repository is public, so the address comes from
XENSE_GITLAB_HOSTinstead. Ask a teammate for the value and export it in your shell profile.
This repository uses third_party/ git submodules to manage hardware SDK dependencies:
| Submodule | Installed package |
|---|---|
third_party/ARX5_SDK |
pyarx |
third_party/libpyflexiv |
flexiv_rt |
third_party/XenseVR-PC-Service |
xensevr_pc_service_sdk |
third_party/XGripper |
xensegripper |
third_party/elite-robots-cs-sdk |
Elite CS C++ SDK (builds elite_cs_sdk) |
third_party/elite-robots-cs-sdk-python |
elite_cs_sdk (Elite CS Python bindings) |
third_party/taccap-gripper |
xense.taccap (TacCap UMI tactile gripper SDK) |
xensesdkis not a submodule β it is installed from PyPI (xensesdk==2.0.1, the published cp312 manylinux wheel, which bundles the patchedlibxense_c.soflash reader). The Elite Python SDK is built against the localthird_party/elite-robots-cs-sdkC++ submodule (no network fetch of the C++ source).
Step 2: π Create and activate the mamba environment:
bash ./setup_env.sh --mamba lerobot-xense
mamba activate lerobot-xenseThe default env name baked into
conda_environment.yamlislerobot-xense-py312. You can pass a different name to--mamba, but the rest of this README and the openpi project assumelerobot-xense-py312.
Step 3: π¦ Install LeRobot-Xense and all hardware SDK bindings:
bash ./setup_env.sh --installThis step will:
- Update the conda environment from
conda_environment.yaml - Install the main package from
pyproject.toml - Install
xensesdkfrom PyPI (xensesdk==2.0.1) - Build and install all
third_partySDK packages:pyarx,flexiv_rt,xensevr_pc_service_sdk,xensegripper,elite_cs_sdk(Elite CS β built from the C++ + Python submodules), andxense.taccap(TacCap UMI gripper) - Configure SpaceMouse udev rules and HID permissions automatically
You will be prompted for
sudopassword during installation (for ARX5 real-time capability and udev rules).
By default --install builds every hardware SDK. On a station that only uses one
or two devices, pass per-hardware-family selectors after --install to build
core + only those SDKs β faster, and it skips SDKs you can't (or don't want to)
build on that host:
# core + Flexiv + TacCap only (arms auto-include the xense gripper stack)
bash ./setup_env.sh --install --flexiv --taccap
# core + Elite only
bash ./setup_env.sh --install --elite
# core only β no hardware SDK bindings
bash ./setup_env.sh --install --core
# list every selector
bash ./setup_env.sh --install --help| Selector | Builds | Robots / teleoperators enabled |
|---|---|---|
--flexiv, --bi_flexiv |
flexiv_rt (+ xense) |
flexiv_rizon4_rt, bi_flexiv_rizon4_rt |
--elite, --bi_elite |
elite_cs_sdk (+ xense) |
elite_cs66_rt, bi_elite_cs66_rt |
--taccap, --bi_taccap |
xense.taccap (+ xense) |
taccap_follower gripper (on any arm) |
--xense |
xensesdk + xensegripper (XGripper) |
serial gripper + tactile sensors |
--arx5, --bi_arx5 |
pyarx |
arx5_follower, bi_arx5 |
--pico4, --bi_pico4 |
xensevr_pc_service_sdk |
pico4, bi_pico4 teleop |
--spacemouse |
pyspacemouse |
spacemouse teleop |
--dynamixel, --trlc |
dynamixel-sdk |
trlc_leader, bi_trlc teleop |
--vive, --vive_tracker |
xense (libsurvive via XGripper) |
vive_tracker teleop |
--all |
everything (explicit) | β |
--core, --none |
nothing (core only) | β |
| (no selector) | everything (default, backward compatible) | β |
Notes:
- No selector = install all β the existing
bash ./setup_env.sh --installbehavior is unchanged. - Arms auto-include
xenseβ--flexiv/--elite/--taccapalso build the xense gripper stack, because those arms drive xense grippers. - Post-install verification only checks the SDKs you selected.
- Code stays compatible with a partial install.
import lerobotand the CLIs (lerobot-teleoperate,lerobot-record, β¦) start fine even when an SDK is absent β a device whose SDK isn't installed simply won't appear as a--robot.type/--teleop.typechoice (and only errors, with a rebuild hint, if you try to construct it).
Step 4: β
Verify the installation. These checks assume a full --install; on a
selective install (e.g. --flexiv --taccap) only the SDKs you selected are built, so
verify just those β the installer already prints a per-SDK verification summary at the end.
python -c 'import pyarx; print("pyarx OK ->", pyarx.__file__)'
python -c 'import flexiv_rt; print("flexiv_rt OK ->", flexiv_rt.__file__)'
python -c 'import xensevr_pc_service_sdk; print("xensevr_pc_service_sdk OK ->", xensevr_pc_service_sdk.__file__)'
python -c 'import xensesdk; print("xensesdk OK ->", xensesdk.__file__)'
python -c 'import xensegripper; print("xensegripper OK ->", xensegripper.__file__)'
python -c 'import elite_cs_sdk; print("elite_cs_sdk OK ->", elite_cs_sdk.__file__)'
python -c 'import xense.taccap; print("xense.taccap OK ->", xense.taccap.__file__)'Step 5: π Note on FFmpeg / video: v5.1 no longer pins ffmpeg
through conda (the robostack ICU pin conflicted with newer ffmpeg
builds). Video encoding/decoding is handled by torchcodec + av
wheels installed via setup_env.sh --install. If you need a system
ffmpeg with libsvtav1, install it separately (apt or upstream
static build):
# Optional: verify torchcodec wheel is loadable
python -c 'import torchcodec; print("torchcodec OK ->", torchcodec.__version__)'The ARX5 SDK requires CAP_SYS_NICE on the Python interpreter for real-time CAN thread scheduling. This is handled by setup_env.sh --install, but can be set manually:
PY_EXE=$(python -c 'import sys, os; p = sys.executable; print(os.path.realpath(p))')
sudo setcap cap_sys_nice+ep "$PY_EXE"
getcap "$PY_EXE" # should show: cap_sys_nice+epTeleoperation and recording are driven by recipe YAML files under
recipes/ (see recipes/README.md for the full
guide). Pass one with --config_path:
lerobot-teleoperate --config_path=recipes/teleop/bi_elite_cs66_rt/diagonal-07.yaml
lerobot-record --config_path=recipes/record/bi_flexiv_rizon4_rt/assemble_box.yamlA recipe is self-contained: it carries both the bench hardware (controller IPs/SNs, camera SNs, mount geometry, per-arm home/start poses) and the run's tuning (control mode, servo gains, guards, gripper force, dataset fields). One file is everything a run needs; adding a bench is a new recipe, no Python change.
ls recipes/teleop/bi_flexiv_rizon4_rt/ # forward-04, forward-05, forward-dewu, diagonal-02
ls recipes/teleop/bi_elite_cs66_rt/ # diagonal-07, diagonal-08Precedence is dataclass default < recipe < CLI, so an explicit
--robot.left_robot_sn=β¦ overrides the recipe for a one-off run.
Gripper (gripper:). Every robot takes one typed gripper block; a bimanual arm
writes it once and both sides get a copy with side stamped in (the two are always
a matched pair). Two backends:
serialβXenseSerialGripper, a parallel jaw over USB serial. Left/right are auto-discovered by board-SN parity (odd SN β left, even SN β right) at connect, so no gripper SN is configured.taccap_followerβxense.taccapFollowerGripper, the centric TacCap gripper (MIT impedance). Left/right resolved from the firmware-burned SN, and its wrist- GSPS tactile cameras auto-discovered at connect.
robot:
gripper:
type: taccap_follower
kp: 8.0
feedforward_torque: -3.0 # negative = clamp harder
auto_discover_cameras: trueThe block is decoded through the gripper registry, so a knob belonging to the other
backend β or a typo β is rejected at parse time rather than silently ignored.
See src/lerobot/grippers/README.md for the full
field lists and the driver contract.
This project includes advanced SpaceMouse support with both single and dual-device modes for precise robotic control.
System Requirements:
- Ubuntu 22.04 / 24.04 (tested) or other Linux distributions
- Python 3.12+
- libhidapi (installed via apt)
Python Packages:
pyspacemouse- Modern cross-platform SpaceMouse libraryhidapi- Python wrapper for HID APIeasyhid- Easy-to-use HID library (dependency of pyspacemouse)
All Python dependencies are automatically installed by setup_env.sh --install.
SpaceMouse requires proper udev rules to allow non-root access. This is configured automatically by setup_env.sh --install (see Step 3 in the Installation section above).
After installation and permissions setup, test your SpaceMouse:
# Basic functionality test (prints real-time 6-DoF values)
python src/lerobot/teleoperators/spacemouse/examples/01_basic.py
# Device discovery (lists connected SpaceMice by path/serial)
python src/lerobot/teleoperators/spacemouse/examples/05_discovery.pyThe test script will display real-time position (x, y, z) and orientation (roll, pitch, yaw) values as you move the SpaceMouse.
π Note: If you're using a 3Dconnexion Universal Receiver (wireless), you may see multiple devices listed (e.g., 14 "UniversalReceiver" entries). This is normal - the receiver exposes multiple HID interfaces for different functions. PySpaceMouse will automatically select the correct interface for 6-DoF input.
- β Modern PySpaceMouse Integration: Uses PySpaceMouse library for cross-platform SpaceMouse support
- β No System Services Required: Direct HID communication, no need for spacenavd daemon
- β Single Device Mode: Traditional 6-DoF control with one SpaceMouse
- β Dual Device Mode: Advanced left/right hand coordination for complex manipulation
- β Flexible Axis Assignment: Configure which device controls position vs orientation
- β Independent Sensitivity: Per-device sensitivity settings for optimal control
from lerobot.teleoperators.spacemouse import SpacemouseConfig, SpacemouseTeleop
# Standard single SpaceMouse setup (default)
config = SpacemouseConfig(
pos_sensitivity=0.8, # Position control sensitivity
ori_sensitivity=1.5, # Orientation control sensitivity
deadzone=0.1, # Deadzone threshold
frequency=200, # Polling frequency (Hz)
)
teleop = SpacemouseTeleop(config)Perfect for complex robotic tasks requiring precise position and orientation control:
from lerobot.teleoperators.spacemouse import SpacemouseConfig, DeviceConfig
# Left hand controls position, right hand controls orientation
config = SpacemouseConfig(
multi_device_mode=True,
left_device=DeviceConfig(
device_index=0,
enabled_axes=(True, True, True, False, False, False), # X, Y, Z position only
pos_sensitivity=0.8,
ori_sensitivity=0.0, # Disabled
),
right_device=DeviceConfig(
device_index=1,
enabled_axes=(False, False, False, True, True, True), # Roll, pitch, yaw only
pos_sensitivity=0.0, # Disabled
ori_sensitivity=1.5,
)
)
teleop = SpacemouseTeleop(config)See examples/09_custom_config.py and examples/03_multi_device.py (under src/lerobot/teleoperators/spacemouse/) for complete configuration examples including:
- Position/Orientation split control
- Dual-arm robot control
- Fine/Coarse movement control
- π€ Dual-Arm Robots: Independent control of two robotic arms
- π― Precision Manipulation: Decouple position and orientation control for fine tasks
- π Complex Assembly: Left hand positions, right hand orients components
- π Industrial Applications: Enhanced ergonomics and control precision
- Single Mode: Any 3Dconnexion SpaceMouse device
- Dual Mode: Two identical SpaceMouse devices (e.g., two SpaceNavigators)
All 3Dconnexion devices supported by PySpaceMouse:
- SpaceNavigator
- SpaceMouse Pro
- SpaceMouse Wireless
- SpaceMouse Compact
- And more...
For lerobot-record with --robot.type=bi_flexiv_rizon4_rt --teleop.type=bi_pico4, the controller buttons are mapped as follows:
| Controller button | Keyboard equivalent | Action |
|---|---|---|
Right A |
go_start |
Reset both arms to start pose (RT non-blocking, recording continues) |
Left X |
rerecord_episode |
Discard current episode and re-record |
Left Y |
exit_early |
Finish current episode early |
Right B |
stop_recording |
Stop the recording session |
Button state is refreshed via BiPico4.poll_buttons() at the top of each loop iteration, before event checks. Keyboard events and controller buttons are unified into the same events[] checks β both are equal-priority input sources.
During RT reset, the record loop keeps running: observations are still sampled, teleop actions are still read, and the teleop pose is re-synced to the robot once the reset trajectory finishes.
This section documents the dataset construction logic in flexiv_rizon4_rt_record_loop, which handles both normal teleoperation recording and the RT reset trajectory recording for bi_flexiv_rizon4_rt + bi_pico4.
while timestamp < control_time_s:
poll_buttons() # lightweight button refresh (no pose computation)
# Unified event checks β keyboard OR controller button, symmetric
stop_recording / B β break
rerecord / X β break
exit_early / Y β break
go_start / A β reset_to_initial_position(), recording continues
get_observation()
check robot_is_moving + sync teleop if reset just finished
get_action()
send_action / dataset write
| Variable | Role |
|---|---|
reset_triggered |
Per-frame flag. Set True the frame reset is triggered. Skips send_action and dataset write for that frame only. Resets to False at the start of every iteration. |
prev_rt_moving |
Edge-detection flag. Set True while robot.rt_moving is True. Cleared to False when movement stops, triggering one call to _sync_rt_teleop_to_robot_pose(). |
prev_observation_frame |
Holds the previous frame's observation. Used by shifted-frame logic to pair obs[t-1] with the robot's actual position at obs[t] as the action. |
robot.send_action(teleop_action) β sent_action
dataset: { obs[t], action = sent_action[t] } # direct frame
prev_observation_frame = obs[t]
robot.reset_to_initial_position() # C++ RT thread takes over arm control
send_action β skipped
dataset β skipped
prev_observation_frame = obs[T] # saved as anchor for next iteration
obs[T] is intentionally not written to the dataset. It is used as prev_observation_frame for the first shifted frame on the next iteration, so it appears exactly once β without this skip it would appear twice (once as a direct frame, once as the prev of the first shifted frame).
send_action β skipped (C++ RT thread drives the arm autonomously)
current_as_action = { key: obs[t][key] for key in robot.action_features }
# robot.action_features = left/right TCP pose (9D each) + gripper (1D each) = 20D total
# Iterates action_features keys only β image keys in obs[t] are excluded automatically.
dataset: { obs[t-1], action = current_as_action } # shifted frame
prev_observation_frame = obs[t]
The action is extracted from the current observation using the same keys as robot.action_features. This records where the robot actually moved to, not what the teleop commanded β the same shifted-frame convention used by bi_arx5_record_loop.
| Frame | reset_triggered |
robot_is_moving |
send_action |
Dataset write | prev_obs updated to |
|---|---|---|---|---|---|
| T-1 (normal teleop) | False | False | β | {obs[T-1], action[T-1]} direct |
obs[T-1] |
| T (reset triggered) | True | False | skipped | skipped | obs[T] |
| T+1 (RT moving) | False | True | skipped | {obs[T], state_20d[T+1]} shifted |
obs[T+1] |
| T+2 (RT moving) | False | True | skipped | {obs[T+1], state_20d[T+2]} shifted |
obs[T+2] |
| β¦ | False | True | skipped | shifted | β¦ |
| N+1 (reset done, teleop synced) | False | False | β | {obs[N+1], action[N+1]} direct |
obs[N+1] |
state_20d[t] = {k: obs[t][k] for k in robot.action_features} β left/right TCP pose (9D each) + gripper (1D each), image keys excluded.
frame T-2 normal teleop β dataset: { obs[T-2], action[T-2] }
frame T-1 normal teleop β dataset: { obs[T-1], action[T-1] }, prev=obs[T-1]
frame T reset trigger β dataset: skipped, prev=obs[T]
frame T+1 rt_moving β dataset: { obs[T], state_20d[T+1] }, prev=obs[T+1]
frame T+2 rt_moving β dataset: { obs[T+1], state_20d[T+2] }, prev=obs[T+2]
...
frame N rt_moving β dataset: { obs[N-1], state_20d[N] }, prev=obs[N]
frame N+1 reset done β _sync_rt_teleop_to_robot_pose()
normal teleop β dataset: { obs[N+1], action[N+1] }
When prev_rt_moving transitions True β False (frame N+1), _sync_rt_teleop_to_robot_pose() is called once. This reads the robot's current TCP pose (now at start position) and calls teleop.reset_to_pose(), updating the Pico4's internal _start_pos reference. Without this sync the teleop would compute position deltas from the pre-reset pose, causing the arm to jump on the first grip after reset.
A dataset in LeRobotDataset format is very simple to use. It can be loaded from a repository on the Hugging Face hub or a local folder simply with e.g. dataset = LeRobotDataset("lerobot/aloha_static_coffee") and can be indexed into like any Hugging Face and PyTorch dataset. For instance dataset[0] will retrieve a single temporal frame from the dataset containing observation(s) and an action as PyTorch tensors ready to be fed to a model.
A specificity of LeRobotDataset is that, rather than retrieving a single frame by its index, we can retrieve several frames based on their temporal relationship with the indexed frame, by setting delta_timestamps to a list of relative times with respect to the indexed frame. For example, with delta_timestamps = {"observation.image": [-1, -0.5, -0.2, 0]} one can retrieve, for a given index, 4 frames: 3 "previous" frames 1 second, 0.5 seconds, and 0.2 seconds before the indexed frame, and the indexed frame itself (corresponding to the 0 entry). See example 1_load_lerobot_dataset.py for more details on delta_timestamps.
Under the hood, the LeRobotDataset format makes use of several ways to serialize data which can be useful to understand if you plan to work more closely with this format. We tried to make a flexible yet simple dataset format that would cover most type of features and specificities present in reinforcement learning and robotics, in simulation and in real-world, with a focus on cameras and robot states but easily extended to other types of sensory inputs as long as they can be represented by a tensor.
Here are the important details and internal structure organization of a typical LeRobotDataset instantiated with dataset = LeRobotDataset("lerobot/aloha_static_coffee"). The exact features will change from dataset to dataset but not the main aspects:
dataset attributes:
β hf_dataset: a Hugging Face dataset (backed by Arrow/parquet). Typical features example:
β β observation.images.cam_high (VideoFrame):
β β VideoFrame = {'path': path to a mp4 video, 'timestamp' (float32): timestamp in the video}
β β observation.state (list of float32): position of an arm joints (for instance)
β ... (more observations)
β β action (list of float32): goal position of an arm joints (for instance)
β β episode_index (int64): index of the episode for this sample
β β frame_index (int64): index of the frame for this sample in the episode ; starts at 0 for each episode
β β timestamp (float32): timestamp in the episode
β β next.done (bool): indicates the end of an episode ; True for the last frame in each episode
β β index (int64): general index in the whole dataset
β meta: a LeRobotDatasetMetadata object containing:
β β info: a dictionary of metadata on the dataset
β β β codebase_version (str): this is to keep track of the codebase version the dataset was created with
β β β fps (int): frame per second the dataset is recorded/synchronized to
β β β features (dict): all features contained in the dataset with their shapes and types
β β β total_episodes (int): total number of episodes in the dataset
β β β total_frames (int): total number of frames in the dataset
β β β robot_type (str): robot type used for recording
β β β data_path (str): formattable string for the parquet files
β β β video_path (str): formattable string for the video files (if using videos)
β β episodes: a DataFrame containing episode metadata with columns:
β β β episode_index (int): index of the episode
β β β tasks (list): list of tasks for this episode
β β β length (int): number of frames in this episode
β β β dataset_from_index (int): start index of this episode in the dataset
β β β dataset_to_index (int): end index of this episode in the dataset
β β stats: a dictionary of statistics (max, mean, min, std) for each feature in the dataset, for instance
β β β observation.images.front_cam: {'max': tensor with same number of dimensions (e.g. `(c, 1, 1)` for images, `(c,)` for states), etc.}
β β β ...
β β tasks: a DataFrame containing task information with task names as index and task_index as values
β root (Path): local directory where the dataset is stored
β image_transforms (Callable): optional image transformations to apply to visual modalities
β delta_timestamps (dict): optional delta timestamps for temporal queries
A LeRobotDataset is serialised using several widespread file formats for each of its parts, namely:
- hf_dataset stored using Hugging Face datasets library serialization to parquet
- videos are stored in mp4 format to save space
- metadata are stored in plain json/jsonl files
Dataset can be uploaded/downloaded from the HuggingFace hub seamlessly. To work on a local dataset, you can specify its location with the root argument if it's not in the default ~/.cache/huggingface/lerobot location.
π Major SpaceMouse System Overhaul:
- Modern Library Migration: Migrated from legacy
spnavto modernPySpaceMouselibrary - Cross-Platform Support: Now supports Linux, macOS, and Windows
- No System Dependencies: Removed requirement for
spacenavdsystem service - Dual-Device Support: Revolutionary dual SpaceMouse mode for advanced manipulation
- Flexible Configuration: Per-device sensitivity and axis assignment
- Hardware Independence: Direct HID communication for better reliability
Breaking Changes:
spacenavdservice is no longer required- Configuration options have been expanded with new dual-device parameters
- Old single-device configurations remain fully compatible
Migration Benefits:
- β Easier setup (no system services to configure)
- β Better cross-platform compatibility
- β More responsive input handling
- β Advanced dual-hand control capabilities
- β Future-proof with active library maintenance
If you use this codebase, please cite the original LeRobot project:
@misc{cadene2024lerobot,
author = {Cadene, Remi and Alibert, Simon and Soare, Alexander and Gallouedec, Quentin and Zouitine, Adil and Palma, Steven and Kooijmans, Pepijn and Aractingi, Michel and Shukor, Mustafa and Aubakirova, Dana and Russi, Martino and Capuano, Francesco and Pascal, Caroline and Choghari, Jade and Moss, Jess and Wolf, Thomas},
title = {LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch},
howpublished = "\url{https://github.com/huggingface/lerobot}",
year = {2024}
}If you use this fork (LeRobot-Xense) specifically, please also cite:
@misc{vertax2026lerobotxense,
author = {vertax42 and Xense Robotics Team},
title = {LeRobot-Xense: LeRobot with Xense Tactile Robotics Support},
howpublished = "\url{https://github.com/Vertax42/lerobot-xense}",
year = {2026}
}