SO-ARM101 Bi-Arm (Dual Follower) Tutorial
This guide introduces the complete workflow for training a bi-arm SO-ARM robot system with LeRobot, including hardware connection, bi-arm calibration, bi-arm teleoperation, dataset recording and management, ACT policy training, and real-robot deployment. Following this guide, you can use two leader arms and two follower arms to collect demonstration data, train an imitation-learning policy, and run it on real robotic arms.
First, connect the cables as follows:
| Role | Port |
|---|---|
| Left follower arm | /dev/ttyACM0 |
| Right follower arm | /dev/ttyACM1 |
| Left leader arm | /dev/ttyACM2 |
| Right leader arm | /dev/ttyACM3 |
The follower-arm type is so101_follower, and the leader-arm type is so101_leader (in LeRobot, so100_leader and so101_leader share the same implementation).
Prerequisites
Install Dependencies
For environment setup, see the SO-ARM101 Tutorial.
USB Permissions
sudo chmod 666 /dev/ttyACM0 /dev/ttyACM1 /dev/ttyACM2 /dev/ttyACM31. Calibration (Critical Step)
1.1 Calibrate the Left Follower Arm
lerobot-calibrate \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--robot.id=my_so101_bi_follower_left1.2 Calibrate the Right Follower Arm
lerobot-calibrate \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower_right1.3 Calibrate the Left Leader Arm
lerobot-calibrate \
--teleop.type=so101_leader \
--teleop.port=/dev/ttyACM2 \
--teleop.id=my_so101_bi_leader_left1.4 Calibrate the Right Leader Arm
lerobot-calibrate \
--teleop.type=so101_leader \
--teleop.port=/dev/ttyACM3 \
--teleop.id=my_so101_bi_leader_rightAfter calibration, the files are saved to:
~/.cache/huggingface/lerobot/calibration/robots/so_follower/my_so101_bi_follower_left.json
~/.cache/huggingface/lerobot/calibration/robots/so_follower/my_so101_bi_follower_right.json
~/.cache/huggingface/lerobot/calibration/teleoperators/so_leader/my_so101_bi_leader_left.json
~/.cache/huggingface/lerobot/calibration/teleoperators/so_leader/my_so101_bi_leader_right.jsonDirectory naming:
so101_followerandso100_follower,so101_leaderandso100_leadershare the same implementation, so the directories are uniformlyso_follower/so_leader; the leader arm is a teleoperator, so its calibration files go underteleoperators/rather thanrobots/.
(Optional) If You Previously Calibrated with Other IDs
For example, if you previously used my_awesome_follower_arm1, my_awesome_follower_arm2, etc., you can copy the calibration files:
CAL_DIR=~/.cache/huggingface/lerobot/calibration
cp $CAL_DIR/robots/so_follower/my_awesome_follower_arm1.json \
$CAL_DIR/robots/so_follower/my_so101_bi_follower_left.json
cp $CAL_DIR/robots/so_follower/my_awesome_follower_arm2.json \
$CAL_DIR/robots/so_follower/my_so101_bi_follower_right.json
cp $CAL_DIR/teleoperators/so_leader/my_awesome_leader_arm3.json \
$CAL_DIR/teleoperators/so_leader/my_so101_bi_leader_left.json
cp $CAL_DIR/teleoperators/so_leader/my_awesome_leader_arm4.json \
$CAL_DIR/teleoperators/so_leader/my_so101_bi_leader_right.json2. Bi-Arm Teleoperation
2.1 Without Cameras
lerobot-teleoperate \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/ttyACM2 \
--teleop.right_arm_config.port=/dev/ttyACM3 \
--teleop.id=my_so101_bi_leader \
--display_data=true2.2 With Cameras
Use lerobot-find-cameras opencv to list camera indices; you can also add or remove cameras as needed.
lerobot-teleoperate \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/ttyACM2 \
--teleop.right_arm_config.port=/dev/ttyACM3 \
--teleop.id=my_so101_bi_leader \
--display_data=trueSafety Note
- Be aware of the surroundings and avoid follower-arm collisions.
3. Record a Dataset
3.1 Save Locally (No Hub Upload)
Add --dataset.root (data goes to that directory) and --dataset.push_to_hub=false, plus --dataset.no_stamp=true to keep the dataset name stable (otherwise a timestamp is automatically appended to repo_id, and later resume recording / replay / training will not find it).
Note:
repo_idshould contain a/(in the formusername/dataset_name); local datasets are not actually uploaded.
lerobot-record \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/ttyACM2 \
--teleop.right_arm_config.port=/dev/ttyACM3 \
--teleop.id=my_so101_bi_leader \
--dataset.repo_id=juxi/bi_so101_task \
--dataset.root=./datasets/bi_so101_task \
--dataset.push_to_hub=false \
--dataset.no_stamp=true \
--dataset.single_task="Pick the cube with left arm and hand it to right arm" \
--dataset.num_episodes=50 \
--dataset.fps=30 \
--dataset.episode_time_s=30 \
--dataset.reset_time_s=10 \
--dataset.video=true \
--display_data=trueVideo encoding already defaults to
libsvtav1, so there is no need to specify it; to customize, use nested parameters like--dataset.rgb_encoder.vcodec=h264.
The data is saved under ./datasets/bi_so101_task/, with this structure:
├── meta/
│ ├── info.json # dataset info (fps, feature shapes, etc.)
│ ├── episodes/ # per-episode metadata (chunk-000/...)
│ ├── stats.json # per-feature normalization statistics
│ └── tasks.parquet # task text → task_index
├── data/ # per-frame feature data (chunk-*.parquet)
└── videos/ # one subdirectory per camera (chunk-*.mp4)3.2 Upload to Hugging Face Hub
If you want automatic upload, keep HF_USER and drop root and push_to_hub=false (upload is the default). Make sure the ports and camera indices match the wiring table:
export HF_USER=your_hf_username
lerobot-record \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/ttyACM2 \
--teleop.right_arm_config.port=/dev/ttyACM3 \
--teleop.id=my_so101_bi_leader \
--dataset.repo_id=${HF_USER}/bi_so101_task \
--dataset.no_stamp=true \
--dataset.single_task="Pick the cube with left arm and hand it to right arm" \
--dataset.num_episodes=50 \
--dataset.fps=30 \
--dataset.episode_time_s=30 \
--dataset.reset_time_s=10 \
--dataset.video=true \
--display_data=trueThe uploaded Hub repository name will be
${HF_USER}/bi_so101_task, matching therepo_idused for training from the Hub in 4.2 below. A local copy is first saved to~/.cache/huggingface/lerobot/${HF_USER}/bi_so101_task/.
3.3 Continue Recording (Resume)
If recording exits unexpectedly (for example, you quit with the right-click while in the reset phase), or you want to complete the collection in several sessions, you can use --resume to keep appending episodes to the same dataset.
Note:
- You must add
--resume=true, otherwiseLeRobotDataset.create()errors out because the directory already exists. - The resume command's
--dataset.rootand--dataset.repo_idmust exactly match the first recording (3.1) (resumerequires an explicitroot). --dataset.num_episodesis how many episodes to record this time, not the total goal. For example, if you have recorded 15 and want 50 in total, write35.- When quitting, try to do so during episode recording or right after it ends naturally; avoid quitting during the "Reset the environment" phase (it makes saving an empty episode fail).
lerobot-record \
--resume=true \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/ttyACM2 \
--teleop.right_arm_config.port=/dev/ttyACM3 \
--teleop.id=my_so101_bi_leader \
--dataset.repo_id=juxi/bi_so101_task \
--dataset.root=./datasets/bi_so101_task \
--dataset.push_to_hub=false \
--dataset.no_stamp=true \
--dataset.single_task="Pick the cube with left arm and hand it to right arm" \
--dataset.num_episodes=35 \
--dataset.fps=30 \
--dataset.episode_time_s=30 \
--dataset.reset_time_s=10 \
--dataset.video=true \
--display_data=true3.4 Replay and Delete Episodes
Replay a Specific Episode
lerobot-replay \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--dataset.repo_id=juxi/bi_so101_task \
--dataset.root=./datasets/bi_so101_task \
--dataset.episode=24
episodeis a 0-based index;24means the 25th episode.
Delete a Specific Episode
python -m lerobot.scripts.lerobot_edit_dataset \
--repo_id=juxi/bi_so101_task \
--root=./datasets/bi_so101_task \
--operation.type=delete_episodes \
--operation.episode_indices="[24]"The deletion rewrites the dataset in place, and the original data is backed up to ./datasets/bi_so101_task_old/. Once you have confirmed the new dataset is correct, you can delete the backup manually:
rm -rf ./datasets/bi_so101_task_oldDelete the Entire Dataset
rm -rf ./datasets/bi_so101_task4. ACT Training
4.1 Train from a Local Dataset
lerobot-train \
--dataset.repo_id=juxi/bi_so101_task \
--dataset.root=./datasets/bi_so101_task \
--policy.type=act \
--policy.device=cuda \
--steps=60000 \
--output_dir=outputs/train/act_bi_so101 \
--wandb.enable=false \
--policy.push_to_hub=false
--dataset.rootpoints to the dataset directory recorded in 3.1 (repo_idmust match the recording). If the--output_dirdirectory already exists, it raisesFileExistsError; use a new output directory or add--resume=trueto continue training.
4.2 Train from Hugging Face Hub
export HF_USER=your_hf_username
lerobot-train \
--dataset.repo_id=${HF_USER}/bi_so101_task \
--policy.type=act \
--policy.device=cuda \
--steps=100000 \
--output_dir=outputs/train/act_bi_so101 \
--wandb.enable=false \
--policy.push_to_hub=falseThe command above uses ACT's default parameters (
chunk_size=100,dim_model=512, etc.).
repo_idmust match the repository name used in the 3.2 upload (3.2 already added--dataset.no_stamp=true, so the repository name is fixed as${HF_USER}/bi_so101_task). No--dataset.rootis needed for training — it downloads from the Hub automatically.
5. Real-Robot Deployment
Note:
lerobot-recordis only for collecting demonstration data. To deploy a trained policy, uselerobot-rollout— the current version oflerobot-recordno longer accepts--policy.pathand also rejects dataset names with theeval_prefix.
5.1 Live Evaluation (No Data Recording)
lerobot-rollout \
--strategy.type=base \
--policy.path=outputs/train/act_bi_so101/checkpoints/last/pretrained_model \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--task="Pick the cube with left arm and hand it to right arm" \
--duration=60 \
--display_data=true--durationis the run time in seconds;0means no time limit.- To take over/stop mid-run, add
--interactive=trueand control it in the terminal with commands like/stopand/reset.
5.2 Evaluate and Record Data (Local)
Use the episodic strategy (behavior similar to the old lerobot-record: records episode by episode with a reset phase):
lerobot-rollout \
--strategy.type=episodic \
--policy.path=outputs/train/act_bi_so101/checkpoints/last/pretrained_model \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--dataset.repo_id=juxi/rollout_bi_so101_task \
--dataset.root=./datasets/rollout_bi_so101_task \
--dataset.no_stamp=true \
--dataset.num_episodes=10 \
--dataset.single_task="Pick the cube with left arm and hand it to right arm" \
--dataset.fps=30 \
--display_data=trueDeployment dataset names must start with
rollout_(an enforced convention in the current version). When recording locally, adding--dataset.rootand--dataset.no_stamp=trueis recommended to keep a timestamp from being appended to the directory name.
5.3 Upload Evaluation Data to Hugging Face Hub
export HF_USER=your_hf_username
lerobot-rollout \
--strategy.type=episodic \
--policy.path=outputs/train/act_bi_so101/checkpoints/last/pretrained_model \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=my_so101_bi_follower \
--robot.left_arm_config.cameras='{
left_wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30}
}' \
--robot.right_arm_config.cameras='{
right_wrist: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30}
}' \
--dataset.repo_id=${HF_USER}/rollout_bi_so101_task \
--dataset.no_stamp=true \
--dataset.num_episodes=10 \
--dataset.single_task="Pick the cube with left arm and hand it to right arm" \
--dataset.fps=30 \
--display_data=true6. FAQ
| Problem | Cause | Solution |
|---|---|---|
| Teleoperation prompts for re-calibration | bi_so_follower cannot find calibration files with the _left / _right suffix | Re-calibrate with IDs containing _left / _right, or copy the existing calibration files |
| Leader arm cannot be moved | Leader torque not disabled | Re-calibrate or check the motor |
| Resume recording reports the directory already exists | --resume=true was not added | Add --resume=true to the lerobot-record command |
--resume=true errors and demands root | Resume must specify the dataset directory explicitly | Add --dataset.root=./datasets/bi_so101_task to the resume command, matching the first recording |
| Dataset directory name has an extra timestamp, and replay/training cannot find it | no_stamp was not set during recording, so a timestamp was automatically appended to repo_id | Add --dataset.no_stamp=true when recording/resuming |
--dataset.vcodec=... reports the parameter does not exist | Legacy parameter; video encoding parameters are now nested | Use --dataset.rgb_encoder.vcodec=h264 instead (the default is already libsvtav1) |
During deployment, lerobot-record reports --policy.path / eval_ errors | The current version of lerobot-record no longer has policy deployment capability | Use lerobot-rollout --strategy.type=episodic for deployment, with dataset names starting with rollout_ |
| Left and right arms are swapped | Wrong port configuration | Swap left_arm_config.port and right_arm_config.port |
| Training cannot find the dataset | The local dataset's root was not specified | Add --dataset.root=./datasets/xxx when training |
| The dataset gets uploaded automatically | push_to_hub=false was not set | Add --dataset.push_to_hub=false when recording |
On exit it reports You must add one or several frames before calling add_episode | Exited during the reset phase; the current episode has no frames | Does not affect already-recorded data; continue recording with --resume=true |

