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

Enable CI for robot devices with mocked versions #398

Open
wants to merge 55 commits into
base: main
Choose a base branch
from

Conversation

Cadene
Copy link
Collaborator

@Cadene Cadene commented Sep 1, 2024

What this does

  • Rework robot devices tests to increase "granularity"
    • Add make_camera and make_motor to instantiate specific camera and motor
    • Add @require_camera and @require_motor that try to instantiate, and skip test if not possible (before we were using @require_motor only)
    • Use these functions in test_cameras.py and test_motors.py instead of relying only on make_robot and @require_robot
  • Add tests for cameras, motors and robots that do not require physical robot devices:
    • Add mock=False argument to classes and functions dynamically import mocked classes, functions, variables
    • Add a new mock argument to test functions

TODO:

  • Test DynamixelMotorsBus without a dynamixel
  • Test ManipulatorRobot without a robot
  • Test control robot without a robot

How it was tested

  • Ran mocked unit tests manually
  • CI

How to checkout & try? (for the reviewer)

pytest -sx tests/test_cameras.py
pytest -sx tests/test_motors.py
pytest -sx tests/test_robots.py
pytest -sx tests/test_control_robot.py

Altogether:

pytest -sx tests/test_cameras.py tests/test_control_robot.py tests/test_motors.py tests/test_robots.py

@Cadene Cadene changed the base branch from main to user/rcadene/2024_07_11_control_aloha September 1, 2024 15:39
@Cadene Cadene requested a review from aliberts September 1, 2024 15:39
@Cadene Cadene added ✨ Enhancement New feature or request 🌍 Real world Real-world robotics & controls labels Sep 1, 2024
@Cadene Cadene mentioned this pull request Sep 2, 2024
1 task
Base automatically changed from user/rcadene/2024_07_11_control_aloha to main September 4, 2024 17:28
@Cadene Cadene force-pushed the user/rcadene/2024_09_01_mock_robot_devices branch from 334ddbd to 96cc243 Compare September 9, 2024 11:37
@Cadene Cadene marked this pull request as ready for review September 11, 2024 23:58
lerobot/__init__.py Outdated Show resolved Hide resolved
lerobot/__init__.py Outdated Show resolved Hide resolved
tests/mock_dynamixel.py Outdated Show resolved Hide resolved
tests/mock_dynamixel.py Outdated Show resolved Hide resolved
tests/test_cameras.py Outdated Show resolved Hide resolved
tests/test_cameras.py Outdated Show resolved Hide resolved
tests/test_cameras.py Show resolved Hide resolved
tests/test_motors.py Outdated Show resolved Hide resolved
tests/test_motors.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@aliberts aliberts left a comment

Choose a reason for hiding this comment

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

First round, very cool!
I'm wondering how to test robots like Stretch that already have a built-in api. We'll probably need to mock the api itself and test that in test_robots.py and test_control_robot.py rather than its low level components. Let me know what you think.

tests/mock_dynamixel.py Outdated Show resolved Hide resolved
tests/test_cameras.py Outdated Show resolved Hide resolved
Copy link
Collaborator Author

@Cadene Cadene left a comment

Choose a reason for hiding this comment

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

I think I addressed all comments!

Comment on lines -350 to +376
while self.stop_event is None or not self.stop_event.is_set():
while not self.stop_event.is_set():
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It seems that self.stop_event is None was not required.

tests/test_cameras.py Show resolved Hide resolved
tests/test_motors.py Outdated Show resolved Hide resolved
tests/test_motors.py Outdated Show resolved Hide resolved
lerobot/__init__.py Outdated Show resolved Hide resolved
lerobot/__init__.py Outdated Show resolved Hide resolved
tests/mock_intelrealsense.py Outdated Show resolved Hide resolved
tests/mock_intelrealsense.py Outdated Show resolved Hide resolved
tests/test_motors.py Outdated Show resolved Hide resolved
tests/mock_dynamixel.py Outdated Show resolved Hide resolved
@Cadene Cadene changed the title Enable CI for robot devices via monkeypatch Enable CI for robot devices with mocked versions Sep 27, 2024
Copy link
Collaborator

@jess-moss jess-moss left a comment

Choose a reason for hiding this comment

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

Overall it is looking good! I think in the future we may want to remove the testing/mock still from the actual classes (like dynamixel.py, etc). I feel like that should be kept separate eventually but for now I think it will work!

camera_ids = []
for device in rs.context().query_devices():
serial_number = int(device.get_info(rs.camera_info(SERIAL_NUMBER_INDEX)))
for device in RSContext().query_devices():
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: In the if...else statement above you say from pyrealsense2 import context as RSContext # noqa: N812 I presume you do this so this line can become for device in context.query_devices():. To do that you would need to import the mock values as follows:

        from tests.mock_intelrealsenseimport camera_info as RSCameraInfo  # noqa: N812
        from tests.mock_intelrealsenseimport context as RSContext  # noqa: N812

However, if you don't want to do that and keep this line as is then I would change the named imports above since they aren't being used anyways, i.e.

        from pyrealsense2 import (
            RSCameraInfo,
            RSContext,
        )

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 am sorry I didnt understand.

This works:

from tests.mock_intelrealsense import RSCameraInfo
RSCameraInfo(SERIAL_NUMBER_INDEX)

This works:

from pyrealsense2 import camera_info as RSCameraInfo
RSCameraInfo(SERIAL_NUMBER_INDEX)

This works:

from pyrealsense2 import camera_info
camera_info(SERIAL_NUMBER_INDEX)

But this doesnt work:

from pyrealsense2 import camera_info as RSCameraInfo
camera_info(SERIAL_NUMBER_INDEX)

@@ -243,24 +262,37 @@ def connect(self):
f"IntelRealSenseCamera({self.camera_index}) is already connected."
)

config = rs.config()
if self.mock:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: Same comment as above, I would either import these values as named variables like you do when it is not mock, i.e., config, format, pipeline, stream, or leave them all unnamed like you do when it is mock.

Copy link
Collaborator Author

@Cadene Cadene Sep 28, 2024

Choose a reason for hiding this comment

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

I am sorry I didnt understand

The original element name is config but I rename it to RSConfig to fit python CamelCase format for naming classes:

from pyrealsense2 import config as RSConfig

# Release camera to make it accessible for `find_camera_indices`
del tmp_camera
import cv2
from cv2 import (
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I don't think you need this line... but I could be wrong ;)

import cv2

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed import cv2 and imported setNumThreads to clarify:

from cv2 import (
    CAP_PROP_FPS,
    CAP_PROP_FRAME_HEIGHT,
    CAP_PROP_FRAME_WIDTH,
    VideoCapture,
    setNumThreads,
)
# Use 1 thread to avoid blocking the main thread. Especially useful during data collection
# when other threads are used to save the images.
setNumThreads(1)

# will go sequentially through the code logic protected by a lock, instead of
# in parallel. Also, we use Recursive Lock to avoid deadlock by allowing each
# thread to acquire the lock multiple times.
# TODO(rcadene, aliberts): Add RLock on every robot devices where it makes sense?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: By the way, @samzapo is really amazing with the multi-threading stuff. I can help a bit too but he's the expert if you need a second set of eyes.

# valid cameras.
# Use 1 thread to avoid blocking the main thread. Especially useful during data collection
# when other threads are used to save the images.
cv2.setNumThreads(1)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: What happens here if this is mock? Isn't cv2 only imported if it is not mock?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Before my big change, cv2 was monkeypatched, and this would be an issue.

After my big change, cv2 isn't monkeypatched anymore, but we have this mock boolean variable that imports it or not. setNumThreads is only executed when mock=False.

if self.mock:
    from tests.mock_opencv import (
        CAP_PROP_FPS,
        CAP_PROP_FRAME_HEIGHT,
        CAP_PROP_FRAME_WIDTH,
        VideoCapture,
    )
else:
    from cv2 import (
        CAP_PROP_FPS,
        CAP_PROP_FRAME_HEIGHT,
        CAP_PROP_FRAME_WIDTH,
        VideoCapture,
        setNumThreads,
    )
    # Use 1 thread to avoid blocking the main thread. Especially useful during data collection
    # when other threads are used to save the images.
    setNumThreads(1)

num_tries += 1
if num_tries > self.fps * 2:
raise TimeoutError("Timed out waiting for async_read() to start.")
# if num_tries > self.fps and (self.thread.ident is None or not self.thread.is_alive()):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I think you have some commented out code to delete:

# if num_tries > self.fps and (self.thread.ident is None or not self.thread.is_alive()):
            #     raise Exception(
            #         "The thread responsible for `self.async_read()` took too much time to start. There might be an issue. Verify that `self.thread.start()` has been called."
            #     )

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. It was from Simon Alibert

@@ -154,6 +143,8 @@
NUM_READ_RETRY = 10
NUM_WRITE_RETRY = 10

COMM_SUCCESS = 0
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I don't love that COMM_SUCCESS is hardcoded here instead of being imported from dynamixel_sdk....

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch! Removed this line. Added dynamic import everywhere

if self.mock:
    from tests.mock_dynamixel import GroupSyncWrite, COMM_SUCCESS
else:
    from dynamixel_sdk import GroupSyncWrite, COMM_SUCCESS
    ```

@@ -359,6 +362,11 @@ def connect(self):
f"DynamixelMotorsBus({self.port}) is already connected. Do not call `motors_bus.connect()` twice."
)

if self.mock:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: It seems like what is being "mocked" is the dynamixel_sdk... not the dynamixel class itself. Should this class be called mock_dynamixel_sdk?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Indeed!

  • Renamed mock_dynamixel.py to mock_dynamixel_sdk.py
  • Renamed mock_intelrealsense.py to mock_pyrealsense2.py
  • Renamed mock_opencv.py to mock_cv2.py

tests/mock_dynamixel.py Outdated Show resolved Hide resolved
Copy link
Collaborator Author

@Cadene Cadene left a comment

Choose a reason for hiding this comment

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

Addressed Jess comments

camera_ids = []
for device in rs.context().query_devices():
serial_number = int(device.get_info(rs.camera_info(SERIAL_NUMBER_INDEX)))
for device in RSContext().query_devices():
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 am sorry I didnt understand.

This works:

from tests.mock_intelrealsense import RSCameraInfo
RSCameraInfo(SERIAL_NUMBER_INDEX)

This works:

from pyrealsense2 import camera_info as RSCameraInfo
RSCameraInfo(SERIAL_NUMBER_INDEX)

This works:

from pyrealsense2 import camera_info
camera_info(SERIAL_NUMBER_INDEX)

But this doesnt work:

from pyrealsense2 import camera_info as RSCameraInfo
camera_info(SERIAL_NUMBER_INDEX)

@@ -243,24 +262,37 @@ def connect(self):
f"IntelRealSenseCamera({self.camera_index}) is already connected."
)

config = rs.config()
if self.mock:
Copy link
Collaborator Author

@Cadene Cadene Sep 28, 2024

Choose a reason for hiding this comment

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

I am sorry I didnt understand

The original element name is config but I rename it to RSConfig to fit python CamelCase format for naming classes:

from pyrealsense2 import config as RSConfig

# Release camera to make it accessible for `find_camera_indices`
del tmp_camera
import cv2
from cv2 import (
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed import cv2 and imported setNumThreads to clarify:

from cv2 import (
    CAP_PROP_FPS,
    CAP_PROP_FRAME_HEIGHT,
    CAP_PROP_FRAME_WIDTH,
    VideoCapture,
    setNumThreads,
)
# Use 1 thread to avoid blocking the main thread. Especially useful during data collection
# when other threads are used to save the images.
setNumThreads(1)

# valid cameras.
# Use 1 thread to avoid blocking the main thread. Especially useful during data collection
# when other threads are used to save the images.
cv2.setNumThreads(1)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Before my big change, cv2 was monkeypatched, and this would be an issue.

After my big change, cv2 isn't monkeypatched anymore, but we have this mock boolean variable that imports it or not. setNumThreads is only executed when mock=False.

if self.mock:
    from tests.mock_opencv import (
        CAP_PROP_FPS,
        CAP_PROP_FRAME_HEIGHT,
        CAP_PROP_FRAME_WIDTH,
        VideoCapture,
    )
else:
    from cv2 import (
        CAP_PROP_FPS,
        CAP_PROP_FRAME_HEIGHT,
        CAP_PROP_FRAME_WIDTH,
        VideoCapture,
        setNumThreads,
    )
    # Use 1 thread to avoid blocking the main thread. Especially useful during data collection
    # when other threads are used to save the images.
    setNumThreads(1)

num_tries += 1
if num_tries > self.fps * 2:
raise TimeoutError("Timed out waiting for async_read() to start.")
# if num_tries > self.fps and (self.thread.ident is None or not self.thread.is_alive()):
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. It was from Simon Alibert

@@ -154,6 +143,8 @@
NUM_READ_RETRY = 10
NUM_WRITE_RETRY = 10

COMM_SUCCESS = 0
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch! Removed this line. Added dynamic import everywhere

if self.mock:
    from tests.mock_dynamixel import GroupSyncWrite, COMM_SUCCESS
else:
    from dynamixel_sdk import GroupSyncWrite, COMM_SUCCESS
    ```

@@ -359,6 +362,11 @@ def connect(self):
f"DynamixelMotorsBus({self.port}) is already connected. Do not call `motors_bus.connect()` twice."
)

if self.mock:
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Indeed!

  • Renamed mock_dynamixel.py to mock_dynamixel_sdk.py
  • Renamed mock_intelrealsense.py to mock_pyrealsense2.py
  • Renamed mock_opencv.py to mock_cv2.py

request.getfixturevalue("patch_builtins_input")

if robot_type == "aloha":
pytest.skip("TODO(rcadene): enable test once aloha_real and act_aloha_real are merged")
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

TODO(rcadene)

Comment on lines +404 to +406
# Do not use `with self.lock` here, as it reduces fps
if self.color_image is not None:
return self.color_image
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@aliberts with self.lock causing slow fps here

Before fix:

with self.lock:
    if self.color_image is not None:
        return self.color_image
Screenshot 2024-09-28 at 15 38 19

After fix:

if self.color_image is not None:
    return self.color_image
Screenshot 2024-09-28 at 15 42 29

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
✨ Enhancement New feature or request 🌍 Real world Real-world robotics & controls
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants