Compare commits

..

92 Commits

Author SHA1 Message Date
CarolinePascal 764404a27e feat(anyskin): adding support for anyskin sensors 2026-01-20 12:33:15 +01:00
CarolinePascal 8b9451b585 fix(mypy): fixing mypy tests 2026-01-20 12:25:55 +01:00
CarolinePascal ab4903e752 tests(clean): cleaning up audio tests 2026-01-20 12:25:55 +01:00
CarolinePascal 538cea6dbc fix(race condition): fixing race condition in stop_recording (wait for proper stop before cleaning) 2026-01-20 12:25:55 +01:00
CarolinePascal 5cd3572713 chore(busy_wait): renaming busy_wait into precise_sleep 2026-01-20 12:25:55 +01:00
CarolinePascal 3399513e5e feat(control loop): adding logs for control loop delays 2026-01-20 12:25:55 +01:00
CarolinePascal 32fc4015ee chore(headers): fixing headers 2026-01-20 12:25:55 +01:00
CarolinePascal cc72c813bf fix(process init timeout): adding proper support when waiting for record process initialization 2026-01-20 12:25:55 +01:00
CarolinePascal 606f31a86e feat(tactile becnhmark): adding illustrative tactile sensor benchmark 2026-01-20 12:25:55 +01:00
CarolinePascal 4933c9dcc7 chore(touchlab imports): adding imports for touchlab sensors 2026-01-20 12:25:52 +01:00
CarolinePascal 7e25385024 feat(touchlab tactile sensors): adding initial support for TouchLab tactile sensors 2026-01-20 12:25:01 +01:00
CarolinePascal cc70bff74d fix(increasing DTS): fix issue with non strictly increasing DTS in media files concatenation 2026-01-20 12:25:01 +01:00
CarolinePascal 9f50913b9c fix(typos): fixing typos in audio handling in LeRobotDataset 2026-01-20 12:25:01 +01:00
CarolinePascal 4eb7694d47 test(rerun audio): adding tests for audio visualization with rerun 2026-01-20 12:25:01 +01:00
CarolinePascal edb5559b5b fix(rerun audio): fixing rerun visualization for audio 2026-01-20 12:24:56 +01:00
CarolinePascal 552ec76195 feat(audio dataset conversion): adding support for audio in dataset v2.1 -> v3.0 conversion scipts 2026-01-20 12:24:00 +01:00
CarolinePascal e75340b473 feat(aggregate audio): adding support for audio in dataset aggregation functions 2026-01-20 12:24:00 +01:00
CarolinePascal 2a4c223ec7 feat(parametrized audio processor): adding parameters for AudioProcessorStep definition 2026-01-20 12:24:00 +01:00
CarolinePascal 1ee4d84f07 fix(audio visualization): making audio visualization robust to multiple channel audio data 2026-01-20 12:24:00 +01:00
CarolinePascal 6bd40ca219 fix(__str__): adding missing __str__ method in PortAudioMicrophone 2026-01-20 12:24:00 +01:00
CarolinePascal b879cf3d04 feat(shared array): removing queues copy and flush delays with a SharedArray inter-process communication 2026-01-20 12:24:00 +01:00
CarolinePascal bd9e5c1a64 fix(is_writing): adding a correct definition to is_writing based on output file opening and closing 2026-01-20 12:24:00 +01:00
CarolinePascal 9271a0c900 fix(Microphone): adding proper logger definition, timeout for end of recording monitoring and failsafe __del__ method 2026-01-20 12:24:00 +01:00
CarolinePascal af2f044f5a feat(rolling vstack): opting for an inplace copy efficient implementation of the rolling vstack for the audio buffer 2026-01-20 12:24:00 +01:00
CarolinePascal 0caba222ef fix(typos): fixing typos and missing imports 2026-01-20 12:23:58 +01:00
CarolinePascal 6d73f5bfe6 test(Microphone): removing unittest.TestCase class architecture to add tests parametrization on multiprocessing/multithreading use 2026-01-20 12:23:22 +01:00
CarolinePascal ef8f40c21b test(LeRobotDataset): add missing test and support for audio frames addition 2026-01-20 12:23:19 +01:00
CarolinePascal 0232879245 test(Microphone): adding missing testsand support for float sample rate 2026-01-20 12:20:28 +01:00
CarolinePascal 2726b4e865 fix(hw-dataset): adding missing support for audio in hw-to-dataset functions 2026-01-20 12:20:28 +01:00
CarolinePascal e126d35249 test(Microphone): adding tests for the PortAudioMicrophone class 2026-01-20 12:20:27 +01:00
CarolinePascal d7ae8cd699 docs(mic utils): adding complete docstrings for Microphone utils functions and removing unused utils function 2026-01-20 12:20:27 +01:00
CarolinePascal 2f96d8bf76 fix(Microphone): adding missing properties and argument in Microphone base class 2026-01-20 12:20:27 +01:00
CarolinePascal e129c71b4f feat(sounddevice SDK): adding SDK interface and support for sounddevice dependency injection 2026-01-20 12:20:27 +01:00
CarolinePascal a02d70389d feat(clear-less start): adding support for synchronized recording start without delaying queue clearings 2026-01-20 12:20:27 +01:00
CarolinePascal 0d4922ce49 refactor(properties): making microphones properties more robust and adding proper checks on state changes 2026-01-20 12:20:27 +01:00
CarolinePascal eaeff78924 style(names): renaming attributes names for better clarity 2026-01-20 12:20:27 +01:00
CarolinePascal e2f3982e2c style(config validation): storing microphone config validation in dedicated methods 2026-01-20 12:20:27 +01:00
CarolinePascal a73ac2bdbb [skip-ci] style(clean): cleaning up audio benchmark 2026-01-20 12:20:27 +01:00
CarolinePascal 95de732e55 [skip-ci] feat(multiprocess audio stream): running input audio stream in a separate process to avoid extensive CPU usage impacts 2026-01-20 12:20:27 +01:00
CarolinePascal b2383236ca [skip-ci] fix(audio buffers): fill audio buffers before entering the control loop to avoid first audio chunk reading delay 2026-01-20 12:20:27 +01:00
CarolinePascal 4b98cc25c8 [skip-ci] fix(async read): remove async read from LeKiwi to avoid delays 2026-01-20 12:20:27 +01:00
CarolinePascal 90780c4de8 [skip-ci] feat(portaudio timeout): decresing portaudio stream timeout for low latency 2026-01-20 12:20:27 +01:00
CarolinePascal 6f6e046c53 [skip-ci] feat(audio rerun): adding support for multiple channels visualization with rerun 2026-01-20 12:20:27 +01:00
CarolinePascal 8cd64eaad1 fix(resnet weights): fix default ResNet weights for audio 2026-01-20 12:20:27 +01:00
CarolinePascal e620395416 feat(init audio buffer): adding a separate parameter for the initial audio buffer size 2026-01-20 12:20:27 +01:00
CarolinePascal 0fbcbcdb2e feat(audio ACT): removing normalization and pretrained weights because it does not really make sense 2026-01-20 12:20:26 +01:00
CarolinePascal 674f5dfd75 test(audio frame): fixing decoded audio frame shape 2026-01-20 12:20:26 +01:00
CarolinePascal 7d430c8067 style(return types): adding missing return types 2026-01-20 12:20:26 +01:00
CarolinePascal 5f114c1d74 feat(init audio buffers): adding correct audio buffer initialization with actually recorded background noise instead of pure silence 2026-01-20 12:20:26 +01:00
CarolinePascal ad01ef19f4 fix(audio buffers): add security crop to avoid audio buffer overfilling 2026-01-20 12:20:26 +01:00
CarolinePascal 59e8f4572c [skip-ci] fix(typo): fixing microphone key typo in audio benchmark 2026-01-20 12:20:26 +01:00
CarolinePascal 97e91698fb [skip-ci] fix(online audio chunks): adding missing observation copy when adding audio buffer 2026-01-20 12:20:26 +01:00
CarolinePascal af0294198a [skip ci] feat(visualize audio): adding audio recordings visualization in rerun 2026-01-20 12:20:22 +01:00
CarolinePascal 421fdcce96 [skip ci] fix(audio latency): setting microphone recording latency to low as default 2026-01-20 12:12:59 +01:00
CarolinePascal bb63ad9715 fix(online audio chunks): querying audio chunks as a shifting widow over previous audio samples to match the default audio chunk size 2026-01-20 12:12:59 +01:00
CarolinePascal 3c90a79c57 feat(audio in ACT): adding audio features support in ACT using mel-spectrogram representation 2026-01-20 12:12:59 +01:00
CarolinePascal 8e29c530ed fix(pytorch audio format): switching to pytorch's default channel first format for audio 2026-01-20 12:12:59 +01:00
CarolinePascal b573b7a052 fix(audio decoding): fixing edge cases where the requested audio chunk starts before the beginning of the recording 2026-01-20 12:12:59 +01:00
CarolinePascal 926184110b feat(audio in policies): adding audio as a input feature in policies 2026-01-20 12:12:59 +01:00
CarolinePascal bf8ede852d fix(busy wait): removing busy_wait on MacOS for python>3.11 2026-01-20 12:12:59 +01:00
CarolinePascal f73db4394b fix(audio chunks): querying audio chunks in the past rather than in the future 2026-01-20 12:12:59 +01:00
CarolinePascal bff91f9927 feat(torchcodec): setting torchcodec as default as the new official release supports audio decoding 2026-01-20 12:12:59 +01:00
CarolinePascal 6d726266fd fix(audio load file): adding missing dimension when loading mono audio data 2026-01-20 12:12:59 +01:00
CarolinePascal 2962330bb1 style(imports): simplify soundfile imports 2026-01-20 12:12:59 +01:00
CarolinePascal 067993bb11 fix(typos): fixing typos 2026-01-20 12:12:58 +01:00
CarolinePascal e4dd00c8f5 fix(audio feature shape): fixing audio feature shape ordering (frames first, channels second) 2026-01-20 12:12:58 +01:00
CarolinePascal e714ff22e2 [skip ci] doc(benchmark): adding microphones recording benchmark 2026-01-20 12:12:58 +01:00
CarolinePascal 3bbd161cfd [skip ci] feat(audio recording): adding new asyn start_recording, stop_recording and read functions to avoid for loop delays 2026-01-20 12:12:58 +01:00
CarolinePascal 6d7be63f59 [skip ci] fix(wav format): fix default WAV format for audio storage 2026-01-20 12:12:58 +01:00
CarolinePascal b9d0dfb9a2 [skip ci] refactor(Microphone class): properly rename Microphone class to PortAudioMicrophone to avoid conflicts with the eponym base class 2026-01-20 12:12:58 +01:00
CarolinePascal dce483060f [skip ci] feat(audio recording): handle folder creation in start_recording directly 2026-01-20 12:12:58 +01:00
CarolinePascal c32b9182d9 [skip ci] feat(torchcodec): adding support for torchcodec audio decoding 2026-01-20 12:12:58 +01:00
CarolinePascal a4d4ef0e7f fix: fixing typos 2026-01-20 12:12:58 +01:00
CarolinePascal 9a5c96b2b1 fix: fixing issue with microphone channels numbering and status recovery on stop_recording 2026-01-20 12:12:58 +01:00
CarolinePascal 0a6ca58299 fix: adding proper definition for "total_audio" in LeRobotDataset 2026-01-20 12:12:58 +01:00
CarolinePascal 688195fc46 docs: add methods descriptions and comments on tricky parts 2026-01-20 12:12:58 +01:00
CarolinePascal 99eb0bbafc Adding last missing audio features in LeRobotDataset 2026-01-20 12:12:58 +01:00
CarolinePascal 16de8b3f19 Adding support for audio data recording and broadcasting for LeKiwi 2026-01-20 12:12:55 +01:00
CarolinePascal 580008663b Adding flag for file writting recording case 2026-01-20 12:12:07 +01:00
CarolinePascal 52c424c5eb Adding multiprocessing support for audio recording 2026-01-20 12:12:07 +01:00
CarolinePascal 836195e59c Renamming sampling rate to sample rate for consistency 2026-01-20 12:12:07 +01:00
CarolinePascal be09a59e05 Adding audio tests 2026-01-20 12:12:03 +01:00
CarolinePascal 373a169bd2 Fixing sounddevice stream active state recovery and adding corresponding exceptions 2026-01-20 12:10:45 +01:00
CarolinePascal 00536c6c5b Adding missing features for audio frames verification and stats 2026-01-20 12:10:45 +01:00
CarolinePascal cdd3a859ef Adding pytorch compatible conversion for audio 2026-01-20 12:10:45 +01:00
CarolinePascal 5276fc0d6f Remove variable audio recordings data types (will be converted to float32 anyway) 2026-01-20 12:10:45 +01:00
CarolinePascal 6a2882f978 Adding audio frames reading capability 2026-01-20 12:10:37 +01:00
CarolinePascal 8874547353 Adding microphone recording in control loop 2026-01-20 12:10:12 +01:00
CarolinePascal 2864caad80 Adding audio modality in LeRobotDatasets 2026-01-20 12:10:12 +01:00
CarolinePascal d998660aa1 Integrate microphones in Robot class 2026-01-20 12:10:09 +01:00
CarolinePascal 7e5f3b35e9 Implementing basic integration of microphones using soundfile and sounddevice 2026-01-20 12:08:27 +01:00
CarolinePascal 01fea7c407 Adding audio dependencies 2026-01-20 12:08:27 +01:00
270 changed files with 7801 additions and 14205 deletions
+1 -12
View File
@@ -18,11 +18,6 @@ name: Documentation
on: on:
# Allows running this workflow manually from the Actions tab # Allows running this workflow manually from the Actions tab
workflow_dispatch: workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g. v0.1.2) - Leave empty for standard main build'
required: false
type: string
# Triggers the workflow on push events to main for the docs folder # Triggers the workflow on push events to main for the docs folder
push: push:
@@ -59,13 +54,7 @@ jobs:
with: with:
commit_sha: ${{ github.sha }} commit_sha: ${{ github.sha }}
package: lerobot package: lerobot
additional_args: >- additional_args: --not_python_module ${{ github.event_name == 'release' && format('--version {0}', github.event.release.tag_name) || '' }}
--not_python_module
${{
(github.event_name == 'release' && format('--version {0}', github.event.release.tag_name)) ||
(inputs.version != '' && format('--version {0}', inputs.version)) ||
''
}}
secrets: secrets:
token: ${{ secrets.HUGGINGFACE_PUSH }} token: ${{ secrets.HUGGINGFACE_PUSH }}
hf_token: ${{ secrets.HF_DOC_BUILD_PUSH }} hf_token: ${{ secrets.HF_DOC_BUILD_PUSH }}
+1 -7
View File
@@ -44,7 +44,7 @@ permissions:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
# Ensures that only the latest commit for a PR or branch is built, canceling older runs. # Ensures that only the latest commit for a PR or branch is built, canceling older runs.
concurrency: concurrency:
@@ -61,7 +61,6 @@ jobs:
MUJOCO_GL: egl MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -90,10 +89,5 @@ jobs:
- name: Install lerobot with test extras - name: Install lerobot with test extras
run: uv sync --extra "test" run: uv sync --extra "test"
- name: Login to Hugging Face
run: |
uv run hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
uv run hf auth whoami
- name: Run pytest - name: Run pytest
run: uv run pytest tests -vv --maxfail=10 run: uv run pytest tests -vv --maxfail=10
+1 -16
View File
@@ -37,7 +37,7 @@ permissions:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu DOCKER_IMAGE_NAME: huggingface/lerobot-gpu
# Ensures that only the latest action is built, canceling older runs. # Ensures that only the latest action is built, canceling older runs.
@@ -60,7 +60,6 @@ jobs:
MUJOCO_GL: egl MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -88,11 +87,6 @@ jobs:
- name: Install lerobot with all extras - name: Install lerobot with all extras
run: uv sync --extra all # TODO(Steven): Make flash-attn optional run: uv sync --extra all # TODO(Steven): Make flash-attn optional
- name: Login to Hugging Face
run: |
uv run hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
uv run hf auth whoami
- name: Run pytest (all extras) - name: Run pytest (all extras)
run: uv run pytest tests -vv --maxfail=10 run: uv run pytest tests -vv --maxfail=10
@@ -107,11 +101,9 @@ jobs:
runs-on: runs-on:
group: aws-general-8-plus group: aws-general-8-plus
if: | if: |
github.repository == 'huggingface/lerobot' && (
(github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && github.event.pull_request.head.repo.fork == false) || (github.event_name == 'pull_request_review' && github.event.review.state == 'approved' && github.event.pull_request.head.repo.fork == false) ||
github.event_name == 'push' || github.event_name == 'push' ||
github.event_name == 'workflow_dispatch' github.event_name == 'workflow_dispatch'
)
outputs: outputs:
image_tag: ${{ steps.set_tag.outputs.image_tag }} image_tag: ${{ steps.set_tag.outputs.image_tag }}
env: env:
@@ -168,7 +160,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -180,12 +171,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Fix ptxas permissions
run: chmod +x /lerobot/.venv/lib/python3.12/site-packages/triton/backends/nvidia/bin/ptxas
- name: Run pytest on GPU - name: Run pytest on GPU
run: pytest tests -vv --maxfail=10 run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests - name: Run end-to-end tests
+4 -17
View File
@@ -28,7 +28,7 @@ on:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME_CPU: huggingface/lerobot-cpu:latest DOCKER_IMAGE_NAME_CPU: huggingface/lerobot-cpu:latest
DOCKER_IMAGE_NAME_GPU: huggingface/lerobot-gpu:latest DOCKER_IMAGE_NAME_GPU: huggingface/lerobot-gpu:latest
@@ -119,7 +119,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-docker-cpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-docker-cpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --shm-size "16gb" options: --shm-size "16gb"
@@ -131,10 +130,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Run pytest on CPU - name: Run pytest on CPU
run: pytest tests -vv --maxfail=10 run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests - name: Run end-to-end tests
@@ -151,7 +146,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -163,10 +157,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Run pytest on GPU - name: Run pytest on GPU
run: pytest tests -vv --maxfail=10 run: pytest tests -vv --maxfail=10
- name: Run end-to-end tests - name: Run end-to-end tests
@@ -184,7 +174,6 @@ jobs:
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
CUDA_VISIBLE_DEVICES: "0,1,2,3" CUDA_VISIBLE_DEVICES: "0,1,2,3"
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-docker-gpu-nightly.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -196,14 +185,12 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Verify GPU availability - name: Verify GPU availability
run: | run: |
nvidia-smi nvidia-smi
python -c "import torch; print(f'PyTorch CUDA available: {torch.cuda.is_available()}'); print(f'Number of GPUs: {torch.cuda.device_count()}')" python -c "import torch; print(f'PyTorch CUDA available: {torch.cuda.is_available()}'); print(f'Number of GPUs: {torch.cuda.device_count()}')"
- name: Run multi-GPU training tests - name: Run multi-GPU training tests
run: pytest -vv tests/training/ # TODO(Steven): Investigate why motors tests are failing in multi-GPU setup
run: pytest tests -vv --maxfail=10 --ignore=tests/motors/
timeout-minutes: 10
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: '3.12' python-version: '3.10'
- name: Run pre-commit hooks - name: Run pre-commit hooks
uses: pre-commit/action@v3.0.1 # zizmor: ignore[unpinned-uses] uses: pre-commit/action@v3.0.1 # zizmor: ignore[unpinned-uses]
+2 -2
View File
@@ -22,7 +22,7 @@ on:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
jobs: jobs:
# This job builds the Python package and publishes it to PyPI # This job builds the Python package and publishes it to PyPI
@@ -45,7 +45,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
python-version: '3.12' python-version: '3.10'
- name: Extract Version - name: Extract Version
id: extract_info id: extract_info
+2 -12
View File
@@ -29,7 +29,7 @@ permissions:
# Sets up the environment variables # Sets up the environment variables
env: env:
UV_VERSION: "0.8.0" UV_VERSION: "0.8.0"
PYTHON_VERSION: "3.12" PYTHON_VERSION: "3.10"
DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:unbound DOCKER_IMAGE_NAME: huggingface/lerobot-gpu:unbound
# Ensures that only the latest action is built, canceling older runs. # Ensures that only the latest action is built, canceling older runs.
@@ -48,7 +48,6 @@ jobs:
MUJOCO_GL: egl MUJOCO_GL: egl
HF_HOME: /mnt/cache/.cache/huggingface HF_HOME: /mnt/cache/.cache/huggingface
HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot HF_LEROBOT_HOME: /mnt/cache/.cache/huggingface/lerobot
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
with: with:
@@ -80,10 +79,7 @@ jobs:
- name: Install lerobot with all extras - name: Install lerobot with all extras
run: uv sync --extra all # TODO(Steven): Make flash-attn optional run: uv sync --extra all # TODO(Steven): Make flash-attn optional
- name: Login to Hugging Face
run: |
uv run hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
uv run hf auth whoami
- name: Run pytest (all extras) - name: Run pytest (all extras)
run: uv run pytest tests -vv run: uv run pytest tests -vv
@@ -95,7 +91,6 @@ jobs:
name: Build and Push Docker name: Build and Push Docker
runs-on: runs-on:
group: aws-general-8-plus group: aws-general-8-plus
if: github.repository == 'huggingface/lerobot'
outputs: outputs:
image_tag: ${{ env.DOCKER_IMAGE_NAME }} image_tag: ${{ env.DOCKER_IMAGE_NAME }}
env: env:
@@ -141,7 +136,6 @@ jobs:
HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot HF_LEROBOT_HOME: /home/user_lerobot/.cache/huggingface/lerobot
TORCH_HOME: /home/user_lerobot/.cache/torch TORCH_HOME: /home/user_lerobot/.cache/torch
TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton TRITON_CACHE_DIR: /home/user_lerobot/.cache/triton
HF_USER_TOKEN: ${{ secrets.LEROBOT_HF_USER }}
container: container:
image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images] image: ${{ needs.build-and-push-docker.outputs.image_tag }} # zizmor: ignore[unpinned-images]
options: --gpus all --shm-size "16gb" options: --gpus all --shm-size "16gb"
@@ -153,10 +147,6 @@ jobs:
shell: bash shell: bash
working-directory: /lerobot working-directory: /lerobot
steps: steps:
- name: Login to Hugging Face
run: |
hf auth login --token "$HF_USER_TOKEN" --add-to-git-credential
hf auth whoami
- name: Run pytest on GPU - name: Run pytest on GPU
run: pytest tests -vv run: pytest tests -vv
- name: Run end-to-end tests - name: Run end-to-end tests
+2 -2
View File
@@ -13,7 +13,7 @@
# limitations under the License. # limitations under the License.
default_language_version: default_language_version:
python: python3.12 python: python3.10
exclude: "tests/artifacts/.*\\.safetensors$" exclude: "tests/artifacts/.*\\.safetensors$"
@@ -55,7 +55,7 @@ repos:
rev: v3.21.0 rev: v3.21.0
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py312-plus] args: [--py310-plus]
##### Markdown Quality ##### ##### Markdown Quality #####
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
-25
View File
@@ -1,25 +0,0 @@
# AI Usage Policy
The LeRobot project welcomes contributions from everyone, and we have a few guidelines regarding AI usage to ensure high code quality, clear communication, and a healthy open-source ecosystem:
- **Please disclose significant AI assistance.** If you used AI tools (e.g., Copilot, Claude, Cursor, ChatGPT) to generate a substantial portion of your code or text, let us know in your PR description. Transparency helps us review your changes more effectively.
- **Own your code (The Human-in-the-Loop).** You must fully understand all the changes you are proposing. If you cannot explain what your AI-assisted code does or how it interacts with LeRobot's broader architecture, please take the time to learn and test it before submitting.
- **Keep issues and discussions focused.** You are welcome to use AI to help draft issues or PR descriptions, but please review and edit them carefully before posting. AI can often be overly verbose; trimming the noise and getting straight to the point helps our maintainers address your needs faster.
Our core maintainers also use AI tools to aid their workflows, but they do so while bringing deep contextual knowledge of the LeRobot codebase to validate the output. We ask all contributors to apply that same level of rigor.
## Remember the Human Maintainers
Please remember that LeRobot is maintained by a dedicated team of humans.
Every discussion, issue, and pull request is read and reviewed by real people. While AI tools can generate thousands of lines of code in seconds, reviewing that code still takes human time and energy. Submitting unverified or low-effort AI output puts an unfair burden on our maintainers.
Today, the quality of the AI output still heavily depends on the developer driving the tool. We ask that you respect our maintainers' time by thoroughly vetting, testing, and refining your submissions.
## AI is Welcome Here
LeRobot operates at the cutting edge of AI and robotics, and many of our maintainers actively embrace AI coding assistants as valuable productivity tools. We are a pro-AI project!
Our reason for having an AI policy is not an anti-AI stance. Rather, it exists to ensure that AI is used to enhance human contributions, not replace them with unverified noise. It's about how the tools are used, not the tools themselves.
We value the unique human insight you bring to the LeRobot community. Let AI empower your workflow, but always let your own judgment take the wheel.
+2 -2
View File
@@ -2,7 +2,7 @@
Everyone is welcome to contribute, and we value everybody's contribution. Code is not the only way to help the community. Answering questions, helping others, reaching out, and improving the documentation are immensely valuable. Everyone is welcome to contribute, and we value everybody's contribution. Code is not the only way to help the community. Answering questions, helping others, reaching out, and improving the documentation are immensely valuable.
Whichever way you choose to contribute, please be mindful to respect our [code of conduct](./CODE_OF_CONDUCT.md) and our [AI policy](./AI_POLICY.md). Whichever way you choose to contribute, please be mindful to respect our [code of conduct](./CODE_OF_CONDUCT.md).
## Ways to Contribute ## Ways to Contribute
@@ -14,7 +14,7 @@ You can contribute in many ways:
- **Documentation:** Improve examples, guides, and docstrings. - **Documentation:** Improve examples, guides, and docstrings.
- **Feedback:** Submit tickets related to bugs or desired new features. - **Feedback:** Submit tickets related to bugs or desired new features.
If you are unsure where to start, join our [Discord Channel](https://discord.gg/q8Dzzpym3f). If you are unsure where to start, join our [Discord Channel](https://discord.gg/JkrYNdmw).
## Development Setup ## Development Setup
-1
View File
@@ -1,3 +1,2 @@
include src/lerobot/templates/lerobot_modelcard_template.md include src/lerobot/templates/lerobot_modelcard_template.md
include src/lerobot/datasets/card_template.md include src/lerobot/datasets/card_template.md
include src/lerobot/envs/metaworld_config.json
-1
View File
@@ -128,7 +128,6 @@ Learn how to implement your own simulation environment or benchmark and distribu
## Resources ## Resources
- **[Documentation](https://huggingface.co/docs/lerobot/index):** The complete guide to tutorials & API. - **[Documentation](https://huggingface.co/docs/lerobot/index):** The complete guide to tutorials & API.
- **[Chinese Tutorials: LeRobot+SO-ARM101中文教程-同济子豪兄](https://zihao-ai.feishu.cn/wiki/space/7589642043471924447)** Detailed doc for assembling, teleoperate, dataset, train, deploy. Verified by Seed Studio and 5 global hackathon players.
- **[Discord](https://discord.gg/q8Dzzpym3f):** Join the `LeRobot` server to discuss with the community. - **[Discord](https://discord.gg/q8Dzzpym3f):** Join the `LeRobot` server to discuss with the community.
- **[X](https://x.com/LeRobotHF):** Follow us on X to stay up-to-date with the latest developments. - **[X](https://x.com/LeRobotHF):** Follow us on X to stay up-to-date with the latest developments.
- **[Robot Learning Tutorial](https://huggingface.co/spaces/lerobot/robot-learning-tutorial):** A free, hands-on course to learn robot learning using LeRobot. - **[Robot Learning Tutorial](https://huggingface.co/spaces/lerobot/robot-learning-tutorial):** A free, hands-on course to learn robot learning using LeRobot.
@@ -0,0 +1,219 @@
#!/usr/bin/env python
# Copyright 2025 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.
import argparse
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from soundfile import read
from lerobot.microphones.configs import MicrophoneConfig
from lerobot.microphones.portaudio import PortAudioMicrophone, PortAudioMicrophoneConfig
from lerobot.microphones.utils import (
async_microphones_start_recording,
async_microphones_stop_recording,
make_microphones_from_configs,
)
from lerobot.utils.robot_utils import (
precise_sleep,
)
def main(
microphones_configs: dict[str, MicrophoneConfig],
audio_chunks_number: int,
audio_chunks_duration: float,
repetitions: int,
multiprocessing: bool = False,
):
recording_dir = Path("outputs/audio_benchmark")
recording_dir.mkdir(parents=True, exist_ok=True)
# Create microphones
microphones = make_microphones_from_configs(microphones_configs)
# Connect microphones
for microphone in microphones.values():
microphone.connect()
all_audio_chunks = []
for i in range(repetitions):
print(f"Repetition {i + 1}/{repetitions}...")
# Create audio chunks
audio_chunks = {}
for microphone_key in microphones:
audio_chunks.update({microphone_key: []})
# Start recording
async_microphones_start_recording(
microphones,
output_files=[
recording_dir / f"{microphone_key}_recording_{i}.wav" for microphone_key in microphones
],
multiprocessing=multiprocessing,
)
# Record audio chunks
for j in range(audio_chunks_number):
precise_sleep(audio_chunks_duration)
for microphone_key, microphone in microphones.items():
audio_chunk = microphone.read()
print(f"{microphone_key} - repetition {i} - chunk {j} - samples {audio_chunk.shape[0]}")
audio_chunks[microphone_key].append(audio_chunk)
# Stop recording
async_microphones_stop_recording(microphones)
for microphone_key in microphones:
audio_chunks[microphone_key] = np.concatenate(audio_chunks[microphone_key], axis=0)
all_audio_chunks.append(audio_chunks)
# Disconnect microphones
for microphone in microphones.values():
microphone.disconnect()
# Compute statistics
cmap = plt.get_cmap("tab10")
_, ax = plt.subplots(nrows=repetitions, ncols=len(microphones))
chunk_length = np.zeros((repetitions, len(microphones)))
record_length = np.zeros((repetitions, len(microphones)))
for i in range(repetitions):
for j, (microphone_key, microphone) in enumerate(microphones.items()):
# Get recorded audio chunks
recorded_audio_chunks = all_audio_chunks[i][microphone_key]
# Load recorded file
recorded_data, _ = read(recording_dir / f"{microphone_key}_recording_{i}.wav")
if recorded_data.ndim == 1:
recorded_data = np.expand_dims(recorded_data, axis=1)
record_length[i, j] = recorded_data.shape[0]
chunk_length[i, j] = recorded_audio_chunks.shape[0]
for k, (chunk_data, record_data) in enumerate(
zip(recorded_audio_chunks.T, recorded_data.T, strict=False)
):
# Plot audio chunks and recorded data
ax[i, j].plot(
np.arange(0, len(chunk_data)) / microphone.sample_rate,
chunk_data,
label=f"audio chunks - channel {k}",
color=cmap(2 * k),
)
ax[i, j].plot(
np.arange(0, len(record_data)) / microphone.sample_rate,
record_data,
label=f"recorded data - channel {k}",
linestyle="dashed",
color=cmap(2 * k + 1),
)
# Plot absolute difference (errors should be located at the end of the recordings)
if recorded_data.shape[0] - recorded_audio_chunks.shape[0] > 0:
chunk_data = np.append(
chunk_data, np.zeros(int(recorded_data.shape[0] - recorded_audio_chunks.shape[0]))
)
else:
record_data = np.append(
record_data, np.zeros(int(-recorded_data.shape[0] + recorded_audio_chunks.shape[0]))
)
ax[i, j].plot(
np.arange(0, len(record_data)) / microphone.sample_rate,
np.abs(chunk_data - record_data),
label=f"differences - channel {k}",
color="red",
linestyle="dotted",
)
ax[i, j].set_title(f"{microphone_key} - repetition {i}")
ax[i, j].legend()
plt.show()
# Print statistics
differences = record_length - chunk_length
for i, (microphone_key, microphone) in enumerate(microphones.items()):
print(
f"Average recorded duration for {microphone_key} : {np.mean(record_length[:, i]) / microphone.sample_rate:.3f} seconds"
)
print(
f"Average chunk duration for {microphone_key} : {np.mean(chunk_length[:, i]) / microphone.sample_rate:.3f} seconds"
)
print(f"Average difference for {microphone_key} : {np.mean(differences[:, i]):.3f} samples")
print(
f"Average difference for {microphone_key} : {np.mean(differences[:, i]) / microphone.sample_rate:.3f} seconds"
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--microphones_indices",
type=int,
nargs="+",
default=[microphone["index"] for microphone in PortAudioMicrophone.find_microphones()],
)
parser.add_argument(
"--microphones_sample_rate",
type=float,
nargs="+",
default=[None] * len(PortAudioMicrophone.find_microphones()),
)
parser.add_argument(
"--microphones_channels",
type=int,
nargs="+",
default=[None] * len(PortAudioMicrophone.find_microphones()),
)
parser.add_argument("--audio_chunks_number", type=int, default=2)
parser.add_argument(
"--audio_chunks_duration",
type=float,
default=1.0,
)
parser.add_argument(
"--repetitions",
type=int,
default=2,
)
parser.add_argument(
"--multiprocessing",
action="store_true",
)
args = vars(parser.parse_args())
args["microphones_configs"] = {}
for index, sample_rate, channels in zip(
args["microphones_indices"],
args["microphones_sample_rate"],
args["microphones_channels"],
strict=False,
):
microphone_config = PortAudioMicrophoneConfig(
microphone_index=index,
sample_rate=sample_rate,
channels=channels,
)
args["microphones_configs"].update({f"microphone_{index}": microphone_config})
args.pop("microphones_indices")
args.pop("microphones_sample_rate")
args.pop("microphones_channels")
main(**args)
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python
# Copyright 2024 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.
import argparse
from pathlib import Path
import numpy as np
import soundfile as sf
from lerobot.microphones.anyskin import AnyskinSensorConfig
from lerobot.microphones.configs import MicrophoneConfig
from lerobot.microphones.utils import (
async_microphones_start_recording,
async_microphones_stop_recording,
make_microphones_from_configs,
)
from lerobot.utils.robot_utils import (
precise_sleep,
)
def main(
sensors_configs: dict[str, MicrophoneConfig],
multiprocessing: bool = False,
):
recording_dir = Path("outputs/tactile_benchmark")
recording_dir.mkdir(parents=True, exist_ok=True)
# Create microphones
sensors = make_microphones_from_configs(sensors_configs)
# Connect microphones
for sensor in sensors.values():
sensor.connect()
# Create audio chunks
data_chunks = {}
for sensor_key in sensors:
data_chunks.update({sensor_key: []})
# Start recording
async_microphones_start_recording(
sensors,
output_files=[recording_dir / f"{sensor_key}_recording.wav" for sensor_key in sensors],
multiprocessing=multiprocessing,
)
# Record audio chunks
precise_sleep(10.0)
for sensor_key, sensor in sensors.items():
data_chunk = sensor.read()
print(f"{sensor_key} - samples {data_chunk.shape[0]}")
data_chunks[sensor_key].append(data_chunk)
# Stop recording
async_microphones_stop_recording(sensors)
for sensor_key in sensors:
data_chunks[sensor_key] = np.concatenate(data_chunks[sensor_key], axis=0)
# Disconnect microphones
for sensor in sensors.values():
sensor.disconnect()
for sensor_key in sensors:
data, sample_rate = sf.read(recording_dir / f"{sensor_key}_recording.wav")
print(f"{sensor_key} - samples {data.shape[0]}")
print(f"{sensor_key} - sample rate {sample_rate}")
print(f"{sensor_key} - data {data}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--sensors_ports",
type=str,
nargs="+",
)
parser.add_argument(
"--sensors_baud_rate",
type=int,
nargs="+",
)
parser.add_argument(
"--sensors_sample_rate",
type=int,
nargs="+",
)
parser.add_argument(
"--sensors_channels",
type=int,
nargs="+",
)
parser.add_argument(
"--multiprocessing",
action="store_true",
)
args = vars(parser.parse_args())
args["sensors_configs"] = {}
for port, baud_rate, sample_rate, channels in zip(
args["sensors_ports"],
args["sensors_baud_rate"],
args["sensors_sample_rate"],
args["sensors_channels"],
strict=False,
):
channels = [1, 2, 3, 4, 5]
sensor_config = AnyskinSensorConfig(
sensor_port=port,
baud_rate=baud_rate,
sample_rate=sample_rate,
channels=channels,
)
args["sensors_configs"].update({f"sensor_{port}": sensor_config})
args.pop("sensors_ports")
args.pop("sensors_baud_rate")
args.pop("sensors_sample_rate")
args.pop("sensors_channels")
main(**args)
+22 -22
View File
@@ -28,9 +28,9 @@ We don't expect the same optimal settings for a dataset of images from a simulat
For these reasons, we run this benchmark on four representative datasets: For these reasons, we run this benchmark on four representative datasets:
- `lerobot/pusht_image`: (96 x 96 pixels) simulation with simple geometric shapes, fixed camera. - `lerobot/pusht_image`: (96 x 96 pixels) simulation with simple geometric shapes, fixed camera.
- `lerobot/aloha_mobile_shrimp_image`: (480 x 640 pixels) real-world indoor, moving camera. - `aliberts/aloha_mobile_shrimp_image`: (480 x 640 pixels) real-world indoor, moving camera.
- `lerobot/paris_street`: (720 x 1280 pixels) real-world outdoor, moving camera. - `aliberts/paris_street`: (720 x 1280 pixels) real-world outdoor, moving camera.
- `lerobot/kitchen`: (1080 x 1920 pixels) real-world indoor, fixed camera. - `aliberts/kitchen`: (1080 x 1920 pixels) real-world indoor, fixed camera.
Note: The datasets used for this benchmark need to be image datasets, not video datasets. Note: The datasets used for this benchmark need to be image datasets, not video datasets.
@@ -179,7 +179,7 @@ python benchmark/video/run_video_benchmark.py \
--output-dir outputs/video_benchmark \ --output-dir outputs/video_benchmark \
--repo-ids \ --repo-ids \
lerobot/pusht_image \ lerobot/pusht_image \
lerobot/aloha_mobile_shrimp_image \ aliberts/aloha_mobile_shrimp_image \
--vcodec libx264 libx265 \ --vcodec libx264 libx265 \
--pix-fmt yuv444p yuv420p \ --pix-fmt yuv444p yuv420p \
--g 2 20 None \ --g 2 20 None \
@@ -203,9 +203,9 @@ python benchmark/video/run_video_benchmark.py \
--output-dir outputs/video_benchmark \ --output-dir outputs/video_benchmark \
--repo-ids \ --repo-ids \
lerobot/pusht_image \ lerobot/pusht_image \
lerobot/aloha_mobile_shrimp_image \ aliberts/aloha_mobile_shrimp_image \
lerobot/paris_street \ aliberts/paris_street \
lerobot/kitchen \ aliberts/kitchen \
--vcodec libx264 libx265 \ --vcodec libx264 libx265 \
--pix-fmt yuv444p yuv420p \ --pix-fmt yuv444p yuv420p \
--g 1 2 3 4 5 6 10 15 20 40 None \ --g 1 2 3 4 5 6 10 15 20 40 None \
@@ -221,9 +221,9 @@ python benchmark/video/run_video_benchmark.py \
--output-dir outputs/video_benchmark \ --output-dir outputs/video_benchmark \
--repo-ids \ --repo-ids \
lerobot/pusht_image \ lerobot/pusht_image \
lerobot/aloha_mobile_shrimp_image \ aliberts/aloha_mobile_shrimp_image \
lerobot/paris_street \ aliberts/paris_street \
lerobot/kitchen \ aliberts/kitchen \
--vcodec libsvtav1 \ --vcodec libsvtav1 \
--pix-fmt yuv420p \ --pix-fmt yuv420p \
--g 1 2 3 4 5 6 10 15 20 40 None \ --g 1 2 3 4 5 6 10 15 20 40 None \
@@ -253,36 +253,36 @@ Since we're using av1 encoding, we're choosing the `pyav` decoder as `video_read
These tables show the results for `g=2` and `crf=30`, using `timestamps-modes=6_frames` and `backend=pyav` These tables show the results for `g=2` and `crf=30`, using `timestamps-modes=6_frames` and `backend=pyav`
| video_images_size_ratio | vcodec | pix_fmt | | | | | video_images_size_ratio | vcodec | pix_fmt | | | |
| --------------------------------- | ---------- | ------- | --------- | --------- | --------- | | ---------------------------------- | ---------- | ------- | --------- | --------- | --------- |
| | libx264 | | libx265 | | libsvtav1 | | | libx264 | | libx265 | | libsvtav1 |
| repo_id | yuv420p | yuv444p | yuv420p | yuv444p | yuv420p | | repo_id | yuv420p | yuv444p | yuv420p | yuv444p | yuv420p |
| lerobot/pusht_image | **16.97%** | 17.58% | 18.57% | 18.86% | 22.06% | | lerobot/pusht_image | **16.97%** | 17.58% | 18.57% | 18.86% | 22.06% |
| lerobot/aloha_mobile_shrimp_image | 2.14% | 2.11% | 1.38% | **1.37%** | 5.59% | | aliberts/aloha_mobile_shrimp_image | 2.14% | 2.11% | 1.38% | **1.37%** | 5.59% |
| lerobot/paris_street | 2.12% | 2.13% | **1.54%** | **1.54%** | 4.43% | | aliberts/paris_street | 2.12% | 2.13% | **1.54%** | **1.54%** | 4.43% |
| lerobot/kitchen | 1.40% | 1.39% | **1.00%** | **1.00%** | 2.52% | | aliberts/kitchen | 1.40% | 1.39% | **1.00%** | **1.00%** | 2.52% |
| video_images_load_time_ratio | vcodec | pix_fmt | | | | | video_images_load_time_ratio | vcodec | pix_fmt | | | |
| --------------------------------- | ------- | ------- | -------- | ------- | --------- | | ---------------------------------- | ------- | ------- | -------- | ------- | --------- |
| | libx264 | | libx265 | | libsvtav1 | | | libx264 | | libx265 | | libsvtav1 |
| repo_id | yuv420p | yuv444p | yuv420p | yuv444p | yuv420p | | repo_id | yuv420p | yuv444p | yuv420p | yuv444p | yuv420p |
| lerobot/pusht_image | 6.45 | 5.19 | **1.90** | 2.12 | 2.47 | | lerobot/pusht_image | 6.45 | 5.19 | **1.90** | 2.12 | 2.47 |
| lerobot/aloha_mobile_shrimp_image | 11.80 | 7.92 | 0.71 | 0.85 | **0.48** | | aliberts/aloha_mobile_shrimp_image | 11.80 | 7.92 | 0.71 | 0.85 | **0.48** |
| lerobot/paris_street | 2.21 | 2.05 | 0.36 | 0.49 | **0.30** | | aliberts/paris_street | 2.21 | 2.05 | 0.36 | 0.49 | **0.30** |
| lerobot/kitchen | 1.46 | 1.46 | 0.28 | 0.51 | **0.26** | | aliberts/kitchen | 1.46 | 1.46 | 0.28 | 0.51 | **0.26** |
| | | vcodec | pix_fmt | | | | | | | vcodec | pix_fmt | | | |
| --------------------------------- | -------- | -------- | ------------ | -------- | --------- | ------------ | | ---------------------------------- | -------- | -------- | ------------ | -------- | --------- | ------------ |
| | | libx264 | | libx265 | | libsvtav1 | | | | libx264 | | libx265 | | libsvtav1 |
| repo_id | metric | yuv420p | yuv444p | yuv420p | yuv444p | yuv420p | | repo_id | metric | yuv420p | yuv444p | yuv420p | yuv444p | yuv420p |
| lerobot/pusht_image | avg_mse | 2.90E-04 | **2.03E-04** | 3.13E-04 | 2.29E-04 | 2.19E-04 | | lerobot/pusht_image | avg_mse | 2.90E-04 | **2.03E-04** | 3.13E-04 | 2.29E-04 | 2.19E-04 |
| | avg_psnr | 35.44 | 37.07 | 35.49 | **37.30** | 37.20 | | | avg_psnr | 35.44 | 37.07 | 35.49 | **37.30** | 37.20 |
| | avg_ssim | 98.28% | **98.85%** | 98.31% | 98.84% | 98.72% | | | avg_ssim | 98.28% | **98.85%** | 98.31% | 98.84% | 98.72% |
| lerobot/aloha_mobile_shrimp_image | avg_mse | 2.76E-04 | 2.59E-04 | 3.17E-04 | 3.06E-04 | **1.30E-04** | | aliberts/aloha_mobile_shrimp_image | avg_mse | 2.76E-04 | 2.59E-04 | 3.17E-04 | 3.06E-04 | **1.30E-04** |
| | avg_psnr | 35.91 | 36.21 | 35.88 | 36.09 | **40.17** | | | avg_psnr | 35.91 | 36.21 | 35.88 | 36.09 | **40.17** |
| | avg_ssim | 95.19% | 95.18% | 95.00% | 95.05% | **97.73%** | | | avg_ssim | 95.19% | 95.18% | 95.00% | 95.05% | **97.73%** |
| lerobot/paris_street | avg_mse | 6.89E-04 | 6.70E-04 | 4.03E-03 | 4.02E-03 | **3.09E-04** | | aliberts/paris_street | avg_mse | 6.89E-04 | 6.70E-04 | 4.03E-03 | 4.02E-03 | **3.09E-04** |
| | avg_psnr | 33.48 | 33.68 | 32.05 | 32.15 | **35.40** | | | avg_psnr | 33.48 | 33.68 | 32.05 | 32.15 | **35.40** |
| | avg_ssim | 93.76% | 93.75% | 89.46% | 89.46% | **95.46%** | | | avg_ssim | 93.76% | 93.75% | 89.46% | 89.46% | **95.46%** |
| lerobot/kitchen | avg_mse | 2.50E-04 | 2.24E-04 | 4.28E-04 | 4.18E-04 | **1.53E-04** | | aliberts/kitchen | avg_mse | 2.50E-04 | 2.24E-04 | 4.28E-04 | 4.18E-04 | **1.53E-04** |
| | avg_psnr | 36.73 | 37.33 | 36.56 | 36.75 | **39.12** | | | avg_psnr | 36.73 | 37.33 | 36.56 | 36.75 | **39.12** |
| | avg_ssim | 95.47% | 95.58% | 95.52% | 95.53% | **96.82%** | | | avg_ssim | 95.47% | 95.58% | 95.52% | 95.53% | **96.82%** |
+1 -3
View File
@@ -24,7 +24,7 @@ ARG OS_VERSION=22.04
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu${OS_VERSION} FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu${OS_VERSION}
# Define Python version argument # Define Python version argument
ARG PYTHON_VERSION=3.12 ARG PYTHON_VERSION=3.10
# Configure environment variables # Configure environment variables
ENV DEBIAN_FRONTEND=noninteractive \ ENV DEBIAN_FRONTEND=noninteractive \
@@ -85,8 +85,6 @@ RUN if [ "$UNBOUND_DEPS" = "true" ]; then \
RUN uv pip install --no-cache ".[all]" RUN uv pip install --no-cache ".[all]"
RUN chmod +x /lerobot/.venv/lib/python${PYTHON_VERSION}/site-packages/triton/backends/nvidia/bin/ptxas
# Copy the rest of the application source code # Copy the rest of the application source code
# Make sure to have the git-LFS files for testing # Make sure to have the git-LFS files for testing
COPY --chown=user_lerobot:user_lerobot . . COPY --chown=user_lerobot:user_lerobot . .
+1 -1
View File
@@ -19,7 +19,7 @@
# docker run -it --rm lerobot-user # docker run -it --rm lerobot-user
# Configure the base image # Configure the base image
ARG PYTHON_VERSION=3.12 ARG PYTHON_VERSION=3.10
FROM python:${PYTHON_VERSION}-slim FROM python:${PYTHON_VERSION}-slim
# Configure environment variables # Configure environment variables
+2 -14
View File
@@ -7,6 +7,8 @@
- sections: - sections:
- local: il_robots - local: il_robots
title: Imitation Learning for Robots title: Imitation Learning for Robots
- local: cameras
title: Cameras
- local: bring_your_own_policies - local: bring_your_own_policies
title: Bring Your Own Policies title: Bring Your Own Policies
- local: integrate_hardware - local: integrate_hardware
@@ -27,10 +29,6 @@
title: Porting Large Datasets title: Porting Large Datasets
- local: using_dataset_tools - local: using_dataset_tools
title: Using the Dataset Tools title: Using the Dataset Tools
- local: dataset_subtask
title: Using Subtasks in the Dataset
- local: streaming_video_encoding
title: Streaming Video Encoding
title: "Datasets" title: "Datasets"
- sections: - sections:
- local: act - local: act
@@ -101,19 +99,11 @@
title: Unitree G1 title: Unitree G1
- local: earthrover_mini_plus - local: earthrover_mini_plus
title: Earth Rover Mini title: Earth Rover Mini
- local: omx
title: OMX
- local: openarm
title: OpenArm
title: "Robots" title: "Robots"
- sections: - sections:
- local: phone_teleop - local: phone_teleop
title: Phone title: Phone
title: "Teleoperators" title: "Teleoperators"
- sections:
- local: cameras
title: Cameras
title: "Sensors"
- sections: - sections:
- local: torch_accelerators - local: torch_accelerators
title: PyTorch accelerators title: PyTorch accelerators
@@ -123,8 +113,6 @@
title: Notebooks title: Notebooks
- local: feetech - local: feetech
title: Updating Feetech Firmware title: Updating Feetech Firmware
- local: damiao
title: Damiao Motors and CAN Bus
title: "Resources" title: "Resources"
- sections: - sections:
- local: contributing - local: contributing
-3
View File
@@ -88,8 +88,5 @@ lerobot-record \
--dataset.repo_id=${HF_USER}/eval_act_your_dataset \ --dataset.repo_id=${HF_USER}/eval_act_your_dataset \
--dataset.num_episodes=10 \ --dataset.num_episodes=10 \
--dataset.single_task="Your task description" \ --dataset.single_task="Your task description" \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
--policy.path=${HF_USER}/act_policy --policy.path=${HF_USER}/act_policy
``` ```
+1 -2
View File
@@ -48,7 +48,7 @@ python -m lerobot.async_inference.robot_client \
--task="dummy" \ # POLICY: The task to run the policy on (`Fold my t-shirt`). Not necessarily defined for all policies, such as `act` --task="dummy" \ # POLICY: The task to run the policy on (`Fold my t-shirt`). Not necessarily defined for all policies, such as `act`
--policy_type=your_policy_type \ # POLICY: the type of policy to run (smolvla, act, etc) --policy_type=your_policy_type \ # POLICY: the type of policy to run (smolvla, act, etc)
--pretrained_name_or_path=user/model \ # POLICY: the model name/path on server to the checkpoint to run (e.g., lerobot/smolvla_base) --pretrained_name_or_path=user/model \ # POLICY: the model name/path on server to the checkpoint to run (e.g., lerobot/smolvla_base)
--policy_device=mps \ # POLICY: the device to run the policy on, on the server (cuda, mps, xpu, cpu) --policy_device=mps \ # POLICY: the device to run the policy on, on the server
--actions_per_chunk=50 \ # POLICY: the number of actions to output at once --actions_per_chunk=50 \ # POLICY: the number of actions to output at once
--chunk_size_threshold=0.5 \ # CLIENT: the threshold for the chunk size before sending a new observation to the server --chunk_size_threshold=0.5 \ # CLIENT: the threshold for the chunk size before sending a new observation to the server
--aggregate_fn_name=weighted_average \ # CLIENT: the function to aggregate actions on overlapping portions --aggregate_fn_name=weighted_average \ # CLIENT: the function to aggregate actions on overlapping portions
@@ -195,7 +195,6 @@ client_cfg = RobotClientConfig(
robot=robot_cfg, robot=robot_cfg,
server_address="localhost:8080", server_address="localhost:8080",
policy_device="mps", policy_device="mps",
client_device="cpu",
policy_type="smolvla", policy_type="smolvla",
pretrained_name_or_path="<user>/smolvla_async", pretrained_name_or_path="<user>/smolvla_async",
chunk_size_threshold=0.5, chunk_size_threshold=0.5,
+4 -4
View File
@@ -32,7 +32,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
# your policy-specific dependencies # your policy-specific dependencies
] ]
requires-python = ">= 3.12" requires-python = ">= 3.11"
[build-system] [build-system]
build-backend = # your-build-backend build-backend = # your-build-backend
@@ -82,7 +82,7 @@ Create your policy implementation by inheriting from LeRobot's base `PreTrainedP
# modeling_my_custom_policy.py # modeling_my_custom_policy.py
import torch import torch
import torch.nn as nn import torch.nn as nn
from typing import Any from typing import Dict, Any
from lerobot.policies.pretrained import PreTrainedPolicy from lerobot.policies.pretrained import PreTrainedPolicy
from .configuration_my_custom_policy import MyCustomPolicyConfig from .configuration_my_custom_policy import MyCustomPolicyConfig
@@ -91,7 +91,7 @@ class MyCustomPolicy(PreTrainedPolicy):
config_class = MyCustomPolicyConfig config_class = MyCustomPolicyConfig
name = "my_custom_policy" name = "my_custom_policy"
def __init__(self, config: MyCustomPolicyConfig, dataset_stats: dict[str, Any] = None): def __init__(self, config: MyCustomPolicyConfig, dataset_stats: Dict[str, Any] = None):
super().__init__(config, dataset_stats) super().__init__(config, dataset_stats)
... ...
``` ```
@@ -102,7 +102,7 @@ Create processor functions:
```python ```python
# processor_my_custom_policy.py # processor_my_custom_policy.py
from typing import Any from typing import Dict, Any
import torch import torch
+79 -93
View File
@@ -1,22 +1,12 @@
# Cameras # Cameras
LeRobot offers multiple options for video capture: LeRobot offers multiple options for video capture, including phone cameras, built-in laptop cameras, external webcams, and Intel RealSense cameras. To efficiently record frames from most cameras, you can use either the `OpenCVCamera` or `RealSenseCamera` class. For additional compatibility details on the `OpenCVCamera` class, refer to the [Video I/O with OpenCV Overview](https://docs.opencv.org/4.x/d0/da7/videoio_overview.html).
| Class | Supported Cameras | ### Finding your camera
| ----------------- | ----------------------------------- |
| `OpenCVCamera` | Phone, built-in laptop, USB webcams |
| `ZMQCamera` | Network-connected cameras |
| `RealSenseCamera` | Intel RealSense (with depth) |
| `Reachy2Camera` | Reachy 2 robot cameras |
> [!TIP] To instantiate a camera, you need a camera identifier. This identifier might change if you reboot your computer or re-plug your camera, a behavior mostly dependant on your operating system.
> For `OpenCVCamera` compatibility details, see the [Video I/O with OpenCV Overview](https://docs.opencv.org/4.x/d0/da7/videoio_overview.html).
### Find your camera To find the camera indices of the cameras plugged into your system, run the following script:
Every camera requires a unique identifier to be instantiated, allowing you to distinguish between multiple connected devices.
`OpenCVCamera` and `RealSenseCamera` support auto-discovery. Run the command below to list available devices and their identifiers. Note that these identifiers may change after rebooting your computer or re-plugging the camera, depending on your operating system.
```bash ```bash
lerobot-find-cameras opencv # or realsense for Intel Realsense cameras lerobot-find-cameras opencv # or realsense for Intel Realsense cameras
@@ -24,7 +14,7 @@ lerobot-find-cameras opencv # or realsense for Intel Realsense cameras
The output will look something like this if you have two cameras connected: The output will look something like this if you have two cameras connected:
```bash ```
--- Detected Cameras --- --- Detected Cameras ---
Camera #0: Camera #0:
Name: OpenCV Camera @ 0 Name: OpenCV Camera @ 0
@@ -43,37 +33,13 @@ Camera #0:
> [!WARNING] > [!WARNING]
> When using Intel RealSense cameras in `macOS`, you could get this [error](https://github.com/IntelRealSense/librealsense/issues/12307): `Error finding RealSense cameras: failed to set power state`, this can be solved by running the same command with `sudo` permissions. Note that using RealSense cameras in `macOS` is unstable. > When using Intel RealSense cameras in `macOS`, you could get this [error](https://github.com/IntelRealSense/librealsense/issues/12307): `Error finding RealSense cameras: failed to set power state`, this can be solved by running the same command with `sudo` permissions. Note that using RealSense cameras in `macOS` is unstable.
`ZMQCamera` and `Reachy2Camera` do not support auto-discovery. They must be configured manually by providing their network address and port or robot SDK settings. ## Use Cameras
## Use cameras Below are two examples, demonstrating how to work with the API.
### Frame access modes - **Asynchronous frame capture** using an OpenCV-based camera
All camera classes implement three access modes for capturing frames:
| Method | Behavior | Blocks? | Best For |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------- |
| `read()` | Waits for the camera hardware to return a frame. May block for a long time depending on the camera and SDK. | Yes | Simple scripts, sequential capture |
| `async_read(timeout_ms)` | Returns the latest unconsumed frame from background thread. Blocks only if buffer is empty, up to `timeout_ms`. Raises `TimeoutError` if no frame arrives. | With a timeout | Control loops synchronized to camera FPS |
| `read_latest(max_age_ms)` | Peeks at the most recent frame in buffer (may be stale). Raises `TimeoutError` if frame is older than `max_age_ms`. | No | UI visualization, logging, monitoring |
### Usage examples
The following examples show how to use the camera API to configure and capture frames from different camera types.
- **Blocking and non-blocking frame capture** using an OpenCV-based camera
- **Color and depth capture** using an Intel RealSense camera - **Color and depth capture** using an Intel RealSense camera
> [!WARNING]
> Failing to cleanly disconnect cameras can cause resource leaks. Use the context manager protocol to ensure automatic cleanup:
>
> ```python
> with OpenCVCamera(config) as camera:
> ...
> ```
>
> You can also call `connect()` and `disconnect()` manually, but always use a `finally` block for the latter.
<hfoptions id="shell_restart"> <hfoptions id="shell_restart">
<hfoption id="Open CV Camera"> <hfoption id="Open CV Camera">
@@ -94,30 +60,16 @@ config = OpenCVCameraConfig(
) )
# Instantiate and connect an `OpenCVCamera`, performing a warm-up read (default). # Instantiate and connect an `OpenCVCamera`, performing a warm-up read (default).
with OpenCVCamera(config) as camera: camera = OpenCVCamera(config)
camera.connect()
# Read a frame synchronously — blocks until hardware delivers a new frame # Read frames asynchronously in a loop via `async_read(timeout_ms)`
frame = camera.read() try:
print(f"read() call returned frame with shape:", frame.shape)
# Read a frame asynchronously with a timeout — returns the latest unconsumed frame or waits up to timeout_ms for a new one
try:
for i in range(10): for i in range(10):
frame = camera.async_read(timeout_ms=200) frame = camera.async_read(timeout_ms=200)
print(f"async_read call returned frame {i} with shape:", frame.shape) print(f"Async frame {i} shape:", frame.shape)
except TimeoutError as e: finally:
print(f"No frame received within timeout: {e}") camera.disconnect()
# Instantly return a frame - returns the most recent frame captured by the camera
try:
initial_frame = camera.read_latest(max_age_ms=1000)
for i in range(10):
frame = camera.read_latest(max_age_ms=1000)
print(f"read_latest call returned frame {i} with shape:", frame.shape)
print(f"Was a new frame received by the camera? {not (initial_frame == frame).any()}")
except TimeoutError as e:
print(f"Frame too old: {e}")
``` ```
<!-- prettier-ignore-end --> <!-- prettier-ignore-end -->
@@ -159,10 +111,10 @@ finally:
</hfoption> </hfoption>
</hfoptions> </hfoptions>
## Use your phone's camera ## Use your phone
<hfoptions id="use phone"> <hfoptions id="use phone">
<hfoption id="iPhone & macOS"> <hfoption id="Mac">
To use your iPhone as a camera on macOS, enable the Continuity Camera feature: To use your iPhone as a camera on macOS, enable the Continuity Camera feature:
@@ -172,49 +124,83 @@ To use your iPhone as a camera on macOS, enable the Continuity Camera feature:
For more details, visit [Apple support](https://support.apple.com/en-gb/guide/mac-help/mchl77879b8a/mac). For more details, visit [Apple support](https://support.apple.com/en-gb/guide/mac-help/mchl77879b8a/mac).
Your iPhone should be detected automatically when running the camera setup script in the next section.
</hfoption> </hfoption>
<hfoption id="OBS virtual camera"> <hfoption id="Linux">
If you want to use your phone as a camera using OBS, follow these steps to set up a virtual camera. If you want to use your phone as a camera on Linux, follow these steps to set up a virtual camera
1. _(Linux only) Install `v4l2loopback-dkms` and `v4l-utils`_. These packages create virtual camera devices and verify their settings. Install with: 1. _Install `v4l2loopback-dkms` and `v4l-utils`_. Those packages are required to create virtual camera devices (`v4l2loopback`) and verify their settings with the `v4l2-ctl` utility from `v4l-utils`. Install them using:
```bash <!-- prettier-ignore-start -->
```python
sudo apt install v4l2loopback-dkms v4l-utils sudo apt install v4l2loopback-dkms v4l-utils
``` ```
<!-- prettier-ignore-end -->
2. _Install the [DroidCam app](https://droidcam.app) on your phone_. This app is available for both iOS and Android. 2. _Install [DroidCam](https://droidcam.app) on your phone_. This app is available for both iOS and Android.
3. _Download and install [OBS Studio](https://obsproject.com)_. 3. _Install [OBS Studio](https://obsproject.com)_. This software will help you manage the camera feed. Install it using [Flatpak](https://flatpak.org):
4. _Download and install the [DroidCam OBS plugin](https://droidcam.app/obs)_.
5. _Start OBS Studio_.
6. _Add your phone as a source_. Follow the instructions [here](https://droidcam.app/obs/usage). Be sure to set the resolution to `640x480` to avoid the watermarks. <!-- prettier-ignore-start -->
7. _Adjust resolution settings_. In OBS Studio, go to `File > Settings > Video` or `OBS > Preferences... > Video`. Change the `Base(Canvas) Resolution` and the `Output(Scaled) Resolution` to `640x480` by manually typing it. ```python
flatpak install flathub com.obsproject.Studio
```
<!-- prettier-ignore-end -->
4. _Install the DroidCam OBS plugin_. This plugin integrates DroidCam with OBS Studio. Install it with:
<!-- prettier-ignore-start -->
```python
flatpak install flathub com.obsproject.Studio.Plugin.DroidCam
```
<!-- prettier-ignore-end -->
5. _Start OBS Studio_. Launch with:
<!-- prettier-ignore-start -->
```python
flatpak run com.obsproject.Studio
```
<!-- prettier-ignore-end -->
6. _Add your phone as a source_. Follow the instructions [here](https://droidcam.app/obs/usage). Be sure to set the resolution to `640x480`.
7. _Adjust resolution settings_. In OBS Studio, go to `File > Settings > Video`. Change the `Base(Canvas) Resolution` and the `Output(Scaled) Resolution` to `640x480` by manually typing it in.
8. _Start virtual camera_. In OBS Studio, follow the instructions [here](https://obsproject.com/kb/virtual-camera-guide). 8. _Start virtual camera_. In OBS Studio, follow the instructions [here](https://obsproject.com/kb/virtual-camera-guide).
9. _Verify the virtual camera setup and resolution_. 9. _Verify the virtual camera setup_. Use `v4l2-ctl` to list the devices:
- **Linux**: Use `v4l2-ctl` to list devices and check resolution:
```bash
v4l2-ctl --list-devices # find VirtualCam and note its /dev/videoX path
v4l2-ctl -d /dev/videoX --get-fmt-video # replace with your VirtualCam path
```
You should see `VirtualCam` listed and resolution `640x480`.
- **macOS**: Open Photo Booth or FaceTime and select "OBS Virtual Camera" as the input.
- **Windows**: The native Camera app doesn't support virtual cameras. Use a video conferencing app (Zoom, Teams) or run `lerobot-find-cameras opencv` directly to verify.
<details> <!-- prettier-ignore-start -->
<summary><strong>Troubleshooting</strong></summary> ```python
v4l2-ctl --list-devices
```
<!-- prettier-ignore-end -->
> The virtual camera resolution is incorrect. You should see an entry like:
Delete the virtual camera source and recreate it. The resolution cannot be changed after creation. ```
VirtualCam (platform:v4l2loopback-000):
/dev/video1
```
> Error reading frame in background thread for OpenCVCamera(X): OpenCVCamera(X) frame width=640 or height=480 do not match configured width=1920 or height=1080. 10. _Check the camera resolution_. Use `v4l2-ctl` to ensure that the virtual camera output resolution is `640x480`. Change `/dev/video1` to the port of your virtual camera from the output of `v4l2-ctl --list-devices`.
This error is caused by OBS Virtual Camera advertising a `1920x1080` resolution despite rescaling. The only fix for now is to comment out the width and height check in `_postprocess_image()`. <!-- prettier-ignore-start -->
```python
v4l2-ctl -d /dev/video1 --get-fmt-video
```
<!-- prettier-ignore-end -->
</details> You should see an entry like:
```
>>> Format Video Capture:
>>> Width/Height : 640/480
>>> Pixel Format : 'YUYV' (YUYV 4:2:2)
```
Troubleshooting: If the resolution is not correct you will have to delete the Virtual Camera port and try again as it cannot be changed.
If everything is set up correctly, you can proceed with the rest of the tutorial.
</hfoption> </hfoption>
</hfoptions> </hfoptions>
If everything is set up correctly, your phone will appear as a standard OpenCV camera and can be used with `OpenCVCamera`.
-165
View File
@@ -1,165 +0,0 @@
# Damiao Motors and CAN Bus
This guide covers setup and usage of Damiao motors with LeRobot via CAN bus communication.
Currently, only Linux is supported, as the OpenArms CAN adapter only has drivers for Linux.
## Linux CAN Setup
Before using Damiao motors, you need to set up the CAN interface on your Linux system.
### Install CAN Utilities
```bash
sudo apt-get install can-utils
```
### Configure CAN Interface (Manual)
For standard CAN FD (recommended for OpenArms):
```bash
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 1000000 dbitrate 5000000 fd on
sudo ip link set can0 up
```
For standard CAN (without FD):
```bash
sudo ip link set can0 down
sudo ip link set can0 type can bitrate 1000000
sudo ip link set can0 up
```
### Configure CAN Interface (Using LeRobot)
LeRobot provides a utility script to setup and test CAN interfaces:
```bash
# Setup multiple interfaces (e.g., OpenArms Followers with 2 CAN buses)
lerobot-setup-can --mode=setup --interfaces=can0,can1
```
## Debugging CAN Communication
Use the built-in debug tools to test motor communication:
```bash
# Test motors on all interfaces
lerobot-setup-can --mode=test --interfaces=can0,can1
# Run speed/latency test
lerobot-setup-can --mode=speed --interfaces=can0
```
The test mode will scan for motors (IDs 0x01-0x08) and report which ones respond. Example output:
```
can0: UP (CAN FD)
Motor 0x01 (joint_1): ✓ FOUND
→ Response 0x11 [FD]: 00112233...
Motor 0x02 (joint_2): ✓ FOUND
Motor 0x03 (joint_3): ✗ No response
...
Summary: 2/8 motors found
```
## Usage
### Basic Setup
```python
from lerobot.motors import Motor
from lerobot.motors.damiao import DamiaoMotorsBus
# Define your motors with send/receive CAN IDs
motors = {
"joint_1": Motor(id=0x01, motor_type_str="dm8009", recv_id=0x11),
"joint_2": Motor(id=0x02, motor_type_str="dm4340", recv_id=0x12),
"joint_3": Motor(id=0x03, motor_type_str="dm4310", recv_id=0x13),
}
# Create the bus
bus = DamiaoMotorsBus(
port="can0", # Linux socketcan interface
motors=motors,
)
# Connect
bus.connect()
```
### Reading Motor States
```python
# Read single motor position (degrees)
position = bus.read("Present_Position", "joint_1")
# Read from multiple motors
positions = bus.sync_read("Present_Position") # All motors
positions = bus.sync_read("Present_Position", ["joint_1", "joint_2"])
# Read all states at once (position, velocity, torque)
states = bus.sync_read_all_states()
# Returns: {'joint_1': {'position': 45.2, 'velocity': 1.3, 'torque': 0.5}, ...}
```
### Writing Motor Commands
```python
# Enable torque
bus.enable_torque()
# Set goal position (degrees)
bus.write("Goal_Position", "joint_1", 45.0)
# Set positions for multiple motors
bus.sync_write("Goal_Position", {
"joint_1": 45.0,
"joint_2": -30.0,
"joint_3": 90.0,
})
# Disable torque
bus.disable_torque()
```
## Configuration Options
| Parameter | Default | Description |
| -------------- | --------- | ----------------------------------------------------------- |
| `port` | - | CAN interface (`can0`) or serial port (`/dev/cu.usbmodem*`) |
| `use_can_fd` | `True` | Enable CAN FD for higher data rates |
| `bitrate` | `1000000` | Nominal bitrate (1 Mbps) |
| `data_bitrate` | `5000000` | CAN FD data bitrate (5 Mbps) |
## Motor Configuration
Each motor requires:
- `id`: CAN ID for sending commands
- `motor_type`: One of the supported motor types (e.g., `"dm8009"`, `"dm4340"`)
- `recv_id`: CAN ID for receiving responses
OpenArms default IDs follow the pattern: send ID `0x0N`, receive ID `0x1N` where N is the joint number.
## Troubleshooting
### No Response from Motors
1. **Check power**
2. **Verify CAN wiring**: Check CAN-H, CAN-L, and GND connections
3. **Check motor IDs**: Use Damiao Debugging Tools to verify/configure IDs
4. **Test CAN interface**: Run `candump can0` to see if messages are being received
5. **Run diagnostics**: `lerobot-setup-can --mode=test --interfaces=can0`
### Motor Timeout Parameter
If motors were configured with timeout=0, they won't respond to commands. Use Damiao Debugging Tools to set a non-zero timeout value.
### Verify CAN FD Status
```bash
ip -d link show can0 | grep fd
```
-278
View File
@@ -1,278 +0,0 @@
# Using Subtasks in LeRobot Datasets
Subtask support in robotics datasets has proven effective in improving robot reasoning and understanding. Subtasks are particularly useful for:
- **Hierarchical policies**: Building policies that include subtask predictions to visualize robot reasoning in real time
- **Reward modeling**: Helping reward models understand task progression (e.g., SARM-style stage-aware reward models)
- **Task decomposition**: Breaking down complex manipulation tasks into atomic, interpretable steps
LeRobotDataset now supports subtasks as part of its dataset structure, alongside tasks.
## What are Subtasks?
While a **task** describes the overall goal (e.g., "Pick up the apple and place it in the basket"), **subtasks** break down the execution into finer-grained steps:
1. "Approach the apple"
2. "Grasp the apple"
3. "Lift the apple"
4. "Move to basket"
5. "Release the apple"
Each frame in the dataset can be annotated with its corresponding subtask, enabling models to learn and predict these intermediate stages.
<img
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/subtask-asset.png"
alt="An overview of subtask annotation showing how frames are labeled with intermediate subtask stages"
width="80%"
/>
<p>
<em>Figure: Overview of subtask annotation.</em>
</p>
**Reference:** _Subtask-learning based for robot self-assembly in flexible collaborative assembly in manufacturing_, Original Article, Published: 19 April 2022.
## Dataset Structure
Subtask information is stored in the dataset metadata:
```
my-dataset/
├── data/
│ └── ...
├── meta/
│ ├── info.json
│ ├── stats.json
│ ├── tasks.parquet
│ ├── subtasks.parquet # Subtask index → subtask string mapping
│ └── episodes/
│ └── ...
└── videos/
└── ...
```
### Subtasks Parquet File
The `meta/subtasks.parquet` file maps subtask indices to their natural language descriptions:
| subtask_index | subtask (index column) |
| ------------- | ---------------------- |
| 0 | "Approach the apple" |
| 1 | "Grasp the apple" |
| 2 | "Lift the apple" |
| ... | ... |
### Frame-Level Annotations
Each frame in the dataset can include a `subtask_index` field that references the subtasks parquet file:
```python
# Example frame data in the parquet file
{
"index": 42,
"timestamp": 1.4,
"episode_index": 0,
"task_index": 0,
"subtask_index": 2, # References "Lift the apple"
"observation.state": [...],
"action": [...],
}
```
## Annotating Datasets with Subtasks
We provide a HuggingFace Space for easily annotating any LeRobotDataset with subtasks:
**[https://huggingface.co/spaces/lerobot/annotate](https://huggingface.co/spaces/lerobot/annotate)**
After completing your annotation:
1. Click "Push to Hub" to upload your annotated dataset
2. You can also run the annotation space locally by following the instructions at [github.com/huggingface/lerobot-annotate](https://github.com/huggingface/lerobot-annotate)
## Loading Datasets with Subtasks
When you load a dataset with subtask annotations, the subtask information is automatically available:
```python
from lerobot.datasets.lerobot_dataset import LeRobotDataset
# Load a dataset with subtask annotations
dataset = LeRobotDataset("jadechoghari/collect-fruit-annotated")
# Access a sample
sample = dataset[100]
# The sample includes both task and subtask information
print(sample["task"]) # "Collect the fruit"
print(sample["subtask"]) # "Grasp the apple"
print(sample["task_index"]) # tensor(0)
print(sample["subtask_index"]) # tensor(2)
```
### Checking for Subtask Support
You can check if a dataset has subtask annotations:
```python
# Check if subtasks are available
has_subtasks = (
"subtask_index" in dataset.features
and dataset.meta.subtasks is not None
)
if has_subtasks:
print(f"Dataset has {len(dataset.meta.subtasks)} unique subtasks")
print("Subtasks:", list(dataset.meta.subtasks.index))
```
## Using Subtasks for Training
### With the Tokenizer Processor
The `TokenizerProcessor` automatically handles subtask tokenization for Vision-Language Action (VLA) models:
```python
from lerobot.processor.tokenizer_processor import TokenizerProcessor
from lerobot.processor.pipeline import ProcessorPipeline
# Create a tokenizer processor
tokenizer_processor = TokenizerProcessor(
tokenizer_name_or_path="google/paligemma-3b-pt-224",
padding="max_length",
max_length=64,
)
# The processor will automatically tokenize subtasks if present in the batch
# and add them to the observation under:
# - "observation.subtask.tokens"
# - "observation.subtask.attention_mask"
```
When subtasks are available in the batch, the tokenizer processor adds:
- `observation.subtask.tokens`: Tokenized subtask text
- `observation.subtask.attention_mask`: Attention mask for the subtask tokens
### DataLoader with Subtasks
```python
import torch
from lerobot.datasets.lerobot_dataset import LeRobotDataset
dataset = LeRobotDataset("jadechoghari/collect-fruit-annotated")
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=16,
shuffle=True,
)
for batch in dataloader:
# Access subtask information in the batch
subtasks = batch["subtask"] # List of subtask strings
subtask_indices = batch["subtask_index"] # Tensor of subtask indices
# Use for training hierarchical policies or reward models
print(f"Batch subtasks: {set(subtasks)}")
```
## Example Datasets with Subtask Annotations
Try loading a dataset with subtask annotations:
```python
from lerobot.datasets.lerobot_dataset import LeRobotDataset
# Example dataset with subtask annotations
dataset = LeRobotDataset("jadechoghari/collect-fruit-annotated")
# Explore the subtasks
print("Available subtasks:")
for subtask_name in dataset.meta.subtasks.index:
print(f" - {subtask_name}")
# Get subtask distribution
subtask_counts = {}
for i in range(len(dataset)):
sample = dataset[i]
subtask = sample["subtask"]
subtask_counts[subtask] = subtask_counts.get(subtask, 0) + 1
print("\nSubtask distribution:")
for subtask, count in sorted(subtask_counts.items(), key=lambda x: -x[1]):
print(f" {subtask}: {count} frames")
```
## Use Cases
### 1. Hierarchical Policy Training
Train policies that predict both actions and current subtask:
```python
class HierarchicalPolicy(nn.Module):
def __init__(self, num_subtasks):
super().__init__()
self.action_head = nn.Linear(hidden_dim, action_dim)
self.subtask_head = nn.Linear(hidden_dim, num_subtasks)
def forward(self, observations):
features = self.encoder(observations)
actions = self.action_head(features)
subtask_logits = self.subtask_head(features)
return actions, subtask_logits
```
### 2. Stage-Aware Reward Modeling (SARM)
Build reward models that understand task progression:
```python
# SARM predicts:
# - Stage: Which subtask is being executed (discrete)
# - Progress: How far along the subtask (continuous 0-1)
class SARMRewardModel(nn.Module):
def forward(self, observations):
features = self.encoder(observations)
stage_logits = self.stage_classifier(features)
progress = self.progress_regressor(features)
return stage_logits, progress
```
### 3. Progress Visualization
Monitor robot execution by tracking subtask progression:
```python
def visualize_execution(model, observations):
for t, obs in enumerate(observations):
action, subtask_logits = model(obs)
predicted_subtask = subtask_names[subtask_logits.argmax()]
print(f"t={t}: Executing '{predicted_subtask}'")
```
## API Reference
### LeRobotDataset Properties
| Property | Type | Description |
| --------------------------- | ---------------------- | ------------------------------------------ |
| `meta.subtasks` | `pd.DataFrame \| None` | DataFrame mapping subtask names to indices |
| `features["subtask_index"]` | `dict` | Feature spec for subtask_index if present |
### Sample Keys
When subtasks are available, each sample includes:
| Key | Type | Description |
| --------------- | -------------- | ------------------------------------ |
| `subtask_index` | `torch.Tensor` | Integer index of the current subtask |
| `subtask` | `str` | Natural language subtask description |
## Related Resources
- [SARM Paper](https://arxiv.org/pdf/2509.25358) - Stage-Aware Reward Modeling for Long Horizon Robot Manipulation
- [LeRobot Annotate Space](https://huggingface.co/spaces/lerobot/annotate) - Interactive annotation tool
- [LeRobotDataset v3.0](./lerobot-dataset-v3) - Dataset format documentation
+4 -13
View File
@@ -1,11 +1,5 @@
# EarthRover Mini Plus # EarthRover Mini Plus
<img
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/Earth_Rover_Mini_5_240c9adc-4f9e-44b7-982f-5d1dc24af1d8.png.webp"
alt="EarthRover Mini Plus"
width="70%"
/>
The EarthRover Mini Plus is a fully open source mobile robot that connects through the cloud using the Frodobots SDK. This lets you control the robot and record datasets for training AI models. The EarthRover Mini Plus is a fully open source mobile robot that connects through the cloud using the Frodobots SDK. This lets you control the robot and record datasets for training AI models.
## What You Need ## What You Need
@@ -13,7 +7,7 @@ The EarthRover Mini Plus is a fully open source mobile robot that connects throu
### Hardware ### Hardware
- EarthRover Mini robot - EarthRover Mini robot
- Computer with Python 3.12 or newer - Computer with Python 3.10 or newer
- Internet connection - Internet connection
### Setting Up the Frodobots SDK ### Setting Up the Frodobots SDK
@@ -170,13 +164,13 @@ Once you can drive the robot well, you can start recording data to train AI mode
We use Hugging Face to store your data online. First, log in with your token from [Hugging Face settings](https://huggingface.co/settings/tokens): We use Hugging Face to store your data online. First, log in with your token from [Hugging Face settings](https://huggingface.co/settings/tokens):
```bash ```bash
hf auth login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
``` ```
Store your Hugging Face username: Store your Hugging Face username:
```bash ```bash
HF_USER=$(hf auth whoami | awk -F': *' 'NR==1 {print $2}') HF_USER=$(huggingface-cli whoami | head -n 1)
echo $HF_USER echo $HF_USER
``` ```
@@ -185,16 +179,13 @@ echo $HF_USER
Use the standard recording command: Use the standard recording command:
```bash ```bash
lerobot-record \ python src/lerobot/scripts/lerobot_record.py \
--robot.type=earthrover_mini_plus \ --robot.type=earthrover_mini_plus \
--teleop.type=keyboard_rover \ --teleop.type=keyboard_rover \
--dataset.repo_id=your_username/dataset_name \ --dataset.repo_id=your_username/dataset_name \
--dataset.num_episodes=2 \ --dataset.num_episodes=2 \
--dataset.fps=10 \ --dataset.fps=10 \
--dataset.single_task="Navigate around obstacles" \ --dataset.single_task="Navigate around obstacles" \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
--display_data=true --display_data=true
``` ```
+2 -2
View File
@@ -155,10 +155,10 @@ Upload your repository to Hugging Face:
pip install huggingface_hub pip install huggingface_hub
# Login to Hugging Face # Login to Hugging Face
hf auth login huggingface-cli login
# Create a new repository # Create a new repository
hf repo create my-org/my-custom-env huggingface-cli repo create my-custom-env --type space --org my-org
# Initialize git and push # Initialize git and push
git init git init
+3 -6
View File
@@ -120,12 +120,9 @@ lerobot-record \
--display_data=true \ --display_data=true \
--dataset.repo_id=<user>/eval_groot-bimanual \ --dataset.repo_id=<user>/eval_groot-bimanual \
--dataset.num_episodes=10 \ --dataset.num_episodes=10 \
--dataset.single_task="Grab and handover the red cube to the other arm" \ --dataset.single_task="Grab and handover the red cube to the other arm"
--dataset.streaming_encoding=true \ --policy.path=<user>/groot-bimanual # your trained model
--dataset.encoder_threads=2 \ --dataset.episode_time_s=30
# --dataset.vcodec=auto \
--policy.path=<user>/groot-bimanual \ # your trained model
--dataset.episode_time_s=30 \
--dataset.reset_time_s=10 --dataset.reset_time_s=10
``` ```
+5 -11
View File
@@ -224,15 +224,12 @@ lerobot-record \
--teleop.port=/dev/tty.usbmodem1201 \ --teleop.port=/dev/tty.usbmodem1201 \
--teleop.id=right \ --teleop.id=right \
--teleop.side=right \ --teleop.side=right \
--dataset.repo_id=<USER>/hand_record_test_with_video_data \ --dataset.repo_id=nepyope/hand_record_test_with_video_data \
--dataset.single_task="Hand recording test with video data" \ --dataset.single_task="Hand recording test with video data" \
--dataset.num_episodes=1 \ --dataset.num_episodes=1 \
--dataset.episode_time_s=5 \ --dataset.episode_time_s=5 \
--dataset.push_to_hub=true \ --dataset.push_to_hub=true \
--dataset.private=true \ --dataset.private=true \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
--display_data=true --display_data=true
``` ```
@@ -244,7 +241,7 @@ lerobot-replay \
--robot.port=/dev/tty.usbmodem58760432281 \ --robot.port=/dev/tty.usbmodem58760432281 \
--robot.id=right \ --robot.id=right \
--robot.side=right \ --robot.side=right \
--dataset.repo_id=<USER>/hand_record_test_with_camera \ --dataset.repo_id=nepyope/hand_record_test_with_camera \
--dataset.episode=0 --dataset.episode=0
``` ```
@@ -252,13 +249,13 @@ lerobot-replay \
```bash ```bash
lerobot-train \ lerobot-train \
--dataset.repo_id=<USER>/hand_record_test_with_video_data \ --dataset.repo_id=nepyope/hand_record_test_with_video_data \
--policy.type=act \ --policy.type=act \
--output_dir=outputs/train/hopejr_hand \ --output_dir=outputs/train/hopejr_hand \
--job_name=hopejr \ --job_name=hopejr \
--policy.device=mps \ --policy.device=mps \
--wandb.enable=true \ --wandb.enable=true \
--policy.repo_id=<USER>/hand_test_policy --policy.repo_id=nepyope/hand_test_policy
``` ```
### Evaluate ### Evaluate
@@ -273,11 +270,8 @@ lerobot-record \
--robot.side=right \ --robot.side=right \
--robot.cameras='{"main": {"type": "opencv", "index_or_path": 0, "width": 640, "height": 480, "fps": 30}}' \ --robot.cameras='{"main": {"type": "opencv", "index_or_path": 0, "width": 640, "height": 480, "fps": 30}}' \
--display_data=false \ --display_data=false \
--dataset.repo_id=<USER>/eval_hopejr \ --dataset.repo_id=nepyope/eval_hopejr \
--dataset.single_task="Evaluate hopejr hand policy" \ --dataset.single_task="Evaluate hopejr hand policy" \
--dataset.num_episodes=10 \ --dataset.num_episodes=10 \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
--policy.path=outputs/train/hopejr_hand/checkpoints/last/pretrained_model --policy.path=outputs/train/hopejr_hand/checkpoints/last/pretrained_model
``` ```
+6 -12
View File
@@ -159,13 +159,13 @@ We use the Hugging Face hub features for uploading your dataset. If you haven't
Add your token to the CLI by running this command: Add your token to the CLI by running this command:
```bash ```bash
hf auth login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
``` ```
Then store your Hugging Face repository name in a variable: Then store your Hugging Face repository name in a variable:
```bash ```bash
HF_USER=$(hf auth whoami | awk -F': *' 'NR==1 {print $2}') HF_USER=$(hf auth whoami | head -n 1)
echo $HF_USER echo $HF_USER
``` ```
@@ -185,10 +185,7 @@ lerobot-record \
--display_data=true \ --display_data=true \
--dataset.repo_id=${HF_USER}/record-test \ --dataset.repo_id=${HF_USER}/record-test \
--dataset.num_episodes=5 \ --dataset.num_episodes=5 \
--dataset.single_task="Grab the black cube" \ --dataset.single_task="Grab the black cube"
--dataset.streaming_encoding=true \
# --dataset.vcodec=auto \
--dataset.encoder_threads=2
``` ```
</hfoption> </hfoption>
<hfoption id="API example"> <hfoption id="API example">
@@ -327,7 +324,7 @@ You can look for other LeRobot datasets on the hub by searching for `LeRobot` [t
You can also push your local dataset to the Hub manually, running: You can also push your local dataset to the Hub manually, running:
```bash ```bash
hf upload ${HF_USER}/record-test ~/.cache/huggingface/lerobot/{repo-id} --repo-type dataset huggingface-cli upload ${HF_USER}/record-test ~/.cache/huggingface/lerobot/{repo-id} --repo-type dataset
``` ```
#### Record function #### Record function
@@ -491,7 +488,7 @@ If your local computer doesn't have a powerful GPU you could utilize Google Cola
Once training is done, upload the latest checkpoint with: Once training is done, upload the latest checkpoint with:
```bash ```bash
hf upload ${HF_USER}/act_so101_test \ huggingface-cli upload ${HF_USER}/act_so101_test \
outputs/train/act_so101_test/checkpoints/last/pretrained_model outputs/train/act_so101_test/checkpoints/last/pretrained_model
``` ```
@@ -499,7 +496,7 @@ You can also upload intermediate checkpoints with:
```bash ```bash
CKPT=010000 CKPT=010000
hf upload ${HF_USER}/act_so101_test${CKPT} \ huggingface-cli upload ${HF_USER}/act_so101_test${CKPT} \
outputs/train/act_so101_test/checkpoints/${CKPT}/pretrained_model outputs/train/act_so101_test/checkpoints/${CKPT}/pretrained_model
``` ```
@@ -518,9 +515,6 @@ lerobot-record \
--display_data=false \ --display_data=false \
--dataset.repo_id=${HF_USER}/eval_so100 \ --dataset.repo_id=${HF_USER}/eval_so100 \
--dataset.single_task="Put lego brick into the transparent box" \ --dataset.single_task="Put lego brick into the transparent box" \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
# <- Teleop optional if you want to teleoperate in between episodes \ # <- Teleop optional if you want to teleoperate in between episodes \
# --teleop.type=so100_leader \ # --teleop.type=so100_leader \
# --teleop.port=/dev/ttyACM0 \ # --teleop.port=/dev/ttyACM0 \
+5 -14
View File
@@ -1,20 +1,18 @@
# Installation # Installation
This guide uses conda (via miniforge) to manage environments. If you prefer another environment manager (e.g. `uv`, `venv`), ensure you have Python >=3.12 and ffmpeg installed with the `libsvtav1` encoder, then skip ahead to [Install LeRobot](#step-3-install-lerobot-). ## Install [`miniforge`](https://conda-forge.org/download/)
## Step 1: Install [`miniforge`](https://conda-forge.org/download/)
```bash ```bash
wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh" wget "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-$(uname)-$(uname -m).sh"
bash Miniforge3-$(uname)-$(uname -m).sh bash Miniforge3-$(uname)-$(uname -m).sh
``` ```
## Step 2: Environment Setup ## Environment Setup
Create a virtual environment with Python 3.12, using conda: Create a virtual environment with Python 3.10, using conda:
```bash ```bash
conda create -y -n lerobot python=3.12 conda create -y -n lerobot python=3.10
``` ```
Then activate your conda environment, you have to do this each time you open a shell to use lerobot: Then activate your conda environment, you have to do this each time you open a shell to use lerobot:
@@ -40,14 +38,7 @@ conda install ffmpeg -c conda-forge
> >
> - _[On Linux only]_ If you want to bring your own ffmpeg: Install [ffmpeg build dependencies](https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu#GettheDependencies) and [compile ffmpeg from source with libsvtav1](https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu#libsvtav1), and make sure you use the corresponding ffmpeg binary to your install with `which ffmpeg`. > - _[On Linux only]_ If you want to bring your own ffmpeg: Install [ffmpeg build dependencies](https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu#GettheDependencies) and [compile ffmpeg from source with libsvtav1](https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu#libsvtav1), and make sure you use the corresponding ffmpeg binary to your install with `which ffmpeg`.
> [!NOTE] ## Install LeRobot 🤗
> When installing LeRobot inside WSL (Windows Subsystem for Linux), make sure to install `evdev` with the following command:
>
> ```bash
> conda install evdev -c conda-forge
> ```
## Step 3: Install LeRobot 🤗
### From Source ### From Source
+2 -8
View File
@@ -1,11 +1,5 @@
# LeKiwi # LeKiwi
<img
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/1740517739083.jpeg"
alt="LeKiwi"
width="70%"
/>
In the steps below, we explain how to assemble the LeKiwi mobile robot. In the steps below, we explain how to assemble the LeKiwi mobile robot.
## Source the parts ## Source the parts
@@ -279,13 +273,13 @@ We use the Hugging Face hub features for uploading your dataset. If you haven't
Add your token to the CLI by running this command: Add your token to the CLI by running this command:
```bash ```bash
hf auth login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
``` ```
Then store your Hugging Face repository name in a variable: Then store your Hugging Face repository name in a variable:
```bash ```bash
HF_USER=$(hf auth whoami | awk -F': *' 'NR==1 {print $2}') HF_USER=$(huggingface-cli whoami | head -n 1)
echo $HF_USER echo $HF_USER
``` ```
+1 -4
View File
@@ -41,10 +41,7 @@ lerobot-record \
--display_data=true \ --display_data=true \
--dataset.repo_id=${HF_USER}/record-test \ --dataset.repo_id=${HF_USER}/record-test \
--dataset.num_episodes=5 \ --dataset.num_episodes=5 \
--dataset.single_task="Grab the black cube" \ --dataset.single_task="Grab the black cube"
--dataset.streaming_encoding=true \
# --dataset.vcodec=auto \
--dataset.encoder_threads=2
``` ```
See the [recording guide](./il_robots#record-a-dataset) for more details. See the [recording guide](./il_robots#record-a-dataset) for more details.
-1
View File
@@ -42,7 +42,6 @@ lerobot-eval \
``` ```
- `--env.task` picks the suite (`libero_object`, `libero_spatial`, etc.). - `--env.task` picks the suite (`libero_object`, `libero_spatial`, etc.).
- `--env.task_ids` picks task ids to run (`[0]`, `[1,2,3]`, etc.). Omit this flag (or set it to `null`) to run all tasks in the suite.
- `--eval.batch_size` controls how many environments run in parallel. - `--eval.batch_size` controls how many environments run in parallel.
- `--eval.n_episodes` sets how many episodes to run in total. - `--eval.n_episodes` sets how many episodes to run in total.
-197
View File
@@ -1,197 +0,0 @@
## Order and Assemble the parts
First, assemble the OMX hardware following the official assembly guide.
OMX Assembly Guide: https://ai.robotis.com/omx/assembly_guide_omx.html
OMX robots are shipped preconfigured from the factory. Motor IDs, communication parameters, and joint offsets are already set, so no additional motor setup or calibration is required before using LeRobot.
## Install LeRobot 🤗
To install LeRobot, follow our [Installation Guide](./installation)
In addition to these instructions, you need to install the Dynamixel SDK:
```bash
pip install -e ".[dynamixel]"
```
## Connect the robot
To find the port for each bus servo adapter, run this script:
```bash
lerobot-find-port
```
This command runs and when prompted, disconnect the USB cable from either the leader or follower arm and press Enter. The output will show 'The port of this MotorsBus is [port]'. This identifies the port for the disconnected arm. Repeat for the other arm to identify both ports.
<hfoptions id="find_port">
<hfoption id="Mac">
Example output on macOS:
```
Finding all available ports for the MotorBus.
['/dev/tty.usbmodem575E0032081', '/dev/tty.usbmodem575E0031751']
Remove the USB cable from your MotorsBus and press Enter when done.
[...Disconnect corresponding leader or follower arm and press Enter...]
The port of this MotorsBus is /dev/tty.usbmodem575E0032081
Reconnect the USB cable.
```
Where the found port is: `/dev/tty.usbmodem575E0032081` corresponding to your leader or follower arm.
</hfoption>
<hfoption id="Linux">
On Linux, we strongly recommend using udev rules to assign persistent and human-readable device names to the OMX leader and follower arms. This avoids issues where device names such as ttyACM0 and ttyACM1 change when the robot is unplugged, replugged, or when the system is rebooted.
#### 1. Find your device serial numbers
You should have obtained the port numbers like ../../ttyACM? for the leader and follower using `lerobot-find-port`. You can match those results with the serial numbers using the `ls -l /dev/serial/by-id/` command.
To create udev rules, you need the unique serial number for each OMX device. The easiest way is to list devices under:
```bash
ls -l /dev/serial/by-id/
```
You will see output similar to:
```bash
usb-ROBOTIS_OpenRB-150_228BDD7B503059384C2E3120FF0A2B19-if00 -> ../../ttyACM0
usb-ROBOTIS_OpenRB-150_67E1ED68503059384C2E3120FF092234-if00 -> ../../ttyACM1
```
In each line, the serial number is the long string after `usb-ROBOTIS_OpenRB-150_` and before `-if00`.
Follower serial: `228BDD7B503059384C2E3120FF0A2B19`
Leader serial: `67E1ED68503059384C2E3120FF092234`
#### 2. Create the udev rule
Create a new udev rule file:
```bash
sudo nano /etc/udev/rules.d/99-omx.rules
```
Paste the following lines, replacing the serial numbers with the values you found above:
```bash
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{serial}=="228BDD7B503059384C2E3120FF0A2B19", SYMLINK+="omx_follower"
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{serial}=="67E1ED68503059384C2E3120FF092234", SYMLINK+="omx_leader"
```
Save the file and reload udev rules:
```bash
sudo udevadm control --reload-rules
sudo udevadm trigger
```
Now unplug and replug both devices once.
#### 3. Verify the symlinks
Check that the persistent device names exist:
```bash
ls -l /dev/omx_follower /dev/omx_leader
```
You should see them pointing to ttyACM\* devices:
```bash
/dev/omx_follower -> ttyACM*
/dev/omx_leader -> ttyACM*
```
These names remain stable across reboots and reconnections.
</hfoption>
</hfoptions>
## Teleoperate
After identifying the correct ports, you can directly teleoperate the follower arm using the leader arm.
<hfoptions id="teleoperate">
<hfoption id="Mac">
### Teleoperate without camera
```bash
lerobot-teleoperate \
--robot.type=omx_follower \
--robot.port=<your_follower_port> \
--robot.id=omx_follower_arm \
--teleop.type=omx_leader \
--teleop.port=<your_leader_port> \
--teleop.id=omx_leader_arm
```
During teleoperation, motions of the leader arm are mirrored in real time by the follower arm. OMX is already preconfigured, teleoperation can begin immediately without any calibration steps.
### Teleoperate with camera
You can also enable camera input during teleoperation by providing a camera configuration for the follower arm.
```bash
lerobot-teleoperate \
--robot.type=omx_follower \
--robot.port=<your_follower_port> \
--robot.id=omx_follower_arm \
--robot.cameras="{front: {type: opencv, index_or_path: '/dev/video0', width: 640, height: 480, fps: 30}}" \
--teleop.type=omx_leader \
--teleop.port=<your_leader_port> \
--teleop.id=omx_leader_arm \
--display_data=true
```
When the camera is enabled, the camera stream is displayed in real time and synchronized with the robot state. This setup is useful for visual monitoring and can be reused later for demonstration recording and imitation learning.
</hfoption>
<hfoption id="Linux">
### Teleoperate without camera
```bash
lerobot-teleoperate \
--robot.type=omx_follower \
--robot.port=/dev/omx_follower \
--robot.id=omx_follower_arm \
--teleop.type=omx_leader \
--teleop.port=/dev/omx_leader \
--teleop.id=omx_leader_arm
```
During teleoperation, motions of the leader arm are mirrored in real time by the follower arm. OMX is already preconfigured, teleoperation can begin immediately without any calibration steps.
### Teleoperate with camera
You can also enable camera input during teleoperation by providing a camera configuration for the follower arm.
```bash
lerobot-teleoperate \
--robot.type=omx_follower \
--robot.port=/dev/omx_follower \
--robot.id=omx_follower_arm \
--robot.cameras="{front: {type: opencv, index_or_path: '/dev/video0', width: 640, height: 480, fps: 30}}" \
--teleop.type=omx_leader \
--teleop.port=/dev/omx_leader \
--teleop.id=omx_leader_arm \
--display_data=true
```
When the camera is enabled, the camera stream is displayed in real time and synchronized with the robot state. This setup is useful for visual monitoring and can be reused later for demonstration recording and imitation learning.
</hfoption>
</hfoptions>
Congrats 🎉, your robot is all set to learn a task on its own.
> If you have any questions or need help, please reach out on [Discord](https://discord.com/invite/robotis).
-276
View File
@@ -1,276 +0,0 @@
# OpenArm
[OpenArm](https://openarm.dev) is an open-source 7DOF humanoid arm designed for physical AI research and deployment.
To get your OpenArm, assembled or DIY, and join the global community, browse verified and certified manufacturers worldwide at [openarm.dev](https://openarm.dev).
## What's Unique?
- **Human-Scale Design**: OpenArm is designed with human-like proportions, scaled for a person around 160-165cm tall. This provides an optimal balance between practical reach and manageable inertia for safe, responsive operation.
- **Safety-First Architecture**: Built with QDD backdrivable motors and high compliance, OpenArm prioritizes safe human-robot interaction while maintaining practical payload capabilities (6.0kg peak / 4.1kg nominal) for real-world tasks.
- **Built for Durability**: Critical structural components use aluminum and stainless steel construction, ensuring robust performance for repetitive data collection and continuous research use.
- **Fully Accessible & Buildable**: Every component, from CNC parts and 3D-printed casings to electrical wiring is designed to be purchasable and buildable by individual researchers and labs, with complete fabrication data provided.
- **Practical & Affordable**: At $6,500 USD for a complete bimanual system, OpenArm delivers research-grade capabilities at a fraction of traditional humanoid robot costs.
## Platform Requirements
<Tip warning={true}>
**Linux Only**: OpenArm currently only works on Linux. The CAN bus USB adapter
does not have macOS drivers and has not been tested on Windows.
</Tip>
## Safety Guide
Before operating OpenArm, please read the [official safety guide](https://docs.openarm.dev/getting-started/safety-guide). Key points:
- **Secure installation**: Fasten the arm to a flat, stable surface with screws or clamps
- **Safe distance**: Keep body parts and objects outside the range of motion during operation
- **Protective equipment**: Always wear safety goggles; use additional PPE as needed
- **Payload limits**: Do not exceed specified payload limits (6.0kg peak / 4.1kg nominal per arm)
- **Emergency stop**: Know the location and operation of the emergency stop device
- **Regular inspection**: Check for loose screws, damaged mechanical limits, unusual noises, and wiring damage
## Hardware Setup
Follow the official [OpenArm hardware documentation](https://docs.openarm.dev) for:
- Bill of materials and sourcing
- 3D printing instructions
- Mechanical assembly
- Electrical wiring
The hardware repositories are available at [github.com/enactic/openarm](https://github.com/enactic/openarm).
## CAN Bus Setup
OpenArm uses CAN bus communication with Damiao motors. Once you have the CAN bus USB adapter plugged into your Linux PC, follow the [Damiao Motors and CAN Bus guide](./damiao) to configure the interface.
Quick setup:
```bash
# Setup CAN interfaces
lerobot-setup-can --mode=setup --interfaces=can0,can1
# Test motor communication
lerobot-setup-can --mode=test --interfaces=can0,can1
```
## Install LeRobot 🤗
Follow our [Installation Guide](./installation), then install the Damiao motor support:
```bash
pip install -e ".[damiao]"
```
## Usage
### Follower Arm (Robot)
<hfoptions id="follower">
<hfoption id="Command">
```bash
lerobot-calibrate \
--robot.type=openarm_follower \
--robot.port=can0 \
--robot.side=right \
--robot.id=my_openarm_follower
```
</hfoption>
<hfoption id="API example">
```python
from lerobot.robots.openarm_follower import OpenArmFollower, OpenArmFollowerConfig
config = OpenArmFollowerConfig(
port="can0",
side="right", # or "left" for left arm
id="my_openarm_follower",
)
follower = OpenArmFollower(config)
follower.connect()
# Read current state
obs = follower.get_observation()
print(obs)
# Send action (position in degrees)
action = {
"joint_1.pos": 0.0,
"joint_2.pos": 0.0,
"joint_3.pos": 0.0,
"joint_4.pos": 45.0,
"joint_5.pos": 0.0,
"joint_6.pos": 0.0,
"joint_7.pos": 0.0,
"gripper.pos": 0.0,
}
follower.send_action(action)
follower.disconnect()
```
</hfoption>
</hfoptions>
### Leader Arm (Teleoperator)
The leader arm is used for teleoperation - manually moving it to control the follower arm.
<hfoptions id="leader">
<hfoption id="Command">
```bash
lerobot-calibrate \
--teleop.type=openarm_leader \
--teleop.port=can1 \
--teleop.id=my_openarm_leader
```
</hfoption>
<hfoption id="API example">
```python
from lerobot.teleoperators.openarm_leader import OpenArmLeader, OpenArmLeaderConfig
config = OpenArmLeaderConfig(
port="can1",
id="my_openarm_leader",
manual_control=True, # Disable torque for manual movement
)
leader = OpenArmLeader(config)
leader.connect()
# Read current position (as action to send to follower)
action = leader.get_action()
print(action)
leader.disconnect()
```
</hfoption>
</hfoptions>
### Teleoperation
To teleoperate OpenArm with leader-follower control:
```bash
lerobot-teleoperate \
--robot.type=openarm_follower \
--robot.port=can0 \
--robot.side=right \
--robot.id=my_follower \
--teleop.type=openarm_leader \
--teleop.port=can1 \
--teleop.id=my_leader
```
### Bimanual Teleoperation
To teleoperate a bimanual OpenArm setup with two leader and two follower arms:
```bash
lerobot-teleoperate \
--robot.type=bi_openarm_follower \
--robot.left_arm_config.port=can0 \
--robot.left_arm_config.side=left \
--robot.right_arm_config.port=can1 \
--robot.right_arm_config.side=right \
--robot.id=my_bimanual_follower \
--teleop.type=bi_openarm_leader \
--teleop.left_arm_config.port=can2 \
--teleop.right_arm_config.port=can3 \
--teleop.id=my_bimanual_leader
```
### Recording Data
To record a dataset during teleoperation:
```bash
lerobot-record \
--robot.type=openarm_follower \
--robot.port=can0 \
--robot.side=right \
--robot.id=my_follower \
--teleop.type=openarm_leader \
--teleop.port=can1 \
--teleop.id=my_leader \
--repo-id=my_hf_username/my_openarm_dataset \
--fps=30 \
--num-episodes=10
```
## Configuration Options
### Follower Configuration
| Parameter | Default | Description |
| --------------------- | --------- | ---------------------------------------------------------- |
| `port` | - | CAN interface (e.g., `can0`) |
| `side` | `None` | Arm side: `"left"`, `"right"`, or `None` for custom limits |
| `use_can_fd` | `True` | Enable CAN FD for higher data rates |
| `can_bitrate` | `1000000` | Nominal bitrate (1 Mbps) |
| `can_data_bitrate` | `5000000` | CAN FD data bitrate (5 Mbps) |
| `max_relative_target` | `None` | Safety limit for relative target positions |
| `position_kp` | Per-joint | Position control proportional gains |
| `position_kd` | Per-joint | Position control derivative gains |
### Leader Configuration
| Parameter | Default | Description |
| ------------------ | --------- | ----------------------------------- |
| `port` | - | CAN interface (e.g., `can1`) |
| `manual_control` | `True` | Disable torque for manual movement |
| `use_can_fd` | `True` | Enable CAN FD for higher data rates |
| `can_bitrate` | `1000000` | Nominal bitrate (1 Mbps) |
| `can_data_bitrate` | `5000000` | CAN FD data bitrate (5 Mbps) |
## Motor Configuration
OpenArm uses Damiao motors with the following default configuration:
| Joint | Motor Type | Send ID | Recv ID |
| --------------------------- | ---------- | ------- | ------- |
| joint_1 (Shoulder pan) | DM8009 | 0x01 | 0x11 |
| joint_2 (Shoulder lift) | DM8009 | 0x02 | 0x12 |
| joint_3 (Shoulder rotation) | DM4340 | 0x03 | 0x13 |
| joint_4 (Elbow flex) | DM4340 | 0x04 | 0x14 |
| joint_5 (Wrist roll) | DM4310 | 0x05 | 0x15 |
| joint_6 (Wrist pitch) | DM4310 | 0x06 | 0x16 |
| joint_7 (Wrist rotation) | DM4310 | 0x07 | 0x17 |
| gripper | DM4310 | 0x08 | 0x18 |
## Troubleshooting
### No Response from Motors
1. Check power supply connections
2. Verify CAN wiring (CAN-H, CAN-L, GND)
3. Run diagnostics: `lerobot-setup-can --mode=test --interfaces=can0`
4. See the [Damiao troubleshooting guide](./damiao#troubleshooting) for more details
### CAN Interface Not Found
Ensure the CAN interface is configured:
```bash
ip link show can0
```
## Resources
- [OpenArm Website](https://openarm.dev)
- [OpenArm Documentation](https://docs.openarm.dev)
- [OpenArm GitHub](https://github.com/enactic/openarm)
- [Safety Guide](https://docs.openarm.dev/getting-started/safety-guide)
- [Damiao Motors and CAN Bus](./damiao)
+5 -9
View File
@@ -66,13 +66,12 @@ Run on of the examples scripts to teleoperate, record a dataset, replay a datase
All scripts assume you configured your robot (e.g., SO-100 follower) and set the correct serial port. All scripts assume you configured your robot (e.g., SO-100 follower) and set the correct serial port.
Additionally you need to **copy the URDF of the robot into the examples folder**. For the examples in this tutorial (using SO100/SO101), copy the `SO101` folder from the [SO-ARM100 repo](https://github.com/TheRobotStudio/SO-ARM100/blob/main/Simulation/SO101) into the `examples/phone_to_so100/` directory, so that the URDF file path becomes `examples/phone_to_so100/SO101/so101_new_calib.urdf`. Additionally you need to **copy the urdf of the robot to the examples folder**. For the examples in this tutorial (Using SO100/SO101) it is highly recommended to use the urdf in the [SO-ARM100 repo](https://github.com/TheRobotStudio/SO-ARM100/blob/main/Simulation/SO101/so101_new_calib.urdf)
- Run this example to teleoperate: - Run this example to teleoperate:
```bash ```bash
cd examples/phone_to_so100 python examples/phone_to_so100/teleoperate.py
python teleoperate.py
``` ```
After running the example: After running the example:
@@ -85,22 +84,19 @@ Additionally you can customize mapping or safety limits by editing the processor
- Run this example to record a dataset, which saves absolute end effector observations and actions: - Run this example to record a dataset, which saves absolute end effector observations and actions:
```bash ```bash
cd examples/phone_to_so100 python examples/phone_to_so100/record.py
python record.py
``` ```
- Run this example to replay recorded episodes: - Run this example to replay recorded episodes:
```bash ```bash
cd examples/phone_to_so100 python examples/phone_to_so100/replay.py
python replay.py
``` ```
- Run this example to evaluate a pretrained policy: - Run this example to evaluate a pretrained policy:
```bash ```bash
cd examples/phone_to_so100 python examples/phone_to_so100/evaluate.py
python evaluate.py
``` ```
### Important pipeline steps and options ### Important pipeline steps and options
+1 -1
View File
@@ -60,7 +60,7 @@ policy.type=pi0
For training π₀, you can use the standard LeRobot training script with the appropriate configuration: For training π₀, you can use the standard LeRobot training script with the appropriate configuration:
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id=your_dataset \ --dataset.repo_id=your_dataset \
--policy.type=pi0 \ --policy.type=pi0 \
--output_dir=./outputs/pi0_training \ --output_dir=./outputs/pi0_training \
+1 -1
View File
@@ -56,7 +56,7 @@ policy.type=pi05
Here's a complete training command for finetuning the base π₀.₅ model on your own dataset: Here's a complete training command for finetuning the base π₀.₅ model on your own dataset:
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py\
--dataset.repo_id=your_dataset \ --dataset.repo_id=your_dataset \
--policy.type=pi05 \ --policy.type=pi05 \
--output_dir=./outputs/pi05_training \ --output_dir=./outputs/pi05_training \
+3 -3
View File
@@ -52,7 +52,7 @@ This approach can transform **any existing VLM** into a VLA by training it to pr
You have two options for the FAST tokenizer: You have two options for the FAST tokenizer:
1. **Use the pre-trained tokenizer**: The `lerobot/fast-action-tokenizer` tokenizer was trained on 1M+ real robot action sequences and works as a general-purpose tokenizer. 1. **Use the pre-trained tokenizer**: The `physical-intelligence/fast` tokenizer was trained on 1M+ real robot action sequences and works as a general-purpose tokenizer.
2. **Train your own tokenizer**: For maximum performance on your specific dataset, you can finetune the tokenizer on your own data. 2. **Train your own tokenizer**: For maximum performance on your specific dataset, you can finetune the tokenizer on your own data.
@@ -115,13 +115,13 @@ lerobot-train \
### Key Training Parameters ### Key Training Parameters
| Parameter | Description | Default | | Parameter | Description | Default |
| -------------------------------------- | -------------------------------------------------- | ------------------------------- | | -------------------------------------- | -------------------------------------------------- | ---------------------------- |
| `--policy.gradient_checkpointing=true` | Reduces memory usage significantly during training | `false` | | `--policy.gradient_checkpointing=true` | Reduces memory usage significantly during training | `false` |
| `--policy.dtype=bfloat16` | Use mixed precision training for efficiency | `float32` | | `--policy.dtype=bfloat16` | Use mixed precision training for efficiency | `float32` |
| `--policy.chunk_size` | Number of action steps to predict (action horizon) | `50` | | `--policy.chunk_size` | Number of action steps to predict (action horizon) | `50` |
| `--policy.n_action_steps` | Number of action steps to execute | `50` | | `--policy.n_action_steps` | Number of action steps to execute | `50` |
| `--policy.max_action_tokens` | Maximum number of FAST tokens per action chunk | `256` | | `--policy.max_action_tokens` | Maximum number of FAST tokens per action chunk | `256` |
| `--policy.action_tokenizer_name` | FAST tokenizer to use | `lerobot/fast-action-tokenizer` | | `--policy.action_tokenizer_name` | FAST tokenizer to use | `physical-intelligence/fast` |
| `--policy.compile_model=true` | Enable torch.compile for faster training | `false` | | `--policy.compile_model=true` | Enable torch.compile for faster training | `false` |
## Inference ## Inference
-6
View File
@@ -159,9 +159,6 @@ lerobot-record \
--dataset.fps=15 \ --dataset.fps=15 \
--dataset.push_to_hub=true \ --dataset.push_to_hub=true \
--dataset.private=true \ --dataset.private=true \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
--display_data=true --display_data=true
``` ```
@@ -201,9 +198,6 @@ lerobot-record \
--dataset.fps=15 \ --dataset.fps=15 \
--dataset.push_to_hub=true \ --dataset.push_to_hub=true \
--dataset.private=true \ --dataset.private=true \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
--display_data=true --display_data=true
``` ```
+4 -4
View File
@@ -269,7 +269,7 @@ This generates visualizations showing video frames with subtask boundaries overl
Train with **no annotations** - uses linear progress from 0 to 1: Train with **no annotations** - uses linear progress from 0 to 1:
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id=your-username/your-dataset \ --dataset.repo_id=your-username/your-dataset \
--policy.type=sarm \ --policy.type=sarm \
--policy.annotation_mode=single_stage \ --policy.annotation_mode=single_stage \
@@ -288,7 +288,7 @@ lerobot-train \
Train with **dense annotations only** (sparse auto-generated): Train with **dense annotations only** (sparse auto-generated):
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id=your-username/your-dataset \ --dataset.repo_id=your-username/your-dataset \
--policy.type=sarm \ --policy.type=sarm \
--policy.annotation_mode=dense_only \ --policy.annotation_mode=dense_only \
@@ -307,7 +307,7 @@ lerobot-train \
Train with **both sparse and dense annotations**: Train with **both sparse and dense annotations**:
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id=your-username/your-dataset \ --dataset.repo_id=your-username/your-dataset \
--policy.type=sarm \ --policy.type=sarm \
--policy.annotation_mode=dual \ --policy.annotation_mode=dual \
@@ -468,7 +468,7 @@ This script:
Once you have the progress file, train your policy with RA-BC weighting. The progress file is auto-detected from the dataset path (`sarm_progress.parquet`). Currently PI0, PI0.5 and SmolVLA are supported with RA-BC: Once you have the progress file, train your policy with RA-BC weighting. The progress file is auto-detected from the dataset path (`sarm_progress.parquet`). Currently PI0, PI0.5 and SmolVLA are supported with RA-BC:
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id=your-username/your-dataset \ --dataset.repo_id=your-username/your-dataset \
--policy.type=pi0 \ --policy.type=pi0 \
--use_rabc=true \ --use_rabc=true \
-3
View File
@@ -106,9 +106,6 @@ lerobot-record \
--dataset.repo_id=${HF_USER}/eval_DATASET_NAME_test \ # <- This will be the dataset name on HF Hub --dataset.repo_id=${HF_USER}/eval_DATASET_NAME_test \ # <- This will be the dataset name on HF Hub
--dataset.episode_time_s=50 \ --dataset.episode_time_s=50 \
--dataset.num_episodes=10 \ --dataset.num_episodes=10 \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2 \
# --dataset.vcodec=auto \
# <- Teleop optional if you want to teleoperate in between episodes \ # <- Teleop optional if you want to teleoperate in between episodes \
# --teleop.type=so100_leader \ # --teleop.type=so100_leader \
# --teleop.port=/dev/ttyACM0 \ # --teleop.port=/dev/ttyACM0 \
-13
View File
@@ -1,18 +1,5 @@
# SO-101 # SO-101
<div style="display: flex; align-items: center; gap: 10px;">
<img
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/SO101_Follower.webp"
alt="SO-101"
width="60%"
/>
<img
src="https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/lerobot/SO101_Leader.webp"
alt="SO-101"
width="60%"
/>
</div>
In the steps below, we explain how to assemble our flagship robot, the SO-101. In the steps below, we explain how to assemble our flagship robot, the SO-101.
## Source the parts ## Source the parts
-155
View File
@@ -1,155 +0,0 @@
# Streaming Video Encoding Guide
## 1. Overview
Streaming video encoding eliminates the traditional PNG round-trip during video dataset recording. Instead of:
1. Capture frame -> write PNG to disk -> (at episode end) read PNG's -> encode to MP4 -> delete PNG's
Frames can be encoded in real-time during capture:
1. Capture frame -> queue to encoder thread -> encode to MP4 directly
This makes `save_episode()` near-instant (the video is already encoded by the time the episode ends) and removes the blocking wait that previously occurred between episodes, especially with multiple cameras in long episodes.
## 2. Tuning Parameters
| Parameter | CLI Flag | Type | Default | Description |
| ----------------------- | --------------------------------- | ------------- | ------------- | ----------------------------------------------------------------- |
| `streaming_encoding` | `--dataset.streaming_encoding` | `bool` | `True` | Enable real-time encoding during capture |
| `vcodec` | `--dataset.vcodec` | `str` | `"libsvtav1"` | Video codec. `"auto"` detects best HW encoder |
| `encoder_threads` | `--dataset.encoder_threads` | `int \| None` | `None` (auto) | Threads per encoder instance. `None` will leave the vcoded decide |
| `encoder_queue_maxsize` | `--dataset.encoder_queue_maxsize` | `int` | `60` | Max buffered frames per camera (~2s at 30fps). Consumes RAM |
## 3. Performance Considerations
Streaming encoding means the CPU is encoding video **during** the capture loop, not after. This creates a CPU budget that must be shared between:
- **Control loop** (reading cameras, control the robot, writing non-video data)
- **Encoder threads** (one pool per camera)
- **Rerun visualization** (if enabled)
- **OS and other processes**
### Resolution & Number of Cameras Impact
| Setup | Throughput (px/sec) | CPU Encoding Load | Notes |
| ------------------------- | ------------------- | ----------------- | ------------------------------ |
| 2camsx 640x480x3 @30fps | 55M | Low | Works on most systems |
| 2camsx 1280x720x3 @30fps | 165M | Moderate | Comfortable on modern systems |
| 2camsx 1920x1080x3 @30fps | 373M | High | Requires powerful high-end CPU |
### `encoder_threads` Tuning
This parameter controls how many threads each encoder instance uses internally:
- **Higher values** (e.g., 4-5): Faster encoding, but uses more CPU cores per camera. Good for high-end systems with many cores.
- **Lower values** (e.g., 1-2): Less CPU per camera, freeing cores for capture and visualization. Good for low-res images and capable CPUs.
- **`None` (default)**: Lets the codec decide. Information available in the codec logs.
### Backpressure and Frame Dropping
Each camera has a bounded queue (`encoder_queue_maxsize`, default 60 frames). When the encoder can't keep up:
1. The queue fills up (consuming RAM)
2. New frames are **dropped** (not blocked) — the capture loop continues uninterrupted
3. A warning is logged: `"Encoder queue full for {camera}, dropped N frame(s)"`
4. At episode end, total dropped frames per camera are reported
### Symptoms of Encoder Falling Behind
- **System feels laggy and freezes**: all CPUs are at 100%
- **Dropped frame warnings** in the log or lower frames/FPS than expected in the recorded dataset
- **Choppy robot movement**: If CPU is severely overloaded, even the capture loop may be affected
- **Accumulated rerun lag**: Visualization falls behind real-time
## 4. Hardware-Accelerated Encoding
### When to Use
Use HW encoding when:
- CPU is the bottleneck (dropped frames, choppy robot, rerun lag)
- You have compatible hardware (GPU or dedicated encoder)
- You're recording at high throughput (high resolution or with many cameras)
### Choosing a Codec
| Codec | CPU Usage | File Size | Quality | Notes |
| --------------------- | --------- | -------------- | ------- | ---------------------------------------------------------------- |
| `libsvtav1` (default) | High | Smallest | Best | Default. Best compression but most CPU-intensive |
| `h264` | Medium | ~30-50% larger | Good | Software H.264. Lower CPU |
| HW encoders | Very Low | Largest | Good | Offloads to dedicated hardware. Best for CPU-constrained systems |
### Available HW Encoders
| Encoder | Platform | Hardware | CLI Value |
| ------------------- | ------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------ |
| `h264_videotoolbox` | macOS | Apple Silicon / Intel | `--dataset.vcodec=h264_videotoolbox` |
| `hevc_videotoolbox` | macOS | Apple Silicon / Intel | `--dataset.vcodec=hevc_videotoolbox` |
| `h264_nvenc` | Linux/Windows | NVIDIA GPU | `--dataset.vcodec=h264_nvenc` |
| `hevc_nvenc` | Linux/Windows | NVIDIA GPU | `--dataset.vcodec=hevc_nvenc` |
| `h264_vaapi` | Linux | Intel/AMD GPU | `--dataset.vcodec=h264_vaapi` |
| `h264_qsv` | Linux/Windows | Intel Quick Sync | `--dataset.vcodec=h264_qsv` |
| `auto` | Any | Probes the system for available HW encoders. Falls back to `libsvtav1` if no HW encoder is found | `--dataset.vcodec=auto` |
> [!NOTE]
> In order to use the HW accelerated encoders you might need to upgrade your GPU drivers.
> [!NOTE]
> `libsvtav1` is the default because it provides the best training performance; other vcodecs can reduce CPU usage and be faster, but they typically produce larger files and may affect training time.
## 5. Troubleshooting
| Symptom | Likely Cause | Fix |
| ------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| System freezes or choppy robot movement or Rerun visualization lag | CPU starved (100% load usage) | Close other apps, reduce encoding throughput, lower `encoder_threads`, use `h264`, use `display_data=False`. If the CPU continues to be at 100% then it might be insufficient for your setup, consider `--dataset.streaming_encoding=false` or HW encoding (`--dataset.vcodec=auto`) |
| "Encoder queue full" warnings or dropped frames in dataset | Encoder can't keep up (Queue overflow) | If CPU is not at 100%: Increase `encoder_threads`, increase `encoder_queue_maxsize` or use HW encoding (`--dataset.vcodec=auto`). |
| High RAM usage | Queue filling faster than encoding | `encoder_threads` too low or CPU insufficient. Reduce `encoder_queue_maxsize` or use HW encoding |
| Large video files | Using HW encoder or H.264 | Expected trade-off. Switch to `libsvtav1` if CPU allows |
| `save_episode()` still slow | `streaming_encoding` is `False` | Set `--dataset.streaming_encoding=true` |
| Encoder thread crash | Codec not available or invalid settings | Check `vcodec` is installed, try `--dataset.vcodec=auto` |
| Recorded dataset is missing frames | CPU/GPU starvation or occasional load spikes | If ~5% of frames are missing, your system is likely overloaded — follow the recommendations above. If fewer frames are missing (~2%), they are probably due to occasional transient load spikes (often at startup) and can be considered expected. |
## 6. Recommended Configurations
These estimates are conservative; we recommend testing them on your setup—start with a low load and increase it gradually.
### High-End Systems: modern 12+ cores (24+ threads)
A throughput between ~250-500M px/sec should be comfortable in CPU. For even better results try HW encoding if available.
```bash
# 3camsx 1280x720x3 @30fps: Defaults work well. Optionally increase encoder parallelism.
# 2camsx 1920x1080x3 @30fps: Defaults work well. Optionally increase encoder parallelism.
lerobot-record --dataset.encoder_threads=5 ...
# 3camsx 1920x1080x3 @30fps: Might require some tuning.
```
### Mid-Range Systems: modern 8+ cores (16+ threads) or Apple Silicon
A throughput between ~80-300M px/sec should be possible in CPU.
```bash
# 3camsx 640x480x3 @30fps: Defaults work well. Optionally decrease encoder parallelism.
# 2camsx 1280x720x3 @30fps: Defaults work well. Optionally decrease encoder parallelism.
lerobot-record --dataset.encoder_threads=2 ...
# 2camsx 1920x1080x3 @30fps: Might require some tuning.
```
### Low-Resource Systems: modern 4+ cores (8+ threads) or Raspberry Pi 5
On very constrained systems, streaming encoding may compete too heavily with the capture loop. Disabling it falls back to the PNG-based approach where encoding happens between episodes (blocking, but doesn't interfere with capture). Alternatively, record at a lower throughput to reduce both capture and encoding load. Consider also changing codec to `h264` and using batch encoding.
```bash
# 2camsx 640x480x3 @30fps: Requires some tuning.
# Use H.264, disable streaming, consider batching encoding
lerobot-record --dataset.vcodec=h264 --dataset.streaming_encoding=false ...
```
## 7. Closing note
Performance ultimately depends on your exact setup — frames-per-second, resolution, CPU cores and load, available memory, episode length, and the encoder you choose. Always test with your target workload, be mindful about your CPU & system capabilities and tune `encoder_threads`, `encoder_queue_maxsize`, and
`vcodec` reasonably. That said, a common practical configuration (for many applications) is three cameras at 640×480x3 @30fps; this usually runs fine with the default streaming video encoding settings in modern systems. Always verify your recorded dataset is healthy by comparing the video duration to the CLI episode duration and confirming the row count equals FPS × CLI duration.
+3 -107
View File
@@ -123,7 +123,7 @@ SSH into the robot and install LeRobot:
```bash ```bash
ssh unitree@<YOUR_ROBOT_IP> ssh unitree@<YOUR_ROBOT_IP>
conda create -y -n lerobot python=3.12 conda create -y -n lerobot python=3.10
conda activate lerobot conda activate lerobot
git clone https://github.com/huggingface/lerobot.git git clone https://github.com/huggingface/lerobot.git
cd lerobot cd lerobot
@@ -153,7 +153,7 @@ With the robot server running, you can now control the robot remotely. Let's lau
### Step 1: Install LeRobot on your machine ### Step 1: Install LeRobot on your machine
```bash ```bash
conda create -y -n lerobot python=3.12 conda create -y -n lerobot python=3.10
conda activate lerobot conda activate lerobot
git clone https://github.com/huggingface/lerobot.git git clone https://github.com/huggingface/lerobot.git
cd lerobot cd lerobot
@@ -188,111 +188,7 @@ Press `Ctrl+C` to stop the policy.
## Running in Simulation Mode (MuJoCo) ## Running in Simulation Mode (MuJoCo)
You can test policies before deploying on the physical robot using MuJoCo simulation. Set `is_simulation=True` in config or pass `--robot.is_simulation=true` via CLI. You can now test policies before unleashing them on the physical robot using MuJoCo. To do so simply set `is_simulation=True` in config.
### Calibrate Exoskeleton Teleoperator
```bash
lerobot-calibrate \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo
```
### Teleoperate in Simulation
```bash
lerobot-teleoperate \
--robot.type=unitree_g1 \
--robot.is_simulation=true \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \
--fps=100
```
### Record Dataset in Simulation
```bash
lerobot-record \
--robot.type=unitree_g1 \
--robot.is_simulation=true \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "localhost", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \
--dataset.repo_id=your-username/dataset-name \
--dataset.single_task="Test" \
--dataset.num_episodes=2 \
--dataset.episode_time_s=5 \
--dataset.reset_time_s=5 \
--dataset.push_to_hub=true \
--dataset.streaming_encoding=true \
# --dataset.vcodec=auto \
--dataset.encoder_threads=2
```
Example simulation dataset: [nepyope/teleop_test_sim](https://huggingface.co/datasets/nepyope/teleop_test_sim)
---
## Running on Real Robot
Once the robot server is running on the G1 (see Part 3), you can teleoperate and record on the real robot.
### Start the Camera Server
On the robot, start the ZMQ image server:
```bash
python src/lerobot/cameras/zmq/image_server.py
```
Keep this running in a separate terminal for camera streaming during recording.
### Teleoperate Real Robot
```bash
lerobot-teleoperate \
--robot.type=unitree_g1 \
--robot.is_simulation=false \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \
--fps=100
```
### Record Dataset on Real Robot
```bash
lerobot-record \
--robot.type=unitree_g1 \
--robot.is_simulation=false \
--robot.cameras='{"global_view": {"type": "zmq", "server_address": "172.18.129.215", "port": 5555, "camera_name": "head_camera", "width": 640, "height": 480, "fps": 30}}' \
--teleop.type=unitree_g1 \
--teleop.left_arm_config.port=/dev/ttyACM1 \
--teleop.right_arm_config.port=/dev/ttyACM0 \
--teleop.id=exo \
--dataset.repo_id=your-username/dataset-name \
--dataset.single_task="Test" \
--dataset.num_episodes=2 \
--dataset.episode_time_s=5 \
--dataset.reset_time_s=5 \
--dataset.push_to_hub=true \
--dataset.streaming_encoding=true \
# --dataset.vcodec=auto \
--dataset.encoder_threads=2
```
**Note**: Update `server_address` to match your robot's camera server IP.
Example real robot dataset: [nepyope/teleop_test_real](https://huggingface.co/datasets/nepyope/teleop_test_real)
---
## Additional Resources ## Additional Resources
-25
View File
@@ -12,7 +12,6 @@ LeRobot provides several utilities for manipulating datasets:
4. **Add Features** - Add new features to a dataset 4. **Add Features** - Add new features to a dataset
5. **Remove Features** - Remove features from a dataset 5. **Remove Features** - Remove features from a dataset
6. **Convert to Video** - Convert image-based datasets to video format for efficient storage 6. **Convert to Video** - Convert image-based datasets to video format for efficient storage
7. **Show the Info of Datasets** - Show the summary of datasets information such as number of episode etc.
The core implementation is in `lerobot.datasets.dataset_tools`. The core implementation is in `lerobot.datasets.dataset_tools`.
An example script detailing how to use the tools API is available in `examples/dataset/use_dataset_tools.py`. An example script detailing how to use the tools API is available in `examples/dataset/use_dataset_tools.py`.
@@ -157,30 +156,6 @@ lerobot-edit-dataset \
**Note:** The resulting dataset will be a proper LeRobotDataset with all cameras encoded as videos in the `videos/` directory, with parquet files containing only metadata (no raw image data). All episodes, stats, and tasks are preserved. **Note:** The resulting dataset will be a proper LeRobotDataset with all cameras encoded as videos in the `videos/` directory, with parquet files containing only metadata (no raw image data). All episodes, stats, and tasks are preserved.
### Show the information of datasets
Show the information of datasets such as number of episode, number of frame, File size and so on.
No change will be made to the dataset
```bash
# Show dataset information without feature details
lerobot-edit-dataset \
--repo_id lerobot/pusht_image \
--operation.type info \
# Show dataset information with feature details
lerobot-edit-dataset \
--repo_id lerobot/pusht_image \
--operation.type info \
--operation.show_features true
```
**Parameters:**
- `parameters`: The flag to control show or no show dataset information with feature details.(default=false)
### Push to Hub ### Push to Hub
Add the `--push_to_hub true` flag to any command to automatically upload the resulting dataset to the Hugging Face Hub: Add the `--push_to_hub true` flag to any command to automatically upload the resulting dataset to the Hugging Face Hub:
+1 -1
View File
@@ -45,7 +45,7 @@ policy.type=wall_x
For training WallX, you can use the standard LeRobot training script with the appropriate configuration: For training WallX, you can use the standard LeRobot training script with the appropriate configuration:
```bash ```bash
lerobot-train \ python src/lerobot/scripts/lerobot_train.py \
--dataset.repo_id=your_dataset \ --dataset.repo_id=your_dataset \
--policy.type=wall_x \ --policy.type=wall_x \
--output_dir=./outputs/wallx_training \ --output_dir=./outputs/wallx_training \
+1 -1
View File
@@ -154,7 +154,7 @@ lerobot-train \
```bash ```bash
lerobot-train \ lerobot-train \
--dataset.repo_id=<USER>/bimanual-so100-handover-cube \ --dataset.repo_id=pepijn223/bimanual-so100-handover-cube \
--output_dir=./outputs/xvla_bimanual \ --output_dir=./outputs/xvla_bimanual \
--job_name=xvla_so101_training \ --job_name=xvla_so101_training \
--policy.path="lerobot/xvla-base" \ --policy.path="lerobot/xvla-base" \
+3 -4
View File
@@ -22,7 +22,7 @@ lerobot-replay \
--robot.type=so100_follower \ --robot.type=so100_follower \
--robot.port=/dev/tty.usbmodem58760431541 \ --robot.port=/dev/tty.usbmodem58760431541 \
--robot.id=black \ --robot.id=black \
--dataset.repo_id=<USER>/record-test \ --dataset.repo_id=aliberts/record-test \
--dataset.episode=2 --dataset.episode=2
``` ```
""" """
@@ -57,7 +57,7 @@ class DatasetReplayConfig:
repo_id: str repo_id: str
# Episode to replay. # Episode to replay.
episode: int episode: int
# Root directory where the dataset will be stored (e.g. 'dataset/path'). If None, defaults to $HF_LEROBOT_HOME/repo_id. # Root directory where the dataset will be stored (e.g. 'dataset/path').
root: str | Path | None = None root: str | Path | None = None
# Limit the frames per second. By default, uses the policy fps. # Limit the frames per second. By default, uses the policy fps.
fps: int = 30 fps: int = 30
@@ -81,7 +81,6 @@ def replay(cfg: ReplayConfig):
actions = dataset.hf_dataset.select_columns(ACTION) actions = dataset.hf_dataset.select_columns(ACTION)
robot.connect() robot.connect()
try:
log_say("Replaying episode", cfg.play_sounds, blocking=True) log_say("Replaying episode", cfg.play_sounds, blocking=True)
for idx in range(dataset.num_frames): for idx in range(dataset.num_frames):
start_episode_t = time.perf_counter() start_episode_t = time.perf_counter()
@@ -98,7 +97,7 @@ def replay(cfg: ReplayConfig):
dt_s = time.perf_counter() - start_episode_t dt_s = time.perf_counter() - start_episode_t
precise_sleep(max(1 / dataset.fps - dt_s, 0.0)) precise_sleep(max(1 / dataset.fps - dt_s, 0.0))
finally:
robot.disconnect() robot.disconnect()
-490
View File
@@ -1,490 +0,0 @@
#!/usr/bin/env python
# Copyright 2025 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.
"""
SLURM-distributed SARM RA-BC annotation pipeline.
Computes SARM progress values for all frames in a dataset, distributed across
SLURM workers, then merges the shards into a single sarm_progress.parquet.
Two subcommands, each a separate SLURM submission:
compute N workers, each computes progress for a subset of episodes
aggregate 1 worker, merges N shards into sarm_progress.parquet, pushes to hub
Usage:
python slurm_compute_rabc.py compute \\
--repo-id user/dataset --reward-model-path user/sarm_model \\
--stride 10 --device cpu --workers 50 --partition cpu
python slurm_compute_rabc.py aggregate \\
--repo-id user/dataset --reward-model-path user/sarm_model \\
--partition cpu --push-to-hub
"""
import argparse
from pathlib import Path
from datatrove.executor import LocalPipelineExecutor
from datatrove.executor.slurm import SlurmPipelineExecutor
from datatrove.pipeline.base import PipelineStep
class ComputeProgressShards(PipelineStep):
"""Each worker computes SARM progress for its assigned episodes."""
def __init__(
self, repo_id, reward_model_path, stride=1, head_mode="sparse", device="cpu", shard_dir="rabc_shards"
):
super().__init__()
if stride < 1:
raise ValueError(f"stride must be >= 1, got {stride}")
self.repo_id = repo_id
self.reward_model_path = reward_model_path
self.stride = stride
self.head_mode = head_mode
self.device = device
self.shard_dir = shard_dir
def run(self, data=None, rank: int = 0, world_size: int = 1):
import logging
from pathlib import Path
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
import torch
from tqdm import tqdm
from lerobot.policies.sarm.compute_rabc_weights import (
generate_all_frame_indices,
interpolate_progress,
load_sarm_resources,
)
from lerobot.utils.utils import init_logging
init_logging()
dataset, reward_model, preprocess = load_sarm_resources(
self.repo_id,
self.reward_model_path,
self.device,
)
if hasattr(preprocess, "eval"):
preprocess.eval()
for step in preprocess.steps:
if hasattr(step, "eval"):
step.eval()
image_key = reward_model.config.image_key
state_key = reward_model.config.state_key
frame_gap = reward_model.config.frame_gap
center_idx = reward_model.config.n_obs_steps // 2
dual_mode = reward_model.config.uses_dual_heads
compute_sparse = self.head_mode in ("sparse", "both") or not dual_mode
compute_dense = self.head_mode in ("dense", "both") and dual_mode
my_episodes = list(range(dataset.num_episodes))[rank::world_size]
if not my_episodes:
logging.info(f"Rank {rank}: no episodes assigned")
return
logging.info(f"Rank {rank}: {len(my_episodes)} / {dataset.num_episodes} episodes")
all_rows = []
for ep_idx in tqdm(my_episodes, desc=f"Rank {rank}"):
ep = dataset.meta.episodes[ep_idx]
ep_start, ep_end = ep["dataset_from_index"], ep["dataset_to_index"]
task = dataset[ep_start].get("task", "perform the task")
all_ep_indices = generate_all_frame_indices(ep_start, ep_end, frame_gap)
if self.stride > 1:
compute_indices = [i for i in all_ep_indices if (i - ep_start) % self.stride == 0]
if (ep_end - 1) not in compute_indices:
compute_indices.append(ep_end - 1)
compute_indices = sorted(set(compute_indices))
else:
compute_indices = all_ep_indices
frame_results = {}
for qi in tqdm(compute_indices, desc=f" Ep {ep_idx}", leave=False):
try:
sample = dataset[qi]
batch = {
image_key: sample[image_key],
"task": task,
"index": qi,
"episode_index": ep_idx,
}
if state_key in sample:
batch[state_key] = sample[state_key]
with torch.no_grad():
processed = preprocess(batch)
vf = processed["video_features"].to(self.device)
tf = processed["text_features"].to(self.device)
sf = processed.get("state_features")
if sf is not None:
sf = sf.to(self.device)
lengths = processed.get("lengths")
sparse_val = dense_val = np.nan
if compute_sparse:
r = reward_model.calculate_rewards(
text_embeddings=tf,
video_embeddings=vf,
state_features=sf,
lengths=lengths,
return_all_frames=True,
head_mode="sparse",
)
sparse_val = float(r[0, center_idx] if r.ndim == 2 else r[center_idx])
if compute_dense:
r = reward_model.calculate_rewards(
text_embeddings=tf,
video_embeddings=vf,
state_features=sf,
lengths=lengths,
return_all_frames=True,
head_mode="dense",
)
dense_val = float(r[0, center_idx] if r.ndim == 2 else r[center_idx])
frame_results[qi] = (sparse_val, dense_val)
except Exception as e:
logging.warning(f"Failed frame {qi}: {e}")
if not frame_results:
logging.warning(f"Episode {ep_idx}: all frames failed, skipping")
continue
# Interpolate to all frames in this episode
computed_idx = np.array(sorted(frame_results.keys()))
all_frame_arr = np.arange(ep_start, ep_end)
sparse_vals = np.array([frame_results[i][0] for i in computed_idx]) if compute_sparse else None
dense_vals = np.array([frame_results[i][1] for i in computed_idx]) if compute_dense else None
if self.stride > 1 and len(computed_idx) > 1:
if compute_sparse:
sparse_vals = interpolate_progress(computed_idx, sparse_vals, all_frame_arr)
if compute_dense:
dense_vals = interpolate_progress(computed_idx, dense_vals, all_frame_arr)
output_frames = all_frame_arr
else:
# Use only successfully computed frames to avoid indexing mismatch on failures
output_frames = computed_idx
for i, fi in enumerate(output_frames):
row = {"index": int(fi), "episode_index": ep_idx, "frame_index": int(fi - ep_start)}
if compute_sparse:
row["progress_sparse"] = float(sparse_vals[i])
if compute_dense:
row["progress_dense"] = float(dense_vals[i])
all_rows.append(row)
if all_rows:
import pandas as pd
df = pd.DataFrame(all_rows).sort_values("index").reset_index(drop=True)
table = pa.Table.from_pandas(df, preserve_index=False)
table = table.replace_schema_metadata({b"reward_model_path": self.reward_model_path.encode()})
shard_dir = Path(self.shard_dir)
shard_dir.mkdir(parents=True, exist_ok=True)
out = shard_dir / f"shard_{rank:05d}.parquet"
pq.write_table(table, out)
logging.info(f"Rank {rank}: saved {len(df)} rows to {out}")
class AggregateProgress(PipelineStep):
"""Merge all shard parquets into final sarm_progress.parquet."""
def __init__(self, repo_id, reward_model_path, shard_dir="rabc_shards", push_to_hub=False):
super().__init__()
self.repo_id = repo_id
self.reward_model_path = reward_model_path
self.shard_dir = shard_dir
self.push_to_hub = push_to_hub
def run(self, data=None, rank: int = 0, world_size: int = 1):
import datetime
import logging
import os
from pathlib import Path
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lerobot.utils.utils import init_logging
init_logging()
if rank != 0:
return
shard_dir = Path(self.shard_dir)
shards = sorted(shard_dir.glob("shard_*.parquet"))
if not shards:
raise FileNotFoundError(f"No shards found in {shard_dir}")
# Log shard modification time range to help detect stale files
mtimes = [os.path.getmtime(s) for s in shards]
oldest = datetime.datetime.fromtimestamp(min(mtimes)).isoformat(timespec="seconds")
newest = datetime.datetime.fromtimestamp(max(mtimes)).isoformat(timespec="seconds")
logging.info(f"Aggregating {len(shards)} shards (oldest: {oldest}, newest: {newest})")
df = pd.concat([pd.read_parquet(s) for s in shards], ignore_index=True)
df = df.sort_values("index").reset_index(drop=True)
table = pa.Table.from_pandas(df, preserve_index=False)
table = table.replace_schema_metadata({b"reward_model_path": self.reward_model_path.encode()})
temp_ds = LeRobotDataset(self.repo_id, download_videos=False)
out_path = Path(temp_ds.root) / "sarm_progress.parquet"
out_path.parent.mkdir(parents=True, exist_ok=True)
pq.write_table(table, out_path)
logging.info(f"Saved {len(df)} rows to {out_path}")
for col in ["progress_sparse", "progress_dense"]:
if col in df.columns:
v = df[col].dropna()
logging.info(
f"{col}: mean={v.mean():.4f} std={v.std():.4f} min={v.min():.4f} max={v.max():.4f}"
)
if self.push_to_hub:
from huggingface_hub import HfApi
api = HfApi()
hub_path = "sarm_progress.parquet"
logging.info(f"Uploading to {self.repo_id}/{hub_path}")
api.upload_file(
path_or_fileobj=str(out_path),
path_in_repo=hub_path,
repo_id=self.repo_id,
repo_type="dataset",
)
logging.info(f"Uploaded: https://huggingface.co/datasets/{self.repo_id}/blob/main/{hub_path}")
def make_compute_executor(
repo_id,
reward_model_path,
stride,
head_mode,
device,
shard_dir,
logs_dir,
job_name,
slurm,
workers,
partition,
cpus_per_task,
mem_per_cpu,
):
kwargs = {
"pipeline": [
ComputeProgressShards(repo_id, reward_model_path, stride, head_mode, device, str(shard_dir)),
],
"logging_dir": str(logs_dir / job_name),
}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": workers,
"workers": workers,
"time": "24:00:00",
"partition": partition,
"cpus_per_task": cpus_per_task,
"sbatch_args": {"mem-per-cpu": mem_per_cpu},
}
)
return SlurmPipelineExecutor(**kwargs)
kwargs.update({"tasks": workers, "workers": 1})
return LocalPipelineExecutor(**kwargs)
def make_aggregate_executor(
repo_id,
reward_model_path,
shard_dir,
logs_dir,
job_name,
slurm,
partition,
cpus_per_task,
mem_per_cpu,
push_to_hub,
):
kwargs = {
"pipeline": [
AggregateProgress(repo_id, reward_model_path, str(shard_dir), push_to_hub),
],
"logging_dir": str(logs_dir / job_name),
}
if slurm:
kwargs.update(
{
"job_name": job_name,
"tasks": 1,
"workers": 1,
"time": "02:00:00",
"partition": partition,
"cpus_per_task": cpus_per_task,
"sbatch_args": {"mem-per-cpu": mem_per_cpu},
}
)
return SlurmPipelineExecutor(**kwargs)
kwargs.update({"tasks": 1, "workers": 1})
return LocalPipelineExecutor(**kwargs)
def _add_shared_args(p):
p.add_argument(
"--repo-id",
type=str,
required=True,
help="Hugging Face repository identifier, e.g. 'user/dataset'.",
)
p.add_argument(
"--shard-dir",
type=Path,
default=Path("rabc_shards"),
help="Directory to read/write per-rank parquet shards.",
)
p.add_argument(
"--logs-dir",
type=Path,
default=Path("logs"),
help="Directory for datatrove logs.",
)
p.add_argument(
"--job-name",
type=str,
default=None,
help="SLURM job name (defaults to rabc_<subcommand>).",
)
p.add_argument(
"--slurm",
type=int,
default=1,
help="1 = submit via SLURM; 0 = run locally (useful for debugging).",
)
p.add_argument(
"--partition",
type=str,
default=None,
help="SLURM partition to submit to.",
)
p.add_argument(
"--cpus-per-task",
type=int,
default=4,
help="Number of CPUs per SLURM task.",
)
p.add_argument(
"--mem-per-cpu",
type=str,
default="4G",
help="Memory per CPU, e.g. '4G' or '1950M'.",
)
def main():
parser = argparse.ArgumentParser(
description="SLURM-distributed SARM RA-BC annotation pipeline",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="command", required=True)
# compute subcommand
cp = sub.add_parser(
"compute",
help="Distribute progress computation across SLURM workers.",
)
_add_shared_args(cp)
cp.add_argument(
"--reward-model-path",
type=str,
required=True,
help="Path or HF repo id of the SARM reward model.",
)
cp.add_argument(
"--stride",
type=int,
default=1,
help="Compute every Nth frame; intermediate frames are interpolated (must be >= 1).",
)
cp.add_argument(
"--head-mode",
type=str,
default="sparse",
choices=["sparse", "dense", "both"],
help="Which reward head(s) to compute.",
)
cp.add_argument(
"--device",
type=str,
default="cpu",
help="Device for reward model inference, e.g. 'cpu' or 'cuda'.",
)
cp.add_argument(
"--workers",
type=int,
default=50,
help="Number of parallel SLURM tasks (one shard per worker).",
)
# aggregate subcommand
ap = sub.add_parser(
"aggregate",
help="Merge per-rank shards into a single sarm_progress.parquet.",
)
_add_shared_args(ap)
ap.add_argument(
"--reward-model-path",
type=str,
required=True,
help="Path or HF repo id of the SARM reward model (stored in parquet metadata).",
)
ap.add_argument(
"--push-to-hub",
action="store_true",
help="Upload sarm_progress.parquet to the Hugging Face Hub after aggregation.",
)
args = parser.parse_args()
job_name = args.job_name or f"rabc_{args.command}"
kwargs = vars(args)
kwargs["slurm"] = kwargs.pop("slurm") == 1
kwargs["job_name"] = job_name
command = kwargs.pop("command")
executor = make_compute_executor(**kwargs) if command == "compute" else make_aggregate_executor(**kwargs)
executor.run()
if __name__ == "__main__":
main()
-2
View File
@@ -78,7 +78,6 @@ def main():
listener, events = init_keyboard_listener() listener, events = init_keyboard_listener()
init_rerun(session_name="lekiwi_evaluate") init_rerun(session_name="lekiwi_evaluate")
try:
if not robot.is_connected: if not robot.is_connected:
raise ValueError("Robot is not connected!") raise ValueError("Robot is not connected!")
@@ -132,7 +131,6 @@ def main():
dataset.save_episode() dataset.save_episode()
recorded_episodes += 1 recorded_episodes += 1
finally:
# Clean up # Clean up
log_say("Stop recording") log_say("Stop recording")
robot.disconnect() robot.disconnect()
+1 -2
View File
@@ -74,7 +74,6 @@ def main():
listener, events = init_keyboard_listener() listener, events = init_keyboard_listener()
init_rerun(session_name="lekiwi_record") init_rerun(session_name="lekiwi_record")
try:
if not robot.is_connected or not leader_arm.is_connected or not keyboard.is_connected: if not robot.is_connected or not leader_arm.is_connected or not keyboard.is_connected:
raise ValueError("Robot or teleop is not connected!") raise ValueError("Robot or teleop is not connected!")
@@ -126,7 +125,7 @@ def main():
# Save episode # Save episode
dataset.save_episode() dataset.save_episode()
recorded_episodes += 1 recorded_episodes += 1
finally:
# Clean up # Clean up
log_say("Stop recording") log_say("Stop recording")
robot.disconnect() robot.disconnect()
+2 -4
View File
@@ -42,7 +42,6 @@ def main():
# Connect to the robot # Connect to the robot
robot.connect() robot.connect()
try:
if not robot.is_connected: if not robot.is_connected:
raise ValueError("Robot is not connected!") raise ValueError("Robot is not connected!")
@@ -53,15 +52,14 @@ def main():
# Get recorded action from dataset # Get recorded action from dataset
action = { action = {
name: float(actions[idx][ACTION][i]) name: float(actions[idx][ACTION][i]) for i, name in enumerate(dataset.features[ACTION]["names"])
for i, name in enumerate(dataset.features[ACTION]["names"])
} }
# Send action to robot # Send action to robot
_ = robot.send_action(action) _ = robot.send_action(action)
precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0)) precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0))
finally:
robot.disconnect() robot.disconnect()
+3 -2
View File
@@ -43,12 +43,13 @@ def main():
keyboard.connect() keyboard.connect()
# Init rerun viewer # Init rerun viewer
init_rerun(session_name="lekiwi_teleop") init_rerun(session_name="lekiwi_teleop", robot=robot, reset_time=True)
if not robot.is_connected or not leader_arm.is_connected or not keyboard.is_connected: if not robot.is_connected or not leader_arm.is_connected or not keyboard.is_connected:
raise ValueError("Robot or teleop is not connected!") raise ValueError("Robot or teleop is not connected!")
print("Starting teleop loop...") print("Starting teleop loop...")
start = time.perf_counter()
while True: while True:
t0 = time.perf_counter() t0 = time.perf_counter()
@@ -69,7 +70,7 @@ def main():
_ = robot.send_action(action) _ = robot.send_action(action)
# Visualize # Visualize
log_rerun_data(observation=observation, action=action) log_rerun_data(observation=observation, action=action, log_time=time.perf_counter() - start)
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0)) precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
+2 -5
View File
@@ -142,7 +142,6 @@ def main():
listener, events = init_keyboard_listener() listener, events = init_keyboard_listener()
init_rerun(session_name="phone_so100_evaluate") init_rerun(session_name="phone_so100_evaluate")
try:
if not robot.is_connected: if not robot.is_connected:
raise ValueError("Robot is not connected!") raise ValueError("Robot is not connected!")
@@ -169,9 +168,7 @@ def main():
) )
# Reset the environment if not stopping or re-recording # Reset the environment if not stopping or re-recording
if not events["stop_recording"] and ( if not events["stop_recording"] and ((episode_idx < NUM_EPISODES - 1) or events["rerecord_episode"]):
(episode_idx < NUM_EPISODES - 1) or events["rerecord_episode"]
):
log_say("Reset the environment") log_say("Reset the environment")
record_loop( record_loop(
robot=robot, robot=robot,
@@ -195,7 +192,7 @@ def main():
# Save episode # Save episode
dataset.save_episode() dataset.save_episode()
episode_idx += 1 episode_idx += 1
finally:
# Clean up # Clean up
log_say("Stop recording") log_say("Stop recording")
robot.disconnect() robot.disconnect()
+2 -5
View File
@@ -149,7 +149,6 @@ def main():
listener, events = init_keyboard_listener() listener, events = init_keyboard_listener()
init_rerun(session_name="phone_so100_record") init_rerun(session_name="phone_so100_record")
try:
if not robot.is_connected or not phone.is_connected: if not robot.is_connected or not phone.is_connected:
raise ValueError("Robot or teleop is not connected!") raise ValueError("Robot or teleop is not connected!")
@@ -174,9 +173,7 @@ def main():
) )
# Reset the environment if not stopping or re-recording # Reset the environment if not stopping or re-recording
if not events["stop_recording"] and ( if not events["stop_recording"] and (episode_idx < NUM_EPISODES - 1 or events["rerecord_episode"]):
episode_idx < NUM_EPISODES - 1 or events["rerecord_episode"]
):
log_say("Reset the environment") log_say("Reset the environment")
record_loop( record_loop(
robot=robot, robot=robot,
@@ -201,7 +198,7 @@ def main():
# Save episode # Save episode
dataset.save_episode() dataset.save_episode()
episode_idx += 1 episode_idx += 1
finally:
# Clean up # Clean up
log_say("Stop recording") log_say("Stop recording")
robot.disconnect() robot.disconnect()
+2 -4
View File
@@ -73,7 +73,6 @@ def main():
# Connect to the robot # Connect to the robot
robot.connect() robot.connect()
try:
if not robot.is_connected: if not robot.is_connected:
raise ValueError("Robot is not connected!") raise ValueError("Robot is not connected!")
@@ -84,8 +83,7 @@ def main():
# Get recorded action from dataset # Get recorded action from dataset
ee_action = { ee_action = {
name: float(actions[idx][ACTION][i]) name: float(actions[idx][ACTION][i]) for i, name in enumerate(dataset.features[ACTION]["names"])
for i, name in enumerate(dataset.features[ACTION]["names"])
} }
# Get robot observation # Get robot observation
@@ -98,7 +96,7 @@ def main():
_ = robot.send_action(joint_action) _ = robot.send_action(joint_action)
precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0)) precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0))
finally:
# Clean up # Clean up
robot.disconnect() robot.disconnect()
+3 -2
View File
@@ -89,12 +89,13 @@ def main():
teleop_device.connect() teleop_device.connect()
# Init rerun viewer # Init rerun viewer
init_rerun(session_name="phone_so100_teleop") init_rerun(session_name="phone_so100_teleop", robot=robot, reset_time=True)
if not robot.is_connected or not teleop_device.is_connected: if not robot.is_connected or not teleop_device.is_connected:
raise ValueError("Robot or teleop is not connected!") raise ValueError("Robot or teleop is not connected!")
print("Starting teleop loop. Move your phone to teleoperate the robot...") print("Starting teleop loop. Move your phone to teleoperate the robot...")
start = time.perf_counter()
while True: while True:
t0 = time.perf_counter() t0 = time.perf_counter()
@@ -111,7 +112,7 @@ def main():
_ = robot.send_action(joint_action) _ = robot.send_action(joint_action)
# Visualize # Visualize
log_rerun_data(observation=phone_obs, action=joint_action) log_rerun_data(observation=phone_obs, action=joint_action, log_time=time.perf_counter() - start)
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0)) precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
+10 -10
View File
@@ -27,8 +27,8 @@ measuring consistency and ground truth alignment.
Usage: Usage:
# Basic usage with smolvla policy # Basic usage with smolvla policy
uv run python examples/rtc/eval_dataset.py \ uv run python examples/rtc/eval_dataset.py \
--policy.path=<USER>/smolvla_check_rtc_last3 \ --policy.path=helper2424/smolvla_check_rtc_last3 \
--dataset.repo_id=<USER>/check_rtc \ --dataset.repo_id=helper2424/check_rtc \
--rtc.execution_horizon=8 \ --rtc.execution_horizon=8 \
--device=mps \ --device=mps \
--rtc.max_guidance_weight=10.0 \ --rtc.max_guidance_weight=10.0 \
@@ -58,16 +58,16 @@ Usage:
--device=cuda --device=cuda
uv run python examples/rtc/eval_dataset.py \ uv run python examples/rtc/eval_dataset.py \
--policy.path=<USER>/reuben_pi0 \ --policy.path=lipsop/reuben_pi0 \
--dataset.repo_id=<USER>/so101_cube_in_cup \ --dataset.repo_id=ReubenLim/so101_cube_in_cup \
--rtc.execution_horizon=8 \ --rtc.execution_horizon=8 \
--device=cuda --device=cuda
# With torch.compile for faster inference (PyTorch 2.0+) # With torch.compile for faster inference (PyTorch 2.0+)
# Note: CUDA graphs disabled by default due to in-place ops in denoising loop # Note: CUDA graphs disabled by default due to in-place ops in denoising loop
uv run python examples/rtc/eval_dataset.py \ uv run python examples/rtc/eval_dataset.py \
--policy.path=<USER>/smolvla_check_rtc_last3 \ --policy.path=helper2424/smolvla_check_rtc_last3 \
--dataset.repo_id=<USER>/check_rtc \ --dataset.repo_id=helper2424/check_rtc \
--rtc.execution_horizon=8 \ --rtc.execution_horizon=8 \
--device=mps \ --device=mps \
--use_torch_compile=true \ --use_torch_compile=true \
@@ -75,8 +75,8 @@ Usage:
# With torch.compile on CUDA (CUDA graphs disabled by default) # With torch.compile on CUDA (CUDA graphs disabled by default)
uv run python examples/rtc/eval_dataset.py \ uv run python examples/rtc/eval_dataset.py \
--policy.path=<USER>/smolvla_check_rtc_last3 \ --policy.path=helper2424/smolvla_check_rtc_last3 \
--dataset.repo_id=<USER>/check_rtc \ --dataset.repo_id=helper2424/check_rtc \
--rtc.execution_horizon=8 \ --rtc.execution_horizon=8 \
--device=cuda \ --device=cuda \
--use_torch_compile=true \ --use_torch_compile=true \
@@ -84,8 +84,8 @@ Usage:
# Enable CUDA graphs (advanced - may cause tensor aliasing errors) # Enable CUDA graphs (advanced - may cause tensor aliasing errors)
uv run python examples/rtc/eval_dataset.py \ uv run python examples/rtc/eval_dataset.py \
--policy.path=<USER>/smolvla_check_rtc_last3 \ --policy.path=helper2424/smolvla_check_rtc_last3 \
--dataset.repo_id=<USER>/check_rtc \ --dataset.repo_id=helper2424/check_rtc \
--use_torch_compile=true \ --use_torch_compile=true \
--torch_compile_backend=inductor \ --torch_compile_backend=inductor \
--torch_compile_mode=max-autotune \ --torch_compile_mode=max-autotune \
+3 -3
View File
@@ -28,7 +28,7 @@ For simulation environments, see eval_with_simulation.py
Usage: Usage:
# Run RTC with Real robot with RTC # Run RTC with Real robot with RTC
uv run examples/rtc/eval_with_real_robot.py \ uv run examples/rtc/eval_with_real_robot.py \
--policy.path=<USER>/smolvla_check_rtc_last3 \ --policy.path=helper2424/smolvla_check_rtc_last3 \
--policy.device=mps \ --policy.device=mps \
--rtc.enabled=true \ --rtc.enabled=true \
--rtc.execution_horizon=20 \ --rtc.execution_horizon=20 \
@@ -41,7 +41,7 @@ Usage:
# Run RTC with Real robot without RTC # Run RTC with Real robot without RTC
uv run examples/rtc/eval_with_real_robot.py \ uv run examples/rtc/eval_with_real_robot.py \
--policy.path=<USER>/smolvla_check_rtc_last3 \ --policy.path=helper2424/smolvla_check_rtc_last3 \
--policy.device=mps \ --policy.device=mps \
--rtc.enabled=false \ --rtc.enabled=false \
--robot.type=so100_follower \ --robot.type=so100_follower \
@@ -53,7 +53,7 @@ Usage:
# Run RTC with Real robot with pi0.5 policy # Run RTC with Real robot with pi0.5 policy
uv run examples/rtc/eval_with_real_robot.py \ uv run examples/rtc/eval_with_real_robot.py \
--policy.path=<USER>/pi05_check_rtc \ --policy.path=helper2424/pi05_check_rtc \
--policy.device=mps \ --policy.device=mps \
--rtc.enabled=true \ --rtc.enabled=true \
--rtc.execution_horizon=20 \ --rtc.execution_horizon=20 \
+2 -5
View File
@@ -142,7 +142,6 @@ def main():
listener, events = init_keyboard_listener() listener, events = init_keyboard_listener()
init_rerun(session_name="so100_so100_evaluate") init_rerun(session_name="so100_so100_evaluate")
try:
if not robot.is_connected: if not robot.is_connected:
raise ValueError("Robot is not connected!") raise ValueError("Robot is not connected!")
@@ -169,9 +168,7 @@ def main():
) )
# Reset the environment if not stopping or re-recording # Reset the environment if not stopping or re-recording
if not events["stop_recording"] and ( if not events["stop_recording"] and ((episode_idx < NUM_EPISODES - 1) or events["rerecord_episode"]):
(episode_idx < NUM_EPISODES - 1) or events["rerecord_episode"]
):
log_say("Reset the environment") log_say("Reset the environment")
record_loop( record_loop(
robot=robot, robot=robot,
@@ -195,7 +192,7 @@ def main():
# Save episode # Save episode
dataset.save_episode() dataset.save_episode()
episode_idx += 1 episode_idx += 1
finally:
# Clean up # Clean up
log_say("Stop recording") log_say("Stop recording")
robot.disconnect() robot.disconnect()
+1 -5
View File
@@ -146,7 +146,6 @@ def main():
listener, events = init_keyboard_listener() listener, events = init_keyboard_listener()
init_rerun(session_name="recording_phone") init_rerun(session_name="recording_phone")
try:
if not leader.is_connected or not follower.is_connected: if not leader.is_connected or not follower.is_connected:
raise ValueError("Robot or teleop is not connected!") raise ValueError("Robot or teleop is not connected!")
@@ -171,9 +170,7 @@ def main():
) )
# Reset the environment if not stopping or re-recording # Reset the environment if not stopping or re-recording
if not events["stop_recording"] and ( if not events["stop_recording"] and (episode_idx < NUM_EPISODES - 1 or events["rerecord_episode"]):
episode_idx < NUM_EPISODES - 1 or events["rerecord_episode"]
):
log_say("Reset the environment") log_say("Reset the environment")
record_loop( record_loop(
robot=follower, robot=follower,
@@ -199,7 +196,6 @@ def main():
dataset.save_episode() dataset.save_episode()
episode_idx += 1 episode_idx += 1
finally:
# Clean up # Clean up
log_say("Stop recording") log_say("Stop recording")
leader.disconnect() leader.disconnect()
+1 -4
View File
@@ -74,7 +74,6 @@ def main():
# Connect to the robot # Connect to the robot
robot.connect() robot.connect()
try:
if not robot.is_connected: if not robot.is_connected:
raise ValueError("Robot is not connected!") raise ValueError("Robot is not connected!")
@@ -85,8 +84,7 @@ def main():
# Get recorded action from dataset # Get recorded action from dataset
ee_action = { ee_action = {
name: float(actions[idx][ACTION][i]) name: float(actions[idx][ACTION][i]) for i, name in enumerate(dataset.features[ACTION]["names"])
for i, name in enumerate(dataset.features[ACTION]["names"])
} }
# Get robot observation # Get robot observation
@@ -100,7 +98,6 @@ def main():
precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0)) precise_sleep(max(1.0 / dataset.fps - (time.perf_counter() - t0), 0.0))
finally:
# Clean up # Clean up
robot.disconnect() robot.disconnect()
+5 -2
View File
@@ -94,9 +94,10 @@ def main():
leader.connect() leader.connect()
# Init rerun viewer # Init rerun viewer
init_rerun(session_name="so100_so100_EE_teleop") init_rerun(session_name="so100_so100_EE_teleop", robot=follower, reset_time=True)
print("Starting teleop loop...") print("Starting teleop loop...")
start = time.perf_counter()
while True: while True:
t0 = time.perf_counter() t0 = time.perf_counter()
@@ -116,7 +117,9 @@ def main():
_ = follower.send_action(follower_joints_act) _ = follower.send_action(follower_joints_act)
# Visualize # Visualize
log_rerun_data(observation=leader_ee_act, action=follower_joints_act) log_rerun_data(
observation=leader_ee_act, action=follower_joints_act, log_time=time.perf_counter() - start
)
precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0)) precise_sleep(max(1.0 / FPS - (time.perf_counter() - t0), 0.0))
@@ -30,7 +30,6 @@ def main():
robot=robot_cfg, robot=robot_cfg,
server_address=server_address, server_address=server_address,
policy_device="mps", policy_device="mps",
client_device="cpu",
policy_type="act", policy_type="act",
pretrained_name_or_path="<user>/robot_learning_tutorial_act", pretrained_name_or_path="<user>/robot_learning_tutorial_act",
chunk_size_threshold=0.5, # g chunk_size_threshold=0.5, # g
+123 -65
View File
@@ -25,11 +25,11 @@ discord = "https://discord.gg/s3KuuzsPFb"
[project] [project]
name = "lerobot" name = "lerobot"
version = "0.4.5" version = "0.4.3"
description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch" description = "🤗 LeRobot: State-of-the-art Machine Learning for Real-World Robotics in Pytorch"
dynamic = ["readme"] dynamic = ["readme"]
license = { text = "Apache-2.0" } license = { text = "Apache-2.0" }
requires-python = ">=3.12" requires-python = ">=3.10"
authors = [ authors = [
{ name = "Rémi Cadène", email = "re.cadene@gmail.com" }, { name = "Rémi Cadène", email = "re.cadene@gmail.com" },
{ name = "Simon Alibert", email = "alibert.sim@gmail.com" }, { name = "Simon Alibert", email = "alibert.sim@gmail.com" },
@@ -50,8 +50,7 @@ classifiers = [
"Intended Audience :: Education", "Intended Audience :: Education",
"Intended Audience :: Science/Research", "Intended Audience :: Science/Research",
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Build Tools", "Topic :: Software Development :: Build Tools",
"Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Scientific/Engineering :: Artificial Intelligence",
] ]
@@ -60,30 +59,28 @@ keywords = ["lerobot", "huggingface", "robotics", "machine learning", "artifici
dependencies = [ dependencies = [
# Hugging Face dependencies # Hugging Face dependencies
"datasets>=4.0.0,<5.0.0", "datasets>=4.0.0,<4.2.0",
"diffusers>=0.27.2,<0.36.0", "diffusers>=0.27.2,<0.36.0",
"huggingface-hub>=1.0.0,<2.0.0", "huggingface-hub[hf-transfer,cli]>=0.34.2,<0.36.0",
"accelerate>=1.10.0,<2.0.0", "accelerate>=1.10.0,<2.0.0",
# Core dependencies # Core dependencies
"numpy>=2.0.0,<2.3.0", # NOTE: Explicitly listing numpy helps the resolver converge faster. Upper bound imposed by opencv-python-headless.
"setuptools>=71.0.0,<81.0.0", "setuptools>=71.0.0,<81.0.0",
"cmake>=3.29.0.1,<4.2.0", "cmake>=3.29.0.1,<4.2.0",
"packaging>=24.2,<26.0",
"torch>=2.2.1,<2.11.0",
"torchcodec>=0.2.1,<0.11.0; sys_platform != 'win32' and (sys_platform != 'linux' or (platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'armv7l')) and (sys_platform != 'darwin' or platform_machine != 'x86_64')",
"torchvision>=0.21.0,<0.26.0",
"einops>=0.8.0,<0.9.0", "einops>=0.8.0,<0.9.0",
"opencv-python-headless>=4.9.0,<4.13.0", "opencv-python-headless>=4.9.0,<4.13.0",
"av>=15.0.0,<16.0.0", "av>=15.0.0,<16.0.0",
"jsonlines>=4.0.0,<5.0.0", "jsonlines>=4.0.0,<5.0.0",
"pynput>=1.7.8,<1.9.0", "packaging>=24.2,<26.0",
"pynput>=1.7.7,<1.9.0",
"pyserial>=3.5,<4.0", "pyserial>=3.5,<4.0",
"wandb>=0.24.0,<0.25.0", "wandb>=0.24.0,<0.25.0",
"draccus==0.10.0", # TODO: Relax version constraint
"torch>=2.2.1,<2.8.0", # TODO: Bumb dependency
"torchcodec>=0.2.1,<0.6.0; sys_platform != 'win32' and (sys_platform != 'linux' or (platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'armv7l')) and (sys_platform != 'darwin' or platform_machine != 'x86_64')", # TODO: Bumb dependency
"torchvision>=0.21.0,<0.23.0", # TODO: Bumb dependency
"draccus==0.10.0", # TODO: Remove ==
"gymnasium>=1.1.1,<2.0.0", "gymnasium>=1.1.1,<2.0.0",
"rerun-sdk>=0.24.0,<0.27.0", "rerun-sdk>=0.24.0,<0.27.0",
@@ -98,55 +95,43 @@ dependencies = [
# Common # Common
pygame-dep = ["pygame>=2.5.1,<2.7.0"] pygame-dep = ["pygame>=2.5.1,<2.7.0"]
placo-dep = ["placo>=0.9.6,<0.9.17"] placo-dep = ["placo>=0.9.6,<0.10.0"]
transformers-dep = ["transformers>=5.3.0,<6.0.0"] transformers-dep = ["transformers>=4.57.1,<5.0.0"]
grpcio-dep = ["grpcio==1.73.1", "protobuf>=6.31.1,<6.32.0"] grpcio-dep = ["grpcio==1.73.1", "protobuf>=6.31.1,<6.32.0"]
can-dep = ["python-can>=4.2.0,<5.0.0"]
peft-dep = ["peft>=0.18.0,<1.0.0"]
scipy-dep = ["scipy>=1.14.0,<2.0.0"]
qwen-vl-utils-dep = ["qwen-vl-utils>=0.0.11,<0.1.0"]
matplotlib-dep = ["matplotlib>=3.10.3,<4.0.0", "contourpy>=1.3.0,<2.0.0"] # NOTE: Explicitly listing contourpy helps the resolver converge faster.
# Motors # Motors
feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0"] feetech = ["feetech-servo-sdk>=1.0.0,<2.0.0"]
dynamixel = ["dynamixel-sdk>=3.7.31,<3.9.0"] dynamixel = ["dynamixel-sdk>=3.7.31,<3.9.0"]
damiao = ["lerobot[can-dep]"]
robstride = ["lerobot[can-dep]"]
# Robots # Robots
openarms = ["lerobot[damiao]"]
gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0,<0.15.0"] gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0,<0.15.0"]
hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"] hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"]
lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"] lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"]
unitree_g1 = [ unitree_g1 = [
"pyzmq>=26.2.1,<28.0.0", "pyzmq>=26.2.1,<28.0.0",
"onnxruntime>=1.16.0,<2.0.0", "onnxruntime>=1.16.0,<2.0.0"
"pin>=3.0.0,<4.0.0",
"meshcat>=0.3.0,<0.4.0",
"lerobot[matplotlib-dep]",
"casadi>=3.6.0,<4.0.0",
] ]
reachy2 = ["reachy2_sdk>=1.0.15,<1.1.0"] reachy2 = ["reachy2_sdk>=1.0.15,<1.1.0"]
kinematics = ["lerobot[placo-dep]"] kinematics = ["lerobot[placo-dep]"]
intelrealsense = [ intelrealsense = [
"pyrealsense2>=2.55.1.6486,<2.57.0 ; sys_platform != 'darwin'", "pyrealsense2>=2.55.1.6486,<2.57.0 ; sys_platform != 'darwin'",
"pyrealsense2-macosx>=2.54,<2.57.0 ; sys_platform == 'darwin'", "pyrealsense2-macosx>=2.54,<2.55.0 ; sys_platform == 'darwin'",
] ]
phone = ["hebi-py>=2.8.0,<2.12.0", "teleop>=0.1.0,<0.2.0", "fastapi<1.0", "lerobot[scipy-dep]"] phone = ["hebi-py>=2.8.0,<2.12.0", "teleop>=0.1.0,<0.2.0", "fastapi<1.0"]
# Policies # Policies
wallx = [ wallx = [
"lerobot[transformers-dep]", "transformers==4.49.0",
"lerobot[peft]", "peft==0.17.1",
"lerobot[scipy-dep]", "scipy==1.15.3",
"torchdiffeq>=0.2.4,<0.3.0", "torchdiffeq==0.2.5",
"lerobot[qwen-vl-utils-dep]", "qwen_vl_utils==0.0.11"
] ]
pi = ["lerobot[transformers-dep]", "lerobot[scipy-dep]"] pi = ["transformers @ git+https://github.com/huggingface/transformers.git@fix/lerobot_openpi", "scipy>=1.10.1,<1.15"]
smolvla = ["lerobot[transformers-dep]", "num2words>=0.5.14,<0.6.0", "accelerate>=1.7.0,<2.0.0", "safetensors>=0.4.3,<1.0.0"] smolvla = ["lerobot[transformers-dep]", "num2words>=0.5.14,<0.6.0", "accelerate>=1.7.0,<2.0.0", "safetensors>=0.4.3,<1.0.0"]
groot = [ groot = [
"lerobot[transformers-dep]", "lerobot[transformers-dep]",
"lerobot[peft]", "peft>=0.13.0,<1.0.0",
"dm-tree>=0.1.8,<1.0.0", "dm-tree>=0.1.8,<1.0.0",
"timm>=1.0.0,<1.1.0", "timm>=1.0.0,<1.1.0",
"safetensors>=0.4.3,<1.0.0", "safetensors>=0.4.3,<1.0.0",
@@ -155,13 +140,14 @@ groot = [
"ninja>=1.11.1,<2.0.0", "ninja>=1.11.1,<2.0.0",
"flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin'" "flash-attn>=2.5.9,<3.0.0 ; sys_platform != 'darwin'"
] ]
sarm = ["lerobot[transformers-dep]", "faker>=33.0.0,<35.0.0", "lerobot[matplotlib-dep]", "lerobot[qwen-vl-utils-dep]"] sarm = ["lerobot[transformers-dep]", "faker>=33.0.0,<35.0.0", "matplotlib>=3.10.3,<4.0.0", "qwen-vl-utils>=0.0.14,<0.1.0"]
xvla = ["lerobot[transformers-dep]"] xvla = ["lerobot[transformers-dep]"]
hilserl = ["lerobot[transformers-dep]", "gym-hil>=0.1.13,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"] hilserl = ["lerobot[transformers-dep]", "gym-hil>=0.1.13,<0.2.0", "lerobot[grpcio-dep]", "lerobot[placo-dep]"]
# Features # Features
async = ["lerobot[grpcio-dep]", "lerobot[matplotlib-dep]"] async = ["lerobot[grpcio-dep]", "matplotlib>=3.10.3,<4.0.0"]
peft = ["lerobot[transformers-dep]", "lerobot[peft-dep]"] peft = ["lerobot[transformers-dep]", "peft>=0.18.0,<1.0.0"]
audio = ["sounddevice>=0.5.1,<0.6.0", "soundfile>=0.13.1,<0.14.0", "librosa>=0.11.0,<0.12.0", "torchaudio>=2.6.0,<2.10.0"]
# Development # Development
dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools==1.73.1", "mypy>=1.19.1"] dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools==1.73.1", "mypy>=1.19.1"]
@@ -169,19 +155,13 @@ test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0
video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"] video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"]
# Simulation # Simulation
# NOTE: Explicitly listing scipy helps flatten the dependecy tree. aloha = ["gym-aloha>=0.1.2,<0.2.0"]
aloha = ["gym-aloha>=0.1.2,<0.2.0", "lerobot[scipy-dep]"]
pusht = ["gym-pusht>=0.1.5,<0.2.0", "pymunk>=6.6.0,<7.0.0"] # TODO: Fix pymunk version in gym-pusht instead pusht = ["gym-pusht>=0.1.5,<0.2.0", "pymunk>=6.6.0,<7.0.0"] # TODO: Fix pymunk version in gym-pusht instead
libero = ["lerobot[transformers-dep]", "hf-libero>=0.1.3,<0.2.0; sys_platform == 'linux'", "lerobot[scipy-dep]"] libero = ["lerobot[transformers-dep]", "hf-libero>=0.1.3,<0.2.0"]
metaworld = ["metaworld==3.0.0", "lerobot[scipy-dep]"] metaworld = ["metaworld==3.0.0"]
# All # All
all = [ all = [
# NOTE(resolver hint): scipy is pulled in transitively via lerobot[scipy-dep] through
# multiple extras (aloha, metaworld, pi, wallx, phone). Listing it explicitly
# helps pip's resolver converge by constraining scipy early, before it encounters
# the loose scipy requirements from transitive deps like dm-control and metaworld.
"scipy>=1.14.0,<2.0.0",
"lerobot[dynamixel]", "lerobot[dynamixel]",
"lerobot[gamepad]", "lerobot[gamepad]",
"lerobot[hopejr]", "lerobot[hopejr]",
@@ -189,20 +169,21 @@ all = [
"lerobot[reachy2]", "lerobot[reachy2]",
"lerobot[kinematics]", "lerobot[kinematics]",
"lerobot[intelrealsense]", "lerobot[intelrealsense]",
"lerobot[wallx]", # "lerobot[wallx]",
"lerobot[pi]", # "lerobot[pi]", TODO(Pepijn): Update pi to transformers v5
"lerobot[smolvla]", "lerobot[smolvla]",
# "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn # "lerobot[groot]", TODO(Steven): Gr00t requires specific installation instructions for flash-attn
"lerobot[xvla]", "lerobot[xvla]",
"lerobot[hilserl]", "lerobot[hilserl]",
"lerobot[async]", "lerobot[async]",
"lerobot[audio]",
"lerobot[dev]", "lerobot[dev]",
"lerobot[test]", "lerobot[test]",
"lerobot[video_benchmark]", "lerobot[video_benchmark]",
"lerobot[aloha]", "lerobot[aloha]",
"lerobot[pusht]", "lerobot[pusht]",
"lerobot[phone]", "lerobot[phone]",
"lerobot[libero]; sys_platform == 'linux'", "lerobot[libero]",
"lerobot[metaworld]", "lerobot[metaworld]",
"lerobot[sarm]", "lerobot[sarm]",
"lerobot[peft]", "lerobot[peft]",
@@ -224,17 +205,13 @@ lerobot-info="lerobot.scripts.lerobot_info:main"
lerobot-find-joint-limits="lerobot.scripts.lerobot_find_joint_limits:main" lerobot-find-joint-limits="lerobot.scripts.lerobot_find_joint_limits:main"
lerobot-imgtransform-viz="lerobot.scripts.lerobot_imgtransform_viz:main" lerobot-imgtransform-viz="lerobot.scripts.lerobot_imgtransform_viz:main"
lerobot-edit-dataset="lerobot.scripts.lerobot_edit_dataset:main" lerobot-edit-dataset="lerobot.scripts.lerobot_edit_dataset:main"
lerobot-setup-can="lerobot.scripts.lerobot_setup_can:main"
# ---------------- Tool Configurations ---------------- # ---------------- Tool Configurations ----------------
[tool.setuptools.package-data]
lerobot = ["envs/*.json"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.ruff] [tool.ruff]
target-version = "py312" target-version = "py310"
line-length = 110 line-length = 110
exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"] exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"]
@@ -303,7 +280,6 @@ default.extend-ignore-identifiers-re = [
"thw", "thw",
"inpt", "inpt",
"ROBOTIS", "ROBOTIS",
"OT_VALUE"
] ]
# TODO: Uncomment when ready to use # TODO: Uncomment when ready to use
@@ -326,7 +302,7 @@ default.extend-ignore-identifiers-re = [
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations # Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
[tool.mypy] [tool.mypy]
python_version = "3.12" python_version = "3.10"
ignore_missing_imports = true ignore_missing_imports = true
follow_imports = "skip" follow_imports = "skip"
# warn_return_any = true # warn_return_any = true
@@ -378,9 +354,9 @@ ignore_errors = false
module = "lerobot.cameras.*" module = "lerobot.cameras.*"
ignore_errors = false ignore_errors = false
[[tool.mypy.overrides]] # [[tool.mypy.overrides]]
module = "lerobot.motors.*" # module = "lerobot.motors.*"
ignore_errors = false # ignore_errors = false
# [[tool.mypy.overrides]] # [[tool.mypy.overrides]]
# module = "lerobot.robots.*" # module = "lerobot.robots.*"
@@ -410,3 +386,85 @@ ignore_errors = false
# [[tool.mypy.overrides]] # [[tool.mypy.overrides]]
# module = "lerobot.scripts.*" # module = "lerobot.scripts.*"
# ignore_errors = false # ignore_errors = false
[tool.uv]
# wallx requires transformers==4.49.0 which conflicts with other extras that need >=4.53.0
conflicts = [
[
{ extra = "wallx" },
{ extra = "transformers-dep" },
],
[
{ extra = "wallx" },
{ extra = "pi" },
],
[
{ extra = "wallx" },
{ extra = "smolvla" },
],
[
{ extra = "wallx" },
{ extra = "groot" },
],
[
{ extra = "wallx" },
{ extra = "xvla" },
],
[
{ extra = "wallx" },
{ extra = "sarm" },
],
[
{ extra = "wallx" },
{ extra = "hilserl" },
],
[
{ extra = "wallx" },
{ extra = "libero" },
],
[
{ extra = "wallx" },
{ extra = "peft" },
],
[
{ extra = "wallx" },
{ extra = "all" },
],
# pi uses custom branch which conflicts with transformers-dep
[
{ extra = "pi" },
{ extra = "transformers-dep" },
],
[
{ extra = "pi" },
{ extra = "smolvla" },
],
[
{ extra = "pi" },
{ extra = "groot" },
],
[
{ extra = "pi" },
{ extra = "xvla" },
],
[
{ extra = "pi" },
{ extra = "sarm" },
],
[
{ extra = "pi" },
{ extra = "hilserl" },
],
[
{ extra = "pi" },
{ extra = "libero" },
],
[
{ extra = "pi" },
{ extra = "peft" },
],
[
{ extra = "pi" },
{ extra = "all" },
],
]
+8
View File
@@ -29,6 +29,7 @@ Example:
print(lerobot.available_policies_per_env) print(lerobot.available_policies_per_env)
print(lerobot.available_robots) print(lerobot.available_robots)
print(lerobot.available_cameras) print(lerobot.available_cameras)
print(lerobot.available_microphones)
print(lerobot.available_motors) print(lerobot.available_motors)
``` ```
@@ -174,6 +175,13 @@ available_cameras = [
"intelrealsense", "intelrealsense",
] ]
# lists all available microphones from `lerobot/microphones`
available_microphones = [
"portaudio",
"touchlab",
"anyskin",
]
# lists all available motors from `lerobot/motors` # lists all available motors from `lerobot/motors`
available_motors = [ available_motors = [
"dynamixel", "dynamixel",
-10
View File
@@ -126,12 +126,6 @@ class RobotClientConfig:
# Device configuration # Device configuration
policy_device: str = field(default="cpu", metadata={"help": "Device for policy inference"}) policy_device: str = field(default="cpu", metadata={"help": "Device for policy inference"})
client_device: str = field(
default="cpu",
metadata={
"help": "Device to move actions to after receiving from server (e.g., for downstream planners)"
},
)
# Control behavior configuration # Control behavior configuration
chunk_size_threshold: float = field(default=0.5, metadata={"help": "Threshold for chunk size control"}) chunk_size_threshold: float = field(default=0.5, metadata={"help": "Threshold for chunk size control"})
@@ -167,9 +161,6 @@ class RobotClientConfig:
if not self.policy_device: if not self.policy_device:
raise ValueError("policy_device cannot be empty") raise ValueError("policy_device cannot be empty")
if not self.client_device:
raise ValueError("client_device cannot be empty")
if self.chunk_size_threshold < 0 or self.chunk_size_threshold > 1: if self.chunk_size_threshold < 0 or self.chunk_size_threshold > 1:
raise ValueError(f"chunk_size_threshold must be between 0 and 1, got {self.chunk_size_threshold}") raise ValueError(f"chunk_size_threshold must be between 0 and 1, got {self.chunk_size_threshold}")
@@ -193,7 +184,6 @@ class RobotClientConfig:
"policy_type": self.policy_type, "policy_type": self.policy_type,
"pretrained_name_or_path": self.pretrained_name_or_path, "pretrained_name_or_path": self.pretrained_name_or_path,
"policy_device": self.policy_device, "policy_device": self.policy_device,
"client_device": self.client_device,
"chunk_size_threshold": self.chunk_size_threshold, "chunk_size_threshold": self.chunk_size_threshold,
"fps": self.fps, "fps": self.fps,
"actions_per_chunk": self.actions_per_chunk, "actions_per_chunk": self.actions_per_chunk,
+2 -3
View File
@@ -18,7 +18,6 @@ import os
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any
import torch import torch
@@ -40,8 +39,8 @@ from lerobot.utils.utils import init_logging
Action = torch.Tensor Action = torch.Tensor
# observation as received from the robot (can be numpy arrays, floats, etc.) # observation as received from the robot
RawObservation = dict[str, Any] RawObservation = dict[str, torch.Tensor]
# observation as those recorded in LeRobot dataset (keys are different) # observation as those recorded in LeRobot dataset (keys are different)
LeRobotObservation = dict[str, torch.Tensor] LeRobotObservation = dict[str, torch.Tensor]
@@ -381,8 +381,6 @@ class PolicyServer(services_pb2_grpc.AsyncInferenceServicer):
action_tensor = torch.stack(processed_actions, dim=1).squeeze(0) action_tensor = torch.stack(processed_actions, dim=1).squeeze(0)
self.logger.debug(f"Postprocessed action shape: {action_tensor.shape}") self.logger.debug(f"Postprocessed action shape: {action_tensor.shape}")
action_tensor = action_tensor.detach().cpu()
"""5. Convert to TimedAction list""" """5. Convert to TimedAction list"""
action_chunk = self._time_action_chunk( action_chunk = self._time_action_chunk(
observation_t.get_timestamp(), list(action_tensor), observation_t.get_timestep() observation_t.get_timestamp(), list(action_tensor), observation_t.get_timestep()
+15 -25
View File
@@ -25,7 +25,6 @@ python src/lerobot/async_inference/robot_client.py \
--policy_type=act \ --policy_type=act \
--pretrained_name_or_path=user/model \ --pretrained_name_or_path=user/model \
--policy_device=mps \ --policy_device=mps \
--client_device=cpu \
--actions_per_chunk=50 \ --actions_per_chunk=50 \
--chunk_size_threshold=0.5 \ --chunk_size_threshold=0.5 \
--aggregate_fn_name=weighted_average \ --aggregate_fn_name=weighted_average \
@@ -41,7 +40,6 @@ from collections.abc import Callable
from dataclasses import asdict from dataclasses import asdict
from pprint import pformat from pprint import pformat
from queue import Queue from queue import Queue
from typing import Any
import draccus import draccus
import grpc import grpc
@@ -49,18 +47,27 @@ import torch
from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig # noqa: F401 from lerobot.cameras.opencv.configuration_opencv import OpenCVCameraConfig # noqa: F401
from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraConfig # noqa: F401 from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraConfig # noqa: F401
from lerobot.robots import ( from lerobot.microphones.anyskin.configuration_anyskin import AnyskinSensorConfig # noqa: F401
RobotConfig, # noqa: F401 from lerobot.microphones.portaudio.configuration_portaudio import PortAudioMicrophoneConfig # noqa: F401
from lerobot.microphones.touchlab.configuration_touchlab import TouchLabSensorConfig # noqa: F401
from lerobot.processor import RobotAction
from lerobot.robots import ( # noqa: F401
Robot,
RobotConfig,
bi_so_follower,
koch_follower,
make_robot_from_config, make_robot_from_config,
omx_follower,
so_follower,
) )
from lerobot.transport import ( from lerobot.transport import (
services_pb2, # type: ignore services_pb2, # type: ignore
services_pb2_grpc, # type: ignore services_pb2_grpc, # type: ignore
) )
from lerobot.transport.utils import grpc_channel_options, send_bytes_in_chunks from lerobot.transport.utils import grpc_channel_options, send_bytes_in_chunks
from lerobot.utils.import_utils import register_third_party_plugins
from .configs import RobotClientConfig from .configs import RobotClientConfig
from .constants import SUPPORTED_ROBOTS
from .helpers import ( from .helpers import (
Action, Action,
FPSTracker, FPSTracker,
@@ -281,21 +288,6 @@ class RobotClient:
timed_actions = pickle.loads(actions_chunk.data) # nosec timed_actions = pickle.loads(actions_chunk.data) # nosec
deserialize_time = time.perf_counter() - deserialize_start deserialize_time = time.perf_counter() - deserialize_start
# Log device type of received actions
if len(timed_actions) > 0:
received_device = timed_actions[0].get_action().device.type
self.logger.debug(f"Received actions on device: {received_device}")
# Move actions to client_device (e.g., for downstream planners that need GPU)
client_device = self.config.client_device
if client_device != "cpu":
for timed_action in timed_actions:
if timed_action.get_action().device.type != client_device:
timed_action.action = timed_action.get_action().to(client_device)
self.logger.debug(f"Converted actions to device: {client_device}")
else:
self.logger.debug(f"Actions kept on device: {client_device}")
self.action_chunk_size = max(self.action_chunk_size, len(timed_actions)) self.action_chunk_size = max(self.action_chunk_size, len(timed_actions))
# Calculate network latency if we have matching observations # Calculate network latency if we have matching observations
@@ -362,7 +354,7 @@ class RobotClient:
action = {key: action_tensor[i].item() for i, key in enumerate(self.robot.action_features)} action = {key: action_tensor[i].item() for i, key in enumerate(self.robot.action_features)}
return action return action
def control_loop_action(self, verbose: bool = False) -> dict[str, Any]: def control_loop_action(self, verbose: bool = False) -> RobotAction:
"""Reading and performing actions in local queue""" """Reading and performing actions in local queue"""
# Lock only for queue operations # Lock only for queue operations
@@ -480,9 +472,8 @@ class RobotClient:
def async_client(cfg: RobotClientConfig): def async_client(cfg: RobotClientConfig):
logging.info(pformat(asdict(cfg))) logging.info(pformat(asdict(cfg)))
# TODO: Assert if checking robot support is still needed with the plugin system if cfg.robot.type not in SUPPORTED_ROBOTS:
# if cfg.robot.type not in SUPPORTED_ROBOTS: raise ValueError(f"Robot {cfg.robot.type} not yet supported!")
# raise ValueError(f"Robot {cfg.robot.type} not yet supported!")
client = RobotClient(cfg) client = RobotClient(cfg)
@@ -508,5 +499,4 @@ def async_client(cfg: RobotClientConfig):
if __name__ == "__main__": if __name__ == "__main__":
register_third_party_plugins()
async_client() # run the client async_client() # run the client
+1 -1
View File
@@ -13,5 +13,5 @@
# limitations under the License. # limitations under the License.
from .camera import Camera from .camera import Camera
from .configs import CameraConfig, ColorMode, Cv2Backends, Cv2Rotation from .configs import CameraConfig, ColorMode, Cv2Rotation
from .utils import make_cameras_from_configs from .utils import make_cameras_from_configs
+18 -82
View File
@@ -15,12 +15,11 @@
# limitations under the License. # limitations under the License.
import abc import abc
import warnings
from typing import Any from typing import Any
from numpy.typing import NDArray # type: ignore # TODO: add type stubs for numpy.typing from numpy.typing import NDArray # type: ignore # TODO: add type stubs for numpy.typing
from .configs import CameraConfig from .configs import CameraConfig, ColorMode
class Camera(abc.ABC): class Camera(abc.ABC):
@@ -31,12 +30,20 @@ class Camera(abc.ABC):
Manages basic camera properties (FPS, resolution) and core operations: Manages basic camera properties (FPS, resolution) and core operations:
- Connection/disconnection - Connection/disconnection
- Frame capture (sync/async/latest) - Frame capture (sync/async)
Attributes: Attributes:
fps (int | None): Configured frames per second fps (int | None): Configured frames per second
width (int | None): Frame width in pixels width (int | None): Frame width in pixels
height (int | None): Frame height in pixels height (int | None): Frame height in pixels
Example:
class MyCamera(Camera):
def __init__(self, config): ...
@property
def is_connected(self) -> bool: ...
def connect(self, warmup=True): ...
# Plus other required methods
""" """
def __init__(self, config: CameraConfig): def __init__(self, config: CameraConfig):
@@ -49,32 +56,6 @@ class Camera(abc.ABC):
self.width: int | None = config.width self.width: int | None = config.width
self.height: int | None = config.height self.height: int | None = config.height
def __enter__(self):
"""
Context manager entry.
Automatically connects to the camera.
"""
self.connect()
return self
def __exit__(self, exc_type, exc_value, traceback) -> None:
"""
Context manager exit.
Automatically disconnects, ensuring resources are released even on error.
"""
self.disconnect()
def __del__(self) -> None:
"""
Destructor safety net.
Attempts to disconnect if the object is garbage collected without cleanup.
"""
try:
if self.is_connected:
self.disconnect()
except Exception: # nosec B110
pass
@property @property
@abc.abstractmethod @abc.abstractmethod
def is_connected(self) -> bool: def is_connected(self) -> bool:
@@ -108,10 +89,12 @@ class Camera(abc.ABC):
pass pass
@abc.abstractmethod @abc.abstractmethod
def read(self) -> NDArray[Any]: def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
"""Capture and return a single frame from the camera synchronously. """Capture and return a single frame from the camera.
This is a blocking call that will wait for the hardware and its SDK. Args:
color_mode: Desired color mode for the output frame. If None,
uses the camera's default color mode.
Returns: Returns:
np.ndarray: Captured frame as a numpy array. np.ndarray: Captured frame as a numpy array.
@@ -120,64 +103,17 @@ class Camera(abc.ABC):
@abc.abstractmethod @abc.abstractmethod
def async_read(self, timeout_ms: float = ...) -> NDArray[Any]: def async_read(self, timeout_ms: float = ...) -> NDArray[Any]:
"""Return the most recent new frame. """Asynchronously capture and return a single frame from the camera.
This method retrieves the latest frame captured by the background thread.
If a new frame is already available in the buffer (captured since the last call),
it returns it immediately.
It blocks up to `timeout_ms` only if the buffer is empty or if the latest frame
was already consumed by a previous `async_read` call.
Essentially, this method return the latest unconsumed frame, waiting if necessary
for a new one to arrive within the specified timeout.
Usage:
- Ideal for control loops where you want to ensure every processed frame
is fresh, effectively synchronizing your loop to the camera's FPS.
- Causes of a timeout usually include: very low camera FPS, heavy processing load,
or if the camera is disconnected.
Args: Args:
timeout_ms: Maximum time to wait for a new frame in milliseconds. timeout_ms: Maximum time to wait for a frame in milliseconds.
Defaults to 200ms (0.2s). Defaults to implementation-specific timeout.
Returns: Returns:
np.ndarray: Captured frame as a numpy array. np.ndarray: Captured frame as a numpy array.
Raises:
TimeoutError: If no new frame arrives within `timeout_ms`.
""" """
pass pass
def read_latest(self, max_age_ms: int = 500) -> NDArray[Any]:
"""Return the most recent frame captured immediately (Peeking).
This method is non-blocking and returns whatever is currently in the
memory buffer. The frame may be stale,
meaning it could have been captured a while ago (hanging camera scenario e.g.).
Usage:
Ideal for scenarios requiring zero latency or decoupled frequencies & when
we want a guaranteed frame, such as UI visualization, logging, or
non-critical monitoring.
Returns:
NDArray[Any]: The frame image (numpy array).
Raises:
TimeoutError: If the latest frame is older than `max_age_ms`.
NotConnectedError: If the camera is not connected.
RuntimeError: If the camera is connected but has not captured any frames yet.
"""
warnings.warn(
f"{self.__class__.__name__}.read_latest() is not implemented. "
"Please override read_latest(); it will be required in future releases.",
FutureWarning,
stacklevel=2,
)
return self.async_read()
@abc.abstractmethod @abc.abstractmethod
def disconnect(self) -> None: def disconnect(self) -> None:
"""Disconnect from the camera and release resources.""" """Disconnect from the camera and release resources."""
-23
View File
@@ -25,10 +25,6 @@ class ColorMode(str, Enum):
RGB = "rgb" RGB = "rgb"
BGR = "bgr" BGR = "bgr"
@classmethod
def _missing_(cls, value: object) -> None:
raise ValueError(f"`color_mode` is expected to be in {list(cls)}, but {value} is provided.")
class Cv2Rotation(int, Enum): class Cv2Rotation(int, Enum):
NO_ROTATION = 0 NO_ROTATION = 0
@@ -36,25 +32,6 @@ class Cv2Rotation(int, Enum):
ROTATE_180 = 180 ROTATE_180 = 180
ROTATE_270 = -90 ROTATE_270 = -90
@classmethod
def _missing_(cls, value: object) -> None:
raise ValueError(f"`rotation` is expected to be in {list(cls)}, but {value} is provided.")
# Subset from https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html
class Cv2Backends(int, Enum):
ANY = 0
V4L2 = 200
DSHOW = 700
PVAPI = 800
ANDROID = 1000
AVFOUNDATION = 1200
MSMF = 1400
@classmethod
def _missing_(cls, value: object) -> None:
raise ValueError(f"`backend` is expected to be in {list(cls)}, but {value} is provided.")
@dataclass(kw_only=True) @dataclass(kw_only=True)
class CameraConfig(draccus.ChoiceRegistry, abc.ABC): # type: ignore # TODO: add type stubs for draccus class CameraConfig(draccus.ChoiceRegistry, abc.ABC): # type: ignore # TODO: add type stubs for draccus
+64 -115
View File
@@ -32,11 +32,10 @@ if platform.system() == "Windows" and "OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"
os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0" os.environ["OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"] = "0"
import cv2 # type: ignore # TODO: add type stubs for OpenCV import cv2 # type: ignore # TODO: add type stubs for OpenCV
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError
from lerobot.utils.errors import DeviceNotConnectedError
from ..camera import Camera from ..camera import Camera
from ..utils import get_cv2_rotation from ..utils import get_cv2_backend, get_cv2_rotation
from .configuration_opencv import ColorMode, OpenCVCameraConfig from .configuration_opencv import ColorMode, OpenCVCameraConfig
# NOTE(Steven): The maximum opencv device index depends on your operating system. For instance, # NOTE(Steven): The maximum opencv device index depends on your operating system. For instance,
@@ -71,24 +70,34 @@ class OpenCVCamera(Camera):
Example: Example:
```python ```python
from lerobot.cameras.opencv import OpenCVCamera from lerobot.cameras.opencv import OpenCVCamera
from lerobot.cameras.configuration_opencv import OpenCVCameraConfig from lerobot.cameras.configuration_opencv import OpenCVCameraConfig, ColorMode, Cv2Rotation
# Basic usage with camera index 0 # Basic usage with camera index 0
config = OpenCVCameraConfig(index_or_path=0) config = OpenCVCameraConfig(index_or_path=0)
camera = OpenCVCamera(config) camera = OpenCVCamera(config)
camera.connect() camera.connect()
# Read 1 frame synchronously (blocking) # Read 1 frame synchronously
color_image = camera.read() color_image = camera.read()
print(color_image.shape)
# Read 1 frame asynchronously (waits for new frame with a timeout) # Read 1 frame asynchronously
async_image = camera.async_read() async_image = camera.async_read()
# Get the latest frame immediately (no wait, returns timestamp)
latest_image, timestamp = camera.read_latest()
# When done, properly disconnect the camera using # When done, properly disconnect the camera using
camera.disconnect() camera.disconnect()
# Example with custom settings
custom_config = OpenCVCameraConfig(
index_or_path='/dev/video0', # Or use an index
fps=30,
width=1280,
height=720,
color_mode=ColorMode.RGB,
rotation=Cv2Rotation.ROTATE_90
)
custom_camera = OpenCVCamera(custom_config)
# ... connect, read, disconnect ...
``` ```
""" """
@@ -114,11 +123,10 @@ class OpenCVCamera(Camera):
self.stop_event: Event | None = None self.stop_event: Event | None = None
self.frame_lock: Lock = Lock() self.frame_lock: Lock = Lock()
self.latest_frame: NDArray[Any] | None = None self.latest_frame: NDArray[Any] | None = None
self.latest_timestamp: float | None = None
self.new_frame_event: Event = Event() self.new_frame_event: Event = Event()
self.rotation: int | None = get_cv2_rotation(config.rotation) self.rotation: int | None = get_cv2_rotation(config.rotation)
self.backend: int = config.backend self.backend: int = get_cv2_backend()
if self.height and self.width: if self.height and self.width:
self.capture_width, self.capture_height = self.width, self.height self.capture_width, self.capture_height = self.width, self.height
@@ -133,23 +141,20 @@ class OpenCVCamera(Camera):
"""Checks if the camera is currently connected and opened.""" """Checks if the camera is currently connected and opened."""
return isinstance(self.videocapture, cv2.VideoCapture) and self.videocapture.isOpened() return isinstance(self.videocapture, cv2.VideoCapture) and self.videocapture.isOpened()
@check_if_already_connected
def connect(self, warmup: bool = True) -> None: def connect(self, warmup: bool = True) -> None:
""" """
Connects to the OpenCV camera specified in the configuration. Connects to the OpenCV camera specified in the configuration.
Initializes the OpenCV VideoCapture object, sets desired camera properties Initializes the OpenCV VideoCapture object, sets desired camera properties
(FPS, width, height), starts the background reading thread and performs initial checks. (FPS, width, height), and performs initial checks.
Args:
warmup (bool): If True, waits at connect() time until at least one valid frame
has been captured by the background thread. Defaults to True.
Raises: Raises:
DeviceAlreadyConnectedError: If the camera is already connected. DeviceAlreadyConnectedError: If the camera is already connected.
ConnectionError: If the specified camera index/path is not found or fails to open. ConnectionError: If the specified camera index/path is not found or the camera is found but fails to open.
RuntimeError: If the camera opens but fails to apply requested settings. RuntimeError: If the camera opens but fails to apply requested FPS/resolution settings.
""" """
if self.is_connected:
raise DeviceAlreadyConnectedError(f"{self} is already connected.")
# Use 1 thread for OpenCV operations to avoid potential conflicts or # Use 1 thread for OpenCV operations to avoid potential conflicts or
# blocking in multi-threaded applications, especially during data collection. # blocking in multi-threaded applications, especially during data collection.
@@ -165,20 +170,15 @@ class OpenCVCamera(Camera):
) )
self._configure_capture_settings() self._configure_capture_settings()
self._start_read_thread()
if warmup and self.warmup_s > 0: if warmup:
start_time = time.time() start_time = time.time()
while time.time() - start_time < self.warmup_s: while time.time() - start_time < self.warmup_s:
self.async_read(timeout_ms=self.warmup_s * 1000) self.read()
time.sleep(0.1) time.sleep(0.1)
with self.frame_lock:
if self.latest_frame is None:
raise ConnectionError(f"{self} failed to capture frames during warmup.")
logger.info(f"{self} connected.") logger.info(f"{self} connected.")
@check_if_not_connected
def _configure_capture_settings(self) -> None: def _configure_capture_settings(self) -> None:
""" """
Applies the specified FOURCC, FPS, width, and height settings to the connected camera. Applies the specified FOURCC, FPS, width, and height settings to the connected camera.
@@ -196,8 +196,11 @@ class OpenCVCamera(Camera):
Raises: Raises:
RuntimeError: If the camera fails to set any of the specified properties RuntimeError: If the camera fails to set any of the specified properties
to the requested value. to the requested value.
DeviceNotConnectedError: If the camera is not connected. DeviceNotConnectedError: If the camera is not connected when attempting
to configure settings.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"Cannot configure settings for {self} as it is not connected.")
# Set FOURCC first (if specified) as it can affect available FPS/resolution options # Set FOURCC first (if specified) as it can affect available FPS/resolution options
if self.config.fourcc is not None: if self.config.fourcc is not None:
@@ -336,18 +339,6 @@ class OpenCVCamera(Camera):
return found_cameras_info return found_cameras_info
def _read_from_hardware(self) -> NDArray[Any]:
if self.videocapture is None:
raise DeviceNotConnectedError(f"{self} videocapture is not initialized")
ret, frame = self.videocapture.read()
if not ret:
raise RuntimeError(f"{self} read failed (status={ret}).")
return frame
@check_if_not_connected
def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]: def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
""" """
Reads a single frame synchronously from the camera. Reads a single frame synchronously from the camera.
@@ -355,6 +346,11 @@ class OpenCVCamera(Camera):
This is a blocking call. It waits for the next available frame from the This is a blocking call. It waits for the next available frame from the
camera hardware via OpenCV. camera hardware via OpenCV.
Args:
color_mode (Optional[ColorMode]): If specified, overrides the default
color mode (`self.color_mode`) for this read operation (e.g.,
request RGB even if default is BGR).
Returns: Returns:
np.ndarray: The captured frame as a NumPy array in the format np.ndarray: The captured frame as a NumPy array in the format
(height, width, channels), using the specified or default (height, width, channels), using the specified or default
@@ -366,31 +362,34 @@ class OpenCVCamera(Camera):
received frame dimensions don't match expectations before rotation. received frame dimensions don't match expectations before rotation.
ValueError: If an invalid `color_mode` is requested. ValueError: If an invalid `color_mode` is requested.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.")
start_time = time.perf_counter() start_time = time.perf_counter()
if color_mode is not None: if self.videocapture is None:
logger.warning( raise DeviceNotConnectedError(f"{self} videocapture is not initialized")
f"{self} read() color_mode parameter is deprecated and will be removed in future versions."
)
if self.thread is None or not self.thread.is_alive(): ret, frame = self.videocapture.read()
raise RuntimeError(f"{self} read thread is not running.")
self.new_frame_event.clear() if not ret or frame is None:
frame = self.async_read(timeout_ms=10000) raise RuntimeError(f"{self} read failed (status={ret}).")
processed_frame = self._postprocess_image(frame, color_mode)
read_duration_ms = (time.perf_counter() - start_time) * 1e3 read_duration_ms = (time.perf_counter() - start_time) * 1e3
logger.debug(f"{self} read took: {read_duration_ms:.1f}ms") logger.debug(f"{self} read took: {read_duration_ms:.1f}ms")
return frame return processed_frame
def _postprocess_image(self, image: NDArray[Any]) -> NDArray[Any]: def _postprocess_image(self, image: NDArray[Any], color_mode: ColorMode | None = None) -> NDArray[Any]:
""" """
Applies color conversion, dimension validation, and rotation to a raw frame. Applies color conversion, dimension validation, and rotation to a raw frame.
Args: Args:
image (np.ndarray): The raw image frame (expected BGR format from OpenCV). image (np.ndarray): The raw image frame (expected BGR format from OpenCV).
color_mode (Optional[ColorMode]): The target color mode (RGB or BGR). If None,
uses the instance's default `self.color_mode`.
Returns: Returns:
np.ndarray: The processed image frame. np.ndarray: The processed image frame.
@@ -400,10 +399,11 @@ class OpenCVCamera(Camera):
RuntimeError: If the raw frame dimensions do not match the configured RuntimeError: If the raw frame dimensions do not match the configured
`width` and `height`. `width` and `height`.
""" """
requested_color_mode = self.color_mode if color_mode is None else color_mode
if self.color_mode not in (ColorMode.RGB, ColorMode.BGR): if requested_color_mode not in (ColorMode.RGB, ColorMode.BGR):
raise ValueError( raise ValueError(
f"Invalid color mode '{self.color_mode}'. Expected {ColorMode.RGB} or {ColorMode.BGR}." f"Invalid color mode '{requested_color_mode}'. Expected {ColorMode.RGB} or {ColorMode.BGR}."
) )
h, w, c = image.shape h, w, c = image.shape
@@ -417,7 +417,7 @@ class OpenCVCamera(Camera):
raise RuntimeError(f"{self} frame channels={c} do not match expected 3 channels (RGB/BGR).") raise RuntimeError(f"{self} frame channels={c} do not match expected 3 channels (RGB/BGR).")
processed_image = image processed_image = image
if self.color_mode == ColorMode.RGB: if requested_color_mode == ColorMode.RGB:
processed_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) processed_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
if self.rotation in [cv2.ROTATE_90_CLOCKWISE, cv2.ROTATE_90_COUNTERCLOCKWISE, cv2.ROTATE_180]: if self.rotation in [cv2.ROTATE_90_CLOCKWISE, cv2.ROTATE_90_COUNTERCLOCKWISE, cv2.ROTATE_180]:
@@ -431,7 +431,7 @@ class OpenCVCamera(Camera):
On each iteration: On each iteration:
1. Reads a color frame 1. Reads a color frame
2. Stores result in latest_frame and updates timestamp (thread-safe) 2. Stores result in latest_frame (thread-safe)
3. Sets new_frame_event to notify listeners 3. Sets new_frame_event to notify listeners
Stops on DeviceNotConnectedError, logs other errors and continues. Stops on DeviceNotConnectedError, logs other errors and continues.
@@ -439,37 +439,30 @@ class OpenCVCamera(Camera):
if self.stop_event is None: if self.stop_event is None:
raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.") raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.")
failure_count = 0
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
raw_frame = self._read_from_hardware() color_image = self.read()
processed_frame = self._postprocess_image(raw_frame)
capture_time = time.perf_counter()
with self.frame_lock: with self.frame_lock:
self.latest_frame = processed_frame self.latest_frame = color_image
self.latest_timestamp = capture_time
self.new_frame_event.set() self.new_frame_event.set()
failure_count = 0
except DeviceNotConnectedError: except DeviceNotConnectedError:
break break
except Exception as e: except Exception as e:
if failure_count <= 10:
failure_count += 1
logger.warning(f"Error reading frame in background thread for {self}: {e}") logger.warning(f"Error reading frame in background thread for {self}: {e}")
else:
raise RuntimeError(f"{self} exceeded maximum consecutive read failures.") from e
def _start_read_thread(self) -> None: def _start_read_thread(self) -> None:
"""Starts or restarts the background read thread if it's not running.""" """Starts or restarts the background read thread if it's not running."""
self._stop_read_thread() if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=0.1)
if self.stop_event is not None:
self.stop_event.set()
self.stop_event = Event() self.stop_event = Event()
self.thread = Thread(target=self._read_loop, args=(), name=f"{self}_read_loop") self.thread = Thread(target=self._read_loop, args=(), name=f"{self}_read_loop")
self.thread.daemon = True self.thread.daemon = True
self.thread.start() self.thread.start()
time.sleep(0.1)
def _stop_read_thread(self) -> None: def _stop_read_thread(self) -> None:
"""Signals the background read thread to stop and waits for it to join.""" """Signals the background read thread to stop and waits for it to join."""
@@ -482,12 +475,6 @@ class OpenCVCamera(Camera):
self.thread = None self.thread = None
self.stop_event = None self.stop_event = None
with self.frame_lock:
self.latest_frame = None
self.latest_timestamp = None
self.new_frame_event.clear()
@check_if_not_connected
def async_read(self, timeout_ms: float = 200) -> NDArray[Any]: def async_read(self, timeout_ms: float = 200) -> NDArray[Any]:
""" """
Reads the latest available frame asynchronously. Reads the latest available frame asynchronously.
@@ -495,7 +482,6 @@ class OpenCVCamera(Camera):
This method retrieves the most recent frame captured by the background This method retrieves the most recent frame captured by the background
read thread. It does not block waiting for the camera hardware directly, read thread. It does not block waiting for the camera hardware directly,
but may wait up to timeout_ms for the background thread to provide a frame. but may wait up to timeout_ms for the background thread to provide a frame.
It is “best effort” under high FPS.
Args: Args:
timeout_ms (float): Maximum time in milliseconds to wait for a frame timeout_ms (float): Maximum time in milliseconds to wait for a frame
@@ -510,14 +496,17 @@ class OpenCVCamera(Camera):
TimeoutError: If no frame becomes available within the specified timeout. TimeoutError: If no frame becomes available within the specified timeout.
RuntimeError: If an unexpected error occurs. RuntimeError: If an unexpected error occurs.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.")
if self.thread is None or not self.thread.is_alive(): if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.") self._start_read_thread()
if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0): if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0):
thread_alive = self.thread is not None and self.thread.is_alive()
raise TimeoutError( raise TimeoutError(
f"Timed out waiting for frame from camera {self} after {timeout_ms} ms. " f"Timed out waiting for frame from camera {self} after {timeout_ms} ms. "
f"Read thread alive: {self.thread.is_alive()}." f"Read thread alive: {thread_alive}."
) )
with self.frame_lock: with self.frame_lock:
@@ -529,41 +518,6 @@ class OpenCVCamera(Camera):
return frame return frame
@check_if_not_connected
def read_latest(self, max_age_ms: int = 500) -> NDArray[Any]:
"""Return the most recent frame captured immediately (Peeking).
This method is non-blocking and returns whatever is currently in the
memory buffer. The frame may be stale,
meaning it could have been captured a while ago (hanging camera scenario e.g.).
Returns:
NDArray[Any]: The frame image (numpy array).
Raises:
TimeoutError: If the latest frame is older than `max_age_ms`.
DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If the camera is connected but has not captured any frames yet.
"""
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
with self.frame_lock:
frame = self.latest_frame
timestamp = self.latest_timestamp
if frame is None or timestamp is None:
raise RuntimeError(f"{self} has not captured any frames yet.")
age_ms = (time.perf_counter() - timestamp) * 1e3
if age_ms > max_age_ms:
raise TimeoutError(
f"{self} latest frame is too old: {age_ms:.1f} ms (max allowed: {max_age_ms} ms)."
)
return frame
def disconnect(self) -> None: def disconnect(self) -> None:
""" """
Disconnects from the camera and cleans up resources. Disconnects from the camera and cleans up resources.
@@ -584,9 +538,4 @@ class OpenCVCamera(Camera):
self.videocapture.release() self.videocapture.release()
self.videocapture = None self.videocapture = None
with self.frame_lock:
self.latest_frame = None
self.latest_timestamp = None
self.new_frame_event.clear()
logger.info(f"{self} disconnected.") logger.info(f"{self} disconnected.")
@@ -15,9 +15,9 @@
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ..configs import CameraConfig, ColorMode, Cv2Backends, Cv2Rotation from ..configs import CameraConfig, ColorMode, Cv2Rotation
__all__ = ["OpenCVCameraConfig", "ColorMode", "Cv2Rotation", "Cv2Backends"] __all__ = ["OpenCVCameraConfig", "ColorMode", "Cv2Rotation"]
@CameraConfig.register_subclass("opencv") @CameraConfig.register_subclass("opencv")
@@ -50,7 +50,6 @@ class OpenCVCameraConfig(CameraConfig):
rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation. rotation: Image rotation setting (0°, 90°, 180°, or 270°). Defaults to no rotation.
warmup_s: Time reading frames before returning from connect (in seconds) warmup_s: Time reading frames before returning from connect (in seconds)
fourcc: FOURCC code for video format (e.g., "MJPG", "YUYV", "I420"). Defaults to None (auto-detect). fourcc: FOURCC code for video format (e.g., "MJPG", "YUYV", "I420"). Defaults to None (auto-detect).
backend: OpenCV backend identifier (https://docs.opencv.org/3.4/d4/d15/group__videoio__flags__base.html). Defaults to ANY.
Note: Note:
- Only 3-channel color output (RGB/BGR) is currently supported. - Only 3-channel color output (RGB/BGR) is currently supported.
@@ -63,12 +62,22 @@ class OpenCVCameraConfig(CameraConfig):
rotation: Cv2Rotation = Cv2Rotation.NO_ROTATION rotation: Cv2Rotation = Cv2Rotation.NO_ROTATION
warmup_s: int = 1 warmup_s: int = 1
fourcc: str | None = None fourcc: str | None = None
backend: Cv2Backends = Cv2Backends.ANY
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.color_mode = ColorMode(self.color_mode) if self.color_mode not in (ColorMode.RGB, ColorMode.BGR):
self.rotation = Cv2Rotation(self.rotation) raise ValueError(
self.backend = Cv2Backends(self.backend) f"`color_mode` is expected to be {ColorMode.RGB.value} or {ColorMode.BGR.value}, but {self.color_mode} is provided."
)
if self.rotation not in (
Cv2Rotation.NO_ROTATION,
Cv2Rotation.ROTATE_90,
Cv2Rotation.ROTATE_180,
Cv2Rotation.ROTATE_270,
):
raise ValueError(
f"`rotation` is expected to be in {(Cv2Rotation.NO_ROTATION, Cv2Rotation.ROTATE_90, Cv2Rotation.ROTATE_180, Cv2Rotation.ROTATE_270)}, but {self.rotation} is provided."
)
if self.fourcc is not None and (not isinstance(self.fourcc, str) or len(self.fourcc) != 4): if self.fourcc is not None and (not isinstance(self.fourcc, str) or len(self.fourcc) != 4):
raise ValueError( raise ValueError(
@@ -74,4 +74,7 @@ class Reachy2CameraConfig(CameraConfig):
f"`image_type` is expected to be 'left' or 'right' for teleop camera, and 'rgb' or 'depth' for depth camera, but {self.image_type} is provided." f"`image_type` is expected to be 'left' or 'right' for teleop camera, and 'rgb' or 'depth' for depth camera, but {self.image_type} is provided."
) )
self.color_mode = ColorMode(self.color_mode) if self.color_mode not in ["rgb", "bgr"]:
raise ValueError(
f"`color_mode` is expected to be 'rgb' or 'bgr', but {self.color_mode} is provided."
)
@@ -32,7 +32,6 @@ if platform.system() == "Windows" and "OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS"
import cv2 # type: ignore # TODO: add type stubs for OpenCV import cv2 # type: ignore # TODO: add type stubs for OpenCV
import numpy as np # type: ignore # TODO: add type stubs for numpy import numpy as np # type: ignore # TODO: add type stubs for numpy
from lerobot.utils.decorators import check_if_not_connected
from lerobot.utils.import_utils import _reachy2_sdk_available from lerobot.utils.import_utils import _reachy2_sdk_available
if TYPE_CHECKING or _reachy2_sdk_available: if TYPE_CHECKING or _reachy2_sdk_available:
@@ -81,8 +80,6 @@ class Reachy2Camera(Camera):
self.config = config self.config = config
self.color_mode = config.color_mode self.color_mode = config.color_mode
self.latest_frame: NDArray[Any] | None = None
self.latest_timestamp: float | None = None
self.cam_manager: CameraManager | None = None self.cam_manager: CameraManager | None = None
@@ -124,12 +121,16 @@ class Reachy2Camera(Camera):
""" """
raise NotImplementedError("Camera detection is not implemented for Reachy2 cameras.") raise NotImplementedError("Camera detection is not implemented for Reachy2 cameras.")
@check_if_not_connected
def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]: def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
""" """
Reads a single frame synchronously from the camera. Reads a single frame synchronously from the camera.
This method retrieves the most recent frame available in Reachy 2's low-level software. This is a blocking call.
Args:
color_mode (Optional[ColorMode]): If specified, overrides the default
color mode (`self.color_mode`) for this read operation (e.g.,
request RGB even if default is BGR).
Returns: Returns:
np.ndarray: The captured frame as a NumPy array in the format np.ndarray: The captured frame as a NumPy array in the format
@@ -138,13 +139,11 @@ class Reachy2Camera(Camera):
""" """
start_time = time.perf_counter() start_time = time.perf_counter()
if self.cam_manager is None: if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.") raise DeviceNotConnectedError(f"{self} is not connected.")
if color_mode is not None: if self.cam_manager is None:
logger.warning( raise DeviceNotConnectedError(f"{self} is not connected.")
f"{self} read() color_mode parameter is deprecated and will be removed in future versions."
)
frame: NDArray[Any] = np.empty((0, 0, 3), dtype=np.uint8) frame: NDArray[Any] = np.empty((0, 0, 3), dtype=np.uint8)
@@ -166,27 +165,25 @@ class Reachy2Camera(Camera):
raise ValueError(f"Invalid camera name '{self.config.name}'. Expected 'teleop' or 'depth'.") raise ValueError(f"Invalid camera name '{self.config.name}'. Expected 'teleop' or 'depth'.")
if frame is None: if frame is None:
raise RuntimeError(f"Internal error: No frame available for {self}.") return np.empty((0, 0, 3), dtype=np.uint8)
if self.color_mode not in (ColorMode.RGB, ColorMode.BGR): if self.config.color_mode == "rgb":
raise ValueError(
f"Invalid color mode '{self.color_mode}'. Expected {ColorMode.RGB} or {ColorMode.BGR}."
)
if self.color_mode == ColorMode.RGB:
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
self.latest_frame = frame
self.latest_timestamp = time.perf_counter()
read_duration_ms = (time.perf_counter() - start_time) * 1e3 read_duration_ms = (time.perf_counter() - start_time) * 1e3
logger.debug(f"{self} read took: {read_duration_ms:.1f}ms") logger.debug(f"{self} read took: {read_duration_ms:.1f}ms")
return frame return frame
@check_if_not_connected
def async_read(self, timeout_ms: float = 200) -> NDArray[Any]: def async_read(self, timeout_ms: float = 200) -> NDArray[Any]:
""" """
Same as read() Reads the latest available frame.
This method retrieves the most recent frame available in Reachy 2's low-level software.
Args:
timeout_ms (float): Maximum time in milliseconds to wait for a frame
to become available. Defaults to 200ms (0.2 seconds).
Returns: Returns:
np.ndarray: The latest captured frame as a NumPy array in the format np.ndarray: The latest captured frame as a NumPy array in the format
@@ -197,40 +194,16 @@ class Reachy2Camera(Camera):
TimeoutError: If no frame becomes available within the specified timeout. TimeoutError: If no frame becomes available within the specified timeout.
RuntimeError: If an unexpected error occurs. RuntimeError: If an unexpected error occurs.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.")
return self.read() frame = self.read()
@check_if_not_connected if frame is None:
def read_latest(self, max_age_ms: int = 500) -> NDArray[Any]: raise RuntimeError(f"Internal error: No frame available for {self}.")
"""Return the most recent frame captured immediately (Peeking).
This method is non-blocking and returns whatever is currently in the return frame
memory buffer. The frame may be stale,
meaning it could have been captured a while ago (hanging camera scenario e.g.).
Returns:
tuple[NDArray, float]:
- The frame image (numpy array).
- The timestamp (time.perf_counter) when this frame was captured.
Raises:
TimeoutError: If the latest frame is older than `max_age_ms`.
DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If the camera is connected but has not captured any frames yet.
"""
if self.latest_frame is None or self.latest_timestamp is None:
raise RuntimeError(f"{self} has not captured any frames yet.")
age_ms = (time.perf_counter() - self.latest_timestamp) * 1e3
if age_ms > max_age_ms:
raise TimeoutError(
f"{self} latest frame is too old: {age_ms:.1f} ms (max allowed: {max_age_ms} ms)."
)
return self.latest_frame
@check_if_not_connected
def disconnect(self) -> None: def disconnect(self) -> None:
""" """
Stops the background read thread (if running). Stops the background read thread (if running).
@@ -238,6 +211,8 @@ class Reachy2Camera(Camera):
Raises: Raises:
DeviceNotConnectedError: If the camera is already disconnected. DeviceNotConnectedError: If the camera is already disconnected.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} not connected.")
if self.cam_manager is not None: if self.cam_manager is not None:
self.cam_manager.disconnect() self.cam_manager.disconnect()
+69 -140
View File
@@ -30,8 +30,7 @@ try:
except Exception as e: except Exception as e:
logging.info(f"Could not import realsense: {e}") logging.info(f"Could not import realsense: {e}")
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError
from lerobot.utils.errors import DeviceNotConnectedError
from ..camera import Camera from ..camera import Camera
from ..configs import ColorMode from ..configs import ColorMode
@@ -73,14 +72,15 @@ class RealSenseCamera(Camera):
camera = RealSenseCamera(config) camera = RealSenseCamera(config)
camera.connect() camera.connect()
# Read 1 frame synchronously (blocking) # Read 1 frame synchronously
color_image = camera.read() color_image = camera.read()
print(color_image.shape)
# Read 1 frame asynchronously (waits for new frame with a timeout) # Read 1 frame asynchronously
async_image = camera.async_read() async_image = camera.async_read()
# Get the latest frame immediately (no wait, returns timestamp) # When done, properly disconnect the camera using
latest_image, timestamp = camera.read_latest() camera.disconnect()
# Example with depth capture and custom settings # Example with depth capture and custom settings
custom_config = RealSenseCameraConfig( custom_config = RealSenseCameraConfig(
@@ -133,9 +133,7 @@ class RealSenseCamera(Camera):
self.thread: Thread | None = None self.thread: Thread | None = None
self.stop_event: Event | None = None self.stop_event: Event | None = None
self.frame_lock: Lock = Lock() self.frame_lock: Lock = Lock()
self.latest_color_frame: NDArray[Any] | None = None self.latest_frame: NDArray[Any] | None = None
self.latest_depth_frame: NDArray[Any] | None = None
self.latest_timestamp: float | None = None
self.new_frame_event: Event = Event() self.new_frame_event: Event = Event()
self.rotation: int | None = get_cv2_rotation(config.rotation) self.rotation: int | None = get_cv2_rotation(config.rotation)
@@ -153,7 +151,6 @@ class RealSenseCamera(Camera):
"""Checks if the camera pipeline is started and streams are active.""" """Checks if the camera pipeline is started and streams are active."""
return self.rs_pipeline is not None and self.rs_profile is not None return self.rs_pipeline is not None and self.rs_profile is not None
@check_if_already_connected
def connect(self, warmup: bool = True) -> None: def connect(self, warmup: bool = True) -> None:
""" """
Connects to the RealSense camera specified in the configuration. Connects to the RealSense camera specified in the configuration.
@@ -161,16 +158,14 @@ class RealSenseCamera(Camera):
Initializes the RealSense pipeline, configures the required streams (color Initializes the RealSense pipeline, configures the required streams (color
and optionally depth), starts the pipeline, and validates the actual stream settings. and optionally depth), starts the pipeline, and validates the actual stream settings.
Args:
warmup (bool): If True, waits at connect() time until at least one valid frame
has been captured by the background thread. Defaults to True.
Raises: Raises:
DeviceAlreadyConnectedError: If the camera is already connected. DeviceAlreadyConnectedError: If the camera is already connected.
ValueError: If the configuration is invalid (e.g., missing serial/name, name not unique). ValueError: If the configuration is invalid (e.g., missing serial/name, name not unique).
ConnectionError: If the camera is found but fails to start the pipeline or no RealSense devices are detected at all. ConnectionError: If the camera is found but fails to start the pipeline or no RealSense devices are detected at all.
RuntimeError: If the pipeline starts but fails to apply requested settings. RuntimeError: If the pipeline starts but fails to apply requested settings.
""" """
if self.is_connected:
raise DeviceAlreadyConnectedError(f"{self} is already connected.")
self.rs_pipeline = rs.pipeline() self.rs_pipeline = rs.pipeline()
rs_config = rs.config() rs_config = rs.config()
@@ -186,18 +181,15 @@ class RealSenseCamera(Camera):
) from e ) from e
self._configure_capture_settings() self._configure_capture_settings()
self._start_read_thread()
# NOTE(Steven/Caroline): Enforcing at least one second of warmup as RS cameras need a bit of time before the first read. If we don't wait, the first read from the warmup will raise.
self.warmup_s = max(self.warmup_s, 1)
if warmup:
time.sleep(
1
) # NOTE(Steven): RS cameras need a bit of time to warm up before the first read. If we don't wait, the first read from the warmup will raise.
start_time = time.time() start_time = time.time()
while time.time() - start_time < self.warmup_s: while time.time() - start_time < self.warmup_s:
self.async_read(timeout_ms=self.warmup_s * 1000) self.read()
time.sleep(0.1) time.sleep(0.1)
with self.frame_lock:
if self.latest_color_frame is None or self.use_depth and self.latest_depth_frame is None:
raise ConnectionError(f"{self} failed to capture frames during warmup.")
logger.info(f"{self} connected.") logger.info(f"{self} connected.")
@@ -290,7 +282,6 @@ class RealSenseCamera(Camera):
if self.use_depth: if self.use_depth:
rs_config.enable_stream(rs.stream.depth) rs_config.enable_stream(rs.stream.depth)
@check_if_not_connected
def _configure_capture_settings(self) -> None: def _configure_capture_settings(self) -> None:
"""Sets fps, width, and height from device stream if not already configured. """Sets fps, width, and height from device stream if not already configured.
@@ -300,6 +291,8 @@ class RealSenseCamera(Camera):
Raises: Raises:
DeviceNotConnectedError: If device is not connected. DeviceNotConnectedError: If device is not connected.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"Cannot validate settings for {self} as it is not connected.")
if self.rs_profile is None: if self.rs_profile is None:
raise RuntimeError(f"{self}: rs_profile must be initialized before use.") raise RuntimeError(f"{self}: rs_profile must be initialized before use.")
@@ -319,7 +312,6 @@ class RealSenseCamera(Camera):
self.width, self.height = actual_width, actual_height self.width, self.height = actual_width, actual_height
self.capture_width, self.capture_height = actual_width, actual_height self.capture_width, self.capture_height = actual_width, actual_height
@check_if_not_connected
def read_depth(self, timeout_ms: int = 200) -> NDArray[Any]: def read_depth(self, timeout_ms: int = 200) -> NDArray[Any]:
""" """
Reads a single frame (depth) synchronously from the camera. Reads a single frame (depth) synchronously from the camera.
@@ -327,6 +319,9 @@ class RealSenseCamera(Camera):
This is a blocking call. It waits for a coherent set of frames (depth) This is a blocking call. It waits for a coherent set of frames (depth)
from the camera hardware via the RealSense pipeline. from the camera hardware via the RealSense pipeline.
Args:
timeout_ms (int): Maximum time in milliseconds to wait for a frame. Defaults to 200ms.
Returns: Returns:
np.ndarray: The depth map as a NumPy array (height, width) np.ndarray: The depth map as a NumPy array (height, width)
of type `np.uint16` (raw depth values in millimeters) and rotation. of type `np.uint16` (raw depth values in millimeters) and rotation.
@@ -335,50 +330,44 @@ class RealSenseCamera(Camera):
DeviceNotConnectedError: If the camera is not connected. DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If reading frames from the pipeline fails or frames are invalid. RuntimeError: If reading frames from the pipeline fails or frames are invalid.
""" """
if timeout_ms:
logger.warning(
f"{self} read() timeout_ms parameter is deprecated and will be removed in future versions."
)
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.")
if not self.use_depth: if not self.use_depth:
raise RuntimeError( raise RuntimeError(
f"Failed to capture depth frame '.read_depth()'. Depth stream is not enabled for {self}." f"Failed to capture depth frame '.read_depth()'. Depth stream is not enabled for {self}."
) )
if self.thread is None or not self.thread.is_alive(): start_time = time.perf_counter()
raise RuntimeError(f"{self} read thread is not running.")
self.new_frame_event.clear()
_ = self.async_read(timeout_ms=10000)
with self.frame_lock:
depth_map = self.latest_depth_frame
if depth_map is None:
raise RuntimeError("No depth frame available. Ensure camera is streaming.")
return depth_map
def _read_from_hardware(self):
if self.rs_pipeline is None: if self.rs_pipeline is None:
raise RuntimeError(f"{self}: rs_pipeline must be initialized before use.") raise RuntimeError(f"{self}: rs_pipeline must be initialized before use.")
ret, frame = self.rs_pipeline.try_wait_for_frames(timeout_ms=10000) ret, frame = self.rs_pipeline.try_wait_for_frames(timeout_ms=timeout_ms)
if not ret or frame is None: if not ret or frame is None:
raise RuntimeError(f"{self} read failed (status={ret}).") raise RuntimeError(f"{self} read_depth failed (status={ret}).")
return frame depth_frame = frame.get_depth_frame()
depth_map = np.asanyarray(depth_frame.get_data())
@check_if_not_connected depth_map_processed = self._postprocess_image(depth_map, depth_frame=True)
def read(self, color_mode: ColorMode | None = None, timeout_ms: int = 0) -> NDArray[Any]:
read_duration_ms = (time.perf_counter() - start_time) * 1e3
logger.debug(f"{self} read took: {read_duration_ms:.1f}ms")
return depth_map_processed
def read(self, color_mode: ColorMode | None = None, timeout_ms: int = 200) -> NDArray[Any]:
""" """
Reads a single frame (color) synchronously from the camera. Reads a single frame (color) synchronously from the camera.
This is a blocking call. It waits for a coherent set of frames (color) This is a blocking call. It waits for a coherent set of frames (color)
from the camera hardware via the RealSense pipeline. from the camera hardware via the RealSense pipeline.
Args:
timeout_ms (int): Maximum time in milliseconds to wait for a frame. Defaults to 200ms.
Returns: Returns:
np.ndarray: The captured color frame as a NumPy array np.ndarray: The captured color frame as a NumPy array
(height, width, channels), processed according to `color_mode` and rotation. (height, width, channels), processed according to `color_mode` and rotation.
@@ -389,36 +378,39 @@ class RealSenseCamera(Camera):
ValueError: If an invalid `color_mode` is requested. ValueError: If an invalid `color_mode` is requested.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.")
start_time = time.perf_counter() start_time = time.perf_counter()
if color_mode is not None: if self.rs_pipeline is None:
logger.warning( raise RuntimeError(f"{self}: rs_pipeline must be initialized before use.")
f"{self} read() color_mode parameter is deprecated and will be removed in future versions."
)
if timeout_ms: ret, frame = self.rs_pipeline.try_wait_for_frames(timeout_ms=timeout_ms)
logger.warning(
f"{self} read() timeout_ms parameter is deprecated and will be removed in future versions."
)
if self.thread is None or not self.thread.is_alive(): if not ret or frame is None:
raise RuntimeError(f"{self} read thread is not running.") raise RuntimeError(f"{self} read failed (status={ret}).")
self.new_frame_event.clear() color_frame = frame.get_color_frame()
color_image_raw = np.asanyarray(color_frame.get_data())
frame = self.async_read(timeout_ms=10000) color_image_processed = self._postprocess_image(color_image_raw, color_mode)
read_duration_ms = (time.perf_counter() - start_time) * 1e3 read_duration_ms = (time.perf_counter() - start_time) * 1e3
logger.debug(f"{self} read took: {read_duration_ms:.1f}ms") logger.debug(f"{self} read took: {read_duration_ms:.1f}ms")
return frame return color_image_processed
def _postprocess_image(self, image: NDArray[Any], depth_frame: bool = False) -> NDArray[Any]: def _postprocess_image(
self, image: NDArray[Any], color_mode: ColorMode | None = None, depth_frame: bool = False
) -> NDArray[Any]:
""" """
Applies color conversion, dimension validation, and rotation to a raw color frame. Applies color conversion, dimension validation, and rotation to a raw color frame.
Args: Args:
image (np.ndarray): The raw image frame (expected RGB format from RealSense). image (np.ndarray): The raw image frame (expected RGB format from RealSense).
color_mode (Optional[ColorMode]): The target color mode (RGB or BGR). If None,
uses the instance's default `self.color_mode`.
Returns: Returns:
np.ndarray: The processed image frame according to `self.color_mode` and `self.rotation`. np.ndarray: The processed image frame according to `self.color_mode` and `self.rotation`.
@@ -429,9 +421,9 @@ class RealSenseCamera(Camera):
`width` and `height`. `width` and `height`.
""" """
if self.color_mode and self.color_mode not in (ColorMode.RGB, ColorMode.BGR): if color_mode and color_mode not in (ColorMode.RGB, ColorMode.BGR):
raise ValueError( raise ValueError(
f"Invalid requested color mode '{self.color_mode}'. Expected {ColorMode.RGB} or {ColorMode.BGR}." f"Invalid requested color mode '{color_mode}'. Expected {ColorMode.RGB} or {ColorMode.BGR}."
) )
if depth_frame: if depth_frame:
@@ -462,7 +454,7 @@ class RealSenseCamera(Camera):
On each iteration: On each iteration:
1. Reads a color frame with 500ms timeout 1. Reads a color frame with 500ms timeout
2. Stores result in latest_frame and updates timestamp (thread-safe) 2. Stores result in latest_frame (thread-safe)
3. Sets new_frame_event to notify listeners 3. Sets new_frame_event to notify listeners
Stops on DeviceNotConnectedError, logs other errors and continues. Stops on DeviceNotConnectedError, logs other errors and continues.
@@ -470,41 +462,25 @@ class RealSenseCamera(Camera):
if self.stop_event is None: if self.stop_event is None:
raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.") raise RuntimeError(f"{self}: stop_event is not initialized before starting read loop.")
failure_count = 0
while not self.stop_event.is_set(): while not self.stop_event.is_set():
try: try:
frame = self._read_from_hardware() color_image = self.read(timeout_ms=500)
color_frame_raw = frame.get_color_frame()
color_frame = np.asanyarray(color_frame_raw.get_data())
processed_color_frame = self._postprocess_image(color_frame)
if self.use_depth:
depth_frame_raw = frame.get_depth_frame()
depth_frame = np.asanyarray(depth_frame_raw.get_data())
processed_depth_frame = self._postprocess_image(depth_frame, depth_frame=True)
capture_time = time.perf_counter()
with self.frame_lock: with self.frame_lock:
self.latest_color_frame = processed_color_frame self.latest_frame = color_image
if self.use_depth:
self.latest_depth_frame = processed_depth_frame
self.latest_timestamp = capture_time
self.new_frame_event.set() self.new_frame_event.set()
failure_count = 0
except DeviceNotConnectedError: except DeviceNotConnectedError:
break break
except Exception as e: except Exception as e:
if failure_count <= 10:
failure_count += 1
logger.warning(f"Error reading frame in background thread for {self}: {e}") logger.warning(f"Error reading frame in background thread for {self}: {e}")
else:
raise RuntimeError(f"{self} exceeded maximum consecutive read failures.") from e
def _start_read_thread(self) -> None: def _start_read_thread(self) -> None:
"""Starts or restarts the background read thread if it's not running.""" """Starts or restarts the background read thread if it's not running."""
self._stop_read_thread() if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=0.1)
if self.stop_event is not None:
self.stop_event.set()
self.stop_event = Event() self.stop_event = Event()
self.thread = Thread(target=self._read_loop, args=(), name=f"{self}_read_loop") self.thread = Thread(target=self._read_loop, args=(), name=f"{self}_read_loop")
@@ -522,14 +498,7 @@ class RealSenseCamera(Camera):
self.thread = None self.thread = None
self.stop_event = None self.stop_event = None
with self.frame_lock:
self.latest_color_frame = None
self.latest_depth_frame = None
self.latest_timestamp = None
self.new_frame_event.clear()
# NOTE(Steven): Missing implementation for depth for now # NOTE(Steven): Missing implementation for depth for now
@check_if_not_connected
def async_read(self, timeout_ms: float = 200) -> NDArray[Any]: def async_read(self, timeout_ms: float = 200) -> NDArray[Any]:
""" """
Reads the latest available frame data (color) asynchronously. Reads the latest available frame data (color) asynchronously.
@@ -537,7 +506,6 @@ class RealSenseCamera(Camera):
This method retrieves the most recent color frame captured by the background This method retrieves the most recent color frame captured by the background
read thread. It does not block waiting for the camera hardware directly, read thread. It does not block waiting for the camera hardware directly,
but may wait up to timeout_ms for the background thread to provide a frame. but may wait up to timeout_ms for the background thread to provide a frame.
It is “best effort” under high FPS.
Args: Args:
timeout_ms (float): Maximum time in milliseconds to wait for a frame timeout_ms (float): Maximum time in milliseconds to wait for a frame
@@ -552,18 +520,21 @@ class RealSenseCamera(Camera):
TimeoutError: If no frame data becomes available within the specified timeout. TimeoutError: If no frame data becomes available within the specified timeout.
RuntimeError: If the background thread died unexpectedly or another error occurs. RuntimeError: If the background thread died unexpectedly or another error occurs.
""" """
if not self.is_connected:
raise DeviceNotConnectedError(f"{self} is not connected.")
if self.thread is None or not self.thread.is_alive(): if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.") self._start_read_thread()
if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0): if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0):
thread_alive = self.thread is not None and self.thread.is_alive()
raise TimeoutError( raise TimeoutError(
f"Timed out waiting for frame from camera {self} after {timeout_ms} ms. " f"Timed out waiting for frame from camera {self} after {timeout_ms} ms. "
f"Read thread alive: {self.thread.is_alive()}." f"Read thread alive: {thread_alive}."
) )
with self.frame_lock: with self.frame_lock:
frame = self.latest_color_frame frame = self.latest_frame
self.new_frame_event.clear() self.new_frame_event.clear()
if frame is None: if frame is None:
@@ -571,42 +542,6 @@ class RealSenseCamera(Camera):
return frame return frame
# NOTE(Steven): Missing implementation for depth for now
@check_if_not_connected
def read_latest(self, max_age_ms: int = 500) -> NDArray[Any]:
"""Return the most recent (color) frame captured immediately (Peeking).
This method is non-blocking and returns whatever is currently in the
memory buffer. The frame may be stale,
meaning it could have been captured a while ago (hanging camera scenario e.g.).
Returns:
NDArray[Any]: The frame image (numpy array).
Raises:
TimeoutError: If the latest frame is older than `max_age_ms`.
DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If the camera is connected but has not captured any frames yet.
"""
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
with self.frame_lock:
frame = self.latest_color_frame
timestamp = self.latest_timestamp
if frame is None or timestamp is None:
raise RuntimeError(f"{self} has not captured any frames yet.")
age_ms = (time.perf_counter() - timestamp) * 1e3
if age_ms > max_age_ms:
raise TimeoutError(
f"{self} latest frame is too old: {age_ms:.1f} ms (max allowed: {max_age_ms} ms)."
)
return frame
def disconnect(self) -> None: def disconnect(self) -> None:
""" """
Disconnects from the camera, stops the pipeline, and cleans up resources. Disconnects from the camera, stops the pipeline, and cleans up resources.
@@ -630,10 +565,4 @@ class RealSenseCamera(Camera):
self.rs_pipeline = None self.rs_pipeline = None
self.rs_profile = None self.rs_profile = None
with self.frame_lock:
self.latest_color_frame = None
self.latest_depth_frame = None
self.latest_timestamp = None
self.new_frame_event.clear()
logger.info(f"{self} disconnected.") logger.info(f"{self} disconnected.")
@@ -60,8 +60,20 @@ class RealSenseCameraConfig(CameraConfig):
warmup_s: int = 1 warmup_s: int = 1
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.color_mode = ColorMode(self.color_mode) if self.color_mode not in (ColorMode.RGB, ColorMode.BGR):
self.rotation = Cv2Rotation(self.rotation) raise ValueError(
f"`color_mode` is expected to be {ColorMode.RGB.value} or {ColorMode.BGR.value}, but {self.color_mode} is provided."
)
if self.rotation not in (
Cv2Rotation.NO_ROTATION,
Cv2Rotation.ROTATE_90,
Cv2Rotation.ROTATE_180,
Cv2Rotation.ROTATE_270,
):
raise ValueError(
f"`rotation` is expected to be in {(Cv2Rotation.NO_ROTATION, Cv2Rotation.ROTATE_90, Cv2Rotation.ROTATE_180, Cv2Rotation.ROTATE_270)}, but {self.rotation} is provided."
)
values = (self.fps, self.width, self.height) values = (self.fps, self.width, self.height)
if any(v is not None for v in values) and any(v is None for v in values): if any(v is not None for v in values) and any(v is None for v in values):
+12
View File
@@ -14,6 +14,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import platform
from typing import cast from typing import cast
from lerobot.utils.import_utils import make_device_from_device_class from lerobot.utils.import_utils import make_device_from_device_class
@@ -67,3 +68,14 @@ def get_cv2_rotation(rotation: Cv2Rotation) -> int | None:
return int(cv2.ROTATE_90_COUNTERCLOCKWISE) return int(cv2.ROTATE_90_COUNTERCLOCKWISE)
else: else:
return None return None
def get_cv2_backend() -> int:
import cv2
if platform.system() == "Windows":
return int(cv2.CAP_MSMF) # Use MSMF for Windows instead of AVFOUNDATION
# elif platform.system() == "Darwin": # macOS
# return cv2.CAP_AVFOUNDATION
else: # Linux and others
return int(cv2.CAP_ANY)
+32 -182
View File
@@ -34,8 +34,7 @@ import cv2
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError
from lerobot.utils.errors import DeviceNotConnectedError
from ..camera import Camera from ..camera import Camera
from ..configs import ColorMode from ..configs import ColorMode
@@ -46,12 +45,6 @@ logger = logging.getLogger(__name__)
class ZMQCamera(Camera): class ZMQCamera(Camera):
""" """
Manages camera interactions via ZeroMQ for receiving frames from a remote server.
This class connects to a ZMQ Publisher, subscribes to frame topics, and decodes
incoming JSON messages containing Base64 encoded images. It supports both
synchronous and asynchronous frame reading patterns.
Example usage: Example usage:
```python ```python
from lerobot.cameras.zmq import ZMQCamera, ZMQCameraConfig from lerobot.cameras.zmq import ZMQCamera, ZMQCameraConfig
@@ -59,16 +52,7 @@ class ZMQCamera(Camera):
config = ZMQCameraConfig(server_address="192.168.123.164", port=5555, camera_name="head_camera") config = ZMQCameraConfig(server_address="192.168.123.164", port=5555, camera_name="head_camera")
camera = ZMQCamera(config) camera = ZMQCamera(config)
camera.connect() camera.connect()
frame = camera.read()
# Read 1 frame synchronously (blocking)
color_image = camera.read()
# Read 1 frame asynchronously (waits for new frame with a timeout)
async_image = camera.async_read()
# Get the latest frame immediately (no wait, returns timestamp)
latest_image, timestamp = camera.read_latest()
camera.disconnect() camera.disconnect()
``` ```
""" """
@@ -84,17 +68,14 @@ class ZMQCamera(Camera):
self.color_mode = config.color_mode self.color_mode = config.color_mode
self.timeout_ms = config.timeout_ms self.timeout_ms = config.timeout_ms
# ZMQ Context and Socket
self.context: zmq.Context | None = None self.context: zmq.Context | None = None
self.socket: zmq.Socket | None = None self.socket: zmq.Socket | None = None
self._connected = False self._connected = False
# Threading resources
self.thread: Thread | None = None self.thread: Thread | None = None
self.stop_event: Event | None = None self.stop_event: Event | None = None
self.frame_lock: Lock = Lock() self.frame_lock: Lock = Lock()
self.latest_frame: NDArray[Any] | None = None self.latest_frame: NDArray[Any] | None = None
self.latest_timestamp: float | None = None
self.new_frame_event: Event = Event() self.new_frame_event: Event = Event()
def __str__(self) -> str: def __str__(self) -> str:
@@ -102,17 +83,12 @@ class ZMQCamera(Camera):
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Checks if the ZMQ socket is initialized and connected."""
return self._connected and self.context is not None and self.socket is not None return self._connected and self.context is not None and self.socket is not None
@check_if_already_connected
def connect(self, warmup: bool = True) -> None: def connect(self, warmup: bool = True) -> None:
"""Connect to ZMQ camera server. """Connect to ZMQ camera server."""
if self.is_connected:
Args: raise DeviceAlreadyConnectedError(f"{self} is already connected.")
warmup (bool): If True, waits for the camera to provide at least one
valid frame before returning. Defaults to True.
"""
logger.info(f"Connecting to {self}...") logger.info(f"Connecting to {self}...")
@@ -127,29 +103,18 @@ class ZMQCamera(Camera):
self.socket.connect(f"tcp://{self.server_address}:{self.port}") self.socket.connect(f"tcp://{self.server_address}:{self.port}")
self._connected = True self._connected = True
# Auto-detect resolution if not provided # Auto-detect resolution
if self.width is None or self.height is None: if self.width is None or self.height is None:
# Read directly from hardware because the thread isn't running yet h, w = self.read().shape[:2]
temp_frame = self._read_from_hardware()
h, w = temp_frame.shape[:2]
self.height = h self.height = h
self.width = w self.width = w
logger.info(f"{self} resolution detected: {w}x{h}") logger.info(f"{self} resolution: {w}x{h}")
self._start_read_thread()
logger.info(f"{self} connected.") logger.info(f"{self} connected.")
if warmup: if warmup:
# Ensure we have captured at least one frame via the thread
start_time = time.time()
while time.time() - start_time < (self.config.warmup_s): # Wait a bit more than timeout
self.async_read(timeout_ms=self.config.warmup_s * 1000)
time.sleep(0.1) time.sleep(0.1)
with self.frame_lock:
if self.latest_frame is None:
raise ConnectionError(f"{self} failed to capture frames during warmup.")
except Exception as e: except Exception as e:
self._cleanup() self._cleanup()
raise RuntimeError(f"Failed to connect to {self}: {e}") from e raise RuntimeError(f"Failed to connect to {self}: {e}") from e
@@ -166,14 +131,15 @@ class ZMQCamera(Camera):
@staticmethod @staticmethod
def find_cameras() -> list[dict[str, Any]]: def find_cameras() -> list[dict[str, Any]]:
""" """ZMQ cameras require manual configuration (server address/port)."""
Detection not implemented for ZMQ cameras. These cameras require manual configuration (server address/port). return []
"""
raise NotImplementedError("Camera detection is not implemented for ZMQ cameras.")
def _read_from_hardware(self) -> NDArray[Any]: def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
""" """
Reads a single frame directly from the ZMQ socket. Read a single frame from the ZMQ camera.
Returns:
np.ndarray: Decoded frame (height, width, 3)
""" """
if not self.is_connected or self.socket is None: if not self.is_connected or self.socket is None:
raise DeviceNotConnectedError(f"{self} is not connected.") raise DeviceNotConnectedError(f"{self} is not connected.")
@@ -181,7 +147,6 @@ class ZMQCamera(Camera):
try: try:
message = self.socket.recv_string() message = self.socket.recv_string()
except Exception as e: except Exception as e:
# Check for ZMQ timeout (EAGAIN/Again) without requiring global zmq import
if type(e).__name__ == "Again": if type(e).__name__ == "Again":
raise TimeoutError(f"{self} timeout after {self.timeout_ms}ms") from e raise TimeoutError(f"{self} timeout after {self.timeout_ms}ms") from e
raise raise
@@ -211,114 +176,42 @@ class ZMQCamera(Camera):
return frame return frame
@check_if_not_connected
def read(self, color_mode: ColorMode | None = None) -> NDArray[Any]:
"""
Reads a single frame synchronously from the camera.
This is a blocking call. It waits for the next available frame from the
camera background thread.
Returns:
np.ndarray: Decoded frame (height, width, 3)
"""
start_time = time.perf_counter()
if color_mode is not None:
logger.warning(
f"{self} read() color_mode parameter is deprecated and will be removed in future versions."
)
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
self.new_frame_event.clear()
frame = self.async_read(timeout_ms=10000)
read_duration_ms = (time.perf_counter() - start_time) * 1e3
logger.debug(f"{self} read took: {read_duration_ms:.1f}ms")
return frame
def _read_loop(self) -> None: def _read_loop(self) -> None:
""" while self.stop_event and not self.stop_event.is_set():
Internal loop run by the background thread for asynchronous reading.
"""
if self.stop_event is None:
raise RuntimeError(f"{self}: stop_event is not initialized.")
failure_count = 0
while not self.stop_event.is_set():
try: try:
frame = self._read_from_hardware() frame = self.read()
capture_time = time.perf_counter()
with self.frame_lock: with self.frame_lock:
self.latest_frame = frame self.latest_frame = frame
self.latest_timestamp = capture_time
self.new_frame_event.set() self.new_frame_event.set()
failure_count = 0
except DeviceNotConnectedError: except DeviceNotConnectedError:
break break
except (TimeoutError, Exception) as e: except TimeoutError:
if failure_count <= 10: pass
failure_count += 1 except Exception as e:
logger.warning(f"Read error: {e}") logger.warning(f"Read error: {e}")
else:
raise RuntimeError(f"{self} exceeded maximum consecutive read failures.") from e
def _start_read_thread(self) -> None: def _start_read_thread(self) -> None:
if self.stop_event is not None: if self.thread and self.thread.is_alive():
self.stop_event.set() return
if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=2.0)
with self.frame_lock:
self.latest_frame = None
self.latest_timestamp = None
self.new_frame_event.clear()
self.stop_event = Event() self.stop_event = Event()
self.thread = Thread(target=self._read_loop, daemon=True, name=f"{self}_read_loop") self.thread = Thread(target=self._read_loop, daemon=True)
self.thread.start() self.thread.start()
time.sleep(0.1)
def _stop_read_thread(self) -> None: def _stop_read_thread(self) -> None:
if self.stop_event is not None: if self.stop_event:
self.stop_event.set() self.stop_event.set()
if self.thread and self.thread.is_alive():
if self.thread is not None and self.thread.is_alive():
self.thread.join(timeout=2.0) self.thread.join(timeout=2.0)
self.thread = None self.thread = None
self.stop_event = None self.stop_event = None
with self.frame_lock: def async_read(self, timeout_ms: float = 10000) -> NDArray[Any]:
self.latest_frame = None """Read latest frame asynchronously (non-blocking)."""
self.latest_timestamp = None if not self.is_connected:
self.new_frame_event.clear() raise DeviceNotConnectedError(f"{self} is not connected.")
@check_if_not_connected if not self.thread or not self.thread.is_alive():
def async_read(self, timeout_ms: float = 200) -> NDArray[Any]: self._start_read_thread()
"""
Reads the latest available frame asynchronously.
Args:
timeout_ms (float): Maximum time in milliseconds to wait for a frame
to become available. Defaults to 200ms.
Returns:
np.ndarray: The latest captured frame.
Raises:
DeviceNotConnectedError: If the camera is not connected.
TimeoutError: If no frame data becomes available within the specified timeout.
RuntimeError: If the background thread is not running.
"""
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0): if not self.new_frame_event.wait(timeout=timeout_ms / 1000.0):
raise TimeoutError(f"{self} async_read timeout after {timeout_ms}ms") raise TimeoutError(f"{self} async_read timeout after {timeout_ms}ms")
@@ -332,54 +225,11 @@ class ZMQCamera(Camera):
return frame return frame
@check_if_not_connected
def read_latest(self, max_age_ms: int = 1000) -> NDArray[Any]:
"""Return the most recent frame captured immediately (Peeking).
This method is non-blocking and returns whatever is currently in the
memory buffer. The frame may be stale,
meaning it could have been captured a while ago (hanging camera scenario e.g.).
Returns:
NDArray[Any]: The frame image (numpy array).
Raises:
TimeoutError: If the latest frame is older than `max_age_ms`.
DeviceNotConnectedError: If the camera is not connected.
RuntimeError: If the camera is connected but has not captured any frames yet.
"""
if self.thread is None or not self.thread.is_alive():
raise RuntimeError(f"{self} read thread is not running.")
with self.frame_lock:
frame = self.latest_frame
timestamp = self.latest_timestamp
if frame is None or timestamp is None:
raise RuntimeError(f"{self} has not captured any frames yet.")
age_ms = (time.perf_counter() - timestamp) * 1e3
if age_ms > max_age_ms:
raise TimeoutError(
f"{self} latest frame is too old: {age_ms:.1f} ms (max allowed: {max_age_ms} ms)."
)
return frame
def disconnect(self) -> None: def disconnect(self) -> None:
"""Disconnect from ZMQ camera.""" """Disconnect from ZMQ camera."""
if not self.is_connected and self.thread is None: if not self.is_connected and not self.thread:
raise DeviceNotConnectedError(f"{self} not connected.") raise DeviceNotConnectedError(f"{self} not connected.")
if self.thread is not None:
self._stop_read_thread() self._stop_read_thread()
self._cleanup() self._cleanup()
with self.frame_lock:
self.latest_frame = None
self.latest_timestamp = None
self.new_frame_event.clear()
logger.info(f"{self} disconnected.") logger.info(f"{self} disconnected.")
+4 -2
View File
@@ -29,10 +29,12 @@ class ZMQCameraConfig(CameraConfig):
camera_name: str = "zmq_camera" camera_name: str = "zmq_camera"
color_mode: ColorMode = ColorMode.RGB color_mode: ColorMode = ColorMode.RGB
timeout_ms: int = 5000 timeout_ms: int = 5000
warmup_s: int = 1
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.color_mode = ColorMode(self.color_mode) if self.color_mode not in (ColorMode.RGB, ColorMode.BGR):
raise ValueError(
f"`color_mode` is expected to be {ColorMode.RGB.value} or {ColorMode.BGR.value}, but {self.color_mode} is provided."
)
if self.timeout_ms <= 0: if self.timeout_ms <= 0:
raise ValueError(f"`timeout_ms` must be positive, but {self.timeout_ms} is provided.") raise ValueError(f"`timeout_ms` must be positive, but {self.timeout_ms} is provided.")
+1 -1
View File
@@ -27,7 +27,7 @@ class DatasetConfig:
# "dataset_index" into the returned item. The index mapping is made according to the order in which the # "dataset_index" into the returned item. The index mapping is made according to the order in which the
# datasets are provided. # datasets are provided.
repo_id: str repo_id: str
# Root directory where the dataset will be stored (e.g. 'dataset/path'). If None, defaults to $HF_LEROBOT_HOME/repo_id. # Root directory where the dataset will be stored (e.g. 'dataset/path').
root: str | None = None root: str | None = None
episodes: list[int] | None = None episodes: list[int] | None = None
image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig) image_transforms: ImageTransformsConfig = field(default_factory=ImageTransformsConfig)
+12 -6
View File
@@ -45,12 +45,12 @@ class PreTrainedConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC): # type: igno
Args: Args:
n_obs_steps: Number of environment steps worth of observations to pass to the policy (takes the n_obs_steps: Number of environment steps worth of observations to pass to the policy (takes the
current step and additional steps going back). current step and additional steps going back).
input_features: A dictionary defining the PolicyFeature of the input data for the policy. The key represents input_shapes: A dictionary defining the shapes of the input data for the policy.
the input data name, and the value is PolicyFeature, which consists of FeatureType and shape attributes. output_shapes: A dictionary defining the shapes of the output data for the policy.
output_features: A dictionary defining the PolicyFeature of the output data for the policy. The key represents input_normalization_modes: A dictionary with key representing the modality and the value specifies the
the output data name, and the value is PolicyFeature, which consists of FeatureType and shape attributes. normalization mode to apply.
normalization_mapping: A dictionary that maps from a str value of FeatureType (e.g., "STATE", "VISUAL") to output_normalization_modes: Similar dictionary as `input_normalization_modes`, but to unnormalize to
a corresponding NormalizationMode (e.g., NormalizationMode.MIN_MAX) the original scale.
""" """
n_obs_steps: int = 1 n_obs_steps: int = 1
@@ -151,6 +151,12 @@ class PreTrainedConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC): # type: igno
return {} return {}
return {key: ft for key, ft in self.input_features.items() if ft.type is FeatureType.VISUAL} return {key: ft for key, ft in self.input_features.items() if ft.type is FeatureType.VISUAL}
@property
def audio_features(self) -> dict[str, PolicyFeature]:
if not self.input_features:
return {}
return {key: ft for key, ft in self.input_features.items() if ft.type is FeatureType.AUDIO}
@property @property
def action_feature(self) -> PolicyFeature | None: def action_feature(self) -> PolicyFeature | None:
if not self.output_features: if not self.output_features:
+1
View File
@@ -20,6 +20,7 @@ from enum import Enum
class FeatureType(str, Enum): class FeatureType(str, Enum):
STATE = "STATE" STATE = "STATE"
VISUAL = "VISUAL" VISUAL = "VISUAL"
AUDIO = "AUDIO"
ENV = "ENV" ENV = "ENV"
ACTION = "ACTION" ACTION = "ACTION"
REWARD = "REWARD" REWARD = "REWARD"
+165 -90
View File
@@ -26,6 +26,8 @@ import tqdm
from lerobot.datasets.compute_stats import aggregate_stats from lerobot.datasets.compute_stats import aggregate_stats
from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata
from lerobot.datasets.utils import ( from lerobot.datasets.utils import (
DEFAULT_AUDIO_FILE_SIZE_IN_MB,
DEFAULT_AUDIO_PATH,
DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_SIZE,
DEFAULT_DATA_FILE_SIZE_IN_MB, DEFAULT_DATA_FILE_SIZE_IN_MB,
DEFAULT_DATA_PATH, DEFAULT_DATA_PATH,
@@ -41,7 +43,7 @@ from lerobot.datasets.utils import (
write_stats, write_stats,
write_tasks, write_tasks,
) )
from lerobot.datasets.video_utils import concatenate_video_files, get_video_duration_in_s from lerobot.datasets.video_utils import concatenate_media_files, get_media_duration_in_s
def validate_all_metadata(all_metadata: list[LeRobotDatasetMetadata]): def validate_all_metadata(all_metadata: list[LeRobotDatasetMetadata]):
@@ -110,70 +112,26 @@ def update_meta_data(
meta_idx, meta_idx,
data_idx, data_idx,
videos_idx, videos_idx,
audios_idx,
): ):
"""Updates metadata DataFrame with new chunk, file, and timestamp indices. """Updates metadata DataFrame with new chunk, file, and timestamp indices.
Adjusts all indices and timestamps to account for previously aggregated Adjusts all indices and timestamps to account for previously aggregated
data and videos in the destination dataset. data and videos in the destination dataset.
For data file indices, uses the 'src_to_dst' mapping from aggregate_data()
to correctly map source file indices to their destination locations.
Args: Args:
df: DataFrame containing the metadata to be updated. df: DataFrame containing the metadata to be updated.
dst_meta: Destination dataset metadata. dst_meta: Destination dataset metadata.
meta_idx: Dictionary containing current metadata chunk and file indices. meta_idx: Dictionary containing current metadata chunk and file indices.
data_idx: Dictionary containing current data chunk and file indices. data_idx: Dictionary containing current data chunk and file indices.
videos_idx: Dictionary containing current video indices and timestamps. videos_idx: Dictionary containing current video indices and timestamps.
audios_idx: Dictionary containing current audio indices and timestamps.
Returns: Returns:
pd.DataFrame: Updated DataFrame with adjusted indices and timestamps. pd.DataFrame: Updated DataFrame with adjusted indices and timestamps.
""" """
df["meta/episodes/chunk_index"] = df["meta/episodes/chunk_index"] + meta_idx["chunk"] df["meta/episodes/chunk_index"] = df["meta/episodes/chunk_index"] + meta_idx["chunk"]
df["meta/episodes/file_index"] = df["meta/episodes/file_index"] + meta_idx["file"] df["meta/episodes/file_index"] = df["meta/episodes/file_index"] + meta_idx["file"]
# Update data file indices using source-to-destination mapping
# This is critical for handling datasets that are already results of a merge
data_src_to_dst = data_idx.get("src_to_dst", {})
if data_src_to_dst:
# Store original indices for lookup
df["_orig_data_chunk"] = df["data/chunk_index"].copy()
df["_orig_data_file"] = df["data/file_index"].copy()
# Vectorized mapping from (src_chunk, src_file) to (dst_chunk, dst_file)
# This is much faster than per-row iteration for large metadata tables
mapping_index = pd.MultiIndex.from_tuples(
list(data_src_to_dst.keys()),
names=["chunk_index", "file_index"],
)
mapping_values = list(data_src_to_dst.values())
mapping_df = pd.DataFrame(
mapping_values,
index=mapping_index,
columns=["dst_chunk", "dst_file"],
)
# Construct a MultiIndex for each row based on original data indices
row_index = pd.MultiIndex.from_arrays(
[df["_orig_data_chunk"], df["_orig_data_file"]],
names=["chunk_index", "file_index"],
)
# Align mapping to rows; missing keys fall back to the default destination
reindexed = mapping_df.reindex(row_index)
reindexed[["dst_chunk", "dst_file"]] = reindexed[["dst_chunk", "dst_file"]].fillna(
{"dst_chunk": data_idx["chunk"], "dst_file": data_idx["file"]}
)
# Assign mapped destination indices back to the DataFrame
df["data/chunk_index"] = reindexed["dst_chunk"].to_numpy()
df["data/file_index"] = reindexed["dst_file"].to_numpy()
# Clean up temporary columns
df = df.drop(columns=["_orig_data_chunk", "_orig_data_file"])
else:
# Fallback to simple offset (backward compatibility for single-file sources)
df["data/chunk_index"] = df["data/chunk_index"] + data_idx["chunk"] df["data/chunk_index"] = df["data/chunk_index"] + data_idx["chunk"]
df["data/file_index"] = df["data/file_index"] + data_idx["file"] df["data/file_index"] = df["data/file_index"] + data_idx["file"]
for key, video_idx in videos_idx.items(): for key, video_idx in videos_idx.items():
@@ -191,7 +149,8 @@ def update_meta_data(
if src_to_dst: if src_to_dst:
# Map each episode to its correct destination file and apply offset # Map each episode to its correct destination file and apply offset
for idx in df.index: for idx in df.index:
src_key = (df.at[idx, "_orig_chunk"], df.at[idx, "_orig_file"]) # Convert to Python int to avoid numpy type mismatch in dict lookup
src_key = (int(df.at[idx, "_orig_chunk"]), int(df.at[idx, "_orig_file"]))
# Get destination chunk/file for this source file # Get destination chunk/file for this source file
dst_chunk, dst_file = src_to_dst.get(src_key, (video_idx["chunk"], video_idx["file"])) dst_chunk, dst_file = src_to_dst.get(src_key, (video_idx["chunk"], video_idx["file"]))
@@ -207,7 +166,8 @@ def update_meta_data(
df[orig_chunk_col] = video_idx["chunk"] df[orig_chunk_col] = video_idx["chunk"]
df[orig_file_col] = video_idx["file"] df[orig_file_col] = video_idx["file"]
for idx in df.index: for idx in df.index:
src_key = (df.at[idx, "_orig_chunk"], df.at[idx, "_orig_file"]) # Convert to Python int to avoid numpy type mismatch in dict lookup
src_key = (int(df.at[idx, "_orig_chunk"]), int(df.at[idx, "_orig_file"]))
offset = src_to_offset.get(src_key, 0) offset = src_to_offset.get(src_key, 0)
df.at[idx, f"videos/{key}/from_timestamp"] += offset df.at[idx, f"videos/{key}/from_timestamp"] += offset
df.at[idx, f"videos/{key}/to_timestamp"] += offset df.at[idx, f"videos/{key}/to_timestamp"] += offset
@@ -223,6 +183,36 @@ def update_meta_data(
# Clean up temporary columns # Clean up temporary columns
df = df.drop(columns=["_orig_chunk", "_orig_file"]) df = df.drop(columns=["_orig_chunk", "_orig_file"])
for key, audio_idx in audios_idx.items():
# Store original audio file indices before updating
orig_chunk_col = f"audio/{key}/chunk_index"
orig_file_col = f"audio/{key}/file_index"
df["_orig_chunk"] = df[orig_chunk_col].copy()
df["_orig_file"] = df[orig_file_col].copy()
# Update chunk and file indices to point to destination
df[orig_chunk_col] = audio_idx["chunk"]
df[orig_file_col] = audio_idx["file"]
# Apply per-source-file timestamp offsets
src_to_offset = audio_idx.get("src_to_offset", {})
if src_to_offset:
# Apply offset based on original source file
for idx in df.index:
src_key = (df.at[idx, "_orig_chunk"], df.at[idx, "_orig_file"])
offset = src_to_offset.get(src_key, 0)
df.at[idx, f"audio/{key}/from_timestamp"] += offset
df.at[idx, f"audio/{key}/to_timestamp"] += offset
else:
# Fallback to simple offset (for backward compatibility)
df[f"audio/{key}/from_timestamp"] = (
df[f"audio/{key}/from_timestamp"] + audio_idx["latest_duration"]
)
df[f"audio/{key}/to_timestamp"] = df[f"audio/{key}/to_timestamp"] + audio_idx["latest_duration"]
# Clean up temporary columns
df = df.drop(columns=["_orig_chunk", "_orig_file"])
df["dataset_from_index"] = df["dataset_from_index"] + dst_meta.info["total_frames"] df["dataset_from_index"] = df["dataset_from_index"] + dst_meta.info["total_frames"]
df["dataset_to_index"] = df["dataset_to_index"] + dst_meta.info["total_frames"] df["dataset_to_index"] = df["dataset_to_index"] + dst_meta.info["total_frames"]
df["episode_index"] = df["episode_index"] + dst_meta.info["total_episodes"] df["episode_index"] = df["episode_index"] + dst_meta.info["total_episodes"]
@@ -237,6 +227,7 @@ def aggregate_datasets(
aggr_root: Path | None = None, aggr_root: Path | None = None,
data_files_size_in_mb: float | None = None, data_files_size_in_mb: float | None = None,
video_files_size_in_mb: float | None = None, video_files_size_in_mb: float | None = None,
audio_files_size_in_mb: float | None = None,
chunk_size: int | None = None, chunk_size: int | None = None,
): ):
"""Aggregates multiple LeRobot datasets into a single unified dataset. """Aggregates multiple LeRobot datasets into a single unified dataset.
@@ -254,6 +245,7 @@ def aggregate_datasets(
aggr_root: Optional root path for the aggregated dataset. aggr_root: Optional root path for the aggregated dataset.
data_files_size_in_mb: Maximum size for data files in MB (defaults to DEFAULT_DATA_FILE_SIZE_IN_MB) data_files_size_in_mb: Maximum size for data files in MB (defaults to DEFAULT_DATA_FILE_SIZE_IN_MB)
video_files_size_in_mb: Maximum size for video files in MB (defaults to DEFAULT_VIDEO_FILE_SIZE_IN_MB) video_files_size_in_mb: Maximum size for video files in MB (defaults to DEFAULT_VIDEO_FILE_SIZE_IN_MB)
audio_files_size_in_mb: Maximum size for audio files in MB (defaults to DEFAULT_AUDIO_FILE_SIZE_IN_MB)
chunk_size: Maximum number of files per chunk (defaults to DEFAULT_CHUNK_SIZE) chunk_size: Maximum number of files per chunk (defaults to DEFAULT_CHUNK_SIZE)
""" """
logging.info("Start aggregate_datasets") logging.info("Start aggregate_datasets")
@@ -262,6 +254,8 @@ def aggregate_datasets(
data_files_size_in_mb = DEFAULT_DATA_FILE_SIZE_IN_MB data_files_size_in_mb = DEFAULT_DATA_FILE_SIZE_IN_MB
if video_files_size_in_mb is None: if video_files_size_in_mb is None:
video_files_size_in_mb = DEFAULT_VIDEO_FILE_SIZE_IN_MB video_files_size_in_mb = DEFAULT_VIDEO_FILE_SIZE_IN_MB
if audio_files_size_in_mb is None:
audio_files_size_in_mb = DEFAULT_AUDIO_FILE_SIZE_IN_MB
if chunk_size is None: if chunk_size is None:
chunk_size = DEFAULT_CHUNK_SIZE chunk_size = DEFAULT_CHUNK_SIZE
@@ -274,6 +268,7 @@ def aggregate_datasets(
) )
fps, robot_type, features = validate_all_metadata(all_metadata) fps, robot_type, features = validate_all_metadata(all_metadata)
video_keys = [key for key in features if features[key]["dtype"] == "video"] video_keys = [key for key in features if features[key]["dtype"] == "video"]
audio_keys = [key for key in features if features[key]["dtype"] == "audio"]
dst_meta = LeRobotDatasetMetadata.create( dst_meta = LeRobotDatasetMetadata.create(
repo_id=aggr_repo_id, repo_id=aggr_repo_id,
@@ -285,31 +280,30 @@ def aggregate_datasets(
chunks_size=chunk_size, chunks_size=chunk_size,
data_files_size_in_mb=data_files_size_in_mb, data_files_size_in_mb=data_files_size_in_mb,
video_files_size_in_mb=video_files_size_in_mb, video_files_size_in_mb=video_files_size_in_mb,
audio_files_size_in_mb=audio_files_size_in_mb,
) )
logging.info("Find all tasks") logging.info("Find all tasks")
unique_tasks = pd.concat([m.tasks for m in all_metadata]).index.unique() unique_tasks = pd.concat([m.tasks for m in all_metadata]).index.unique()
dst_meta.tasks = pd.DataFrame( dst_meta.tasks = pd.DataFrame({"task_index": range(len(unique_tasks))}, index=unique_tasks)
{"task_index": range(len(unique_tasks))}, index=pd.Index(unique_tasks, name="task")
)
meta_idx = {"chunk": 0, "file": 0} meta_idx = {"chunk": 0, "file": 0}
data_idx = {"chunk": 0, "file": 0} data_idx = {"chunk": 0, "file": 0}
videos_idx = { videos_idx = {
key: {"chunk": 0, "file": 0, "latest_duration": 0, "episode_duration": 0} for key in video_keys key: {"chunk": 0, "file": 0, "latest_duration": 0, "episode_duration": 0} for key in video_keys
} }
audios_idx = {
key: {"chunk": 0, "file": 0, "latest_duration": 0, "episode_duration": 0} for key in audio_keys
}
dst_meta.episodes = {} dst_meta.episodes = {}
for src_meta in tqdm.tqdm(all_metadata, desc="Copy data and videos"): for src_meta in tqdm.tqdm(all_metadata, desc="Copy data and videos"):
videos_idx = aggregate_videos(src_meta, dst_meta, videos_idx, video_files_size_in_mb, chunk_size) videos_idx = aggregate_videos(src_meta, dst_meta, videos_idx, video_files_size_in_mb, chunk_size)
audios_idx = aggregate_audio(src_meta, dst_meta, audios_idx, audio_files_size_in_mb, chunk_size)
data_idx = aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_size) data_idx = aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_size)
meta_idx = aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx) meta_idx = aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx, audios_idx)
# Clear the src_to_dst mapping after processing each source dataset
# to avoid interference between different source datasets
data_idx.pop("src_to_dst", None)
dst_meta.info["total_episodes"] += src_meta.total_episodes dst_meta.info["total_episodes"] += src_meta.total_episodes
dst_meta.info["total_frames"] += src_meta.total_frames dst_meta.info["total_frames"] += src_meta.total_frames
@@ -361,6 +355,10 @@ def aggregate_videos(src_meta, dst_meta, videos_idx, video_files_size_in_mb, chu
dst_file_durations = video_idx["dst_file_durations"] dst_file_durations = video_idx["dst_file_durations"]
for src_chunk_idx, src_file_idx in unique_chunk_file_pairs: for src_chunk_idx, src_file_idx in unique_chunk_file_pairs:
# Convert to Python int to ensure consistent dict keys
src_chunk_idx = int(src_chunk_idx)
src_file_idx = int(src_file_idx)
src_path = src_meta.root / DEFAULT_VIDEO_PATH.format( src_path = src_meta.root / DEFAULT_VIDEO_PATH.format(
video_key=key, video_key=key,
chunk_index=src_chunk_idx, chunk_index=src_chunk_idx,
@@ -373,7 +371,7 @@ def aggregate_videos(src_meta, dst_meta, videos_idx, video_files_size_in_mb, chu
file_index=file_idx, file_index=file_idx,
) )
src_duration = get_video_duration_in_s(src_path) src_duration = get_media_duration_in_s(src_path, media_type="video")
dst_key = (chunk_idx, file_idx) dst_key = (chunk_idx, file_idx)
if not dst_path.exists(): if not dst_path.exists():
@@ -412,7 +410,7 @@ def aggregate_videos(src_meta, dst_meta, videos_idx, video_files_size_in_mb, chu
current_dst_duration = dst_file_durations.get(dst_key, 0) current_dst_duration = dst_file_durations.get(dst_key, 0)
videos_idx[key]["src_to_offset"][(src_chunk_idx, src_file_idx)] = current_dst_duration videos_idx[key]["src_to_offset"][(src_chunk_idx, src_file_idx)] = current_dst_duration
videos_idx[key]["src_to_dst"][(src_chunk_idx, src_file_idx)] = dst_key videos_idx[key]["src_to_dst"][(src_chunk_idx, src_file_idx)] = dst_key
concatenate_video_files( concatenate_media_files(
[dst_path, src_path], [dst_path, src_path],
dst_path, dst_path,
) )
@@ -427,22 +425,111 @@ def aggregate_videos(src_meta, dst_meta, videos_idx, video_files_size_in_mb, chu
return videos_idx return videos_idx
def aggregate_audio(src_meta, dst_meta, audios_idx, audio_files_size_in_mb, chunk_size):
"""Aggregates audio files from a source dataset into the destination dataset.
Handles audio file concatenation and rotation based on file size limits.
Creates new audio files when size limits are exceeded.
Args:
src_meta: Source dataset metadata.
dst_meta: Destination dataset metadata.
audio_idx: Dictionary tracking audio chunk and file indices.
audio_files_size_in_mb: Maximum size for audio files in MB (defaults to DEFAULT_AUDIO_FILE_SIZE_IN_MB)
chunk_size: Maximum number of files per chunk (defaults to DEFAULT_CHUNK_SIZE)
Returns:
dict: Updated audio_idx with current chunk and file indices.
"""
for key in audios_idx:
audios_idx[key]["episode_duration"] = 0
# Track offset for each source (chunk, file) pair
audios_idx[key]["src_to_offset"] = {}
for key, audio_idx in audios_idx.items():
unique_chunk_file_pairs = {
(chunk, file)
for chunk, file in zip(
src_meta.episodes[f"audio/{key}/chunk_index"],
src_meta.episodes[f"audio/{key}/file_index"],
strict=False,
)
}
unique_chunk_file_pairs = sorted(unique_chunk_file_pairs)
chunk_idx = audio_idx["chunk"]
file_idx = audio_idx["file"]
current_offset = audio_idx["latest_duration"]
for src_chunk_idx, src_file_idx in unique_chunk_file_pairs:
src_path = src_meta.root / DEFAULT_AUDIO_PATH.format(
audio_key=key,
chunk_index=src_chunk_idx,
file_index=src_file_idx,
)
dst_path = dst_meta.root / DEFAULT_AUDIO_PATH.format(
audio_key=key,
chunk_index=chunk_idx,
file_index=file_idx,
)
src_duration = get_media_duration_in_s(src_path, media_type="audio")
if not dst_path.exists():
# Store offset before incrementing
audios_idx[key]["src_to_offset"][(src_chunk_idx, src_file_idx)] = current_offset
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(str(src_path), str(dst_path))
audios_idx[key]["episode_duration"] += src_duration
current_offset += src_duration
continue
# Check file sizes before appending
src_size = get_file_size_in_mb(src_path)
dst_size = get_file_size_in_mb(dst_path)
if dst_size + src_size >= audio_files_size_in_mb:
# Rotate to a new file, this source becomes start of new destination
# So its offset should be 0
audios_idx[key]["src_to_offset"][(src_chunk_idx, src_file_idx)] = 0
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, chunk_size)
dst_path = dst_meta.root / DEFAULT_AUDIO_PATH.format(
audio_key=key,
chunk_index=chunk_idx,
file_index=file_idx,
)
dst_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(str(src_path), str(dst_path))
# Reset offset for next file
current_offset = src_duration
else:
# Append to existing video file - use current accumulated offset
audios_idx[key]["src_to_offset"][(src_chunk_idx, src_file_idx)] = current_offset
concatenate_media_files(
[dst_path, src_path],
dst_path,
)
current_offset += src_duration
audios_idx[key]["episode_duration"] += src_duration
audios_idx[key]["chunk"] = chunk_idx
audios_idx[key]["file"] = file_idx
return audios_idx
def aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_size): def aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_size):
"""Aggregates data chunks from a source dataset into the destination dataset. """Aggregates data chunks from a source dataset into the destination dataset.
Reads source data files, updates indices to match the aggregated dataset, Reads source data files, updates indices to match the aggregated dataset,
and writes them to the destination with proper file rotation. and writes them to the destination with proper file rotation.
Tracks a `src_to_dst` mapping from source (chunk, file) to destination (chunk, file)
which is critical for correctly updating episode metadata when source datasets
have multiple data files (e.g., from a previous merge operation).
Args: Args:
src_meta: Source dataset metadata. src_meta: Source dataset metadata.
dst_meta: Destination dataset metadata. dst_meta: Destination dataset metadata.
data_idx: Dictionary tracking data chunk and file indices. data_idx: Dictionary tracking data chunk and file indices.
data_files_size_in_mb: Maximum size for data files in MB.
chunk_size: Maximum number of files per chunk.
Returns: Returns:
dict: Updated data_idx with current chunk and file indices. dict: Updated data_idx with current chunk and file indices.
@@ -460,10 +547,6 @@ def aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_si
# retrieve features schema for proper image typing in parquet # retrieve features schema for proper image typing in parquet
hf_features = get_hf_features_from_features(dst_meta.features) if contains_images else None hf_features = get_hf_features_from_features(dst_meta.features) if contains_images else None
# Track source to destination file mapping for metadata update
# This is critical for handling datasets that are already results of a merge
src_to_dst: dict[tuple[int, int], tuple[int, int]] = {}
for src_chunk_idx, src_file_idx in unique_chunk_file_ids: for src_chunk_idx, src_file_idx in unique_chunk_file_ids:
src_path = src_meta.root / DEFAULT_DATA_PATH.format( src_path = src_meta.root / DEFAULT_DATA_PATH.format(
chunk_index=src_chunk_idx, file_index=src_file_idx chunk_index=src_chunk_idx, file_index=src_file_idx
@@ -476,9 +559,7 @@ def aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_si
df = pd.read_parquet(src_path) df = pd.read_parquet(src_path)
df = update_data_df(df, src_meta, dst_meta) df = update_data_df(df, src_meta, dst_meta)
# Write data and get the actual destination file it was written to data_idx = append_or_create_parquet_file(
# This avoids duplicating the rotation logic here
data_idx, (dst_chunk, dst_file) = append_or_create_parquet_file(
df, df,
src_path, src_path,
data_idx, data_idx,
@@ -490,16 +571,10 @@ def aggregate_data(src_meta, dst_meta, data_idx, data_files_size_in_mb, chunk_si
hf_features=hf_features, hf_features=hf_features,
) )
# Record the mapping from source to actual destination
src_to_dst[(src_chunk_idx, src_file_idx)] = (dst_chunk, dst_file)
# Add the mapping to data_idx for use in metadata update
data_idx["src_to_dst"] = src_to_dst
return data_idx return data_idx
def aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx): def aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx, audios_idx):
"""Aggregates metadata from a source dataset into the destination dataset. """Aggregates metadata from a source dataset into the destination dataset.
Reads source metadata files, updates all indices and timestamps, Reads source metadata files, updates all indices and timestamps,
@@ -511,6 +586,7 @@ def aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx):
meta_idx: Dictionary tracking metadata chunk and file indices. meta_idx: Dictionary tracking metadata chunk and file indices.
data_idx: Dictionary tracking data chunk and file indices. data_idx: Dictionary tracking data chunk and file indices.
videos_idx: Dictionary tracking video indices and timestamps. videos_idx: Dictionary tracking video indices and timestamps.
audios_idx: Dictionary tracking audio indices and timestamps.
Returns: Returns:
dict: Updated meta_idx with current chunk and file indices. dict: Updated meta_idx with current chunk and file indices.
@@ -534,9 +610,10 @@ def aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx):
meta_idx, meta_idx,
data_idx, data_idx,
videos_idx, videos_idx,
audios_idx,
) )
meta_idx, _ = append_or_create_parquet_file( meta_idx = append_or_create_parquet_file(
df, df,
src_path, src_path,
meta_idx, meta_idx,
@@ -550,7 +627,8 @@ def aggregate_metadata(src_meta, dst_meta, meta_idx, data_idx, videos_idx):
# Increment latest_duration by the total duration added from this source dataset # Increment latest_duration by the total duration added from this source dataset
for k in videos_idx: for k in videos_idx:
videos_idx[k]["latest_duration"] += videos_idx[k]["episode_duration"] videos_idx[k]["latest_duration"] += videos_idx[k]["episode_duration"]
for k in audios_idx:
audios_idx[k]["latest_duration"] += audios_idx[k]["episode_duration"]
return meta_idx return meta_idx
@@ -564,7 +642,7 @@ def append_or_create_parquet_file(
contains_images: bool = False, contains_images: bool = False,
aggr_root: Path = None, aggr_root: Path = None,
hf_features: datasets.Features | None = None, hf_features: datasets.Features | None = None,
) -> tuple[dict[str, int], tuple[int, int]]: ):
"""Appends data to an existing parquet file or creates a new one based on size constraints. """Appends data to an existing parquet file or creates a new one based on size constraints.
Manages file rotation when size limits are exceeded to prevent individual files Manages file rotation when size limits are exceeded to prevent individual files
@@ -582,11 +660,9 @@ def append_or_create_parquet_file(
hf_features: Optional HuggingFace Features schema for proper image typing. hf_features: Optional HuggingFace Features schema for proper image typing.
Returns: Returns:
tuple: (updated_idx, (dst_chunk, dst_file)) where updated_idx is the index dict dict: Updated index dictionary with current chunk and file indices.
and (dst_chunk, dst_file) is the actual destination file the data was written to.
""" """
dst_chunk, dst_file = idx["chunk"], idx["file"] dst_path = aggr_root / default_path.format(chunk_index=idx["chunk"], file_index=idx["file"])
dst_path = aggr_root / default_path.format(chunk_index=dst_chunk, file_index=dst_file)
if not dst_path.exists(): if not dst_path.exists():
dst_path.parent.mkdir(parents=True, exist_ok=True) dst_path.parent.mkdir(parents=True, exist_ok=True)
@@ -594,15 +670,14 @@ def append_or_create_parquet_file(
to_parquet_with_hf_images(df, dst_path, features=hf_features) to_parquet_with_hf_images(df, dst_path, features=hf_features)
else: else:
df.to_parquet(dst_path) df.to_parquet(dst_path)
return idx, (dst_chunk, dst_file) return idx
src_size = get_parquet_file_size_in_mb(src_path) src_size = get_parquet_file_size_in_mb(src_path)
dst_size = get_parquet_file_size_in_mb(dst_path) dst_size = get_parquet_file_size_in_mb(dst_path)
if dst_size + src_size >= max_mb: if dst_size + src_size >= max_mb:
idx["chunk"], idx["file"] = update_chunk_file_indices(idx["chunk"], idx["file"], chunk_size) idx["chunk"], idx["file"] = update_chunk_file_indices(idx["chunk"], idx["file"], chunk_size)
dst_chunk, dst_file = idx["chunk"], idx["file"] new_path = aggr_root / default_path.format(chunk_index=idx["chunk"], file_index=idx["file"])
new_path = aggr_root / default_path.format(chunk_index=dst_chunk, file_index=dst_file)
new_path.parent.mkdir(parents=True, exist_ok=True) new_path.parent.mkdir(parents=True, exist_ok=True)
final_df = df final_df = df
target_path = new_path target_path = new_path
@@ -621,7 +696,7 @@ def append_or_create_parquet_file(
else: else:
final_df.to_parquet(target_path) final_df.to_parquet(target_path)
return idx, (dst_chunk, dst_file) return idx
def finalize_aggregation(aggr_meta, all_metadata): def finalize_aggregation(aggr_meta, all_metadata):
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python
# Copyright 2025 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.
import logging
from pathlib import Path
import av
import torch
import torchaudio
import torchcodec
from numpy import ceil
CHANNELS_LAYOUTS_MAPPING = {
1: "mono",
2: "stereo",
3: "2.1",
4: "3.1",
5: "4.1",
6: "5.1",
7: "6.1",
8: "7.1",
16: "hexadecagonal",
24: "22.2",
}
def decode_audio(
audio_path: Path | str,
timestamps: list[float],
duration: float,
start_time_s: float | None = 0.0,
backend: str | None = "torchcodec",
) -> torch.Tensor:
"""
Decodes audio using the specified backend.
Args:
audio_path (Path): Path to the audio file.
timestamps (list[float]): List of (starting) timestamps to extract audio chunks.
duration (float): Duration of the audio chunks in seconds.
backend (str, optional): Backend to use for decoding. Defaults to "torchcodec".
Returns:
torch.Tensor: Decoded audio chunks.
Currently supports torchaudio.
"""
if backend == "torchcodec":
return decode_audio_torchcodec(audio_path, timestamps, duration, start_time_s)
elif backend == "torchaudio":
return decode_audio_torchaudio(audio_path, timestamps, duration, start_time_s)
else:
raise ValueError(f"Unsupported video backend: {backend}")
def decode_audio_torchcodec(
audio_path: Path | str,
timestamps: list[float],
duration: float,
start_time_s: float | None = 0.0,
log_loaded_timestamps: bool = False,
) -> torch.Tensor:
# TODO(CarolinePascal) : add channels selection
audio_decoder = torchcodec.decoders.AudioDecoder(audio_path)
audio_sample_rate = audio_decoder.metadata.sample_rate
audio_channels = audio_decoder.metadata.num_channels
# TODO(CarolinePascal) : assert ts < total record duration
audio_chunks = []
timestamps = [
timestamp + start_time_s for timestamp in timestamps
] # Add an offset of start_time_s to each timestamp
for ts in timestamps:
current_audio_chunk = audio_decoder.get_samples_played_in_range(
start_seconds=max(0.0, ts - duration), stop_seconds=ts
)
current_audio_chunk_data = current_audio_chunk.data
# Case where the requested audio chunk starts before the beginning of the audio stream
if ts - duration < 0:
# No useful audio sample has been recorded
if ts < 1 / audio_sample_rate:
# TODO(CarolinePascal) : add low level white noise instead of zeros ?
current_audio_chunk_data = torch.zeros(
(audio_channels, int(ceil(duration * audio_sample_rate)))
)
# At least one useful audio sample has been recorded
else:
# Pad the beginning of the audio chunk with zeros
# TODO(CarolinePascal) : add low level white noise instead of zeros ?
current_audio_chunk_data = torch.nn.functional.pad(
current_audio_chunk_data,
(int(ceil((duration - ts) * audio_sample_rate)), 0, 0, 0), # left, right, top, bottom
)
if log_loaded_timestamps:
logging.info(
f"audio chunk loaded at timestamp={current_audio_chunk.pts_seconds:.4f} with duration={current_audio_chunk.duration_seconds:.4f}"
)
audio_chunks.append(current_audio_chunk_data)
audio_chunks = torch.stack(audio_chunks)
assert len(timestamps) == len(audio_chunks)
return audio_chunks
def decode_audio_torchaudio(
audio_path: Path | str,
timestamps: list[float],
duration: float,
start_time_s: float | None = 0.0,
log_loaded_timestamps: bool = False,
) -> torch.Tensor:
# TODO(CarolinePascal) : add channels selection
audio_path = str(audio_path)
reader = torchaudio.io.StreamReader(src=audio_path)
audio_sample_rate = reader.get_src_stream_info(reader.default_audio_stream).sample_rate
audio_channels = reader.get_src_stream_info(reader.default_audio_stream).num_channels
# TODO(CarolinePascal) : assert ts < total record duration
# TODO(CarolinePascal) : sort timestamps ?
reader.add_basic_audio_stream(
frames_per_chunk=int(ceil(duration * audio_sample_rate)), # Too much is better than not enough
buffer_chunk_size=-1, # No dropping frames
format="fltp", # Format as float32
)
audio_chunks = []
timestamps = [
timestamp + start_time_s for timestamp in timestamps
] # Add an offset of start_time_s to each timestamp
for ts in timestamps:
reader.seek(max(0.0, ts - duration)) # Default to closest audio sample. Needs to be non-negative !
status = reader.fill_buffer()
if status != 0:
# Should not happen, but just in case
logging.warning("Audio stream reached end of recording before decoding desired timestamps.")
current_audio_chunk = reader.pop_chunks()[0]
current_audio_chunk_data = current_audio_chunk.t() # Channel first format
# Case where the requested audio chunk starts before the beginning of the audio stream
if ts - duration < 0:
# No useful audio sample has been recorded
if ts < 1 / audio_sample_rate:
current_audio_chunk_data = torch.zeros(
(audio_channels, int(ceil(duration * audio_sample_rate)))
)
# At least one useful audio sample has been recorded
else:
# Remove the superfluous last samples of the audio chunk
current_audio_chunk_data = current_audio_chunk_data[:, : int(ceil(ts * audio_sample_rate))]
# Pad the beginning of the audio chunk with zeros
# TODO(CarolinePascal) : add low level white noise instead of zeros ?
current_audio_chunk_data = torch.nn.functional.pad(
current_audio_chunk_data,
(int(ceil((duration - ts) * audio_sample_rate)), 0, 0, 0), # left, right, top, bottom
)
if log_loaded_timestamps:
logging.info(
f"audio chunk loaded at starting timestamp={current_audio_chunk['pts']:.4f} with duration={len(current_audio_chunk) / audio_sample_rate:.4f}"
)
audio_chunks.append(current_audio_chunk_data)
audio_chunks = torch.stack(audio_chunks)
assert len(timestamps) == len(audio_chunks)
return audio_chunks
def encode_audio(
input_path: Path | str,
output_path: Path | str,
codec: str = "aac", # TODO(CarolinePascal) : investigate Fraunhofer FDK AAC (libfdk_aac) codec and and constant (file size control) /variable (quality control) bitrate options
bit_rate: int | None = None,
sample_rate: int | None = None,
log_level: int | None = av.logging.ERROR,
overwrite: bool = False,
) -> None:
"""Encodes an audio file using ffmpeg."""
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=overwrite)
# Set logging level
if log_level is not None:
# "While less efficient, it is generally preferable to modify logging with Pythons logging"
logging.getLogger("libav").setLevel(log_level)
# Open input file
with av.open(str(input_path), "r") as input:
input_stream = input.streams.audio[0] # Assuming the first stream is the audio stream to be encoded
# Define sub-sampling options
if sample_rate is None:
sample_rate = input_stream.rate
# Create and open output file (overwrite by default)
with av.open(str(output_path), "w") as output:
output_stream = output.add_stream(
codec, rate=sample_rate, layout=CHANNELS_LAYOUTS_MAPPING[input_stream.channels]
)
if bit_rate is not None:
output_stream.bit_rate = bit_rate
# Loop through input WAV packets and encode them
for input_frame in input.decode(
input_stream
): # This step handles both demuxing and decoding under the hood
packet = output_stream.encode(input_frame)
if packet:
output.mux(packet)
# Flush the encoder
packet = output_stream.encode()
if packet:
output.mux(packet)
# Reset logging level
if log_level is not None:
av.logging.restore_default_callback()
if not output_path.exists():
raise OSError(f"Audio encoding did not work. File not found: {output_path}.")
def get_audio_info(video_path: Path | str) -> dict:
# Set logging level
logging.getLogger("libav").setLevel(av.logging.ERROR)
# Getting audio stream information
audio_info = {}
with av.open(str(video_path), "r") as audio_file:
try:
audio_stream = audio_file.streams.audio[0]
except IndexError:
# Reset logging level
av.logging.restore_default_callback()
return {"has_audio": False}
audio_info["audio.channels"] = audio_stream.channels
audio_info["audio.codec"] = audio_stream.codec.canonical_name
# In an ideal loseless case : bit depth x sample rate x channels = bit rate.
# In an actual compressed case, the bit rate is set according to the compression level : the lower the bit rate, the more compression is applied.
audio_info["audio.bit_rate"] = audio_stream.bit_rate
audio_info["audio.sample_rate"] = audio_stream.sample_rate # Number of samples per second
# In an ideal loseless case : fixed number of bits per sample.
# In an actual compressed case : variable number of bits per sample (often reduced to match a given depth rate).
audio_info["audio.bit_depth"] = audio_stream.format.bits
audio_info["audio.channel_layout"] = audio_stream.layout.name
audio_info["has_audio"] = True
# Reset logging level
av.logging.restore_default_callback()
return audio_info
-7
View File
@@ -7,13 +7,6 @@
This dataset was created using [LeRobot](https://github.com/huggingface/lerobot). This dataset was created using [LeRobot](https://github.com/huggingface/lerobot).
{% if repo_id is defined and repo_id %}
<a class="flex" href="https://huggingface.co/spaces/lerobot/visualize_dataset?path={{ repo_id }}">
<img class="block dark:hidden" src="https://huggingface.co/datasets/huggingface/badges/resolve/main/visualize-this-dataset-xl.svg"/>
<img class="hidden dark:block" src="https://huggingface.co/datasets/huggingface/badges/resolve/main/visualize-this-dataset-xl-dark.svg"/>
</a>
{% endif %}
## Dataset Description ## Dataset Description
{{ dataset_description | default("", true) }} {{ dataset_description | default("", true) }}
+22 -1
View File
@@ -15,7 +15,7 @@
# limitations under the License. # limitations under the License.
import numpy as np import numpy as np
from lerobot.datasets.utils import load_image_as_numpy from lerobot.datasets.utils import load_audio_from_path, load_image_as_numpy
DEFAULT_QUANTILES = [0.01, 0.10, 0.50, 0.90, 0.99] DEFAULT_QUANTILES = [0.01, 0.10, 0.50, 0.90, 0.99]
@@ -245,6 +245,20 @@ def sample_images(image_paths: list[str]) -> np.ndarray:
return images return images
def sample_audio_from_path(audio_path: str) -> np.ndarray:
"""Samples audio data from an audio recording stored in a WAV file."""
data = load_audio_from_path(audio_path)
sampled_indices = sample_indices(len(data))
return data[sampled_indices]
def sample_audio_from_data(data: np.ndarray) -> np.ndarray:
"""Samples audio data from an audio recording stored in a numpy array."""
sampled_indices = sample_indices(len(data))
return data[sampled_indices]
def _reshape_stats_by_axis( def _reshape_stats_by_axis(
stats: dict[str, np.ndarray], stats: dict[str, np.ndarray],
axis: int | tuple[int, ...] | None, axis: int | tuple[int, ...] | None,
@@ -512,6 +526,13 @@ def compute_episode_stats(
ep_ft_array = sample_images(data) ep_ft_array = sample_images(data)
axes_to_reduce = (0, 2, 3) axes_to_reduce = (0, 2, 3)
keepdims = True keepdims = True
elif features[key]["dtype"] == "audio":
try:
ep_ft_array = sample_audio_from_path(data[0])
except TypeError: # Should only be triggered for LeKiwi robot, for which audio is stored chunk by chunk in a visual frame-like manner
ep_ft_array = sample_audio_from_data(data)
axes_to_reduce = 0
keepdims = True
else: else:
ep_ft_array = data ep_ft_array = data
axes_to_reduce = 0 axes_to_reduce = 0
+32 -478
View File
@@ -25,7 +25,6 @@ This module provides utilities for:
import logging import logging
import shutil import shutil
from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
@@ -46,8 +45,6 @@ from lerobot.datasets.utils import (
DEFAULT_DATA_FILE_SIZE_IN_MB, DEFAULT_DATA_FILE_SIZE_IN_MB,
DEFAULT_DATA_PATH, DEFAULT_DATA_PATH,
DEFAULT_EPISODES_PATH, DEFAULT_EPISODES_PATH,
DEFAULT_SUBTASKS_PATH,
flatten_dict,
get_parquet_file_size_in_mb, get_parquet_file_size_in_mb,
load_episodes, load_episodes,
update_chunk_file_indices, update_chunk_file_indices,
@@ -92,8 +89,8 @@ def delete_episodes(
Args: Args:
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
episode_indices: List of episode indices to delete. episode_indices: List of episode indices to delete.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
""" """
if not episode_indices: if not episode_indices:
raise ValueError("No episodes to delete") raise ValueError("No episodes to delete")
@@ -144,315 +141,6 @@ def delete_episodes(
return new_dataset return new_dataset
def trim_episode_start(
dataset: LeRobotDataset,
seconds: float,
episode_indices: list[int] | None = None,
output_dir: str | Path | None = None,
repo_id: str | None = None,
) -> LeRobotDataset:
"""Trim the first N seconds from selected episodes and create a new dataset.
The operation rewrites data parquet files and updates episode metadata so that:
- frame_index starts at 0 for each trimmed episode
- timestamp starts at 0 for each trimmed episode
- global index remains contiguous across the full dataset
- dataset_from_index / dataset_to_index reflect new frame ranges
Video files are copied as-is and per-episode video timestamps are shifted forward
for trimmed episodes.
Episodes selected for trimming that are too short (length <= trim_frames) are skipped
from the output dataset.
Args:
dataset: The source LeRobotDataset.
seconds: Number of seconds to remove from episode starts.
episode_indices: Optional list of episode indices to trim. If None, trims all episodes.
output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Repository ID for the new dataset. If None, appends "_trimmed" to original.
"""
if seconds <= 0:
raise ValueError(f"seconds must be strictly positive, got {seconds}")
if dataset.meta.episodes is None:
dataset.meta.episodes = load_episodes(dataset.meta.root)
trim_frames = int(seconds * dataset.meta.fps)
if trim_frames <= 0:
raise ValueError(
f"seconds={seconds} corresponds to 0 frames at fps={dataset.meta.fps}. "
"Increase seconds so at least one frame is trimmed."
)
if episode_indices is None:
episode_indices = list(range(dataset.meta.total_episodes))
if len(episode_indices) == 0:
raise ValueError("No episodes specified to trim")
episode_indices = sorted(set(episode_indices))
valid_indices = set(range(dataset.meta.total_episodes))
invalid = set(episode_indices) - valid_indices
if invalid:
raise ValueError(f"Invalid episode indices: {invalid}")
too_short = sorted(
ep_idx for ep_idx in episode_indices if int(dataset.meta.episodes[ep_idx]["length"]) <= trim_frames
)
trim_set = set(episode_indices)
skipped_set = set(too_short)
trim_set -= skipped_set
if too_short:
logging.warning(
f"Skipping {len(too_short)} episode(s) that are too short to trim "
f"({trim_frames} frames): {too_short}"
)
episodes_to_keep = [ep_idx for ep_idx in range(dataset.meta.total_episodes) if ep_idx not in skipped_set]
if not episodes_to_keep:
raise ValueError(
"All episodes selected for trimming are too short and would be skipped. "
"Try a smaller trim duration."
)
logging.info(
f"Trimming {len(trim_set)} episode(s) by {seconds}s and keeping {len(episodes_to_keep)} "
f"episode(s) in output"
)
if repo_id is None:
repo_id = f"{dataset.repo_id}_trimmed"
output_dir = Path(output_dir) if output_dir is not None else HF_LEROBOT_HOME / repo_id
new_meta = LeRobotDatasetMetadata.create(
repo_id=repo_id,
fps=dataset.meta.fps,
features=dataset.meta.features,
robot_type=dataset.meta.robot_type,
root=output_dir,
use_videos=len(dataset.meta.video_keys) > 0,
chunks_size=dataset.meta.chunks_size,
data_files_size_in_mb=dataset.meta.data_files_size_in_mb,
video_files_size_in_mb=dataset.meta.video_files_size_in_mb,
)
if dataset.meta.tasks is not None:
write_tasks(dataset.meta.tasks, new_meta.root)
new_meta.tasks = dataset.meta.tasks.copy()
subtasks_path = dataset.root / DEFAULT_SUBTASKS_PATH
if subtasks_path.exists():
dst_subtasks_path = new_meta.root / DEFAULT_SUBTASKS_PATH
dst_subtasks_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(subtasks_path, dst_subtasks_path)
episode_mapping = {old_idx: new_idx for new_idx, old_idx in enumerate(episodes_to_keep)}
trim_duration_s = trim_frames / dataset.meta.fps
episode_lengths: dict[int, int] = {}
episode_ranges: dict[int, tuple[int, int]] = {}
total_frames = 0
for old_ep_idx in episodes_to_keep:
new_ep_idx = episode_mapping[old_ep_idx]
src_length = int(dataset.meta.episodes[old_ep_idx]["length"])
new_length = src_length - trim_frames if old_ep_idx in trim_set else src_length
episode_lengths[new_ep_idx] = new_length
episode_ranges[new_ep_idx] = (total_frames, total_frames + new_length)
total_frames += new_length
numeric_features = {
k: v
for k, v in dataset.meta.features.items()
if v["dtype"] not in ["image", "video", "string"]
}
episode_stats_parts: dict[int, list[dict[str, dict]]] = defaultdict(list)
episode_file_metadata: dict[int, dict[str, int]] = {}
data_dir = dataset.root / DATA_DIR
parquet_files = sorted(data_dir.glob("*/*.parquet"))
if not parquet_files:
raise ValueError(f"No parquet files found in {data_dir}")
for src_path in tqdm(parquet_files, desc="Trimming data files"):
df = pd.read_parquet(src_path).reset_index(drop=True)
if len(df) == 0:
continue
if skipped_set:
keep_mask = ~df["episode_index"].isin(skipped_set)
if not keep_mask.all():
df = df.loc[keep_mask].copy().reset_index(drop=True)
if len(df) == 0:
continue
if trim_set:
trim_mask = df["episode_index"].isin(trim_set) & (df["frame_index"] < trim_frames)
if trim_mask.any():
df = df.loc[~trim_mask].copy().reset_index(drop=True)
if len(df) == 0:
continue
relative_path = src_path.relative_to(dataset.root)
chunk_idx = int(relative_path.parts[1].split("-")[1])
file_idx = int(relative_path.parts[2].split("-")[1].split(".")[0])
for old_ep_idx in sorted(df["episode_index"].unique().tolist()):
ep_mask = df["episode_index"] == old_ep_idx
new_ep_idx = episode_mapping[old_ep_idx]
if old_ep_idx in trim_set:
df.loc[ep_mask, "frame_index"] = df.loc[ep_mask, "frame_index"] - trim_frames
shifted_timestamps = df.loc[ep_mask, "timestamp"].to_numpy(dtype=np.float64) - trim_duration_s
df.loc[ep_mask, "timestamp"] = np.clip(shifted_timestamps, a_min=0.0, a_max=None)
df.loc[ep_mask, "episode_index"] = new_ep_idx
ep_start, _ = episode_ranges[new_ep_idx]
new_indices = ep_start + df.loc[ep_mask, "frame_index"].to_numpy(dtype=np.int64)
df.loc[ep_mask, "index"] = new_indices
if new_ep_idx in episode_file_metadata:
existing = episode_file_metadata[new_ep_idx]
if (
existing["data/chunk_index"] != chunk_idx
or existing["data/file_index"] != file_idx
):
raise ValueError(
f"Episode {old_ep_idx} spans multiple data files. "
"trim_episode_start currently expects one data file per episode."
)
else:
episode_file_metadata[new_ep_idx] = {
"data/chunk_index": chunk_idx,
"data/file_index": file_idx,
}
if numeric_features:
ep_df = df.loc[ep_mask]
episode_data: dict[str, np.ndarray] = {}
episode_feature_spec: dict[str, dict] = {}
for key, feature in numeric_features.items():
if key not in ep_df.columns:
continue
values = ep_df[key].to_numpy()
if len(values) == 0:
continue
first_value = values[0]
if isinstance(first_value, np.ndarray):
episode_data[key] = np.stack(values)
elif isinstance(first_value, (list, tuple)):
episode_data[key] = np.stack(values)
else:
episode_data[key] = np.asarray(values)
episode_feature_spec[key] = feature
if episode_data:
episode_stats_parts[new_ep_idx].append(
compute_episode_stats(episode_data, episode_feature_spec)
)
df["index"] = df["index"].astype(np.int64)
if "frame_index" in df.columns:
df["frame_index"] = df["frame_index"].astype(np.int64)
dst_path = new_meta.root / DEFAULT_DATA_PATH.format(chunk_index=chunk_idx, file_index=file_idx)
dst_path.parent.mkdir(parents=True, exist_ok=True)
_write_parquet(df, dst_path, new_meta)
all_episode_stats = []
for old_ep_idx in tqdm(episodes_to_keep, desc="Writing episode metadata"):
new_ep_idx = episode_mapping[old_ep_idx]
if new_ep_idx not in episode_file_metadata:
raise ValueError(f"Missing data file metadata for episode {old_ep_idx}")
from_idx, to_idx = episode_ranges[new_ep_idx]
src_episode = dataset.meta.episodes[old_ep_idx]
ep_data_meta = episode_file_metadata[new_ep_idx]
stats_parts = episode_stats_parts.get(new_ep_idx, [])
ep_stats = aggregate_stats(stats_parts) if len(stats_parts) > 1 else (stats_parts[0] if stats_parts else {})
if ep_stats:
all_episode_stats.append(ep_stats)
episode_meta = {
"data/chunk_index": ep_data_meta["data/chunk_index"],
"data/file_index": ep_data_meta["data/file_index"],
"dataset_from_index": from_idx,
"dataset_to_index": to_idx,
}
for video_key in dataset.meta.video_keys:
from_ts = src_episode[f"videos/{video_key}/from_timestamp"]
if old_ep_idx in trim_set:
from_ts += trim_duration_s
episode_meta.update(
{
f"videos/{video_key}/chunk_index": src_episode[f"videos/{video_key}/chunk_index"],
f"videos/{video_key}/file_index": src_episode[f"videos/{video_key}/file_index"],
f"videos/{video_key}/from_timestamp": from_ts,
f"videos/{video_key}/to_timestamp": src_episode[f"videos/{video_key}/to_timestamp"],
}
)
episode_dict = {
"episode_index": new_ep_idx,
"tasks": src_episode["tasks"],
"length": episode_lengths[new_ep_idx],
}
episode_dict.update(episode_meta)
if ep_stats:
episode_dict.update(flatten_dict({"stats": ep_stats}))
new_meta._save_episode_metadata(episode_dict)
new_meta._close_writer()
if new_meta.video_keys:
_copy_videos(dataset, new_meta)
new_meta.info.update(
{
"total_episodes": len(episodes_to_keep),
"total_frames": total_frames,
"total_tasks": len(new_meta.tasks) if new_meta.tasks is not None else 0,
"splits": {"train": f"0:{len(episodes_to_keep)}"},
}
)
if new_meta.video_keys and dataset.meta.video_keys:
for key in new_meta.video_keys:
if key in dataset.meta.features:
new_meta.info["features"][key]["info"] = dataset.meta.info["features"][key].get("info", {})
write_info(new_meta.info, new_meta.root)
merged_stats = aggregate_stats(all_episode_stats) if all_episode_stats else {}
if dataset.meta.stats:
for key, value in dataset.meta.stats.items():
if key not in merged_stats:
merged_stats[key] = value
if merged_stats:
write_stats(merged_stats, new_meta.root)
return LeRobotDataset(
repo_id=repo_id,
root=output_dir,
image_transforms=dataset.image_transforms,
delta_timestamps=dataset.delta_timestamps,
tolerance_s=dataset.tolerance_s,
)
def split_dataset( def split_dataset(
dataset: LeRobotDataset, dataset: LeRobotDataset,
splits: dict[str, float | list[int]], splits: dict[str, float | list[int]],
@@ -464,7 +152,7 @@ def split_dataset(
dataset: The source LeRobotDataset to split. dataset: The source LeRobotDataset to split.
splits: Either a dict mapping split names to episode indices, or a dict mapping splits: Either a dict mapping split names to episode indices, or a dict mapping
split names to fractions (must sum to <= 1.0). split names to fractions (must sum to <= 1.0).
output_dir: Root directory where the split datasets will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. output_dir: Base directory for output datasets. If None, uses default location.
Examples: Examples:
Split by specific episodes Split by specific episodes
@@ -555,8 +243,8 @@ def merge_datasets(
Args: Args:
datasets: List of LeRobotDatasets to merge. datasets: List of LeRobotDatasets to merge.
output_repo_id: Merged dataset identifier. output_repo_id: Repository ID for the merged dataset.
output_dir: Root directory where the merged dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/output_repo_id. output_dir: Directory to save the merged dataset. If None, uses default location.
""" """
if not datasets: if not datasets:
raise ValueError("No datasets to merge") raise ValueError("No datasets to merge")
@@ -600,8 +288,8 @@ def modify_features(
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
add_features: Optional dict mapping feature names to (feature_values, feature_info) tuples. add_features: Optional dict mapping feature names to (feature_values, feature_info) tuples.
remove_features: Optional feature name(s) to remove. Can be a single string or list. remove_features: Optional feature name(s) to remove. Can be a single string or list.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
Returns: Returns:
New dataset with features modified. New dataset with features modified.
@@ -702,8 +390,8 @@ def add_features(
Args: Args:
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
features: Dictionary mapping feature names to (feature_values, feature_info) tuples. features: Dictionary mapping feature names to (feature_values, feature_info) tuples.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
Returns: Returns:
New dataset with all features added. New dataset with all features added.
@@ -739,8 +427,8 @@ def remove_feature(
Args: Args:
dataset: The source LeRobotDataset. dataset: The source LeRobotDataset.
feature_names: Name(s) of features to remove. Can be a single string or list. feature_names: Name(s) of features to remove. Can be a single string or list.
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new dataset. If None, uses default location.
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset. If None, appends "_modified" to original.
Returns: Returns:
New dataset with features removed. New dataset with features removed.
@@ -879,22 +567,20 @@ def _copy_and_reindex_data(
def _keep_episodes_from_video_with_av( def _keep_episodes_from_video_with_av(
input_path: Path, input_path: Path,
output_path: Path, output_path: Path,
episodes_to_keep: list[tuple[int, int]], episodes_to_keep: list[tuple[float, float]],
fps: float, fps: float,
vcodec: str = "libsvtav1", vcodec: str = "libsvtav1",
pix_fmt: str = "yuv420p", pix_fmt: str = "yuv420p",
) -> None: ) -> None:
"""Keep only specified episodes from a video file using PyAV. """Keep only specified episodes from a video file using PyAV.
This function decodes frames from specified frame ranges and re-encodes them with This function decodes frames from specified time ranges and re-encodes them with
properly reset timestamps to ensure monotonic progression. properly reset timestamps to ensure monotonic progression.
Args: Args:
input_path: Source video file path. input_path: Source video file path.
output_path: Destination video file path. output_path: Destination video file path.
episodes_to_keep: List of (start_frame, end_frame) tuples for episodes to keep. episodes_to_keep: List of (start_time, end_time) tuples for episodes to keep.
Ranges are half-open intervals: [start_frame, end_frame), where start_frame
is inclusive and end_frame is exclusive.
fps: Frame rate of the video. fps: Frame rate of the video.
vcodec: Video codec to use for encoding. vcodec: Video codec to use for encoding.
pix_fmt: Pixel format for output video. pix_fmt: Pixel format for output video.
@@ -936,10 +622,9 @@ def _keep_episodes_from_video_with_av(
# Create set of (start, end) ranges for fast lookup. # Create set of (start, end) ranges for fast lookup.
# Convert to a sorted list for efficient checking. # Convert to a sorted list for efficient checking.
frame_ranges = sorted(episodes_to_keep) time_ranges = sorted(episodes_to_keep)
# Track frame index for setting PTS and current range being processed. # Track frame index for setting PTS and current range being processed.
src_frame_count = 0
frame_count = 0 frame_count = 0
range_idx = 0 range_idx = 0
@@ -949,20 +634,21 @@ def _keep_episodes_from_video_with_av(
if frame is None: if frame is None:
continue continue
# Check if frame is in any of our desired frame ranges. # Get frame timestamp.
frame_time = float(frame.pts * frame.time_base) if frame.pts is not None else 0.0
# Check if frame is in any of our desired time ranges.
# Skip ranges that have already passed. # Skip ranges that have already passed.
while range_idx < len(frame_ranges) and src_frame_count >= frame_ranges[range_idx][1]: while range_idx < len(time_ranges) and frame_time >= time_ranges[range_idx][1]:
range_idx += 1 range_idx += 1
# If we've passed all ranges, stop processing. # If we've passed all ranges, stop processing.
if range_idx >= len(frame_ranges): if range_idx >= len(time_ranges):
break break
# Check if frame is in current range. # Check if frame is in current range.
start_frame = frame_ranges[range_idx][0] start_ts, end_ts = time_ranges[range_idx]
if frame_time < start_ts:
if src_frame_count < start_frame:
src_frame_count += 1
continue continue
# Frame is in range - create a new frame with reset timestamps. # Frame is in range - create a new frame with reset timestamps.
@@ -975,7 +661,6 @@ def _keep_episodes_from_video_with_av(
for pkt in v_out.encode(new_frame): for pkt in v_out.encode(new_frame):
out.mux(pkt) out.mux(pkt)
src_frame_count += 1
frame_count += 1 frame_count += 1
# Flush encoder. # Flush encoder.
@@ -1064,17 +749,15 @@ def _copy_and_reindex_videos(
f"videos/{video_key}/to_timestamp" f"videos/{video_key}/to_timestamp"
] ]
else: else:
# Build list of frame ranges to keep, in sorted order. # Build list of time ranges to keep, in sorted order.
sorted_keep_episodes = sorted(episodes_in_file, key=lambda x: episode_mapping[x]) sorted_keep_episodes = sorted(episodes_in_file, key=lambda x: episode_mapping[x])
episodes_to_keep_ranges: list[tuple[int, int]] = [] episodes_to_keep_ranges: list[tuple[float, float]] = []
for old_idx in sorted_keep_episodes: for old_idx in sorted_keep_episodes:
src_ep = src_dataset.meta.episodes[old_idx] src_ep = src_dataset.meta.episodes[old_idx]
from_frame = round(src_ep[f"videos/{video_key}/from_timestamp"] * src_dataset.meta.fps) from_ts = src_ep[f"videos/{video_key}/from_timestamp"]
to_frame = round(src_ep[f"videos/{video_key}/to_timestamp"] * src_dataset.meta.fps) to_ts = src_ep[f"videos/{video_key}/to_timestamp"]
assert src_ep["length"] == to_frame - from_frame, ( episodes_to_keep_ranges.append((from_ts, to_ts))
f"Episode length mismatch: {src_ep['length']} vs {to_frame - from_frame}"
)
episodes_to_keep_ranges.append((from_frame, to_frame))
# Use PyAV filters to efficiently re-encode only the desired segments. # Use PyAV filters to efficiently re-encode only the desired segments.
assert src_dataset.meta.video_path is not None assert src_dataset.meta.video_path is not None
@@ -1713,137 +1396,9 @@ BYTES_PER_KIB = 1024
BYTES_PER_MIB = BYTES_PER_KIB * BYTES_PER_KIB BYTES_PER_MIB = BYTES_PER_KIB * BYTES_PER_KIB
def modify_tasks(
dataset: LeRobotDataset,
new_task: str | None = None,
episode_tasks: dict[int, str] | None = None,
) -> LeRobotDataset:
"""Modify tasks in a LeRobotDataset.
This function allows you to either:
1. Set a single task for the entire dataset (using `new_task`)
2. Set specific tasks for specific episodes (using `episode_tasks`)
You can combine both: `new_task` sets the default, and `episode_tasks` overrides
specific episodes.
The dataset is modified in-place, updating only the task-related files:
- meta/tasks.parquet
- data/**/*.parquet (task_index column)
- meta/episodes/**/*.parquet (tasks column)
- meta/info.json (total_tasks)
Args:
dataset: The source LeRobotDataset to modify.
new_task: A single task string to apply to all episodes. If None and episode_tasks
is also None, raises an error.
episode_tasks: Optional dict mapping episode indices to their task strings.
Overrides `new_task` for specific episodes.
Examples:
Set a single task for all episodes:
dataset = modify_tasks(dataset, new_task="Pick up the cube")
Set different tasks for specific episodes:
dataset = modify_tasks(
dataset,
episode_tasks={0: "Task A", 1: "Task B", 2: "Task A"}
)
Set a default task with overrides:
dataset = modify_tasks(
dataset,
new_task="Default task",
episode_tasks={5: "Special task for episode 5"}
)
"""
if new_task is None and episode_tasks is None:
raise ValueError("Must specify at least one of new_task or episode_tasks")
if episode_tasks is not None:
valid_indices = set(range(dataset.meta.total_episodes))
invalid = set(episode_tasks.keys()) - valid_indices
if invalid:
raise ValueError(f"Invalid episode indices: {invalid}")
# Ensure episodes metadata is loaded
if dataset.meta.episodes is None:
dataset.meta.episodes = load_episodes(dataset.root)
# Build the mapping from episode index to task string
episode_to_task: dict[int, str] = {}
for ep_idx in range(dataset.meta.total_episodes):
if episode_tasks and ep_idx in episode_tasks:
episode_to_task[ep_idx] = episode_tasks[ep_idx]
elif new_task is not None:
episode_to_task[ep_idx] = new_task
else:
# Keep original task if not overridden and no default provided
original_tasks = dataset.meta.episodes[ep_idx]["tasks"]
if not original_tasks:
raise ValueError(f"Episode {ep_idx} has no tasks and no default task was provided")
episode_to_task[ep_idx] = original_tasks[0]
# Collect all unique tasks and create new task mapping
unique_tasks = sorted(set(episode_to_task.values()))
new_task_df = pd.DataFrame(
{"task_index": list(range(len(unique_tasks)))}, index=pd.Index(unique_tasks, name="task")
)
task_to_index = {task: idx for idx, task in enumerate(unique_tasks)}
logging.info(f"Modifying tasks in {dataset.repo_id}")
logging.info(f"New tasks: {unique_tasks}")
root = dataset.root
# Update data files - modify task_index column
logging.info("Updating data files...")
data_dir = root / DATA_DIR
for parquet_path in tqdm(sorted(data_dir.rglob("*.parquet")), desc="Updating data"):
df = pd.read_parquet(parquet_path)
# Build a mapping from episode_index to new task_index for rows in this file
episode_indices_in_file = df["episode_index"].unique()
ep_to_new_task_idx = {
ep_idx: task_to_index[episode_to_task[ep_idx]] for ep_idx in episode_indices_in_file
}
# Update task_index column
df["task_index"] = df["episode_index"].map(ep_to_new_task_idx)
df.to_parquet(parquet_path, index=False)
# Update episodes metadata - modify tasks column
logging.info("Updating episodes metadata...")
episodes_dir = root / "meta" / "episodes"
for parquet_path in tqdm(sorted(episodes_dir.rglob("*.parquet")), desc="Updating episodes"):
df = pd.read_parquet(parquet_path)
# Update tasks column
df["tasks"] = df["episode_index"].apply(lambda ep_idx: [episode_to_task[ep_idx]])
df.to_parquet(parquet_path, index=False)
# Write new tasks.parquet
write_tasks(new_task_df, root)
# Update info.json
dataset.meta.info["total_tasks"] = len(unique_tasks)
write_info(dataset.meta.info, root)
# Reload metadata to reflect changes
dataset.meta.tasks = new_task_df
dataset.meta.episodes = load_episodes(root)
logging.info(f"Tasks: {unique_tasks}")
return dataset
def convert_image_to_video_dataset( def convert_image_to_video_dataset(
dataset: LeRobotDataset, dataset: LeRobotDataset,
output_dir: Path | None = None, output_dir: Path,
repo_id: str | None = None, repo_id: str | None = None,
vcodec: str = "libsvtav1", vcodec: str = "libsvtav1",
pix_fmt: str = "yuv420p", pix_fmt: str = "yuv420p",
@@ -1862,8 +1417,8 @@ def convert_image_to_video_dataset(
Args: Args:
dataset: The source LeRobot dataset with images dataset: The source LeRobot dataset with images
output_dir: Root directory where the edited dataset will be stored. If not specified, defaults to $HF_LEROBOT_HOME/repo_id. Equivalent to new_root in EditDatasetConfig. output_dir: Directory to save the new video dataset
repo_id: Edited dataset identifier. Equivalent to new_repo_id in EditDatasetConfig. repo_id: Repository ID for the new dataset (default: original_id + "_video")
vcodec: Video codec (default: libsvtav1) vcodec: Video codec (default: libsvtav1)
pix_fmt: Pixel format (default: yuv420p) pix_fmt: Pixel format (default: yuv420p)
g: Group of pictures size (default: 2) g: Group of pictures size (default: 2)
@@ -1914,7 +1469,6 @@ def convert_image_to_video_dataset(
# Video info will be updated after episodes are encoded # Video info will be updated after episodes are encoded
# Create new metadata for video dataset # Create new metadata for video dataset
output_dir = Path(output_dir) if output_dir is not None else HF_LEROBOT_HOME / repo_id
new_meta = LeRobotDatasetMetadata.create( new_meta = LeRobotDatasetMetadata.create(
repo_id=repo_id, repo_id=repo_id,
fps=dataset.meta.fps, fps=dataset.meta.fps,
+392 -151
View File
@@ -33,12 +33,16 @@ import torch.utils
from huggingface_hub import HfApi, snapshot_download from huggingface_hub import HfApi, snapshot_download
from huggingface_hub.errors import RevisionNotFoundError from huggingface_hub.errors import RevisionNotFoundError
from lerobot.datasets.audio_utils import decode_audio, encode_audio, get_audio_info
from lerobot.datasets.compute_stats import aggregate_stats, compute_episode_stats from lerobot.datasets.compute_stats import aggregate_stats, compute_episode_stats
from lerobot.datasets.image_writer import AsyncImageWriter, write_image from lerobot.datasets.image_writer import AsyncImageWriter, write_image
from lerobot.datasets.utils import ( from lerobot.datasets.utils import (
DEFAULT_AUDIO_CHUNK_DURATION,
DEFAULT_EPISODES_PATH, DEFAULT_EPISODES_PATH,
DEFAULT_FEATURES, DEFAULT_FEATURES,
DEFAULT_IMAGE_PATH, DEFAULT_IMAGE_PATH,
DEFAULT_INITIAL_AUDIO_BUFFER_DURATION,
DEFAULT_RAW_AUDIO_PATH,
INFO_PATH, INFO_PATH,
_validate_feature_names, _validate_feature_names,
check_delta_timestamps, check_delta_timestamps,
@@ -57,7 +61,6 @@ from lerobot.datasets.utils import (
load_info, load_info,
load_nested_dataset, load_nested_dataset,
load_stats, load_stats,
load_subtasks,
load_tasks, load_tasks,
update_chunk_file_indices, update_chunk_file_indices,
validate_episode_buffer, validate_episode_buffer,
@@ -68,19 +71,20 @@ from lerobot.datasets.utils import (
write_tasks, write_tasks,
) )
from lerobot.datasets.video_utils import ( from lerobot.datasets.video_utils import (
StreamingVideoEncoder,
VideoFrame, VideoFrame,
concatenate_video_files, concatenate_media_files,
decode_video_frames, decode_video_frames,
encode_video_frames, encode_video_frames,
get_media_duration_in_s,
get_safe_default_codec, get_safe_default_codec,
get_video_duration_in_s,
get_video_info, get_video_info,
resolve_vcodec,
) )
from lerobot.microphones import Microphone
from lerobot.microphones.utils import async_microphones_start_recording
from lerobot.utils.constants import HF_LEROBOT_HOME from lerobot.utils.constants import HF_LEROBOT_HOME
CODEBASE_VERSION = "v3.0" CODEBASE_VERSION = "v3.0"
VALID_VIDEO_CODECS = {"h264", "hevc", "libsvtav1"}
class LeRobotDatasetMetadata: class LeRobotDatasetMetadata:
@@ -164,7 +168,6 @@ class LeRobotDatasetMetadata:
self.info = load_info(self.root) self.info = load_info(self.root)
check_version_compatibility(self.repo_id, self._version, CODEBASE_VERSION) check_version_compatibility(self.repo_id, self._version, CODEBASE_VERSION)
self.tasks = load_tasks(self.root) self.tasks = load_tasks(self.root)
self.subtasks = load_subtasks(self.root)
self.episodes = load_episodes(self.root) self.episodes = load_episodes(self.root)
self.stats = load_stats(self.root) self.stats = load_stats(self.root)
@@ -217,6 +220,19 @@ class LeRobotDatasetMetadata:
fpath = self.video_path.format(video_key=vid_key, chunk_index=chunk_idx, file_index=file_idx) fpath = self.video_path.format(video_key=vid_key, chunk_index=chunk_idx, file_index=file_idx)
return Path(fpath) return Path(fpath)
def get_audio_file_path(self, ep_index: int, audio_key: str) -> Path:
if self.episodes is None:
self.episodes = load_episodes(self.root)
if ep_index >= len(self.episodes):
raise IndexError(
f"Episode index {ep_index} out of range. Episodes: {len(self.episodes) if self.episodes else 0}"
)
ep = self.episodes[ep_index]
chunk_idx = ep[f"audio/{audio_key}/chunk_index"]
file_idx = ep[f"audio/{audio_key}/file_index"]
fpath = self.audio_path.format(audio_key=audio_key, chunk_index=chunk_idx, file_index=file_idx)
return Path(fpath)
@property @property
def data_path(self) -> str: def data_path(self) -> str:
"""Formattable string for the parquet files.""" """Formattable string for the parquet files."""
@@ -227,6 +243,11 @@ class LeRobotDatasetMetadata:
"""Formattable string for the video files.""" """Formattable string for the video files."""
return self.info["video_path"] return self.info["video_path"]
@property
def audio_path(self) -> str | None:
"""Formattable string for the audio files."""
return self.info["audio_path"]
@property @property
def robot_type(self) -> str | None: def robot_type(self) -> str | None:
"""Robot type used in recording this dataset.""" """Robot type used in recording this dataset."""
@@ -257,6 +278,11 @@ class LeRobotDatasetMetadata:
"""Keys to access visual modalities (regardless of their storage method).""" """Keys to access visual modalities (regardless of their storage method)."""
return [key for key, ft in self.features.items() if ft["dtype"] in ["video", "image"]] return [key for key, ft in self.features.items() if ft["dtype"] in ["video", "image"]]
@property
def audio_keys(self) -> list[str]:
"""Keys to access audio modalities."""
return [key for key, ft in self.features.items() if ft["dtype"] == "audio"]
@property @property
def names(self) -> dict[str, list | dict]: def names(self) -> dict[str, list | dict]:
"""Names of the various dimensions of vector modalities.""" """Names of the various dimensions of vector modalities."""
@@ -297,6 +323,11 @@ class LeRobotDatasetMetadata:
"""Max size of video file in mega bytes.""" """Max size of video file in mega bytes."""
return self.info["video_files_size_in_mb"] return self.info["video_files_size_in_mb"]
@property
def audio_files_size_in_mb(self) -> int:
"""Max size of audio file in mega bytes."""
return self.info["audio_files_size_in_mb"]
def get_task_index(self, task: str) -> int | None: def get_task_index(self, task: str) -> int | None:
""" """
Given a task in natural language, returns its task_index if the task already exists in the dataset, Given a task in natural language, returns its task_index if the task already exists in the dataset,
@@ -314,7 +345,7 @@ class LeRobotDatasetMetadata:
if self.tasks is None: if self.tasks is None:
new_tasks = tasks new_tasks = tasks
task_indices = range(len(tasks)) task_indices = range(len(tasks))
self.tasks = pd.DataFrame({"task_index": task_indices}, index=pd.Index(tasks, name="task")) self.tasks = pd.DataFrame({"task_index": task_indices}, index=tasks)
else: else:
new_tasks = [task for task in tasks if task not in self.tasks.index] new_tasks = [task for task in tasks if task not in self.tasks.index]
new_task_indices = range(len(self.tasks), len(self.tasks) + len(new_tasks)) new_task_indices = range(len(self.tasks), len(self.tasks) + len(new_tasks))
@@ -438,11 +469,27 @@ class LeRobotDatasetMetadata:
video_path = self.root / self.video_path.format(video_key=key, chunk_index=0, file_index=0) video_path = self.root / self.video_path.format(video_key=key, chunk_index=0, file_index=0)
self.info["features"][key]["info"] = get_video_info(video_path) self.info["features"][key]["info"] = get_video_info(video_path)
def update_audio_info(self, audio_key: str | None = None) -> None:
"""
Warning: this function writes info from first episode audio, implicitly assuming that all audio have
been encoded the same way. Also, this means it assumes the first episode exists.
"""
if audio_key is not None and audio_key not in self.audio_keys:
raise ValueError(f"Audio key {audio_key} not found in dataset")
audio_keys = [audio_key] if audio_key is not None else self.audio_keys
for key in audio_keys:
if not self.features[key].get("info", None):
audio_path = self.root / self.audio_path.format(audio_key=key, chunk_index=0, file_index=0)
self.info["features"][key]["info"] = get_audio_info(audio_path)
self.info["features"][key]["info"]["start_time_s"] = DEFAULT_INITIAL_AUDIO_BUFFER_DURATION
def update_chunk_settings( def update_chunk_settings(
self, self,
chunks_size: int | None = None, chunks_size: int | None = None,
data_files_size_in_mb: int | None = None, data_files_size_in_mb: int | None = None,
video_files_size_in_mb: int | None = None, video_files_size_in_mb: int | None = None,
audio_files_size_in_mb: int | None = None,
) -> None: ) -> None:
"""Update chunk and file size settings after dataset creation. """Update chunk and file size settings after dataset creation.
@@ -454,6 +501,7 @@ class LeRobotDatasetMetadata:
chunks_size: Maximum number of files per chunk directory. If None, keeps current value. chunks_size: Maximum number of files per chunk directory. If None, keeps current value.
data_files_size_in_mb: Maximum size for data parquet files in MB. If None, keeps current value. data_files_size_in_mb: Maximum size for data parquet files in MB. If None, keeps current value.
video_files_size_in_mb: Maximum size for video files in MB. If None, keeps current value. video_files_size_in_mb: Maximum size for video files in MB. If None, keeps current value.
audio_files_size_in_mb: Maximum size for audio files in MB. If None, keeps current value.
""" """
if chunks_size is not None: if chunks_size is not None:
if chunks_size <= 0: if chunks_size <= 0:
@@ -470,6 +518,11 @@ class LeRobotDatasetMetadata:
raise ValueError(f"video_files_size_in_mb must be positive, got {video_files_size_in_mb}") raise ValueError(f"video_files_size_in_mb must be positive, got {video_files_size_in_mb}")
self.info["video_files_size_in_mb"] = video_files_size_in_mb self.info["video_files_size_in_mb"] = video_files_size_in_mb
if audio_files_size_in_mb is not None:
if audio_files_size_in_mb <= 0:
raise ValueError(f"audio_files_size_in_mb must be positive, got {audio_files_size_in_mb}")
self.info["audio_files_size_in_mb"] = audio_files_size_in_mb
# Update the info file on disk # Update the info file on disk
write_info(self.info, self.root) write_info(self.info, self.root)
@@ -477,12 +530,13 @@ class LeRobotDatasetMetadata:
"""Get current chunk and file size settings. """Get current chunk and file size settings.
Returns: Returns:
Dict containing chunks_size, data_files_size_in_mb, and video_files_size_in_mb. Dict containing chunks_size, data_files_size_in_mb, video_files_size_in_mb, and audio_files_size_in_mb.
""" """
return { return {
"chunks_size": self.chunks_size, "chunks_size": self.chunks_size,
"data_files_size_in_mb": self.data_files_size_in_mb, "data_files_size_in_mb": self.data_files_size_in_mb,
"video_files_size_in_mb": self.video_files_size_in_mb, "video_files_size_in_mb": self.video_files_size_in_mb,
"audio_files_size_in_mb": self.audio_files_size_in_mb,
} }
def __repr__(self): def __repr__(self):
@@ -509,6 +563,7 @@ class LeRobotDatasetMetadata:
chunks_size: int | None = None, chunks_size: int | None = None,
data_files_size_in_mb: int | None = None, data_files_size_in_mb: int | None = None,
video_files_size_in_mb: int | None = None, video_files_size_in_mb: int | None = None,
audio_files_size_in_mb: int | None = None,
) -> "LeRobotDatasetMetadata": ) -> "LeRobotDatasetMetadata":
"""Creates metadata for a LeRobotDataset.""" """Creates metadata for a LeRobotDataset."""
obj = cls.__new__(cls) obj = cls.__new__(cls)
@@ -521,7 +576,6 @@ class LeRobotDatasetMetadata:
_validate_feature_names(features) _validate_feature_names(features)
obj.tasks = None obj.tasks = None
obj.subtasks = None
obj.episodes = None obj.episodes = None
obj.stats = None obj.stats = None
obj.info = create_empty_dataset_info( obj.info = create_empty_dataset_info(
@@ -533,6 +587,7 @@ class LeRobotDatasetMetadata:
chunks_size, chunks_size,
data_files_size_in_mb, data_files_size_in_mb,
video_files_size_in_mb, video_files_size_in_mb,
audio_files_size_in_mb,
) )
if len(obj.video_keys) > 0 and not use_videos: if len(obj.video_keys) > 0 and not use_videos:
raise ValueError() raise ValueError()
@@ -546,19 +601,12 @@ class LeRobotDatasetMetadata:
def _encode_video_worker( def _encode_video_worker(
video_key: str, video_key: str, episode_index: int, root: Path, fps: int, vcodec: str = "libsvtav1"
episode_index: int,
root: Path,
fps: int,
vcodec: str = "libsvtav1",
encoder_threads: int | None = None,
) -> Path: ) -> Path:
temp_path = Path(tempfile.mkdtemp(dir=root)) / f"{video_key}_{episode_index:03d}.mp4" temp_path = Path(tempfile.mkdtemp(dir=root)) / f"{video_key}_{episode_index:03d}.mp4"
fpath = DEFAULT_IMAGE_PATH.format(image_key=video_key, episode_index=episode_index, frame_index=0) fpath = DEFAULT_IMAGE_PATH.format(image_key=video_key, episode_index=episode_index, frame_index=0)
img_dir = (root / fpath).parent img_dir = (root / fpath).parent
encode_video_frames( encode_video_frames(img_dir, temp_path, fps, vcodec=vcodec, overwrite=True)
img_dir, temp_path, fps, vcodec=vcodec, overwrite=True, encoder_threads=encoder_threads
)
shutil.rmtree(img_dir) shutil.rmtree(img_dir)
return temp_path return temp_path
@@ -575,12 +623,11 @@ class LeRobotDataset(torch.utils.data.Dataset):
revision: str | None = None, revision: str | None = None,
force_cache_sync: bool = False, force_cache_sync: bool = False,
download_videos: bool = True, download_videos: bool = True,
download_audio: bool = True,
video_backend: str | None = None, video_backend: str | None = None,
audio_backend: str | None = None,
batch_encoding_size: int = 1, batch_encoding_size: int = 1,
vcodec: str = "libsvtav1", vcodec: str = "libsvtav1",
streaming_encoding: bool = False,
encoder_queue_maxsize: int = 30,
encoder_threads: int | None = None,
): ):
""" """
2 modes are available for instantiating this class, depending on 2 different use cases: 2 modes are available for instantiating this class, depending on 2 different use cases:
@@ -612,6 +659,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
task-conditioned training. task-conditioned training.
- hf_dataset (from datasets.Dataset), which will read any values from parquet files. - hf_dataset (from datasets.Dataset), which will read any values from parquet files.
- videos (optional) from which frames are loaded to be synchronous with data from parquet files. - videos (optional) from which frames are loaded to be synchronous with data from parquet files.
- audio (optional) from which audio is loaded to be synchronous with data from parquet files.
A typical LeRobotDataset looks like this from its root path: A typical LeRobotDataset looks like this from its root path:
. .
@@ -637,19 +685,37 @@ class LeRobotDataset(torch.utils.data.Dataset):
│ ├── info.json │ ├── info.json
│ ├── stats.json │ ├── stats.json
│ └── tasks.parquet │ └── tasks.parquet
── videos ── videos
├── observation.images.laptop ├── observation.images.laptop
│ │ ├── chunk-000
│ │ │ ├── file-000.mp4
│ │ │ ├── file-001.mp4
│ │ │ └── ...
│ │ ├── chunk-001
│ │ │ └── ...
│ │ └── ...
│ ├── observation.images.phone
│ │ ├── chunk-000
│ │ │ ├── file-000.mp4
│ │ │ ├── file-001.mp4
│ │ │ └── ...
│ │ ├── chunk-001
│ │ │ └── ...
│ │ └── ...
│ └── ...
└── audio
├── observation.audio.laptop
│ ├── chunk-000 │ ├── chunk-000
│ │ ├── file-000.mp4 │ │ ├── file-000.m4a
│ │ ├── file-001.mp4 │ │ ├── file-001.m4a
│ │ └── ... │ │ └── ...
│ ├── chunk-001 │ ├── chunk-001
│ │ └── ... │ │ └── ...
│ └── ... │ └── ...
├── observation.images.phone ├── observation.audio.phone
│ ├── chunk-000 │ ├── chunk-000
│ │ ├── file-000.mp4 │ │ ├── file-000.m4a
│ │ ├── file-001.mp4 │ │ ├── file-001.m4a
│ │ └── ... │ │ └── ...
│ ├── chunk-001 │ ├── chunk-001
│ │ └── ... │ │ └── ...
@@ -664,11 +730,11 @@ class LeRobotDataset(torch.utils.data.Dataset):
for the README). for the README).
Args: Args:
repo_id (str): This is the repo id that will be used to fetch the dataset. repo_id (str): This is the repo id that will be used to fetch the dataset. Locally, the dataset
root (Path | None, optional): Local directory where the dataset will be downloaded and will be stored under root/repo_id.
stored. If set, all dataset files will be stored directly under this path. If not set, the root (Path | None, optional): Local directory to use for downloading/writing files. You can also
dataset files will be stored under $HF_LEROBOT_HOME/repo_id (configurable via the set the LEROBOT_HOME environment variable to point to a different location. Defaults to
HF_LEROBOT_HOME environment variable). '~/.cache/huggingface/lerobot'.
episodes (list[int] | None, optional): If specified, this will only load episodes specified by episodes (list[int] | None, optional): If specified, this will only load episodes specified by
their episode_index in this list. Defaults to None. their episode_index in this list. Defaults to None.
image_transforms (Callable | None, optional): You can pass standard v2 image transforms from image_transforms (Callable | None, optional): You can pass standard v2 image transforms from
@@ -689,22 +755,19 @@ class LeRobotDataset(torch.utils.data.Dataset):
download_videos (bool, optional): Flag to download the videos. Note that when set to True but the download_videos (bool, optional): Flag to download the videos. Note that when set to True but the
video files are already present on local disk, they won't be downloaded again. Defaults to video files are already present on local disk, they won't be downloaded again. Defaults to
True. True.
download_audio (bool, optional): Flag to download the audio. Defaults to True.
video_backend (str | None, optional): Video backend to use for decoding videos. Defaults to torchcodec when available int the platform; otherwise, defaults to 'pyav'. video_backend (str | None, optional): Video backend to use for decoding videos. Defaults to torchcodec when available int the platform; otherwise, defaults to 'pyav'.
You can also use the 'pyav' decoder used by Torchvision, which used to be the default option, or 'video_reader' which is another decoder of Torchvision. You can also use the 'pyav' decoder used by Torchvision, which used to be the default option, or 'video_reader' which is another decoder of Torchvision.
audio_backend (str | None, optional): Audio backend to use for decoding audio. Defaults to 'torchcodec'.
batch_encoding_size (int, optional): Number of episodes to accumulate before batch encoding videos. batch_encoding_size (int, optional): Number of episodes to accumulate before batch encoding videos.
Set to 1 for immediate encoding (default), or higher for batched encoding. Defaults to 1. Set to 1 for immediate encoding (default), or higher for batched encoding. Defaults to 1.
vcodec (str, optional): Video codec for encoding videos during recording. Options: 'h264', 'hevc', vcodec (str, optional): Video codec for encoding videos during recording. Options: 'h264', 'hevc',
'libsvtav1', 'auto', or hardware-specific codecs like 'h264_videotoolbox', 'h264_nvenc'. 'libsvtav1'. Defaults to 'libsvtav1'. Use 'h264' for faster encoding on systems where AV1
Defaults to 'libsvtav1'. Use 'auto' to auto-detect the best available hardware encoder. encoding is CPU-heavy.
streaming_encoding (bool, optional): If True, encode video frames in real-time during capture
instead of writing PNG images first. This makes save_episode() near-instant. Defaults to False.
encoder_queue_maxsize (int, optional): Maximum number of frames to buffer per camera when using
streaming encoding. Defaults to 30 (~1s at 30fps).
encoder_threads (int | None, optional): Number of threads per encoder instance. None lets the
codec auto-detect (default). Lower values reduce CPU usage per encoder. Maps to 'lp' (via svtav1-params) for
libsvtav1 and 'threads' for h264/hevc.
""" """
super().__init__() super().__init__()
if vcodec not in VALID_VIDEO_CODECS:
raise ValueError(f"Invalid vcodec '{vcodec}'. Must be one of: {sorted(VALID_VIDEO_CODECS)}")
self.repo_id = repo_id self.repo_id = repo_id
self.root = Path(root) if root else HF_LEROBOT_HOME / repo_id self.root = Path(root) if root else HF_LEROBOT_HOME / repo_id
self.image_transforms = image_transforms self.image_transforms = image_transforms
@@ -713,11 +776,13 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.tolerance_s = tolerance_s self.tolerance_s = tolerance_s
self.revision = revision if revision else CODEBASE_VERSION self.revision = revision if revision else CODEBASE_VERSION
self.video_backend = video_backend if video_backend else get_safe_default_codec() self.video_backend = video_backend if video_backend else get_safe_default_codec()
self.audio_backend = (
audio_backend if audio_backend else "torchcodec"
) # Waiting for torchcodec release #TODO(CarolinePascal)
self.delta_indices = None self.delta_indices = None
self.batch_encoding_size = batch_encoding_size self.batch_encoding_size = batch_encoding_size
self.episodes_since_last_encoding = 0 self.episodes_since_last_encoding = 0
self.vcodec = resolve_vcodec(vcodec) self.vcodec = vcodec
self._encoder_threads = encoder_threads
# Unused attributes # Unused attributes
self.image_writer = None self.image_writer = None
@@ -725,7 +790,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.writer = None self.writer = None
self.latest_episode = None self.latest_episode = None
self._current_file_start_frame = None # Track the starting frame index of the current parquet file self._current_file_start_frame = None # Track the starting frame index of the current parquet file
self._streaming_encoder = None
self.root.mkdir(exist_ok=True, parents=True) self.root.mkdir(exist_ok=True, parents=True)
@@ -747,7 +811,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
# Check if cached dataset contains all requested episodes # Check if cached dataset contains all requested episodes
if not self._check_cached_episodes_sufficient(): if not self._check_cached_episodes_sufficient():
raise FileNotFoundError("Cached dataset doesn't contain all requested episodes") raise FileNotFoundError("Cached dataset doesn't contain all requested episodes")
except (FileNotFoundError, NotADirectoryError): except (AssertionError, FileNotFoundError, NotADirectoryError):
if is_valid_version(self.revision): if is_valid_version(self.revision):
self.revision = get_safe_version(self.repo_id, self.revision) self.revision = get_safe_version(self.repo_id, self.revision)
self.download(download_videos) self.download(download_videos)
@@ -767,19 +831,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
check_delta_timestamps(self.delta_timestamps, self.fps, self.tolerance_s) check_delta_timestamps(self.delta_timestamps, self.fps, self.tolerance_s)
self.delta_indices = get_delta_indices(self.delta_timestamps, self.fps) self.delta_indices = get_delta_indices(self.delta_timestamps, self.fps)
# Initialize streaming encoder for resumed recording
if streaming_encoding and len(self.meta.video_keys) > 0:
self._streaming_encoder = StreamingVideoEncoder(
fps=self.meta.fps,
vcodec=self.vcodec,
pix_fmt="yuv420p",
g=2,
crf=30,
preset=None,
queue_maxsize=encoder_queue_maxsize,
encoder_threads=encoder_threads,
)
def _close_writer(self) -> None: def _close_writer(self) -> None:
"""Close and cleanup the parquet writer if it exists.""" """Close and cleanup the parquet writer if it exists."""
writer = getattr(self, "writer", None) writer = getattr(self, "writer", None)
@@ -800,6 +851,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
license: str | None = "apache-2.0", license: str | None = "apache-2.0",
tag_version: bool = True, tag_version: bool = True,
push_videos: bool = True, push_videos: bool = True,
push_audio: bool = True,
private: bool = False, private: bool = False,
allow_patterns: list[str] | str | None = None, allow_patterns: list[str] | str | None = None,
upload_large_folder: bool = False, upload_large_folder: bool = False,
@@ -808,6 +860,8 @@ class LeRobotDataset(torch.utils.data.Dataset):
ignore_patterns = ["images/"] ignore_patterns = ["images/"]
if not push_videos: if not push_videos:
ignore_patterns.append("videos/") ignore_patterns.append("videos/")
if not push_audio:
ignore_patterns.append("audio/")
hub_api = HfApi() hub_api = HfApi()
hub_api.create_repo( hub_api.create_repo(
@@ -839,7 +893,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
hub_api.upload_folder(**upload_kwargs) hub_api.upload_folder(**upload_kwargs)
card = create_lerobot_dataset_card( card = create_lerobot_dataset_card(
tags=tags, dataset_info=self.meta.info, license=license, repo_id=self.repo_id, **card_kwargs tags=tags, dataset_info=self.meta.info, license=license, **card_kwargs
) )
card.push_to_hub(repo_id=self.repo_id, repo_type="dataset", revision=branch) card.push_to_hub(repo_id=self.repo_id, repo_type="dataset", revision=branch)
@@ -862,7 +916,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
ignore_patterns=ignore_patterns, ignore_patterns=ignore_patterns,
) )
def download(self, download_videos: bool = True) -> None: def download(self, download_videos: bool = True, download_audio: bool = True) -> None:
"""Downloads the dataset from the given 'repo_id' at the provided version. If 'episodes' is given, this """Downloads the dataset from the given 'repo_id' at the provided version. If 'episodes' is given, this
will only download those episodes (selected by their episode_index). If 'episodes' is None, the whole will only download those episodes (selected by their episode_index). If 'episodes' is None, the whole
dataset will be downloaded. Thanks to the behavior of snapshot_download, if the files are already present dataset will be downloaded. Thanks to the behavior of snapshot_download, if the files are already present
@@ -870,8 +924,12 @@ class LeRobotDataset(torch.utils.data.Dataset):
""" """
# TODO(rcadene, aliberts): implement faster transfer # TODO(rcadene, aliberts): implement faster transfer
# https://huggingface.co/docs/huggingface_hub/en/guides/download#faster-downloads # https://huggingface.co/docs/huggingface_hub/en/guides/download#faster-downloads
ignore_patterns = None if download_videos else "videos/"
files = None files = None
ignore_patterns = []
if not download_videos:
ignore_patterns.append("videos/")
if not download_audio:
ignore_patterns.append("audio/")
if self.episodes is not None: if self.episodes is not None:
files = self.get_episodes_file_paths() files = self.get_episodes_file_paths()
self.pull_from_repo(allow_patterns=files, ignore_patterns=ignore_patterns) self.pull_from_repo(allow_patterns=files, ignore_patterns=ignore_patterns)
@@ -886,6 +944,15 @@ class LeRobotDataset(torch.utils.data.Dataset):
for ep_idx in episodes for ep_idx in episodes
] ]
fpaths += video_files fpaths += video_files
if len(self.meta.audio_keys) > 0:
audio_files = [
str(self.meta.get_compressed_audio_file_path(ep_idx, audio_key))
for audio_key in self.meta.audio_keys
for ep_idx in episodes
]
fpaths += audio_files
# episodes are stored in the same files, so we return unique paths only # episodes are stored in the same files, so we return unique paths only
fpaths = list(set(fpaths)) fpaths = list(set(fpaths))
return fpaths return fpaths
@@ -898,7 +965,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
return hf_dataset return hf_dataset
def _check_cached_episodes_sufficient(self) -> bool: def _check_cached_episodes_sufficient(self) -> bool:
"""Check if the cached dataset contains all requested episodes and their video files.""" """Check if the cached dataset contains all requested episodes and their video and audio files."""
if self.hf_dataset is None or len(self.hf_dataset) == 0: if self.hf_dataset is None or len(self.hf_dataset) == 0:
return False return False
@@ -926,6 +993,14 @@ class LeRobotDataset(torch.utils.data.Dataset):
if not video_path.exists(): if not video_path.exists():
return False return False
# Check if all required audio files exist
if len(self.meta.audio_keys) > 0:
for ep_idx in requested_episodes:
for audio_key in self.meta.audio_keys:
audio_path = self.root / self.meta.get_audio_file_path(ep_idx, audio_key)
if not audio_path.exists():
return False
return True return True
def create_hf_dataset(self) -> datasets.Dataset: def create_hf_dataset(self) -> datasets.Dataset:
@@ -1004,7 +1079,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
query_indices: dict[str, list[int]] | None = None, query_indices: dict[str, list[int]] | None = None,
) -> dict[str, list[float]]: ) -> dict[str, list[float]]:
query_timestamps = {} query_timestamps = {}
for key in self.meta.video_keys: for key in self.meta.video_keys + self.meta.audio_keys:
if query_indices is not None and key in query_indices: if query_indices is not None and key in query_indices:
if self._absolute_to_relative_idx is not None: if self._absolute_to_relative_idx is not None:
relative_indices = [self._absolute_to_relative_idx[idx] for idx in query_indices[key]] relative_indices = [self._absolute_to_relative_idx[idx] for idx in query_indices[key]]
@@ -1019,7 +1094,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
def _query_hf_dataset(self, query_indices: dict[str, list[int]]) -> dict: def _query_hf_dataset(self, query_indices: dict[str, list[int]]) -> dict:
""" """
Query dataset for indices across keys, skipping video keys. Query dataset for indices across keys, skipping video keys and audio keys.
Tries column-first [key][indices] for speed, falls back to row-first. Tries column-first [key][indices] for speed, falls back to row-first.
@@ -1031,7 +1106,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
""" """
result: dict = {} result: dict = {}
for key, q_idx in query_indices.items(): for key, q_idx in query_indices.items():
if key in self.meta.video_keys: if key in self.meta.video_keys or key in self.meta.audio_keys:
continue continue
# Map absolute indices to relative indices if needed # Map absolute indices to relative indices if needed
relative_indices = ( relative_indices = (
@@ -1066,6 +1141,28 @@ class LeRobotDataset(torch.utils.data.Dataset):
return item return item
# TODO(CarolinePascal): add variable query durations
def _query_audio(
self, query_timestamps: dict[str, list[float]], query_duration: float, ep_idx: int
) -> dict[str, torch.Tensor]:
ep = self.meta.episodes[ep_idx]
item = {}
for audio_key, query_ts in query_timestamps.items():
# Episodes are stored sequentially on a single mp4 to reduce the number of files.
# Thus we load the start timestamp of the episode on this mp4 and,
# shift the query timestamp accordingly.
from_timestamp = ep[f"audio/{audio_key}/from_timestamp"]
shifted_query_ts = [from_timestamp + ts for ts in query_ts]
audio_path = self.root / self.meta.get_audio_file_path(ep_idx, audio_key)
start_time_s = self.meta.features[audio_key]["info"].get("start_time_s", 0.0)
audio_chunk = decode_audio(
audio_path, shifted_query_ts, query_duration, start_time_s, self.audio_backend
)
item[audio_key] = audio_chunk.squeeze(0)
return item
def _ensure_hf_dataset_loaded(self): def _ensure_hf_dataset_loaded(self):
"""Lazy load the HF dataset only when needed for reading.""" """Lazy load the HF dataset only when needed for reading."""
if self._lazy_loading or self.hf_dataset is None: if self._lazy_loading or self.hf_dataset is None:
@@ -1095,11 +1192,12 @@ class LeRobotDataset(torch.utils.data.Dataset):
for key, val in query_result.items(): for key, val in query_result.items():
item[key] = val item[key] = val
if len(self.meta.video_keys) > 0: if len(self.meta.video_keys) > 0 or len(self.meta.audio_keys) > 0:
current_ts = item["timestamp"].item() current_ts = item["timestamp"].item()
query_timestamps = self._get_query_timestamps(current_ts, query_indices) query_timestamps = self._get_query_timestamps(current_ts, query_indices)
video_frames = self._query_videos(query_timestamps, ep_idx) video_frames = self._query_videos(query_timestamps, ep_idx)
item = {**video_frames, **item} audio_chunks = self._query_audio(query_timestamps, DEFAULT_AUDIO_CHUNK_DURATION, ep_idx)
item = {**item, **video_frames, **audio_chunks}
if self.image_transforms is not None: if self.image_transforms is not None:
image_keys = self.meta.camera_keys image_keys = self.meta.camera_keys
@@ -1109,12 +1207,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
# Add task as a string # Add task as a string
task_idx = item["task_index"].item() task_idx = item["task_index"].item()
item["task"] = self.meta.tasks.iloc[task_idx].name item["task"] = self.meta.tasks.iloc[task_idx].name
# add subtask information if available
if "subtask_index" in self.features and self.meta.subtasks is not None:
subtask_idx = item["subtask_index"].item()
item["subtask"] = self.meta.subtasks.iloc[subtask_idx].name
return item return item
def __repr__(self): def __repr__(self):
@@ -1135,8 +1227,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
""" """
self._close_writer() self._close_writer()
self.meta._close_writer() self.meta._close_writer()
if self._streaming_encoder is not None:
self._streaming_encoder.close()
def create_episode_buffer(self, episode_index: int | None = None) -> dict: def create_episode_buffer(self, episode_index: int | None = None) -> dict:
current_ep_idx = self.meta.total_episodes if episode_index is None else episode_index current_ep_idx = self.meta.total_episodes if episode_index is None else episode_index
@@ -1155,6 +1245,10 @@ class LeRobotDataset(torch.utils.data.Dataset):
) )
return self.root / fpath return self.root / fpath
def _get_raw_audio_file_path(self, episode_index: int, audio_key: str) -> Path:
fpath = DEFAULT_RAW_AUDIO_PATH.format(audio_key=audio_key, episode_index=episode_index)
return self.root / fpath
def _get_image_file_dir(self, episode_index: int, image_key: str) -> Path: def _get_image_file_dir(self, episode_index: int, image_key: str) -> Path:
return self._get_image_file_path(episode_index, image_key, frame_index=0).parent return self._get_image_file_path(episode_index, image_key, frame_index=0).parent
@@ -1191,13 +1285,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.episode_buffer["timestamp"].append(timestamp) self.episode_buffer["timestamp"].append(timestamp)
self.episode_buffer["task"].append(frame.pop("task")) # Remove task from frame after processing self.episode_buffer["task"].append(frame.pop("task")) # Remove task from frame after processing
# Start streaming encoder on first frame of episode (once, before iterating keys)
if frame_index == 0 and self._streaming_encoder is not None:
self._streaming_encoder.start_episode(
video_keys=list(self.meta.video_keys),
temp_dir=self.root,
)
# Add frame features to episode_buffer # Add frame features to episode_buffer
for key in frame: for key in frame:
if key not in self.features: if key not in self.features:
@@ -1205,10 +1292,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
f"An element of the frame is not in the features. '{key}' not in '{self.features.keys()}'." f"An element of the frame is not in the features. '{key}' not in '{self.features.keys()}'."
) )
if self.features[key]["dtype"] == "video" and self._streaming_encoder is not None: if self.features[key]["dtype"] in ["image", "video"]:
self._streaming_encoder.feed_frame(key, frame[key])
self.episode_buffer[key].append(None) # Placeholder (video keys are skipped in parquet)
elif self.features[key]["dtype"] in ["image", "video"]:
img_path = self._get_image_file_path( img_path = self._get_image_file_path(
episode_index=self.episode_buffer["episode_index"], image_key=key, frame_index=frame_index episode_index=self.episode_buffer["episode_index"], image_key=key, frame_index=frame_index
) )
@@ -1217,11 +1301,43 @@ class LeRobotDataset(torch.utils.data.Dataset):
compress_level = 1 if self.features[key]["dtype"] == "video" else 6 compress_level = 1 if self.features[key]["dtype"] == "video" else 6
self._save_image(frame[key], img_path, compress_level) self._save_image(frame[key], img_path, compress_level)
self.episode_buffer[key].append(str(img_path)) self.episode_buffer[key].append(str(img_path))
elif self.features[key]["dtype"] == "audio":
if (
self.meta.robot_type == "lekiwi"
): # Raw data storage should only be triggered for LeKiwi robot, for which audio is stored chunk by chunk in a visual frame-like manner
self.episode_buffer[key].append(frame[key])
else: # Otherwise, only the audio file path is stored in the episode buffer
if frame_index == 0:
audio_path = self._get_raw_audio_file_path(
episode_index=self.episode_buffer["episode_index"], audio_key=key
)
self.episode_buffer[key].append(str(audio_path))
else: else:
self.episode_buffer[key].append(frame[key]) self.episode_buffer[key].append(frame[key])
self.episode_buffer["size"] += 1 self.episode_buffer["size"] += 1
def add_microphone_recording(self, microphone_key: str, microphone: Microphone) -> None:
"""
Starts recording audio data provided by the microphone and directly writes it in a .wav file.
"""
audio_file = self._get_raw_audio_file_path(self.num_episodes, "observation.audio." + microphone_key)
microphone.start_recording(output_file=audio_file)
def add_microphones_recordings(self, microphones: dict[str, Microphone]) -> None:
"""
Starts recording audio data provided by multiple microphones and directly writes it in appropriate .wav files.
"""
output_files = []
for microphone_key in microphones:
output_files.append(
self._get_raw_audio_file_path(self.num_episodes, "observation.audio." + microphone_key)
)
async_microphones_start_recording(microphones, output_files)
def save_episode( def save_episode(
self, self,
episode_data: dict | None = None, episode_data: dict | None = None,
@@ -1265,42 +1381,24 @@ class LeRobotDataset(torch.utils.data.Dataset):
# are processed separately by storing image path and frame info as meta data # are processed separately by storing image path and frame info as meta data
if key in ["index", "episode_index", "task_index"] or ft["dtype"] in ["image", "video"]: if key in ["index", "episode_index", "task_index"] or ft["dtype"] in ["image", "video"]:
continue continue
elif ft["dtype"] == "audio":
if (
self.meta.robot_type == "lekiwi"
): # Raw data storage should only be triggered for LeKiwi robot, for which audio is stored chunk by chunk in a visual frame-like manner
episode_buffer[key] = np.concatenate(episode_buffer[key], axis=0)
continue
episode_buffer[key] = np.stack(episode_buffer[key]) episode_buffer[key] = np.stack(episode_buffer[key])
# Wait for image writer to end, so that episode stats over images can be computed # Wait for image writer to end, so that episode stats over images can be computed
self._wait_image_writer() self._wait_image_writer()
has_video_keys = len(self.meta.video_keys) > 0
use_streaming = self._streaming_encoder is not None and has_video_keys
use_batched_encoding = self.batch_encoding_size > 1
if use_streaming:
# Compute stats for non-video features only (video stats come from encoder)
non_video_buffer = {
k: v
for k, v in episode_buffer.items()
if self.features.get(k, {}).get("dtype") not in ("video",)
}
non_video_features = {k: v for k, v in self.features.items() if v["dtype"] != "video"}
ep_stats = compute_episode_stats(non_video_buffer, non_video_features)
else:
ep_stats = compute_episode_stats(episode_buffer, self.features) ep_stats = compute_episode_stats(episode_buffer, self.features)
ep_metadata = self._save_episode_data(episode_buffer) ep_metadata = self._save_episode_data(episode_buffer)
has_video_keys = len(self.meta.video_keys) > 0
has_audio_keys = len(self.meta.audio_keys) > 0
use_batched_encoding = self.batch_encoding_size > 1
if use_streaming: if (has_video_keys or has_audio_keys) and not use_batched_encoding:
# Finish streaming encoding and collect results
streaming_results = self._streaming_encoder.finish_episode()
for video_key in self.meta.video_keys:
temp_path, video_stats = streaming_results[video_key]
if video_stats is not None:
# Format stats same as compute_episode_stats: normalize to [0,1], reshape to (C,1,1)
ep_stats[video_key] = {
k: v if k == "count" else np.squeeze(v.reshape(1, -1, 1, 1) / 255.0, axis=0)
for k, v in video_stats.items()
}
ep_metadata.update(self._save_episode_video(video_key, episode_index, temp_path=temp_path))
elif has_video_keys and not use_batched_encoding:
num_cameras = len(self.meta.video_keys) num_cameras = len(self.meta.video_keys)
if parallel_encoding and num_cameras > 1: if parallel_encoding and num_cameras > 1:
# TODO(Steven): Ideally we would like to control the number of threads per encoding such that: # TODO(Steven): Ideally we would like to control the number of threads per encoding such that:
@@ -1314,7 +1412,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
self.root, self.root,
self.fps, self.fps,
self.vcodec, self.vcodec,
self._encoder_threads,
): video_key ): video_key
for video_key in self.meta.video_keys for video_key in self.meta.video_keys
} }
@@ -1338,21 +1435,30 @@ class LeRobotDataset(torch.utils.data.Dataset):
for video_key in self.meta.video_keys: for video_key in self.meta.video_keys:
ep_metadata.update(self._save_episode_video(video_key, episode_index)) ep_metadata.update(self._save_episode_video(video_key, episode_index))
# TODO(Caroline): add parallel encoding for audio as well
for audio_key in self.meta.audio_keys:
ep_metadata.update(self._save_episode_audio(audio_key, episode_index))
# `meta.save_episode` need to be executed after encoding the videos # `meta.save_episode` need to be executed after encoding the videos
self.meta.save_episode(episode_index, episode_length, episode_tasks, ep_stats, ep_metadata) self.meta.save_episode(episode_index, episode_length, episode_tasks, ep_stats, ep_metadata)
if has_video_keys and use_batched_encoding: if (has_video_keys or has_audio_keys) and use_batched_encoding:
# Check if we should trigger batch encoding # Check if we should trigger batch encoding
self.episodes_since_last_encoding += 1 self.episodes_since_last_encoding += 1
if self.episodes_since_last_encoding == self.batch_encoding_size: if self.episodes_since_last_encoding == self.batch_encoding_size:
start_ep = self.num_episodes - self.batch_encoding_size start_ep = self.num_episodes - self.batch_encoding_size
end_ep = self.num_episodes end_ep = self.num_episodes
if has_video_keys:
self._batch_save_episode_video(start_ep, end_ep) self._batch_save_episode_video(start_ep, end_ep)
if has_audio_keys:
self._batch_save_episode_audio(start_ep, end_ep)
self.episodes_since_last_encoding = 0 self.episodes_since_last_encoding = 0
if not episode_data: if not episode_data:
# Reset episode buffer and clean up temporary images (if not already deleted during video encoding) # Reset episode buffer and clean up temporary images (if not already deleted during video encoding)
self.clear_episode_buffer(delete_images=len(self.meta.image_keys) > 0) self.clear_episode_buffer(
delete_images=len(self.meta.image_keys) > 0, delete_audio=len(self.meta.audio_keys) > 0
)
def _batch_save_episode_video(self, start_episode: int, end_episode: int | None = None) -> None: def _batch_save_episode_video(self, start_episode: int, end_episode: int | None = None) -> None:
""" """
@@ -1403,7 +1509,70 @@ class LeRobotDataset(torch.utils.data.Dataset):
dtype_backend="pyarrow" dtype_backend="pyarrow"
) # allows NaN values along with integers ) # allows NaN values along with integers
# Save the current episode's audio metadata to the dataframe
audio_ep_metadata = {}
for audio_key in self.meta.audio_keys:
audio_ep_metadata.update(self._save_episode_audio(audio_key, ep_idx))
audio_ep_metadata.pop("episode_index")
audio_ep_df = pd.DataFrame(audio_ep_metadata, index=[ep_idx]).convert_dtypes(
dtype_backend="pyarrow"
) # allows NaN values along with integers
episode_df = episode_df.combine_first(video_ep_df) episode_df = episode_df.combine_first(video_ep_df)
episode_df = episode_df.combine_first(audio_ep_df)
episode_df.to_parquet(episode_df_path)
self.meta.episodes = load_episodes(self.root)
def _batch_save_episode_audio(self, start_episode: int, end_episode: int | None = None) -> None:
"""
Batch save audio for multiple episodes.
Args:
start_episode: Starting episode index (inclusive)
end_episode: Ending episode index (exclusive). If None, encodes all episodes from start_episode to the current episode.
"""
if end_episode is None:
end_episode = self.num_episodes
logging.info(
f"Batch encoding {self.batch_encoding_size} audio for episodes {start_episode} to {end_episode - 1}"
)
chunk_idx = self.meta.episodes[start_episode]["data/chunk_index"]
file_idx = self.meta.episodes[start_episode]["data/file_index"]
episode_df_path = self.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx)
episode_df = pd.read_parquet(episode_df_path)
for ep_idx in range(start_episode, end_episode):
logging.info(f"Encoding audio for episode {ep_idx}")
if (
self.meta.episodes[ep_idx]["data/chunk_index"] != chunk_idx
or self.meta.episodes[ep_idx]["data/file_index"] != file_idx
):
# The current episode is in a new chunk or file.
# Save previous episode dataframe and update the Hugging Face dataset by reloading it.
episode_df.to_parquet(episode_df_path)
self.meta.episodes = load_episodes(self.root)
# Load new episode dataframe
chunk_idx = self.meta.episodes[ep_idx]["data/chunk_index"]
file_idx = self.meta.episodes[ep_idx]["data/file_index"]
episode_df_path = self.root / DEFAULT_EPISODES_PATH.format(
chunk_index=chunk_idx, file_index=file_idx
)
episode_df = pd.read_parquet(episode_df_path)
# Save the current episode's video metadata to the dataframe
audio_ep_metadata = {}
for audio_key in self.meta.audio_keys:
audio_ep_metadata.update(self._save_episode_audio(audio_key, ep_idx))
audio_ep_metadata.pop("episode_index")
audio_ep_df = pd.DataFrame(audio_ep_metadata, index=[ep_idx]).convert_dtypes(
dtype_backend="pyarrow"
) # allows NaN values along with integers
episode_df = episode_df.combine_first(audio_ep_df)
episode_df.to_parquet(episode_df_path) episode_df.to_parquet(episode_df_path)
self.meta.episodes = load_episodes(self.root) self.meta.episodes = load_episodes(self.root)
@@ -1514,7 +1683,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
ep_path = temp_path ep_path = temp_path
ep_size_in_mb = get_file_size_in_mb(ep_path) ep_size_in_mb = get_file_size_in_mb(ep_path)
ep_duration_in_s = get_video_duration_in_s(ep_path) ep_duration_in_s = get_media_duration_in_s(ep_path, media_type="video")
if ( if (
episode_index == 0 episode_index == 0
@@ -1560,7 +1729,7 @@ class LeRobotDataset(torch.utils.data.Dataset):
latest_duration_in_s = 0.0 latest_duration_in_s = 0.0
else: else:
# Update latest video file # Update latest video file
concatenate_video_files( concatenate_media_files(
[latest_path, ep_path], [latest_path, ep_path],
latest_path, latest_path,
) )
@@ -1582,11 +1751,79 @@ class LeRobotDataset(torch.utils.data.Dataset):
} }
return metadata return metadata
def clear_episode_buffer(self, delete_images: bool = True) -> None: def _save_episode_audio(self, audio_key: str, episode_index: int) -> dict:
# Cancel streaming encoder if active # Encode episode audio into a temporary audio file
if self._streaming_encoder is not None: ep_path = self._encode_temporary_episode_audio(audio_key, episode_index)
self._streaming_encoder.cancel_episode() ep_size_in_mb = get_file_size_in_mb(ep_path)
ep_duration_in_s = get_media_duration_in_s(ep_path, media_type="audio")
if (
episode_index == 0
or self.meta.latest_episode is None
or f"audio/{audio_key}/chunk_index" not in self.meta.latest_episode
):
# Initialize indices for a new dataset made of the first episode data
chunk_idx, file_idx = 0, 0
if self.meta.episodes is not None and len(self.meta.episodes) > 0:
# It means we are resuming recording, so we need to load the latest episode
# Update the indices to avoid overwriting the latest episode
old_chunk_idx = self.meta.episodes[-1][f"audio/{audio_key}/chunk_index"]
old_file_idx = self.meta.episodes[-1][f"audio/{audio_key}/file_index"]
chunk_idx, file_idx = update_chunk_file_indices(
old_chunk_idx, old_file_idx, self.meta.chunks_size
)
latest_duration_in_s = 0.0
new_path = self.root / self.meta.audio_path.format(
audio_key=audio_key, chunk_index=chunk_idx, file_index=file_idx
)
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(ep_path), str(new_path))
else:
# Retrieve information from the latest updated audio file using latest_episode
latest_ep = self.meta.latest_episode
chunk_idx = latest_ep[f"audio/{audio_key}/chunk_index"][0]
file_idx = latest_ep[f"audio/{audio_key}/file_index"][0]
latest_path = self.root / self.meta.audio_path.format(
audio_key=audio_key, chunk_index=chunk_idx, file_index=file_idx
)
latest_size_in_mb = get_file_size_in_mb(latest_path)
latest_duration_in_s = latest_ep[f"audio/{audio_key}/to_timestamp"][0]
if latest_size_in_mb + ep_size_in_mb >= self.meta.audio_files_size_in_mb:
# Move temporary episode audio to a new audio file in the dataset
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, self.meta.chunks_size)
new_path = self.root / self.meta.audio_path.format(
audio_key=audio_key, chunk_index=chunk_idx, file_index=file_idx
)
new_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(ep_path), str(new_path))
latest_duration_in_s = 0.0
else:
# Update latest audio file
concatenate_media_files(
[latest_path, ep_path],
latest_path,
)
# Remove temporary directory
shutil.rmtree(str(ep_path.parent))
# Update audio info (only needed when first episode is encoded since it reads from episode 0)
if episode_index == 0:
self.meta.update_audio_info(audio_key)
write_info(self.meta.info, self.meta.root) # ensure audio info always written properly
metadata = {
"episode_index": episode_index,
f"audio/{audio_key}/chunk_index": chunk_idx,
f"audio/{audio_key}/file_index": file_idx,
f"audio/{audio_key}/from_timestamp": latest_duration_in_s,
f"audio/{audio_key}/to_timestamp": latest_duration_in_s + ep_duration_in_s,
}
return metadata
def clear_episode_buffer(self, delete_images: bool = True, delete_audio: bool = True) -> None:
# Clean up image files for the current episode buffer # Clean up image files for the current episode buffer
if delete_images: if delete_images:
# Wait for the async image writer to finish # Wait for the async image writer to finish
@@ -1600,6 +1837,16 @@ class LeRobotDataset(torch.utils.data.Dataset):
if img_dir.is_dir(): if img_dir.is_dir():
shutil.rmtree(img_dir) shutil.rmtree(img_dir)
# Clean up audio files for the current episode buffer
if delete_audio:
episode_index = self.episode_buffer["episode_index"]
if isinstance(episode_index, np.ndarray):
episode_index = episode_index.item() if episode_index.size == 1 else episode_index[0]
for audio_key in self.meta.audio_keys:
audio_file = self._get_raw_audio_file_path(episode_index, audio_key)
if audio_file.is_file():
audio_file.unlink()
# Reset the buffer # Reset the buffer
self.episode_buffer = self.create_episode_buffer() self.episode_buffer = self.create_episode_buffer()
@@ -1634,9 +1881,19 @@ class LeRobotDataset(torch.utils.data.Dataset):
Note: `encode_video_frames` is a blocking call. Making it asynchronous shouldn't speedup encoding, Note: `encode_video_frames` is a blocking call. Making it asynchronous shouldn't speedup encoding,
since video encoding with ffmpeg is already using multithreading. since video encoding with ffmpeg is already using multithreading.
""" """
return _encode_video_worker( return _encode_video_worker(video_key, episode_index, self.root, self.fps, self.vcodec)
video_key, episode_index, self.root, self.fps, self.vcodec, self._encoder_threads
) def _encode_temporary_episode_audio(self, audio_key: str, episode_index: int) -> Path:
"""
Use ffmpeg to convert raw audio files into m4a audio files.
Note: `encode_episode_audio` is a blocking call. Making it asynchronous shouldn't speedup encoding,
since audio encoding with ffmpeg is already using multithreading.
"""
temp_path = Path(tempfile.mkdtemp(dir=self.root)) / f"{audio_key}_{episode_index:03d}.m4a"
raw_audio_file = self._get_raw_audio_file_path(episode_index, audio_key)
encode_audio(raw_audio_file, temp_path, overwrite=True)
raw_audio_file.unlink()
return temp_path
@classmethod @classmethod
def create( def create(
@@ -1651,15 +1908,13 @@ class LeRobotDataset(torch.utils.data.Dataset):
image_writer_processes: int = 0, image_writer_processes: int = 0,
image_writer_threads: int = 0, image_writer_threads: int = 0,
video_backend: str | None = None, video_backend: str | None = None,
audio_backend: str | None = None,
batch_encoding_size: int = 1, batch_encoding_size: int = 1,
vcodec: str = "libsvtav1", vcodec: str = "libsvtav1",
metadata_buffer_size: int = 10,
streaming_encoding: bool = False,
encoder_queue_maxsize: int = 30,
encoder_threads: int | None = None,
) -> "LeRobotDataset": ) -> "LeRobotDataset":
"""Create a LeRobot Dataset from scratch in order to record data.""" """Create a LeRobot Dataset from scratch in order to record data."""
vcodec = resolve_vcodec(vcodec) if vcodec not in VALID_VIDEO_CODECS:
raise ValueError(f"Invalid vcodec '{vcodec}'. Must be one of: {sorted(VALID_VIDEO_CODECS)}")
obj = cls.__new__(cls) obj = cls.__new__(cls)
obj.meta = LeRobotDatasetMetadata.create( obj.meta = LeRobotDatasetMetadata.create(
repo_id=repo_id, repo_id=repo_id,
@@ -1668,7 +1923,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
features=features, features=features,
root=root, root=root,
use_videos=use_videos, use_videos=use_videos,
metadata_buffer_size=metadata_buffer_size,
) )
obj.repo_id = obj.meta.repo_id obj.repo_id = obj.meta.repo_id
obj.root = obj.meta.root obj.root = obj.meta.root
@@ -1678,7 +1932,6 @@ class LeRobotDataset(torch.utils.data.Dataset):
obj.batch_encoding_size = batch_encoding_size obj.batch_encoding_size = batch_encoding_size
obj.episodes_since_last_encoding = 0 obj.episodes_since_last_encoding = 0
obj.vcodec = vcodec obj.vcodec = vcodec
obj._encoder_threads = encoder_threads
if image_writer_processes or image_writer_threads: if image_writer_processes or image_writer_threads:
obj.start_image_writer(image_writer_processes, image_writer_threads) obj.start_image_writer(image_writer_processes, image_writer_threads)
@@ -1700,22 +1953,9 @@ class LeRobotDataset(torch.utils.data.Dataset):
obj._lazy_loading = False obj._lazy_loading = False
obj._recorded_frames = 0 obj._recorded_frames = 0
obj._writer_closed_for_reading = False obj._writer_closed_for_reading = False
obj.audio_backend = (
# Initialize streaming encoder audio_backend if audio_backend is not None else "torchcodec"
if streaming_encoding and len(obj.meta.video_keys) > 0: ) # Waiting for torchcodec release #TODO(CarolinePascal)
obj._streaming_encoder = StreamingVideoEncoder(
fps=fps,
vcodec=vcodec,
pix_fmt="yuv420p",
g=2,
crf=30,
preset=None,
queue_maxsize=encoder_queue_maxsize,
encoder_threads=encoder_threads,
)
else:
obj._streaming_encoder = None
return obj return obj
@@ -1736,6 +1976,7 @@ class MultiLeRobotDataset(torch.utils.data.Dataset):
tolerances_s: dict | None = None, tolerances_s: dict | None = None,
download_videos: bool = True, download_videos: bool = True,
video_backend: str | None = None, video_backend: str | None = None,
audio_backend: str | None = None,
): ):
super().__init__() super().__init__()
self.repo_ids = repo_ids self.repo_ids = repo_ids
@@ -1753,6 +1994,7 @@ class MultiLeRobotDataset(torch.utils.data.Dataset):
tolerance_s=self.tolerances_s[repo_id], tolerance_s=self.tolerances_s[repo_id],
download_videos=download_videos, download_videos=download_videos,
video_backend=video_backend, video_backend=video_backend,
audio_backend=audio_backend,
) )
for repo_id in repo_ids for repo_id in repo_ids
] ]
@@ -1771,7 +2013,6 @@ class MultiLeRobotDataset(torch.utils.data.Dataset):
) )
for repo_id, ds in zip(self.repo_ids, self._datasets, strict=True): for repo_id, ds in zip(self.repo_ids, self._datasets, strict=True):
extra_keys = set(ds.features).difference(intersection_features) extra_keys = set(ds.features).difference(intersection_features)
if extra_keys:
logging.warning( logging.warning(
f"keys {extra_keys} of {repo_id} were disabled as they are not contained in all the " f"keys {extra_keys} of {repo_id} were disabled as they are not contained in all the "
"other datasets." "other datasets."
+9 -10
View File
@@ -216,17 +216,16 @@ class ImageTransformsConfig:
def make_transform_from_config(cfg: ImageTransformConfig): def make_transform_from_config(cfg: ImageTransformConfig):
if cfg.type == "SharpnessJitter": if cfg.type == "Identity":
return v2.Identity(**cfg.kwargs)
elif cfg.type == "ColorJitter":
return v2.ColorJitter(**cfg.kwargs)
elif cfg.type == "SharpnessJitter":
return SharpnessJitter(**cfg.kwargs) return SharpnessJitter(**cfg.kwargs)
elif cfg.type == "RandomAffine":
transform_cls = getattr(v2, cfg.type, None) return v2.RandomAffine(**cfg.kwargs)
if isinstance(transform_cls, type) and issubclass(transform_cls, Transform): else:
return transform_cls(**cfg.kwargs) raise ValueError(f"Transform '{cfg.type}' is not valid.")
raise ValueError(
f"Transform '{cfg.type}' is not valid. It must be a class in "
f"torchvision.transforms.v2 or 'SharpnessJitter'."
)
class ImageTransforms(Transform): class ImageTransforms(Transform):
+82 -17
View File
@@ -21,7 +21,7 @@ from collections import deque
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from pathlib import Path from pathlib import Path
from pprint import pformat from pprint import pformat
from typing import Any from typing import Any, Generic, TypeVar
import datasets import datasets
import numpy as np import numpy as np
@@ -36,6 +36,7 @@ from datasets.table import embed_table_storage
from huggingface_hub import DatasetCard, DatasetCardData, HfApi from huggingface_hub import DatasetCard, DatasetCardData, HfApi
from huggingface_hub.errors import RevisionNotFoundError from huggingface_hub.errors import RevisionNotFoundError
from PIL import Image as PILImage from PIL import Image as PILImage
from soundfile import read
from torchvision import transforms from torchvision import transforms
from lerobot.configs.types import FeatureType, PolicyFeature from lerobot.configs.types import FeatureType, PolicyFeature
@@ -50,6 +51,7 @@ from lerobot.utils.utils import SuppressProgressBars, is_valid_numpy_dtype_strin
DEFAULT_CHUNK_SIZE = 1000 # Max number of files per chunk DEFAULT_CHUNK_SIZE = 1000 # Max number of files per chunk
DEFAULT_DATA_FILE_SIZE_IN_MB = 100 # Max size per file DEFAULT_DATA_FILE_SIZE_IN_MB = 100 # Max size per file
DEFAULT_VIDEO_FILE_SIZE_IN_MB = 200 # Max size per file DEFAULT_VIDEO_FILE_SIZE_IN_MB = 200 # Max size per file
DEFAULT_AUDIO_FILE_SIZE_IN_MB = 100 # Max size per file
INFO_PATH = "meta/info.json" INFO_PATH = "meta/info.json"
STATS_PATH = "meta/stats.json" STATS_PATH = "meta/stats.json"
@@ -57,14 +59,19 @@ STATS_PATH = "meta/stats.json"
EPISODES_DIR = "meta/episodes" EPISODES_DIR = "meta/episodes"
DATA_DIR = "data" DATA_DIR = "data"
VIDEO_DIR = "videos" VIDEO_DIR = "videos"
AUDIO_DIR = "audio"
CHUNK_FILE_PATTERN = "chunk-{chunk_index:03d}/file-{file_index:03d}" CHUNK_FILE_PATTERN = "chunk-{chunk_index:03d}/file-{file_index:03d}"
DEFAULT_TASKS_PATH = "meta/tasks.parquet" DEFAULT_TASKS_PATH = "meta/tasks.parquet"
DEFAULT_SUBTASKS_PATH = "meta/subtasks.parquet"
DEFAULT_EPISODES_PATH = EPISODES_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet" DEFAULT_EPISODES_PATH = EPISODES_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet"
DEFAULT_DATA_PATH = DATA_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet" DEFAULT_DATA_PATH = DATA_DIR + "/" + CHUNK_FILE_PATTERN + ".parquet"
DEFAULT_VIDEO_PATH = VIDEO_DIR + "/{video_key}/" + CHUNK_FILE_PATTERN + ".mp4" DEFAULT_VIDEO_PATH = VIDEO_DIR + "/{video_key}/" + CHUNK_FILE_PATTERN + ".mp4"
DEFAULT_AUDIO_PATH = AUDIO_DIR + "/{audio_key}/" + CHUNK_FILE_PATTERN + ".m4a"
DEFAULT_IMAGE_PATH = "images/{image_key}/episode-{episode_index:06d}/frame-{frame_index:06d}.png" DEFAULT_IMAGE_PATH = "images/{image_key}/episode-{episode_index:06d}/frame-{frame_index:06d}.png"
DEFAULT_RAW_AUDIO_PATH = "raw_audio/{audio_key}/episode_{episode_index:06d}.wav"
DEFAULT_AUDIO_CHUNK_DURATION = 0.5 # seconds
DEFAULT_INITIAL_AUDIO_BUFFER_DURATION = 1.0 # seconds
LEGACY_EPISODES_PATH = "meta/episodes.jsonl" LEGACY_EPISODES_PATH = "meta/episodes.jsonl"
LEGACY_EPISODES_STATS_PATH = "meta/episodes_stats.jsonl" LEGACY_EPISODES_STATS_PATH = "meta/episodes_stats.jsonl"
@@ -78,6 +85,8 @@ DEFAULT_FEATURES = {
"task_index": {"dtype": "int64", "shape": (1,), "names": None}, "task_index": {"dtype": "int64", "shape": (1,), "names": None},
} }
T = TypeVar("T")
def get_parquet_file_size_in_mb(parquet_path: str | Path) -> float: def get_parquet_file_size_in_mb(parquet_path: str | Path) -> float:
metadata = pq.read_metadata(parquet_path) metadata = pq.read_metadata(parquet_path)
@@ -120,9 +129,19 @@ def load_nested_dataset(
raise FileNotFoundError(f"Provided directory does not contain any parquet file: {pq_dir}") raise FileNotFoundError(f"Provided directory does not contain any parquet file: {pq_dir}")
with SuppressProgressBars(): with SuppressProgressBars():
# We use .from_parquet() memory-mapped loading for efficiency # When no filtering needed, Dataset uses memory-mapped loading for efficiency
filters = pa_ds.field("episode_index").isin(episodes) if episodes is not None else None # PyArrow loads the entire dataset into memory
return Dataset.from_parquet([str(path) for path in paths], filters=filters, features=features) if episodes is None:
return Dataset.from_parquet([str(path) for path in paths], features=features)
arrow_dataset = pa_ds.dataset(paths, format="parquet")
filter_expr = pa_ds.field("episode_index").isin(episodes)
table = arrow_dataset.to_table(filter=filter_expr)
if features is not None:
table = table.cast(features.arrow_schema)
return Dataset(table)
def get_parquet_num_frames(parquet_path: str | Path) -> int: def get_parquet_num_frames(parquet_path: str | Path) -> int:
@@ -339,18 +358,9 @@ def write_tasks(tasks: pandas.DataFrame, local_dir: Path) -> None:
def load_tasks(local_dir: Path) -> pandas.DataFrame: def load_tasks(local_dir: Path) -> pandas.DataFrame:
tasks = pd.read_parquet(local_dir / DEFAULT_TASKS_PATH) tasks = pd.read_parquet(local_dir / DEFAULT_TASKS_PATH)
tasks.index.name = "task"
return tasks return tasks
def load_subtasks(local_dir: Path) -> pandas.DataFrame | None:
"""Load subtasks from subtasks.parquet if it exists."""
subtasks_path = local_dir / DEFAULT_SUBTASKS_PATH
if subtasks_path.exists():
return pd.read_parquet(subtasks_path)
return None
def write_episodes(episodes: Dataset, local_dir: Path) -> None: def write_episodes(episodes: Dataset, local_dir: Path) -> None:
"""Write episode metadata to a parquet file in the LeRobot v3.0 format. """Write episode metadata to a parquet file in the LeRobot v3.0 format.
This function writes episode-level metadata to a single parquet file. This function writes episode-level metadata to a single parquet file.
@@ -406,6 +416,16 @@ def load_image_as_numpy(
return img_array return img_array
def load_audio_from_path(fpath: str | Path) -> np.ndarray:
audio_data, _ = read(fpath, dtype="float32")
# Fill missing channel dimension when loading mono audio data
if audio_data.ndim == 1:
audio_data = np.expand_dims(audio_data, axis=1)
return audio_data
def hf_transform_to_torch(items_dict: dict[str, list[Any]]) -> dict[str, list[torch.Tensor | str]]: def hf_transform_to_torch(items_dict: dict[str, list[Any]]) -> dict[str, list[torch.Tensor | str]]:
"""Convert a batch from a Hugging Face dataset to torch tensors. """Convert a batch from a Hugging Face dataset to torch tensors.
@@ -574,7 +594,7 @@ def get_hf_features_from_features(features: dict) -> datasets.Features:
""" """
hf_features = {} hf_features = {}
for key, ft in features.items(): for key, ft in features.items():
if ft["dtype"] == "video": if ft["dtype"] == "video" or ft["dtype"] == "audio":
continue continue
elif ft["dtype"] == "image": elif ft["dtype"] == "image":
hf_features[key] = datasets.Image() hf_features[key] = datasets.Image()
@@ -637,7 +657,12 @@ def hw_to_dataset_features(
for key, ftype in hw_features.items() for key, ftype in hw_features.items()
if ftype is float or (isinstance(ftype, PolicyFeature) and ftype.type != FeatureType.VISUAL) if ftype is float or (isinstance(ftype, PolicyFeature) and ftype.type != FeatureType.VISUAL)
} }
cam_fts = {key: shape for key, shape in hw_features.items() if isinstance(shape, tuple)} cam_fts = {
key: shape for key, shape in hw_features.items() if isinstance(shape, tuple) and len(shape) == 3
}
mic_fts = {
key: shape for key, shape in hw_features.items() if isinstance(shape, tuple) and len(shape) == 2
}
if joint_fts and prefix == ACTION: if joint_fts and prefix == ACTION:
features[prefix] = { features[prefix] = {
@@ -660,6 +685,14 @@ def hw_to_dataset_features(
"names": ["height", "width", "channels"], "names": ["height", "width", "channels"],
} }
for key, parameters in mic_fts.items():
features[f"{prefix}.audio.{key}"] = {
"dtype": "audio",
"shape": (len(parameters[1]),),
"names": ["channels"],
"info": {"sample_rate": parameters[0]},
}
_validate_feature_names(features) _validate_feature_names(features)
return features return features
@@ -689,6 +722,8 @@ def build_dataset_frame(
frame[key] = np.array([values[name] for name in ft["names"]], dtype=np.float32) frame[key] = np.array([values[name] for name in ft["names"]], dtype=np.float32)
elif ft["dtype"] in ["image", "video"]: elif ft["dtype"] in ["image", "video"]:
frame[key] = values[key.removeprefix(f"{prefix}.images.")] frame[key] = values[key.removeprefix(f"{prefix}.images.")]
elif ft["dtype"] == "audio":
frame[key] = values[key.removeprefix(f"{prefix}.audio.")]
return frame return frame
@@ -722,6 +757,10 @@ def dataset_to_policy_features(features: dict[str, dict]) -> dict[str, PolicyFea
# Backward compatibility for "channel" which is an error introduced in LeRobotDataset v2.0 for ported datasets. # Backward compatibility for "channel" which is an error introduced in LeRobotDataset v2.0 for ported datasets.
if names[2] in ["channel", "channels"]: # (h, w, c) -> (c, h, w) if names[2] in ["channel", "channels"]: # (h, w, c) -> (c, h, w)
shape = (shape[2], shape[0], shape[1]) shape = (shape[2], shape[0], shape[1])
elif ft["dtype"] == "audio":
type = FeatureType.AUDIO
if len(shape) != 2:
raise ValueError(f"Number of dimensions of {key} != 2 (shape={shape})")
elif key == OBS_ENV_STATE: elif key == OBS_ENV_STATE:
type = FeatureType.ENV type = FeatureType.ENV
elif key.startswith(OBS_STR): elif key.startswith(OBS_STR):
@@ -800,6 +839,7 @@ def create_empty_dataset_info(
chunks_size: int | None = None, chunks_size: int | None = None,
data_files_size_in_mb: int | None = None, data_files_size_in_mb: int | None = None,
video_files_size_in_mb: int | None = None, video_files_size_in_mb: int | None = None,
audio_files_size_in_mb: int | None = None,
) -> dict: ) -> dict:
"""Create a template dictionary for a new dataset's `info.json`. """Create a template dictionary for a new dataset's `info.json`.
@@ -809,6 +849,10 @@ def create_empty_dataset_info(
features (dict): The LeRobot features dictionary for the dataset. features (dict): The LeRobot features dictionary for the dataset.
use_videos (bool): Whether the dataset will store videos. use_videos (bool): Whether the dataset will store videos.
robot_type (str | None): The type of robot used, if any. robot_type (str | None): The type of robot used, if any.
chunks_size (int | None): The maximum number of files per chunk directory.
data_files_size_in_mb (int | None): The maximum size for data files in MB.
video_files_size_in_mb (int | None): The maximum size for video files in MB.
audio_files_size_in_mb (int | None): The maximum size for audio files in MB.
Returns: Returns:
dict: A dictionary with the initial dataset metadata. dict: A dictionary with the initial dataset metadata.
@@ -822,10 +866,12 @@ def create_empty_dataset_info(
"chunks_size": chunks_size or DEFAULT_CHUNK_SIZE, "chunks_size": chunks_size or DEFAULT_CHUNK_SIZE,
"data_files_size_in_mb": data_files_size_in_mb or DEFAULT_DATA_FILE_SIZE_IN_MB, "data_files_size_in_mb": data_files_size_in_mb or DEFAULT_DATA_FILE_SIZE_IN_MB,
"video_files_size_in_mb": video_files_size_in_mb or DEFAULT_VIDEO_FILE_SIZE_IN_MB, "video_files_size_in_mb": video_files_size_in_mb or DEFAULT_VIDEO_FILE_SIZE_IN_MB,
"audio_files_size_in_mb": audio_files_size_in_mb or DEFAULT_AUDIO_FILE_SIZE_IN_MB,
"fps": fps, "fps": fps,
"splits": {}, "splits": {},
"data_path": DEFAULT_DATA_PATH, "data_path": DEFAULT_DATA_PATH,
"video_path": DEFAULT_VIDEO_PATH if use_videos else None, "video_path": DEFAULT_VIDEO_PATH if use_videos else None,
"audio_path": DEFAULT_AUDIO_PATH,
"features": features, "features": features,
} }
@@ -1049,6 +1095,8 @@ def validate_feature_dtype_and_shape(
return validate_feature_numpy_array(name, expected_dtype, expected_shape, value) return validate_feature_numpy_array(name, expected_dtype, expected_shape, value)
elif expected_dtype in ["image", "video"]: elif expected_dtype in ["image", "video"]:
return validate_feature_image_or_video(name, expected_shape, value) return validate_feature_image_or_video(name, expected_shape, value)
elif expected_dtype == "audio":
return validate_feature_audio(name, expected_shape, value)
elif expected_dtype == "string": elif expected_dtype == "string":
return validate_feature_string(name, value) return validate_feature_string(name, value)
else: else:
@@ -1115,6 +1163,23 @@ def validate_feature_image_or_video(
return error_message return error_message
def validate_feature_audio(name: str, expected_shape: list[str], value: np.ndarray):
error_message = ""
if isinstance(value, np.ndarray):
actual_shape = value.shape
c = expected_shape
if (len(actual_shape) != 2 and len(actual_shape) != 1) or actual_shape[-1] != c[
-1
]: # The number of frames might be different
error_message += (
f"The feature '{name}' of shape '{actual_shape}' does not have the expected shape '{c}'.\n"
)
else:
error_message += f"The feature '{name}' is expected to be of type 'np.ndarray', but type '{type(value)}' provided instead.\n"
return error_message
def validate_feature_string(name: str, value: str) -> str: def validate_feature_string(name: str, value: str) -> str:
"""Validate a feature that is expected to be a string. """Validate a feature that is expected to be a string.
@@ -1232,7 +1297,7 @@ class LookAheadError(Exception):
pass pass
class Backtrackable[T]: class Backtrackable(Generic[T]):
""" """
Wrap any iterator/iterable so you can step back up to `history` items Wrap any iterator/iterable so you can step back up to `history` items
and look ahead up to `lookahead` items. and look ahead up to `lookahead` items.
@@ -36,11 +36,8 @@ Convert a local dataset (works in place):
```bash ```bash
python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \ python src/lerobot/datasets/v30/convert_dataset_v21_to_v30.py \
--repo-id=lerobot/pusht \ --repo-id=lerobot/pusht \
--root=/path/to/local/dataset/directory \ --root=/path/to/local/dataset/directory
--push-to-hub=false --push-to-hub=false
N.B. Path semantics (v2): --root is the exact dataset folder containing
meta/, data/, videos/. When omitted, defaults to $HF_LEROBOT_HOME/{repo_id}.
``` ```
""" """
@@ -62,6 +59,8 @@ from requests import HTTPError
from lerobot.datasets.compute_stats import aggregate_stats from lerobot.datasets.compute_stats import aggregate_stats
from lerobot.datasets.lerobot_dataset import CODEBASE_VERSION, LeRobotDataset from lerobot.datasets.lerobot_dataset import CODEBASE_VERSION, LeRobotDataset
from lerobot.datasets.utils import ( from lerobot.datasets.utils import (
DEFAULT_AUDIO_FILE_SIZE_IN_MB,
DEFAULT_AUDIO_PATH,
DEFAULT_CHUNK_SIZE, DEFAULT_CHUNK_SIZE,
DEFAULT_DATA_FILE_SIZE_IN_MB, DEFAULT_DATA_FILE_SIZE_IN_MB,
DEFAULT_DATA_PATH, DEFAULT_DATA_PATH,
@@ -82,7 +81,7 @@ from lerobot.datasets.utils import (
write_stats, write_stats,
write_tasks, write_tasks,
) )
from lerobot.datasets.video_utils import concatenate_video_files, get_video_duration_in_s from lerobot.datasets.video_utils import concatenate_media_files, get_media_duration_in_s
from lerobot.utils.constants import HF_LEROBOT_HOME from lerobot.utils.constants import HF_LEROBOT_HOME
from lerobot.utils.utils import init_logging from lerobot.utils.utils import init_logging
@@ -108,7 +107,7 @@ episodes.jsonl
{"episode_index": 1, "tasks": ["Put the blue block in the green bowl"], "length": 266} {"episode_index": 1, "tasks": ["Put the blue block in the green bowl"], "length": 266}
NEW NEW
meta/episodes/chunk-000/file_000.parquet meta/episodes/chunk-000/episodes_000.parquet
episode_index | video_chunk_index | video_file_index | data_chunk_index | data_file_index | tasks | length episode_index | video_chunk_index | video_file_index | data_chunk_index | data_file_index | tasks | length
------------------------- -------------------------
OLD OLD
@@ -116,16 +115,15 @@ tasks.jsonl
{"task_index": 1, "task": "Put the blue block in the green bowl"} {"task_index": 1, "task": "Put the blue block in the green bowl"}
NEW NEW
meta/tasks.parquet meta/tasks/chunk-000/file_000.parquet
task_index | task task_index | task
------------------------- -------------------------
OLD OLD
episodes_stats.jsonl episodes_stats.jsonl
{"episode_index": 1, "stats": {"feature_name": {"min": ..., "max": ..., "mean": ..., "std": ..., "count": ...}}}
NEW NEW
meta/episodes/chunk-000/file_000.parquet meta/episodes_stats/chunk-000/file_000.parquet
episode_index | feature_name/min | feature_name/max | feature_name/mean | feature_name/std | feature_name/count episode_index | mean | std | min | max
------------------------- -------------------------
UPDATE UPDATE
meta/info.json meta/info.json
@@ -174,7 +172,7 @@ def convert_tasks(root, new_root):
tasks, _ = legacy_load_tasks(root) tasks, _ = legacy_load_tasks(root)
task_indices = tasks.keys() task_indices = tasks.keys()
task_strings = tasks.values() task_strings = tasks.values()
df_tasks = pd.DataFrame({"task_index": task_indices}, index=pd.Index(task_strings, name="task")) df_tasks = pd.DataFrame({"task_index": task_indices}, index=task_strings)
write_tasks(df_tasks, new_root) write_tasks(df_tasks, new_root)
@@ -205,6 +203,7 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
image_keys = get_image_keys(root) image_keys = get_image_keys(root)
ep_idx = 0
chunk_idx = 0 chunk_idx = 0
file_idx = 0 file_idx = 0
size_in_mb = 0 size_in_mb = 0
@@ -214,23 +213,9 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
logging.info(f"Converting data files from {len(ep_paths)} episodes") logging.info(f"Converting data files from {len(ep_paths)} episodes")
for ep_idx, ep_path in enumerate(tqdm.tqdm(ep_paths, desc="convert data files")): for ep_path in tqdm.tqdm(ep_paths, desc="convert data files"):
ep_size_in_mb = get_parquet_file_size_in_mb(ep_path) ep_size_in_mb = get_parquet_file_size_in_mb(ep_path)
ep_num_frames = get_parquet_num_frames(ep_path) ep_num_frames = get_parquet_num_frames(ep_path)
# Check if we need to start a new file BEFORE creating metadata
if size_in_mb + ep_size_in_mb >= data_file_size_in_mb and len(paths_to_cat) > 0:
# Write the accumulated data files
concat_data_files(paths_to_cat, new_root, chunk_idx, file_idx, image_keys)
# Move to next file
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
# Reset for the next file
size_in_mb = 0
paths_to_cat = []
# Now create metadata with correct chunk/file indices
ep_metadata = { ep_metadata = {
"episode_index": ep_idx, "episode_index": ep_idx,
"data/chunk_index": chunk_idx, "data/chunk_index": chunk_idx,
@@ -241,7 +226,20 @@ def convert_data(root: Path, new_root: Path, data_file_size_in_mb: int):
size_in_mb += ep_size_in_mb size_in_mb += ep_size_in_mb
num_frames += ep_num_frames num_frames += ep_num_frames
episodes_metadata.append(ep_metadata) episodes_metadata.append(ep_metadata)
ep_idx += 1
if size_in_mb < data_file_size_in_mb:
paths_to_cat.append(ep_path) paths_to_cat.append(ep_path)
continue
if paths_to_cat:
concat_data_files(paths_to_cat, new_root, chunk_idx, file_idx, image_keys)
# Reset for the next file
size_in_mb = ep_size_in_mb
paths_to_cat = [ep_path]
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
# Write remaining data if any # Write remaining data if any
if paths_to_cat: if paths_to_cat:
@@ -315,12 +313,12 @@ def convert_videos_of_camera(root: Path, new_root: Path, video_key: str, video_f
for ep_path in tqdm.tqdm(ep_paths, desc=f"convert videos of {video_key}"): for ep_path in tqdm.tqdm(ep_paths, desc=f"convert videos of {video_key}"):
ep_size_in_mb = get_file_size_in_mb(ep_path) ep_size_in_mb = get_file_size_in_mb(ep_path)
ep_duration_in_s = get_video_duration_in_s(ep_path) ep_duration_in_s = get_media_duration_in_s(ep_path, media_type="video")
# Check if adding this episode would exceed the limit # Check if adding this episode would exceed the limit
if size_in_mb + ep_size_in_mb >= video_file_size_in_mb and len(paths_to_cat) > 0: if size_in_mb + ep_size_in_mb >= video_file_size_in_mb and len(paths_to_cat) > 0:
# Size limit would be exceeded, save current accumulation WITHOUT this episode # Size limit would be exceeded, save current accumulation WITHOUT this episode
concatenate_video_files( concatenate_media_files(
paths_to_cat, paths_to_cat,
new_root new_root
/ DEFAULT_VIDEO_PATH.format(video_key=video_key, chunk_index=chunk_idx, file_index=file_idx), / DEFAULT_VIDEO_PATH.format(video_key=video_key, chunk_index=chunk_idx, file_index=file_idx),
@@ -356,7 +354,7 @@ def convert_videos_of_camera(root: Path, new_root: Path, video_key: str, video_f
# Write remaining videos if any # Write remaining videos if any
if paths_to_cat: if paths_to_cat:
concatenate_video_files( concatenate_media_files(
paths_to_cat, paths_to_cat,
new_root new_root
/ DEFAULT_VIDEO_PATH.format(video_key=video_key, chunk_index=chunk_idx, file_index=file_idx), / DEFAULT_VIDEO_PATH.format(video_key=video_key, chunk_index=chunk_idx, file_index=file_idx),
@@ -371,8 +369,124 @@ def convert_videos_of_camera(root: Path, new_root: Path, video_key: str, video_f
return episodes_metadata return episodes_metadata
def get_audio_keys(root):
info = load_info(root)
features = info["features"]
audio_keys = [key for key, ft in features.items() if ft["dtype"] == "audio"]
return audio_keys
def convert_audios(root: Path, new_root: Path, audio_file_size_in_mb: int):
logging.info(f"Converting audios from {root} to {new_root}")
audio_keys = get_audio_keys(root)
if len(audio_keys) == 0:
return None
audio_keys = sorted(audio_keys)
eps_metadata_per_mic = []
for microphone in audio_keys:
eps_metadata = convert_audios_of_microphone(root, new_root, microphone, audio_file_size_in_mb)
eps_metadata_per_mic.append(eps_metadata)
num_eps_per_mic = [len(eps_mic_map) for eps_mic_map in eps_metadata_per_mic]
if len(set(num_eps_per_mic)) != 1:
raise ValueError(f"All microphones dont have same number of episodes ({num_eps_per_mic}).")
episodes_metadata = []
num_microphones = len(audio_keys)
num_episodes = num_eps_per_mic[0]
for ep_idx in tqdm.tqdm(range(num_episodes), desc="convert audios"):
# Sanity check
ep_ids = [
eps_metadata_per_mic[mic_idx][ep_idx]["episode_index"] for mic_idx in range(num_microphones)
]
ep_ids += [ep_idx]
if len(set(ep_ids)) != 1:
raise ValueError(f"All episode indices need to match ({ep_ids}).")
ep_dict = {}
for mic_idx in range(num_microphones):
ep_dict.update(eps_metadata_per_mic[mic_idx][ep_idx])
episodes_metadata.append(ep_dict)
return episodes_metadata
def convert_audios_of_microphone(root: Path, new_root: Path, audio_key: str, audio_file_size_in_mb: int):
# Access old paths to m4a
audios_dir = root / "audio"
ep_paths = sorted(audios_dir.glob(f"*/{audio_key}/*.m4a"))
ep_idx = 0
chunk_idx = 0
file_idx = 0
size_in_mb = 0
duration_in_s = 0.0
paths_to_cat = []
episodes_metadata = []
for ep_path in tqdm.tqdm(ep_paths, desc=f"convert audios of {audio_key}"):
ep_size_in_mb = get_file_size_in_mb(ep_path)
ep_duration_in_s = get_media_duration_in_s(ep_path, media_type="audio")
# Check if adding this episode would exceed the limit
if size_in_mb + ep_size_in_mb >= audio_file_size_in_mb and len(paths_to_cat) > 0:
# Size limit would be exceeded, save current accumulation WITHOUT this episode
concatenate_media_files(
paths_to_cat,
new_root
/ DEFAULT_AUDIO_PATH.format(audio_key=audio_key, chunk_index=chunk_idx, file_index=file_idx),
)
# Update episodes metadata for the file we just saved
for i, _ in enumerate(paths_to_cat):
past_ep_idx = ep_idx - len(paths_to_cat) + i
episodes_metadata[past_ep_idx][f"audio/{audio_key}/chunk_index"] = chunk_idx
episodes_metadata[past_ep_idx][f"audio/{audio_key}/file_index"] = file_idx
# Move to next file and start fresh with current episode
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, DEFAULT_CHUNK_SIZE)
size_in_mb = 0
duration_in_s = 0.0
paths_to_cat = []
# Add current episode metadata
ep_metadata = {
"episode_index": ep_idx,
f"audio/{audio_key}/chunk_index": chunk_idx, # Will be updated when file is saved
f"audio/{audio_key}/file_index": file_idx, # Will be updated when file is saved
f"audio/{audio_key}/from_timestamp": duration_in_s,
f"audio/{audio_key}/to_timestamp": duration_in_s + ep_duration_in_s,
}
episodes_metadata.append(ep_metadata)
# Add current episode to accumulation
paths_to_cat.append(ep_path)
size_in_mb += ep_size_in_mb
duration_in_s += ep_duration_in_s
ep_idx += 1
# Write remaining videos if any
if paths_to_cat:
concatenate_media_files(
paths_to_cat,
new_root
/ DEFAULT_AUDIO_PATH.format(audio_key=audio_key, chunk_index=chunk_idx, file_index=file_idx),
)
# Update episodes metadata for the final file
for i, _ in enumerate(paths_to_cat):
past_ep_idx = ep_idx - len(paths_to_cat) + i
episodes_metadata[past_ep_idx][f"audio/{audio_key}/chunk_index"] = chunk_idx
episodes_metadata[past_ep_idx][f"audio/{audio_key}/file_index"] = file_idx
return episodes_metadata
def generate_episode_metadata_dict( def generate_episode_metadata_dict(
episodes_legacy_metadata, episodes_metadata, episodes_stats, episodes_videos=None episodes_legacy_metadata, episodes_metadata, episodes_stats, episodes_videos=None, episodes_audios=None
): ):
num_episodes = len(episodes_metadata) num_episodes = len(episodes_metadata)
episodes_legacy_metadata_vals = list(episodes_legacy_metadata.values()) episodes_legacy_metadata_vals = list(episodes_legacy_metadata.values())
@@ -396,16 +510,30 @@ def generate_episode_metadata_dict(
ep_video = episodes_videos[i] ep_video = episodes_videos[i]
ep_ids_set.add(ep_video["episode_index"]) ep_ids_set.add(ep_video["episode_index"])
if episodes_audios is None:
ep_audio = {}
else:
ep_audio = episodes_audios[i]
ep_ids_set.add(ep_audio["episode_index"])
if len(ep_ids_set) != 1: if len(ep_ids_set) != 1:
raise ValueError(f"Number of episodes is not the same ({ep_ids_set}).") raise ValueError(f"Number of episodes is not the same ({ep_ids_set}).")
ep_dict = {**ep_metadata, **ep_video, **ep_legacy_metadata, **flatten_dict({"stats": ep_stats})} ep_dict = {
**ep_metadata,
**ep_video,
**ep_audio,
**ep_legacy_metadata,
**flatten_dict({"stats": ep_stats}),
}
ep_dict["meta/episodes/chunk_index"] = 0 ep_dict["meta/episodes/chunk_index"] = 0
ep_dict["meta/episodes/file_index"] = 0 ep_dict["meta/episodes/file_index"] = 0
yield ep_dict yield ep_dict
def convert_episodes_metadata(root, new_root, episodes_metadata, episodes_video_metadata=None): def convert_episodes_metadata(
root, new_root, episodes_metadata, episodes_video_metadata=None, episodes_audio_metadata=None
):
logging.info(f"Converting episodes metadata from {root} to {new_root}") logging.info(f"Converting episodes metadata from {root} to {new_root}")
episodes_legacy_metadata = legacy_load_episodes(root) episodes_legacy_metadata = legacy_load_episodes(root)
@@ -414,13 +542,19 @@ def convert_episodes_metadata(root, new_root, episodes_metadata, episodes_video_
num_eps_set = {len(episodes_legacy_metadata), len(episodes_metadata)} num_eps_set = {len(episodes_legacy_metadata), len(episodes_metadata)}
if episodes_video_metadata is not None: if episodes_video_metadata is not None:
num_eps_set.add(len(episodes_video_metadata)) num_eps_set.add(len(episodes_video_metadata))
if episodes_audio_metadata is not None:
num_eps_set.add(len(episodes_audio_metadata))
if len(num_eps_set) != 1: if len(num_eps_set) != 1:
raise ValueError(f"Number of episodes is not the same ({num_eps_set}).") raise ValueError(f"Number of episodes is not the same ({num_eps_set}).")
ds_episodes = Dataset.from_generator( ds_episodes = Dataset.from_generator(
lambda: generate_episode_metadata_dict( lambda: generate_episode_metadata_dict(
episodes_legacy_metadata, episodes_metadata, episodes_stats, episodes_video_metadata episodes_legacy_metadata,
episodes_metadata,
episodes_stats,
episodes_video_metadata,
episodes_audio_metadata,
) )
) )
write_episodes(ds_episodes, new_root) write_episodes(ds_episodes, new_root)
@@ -429,20 +563,22 @@ def convert_episodes_metadata(root, new_root, episodes_metadata, episodes_video_
write_stats(stats, new_root) write_stats(stats, new_root)
def convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb): def convert_info(root, new_root, data_file_size_in_mb, video_file_size_in_mb, audio_file_size_in_mb):
info = load_info(root) info = load_info(root)
info["codebase_version"] = V30 info["codebase_version"] = V30
del info["total_chunks"] del info["total_chunks"]
del info["total_videos"] del info["total_videos"]
info["data_files_size_in_mb"] = data_file_size_in_mb info["data_files_size_in_mb"] = data_file_size_in_mb
info["video_files_size_in_mb"] = video_file_size_in_mb info["video_files_size_in_mb"] = video_file_size_in_mb
info["audio_files_size_in_mb"] = audio_file_size_in_mb
info["data_path"] = DEFAULT_DATA_PATH info["data_path"] = DEFAULT_DATA_PATH
info["video_path"] = DEFAULT_VIDEO_PATH if info["video_path"] is not None else None info["video_path"] = DEFAULT_VIDEO_PATH if info["video_path"] is not None else None
info["audio_path"] = DEFAULT_AUDIO_PATH if info["audio_path"] is not None else None
info["fps"] = int(info["fps"]) info["fps"] = int(info["fps"])
logging.info(f"Converting info from {root} to {new_root}") logging.info(f"Converting info from {root} to {new_root}")
for key in info["features"]: for key in info["features"]:
if info["features"][key]["dtype"] == "video": if info["features"][key]["dtype"] == "video" or info["features"][key]["dtype"] == "audio":
# already has fps in video_info # already has fps in video_info or audio_info
continue continue
info["features"][key]["fps"] = info["fps"] info["features"][key]["fps"] = info["fps"]
write_info(info, new_root) write_info(info, new_root)
@@ -453,6 +589,7 @@ def convert_dataset(
branch: str | None = None, branch: str | None = None,
data_file_size_in_mb: int | None = None, data_file_size_in_mb: int | None = None,
video_file_size_in_mb: int | None = None, video_file_size_in_mb: int | None = None,
audio_file_size_in_mb: int | None = None,
root: str | Path | None = None, root: str | Path | None = None,
push_to_hub: bool = True, push_to_hub: bool = True,
force_conversion: bool = False, force_conversion: bool = False,
@@ -461,6 +598,8 @@ def convert_dataset(
data_file_size_in_mb = DEFAULT_DATA_FILE_SIZE_IN_MB data_file_size_in_mb = DEFAULT_DATA_FILE_SIZE_IN_MB
if video_file_size_in_mb is None: if video_file_size_in_mb is None:
video_file_size_in_mb = DEFAULT_VIDEO_FILE_SIZE_IN_MB video_file_size_in_mb = DEFAULT_VIDEO_FILE_SIZE_IN_MB
if audio_file_size_in_mb is None:
audio_file_size_in_mb = DEFAULT_AUDIO_FILE_SIZE_IN_MB
# First check if the dataset already has a v3.0 version # First check if the dataset already has a v3.0 version
if root is None and not force_conversion: if root is None and not force_conversion:
@@ -473,7 +612,7 @@ def convert_dataset(
# Set root based on whether local dataset path is provided # Set root based on whether local dataset path is provided
use_local_dataset = False use_local_dataset = False
root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) root = HF_LEROBOT_HOME / repo_id if root is None else Path(root) / repo_id
if root.exists(): if root.exists():
validate_local_dataset_version(root) validate_local_dataset_version(root)
use_local_dataset = True use_local_dataset = True
@@ -502,7 +641,10 @@ def convert_dataset(
convert_tasks(root, new_root) convert_tasks(root, new_root)
episodes_metadata = convert_data(root, new_root, data_file_size_in_mb) episodes_metadata = convert_data(root, new_root, data_file_size_in_mb)
episodes_videos_metadata = convert_videos(root, new_root, video_file_size_in_mb) episodes_videos_metadata = convert_videos(root, new_root, video_file_size_in_mb)
convert_episodes_metadata(root, new_root, episodes_metadata, episodes_videos_metadata) episodes_audios_metadata = convert_audios(root, new_root, audio_file_size_in_mb)
convert_episodes_metadata(
root, new_root, episodes_metadata, episodes_videos_metadata, episodes_audios_metadata
)
shutil.move(str(root), str(old_root)) shutil.move(str(root), str(old_root))
shutil.move(str(new_root), str(root)) shutil.move(str(new_root), str(root))
@@ -515,7 +657,7 @@ def convert_dataset(
print(f"tag={CODEBASE_VERSION} probably doesn't exist. Skipping exception ({e})") print(f"tag={CODEBASE_VERSION} probably doesn't exist. Skipping exception ({e})")
pass pass
hub_api.delete_files( hub_api.delete_files(
delete_patterns=["data/chunk*/episode_*", "meta/*.jsonl", "videos/chunk*"], delete_patterns=["data/chunk*/episode_*", "meta/*.jsonl", "videos/chunk*", "audio/chunk*"],
repo_id=repo_id, repo_id=repo_id,
revision=branch, revision=branch,
repo_type="dataset", repo_type="dataset",
@@ -533,7 +675,7 @@ if __name__ == "__main__":
type=str, type=str,
required=True, required=True,
help="Repository identifier on Hugging Face: a community or a user name `/` the name of the dataset " help="Repository identifier on Hugging Face: a community or a user name `/` the name of the dataset "
"(e.g. `lerobot/pusht`, `<USER>/aloha_sim_insertion_human`).", "(e.g. `lerobot/pusht`, `cadene/aloha_sim_insertion_human`).",
) )
parser.add_argument( parser.add_argument(
"--branch", "--branch",
@@ -553,11 +695,17 @@ if __name__ == "__main__":
default=None, default=None,
help="File size in MB. Defaults to 100 for data and 500 for videos.", help="File size in MB. Defaults to 100 for data and 500 for videos.",
) )
parser.add_argument(
"--audio-file-size-in-mb",
type=int,
default=None,
help="File size in MB. Defaults to 100 for audio.",
)
parser.add_argument( parser.add_argument(
"--root", "--root",
type=str, type=str,
default=None, default=None,
help="Local directory to use for downloading/writing the dataset. Defaults to $HF_LEROBOT_HOME/repo_id.", help="Local directory to use for downloading/writing the dataset.",
) )
parser.add_argument( parser.add_argument(
"--push-to-hub", "--push-to-hub",
+96 -531
View File
@@ -13,106 +13,25 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import contextlib
import glob import glob
import importlib import importlib
import logging import logging
import queue
import shutil import shutil
import tempfile import tempfile
import threading
import warnings import warnings
from dataclasses import dataclass, field from dataclasses import dataclass, field
from fractions import Fraction
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
from typing import Any, ClassVar from typing import Any, ClassVar
import av import av
import fsspec import fsspec
import numpy as np
import pyarrow as pa import pyarrow as pa
import torch import torch
import torchvision import torchvision
from datasets.features.features import register_feature from datasets.features.features import register_feature
from PIL import Image from PIL import Image
# List of hardware encoders to probe for auto-selection. Availability depends on the platform and FFmpeg build.
# Determines the order of preference for auto-selection when vcodec="auto" is used.
HW_ENCODERS = [
"h264_videotoolbox", # macOS
"hevc_videotoolbox", # macOS
"h264_nvenc", # NVIDIA GPU
"hevc_nvenc", # NVIDIA GPU
"h264_vaapi", # Linux Intel/AMD
"h264_qsv", # Intel Quick Sync
]
VALID_VIDEO_CODECS = {"h264", "hevc", "libsvtav1", "auto"} | set(HW_ENCODERS)
def _get_codec_options(
vcodec: str,
g: int | None = 2,
crf: int | None = 30,
preset: int | None = None,
) -> dict:
"""Build codec-specific options dict for video encoding."""
options = {}
# GOP size (keyframe interval) - supported by VideoToolbox and software encoders
if g is not None and (vcodec in ("h264_videotoolbox", "hevc_videotoolbox") or vcodec not in HW_ENCODERS):
options["g"] = str(g)
# Quality control (codec-specific parameter names)
if crf is not None:
if vcodec in ("h264", "hevc", "libsvtav1"):
options["crf"] = str(crf)
elif vcodec in ("h264_videotoolbox", "hevc_videotoolbox"):
quality = max(1, min(100, int(100 - crf * 2)))
options["q:v"] = str(quality)
elif vcodec in ("h264_nvenc", "hevc_nvenc"):
options["rc"] = "constqp"
options["qp"] = str(crf)
elif vcodec in ("h264_vaapi",):
options["qp"] = str(crf)
elif vcodec in ("h264_qsv",):
options["global_quality"] = str(crf)
# Preset (only for libsvtav1)
if vcodec == "libsvtav1":
options["preset"] = str(preset) if preset is not None else "12"
return options
def detect_available_hw_encoders() -> list[str]:
"""Probe PyAV/FFmpeg for available hardware video encoders."""
available = []
for codec_name in HW_ENCODERS:
try:
av.codec.Codec(codec_name, "w")
available.append(codec_name)
except Exception: # nosec B110
pass # nosec B110
return available
def resolve_vcodec(vcodec: str) -> str:
"""Validate vcodec and resolve 'auto' to best available HW encoder, fallback to libsvtav1."""
if vcodec not in VALID_VIDEO_CODECS:
raise ValueError(f"Invalid vcodec '{vcodec}'. Must be one of: {sorted(VALID_VIDEO_CODECS)}")
if vcodec != "auto":
logging.info(f"Using video codec: {vcodec}")
return vcodec
available = detect_available_hw_encoders()
for encoder in HW_ENCODERS:
if encoder in available:
logging.info(f"Auto-selected video codec: {encoder}")
return encoder
logging.info("No hardware encoder available, falling back to software encoder 'libsvtav1'")
return "libsvtav1"
def get_safe_default_codec(): def get_safe_default_codec():
if importlib.util.find_spec("torchcodec"): if importlib.util.find_spec("torchcodec"):
@@ -227,12 +146,11 @@ def decode_video_frames_torchvision(
min_, argmin_ = dist.min(1) min_, argmin_ = dist.min(1)
is_within_tol = min_ < tolerance_s is_within_tol = min_ < tolerance_s
if not is_within_tol.all(): assert is_within_tol.all(), (
raise FrameTimestampError(
f"One or several query timestamps unexpectedly violate the tolerance ({min_[~is_within_tol]} > {tolerance_s=})." f"One or several query timestamps unexpectedly violate the tolerance ({min_[~is_within_tol]} > {tolerance_s=})."
" It means that the closest frame that can be loaded from the video is too far away in time." "It means that the closest frame that can be loaded from the video is too far away in time."
" This might be due to synchronization issues with timestamps during data collection." "This might be due to synchronization issues with timestamps during data collection."
" To be safe, we advise to ignore this item during training." "To be safe, we advise to ignore this item during training."
f"\nqueried timestamps: {query_ts}" f"\nqueried timestamps: {query_ts}"
f"\nloaded timestamps: {loaded_ts}" f"\nloaded timestamps: {loaded_ts}"
f"\nvideo: {video_path}" f"\nvideo: {video_path}"
@@ -249,11 +167,7 @@ def decode_video_frames_torchvision(
# convert to the pytorch format which is float32 in [0,1] range (and channel first) # convert to the pytorch format which is float32 in [0,1] range (and channel first)
closest_frames = closest_frames.type(torch.float32) / 255 closest_frames = closest_frames.type(torch.float32) / 255
if len(timestamps) != len(closest_frames): assert len(timestamps) == len(closest_frames)
raise FrameTimestampError(
f"Number of retrieved frames ({len(closest_frames)}) does not match "
f"number of queried timestamps ({len(timestamps)})"
)
return closest_frames return closest_frames
@@ -358,12 +272,11 @@ def decode_video_frames_torchcodec(
min_, argmin_ = dist.min(1) min_, argmin_ = dist.min(1)
is_within_tol = min_ < tolerance_s is_within_tol = min_ < tolerance_s
if not is_within_tol.all(): assert is_within_tol.all(), (
raise FrameTimestampError(
f"One or several query timestamps unexpectedly violate the tolerance ({min_[~is_within_tol]} > {tolerance_s=})." f"One or several query timestamps unexpectedly violate the tolerance ({min_[~is_within_tol]} > {tolerance_s=})."
" It means that the closest frame that can be loaded from the video is too far away in time." "It means that the closest frame that can be loaded from the video is too far away in time."
" This might be due to synchronization issues with timestamps during data collection." "This might be due to synchronization issues with timestamps during data collection."
" To be safe, we advise to ignore this item during training." "To be safe, we advise to ignore this item during training."
f"\nqueried timestamps: {query_ts}" f"\nqueried timestamps: {query_ts}"
f"\nloaded timestamps: {loaded_ts}" f"\nloaded timestamps: {loaded_ts}"
f"\nvideo: {video_path}" f"\nvideo: {video_path}"
@@ -396,13 +309,14 @@ def encode_video_frames(
g: int | None = 2, g: int | None = 2,
crf: int | None = 30, crf: int | None = 30,
fast_decode: int = 0, fast_decode: int = 0,
log_level: int | None = av.logging.WARNING, log_level: int | None = av.logging.ERROR,
overwrite: bool = False, overwrite: bool = False,
preset: int | None = None, preset: int | None = None,
encoder_threads: int | None = None,
) -> None: ) -> None:
"""More info on ffmpeg arguments tuning on `benchmark/video/README.md`""" """More info on ffmpeg arguments tuning on `benchmark/video/README.md`"""
vcodec = resolve_vcodec(vcodec) # Check encoder availability
if vcodec not in ["h264", "hevc", "libsvtav1"]:
raise ValueError(f"Unsupported video codec: {vcodec}. Supported codecs are: h264, hevc, libsvtav1.")
video_path = Path(video_path) video_path = Path(video_path)
imgs_dir = Path(imgs_dir) imgs_dir = Path(imgs_dir)
@@ -433,22 +347,21 @@ def encode_video_frames(
width, height = dummy_image.size width, height = dummy_image.size
# Define video codec options # Define video codec options
video_options = _get_codec_options(vcodec, g, crf, preset) video_options = {}
if g is not None:
video_options["g"] = str(g)
if crf is not None:
video_options["crf"] = str(crf)
if fast_decode: if fast_decode:
key = "svtav1-params" if vcodec == "libsvtav1" else "tune" key = "svtav1-params" if vcodec == "libsvtav1" else "tune"
value = f"fast-decode={fast_decode}" if vcodec == "libsvtav1" else "fastdecode" value = f"fast-decode={fast_decode}" if vcodec == "libsvtav1" else "fastdecode"
video_options[key] = value video_options[key] = value
if encoder_threads is not None:
if vcodec == "libsvtav1": if vcodec == "libsvtav1":
lp_param = f"lp={encoder_threads}" video_options["preset"] = str(preset) if preset is not None else "12"
if "svtav1-params" in video_options:
video_options["svtav1-params"] += f":{lp_param}"
else:
video_options["svtav1-params"] = lp_param
else:
video_options["threads"] = str(encoder_threads)
# Set logging level # Set logging level
if log_level is not None: if log_level is not None:
@@ -484,42 +397,42 @@ def encode_video_frames(
raise OSError(f"Video encoding did not work. File not found: {video_path}.") raise OSError(f"Video encoding did not work. File not found: {video_path}.")
def concatenate_video_files( def concatenate_media_files(
input_video_paths: list[Path | str], output_video_path: Path, overwrite: bool = True input_media_paths: list[Path | str], output_media_path: Path, overwrite: bool = True
): ):
""" """
Concatenate multiple video files into a single video file using pyav. Concatenate multiple media files (video & audio) into a single media file using pyav.
This function takes a list of video input file paths and concatenates them into a single This function takes a list of input media file paths and concatenates them into a single
output video file. It uses ffmpeg's concat demuxer with stream copy mode for fast output media file. It uses ffmpeg's concat demuxer with stream copy mode for fast
concatenation without re-encoding. concatenation without re-encoding.
Args: Args:
input_video_paths: Ordered list of input video file paths to concatenate. input_media_paths: Ordered list of input media file paths to concatenate.
output_video_path: Path to the output video file. output_media_path: Path to the output media file.
overwrite: Whether to overwrite the output video file if it already exists. Default is True. overwrite: Whether to overwrite the output media file if it already exists. Default is True.
Note: Note:
- Creates a temporary directory for intermediate files that is cleaned up after use. - Creates a temporary .ffconcat file and container audio/video file that are cleaned up after use.
- Uses ffmpeg's concat demuxer which requires all input videos to have the same - Uses ffmpeg's concat demuxer which requires all input media files to have the same
codec, resolution, and frame rate for proper concatenation. codec, resolution, and frame rate for proper concatenation.
""" """
output_video_path = Path(output_video_path) output_media_path = Path(output_media_path)
if output_video_path.exists() and not overwrite: if output_media_path.exists() and not overwrite:
logging.warning(f"Video file already exists: {output_video_path}. Skipping concatenation.") logging.warning(f"Media file already exists: {output_media_path}. Skipping concatenation.")
return return
output_video_path.parent.mkdir(parents=True, exist_ok=True) output_media_path.parent.mkdir(parents=True, exist_ok=True)
if len(input_video_paths) == 0: if len(input_media_paths) == 0:
raise FileNotFoundError("No input video paths provided.") raise FileNotFoundError("No input media paths provided.")
# Create a temporary .ffconcat file to list the input video paths # Create a temporary .ffconcat file to list the input media paths
with tempfile.NamedTemporaryFile(mode="w", suffix=".ffconcat", delete=False) as tmp_concatenate_file: with tempfile.NamedTemporaryFile(mode="w", suffix=".ffconcat", delete=False) as tmp_concatenate_file:
tmp_concatenate_file.write("ffconcat version 1.0\n") tmp_concatenate_file.write("ffconcat version 1.0\n")
for input_path in input_video_paths: for input_path in input_media_paths:
tmp_concatenate_file.write(f"file '{str(input_path.resolve())}'\n") tmp_concatenate_file.write(f"file '{str(input_path.resolve())}'\n")
tmp_concatenate_file.flush() tmp_concatenate_file.flush()
tmp_concatenate_path = tmp_concatenate_file.name tmp_concatenate_path = tmp_concatenate_file.name
@@ -529,11 +442,12 @@ def concatenate_video_files(
tmp_concatenate_path, mode="r", format="concat", options={"safe": "0"} tmp_concatenate_path, mode="r", format="concat", options={"safe": "0"}
) # safe = 0 allows absolute paths as well as relative paths ) # safe = 0 allows absolute paths as well as relative paths
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as tmp_named_file: # Using an intermediate container to store the concatenated media file is necessary to avoid inplace concatenation read-write race conditions.
tmp_output_video_path = tmp_named_file.name with tempfile.NamedTemporaryFile(suffix=output_media_path.suffix, delete=False) as tmp_named_file:
tmp_output_media_path = tmp_named_file.name
output_container = av.open( output_container = av.open(
tmp_output_video_path, mode="w", options={"movflags": "faststart"} tmp_output_media_path, mode="w", options={"movflags": "faststart"}
) # faststart is to move the metadata to the beginning of the file to speed up loading ) # faststart is to move the metadata to the beginning of the file to speed up loading
# Replicate input streams in output container # Replicate input streams in output container
@@ -548,6 +462,7 @@ def concatenate_video_files(
stream_map[input_stream.index].time_base = input_stream.time_base stream_map[input_stream.index].time_base = input_stream.time_base
# Demux + remux packets (no re-encode) # Demux + remux packets (no re-encode)
last_dts = None
for packet in input_container.demux(): for packet in input_container.demux():
# Skip packets from un-mapped streams # Skip packets from un-mapped streams
if packet.stream.index not in stream_map: if packet.stream.index not in stream_map:
@@ -556,6 +471,16 @@ def concatenate_video_files(
# Skip demux flushing packets # Skip demux flushing packets
if packet.dts is None: if packet.dts is None:
continue continue
else:
# Enforce strictly increasing decoding timestamps (DTS)
if last_dts is not None and packet.dts <= last_dts:
shift = last_dts - packet.dts + 1
packet.dts += shift
packet.pts += shift # Presenting timestamps (PTS) are the same as DTS here
logging.warning(
f"Non-monotonic DTS; previous: {last_dts}, current: {packet.dts - shift}; changing to {packet.dts}. This may result in incorrect timestamps in the output file."
)
last_dts = packet.dts
output_stream = stream_map[packet.stream.index] output_stream = stream_map[packet.stream.index]
packet.stream = output_stream packet.stream = output_stream
@@ -563,352 +488,10 @@ def concatenate_video_files(
input_container.close() input_container.close()
output_container.close() output_container.close()
shutil.move(tmp_output_video_path, output_video_path) shutil.move(tmp_output_media_path, output_media_path)
Path(tmp_concatenate_path).unlink() Path(tmp_concatenate_path).unlink()
class _CameraEncoderThread(threading.Thread):
"""A thread that encodes video frames streamed via a queue into an MP4 file.
One instance is created per camera per episode. Frames are received as numpy arrays
from the main thread, encoded in real-time using PyAV (which releases the GIL during
encoding), and written to disk. Stats are computed incrementally using
RunningQuantileStats and returned via result_queue.
"""
def __init__(
self,
video_path: Path,
fps: int,
vcodec: str,
pix_fmt: str,
g: int | None,
crf: int | None,
preset: int | None,
frame_queue: queue.Queue,
result_queue: queue.Queue,
stop_event: threading.Event,
encoder_threads: int | None = None,
):
super().__init__(daemon=True)
self.video_path = video_path
self.fps = fps
self.vcodec = vcodec
self.pix_fmt = pix_fmt
self.g = g
self.crf = crf
self.preset = preset
self.frame_queue = frame_queue
self.result_queue = result_queue
self.stop_event = stop_event
self.encoder_threads = encoder_threads
def run(self) -> None:
from lerobot.datasets.compute_stats import RunningQuantileStats, auto_downsample_height_width
container = None
output_stream = None
stats_tracker = RunningQuantileStats()
frame_count = 0
try:
logging.getLogger("libav").setLevel(av.logging.WARNING)
while True:
try:
frame_data = self.frame_queue.get(timeout=1)
except queue.Empty:
if self.stop_event.is_set():
break
continue
if frame_data is None:
# Sentinel: flush and close
break
# Ensure HWC uint8 numpy array
if isinstance(frame_data, np.ndarray):
if frame_data.ndim == 3 and frame_data.shape[0] == 3:
# CHW -> HWC
frame_data = frame_data.transpose(1, 2, 0)
if frame_data.dtype != np.uint8:
frame_data = (frame_data * 255).astype(np.uint8)
# Open container on first frame (to get width/height)
if container is None:
height, width = frame_data.shape[:2]
video_options = _get_codec_options(self.vcodec, self.g, self.crf, self.preset)
if self.encoder_threads is not None:
if self.vcodec == "libsvtav1":
lp_param = f"lp={self.encoder_threads}"
if "svtav1-params" in video_options:
video_options["svtav1-params"] += f":{lp_param}"
else:
video_options["svtav1-params"] = lp_param
else:
video_options["threads"] = str(self.encoder_threads)
Path(self.video_path).parent.mkdir(parents=True, exist_ok=True)
container = av.open(str(self.video_path), "w")
output_stream = container.add_stream(self.vcodec, self.fps, options=video_options)
output_stream.pix_fmt = self.pix_fmt
output_stream.width = width
output_stream.height = height
output_stream.time_base = Fraction(1, self.fps)
# Encode frame with explicit timestamps
pil_img = Image.fromarray(frame_data)
video_frame = av.VideoFrame.from_image(pil_img)
video_frame.pts = frame_count
video_frame.time_base = Fraction(1, self.fps)
packet = output_stream.encode(video_frame)
if packet:
container.mux(packet)
# Update stats with downsampled frame (per-channel stats like compute_episode_stats)
img_chw = frame_data.transpose(2, 0, 1) # HWC -> CHW
img_downsampled = auto_downsample_height_width(img_chw)
# 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)
stats_tracker.update(img_for_stats)
frame_count += 1
# Flush encoder
if output_stream is not None:
packet = output_stream.encode()
if packet:
container.mux(packet)
if container is not None:
container.close()
av.logging.restore_default_callback()
# Get stats and put on result queue
if frame_count >= 2:
stats = stats_tracker.get_statistics()
self.result_queue.put(("ok", stats))
else:
self.result_queue.put(("ok", None))
except Exception as e:
logging.error(f"Encoder thread error: {e}")
if container is not None:
with contextlib.suppress(Exception):
container.close()
self.result_queue.put(("error", str(e)))
class StreamingVideoEncoder:
"""Manages per-camera encoder threads for real-time video encoding during recording.
Instead of writing frames as PNG images and then encoding to MP4 at episode end,
this class streams frames directly to encoder threads, eliminating the
PNG round-trip and making save_episode() near-instant.
Uses threading instead of multiprocessing to avoid the overhead of pickling large
numpy arrays through multiprocessing.Queue. PyAV's encode() releases the GIL,
so encoding runs in parallel with the main recording loop.
"""
def __init__(
self,
fps: int,
vcodec: str = "libsvtav1",
pix_fmt: str = "yuv420p",
g: int | None = 2,
crf: int | None = 30,
preset: int | None = None,
queue_maxsize: int = 30,
encoder_threads: int | None = None,
):
self.fps = fps
self.vcodec = resolve_vcodec(vcodec)
self.pix_fmt = pix_fmt
self.g = g
self.crf = crf
self.preset = preset
self.queue_maxsize = queue_maxsize
self.encoder_threads = encoder_threads
self._frame_queues: dict[str, queue.Queue] = {}
self._result_queues: dict[str, queue.Queue] = {}
self._threads: dict[str, _CameraEncoderThread] = {}
self._stop_events: dict[str, threading.Event] = {}
self._video_paths: dict[str, Path] = {}
self._dropped_frames: dict[str, int] = {}
self._episode_active = False
def start_episode(self, video_keys: list[str], temp_dir: Path) -> None:
"""Start encoder threads for a new episode.
Args:
video_keys: List of video feature keys (e.g. ["observation.images.laptop"])
temp_dir: Base directory for temporary MP4 files
"""
if self._episode_active:
self.cancel_episode()
self._dropped_frames.clear()
for video_key in video_keys:
frame_queue: queue.Queue = queue.Queue(maxsize=self.queue_maxsize)
result_queue: queue.Queue = queue.Queue(maxsize=1)
stop_event = threading.Event()
temp_video_dir = Path(tempfile.mkdtemp(dir=temp_dir))
video_path = temp_video_dir / f"{video_key.replace('/', '_')}_streaming.mp4"
encoder_thread = _CameraEncoderThread(
video_path=video_path,
fps=self.fps,
vcodec=self.vcodec,
pix_fmt=self.pix_fmt,
g=self.g,
crf=self.crf,
preset=self.preset,
frame_queue=frame_queue,
result_queue=result_queue,
stop_event=stop_event,
encoder_threads=self.encoder_threads,
)
encoder_thread.start()
self._frame_queues[video_key] = frame_queue
self._result_queues[video_key] = result_queue
self._threads[video_key] = encoder_thread
self._stop_events[video_key] = stop_event
self._video_paths[video_key] = video_path
self._episode_active = True
def feed_frame(self, video_key: str, image: np.ndarray) -> None:
"""Feed a frame to the encoder for a specific camera.
A copy of the image is made before enqueueing to prevent race conditions
with camera drivers that may reuse buffers. If the encoder queue is full
(encoder can't keep up), the frame is dropped with a warning instead of
crashing the recording session.
Args:
video_key: The video feature key
image: numpy array in (H,W,C) or (C,H,W) format, uint8 or float
Raises:
RuntimeError: If the encoder thread has crashed
"""
if not self._episode_active:
raise RuntimeError("No active episode. Call start_episode() first.")
thread = self._threads[video_key]
if not thread.is_alive():
# Check for error
try:
status, msg = self._result_queues[video_key].get_nowait()
if status == "error":
raise RuntimeError(f"Encoder thread for {video_key} crashed: {msg}")
except queue.Empty:
pass
raise RuntimeError(f"Encoder thread for {video_key} is not alive")
try:
self._frame_queues[video_key].put(image.copy(), timeout=0.1)
except queue.Full:
self._dropped_frames[video_key] = self._dropped_frames.get(video_key, 0) + 1
count = self._dropped_frames[video_key]
# Log periodically to avoid spam (1st, then every 10th)
if count == 1 or count % 10 == 0:
logging.warning(
f"Encoder queue full for {video_key}, dropped {count} frame(s). "
f"Consider using vcodec='auto' for hardware encoding or increasing encoder_queue_maxsize."
)
def finish_episode(self) -> dict[str, tuple[Path, dict | None]]:
"""Finish encoding the current episode.
Sends sentinel values, waits for encoder threads to complete,
and collects results.
Returns:
Dict mapping video_key to (mp4_path, stats_dict_or_None)
"""
if not self._episode_active:
raise RuntimeError("No active episode to finish.")
results = {}
# Report dropped frames
for video_key, count in self._dropped_frames.items():
if count > 0:
logging.warning(f"Episode finished with {count} dropped frame(s) for {video_key}.")
# Send sentinel to all queues
for video_key in self._frame_queues:
self._frame_queues[video_key].put(None)
# Wait for all threads and collect results
for video_key in self._threads:
self._threads[video_key].join(timeout=120)
if self._threads[video_key].is_alive():
logging.error(f"Encoder thread for {video_key} did not finish in time")
self._stop_events[video_key].set()
self._threads[video_key].join(timeout=5)
results[video_key] = (self._video_paths[video_key], None)
continue
try:
status, data = self._result_queues[video_key].get(timeout=5)
if status == "error":
raise RuntimeError(f"Encoder thread for {video_key} failed: {data}")
results[video_key] = (self._video_paths[video_key], data)
except queue.Empty:
logging.error(f"No result from encoder thread for {video_key}")
results[video_key] = (self._video_paths[video_key], None)
self._cleanup()
self._episode_active = False
return results
def cancel_episode(self) -> None:
"""Cancel the current episode, stopping encoder threads and cleaning up."""
if not self._episode_active:
return
# Signal all threads to stop
for video_key in self._stop_events:
self._stop_events[video_key].set()
# Wait for threads to finish
for video_key in self._threads:
self._threads[video_key].join(timeout=5)
# Clean up temp MP4 files
video_path = self._video_paths.get(video_key)
if video_path is not None and video_path.exists():
shutil.rmtree(str(video_path.parent), ignore_errors=True)
self._cleanup()
self._episode_active = False
def close(self) -> None:
"""Close the encoder, canceling any in-progress episode."""
if self._episode_active:
self.cancel_episode()
def _cleanup(self) -> None:
"""Clean up queues and thread tracking dicts."""
for q in self._frame_queues.values():
with contextlib.suppress(Exception):
while not q.empty():
q.get_nowait()
self._frame_queues.clear()
self._result_queues.clear()
self._threads.clear()
self._stop_events.clear()
self._video_paths.clear()
@dataclass @dataclass
class VideoFrame: class VideoFrame:
# TODO(rcadene, lhoestq): move to Hugging Face `datasets` repo # TODO(rcadene, lhoestq): move to Hugging Face `datasets` repo
@@ -941,41 +524,9 @@ with warnings.catch_warnings():
register_feature(VideoFrame, "VideoFrame") register_feature(VideoFrame, "VideoFrame")
def get_audio_info(video_path: Path | str) -> dict:
# Set logging level
logging.getLogger("libav").setLevel(av.logging.WARNING)
# Getting audio stream information
audio_info = {}
with av.open(str(video_path), "r") as audio_file:
try:
audio_stream = audio_file.streams.audio[0]
except IndexError:
# Reset logging level
av.logging.restore_default_callback()
return {"has_audio": False}
audio_info["audio.channels"] = audio_stream.channels
audio_info["audio.codec"] = audio_stream.codec.canonical_name
# In an ideal loseless case : bit depth x sample rate x channels = bit rate.
# In an actual compressed case, the bit rate is set according to the compression level : the lower the bit rate, the more compression is applied.
audio_info["audio.bit_rate"] = audio_stream.bit_rate
audio_info["audio.sample_rate"] = audio_stream.sample_rate # Number of samples per second
# In an ideal loseless case : fixed number of bits per sample.
# In an actual compressed case : variable number of bits per sample (often reduced to match a given depth rate).
audio_info["audio.bit_depth"] = audio_stream.format.bits
audio_info["audio.channel_layout"] = audio_stream.layout.name
audio_info["has_audio"] = True
# Reset logging level
av.logging.restore_default_callback()
return audio_info
def get_video_info(video_path: Path | str) -> dict: def get_video_info(video_path: Path | str) -> dict:
# Set logging level # Set logging level
logging.getLogger("libav").setLevel(av.logging.WARNING) logging.getLogger("libav").setLevel(av.logging.ERROR)
# Getting video stream information # Getting video stream information
video_info = {} video_info = {}
@@ -1002,9 +553,6 @@ def get_video_info(video_path: Path | str) -> dict:
# Reset logging level # Reset logging level
av.logging.restore_default_callback() av.logging.restore_default_callback()
# Adding audio stream information
video_info.update(**get_audio_info(video_path))
return video_info return video_info
@@ -1019,22 +567,22 @@ def get_video_pixel_channels(pix_fmt: str) -> int:
raise ValueError("Unknown format") raise ValueError("Unknown format")
def get_video_duration_in_s(video_path: Path | str) -> float: def get_media_duration_in_s(media_path: Path | str, media_type: str = "video") -> float:
""" """
Get the duration of a video file in seconds using PyAV. Get the duration of a media file (video & audio) in seconds using PyAV.
Args: Args:
video_path: Path to the video file. media_path: Path to the media file.
Returns: Returns:
Duration of the video in seconds. Duration of the media file in seconds.
""" """
with av.open(str(video_path)) as container: with av.open(str(media_path)) as container:
# Get the first video stream # Get the first stream
video_stream = container.streams.video[0] stream = container.streams.video[0] if media_type == "video" else container.streams.audio[0]
# Calculate duration: stream.duration * stream.time_base gives duration in seconds # Calculate duration: stream.duration * stream.time_base gives duration in seconds
if video_stream.duration is not None: if stream.duration is not None:
duration = float(video_stream.duration * video_stream.time_base) duration = float(stream.duration * stream.time_base)
else: else:
# Fallback to container duration if stream duration is not available # Fallback to container duration if stream duration is not available
duration = float(container.duration / av.time_base) duration = float(container.duration / av.time_base)
@@ -1043,12 +591,12 @@ def get_video_duration_in_s(video_path: Path | str) -> float:
class VideoEncodingManager: class VideoEncodingManager:
""" """
Context manager that ensures proper video encoding and data cleanup even if exceptions occur. Context manager that ensures proper video and audio encoding and data cleanup even if exceptions occur.
This manager handles: This manager handles:
- Batch encoding for any remaining episodes when recording interrupted - Batch encoding for any remaining episodes when recording interrupted
- Cleaning up temporary image files from interrupted episodes - Cleaning up temporary image and audio files from interrupted episodes
- Removing empty image directories - Removing empty image and audio directories
Args: Args:
dataset: The LeRobotDataset instance dataset: The LeRobotDataset instance
@@ -1061,15 +609,8 @@ class VideoEncodingManager:
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
streaming_encoder = getattr(self.dataset, "_streaming_encoder", None)
if streaming_encoder is not None:
# Handle streaming encoder cleanup
if exc_type is not None:
streaming_encoder.cancel_episode()
streaming_encoder.close()
elif self.dataset.episodes_since_last_encoding > 0:
# Handle any remaining episodes that haven't been batch encoded # Handle any remaining episodes that haven't been batch encoded
if self.dataset.episodes_since_last_encoding > 0:
if exc_type is not None: if exc_type is not None:
logging.info("Exception occurred. Encoding remaining episodes before exit...") logging.info("Exception occurred. Encoding remaining episodes before exit...")
else: else:
@@ -1082,12 +623,13 @@ class VideoEncodingManager:
f"from episode {start_ep} to {end_ep - 1}" f"from episode {start_ep} to {end_ep - 1}"
) )
self.dataset._batch_save_episode_video(start_ep, end_ep) self.dataset._batch_save_episode_video(start_ep, end_ep)
self.dataset._batch_save_episode_audio(start_ep, end_ep)
# Finalize the dataset to properly close all writers # Finalize the dataset to properly close all writers
self.dataset.finalize() self.dataset.finalize()
# Clean up episode images if recording was interrupted (only for non-streaming mode) # Clean up episode images if recording was interrupted
if exc_type is not None and streaming_encoder is None: if exc_type is not None:
interrupted_episode_index = self.dataset.num_episodes interrupted_episode_index = self.dataset.num_episodes
for key in self.dataset.meta.video_keys: for key in self.dataset.meta.video_keys:
img_dir = self.dataset._get_image_file_path( img_dir = self.dataset._get_image_file_path(
@@ -1098,15 +640,38 @@ class VideoEncodingManager:
f"Cleaning up interrupted episode images for episode {interrupted_episode_index}, camera {key}" f"Cleaning up interrupted episode images for episode {interrupted_episode_index}, camera {key}"
) )
shutil.rmtree(img_dir) shutil.rmtree(img_dir)
for key in self.dataset.meta.audio_keys:
audio_file = self.dataset._get_raw_audio_file_path(
episode_index=interrupted_episode_index, audio_key=key
)
if audio_file.exists():
logging.debug(
f"Cleaning up interrupted episode audio for episode {interrupted_episode_index}, microphone {key}"
)
audio_file.unlink()
# Clean up any remaining images directory if it's empty # Clean up any remaining images directory if it's empty
img_dir = self.dataset.root / "images" img_dir = self.dataset.root / "images"
if img_dir.exists(): # Check for any remaining PNG files
png_files = list(img_dir.rglob("*.png")) png_files = list(img_dir.rglob("*.png"))
if len(png_files) == 0: if len(png_files) == 0:
# Only remove the images directory if no PNG files remain
if img_dir.exists():
shutil.rmtree(img_dir) shutil.rmtree(img_dir)
logging.debug("Cleaned up empty images directory") logging.debug("Cleaned up empty images directory")
else: else:
logging.debug(f"Images directory is not empty, containing {len(png_files)} PNG files") logging.debug(f"Images directory is not empty, containing {len(png_files)} PNG files")
# Clean up any remaining audio directory if it's empty
audio_dir = self.dataset.root / "raw_audio"
# Check for any remaining WAV files
wav_files = list(audio_dir.rglob("*.wav"))
if len(wav_files) == 0:
# Only remove the raw_audio directory if no WAV files remain
if audio_dir.exists():
shutil.rmtree(audio_dir)
logging.debug("Cleaned up empty audio directory")
else:
logging.debug(f"Audio directory is not empty, containing {len(wav_files)} WAV files")
return False # Don't suppress the original exception return False # Don't suppress the original exception
+4 -6
View File
@@ -205,7 +205,6 @@ class ObservationConfig:
add_joint_velocity_to_observation: bool = False add_joint_velocity_to_observation: bool = False
add_current_to_observation: bool = False add_current_to_observation: bool = False
add_ee_pose_to_observation: bool = False
display_cameras: bool = False display_cameras: bool = False
@@ -261,7 +260,6 @@ class HILSerlRobotEnvConfig(EnvConfig):
@dataclass @dataclass
class LiberoEnv(EnvConfig): class LiberoEnv(EnvConfig):
task: str = "libero_10" # can also choose libero_spatial, libero_object, etc. task: str = "libero_10" # can also choose libero_spatial, libero_object, etc.
task_ids: list[int] | None = None
fps: int = 30 fps: int = 30
episode_length: int | None = None episode_length: int | None = None
obs_type: str = "pixels_agent_pos" obs_type: str = "pixels_agent_pos"
@@ -340,10 +338,10 @@ class LiberoEnv(EnvConfig):
@property @property
def gym_kwargs(self) -> dict: def gym_kwargs(self) -> dict:
kwargs: dict[str, Any] = {"obs_type": self.obs_type, "render_mode": self.render_mode} return {
if self.task_ids is not None: "obs_type": self.obs_type,
kwargs["task_ids"] = self.task_ids "render_mode": self.render_mode,
return kwargs }
@EnvConfig.register_subclass("metaworld") @EnvConfig.register_subclass("metaworld")
+2 -7
View File
@@ -112,7 +112,6 @@ class LiberoEnv(gym.Env):
visualization_height: int = 480, visualization_height: int = 480,
init_states: bool = True, init_states: bool = True,
episode_index: int = 0, episode_index: int = 0,
n_envs: int = 1,
camera_name_mapping: dict[str, str] | None = None, camera_name_mapping: dict[str, str] | None = None,
num_steps_wait: int = 10, num_steps_wait: int = 10,
control_mode: str = "relative", control_mode: str = "relative",
@@ -146,9 +145,7 @@ class LiberoEnv(gym.Env):
self.episode_length = episode_length self.episode_length = episode_length
# Load once and keep # Load once and keep
self._init_states = get_task_init_states(task_suite, self.task_id) if self.init_states else None self._init_states = get_task_init_states(task_suite, self.task_id) if self.init_states else None
self._reset_stride = n_envs # when performing a reset, append `_reset_stride` to `init_state_id`. self._init_state_id = self.episode_index # tie each sub-env to a fixed init state
self.init_state_id = self.episode_index # tie each sub-env to a fixed init state
self._env = self._make_envs_task(task_suite, self.task_id) self._env = self._make_envs_task(task_suite, self.task_id)
default_steps = 500 default_steps = 500
@@ -298,8 +295,7 @@ class LiberoEnv(gym.Env):
self._env.seed(seed) self._env.seed(seed)
raw_obs = self._env.reset() raw_obs = self._env.reset()
if self.init_states and self._init_states is not None: if self.init_states and self._init_states is not None:
raw_obs = self._env.set_init_state(self._init_states[self.init_state_id % len(self._init_states)]) raw_obs = self._env.set_init_state(self._init_states[self._init_state_id])
self.init_state_id += self._reset_stride # Change init_state_id when reset
# After reset, objects may be unstable (slightly floating, intersecting, etc.). # After reset, objects may be unstable (slightly floating, intersecting, etc.).
# Step the simulator with a no-op action for a few frames so everything settles. # Step the simulator with a no-op action for a few frames so everything settles.
@@ -377,7 +373,6 @@ def _make_env_fns(
init_states=init_states, init_states=init_states,
episode_length=episode_length, episode_length=episode_length,
episode_index=episode_index, episode_index=episode_index,
n_envs=n_envs,
control_mode=control_mode, control_mode=control_mode,
**local_kwargs, **local_kwargs,
) )
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved. # Copyright 2025 The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,7 +12,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from .config_openarm_mini import OpenArmMiniConfig from .configs import MicrophoneConfig
from .openarm_mini import OpenArmMini from .microphone import Microphone
from .utils import make_microphones_from_configs
__all__ = ["OpenArmMini", "OpenArmMiniConfig"]
@@ -1,5 +1,3 @@
#!/usr/bin/env python
# Copyright 2025 The HuggingFace Inc. team. All rights reserved. # Copyright 2025 The HuggingFace Inc. team. All rights reserved.
# #
# Licensed under the Apache License, Version 2.0 (the "License"); # Licensed under the Apache License, Version 2.0 (the "License");
@@ -14,5 +12,5 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from .damiao import DamiaoMotorsBus from .configuration_anyskin import AnyskinSensorConfig
from .tables import * from .sensor_anyskin import AnyskinSensor

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