ROS 2 Jazzy workspace for a SO-101 follower arm (gi_jane) with a wrist-mounted Intel RealSense D405, a SO-101 leader (gi_joe) for teleop, MoveIt 2, and LeRobot ACT for “pick up the cube.”
The robot is commanded in joint position through ros2_control (ForwardCommandController). Imitation uses wrist RGB (640×480 @ 30 Hz) plus joint state — not depth. Depth is used only for cube detection / hover.
On hardware, the hover → ACT → home pipeline succeeded on 8 / 10 timed trials (80%). Further demos were consistent with that rate.
demo.mp4 |
- Dataset (
ZackFra/so101_d405_wrist) — 86 leader-teleop episodes (18,279 frames @ 30 Hz) of “pick up the cube.” Wrist RGB 640×480 plus joint positions; no depth. - ACT v1 (
ZackFra/act_so101_d405_wrist) — 80k-step ACT trained on the first 40 episodes. - ACT v2 (
ZackFra/act_so101_d405_wrist_v2) — 100k-step ACT on all 86 episodes. This is the checkpointgrasp_cube.pyruns by default.
Local copies still live under outputs/ (gitignored).
This repo is for people putting a SO-101 on ROS 2 Jazzy with a wrist D405 and wanting a path from teleop demos to a closed-loop pick — not a generic LeRobot tutorial and not a stock SO-101 URDF.
Typical readers:
- SO-101 owners who want MoveIt,
ros2_control, and a wrist camera in one bringup instead of stitching Hugging Face LeRobot USB control to a separate ROS stack. - Imitation / ACT users who need the camera to live in ROS for detection and planning, then feed the same wrist RGB into LeRobot without opening the D405 twice.
- Anyone mounting a D405 on the SO-101 wrist. Public SO-101 models generally stop at the gripper. This tree includes the printed holder mesh, inertial estimate (holder + camera), and hand-eye TF from
grippertod405_wrist_color_optical_frame. That description was rolled here because nothing equivalent turned up off the shelf. - Jazzy + Feetech users hitting
JointTrajectoryControllerissues: MoveIt still Plan & Executes because a small adapter turns FollowJointTrajectory into FCCFloat64MultiArraycommands.
You get less value if you only need stock lerobot-teleop on the Feetech bus with no ROS, or a different arm/camera.
Pieces that are specific to this workspace, not copies of upstream SO-101 / LeRobot / RealSense demos:
- SO-101 + D405 URDF —
Wrist_Roll_D405_Holder.stlon the gripper link, mass/inertia for PLA holder + D405 body, optional URDF optical frames vs a calibrated static TF (camera_pose.launch.py). Hand-eye assets live underconfig/realsense-d405/. - One launch, two camera owners —
enable_d405:=truefor detect/hover/ACT (ROS node);falsefor record/eval (LeRobotintelrealsense). Documented because USB cannot be shared; mixing them fails withVIDIOC_S_FMT. - ACT while ROS keeps the camera —
scripts/ros_image_camera.pyis a LeRobotCamerathat subscribes to/d405_wrist/color/image_raw(SensorData QoS) so policy inference does not steal the device fromrealsense2_camera. - QoS that actually streams — D405 color/depth default to Reliable + history 1, which stalls Python Best Effort subscribers after 1–2 frames. Bringup sets
color_qos/depth_qostoSENSOR_DATA. - FCC trajectory adapter — JTC on this Feetech + Jazzy setup is not used for execution;
fcc_trajectory_adapteris the MoveIt ↔ hardware bridge. - Detect → hover → ACT → home — HSV + aligned depth publishes
/red_cube/hover_pose; MoveIt parks with wrist-roll/flex holds; ACT runs ~12 s; thenhome.grasp_cube.pyloops that with a live Qt view (conda OpenCV is headless, so noimshow). - Gripper outside the MoveIt
armgroup — named pose YAML still commandsgripper_jointon/gripper_controller/commands. - Conda LeRobot + sourced Jazzy — scripts preload conda OpenSSL 3.3 so
_ssl/torch work after ROS puts Ubuntu 3.0libcryptoonLD_LIBRARY_PATH; one rclpy context for the grasp loop so Jazzy does not hitContext.init() must only be called once.
The ACT policy itself is stock LeRobot ACT on RGB + joints. The integration around it (description, bringup, camera split, hover, QoS, FCC) is the reusable part.
| Role | Name | Notes |
|---|---|---|
| Follower | gi_jane |
Feetech STS3215 bus on /dev/so101_follower |
| Leader | gi_joe |
/dev/so101_leader |
| Wrist camera | D405 | USB 3.x; serial used in this setup: 353322271703 |
Do not run leader teleop and RViz Plan & Execute (or grasp_cube.py / hover) at the same time — both write arm/gripper commands.
flowchart TB
subgraph hw [Hardware]
leader[SO-101 leader gi_joe]
follower[SO-101 follower gi_jane]
d405[D405 wrist]
end
subgraph bringup [so101_bringup]
feetech[Feetech ros2_control]
camNode[realsense2_camera d405_wrist]
moveit[move_group + servo]
fcc[fcc_trajectory_adapter]
feetech --> follower
camNode --> d405
moveit --> fcc
fcc --> feetech
end
subgraph scripts [Python scripts conda grover]
teleop[lerobot-teleop / record]
detect[detect_red_cube]
hover[hover_above_cube / grasp_cube]
act[ACT policy]
end
leader --> teleop
teleop -->|"/arm_controller/commands"| feetech
camNode -->|color + aligned depth| detect
detect -->|/red_cube/hover_pose| hover
hover --> moveit
camNode -->|RGB topic| act
act -->|joint goals| feetech
Camera ownership (important): the D405 USB device can be opened by only one process.
| Mode | Bringup | Who opens the D405 |
|---|---|---|
Detect / hover / grasp_cube.py |
enable_d405:=true |
ROS realsense2_camera |
| Record / eval teleop | enable_d405:=false |
LeRobot intelrealsense |
| Train ACT | Camera not needed | — |
Wrist RGB for ACT after hover comes from /d405_wrist/color/image_raw (scripts/ros_image_camera.py), not a second USB open. Color/depth publishers use SensorData / Best Effort QoS so Python subscribers do not stall after a couple of frames.
Packages (under src/):
lerobot_description— URDF/xacro, D405 mount, Gazebo/RVizlerobot_controller—ros2_controlconfigs, FCC trajectory adapter (MoveIt FollowJointTrajectory →Float64MultiArray)lerobot_moveit— MoveIt 2 + optional octomap from the wrist cloudlerobot-ros— LeRobot robot typeso101_ros(ActionType.JOINT_POSITION)
Workspace scripts live in scripts/. Named joint poses are YAML in config/poses/. Datasets and checkpoints go under outputs/ (gitignored).
-
OS / ROS: Ubuntu 24.04, ROS 2 Jazzy, MoveIt 2,
realsense2_camera, Feetechros2_controlhardware plugin. -
Workspace:
cd ~/GitHub/grover_ws # optional: vcs import src < grover.repos source /opt/ros/jazzy/setup.bash colcon build --symlink-install source install/setup.bash
colcon_defaults.yamlskips buildinglibrealsense2fromsrc/. -
Conda
grover: LeRobot 0.5.1, PyTorch, OpenCV (this env’scv2is headless — camera windows use Qt viascripts/view_ros_image.py). Activate it for allpython3 scripts/…commands.Sourcing ROS puts Ubuntu OpenSSL 3.0 on
LD_LIBRARY_PATH. Scripts that import torch afterrclpypreload conda’slibssl/libcryptoso_sslcan findOPENSSL_3.3.0. -
Devices: follower and leader should appear as
/dev/so101_followerand/dev/so101_leader. Bringup runsscripts/configure_so101_follower_bus.pyon the follower beforeros2_controlopens the port. -
Hand-eye (optional): print
config/realsense-d405/charuco.pngat 1:1 (28 mm squares). Seeconfig/realsense-d405/README.md. Bringup usesconfig/realsense-d405/camera_pose.launch.pyforgripper→d405_wrist_color_optical_frame.
Always source /opt/ros/jazzy/setup.bash, source install/setup.bash, and conda activate grover.
Cube detect / hover / ACT (ROS owns the D405):
ros2 launch launch/so101_bringup.launch.py is_sim:=False enable_d405:=true enable_octomap:=falseRecord or LeRobot eval (LeRobot owns the D405):
ros2 launch launch/so101_bringup.launch.py is_sim:=False enable_d405:=false enable_octomap:=falseSimulation (default is_sim:=True):
ros2 launch launch/so101_bringup.launch.pyUseful args: follower_serial_port, enable_octomap, disable_servo_torque.
Bringup as above. Do not Plan & Execute in RViz.
python3 scripts/lerobot-teleop.pySnapshot a pose from /joint_states:
python3 scripts/snapshot_joints.py top_view
python3 scripts/moveit_goto_joints.py top_view # arm + gripper from YAMLtop_view is closed (~−0.18 rad); top_view_open / open_jaw are open (~1.68 rad). Gripper is not in the MoveIt arm group; moveit_goto_joints.py publishes /gripper_controller/commands when the YAML has gripper_joint.
Bringup with enable_d405:=false. Right arrow = end episode early; after each episode you get reset_time_s of teleop (not saved) to re-aim.
python3 scripts/lerobot_train_teleop.py --mode record --task 'pick up the cube'
# append more episodes:
python3 scripts/lerobot_train_teleop.py --mode record --resume --task 'pick up the cube'--num-episodes is this session, not the dataset total. Data: outputs/datasets/so101_d405_wrist.
No bringup required. Example (~86 episodes, 100k steps):
tmux new -s act
conda activate grover
cd ~/GitHub/grover_ws
mkdir -p outputs/train
systemd-inhibit --what=idle:sleep --who=act-train --why="ACT training" \
python3 scripts/lerobot_train_teleop.py --mode train --steps 100000 \
--output-dir outputs/train/act_so101_d405_wrist_v2 \
2>&1 | tee outputs/train/act_so101_d405_wrist_v2.logDetach: Ctrl-b d. Reattach: tmux attach -t act. Checkpoints every 20k steps under …/checkpoints/{020000,…}/pretrained_model. First hardware check ~20k; 40–80k is usually more useful.
Training is RGB + joints only. Depth is not in the dataset.
Bringup with enable_d405:=true. Stop teleop.
python3 scripts/grasp_cube.pyLoop: Enter to start each cycle. Sequence:
top_view_open(MoveIt + open gripper)top_view(MoveIt + close gripper)- Wait for a stable
/red_cube/hover_pose(HSV + aligned depth) - MoveIt to hover above the cube (wrist held)
- ACT ~12 s from the latest v2 checkpoint (
100000by default) - MoveIt
home
Success rate: 8 / 10 (80%) on a timed set of autonomous grasps; later demos matched that performance.
A Qt window shows /d405_wrist/color/image_raw (scripts/view_ros_image.py; conda OpenCV cannot imshow). Ctrl-C quits. --once is a single grasp. --no-detect if detect_red_cube.py is already running.
Pieces, if you want them separate:
python3 scripts/moveit_goto_joints.py top_view
python3 scripts/detect_red_cube.py # leave running
python3 scripts/hover_above_cube.py
python3 scripts/view_ros_image.py # optional live viewPolicy override:
python3 scripts/hover_above_cube.py \
--policy-path outputs/train/act_so101_d405_wrist_v2/checkpoints/100000/pretrained_modelLive D405 in rqt_image_view: topic /d405_wrist/color/image_raw, Reliability Best Effort.
The ACT policy is egocentric: wrist RGB + joint positions. Moving the whole robot without unmounting the camera is fine if you still start from a familiar pose (top_view / hover) and the cube looks like the demos. There is no map or world-fixed camera.
| Script | Role |
|---|---|
grasp_cube.py |
Loop: open → close → hover → ACT → home |
hover_above_cube.py |
Stable hover pose → ACT → home |
detect_red_cube.py |
Red cube → /red_cube/hover_pose |
view_ros_image.py |
Qt view of a ROS Image topic |
moveit_goto_joints.py / moveit_goto_pose.py |
Named joints / Cartesian goals |
snapshot_joints.py |
Save /joint_states to config/poses/ |
lerobot-teleop.py |
Leader → follower |
lerobot_train_teleop.py |
Record / train / eval |
ros_image_camera.py |
LeRobot camera that subscribes to ROS RGB |
VIDIOC_S_FMT/ camera open failed while recording: ROS still has the D405. Relaunch withenable_d405:=false.COLOR STALLEDon detect: color/depth must be Best Effort. Bringup setscolor_qos/depth_qostoSENSOR_DATA. Confirm withros2 topic info /d405_wrist/color/image_raw -v.OPENSSL_3.3.0not found: ROSLD_LIBRARY_PATHvs conda_ssl. Use the workspace scripts (they preload conda OpenSSL); run from envgrover.Context.init() must only be called once: do not mix a secondrclpy.init()in the same process after the grasp loop has already initialized ROS.grasp_cube.pyholds one context for the whole session.- No OpenCV window: grover
cv2is headless. Useview_ros_image.py(PyQt5), notcv2.imshow.