Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for Stretch (hello-robot) #409

Merged
merged 45 commits into from
Oct 4, 2024
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0e4b9e2
Add stretch_body
aliberts Sep 4, 2024
7992d2e
Fix poetry relax
aliberts Sep 4, 2024
bea022f
Fix install
aliberts Sep 6, 2024
90e7895
Add stretch boilerplate
aliberts Sep 6, 2024
bf5b6e1
Fix install
aliberts Sep 6, 2024
e5a4fa3
Add teleop
aliberts Sep 6, 2024
2e36039
Fix '--camera-ids' empty, add init_from_name to IntelRealSenseCamera
aliberts Sep 10, 2024
9a6150c
Add camera rotation
aliberts Sep 10, 2024
38f6be8
Add StretchRobotConfig, cameras obs
aliberts Sep 10, 2024
f2269c1
Add safe_disconnect
aliberts Sep 10, 2024
8b28a3d
Add todo
aliberts Sep 10, 2024
e9682af
Reduce workers
aliberts Sep 10, 2024
4219527
Fixes
aliberts Sep 10, 2024
aaf7fd4
Fix typo
aliberts Sep 10, 2024
2bf4dcb
Invert has_method args
aliberts Sep 10, 2024
f5651a5
Add nav cam, add rotation to opencv cams
aliberts Sep 11, 2024
e755d10
Update lerobot/common/datasets/compute_stats.py
aliberts Sep 11, 2024
a98556b
Fix install
aliberts Sep 11, 2024
6bc1c6b
Fix CI install
aliberts Sep 11, 2024
bd4fdc8
Add copyrights
aliberts Sep 11, 2024
12a2f2c
Remove default robot_type
aliberts Sep 12, 2024
16dd73f
Remove StretchRobot import
aliberts Sep 12, 2024
88526d0
Add capture_observation
aliberts Sep 12, 2024
9e2d98c
Add send_action, type-hints
aliberts Sep 12, 2024
1053845
Add todo
aliberts Sep 12, 2024
652eb01
Merge remote-tracking branch 'origin/main' into user/aliberts/2024_09…
aliberts Sep 24, 2024
3fbd680
Fixes
aliberts Sep 24, 2024
42d9556
Update stretch-body
aliberts Sep 25, 2024
db09ebf
Add pynput
aliberts Sep 25, 2024
ddd8059
Add connection errors
aliberts Sep 25, 2024
d1fc630
Add doc
aliberts Sep 25, 2024
7c87025
Add logging step
aliberts Sep 26, 2024
e0fa5e4
Change stretch log levels
aliberts Sep 26, 2024
d5fbfd4
Add system check
aliberts Sep 26, 2024
78e8435
Update pyproject.toml
aliberts Sep 26, 2024
37572eb
Add todo
aliberts Sep 26, 2024
eb48452
Add suggestion
aliberts Sep 26, 2024
722092a
Cleanup camera utils
aliberts Sep 27, 2024
56d3845
Add nav cam
aliberts Sep 27, 2024
306a1ac
Add more info in markdown
Cadene Oct 4, 2024
c24b137
Add comment
aliberts Oct 4, 2024
2c0feed
fps=20, homogenization intelrealsense opencv
Cadene Oct 4, 2024
d28f556
Merge remote-tracking branch 'origin/main' into user/aliberts/2024_09…
Cadene Oct 4, 2024
d210d2e
fix unit tests
Cadene Oct 4, 2024
d716511
fix unit tests
Cadene Oct 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ jobs:
lfs: true # Ensure LFS files are pulled

- name: Install apt dependencies
run: sudo apt-get update && sudo apt-get install -y libegl1-mesa-dev ffmpeg
run: |
sudo apt-get update && \
sudo apt-get install -y libegl1-mesa-dev ffmpeg portaudio19-dev
Cadene marked this conversation as resolved.
Show resolved Hide resolved

- name: Install poetry
run: |
Expand Down Expand Up @@ -110,7 +112,9 @@ jobs:
lfs: true # Ensure LFS files are pulled

- name: Install apt dependencies
run: sudo apt-get update && sudo apt-get install -y libegl1-mesa-dev
run: |
sudo apt-get update && \
sudo apt-get install -y libegl1-mesa-dev portaudio19-dev

- name: Install poetry
run: |
Expand Down
2 changes: 1 addition & 1 deletion examples/7_get_started_with_real_robot.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ poetry install --sync --extras "dynamixel"
```bash
conda install -c conda-forge ffmpeg
pip uninstall opencv-python
conda install -c conda-forge opencv>=4.10.0
conda install -c conda-forge "opencv>=4.10.0"
```

You are now ready to plug the 5V power supply to the motor bus of the leader arm (the smaller one) since all its motors only require 5V.
Expand Down
112 changes: 112 additions & 0 deletions examples/8_use_stretch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
This tutorial explains how to use [Stretch 3](https://hello-robot.com/stretch-3-product) with LeRobot.

## Setup

Familiarize yourself with Stretch by following its [tutorials](https://docs.hello-robot.com/0.3/getting_started/hello_robot/) (recommended).

To use LeRobot on Stretch, 3 options are available:
- [tethered setup](https://docs.hello-robot.com/0.3/getting_started/connecting_to_stretch/#tethered-setup)
- [untethered setup](https://docs.hello-robot.com/0.3/getting_started/connecting_to_stretch/#untethered-setup)
- ssh directly into Stretch (you will first need to install and configure openssh-server on stretch using one of the two above setups)


## Install LeRobot

On Stretch's CLI, follow these steps:

1. [Install Miniconda](https://docs.anaconda.com/miniconda/#quick-command-line-install):
```bash
mkdir -p ~/miniconda3
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda3/miniconda.sh
bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3
rm ~/miniconda3/miniconda.sh
~/miniconda3/bin/conda init bash
```

2. Comment out these lines in `~/.profile` (this can mess up paths used by conda and ~/.local/bin should already be in your PATH)
```
# set PATH so it includes user's private bin if it exists
if [ -d "$HOME/.local/bin" ] ; then
PATH="$HOME/.local/bin:$PATH"
fi
```

3. Restart shell or `source ~/.bashrc`

4. Create and activate a fresh conda environment for lerobot
```bash
conda create -y -n lerobot python=3.10 && conda activate lerobot
```

5. Clone LeRobot:
```bash
git clone https://github.com/huggingface/lerobot.git ~/lerobot
```

6. Install LeRobot
```bash
cd ~/lerobot && pip install -e ".[stretch]"

conda install -y -c conda-forge ffmpeg
pip uninstall -y opencv-python
conda install -y -c conda-forge "opencv>=4.10.0"
```

## Teleoperate, record a dataset and run a policy

> **Note:** As indicated in Stretch's [doc](https://docs.hello-robot.com/0.3/getting_started/stretch_hardware_overview/#turning-off-gamepad-teleoperation), you may need to free the "robot process" after booting Stretch by running `stretch_free_robot_process.py`

Before operating Stretch, you need to [home](https://docs.hello-robot.com/0.3/getting_started/stretch_hardware_overview/#homing) it first. In the scripts used below, if the robot is not already homed it will be automatically homed first. Be mindful about giving Stretch some space as this procedure will move the robot's arm and gripper. If you want to "manually" home Stretch first, you can simply run this command:
```bash
python lerobot/scripts/control_robot.py calibrate \
--robot-path lerobot/configs/robot/stretch.yaml
```
This is equivalent to running `stretch_robot_home.py`

Cadene marked this conversation as resolved.
Show resolved Hide resolved
Try out teleoperation (you can learn about the controls in Stretch's [documentation](https://docs.hello-robot.com/0.3/getting_started/hello_robot/#gamepad-teleoperation)):
```bash
python lerobot/scripts/control_robot.py teleoperate \
--robot-path lerobot/configs/robot/stretch.yaml
```
This is essentially the same as running `stretch_gamepad_teleop.py`


Once you're familiar with the gamepad controls and after a bit of practice, you can try to record your first dataset with Stretch.

If you want to use the Hugging Face hub features for uploading your dataset and you haven't previously done it, make sure you've logged in using a write-access token, which can be generated from the [Hugging Face settings](https://huggingface.co/settings/tokens):
```bash
huggingface-cli login --token ${HUGGINGFACE_TOKEN} --add-to-git-credential
```

Store your Hugging Face repository name in a variable to run these commands:
```bash
HF_USER=$(huggingface-cli whoami | head -n 1)
echo $HF_USER
```

Record one episode:
```bash
python lerobot/scripts/control_robot.py record \
--robot-path lerobot/configs/robot/stretch.yaml \
--fps 30 \
--root data \
--repo-id ${HF_USER}/stretch_test \
Cadene marked this conversation as resolved.
Show resolved Hide resolved
--tags stretch tutorial \
--warmup-time-s 3 \
--episode-time-s 40 \
--reset-time-s 10 \
--num-episodes 1 \
--push-to-hub 0
```

Note that if you're using ssh to connect to Stretch and run this script, you won't be able to visualize its cameras feed (though they'll still be recording).

Now try to replay this episode (make sure the robot's initial position is the same):
```bash
python lerobot/scripts/control_robot.py replay \
--robot-path lerobot/configs/robot/stretch.yaml \
--fps 30 \
--root data \
--repo-id ${HF_USER}/stretch_test \
--episode 0
```
2 changes: 1 addition & 1 deletion lerobot/common/datasets/compute_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def get_stats_einops_patterns(dataset, num_workers=0):
return stats_patterns


def compute_stats(dataset, batch_size=32, num_workers=16, max_num_samples=None):
def compute_stats(dataset, batch_size=8, num_workers=8, max_num_samples=None):
"""Compute mean/std and min/max statistics of all data keys in a LeRobotDataset."""
if max_num_samples is None:
max_num_samples = len(dataset)
Expand Down
73 changes: 60 additions & 13 deletions lerobot/common/robot_devices/cameras/intelrealsense.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import threading
import time
import traceback
from collections import Counter
from dataclasses import dataclass, replace
from pathlib import Path
from threading import Thread
Expand All @@ -28,22 +29,23 @@
SERIAL_NUMBER_INDEX = 1


def find_camera_indices(raise_when_empty=True) -> list[int]:
def find_cameras_info(raise_when_empty=True) -> dict[int, str]:
"""
Find the serial numbers of the Intel RealSense cameras
Find the names and the serial numbers of the Intel RealSense cameras
connected to the computer.
"""
camera_ids = []
cameras_info = {}
for device in rs.context().query_devices():
serial_number = int(device.get_info(rs.camera_info(SERIAL_NUMBER_INDEX)))
camera_ids.append(serial_number)
name = device.get_info(rs.camera_info.name)
cameras_info[serial_number] = name

if raise_when_empty and len(camera_ids) == 0:
if raise_when_empty and len(cameras_info) == 0:
raise OSError(
"Not a single camera was detected. Try re-plugging, or re-installing `librealsense` and its python wrapper `pyrealsense2`, or updating the firmware."
)

return camera_ids
return cameras_info
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not fan of modifying this function, to stay consistent with the one in opencv.py
Instead, could we have another function that given camera_indices / serial number retrieves the camera name?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue is that we would still need to iterate through rs.context().query_devices() in that function and compare device.get_info(rs.camera_info(SERIAL_NUMBER_INDEX)) against the given serial_number before accessing the name. We can do it but:

    1. Would makes things harder to read and understand why it's doing those convolutions
    1. Would be slower as you'd iterate twice on the devices

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What can we do to unify opencv and intelrealsense on this function?

I would imagine find_cameras_info(raise_when_empty=True) -> list[dict]: for both, with camera_infos[0]["index"] being the camera index port for opencv or intelrealsense serial, and camera_infos[0]["name"] not existing for opencv.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: camera_infos instead of cameras_info for consistency with camera_ids and how we usually add a s at the end for our variable names of type list

Copy link
Collaborator Author

@aliberts aliberts Sep 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What can we do to unify opencv and intelrealsense on this function?

As we discussed, I think we should eventually converge to a single Camera class with different backends (realsense, opencv...). This would probably simplify the design and avoid having to monkeypatch those classes in the tests. Then we could think about the design of this function (which would be a single one). Wdyt?

Nit: camera_infos instead of cameras_info for consistency with camera_ids and how we usually add a s at the end for our variable names of type list

I wondered about the exact same thing but the plural of "info" or "information" in English is without a s.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I agree, but I think we should homogenise opencv and intelrealsense at this level before merging. What do you think of my suggestion?

  2. I know but we dont write in english, we write in python (which is an english dialect) so the english grammar rules dont apply the same way ^^



def save_image(img_array, camera_idx, frame_index, images_dir):
Expand All @@ -59,7 +61,7 @@ def save_image(img_array, camera_idx, frame_index, images_dir):

def save_images_from_cameras(
images_dir: Path,
camera_ids: list[int] | None = None,
camera_ids: list[int | None],
fps=None,
width=None,
height=None,
Expand All @@ -69,12 +71,13 @@ def save_images_from_cameras(
Initializes all the cameras and saves images to the directory. Useful to visually identify the camera
associated to a given camera index.
"""
if camera_ids is None:
camera_ids = find_camera_indices()
if len(camera_ids) == 0:
camera_ids = find_cameras_info()

print("Connecting cameras")
cameras = []
for cam_idx in camera_ids:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, the variable should be renamed to cameras_info

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, this was because it was copied from a very similar function for opencv cameras (see this discussion).
I'll wait for that discussion to resolve before changing it.

print(f"{cam_idx=}")
camera = IntelRealSenseCamera(cam_idx, fps=fps, width=width, height=height)
camera.connect()
print(
Expand All @@ -93,7 +96,7 @@ def save_images_from_cameras(
frame_index = 0
start_time = time.perf_counter()
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
while True:
now = time.perf_counter()

Expand Down Expand Up @@ -140,6 +143,7 @@ class IntelRealSenseCameraConfig:
IntelRealSenseCameraConfig(90, 640, 480)
IntelRealSenseCameraConfig(30, 1280, 720)
IntelRealSenseCameraConfig(30, 640, 480, use_depth=True)
IntelRealSenseCameraConfig(30, 640, 480, rotation=90)
```
"""

Expand All @@ -149,6 +153,7 @@ class IntelRealSenseCameraConfig:
color_mode: str = "rgb"
use_depth: bool = False
force_hardware_reset: bool = True
rotation: int | None = None
Cadene marked this conversation as resolved.
Show resolved Hide resolved

def __post_init__(self):
if self.color_mode not in ["rgb", "bgr"]:
Expand All @@ -162,11 +167,15 @@ def __post_init__(self):
f"but {self.fps=}, {self.width=}, {self.height=} were provided."
)

if self.rotation not in [-90, None, 90, 180]:
raise ValueError(f"`rotation` must be in [-90, None, 90, 180] (got {self.rotation})")


class IntelRealSenseCamera:
"""
The IntelRealSenseCamera class is similar to OpenCVCamera class but adds additional features for Intel Real Sense cameras:
- camera_index corresponds to the serial number of the camera,
- can be instantiated with the camera's name — if it's unique — using IntelRealSenseCamera.init_from_name(),
- camera_index won't randomly change as it can be the case of OpenCVCamera for Linux,
- read is more reliable than OpenCVCamera,
- depth map can be returned.
Expand All @@ -181,8 +190,10 @@ class IntelRealSenseCamera:

Example of usage:
```python
camera_index = 128422271347
camera = IntelRealSenseCamera(camera_index)
# Instantiate with camera index (its serial number)
camera = IntelRealSenseCamera(128422271347)
# Or by its name if it's unique
camera = IntelRealSenseCamera.init_from_name("Intel RealSense D405")
camera.connect()
color_image = camera.read()
# when done using the camera, consider disconnecting
Expand Down Expand Up @@ -237,6 +248,36 @@ def __init__(
self.depth_map = None
self.logs = {}

# TODO(alibets): Do we keep original width/height or do we define them after rotation?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely nice to keep width/height consistent with the rotated image

self.rotation = None
if config.rotation == -90:
self.rotation = cv2.ROTATE_90_COUNTERCLOCKWISE
elif config.rotation == 90:
self.rotation = cv2.ROTATE_90_CLOCKWISE
elif config.rotation == 180:
self.rotation = cv2.ROTATE_180

@classmethod
def init_from_name(cls, name: str, config: IntelRealSenseCameraConfig | None = None, **kwargs):
cameras_info = find_cameras_info()
this_name_count = Counter(cameras_info.values())[name]
if this_name_count > 1:
# TODO(aliberts): Test this with multiple identical cameras (Aloha)
raise ValueError(
f"Multiple {name} cameras have been detected. Please use their serial number to instantiate them."
)

name_to_serial_dict = {name: serial for serial, name in cameras_info.items()}
serial = name_to_serial_dict[name]

if config is None:
config = IntelRealSenseCameraConfig()

# Overwrite the config arguments using kwargs
config = replace(config, **kwargs)

return cls(camera_index=serial, config=config, **kwargs)

def connect(self):
if self.is_connected:
raise RobotDeviceAlreadyConnectedError(
Expand Down Expand Up @@ -270,7 +311,7 @@ def connect(self):
# valid cameras.
if not is_camera_open:
# Verify that the provided `camera_index` is valid before printing the traceback
available_cam_ids = find_camera_indices()
available_cam_ids = find_cameras_info()
if self.camera_index not in available_cam_ids:
raise ValueError(
f"`camera_index` is expected to be one of these available cameras {available_cam_ids}, but {self.camera_index} is provided instead. "
Expand Down Expand Up @@ -323,6 +364,9 @@ def read(self, temporary_color: str | None = None) -> np.ndarray | tuple[np.ndar
f"Can't capture color image with expected height and width ({self.height} x {self.width}). ({h} x {w}) returned instead."
)

if self.rotation is not None:
color_image = cv2.rotate(color_image, self.rotation)

# log the number of seconds it took to read the image
self.logs["delta_timestamp_s"] = time.perf_counter() - start_time

Expand All @@ -342,6 +386,9 @@ def read(self, temporary_color: str | None = None) -> np.ndarray | tuple[np.ndar
f"Can't capture depth map with expected height and width ({self.height} x {self.width}). ({h} x {w}) returned instead."
)

if self.rotation is not None:
depth_map = cv2.rotate(depth_map, self.rotation)

return color_image, depth_map
else:
return color_image
Expand Down
20 changes: 18 additions & 2 deletions lerobot/common/robot_devices/cameras/opencv.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,13 @@ def save_image(img_array, camera_index, frame_index, images_dir):


def save_images_from_cameras(
images_dir: Path, camera_ids: list[int] | None = None, fps=None, width=None, height=None, record_time_s=2
images_dir: Path, camera_ids: list[int | None], fps=None, width=None, height=None, record_time_s=2
):
"""
Initializes all the cameras and saves images to the directory. Useful to visually identify the camera
associated to a given camera index.
"""
if camera_ids is None:
if len(camera_ids) == 0:
camera_ids = find_camera_indices()

print("Connecting cameras")
Expand Down Expand Up @@ -156,13 +156,17 @@ class OpenCVCameraConfig:
width: int | None = None
height: int | None = None
color_mode: str = "rgb"
rotation: int | None = None

def __post_init__(self):
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."
)

if self.rotation not in [-90, None, 90, 180]:
raise ValueError(f"`rotation` must be in [-90, None, 90, 180] (got {self.rotation})")


class OpenCVCamera:
"""
Expand Down Expand Up @@ -223,6 +227,15 @@ def __init__(self, camera_index: int, config: OpenCVCameraConfig | None = None,
self.color_image = None
self.logs = {}

# TODO(alibets): Do we keep original width/height or do we define them after rotation?
self.rotation = None
if config.rotation == -90:
self.rotation = cv2.ROTATE_90_COUNTERCLOCKWISE
elif config.rotation == 90:
self.rotation = cv2.ROTATE_90_CLOCKWISE
elif config.rotation == 180:
self.rotation = cv2.ROTATE_180

def connect(self):
if self.is_connected:
raise RobotDeviceAlreadyConnectedError(f"OpenCVCamera({self.camera_index}) is already connected.")
Expand Down Expand Up @@ -328,6 +341,9 @@ def read(self, temporary_color_mode: str | None = None) -> np.ndarray:
f"Can't capture color image with expected height and width ({self.height} x {self.width}). ({h} x {w}) returned instead."
)

if self.rotation is not None:
color_image = cv2.rotate(color_image, self.rotation)

# log the number of seconds it took to read the image
self.logs["delta_timestamp_s"] = time.perf_counter() - start_time

Expand Down
Loading
Loading