From 039de254ea5f69ef7e7c39256cd0202e26814cf2 Mon Sep 17 00:00:00 2001 From: Simon Alibert <75076266+aliberts@users.noreply.github.com> Date: Tue, 8 Jul 2025 15:47:11 +0200 Subject: [PATCH] Add Hope Jr (#935) * Fix imports * Add feetech write tests * Nit * Add autoclosing fixture * Assert ping stub called * Add CalibrationMode * Add Motor in dxl robots * Simplify split_int_bytes * Rename read/write -> sync_read/write, refactor, add write * Rename tests * Refactor dxl tests by functionality * Add dxl write test * Refactor _is_comm_success * Refactor feetech tests by functionality * Add feetech write test * Simplify _is_comm_success & _is_error * Move mock_serial patch to dedicated file * Remove test skips & fix docstrings * Nit * Add dxl operating modes * Add is_connected in robots and teleops * Update Koch * Add feetech operating modes * Caps dxl OperatingMode * Update ensure_safe_goal_position * Update so100 * Privatize methods & renames * Fix dict * Add _configure_motors & move ping methods * Return models (str) with pings * Implement feetech broadcast ping * Add raw_values option * Rename idx -> id_ * Improve errors * Fix feetech ping tests * Ensure motors exist at connection time * Update tests * Add test_motors_bus * Move DriveMode & TorqueMode * Update Koch imports * Update so100 imports * Fix visualize_motors_bus * Fix imports * Add calibration * Rename idx -> id_ * Rename idx -> id_ * (WIP) _async_read * Add new calibration method for robot refactor (#896) Co-authored-by: Simon Alibert * Remove deprecated scripts * Rename CalibrationMode -> MotorNormMode * Fix calibration functions * Remove todo * Add scan_port utility * Add calibration utilities * Move encoding functions to encoding_utils * Add test_encoding_utils * Rename test * Add more calibration utilities * Format baudrate tables * Implement SO-100 leader calibration * Implement SO-100 follower calibration * Implement Koch calibration * Add test_scan_port (TODO) * Fix calibration * Hack feetech firmware bug * Update tests * Update Koch & SO-100 * Improve format * Rename SO-100 classes * Rename Koch classes * Add calibration tests * Remove old calibration tests * Revert feetech hack and monkeypatch instead * Simplify motors mocks * Add is_calibrated test * Update viperx & widowx * Rename viperx & widowx * Remove old calibration * feat(teleop): thread-safe keyboard teleop implementation (#869) Co-authored-by: Simon Alibert <75076266+aliberts@users.noreply.github.com> * Add support for feetech scs series + various fixes * Update dynamixel with motors bus & tables changes * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * (WIP) Add Hope Jr * Rename arm -> hand * (WIP) Add homonculus arm & glove * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add Feetech protocol version * Implement read * Use constants from sdks * (nit) move write * Fix broadcast ping type hint * Add protocol 1 broadcast ping * Refactor & add _serialize_data * Add feetech sm8512bl * Make feetech broadcast ping faster in protocol 1 * Cleanup * Add support for feetech protocol 1 to _split_into_byte_chunks * Fix unormalize * Remove test_motors_bus fixtures * Add more segmented tests (base motor bus & feetech), add feetech protocol 1 support * Add more segmented tests (dynamixel) * Refactor tests * Add handshake, fix feetech _read_firmware_version * Fix tests * Motors config & disconnect fixes * Add torque_disabled context * Update branch & fix pre-commit errors * Fix hand & glove readings * Update feetech tables * Move read/write_calibration implementations * Add setup_motor * Fix calibration msg display * Fix setup_motor & add it to robots * Fix _find_single_motor * Remove deprecated configure_motor * Remove deprecated dynamixel_calibration * Remove names * Remove deprecated import * refactor/lekiwi robot (#863) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Simon Alibert Co-authored-by: Simon Alibert <75076266+aliberts@users.noreply.github.com> * fix(teleoperators): use property is_connected (#1075) * Remove deprecated manipulator * Update robot features & naming * Update teleop features & naming * Add make_teleoperator_from_config * Rename find_port * Fix config parsing * Remove app script * Add setup_motors * Add teleoperate * Add record * Add replay * Fix test_datasets * Add mock robot & teleop * Add new test_control_robot * Add test_record_and_resume * Remove deprecated scripts & tests * Add calibrate * Add docstrings * Fix tests (no-extras install) * Add SO101 * Remove pynput from optional deps * Rename example 7 * Remove unecessary id * Add MotorsBus docstrings * Rename arm -> bus * Remove Moss arm * Fix setup_motors & calibrate configs * Fix test_calibrate * Add copyrights * Update hand & arm * Update homonculus hand & arm * Fix dxl _find_single_motor * Update glove * Add setup_motors for lekiwi * Fix glove calibration * Complete docstring * Add check for same min and max during calibration * Move MockMotorsBus * Add so100_follower tests * (WIP) add calibration gui * Fix test * Add setup_motors * Update calibration gui * Remove old .cache folder * Replace deprecated abc.abstractproperty * Fix feetech protocol 1 configure * Cleanup gui & add copyrights * Anatomically precise joint names * (WIP) Add glove to hand joints translation * Move make_robot_config * Add drive_mode & norm_mode in glove calibration * Fix joints translation * Fix normalization drive_mode * nit * Fix glove to hand conversion * Adapt feetech calibration * Remove pygame prompt * Implement arm calibration (hacks) * Better MotorsBus error messages * Update feetech read_calibration * Fix feetech test_is_calibrated * Cleanup glove * (WIP) Update arm * Add changes from #1117 * refactor(cameras): cameras implementations + tests improvements (#1108) Co-authored-by: Simon Alibert Co-authored-by: Simon Alibert <75076266+aliberts@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> * Fix arm joints order * Add timeout/event logic * Fix arm & glove * Fix predict_action from record * fix(cameras): update docstring + handle sn when starts with 0 + update timeouts to more reasonable value (#1154) * fix(scripts): parser instead of draccus in record + add __get_path_fields__() to RecordConfig (#1155) * Left/Right sides + other fixes * Arm fixes and add config * More hacks * Add control scripts * Fix merge errors * push changes to calibration, teleop and docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Move readme to docs * update readme Signed-off-by: Martino Russi <77496684+nepyope@users.noreply.github.com> * Add files via upload Signed-off-by: Martino Russi <77496684+nepyope@users.noreply.github.com> * Update image sources * Symlink doc * Compress image * Move image * Update docs link * fix docs * simplify teleop scripts * fix variable names * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address code review * add EMA to glove * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * integrate teleoperation for hand * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * update docs * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * import hopejr/homunculus in teleoperate * update docs for teleoperate, record, replay, train and inference * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * chore(hopejr): address comments * chore(hopejr): address coments 2 * chore(docs): update teleoperation instructions for the hand/glove * fix(hopejr): calibration int + update docs --------- Signed-off-by: Martino Russi <77496684+nepyope@users.noreply.github.com> Signed-off-by: Simon Alibert <75076266+aliberts@users.noreply.github.com> Co-authored-by: Pepijn <138571049+pkooij@users.noreply.github.com> Co-authored-by: Steven Palma Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: nepyope Co-authored-by: Martino Russi <77496684+nepyope@users.noreply.github.com> Co-authored-by: Steven Palma --- README.md | 23 + docs/source/_toctree.yml | 2 + docs/source/hope_jr.mdx | 1 + media/hope_jr/hopejr.png | Bin 0 -> 73277 bytes pyproject.toml | 1 + src/lerobot/calibrate.py | 2 + src/lerobot/motors/calibration_gui.py | 401 ++++++++++++++++++ src/lerobot/motors/dynamixel/dynamixel.py | 9 +- src/lerobot/motors/feetech/feetech.py | 15 +- src/lerobot/motors/feetech/tables.py | 2 +- src/lerobot/motors/motors_bus.py | 11 +- src/lerobot/record.py | 2 + src/lerobot/replay.py | 1 + src/lerobot/robots/hope_jr/__init__.py | 3 + src/lerobot/robots/hope_jr/config_hope_jr.py | 51 +++ src/lerobot/robots/hope_jr/hope_jr.mdx | 268 ++++++++++++ src/lerobot/robots/hope_jr/hope_jr_arm.py | 176 ++++++++ src/lerobot/robots/hope_jr/hope_jr_hand.py | 200 +++++++++ src/lerobot/robots/utils.py | 8 + src/lerobot/teleoperate.py | 2 + .../teleoperators/homunculus/__init__.py | 4 + .../homunculus/config_homunculus.py | 38 ++ .../homunculus/homunculus_arm.py | 310 ++++++++++++++ .../homunculus/homunculus_glove.py | 338 +++++++++++++++ .../homunculus/joints_translation.py | 63 +++ src/lerobot/teleoperators/utils.py | 8 + 26 files changed, 1922 insertions(+), 17 deletions(-) create mode 120000 docs/source/hope_jr.mdx create mode 100644 media/hope_jr/hopejr.png create mode 100644 src/lerobot/motors/calibration_gui.py create mode 100644 src/lerobot/robots/hope_jr/__init__.py create mode 100644 src/lerobot/robots/hope_jr/config_hope_jr.py create mode 100644 src/lerobot/robots/hope_jr/hope_jr.mdx create mode 100644 src/lerobot/robots/hope_jr/hope_jr_arm.py create mode 100644 src/lerobot/robots/hope_jr/hope_jr_hand.py create mode 100644 src/lerobot/teleoperators/homunculus/__init__.py create mode 100644 src/lerobot/teleoperators/homunculus/config_homunculus.py create mode 100644 src/lerobot/teleoperators/homunculus/homunculus_arm.py create mode 100644 src/lerobot/teleoperators/homunculus/homunculus_glove.py create mode 100644 src/lerobot/teleoperators/homunculus/joints_translation.py diff --git a/README.md b/README.md index 153a3a215..ff7a92384 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,29 @@ +

+

+ Build Your Own HopeJR Robot!

+

+ +
+ HopeJR robot + +

Meet HopeJR – A humanoid robot arm and hand for dexterous manipulation!

+

Control it with exoskeletons and gloves for precise hand movements.

+

Perfect for advanced manipulation tasks! 🤖

+ +

+ See the full HopeJR tutorial here.

+
+ +
+

Build Your Own SO-101 Robot!

diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index ea80e8257..83777a3c8 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -23,6 +23,8 @@ title: Finetune SmolVLA title: "Policies" - sections: + - local: hope_jr + title: Hope Jr - local: so101 title: SO-101 - local: so100 diff --git a/docs/source/hope_jr.mdx b/docs/source/hope_jr.mdx new file mode 120000 index 000000000..402422634 --- /dev/null +++ b/docs/source/hope_jr.mdx @@ -0,0 +1 @@ +../../src/lerobot/robots/hope_jr/hope_jr.mdx \ No newline at end of file diff --git a/media/hope_jr/hopejr.png b/media/hope_jr/hopejr.png new file mode 100644 index 0000000000000000000000000000000000000000..4186547a25db052d6bedf07ca839889f2c5c055f GIT binary patch literal 73277 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 literal 0 HcmV?d00001 diff --git a/pyproject.toml b/pyproject.toml index 9fc84d903..408e3b773 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ dora = [ dynamixel = ["dynamixel-sdk>=3.7.31"] feetech = ["feetech-servo-sdk>=1.0.0"] gamepad = ["pygame>=2.5.1", "hidapi>=0.14.0"] +hopejr = ["feetech-servo-sdk>=1.0.0", "pygame>=2.5.1"] kinematics = ["placo>=0.9.6"] intelrealsense = [ "pyrealsense2>=2.55.1.6486 ; sys_platform != 'darwin'", diff --git a/src/lerobot/calibrate.py b/src/lerobot/calibrate.py index 37a9d5bdf..1e8bf4751 100644 --- a/src/lerobot/calibrate.py +++ b/src/lerobot/calibrate.py @@ -36,6 +36,7 @@ from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraCon from lerobot.robots import ( # noqa: F401 Robot, RobotConfig, + hope_jr, koch_follower, lekiwi, make_robot_from_config, @@ -45,6 +46,7 @@ from lerobot.robots import ( # noqa: F401 from lerobot.teleoperators import ( # noqa: F401 Teleoperator, TeleoperatorConfig, + homunculus, koch_leader, make_teleoperator_from_config, so100_leader, diff --git a/src/lerobot/motors/calibration_gui.py b/src/lerobot/motors/calibration_gui.py new file mode 100644 index 000000000..9832a1636 --- /dev/null +++ b/src/lerobot/motors/calibration_gui.py @@ -0,0 +1,401 @@ +# 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 math +import os +from dataclasses import dataclass + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "1" + +from lerobot.motors import MotorCalibration, MotorsBus + +BAR_LEN, BAR_THICKNESS = 450, 8 +HANDLE_R = 10 +BRACKET_W, BRACKET_H = 6, 14 +TRI_W, TRI_H = 12, 14 + +BTN_W, BTN_H = 60, 22 +SAVE_W, SAVE_H = 80, 28 +LOAD_W = 80 +DD_W, DD_H = 160, 28 + +TOP_GAP = 50 +PADDING_Y, TOP_OFFSET = 70, 60 +FONT_SIZE, FPS = 20, 60 + +BG_COLOR = (30, 30, 30) +BAR_RED, BAR_GREEN = (200, 60, 60), (60, 200, 60) +HANDLE_COLOR, TEXT_COLOR = (240, 240, 240), (250, 250, 250) +TICK_COLOR = (250, 220, 40) +BTN_COLOR, BTN_COLOR_HL = (80, 80, 80), (110, 110, 110) +DD_COLOR, DD_COLOR_HL = (70, 70, 70), (100, 100, 100) + + +def dist(a, b): + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +@dataclass +class RangeValues: + min_v: int + pos_v: int + max_v: int + + +class RangeSlider: + """One motor = one slider row""" + + def __init__(self, motor, idx, res, calibration, present, label_pad, base_y): + import pygame + + self.motor = motor + self.res = res + self.x0 = 40 + label_pad + self.x1 = self.x0 + BAR_LEN + self.y = base_y + idx * PADDING_Y + + self.min_v = calibration.range_min + self.max_v = calibration.range_max + self.pos_v = max(self.min_v, min(present, self.max_v)) + + self.min_x = self._pos_from_val(self.min_v) + self.max_x = self._pos_from_val(self.max_v) + self.pos_x = self._pos_from_val(self.pos_v) + + self.min_btn = pygame.Rect(self.x0 - BTN_W - 6, self.y - BTN_H // 2, BTN_W, BTN_H) + self.max_btn = pygame.Rect(self.x1 + 6, self.y - BTN_H // 2, BTN_W, BTN_H) + + self.drag_min = self.drag_max = self.drag_pos = False + self.tick_val = present + self.font = pygame.font.Font(None, FONT_SIZE) + + def _val_from_pos(self, x): + return round((x - self.x0) / BAR_LEN * self.res) + + def _pos_from_val(self, v): + return self.x0 + (v / self.res) * BAR_LEN + + def set_tick(self, v): + self.tick_val = max(0, min(v, self.res)) + + def _triangle_hit(self, pos): + import pygame + + tri_top = self.y - BAR_THICKNESS // 2 - 2 + return pygame.Rect(self.pos_x - TRI_W // 2, tri_top - TRI_H, TRI_W, TRI_H).collidepoint(pos) + + def handle_event(self, e): + import pygame + + if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + if self.min_btn.collidepoint(e.pos): + self.min_x, self.min_v = self.pos_x, self.pos_v + return + if self.max_btn.collidepoint(e.pos): + self.max_x, self.max_v = self.pos_x, self.pos_v + return + if dist(e.pos, (self.min_x, self.y)) <= HANDLE_R: + self.drag_min = True + elif dist(e.pos, (self.max_x, self.y)) <= HANDLE_R: + self.drag_max = True + elif self._triangle_hit(e.pos): + self.drag_pos = True + + elif e.type == pygame.MOUSEBUTTONUP and e.button == 1: + self.drag_min = self.drag_max = self.drag_pos = False + + elif e.type == pygame.MOUSEMOTION: + x = e.pos[0] + if self.drag_min: + self.min_x = max(self.x0, min(x, self.pos_x)) + elif self.drag_max: + self.max_x = min(self.x1, max(x, self.pos_x)) + elif self.drag_pos: + self.pos_x = max(self.min_x, min(x, self.max_x)) + + self.min_v = self._val_from_pos(self.min_x) + self.max_v = self._val_from_pos(self.max_x) + self.pos_v = self._val_from_pos(self.pos_x) + + def _draw_button(self, surf, rect, text): + import pygame + + clr = BTN_COLOR_HL if rect.collidepoint(pygame.mouse.get_pos()) else BTN_COLOR + pygame.draw.rect(surf, clr, rect, border_radius=4) + t = self.font.render(text, True, TEXT_COLOR) + surf.blit(t, (rect.centerx - t.get_width() // 2, rect.centery - t.get_height() // 2)) + + def draw(self, surf): + import pygame + + # motor name above set-min button (right-aligned) + name_surf = self.font.render(self.motor, True, TEXT_COLOR) + surf.blit( + name_surf, + (self.min_btn.right - name_surf.get_width(), self.min_btn.y - name_surf.get_height() - 4), + ) + + # bar + active section + pygame.draw.rect(surf, BAR_RED, (self.x0, self.y - BAR_THICKNESS // 2, BAR_LEN, BAR_THICKNESS)) + pygame.draw.rect( + surf, BAR_GREEN, (self.min_x, self.y - BAR_THICKNESS // 2, self.max_x - self.min_x, BAR_THICKNESS) + ) + + # tick + tick_x = self._pos_from_val(self.tick_val) + pygame.draw.line( + surf, + TICK_COLOR, + (tick_x, self.y - BAR_THICKNESS // 2 - 4), + (tick_x, self.y + BAR_THICKNESS // 2 + 4), + 2, + ) + + # brackets + for x, sign in ((self.min_x, +1), (self.max_x, -1)): + pygame.draw.line( + surf, HANDLE_COLOR, (x, self.y - BRACKET_H // 2), (x, self.y + BRACKET_H // 2), 2 + ) + pygame.draw.line( + surf, + HANDLE_COLOR, + (x, self.y - BRACKET_H // 2), + (x + sign * BRACKET_W, self.y - BRACKET_H // 2), + 2, + ) + pygame.draw.line( + surf, + HANDLE_COLOR, + (x, self.y + BRACKET_H // 2), + (x + sign * BRACKET_W, self.y + BRACKET_H // 2), + 2, + ) + + # triangle ▼ + tri_top = self.y - BAR_THICKNESS // 2 - 2 + pygame.draw.polygon( + surf, + HANDLE_COLOR, + [ + (self.pos_x, tri_top), + (self.pos_x - TRI_W // 2, tri_top - TRI_H), + (self.pos_x + TRI_W // 2, tri_top - TRI_H), + ], + ) + + # numeric labels + fh = self.font.get_height() + pos_y = tri_top - TRI_H - 4 - fh + txts = [ + (self.min_v, self.min_x, self.y - BRACKET_H // 2 - 4 - fh), + (self.max_v, self.max_x, self.y - BRACKET_H // 2 - 4 - fh), + (self.pos_v, self.pos_x, pos_y), + ] + for v, x, y in txts: + s = self.font.render(str(v), True, TEXT_COLOR) + surf.blit(s, (x - s.get_width() // 2, y)) + + # buttons + self._draw_button(surf, self.min_btn, "set min") + self._draw_button(surf, self.max_btn, "set max") + + # external + def values(self) -> RangeValues: + return RangeValues(self.min_v, self.pos_v, self.max_v) + + +class RangeFinderGUI: + def __init__(self, bus: MotorsBus, groups: dict[str, list[str]] | None = None): + import pygame + + self.bus = bus + self.groups = groups if groups is not None else {"all": list(bus.motors)} + self.group_names = list(groups) + self.current_group = self.group_names[0] + + if not bus.is_connected: + bus.connect() + + self.calibration = bus.read_calibration() + self.res_table = bus.model_resolution_table + self.present_cache = { + m: bus.read("Present_Position", m, normalize=False) for motors in groups.values() for m in motors + } + + pygame.init() + self.font = pygame.font.Font(None, FONT_SIZE) + + label_pad = max(self.font.size(m)[0] for ms in groups.values() for m in ms) + self.label_pad = label_pad + width = 40 + label_pad + BAR_LEN + 6 + BTN_W + 10 + SAVE_W + 10 + self.controls_bottom = 10 + SAVE_H + self.base_y = self.controls_bottom + TOP_GAP + height = self.base_y + PADDING_Y * len(groups[self.current_group]) + 40 + + self.screen = pygame.display.set_mode((width, height)) + pygame.display.set_caption("Motors range finder") + + # ui rects + self.save_btn = pygame.Rect(width - SAVE_W - 10, 10, SAVE_W, SAVE_H) + self.load_btn = pygame.Rect(self.save_btn.left - LOAD_W - 10, 10, LOAD_W, SAVE_H) + self.dd_btn = pygame.Rect(width // 2 - DD_W // 2, 10, DD_W, DD_H) + self.dd_open = False # dropdown expanded? + + self.clock = pygame.time.Clock() + self._build_sliders() + self._adjust_height() + + def _adjust_height(self): + import pygame + + motors = self.groups[self.current_group] + new_h = self.base_y + PADDING_Y * len(motors) + 40 + if new_h != self.screen.get_height(): + w = self.screen.get_width() + self.screen = pygame.display.set_mode((w, new_h)) + + def _build_sliders(self): + self.sliders: list[RangeSlider] = [] + motors = self.groups[self.current_group] + for i, m in enumerate(motors): + self.sliders.append( + RangeSlider( + motor=m, + idx=i, + res=self.res_table[self.bus.motors[m].model] - 1, + calibration=self.calibration[m], + present=self.present_cache[m], + label_pad=self.label_pad, + base_y=self.base_y, + ) + ) + + def _draw_dropdown(self): + import pygame + + # collapsed box + hover = self.dd_btn.collidepoint(pygame.mouse.get_pos()) + pygame.draw.rect(self.screen, DD_COLOR_HL if hover else DD_COLOR, self.dd_btn, border_radius=6) + + txt = self.font.render(self.current_group, True, TEXT_COLOR) + self.screen.blit( + txt, (self.dd_btn.centerx - txt.get_width() // 2, self.dd_btn.centery - txt.get_height() // 2) + ) + + tri_w, tri_h = 12, 6 + cx = self.dd_btn.right - 14 + cy = self.dd_btn.centery + 1 + pygame.draw.polygon( + self.screen, + TEXT_COLOR, + [(cx - tri_w // 2, cy - tri_h // 2), (cx + tri_w // 2, cy - tri_h // 2), (cx, cy + tri_h // 2)], + ) + + if not self.dd_open: + return + + # expanded list + for i, name in enumerate(self.group_names): + item_rect = pygame.Rect(self.dd_btn.left, self.dd_btn.bottom + i * DD_H, DD_W, DD_H) + clr = DD_COLOR_HL if item_rect.collidepoint(pygame.mouse.get_pos()) else DD_COLOR + pygame.draw.rect(self.screen, clr, item_rect) + t = self.font.render(name, True, TEXT_COLOR) + self.screen.blit( + t, (item_rect.centerx - t.get_width() // 2, item_rect.centery - t.get_height() // 2) + ) + + def _handle_dropdown_event(self, e): + import pygame + + if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + if self.dd_btn.collidepoint(e.pos): + self.dd_open = not self.dd_open + return True + if self.dd_open: + for i, name in enumerate(self.group_names): + item_rect = pygame.Rect(self.dd_btn.left, self.dd_btn.bottom + i * DD_H, DD_W, DD_H) + if item_rect.collidepoint(e.pos): + if name != self.current_group: + self.current_group = name + self._build_sliders() + self._adjust_height() + self.dd_open = False + return True + self.dd_open = False + return False + + def _save_current(self): + for s in self.sliders: + self.calibration[s.motor].range_min = s.min_v + self.calibration[s.motor].range_max = s.max_v + + with self.bus.torque_disabled(): + self.bus.write_calibration(self.calibration) + + def _load_current(self): + self.calibration = self.bus.read_calibration() + for s in self.sliders: + s.min_v = self.calibration[s.motor].range_min + s.max_v = self.calibration[s.motor].range_max + s.min_x = s._pos_from_val(s.min_v) + s.max_x = s._pos_from_val(s.max_v) + + def run(self) -> dict[str, MotorCalibration]: + import pygame + + while True: + for e in pygame.event.get(): + if e.type == pygame.QUIT: + pygame.quit() + return self.calibration + + if self._handle_dropdown_event(e): + continue + + if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1: + if self.save_btn.collidepoint(e.pos): + self._save_current() + elif self.load_btn.collidepoint(e.pos): + self._load_current() + + for s in self.sliders: + s.handle_event(e) + + # live goal write while dragging + for s in self.sliders: + if s.drag_pos: + self.bus.write("Goal_Position", s.motor, s.pos_v, normalize=False) + + # tick update + for s in self.sliders: + pos = self.bus.read("Present_Position", s.motor, normalize=False) + s.set_tick(pos) + self.present_cache[s.motor] = pos + + # ─ drawing + self.screen.fill(BG_COLOR) + for s in self.sliders: + s.draw(self.screen) + + self._draw_dropdown() + + # load / save buttons + for rect, text in ((self.load_btn, "LOAD"), (self.save_btn, "SAVE")): + clr = BTN_COLOR_HL if rect.collidepoint(pygame.mouse.get_pos()) else BTN_COLOR + pygame.draw.rect(self.screen, clr, rect, border_radius=6) + t = self.font.render(text, True, TEXT_COLOR) + self.screen.blit(t, (rect.centerx - t.get_width() // 2, rect.centery - t.get_height() // 2)) + + pygame.display.flip() + self.clock.tick(FPS) diff --git a/src/lerobot/motors/dynamixel/dynamixel.py b/src/lerobot/motors/dynamixel/dynamixel.py index d4f41643c..1113ec0f7 100644 --- a/src/lerobot/motors/dynamixel/dynamixel.py +++ b/src/lerobot/motors/dynamixel/dynamixel.py @@ -162,11 +162,11 @@ class DynamixelMotorsBus(MotorsBus): raise RuntimeError(f"Motor '{motor}' (model '{model}') was not found. Make sure it is connected.") - def configure_motors(self) -> None: + def configure_motors(self, return_delay_time=0) -> None: # By default, Dynamixel motors have a 500µs delay response time (corresponding to a value of 250 on # the 'Return_Delay_Time' address). We ensure this is reduced to the minimum of 2µs (value of 0). for motor in self.motors: - self.write("Return_Delay_Time", motor, 0) + self.write("Return_Delay_Time", motor, return_delay_time) @property def is_calibrated(self) -> bool: @@ -190,13 +190,14 @@ class DynamixelMotorsBus(MotorsBus): return calibration - def write_calibration(self, calibration_dict: dict[str, MotorCalibration]) -> None: + def write_calibration(self, calibration_dict: dict[str, MotorCalibration], cache: bool = True) -> None: for motor, calibration in calibration_dict.items(): self.write("Homing_Offset", motor, calibration.homing_offset) self.write("Min_Position_Limit", motor, calibration.range_min) self.write("Max_Position_Limit", motor, calibration.range_max) - self.calibration = calibration_dict + if cache: + self.calibration = calibration_dict def disable_torque(self, motors: str | list[str] | None = None, num_retry: int = 0) -> None: for motor in self._get_motors_list(motors): diff --git a/src/lerobot/motors/feetech/feetech.py b/src/lerobot/motors/feetech/feetech.py index 7edf869a4..88d45ba39 100644 --- a/src/lerobot/motors/feetech/feetech.py +++ b/src/lerobot/motors/feetech/feetech.py @@ -219,15 +219,15 @@ class FeetechMotorsBus(MotorsBus): raise RuntimeError(f"Motor '{motor}' (model '{model}') was not found. Make sure it is connected.") - def configure_motors(self) -> None: + def configure_motors(self, return_delay_time=0, maximum_acceleration=254, acceleration=254) -> None: for motor in self.motors: # By default, Feetech motors have a 500µs delay response time (corresponding to a value of 250 on # the 'Return_Delay_Time' address). We ensure this is reduced to the minimum of 2µs (value of 0). - self.write("Return_Delay_Time", motor, 0) + self.write("Return_Delay_Time", motor, return_delay_time) # Set 'Maximum_Acceleration' to 254 to speedup acceleration and deceleration of the motors. - # Note: this address is not in the official STS3215 Memory Table - self.write("Maximum_Acceleration", motor, 254) - self.write("Acceleration", motor, 254) + if self.protocol_version == 0: + self.write("Maximum_Acceleration", motor, maximum_acceleration) + self.write("Acceleration", motor, acceleration) @property def is_calibrated(self) -> bool: @@ -270,14 +270,15 @@ class FeetechMotorsBus(MotorsBus): return calibration - def write_calibration(self, calibration_dict: dict[str, MotorCalibration]) -> None: + def write_calibration(self, calibration_dict: dict[str, MotorCalibration], cache: bool = True) -> None: for motor, calibration in calibration_dict.items(): if self.protocol_version == 0: self.write("Homing_Offset", motor, calibration.homing_offset) self.write("Min_Position_Limit", motor, calibration.range_min) self.write("Max_Position_Limit", motor, calibration.range_max) - self.calibration = calibration_dict + if cache: + self.calibration = calibration_dict def _get_half_turn_homings(self, positions: dict[NameOrID, Value]) -> dict[NameOrID, Value]: """ diff --git a/src/lerobot/motors/feetech/tables.py b/src/lerobot/motors/feetech/tables.py index 0a2f2659f..48814957f 100644 --- a/src/lerobot/motors/feetech/tables.py +++ b/src/lerobot/motors/feetech/tables.py @@ -189,7 +189,7 @@ MODEL_RESOLUTION = { "scs_series": 1024, "sts3215": 4096, "sts3250": 4096, - "sm8512bl": 65536, + "sm8512bl": 4096, "scs0009": 1024, } diff --git a/src/lerobot/motors/motors_bus.py b/src/lerobot/motors/motors_bus.py index 7386bfb1c..26522c7c9 100644 --- a/src/lerobot/motors/motors_bus.py +++ b/src/lerobot/motors/motors_bus.py @@ -586,7 +586,7 @@ class MotorsBus(abc.ABC): pass @contextmanager - def torque_disabled(self): + def torque_disabled(self, motors: int | str | list[str] | None = None): """Context-manager that guarantees torque is re-enabled. This helper is useful to temporarily disable torque when configuring motors. @@ -596,11 +596,11 @@ class MotorsBus(abc.ABC): ... # Safe operations here ... pass """ - self.disable_torque() + self.disable_torque(motors) try: yield finally: - self.enable_torque() + self.enable_torque(motors) def set_timeout(self, timeout_ms: int | None = None): """Change the packet timeout used by the SDK. @@ -653,12 +653,13 @@ class MotorsBus(abc.ABC): pass @abc.abstractmethod - def write_calibration(self, calibration_dict: dict[str, MotorCalibration]) -> None: - """Write calibration parameters to the motors and cache them. + def write_calibration(self, calibration_dict: dict[str, MotorCalibration], cache: bool = True) -> None: + """Write calibration parameters to the motors and optionally cache them. Args: calibration_dict (dict[str, MotorCalibration]): Calibration obtained from :pymeth:`read_calibration` or crafted by the user. + cache (bool, optional): Save the calibration to :pyattr:`calibration`. Defaults to True. """ pass diff --git a/src/lerobot/record.py b/src/lerobot/record.py index 635bdf1e4..9fc0dc7ed 100644 --- a/src/lerobot/record.py +++ b/src/lerobot/record.py @@ -57,6 +57,7 @@ from lerobot.policies.pretrained import PreTrainedPolicy from lerobot.robots import ( # noqa: F401 Robot, RobotConfig, + hope_jr, koch_follower, make_robot_from_config, so100_follower, @@ -65,6 +66,7 @@ from lerobot.robots import ( # noqa: F401 from lerobot.teleoperators import ( # noqa: F401 Teleoperator, TeleoperatorConfig, + homunculus, koch_leader, make_teleoperator_from_config, so100_leader, diff --git a/src/lerobot/replay.py b/src/lerobot/replay.py index ef20c28ef..c51c55cee 100644 --- a/src/lerobot/replay.py +++ b/src/lerobot/replay.py @@ -39,6 +39,7 @@ from lerobot.datasets.lerobot_dataset import LeRobotDataset from lerobot.robots import ( # noqa: F401 Robot, RobotConfig, + hope_jr, koch_follower, make_robot_from_config, so100_follower, diff --git a/src/lerobot/robots/hope_jr/__init__.py b/src/lerobot/robots/hope_jr/__init__.py new file mode 100644 index 000000000..324e3c8e8 --- /dev/null +++ b/src/lerobot/robots/hope_jr/__init__.py @@ -0,0 +1,3 @@ +from .config_hope_jr import HopeJrArmConfig, HopeJrHandConfig +from .hope_jr_arm import HopeJrArm +from .hope_jr_hand import HopeJrHand diff --git a/src/lerobot/robots/hope_jr/config_hope_jr.py b/src/lerobot/robots/hope_jr/config_hope_jr.py new file mode 100644 index 000000000..747e98e01 --- /dev/null +++ b/src/lerobot/robots/hope_jr/config_hope_jr.py @@ -0,0 +1,51 @@ +#!/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. + +from dataclasses import dataclass, field + +from lerobot.cameras import CameraConfig + +from ..config import RobotConfig + + +@RobotConfig.register_subclass("hope_jr_hand") +@dataclass +class HopeJrHandConfig(RobotConfig): + port: str # Port to connect to the hand + side: str # "left" / "right" + + disable_torque_on_disconnect: bool = True + + cameras: dict[str, CameraConfig] = field(default_factory=dict) + + def __post_init__(self): + super().__post_init__() + if self.side not in ["right", "left"]: + raise ValueError(self.side) + + +@RobotConfig.register_subclass("hope_jr_arm") +@dataclass +class HopeJrArmConfig(RobotConfig): + port: str # Port to connect to the hand + disable_torque_on_disconnect: bool = True + + # `max_relative_target` limits the magnitude of the relative positional target vector for safety purposes. + # Set this to a positive scalar to have the same value for all motors, or a list that is the same length as + # the number of motors in your follower arms. + max_relative_target: int | None = None + + cameras: dict[str, CameraConfig] = field(default_factory=dict) diff --git a/src/lerobot/robots/hope_jr/hope_jr.mdx b/src/lerobot/robots/hope_jr/hope_jr.mdx new file mode 100644 index 000000000..2f9ec9d89 --- /dev/null +++ b/src/lerobot/robots/hope_jr/hope_jr.mdx @@ -0,0 +1,268 @@ +# HopeJR + +## Prerequisites + +- [Hardware Setup](https://github.com/TheRobotStudio/HOPEJr) + +## Install LeRobot + +Follow the [installation instructions](https://github.com/huggingface/lerobot#installation) to install LeRobot. + +Install LeRobot with HopeJR dependencies: +```bash +pip install -e ".[hopejr]" +``` + +## Device Configuration + +Before starting calibration and operation, you need to identify the USB ports for each HopeJR component. Run this script to find the USB ports for the arm, hand, glove, and exoskeleton: + +```bash +python -m lerobot.find_port +``` + +This will display the available USB ports and their associated devices. Make note of the port paths (e.g., `/dev/tty.usbmodem58760433331`, `/dev/tty.usbmodem11301`) as you'll need to specify them in the `--robot.port` and `--teleop.port` parameters when recording data, replaying episodes, or running teleoperation scripts. + +## Step 1: Calibration + +Before performing teleoperation, HopeJR's limbs need to be calibrated. Calibration files will be saved in `~/.cache/huggingface/lerobot/calibration` + +### 1.1 Calibrate Robot Hand + +```bash +python -m lerobot.calibrate \ + --robot.type=hope_jr_hand \ + --robot.port=/dev/tty.usbmodem58760432281 \ + --robot.id=blue \ + --robot.side=right +``` + +When running the calibration script, a calibration GUI will pop up. Finger joints are named as follows: + +**Thumb**: +- **CMC**: base joint connecting thumb to hand +- **MCP**: knuckle joint +- **PIP**: first finger joint +- **DIP** : fingertip joint + +**Index, Middle, Ring, and Pinky fingers**: +- **Radial flexor**: Moves base of finger towards the thumb +- **Ulnar flexor**: Moves base of finger towards the pinky +- **PIP/DIP**: Flexes the distal and proximal phalanx of the finger + +Each one of these will need to be calibrated individually via the GUI. + Note that ulnar and radial flexors should have ranges of the same size (but with different offsets) in order to get symmetric movement. + +

+ Setting boundaries in the hand calibration GUI + +

+ +Use the calibration interface to set the range boundaries for each joint as shown above. + +

+ Saving calibration values + +

+ +Once you have set the appropriate boundaries for all joints, click "Save" to save the calibration values to the motors. + +### 1.2 Calibrate Teleoperator Glove + +```bash +python -m lerobot.calibrate \ + --teleop.type=homunculus_glove \ + --teleop.port=/dev/tty.usbmodem11201 \ + --teleop.id=red \ + --teleop.side=right +``` + +Move each finger through its full range of motion, starting from the thumb. + +``` +Move thumb through its entire range of motion. +Recording positions. Press ENTER to stop... + +------------------------------------------- +NAME | MIN | POS | MAX +thumb_cmc | 1790 | 1831 | 1853 +thumb_mcp | 1497 | 1514 | 1528 +thumb_pip | 1466 | 1496 | 1515 +thumb_dip | 1463 | 1484 | 1514 +``` + +Continue with each finger: + +``` +Move middle through its entire range of motion. +Recording positions. Press ENTER to stop... + +------------------------------------------- +NAME | MIN | POS | MAX +middle_mcp_abduction | 1598 | 1718 | 1820 +middle_mcp_flexion | 1512 | 1658 | 2136 +middle_dip | 1484 | 1500 | 1547 +``` + +Once calibration is complete, the system will save the calibration to `/Users/your_username/.cache/huggingface/lerobot/calibration/teleoperators/homunculus_glove/red.json` + +### 1.3 Calibrate Robot Arm + +```bash +python -m lerobot.calibrate \ + --robot.type=hope_jr_arm \ + --robot.port=/dev/tty.usbserial-1110 \ + --robot.id=white +``` + +This will open a calibration GUI where you can set the range limits for each motor. The arm motions are organized as follows: +- **Shoulder**: pitch, yaw, and roll +- **Elbow**: flex +- **Wrist**: pitch, yaw, and roll + +

+ Setting boundaries in the arm calibration GUI + +

+ +Use the calibration interface to set the range boundaries for each joint. Move each joint through its full range of motion and adjust the minimum and maximum values accordingly. Once you have set the appropriate boundaries for all joints, save the calibration. + +### 1.4 Calibrate Teleoperator Exoskeleton + +```bash +python -m lerobot.calibrate \ + --teleop.type=homunculus_arm \ + --teleop.port=/dev/tty.usbmodem11201 \ + --teleop.id=black +``` + +The exoskeleton allows one to control the robot arm. During calibration, you'll be prompted to move all joints through their full range of motion: + +``` +Move all joints through their entire range of motion. +Recording positions. Press ENTER to stop... + +------------------------------------------- +------------------------------------------- +NAME | MIN | POS | MAX +shoulder_pitch | 586 | 736 | 895 +shoulder_yaw | 1257 | 1374 | 1390 +shoulder_roll | 449 | 1034 | 2564 +elbow_flex | 3023 | 3117 | 3134 +wrist_roll | 3073 | 3096 | 3147 +wrist_yaw | 2143 | 2171 | 2185 +wrist_pitch | 1975 | 1993 | 2074 +Calibration saved to /Users/your_username/.cache/huggingface/lerobot/calibration/teleoperators/homunculus_arm/black.json +``` + +## Step 2: Teleoperation + +Due to global variable conflicts in the Feetech middleware, teleoperation for arm and hand must run in separate shell sessions: + +### Hand +```bash +python -m lerobot.teleoperate \ + --robot.type=hope_jr_hand \ + --robot.port=/dev/tty.usbmodem58760432281 \ + --robot.id=blue \ + --robot.side=right \ + --teleop.type=homunculus_glove \ + --teleop.port=/dev/tty.usbmodem11201 \ + --teleop.id=red \ + --teleop.side=right \ + --display_data=true \ + --fps=30 +``` + +### Arm +```bash +python -m lerobot.teleoperate \ + --robot.type=hope_jr_arm \ + --robot.port=/dev/tty.usbserial-1110 \ + --robot.id=white \ + --teleop.type=homunculus_arm \ + --teleop.port=/dev/tty.usbmodem11201 \ + --teleop.id=black \ + --display_data=true \ + --fps=30 +``` + +## Step 3: Record, Replay, Train + +Record, Replay and Train with Hope-JR is still experimental. + +### Record + +This step records the dataset, which can be seen as an example [here](https://huggingface.co/datasets/nepyope/hand_record_test_with_video_data/settings). + +```bash +python -m lerobot.record \ + --robot.type=hope_jr_hand \ + --robot.port=/dev/tty.usbmodem58760432281 \ + --robot.id=right \ + --robot.side=right \ + --robot.cameras='{"main": {"type": "opencv", "index_or_path": 0, "width": 640, "height": 480, "fps": 30}}' \ + --teleop.type=homunculus_glove \ + --teleop.port=/dev/tty.usbmodem1201 \ + --teleop.id=right \ + --teleop.side=right \ + --dataset.repo_id=nepyope/hand_record_test_with_video_data \ + --dataset.single_task="Hand recording test with video data" \ + --dataset.num_episodes=1 \ + --dataset.episode_time_s=5 \ + --dataset.push_to_hub=true \ + --dataset.private=true \ + --display_data=true +``` + +### Replay + +```bash +python -m lerobot.replay \ + --robot.type=hope_jr_hand \ + --robot.port=/dev/tty.usbmodem58760432281 \ + --robot.id=right \ + --robot.side=right \ + --dataset.repo_id=nepyope/hand_record_test_with_camera \ + --dataset.episode=0 +``` + +### Train + +```bash +python -m lerobot.scripts.train \ + --dataset.repo_id=nepyope/hand_record_test_with_video_data \ + --policy.type=act \ + --output_dir=outputs/train/hopejr_hand \ + --job_name=hopejr \ + --policy.device=mps \ + --wandb.enable=true \ + --policy.repo_id=nepyope/hand_test_policy +``` + +### Evaluate + +This training run can be viewed as an example [here](https://wandb.ai/tino/lerobot/runs/rp0k8zvw?nw=nwusertino). + +```bash +python -m lerobot.record \ + --robot.type=hope_jr_hand \ + --robot.port=/dev/tty.usbmodem58760432281 \ + --robot.id=right \ + --robot.side=right \ + --robot.cameras='{"main": {"type": "opencv", "index_or_path": 0, "width": 640, "height": 480, "fps": 30}}' \ + --display_data=false \ + --dataset.repo_id=nepyope/eval_hopejr \ + --dataset.single_task="Evaluate hopejr hand policy" \ + --dataset.num_episodes=10 \ + --policy.path=outputs/train/hopejr_hand/checkpoints/last/pretrained_model +``` diff --git a/src/lerobot/robots/hope_jr/hope_jr_arm.py b/src/lerobot/robots/hope_jr/hope_jr_arm.py new file mode 100644 index 000000000..0e3a615a9 --- /dev/null +++ b/src/lerobot/robots/hope_jr/hope_jr_arm.py @@ -0,0 +1,176 @@ +#!/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 +import time +from functools import cached_property +from typing import Any + +from lerobot.cameras.utils import make_cameras_from_configs +from lerobot.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError +from lerobot.motors import Motor, MotorNormMode +from lerobot.motors.calibration_gui import RangeFinderGUI +from lerobot.motors.feetech import ( + FeetechMotorsBus, +) + +from ..robot import Robot +from ..utils import ensure_safe_goal_position +from .config_hope_jr import HopeJrArmConfig + +logger = logging.getLogger(__name__) + + +class HopeJrArm(Robot): + config_class = HopeJrArmConfig + name = "hope_jr_arm" + + def __init__(self, config: HopeJrArmConfig): + super().__init__(config) + self.config = config + self.bus = FeetechMotorsBus( + port=self.config.port, + motors={ + "shoulder_pitch": Motor(1, "sm8512bl", MotorNormMode.RANGE_M100_100), + "shoulder_yaw": Motor(2, "sts3250", MotorNormMode.RANGE_M100_100), + "shoulder_roll": Motor(3, "sts3250", MotorNormMode.RANGE_M100_100), + "elbow_flex": Motor(4, "sts3250", MotorNormMode.RANGE_M100_100), + "wrist_roll": Motor(5, "sts3250", MotorNormMode.RANGE_M100_100), + "wrist_yaw": Motor(6, "sts3250", MotorNormMode.RANGE_M100_100), + "wrist_pitch": Motor(7, "sts3250", MotorNormMode.RANGE_M100_100), + }, + calibration=self.calibration, + ) + self.cameras = make_cameras_from_configs(config.cameras) + + # HACK + self.shoulder_pitch = "shoulder_pitch" + self.other_motors = [m for m in self.bus.motors if m != "shoulder_pitch"] + + @property + def _motors_ft(self) -> dict[str, type]: + return {f"{motor}.pos": float for motor in self.bus.motors} + + @property + def _cameras_ft(self) -> dict[str, tuple]: + return { + cam: (self.config.cameras[cam].height, self.config.cameras[cam].width, 3) for cam in self.cameras + } + + @cached_property + def observation_features(self) -> dict[str, type | tuple]: + return {**self._motors_ft, **self._cameras_ft} + + @cached_property + def action_features(self) -> dict[str, type]: + return self._motors_ft + + @property + def is_connected(self) -> bool: + return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values()) + + def connect(self, calibrate: bool = True) -> None: + """ + We assume that at connection time, arm is in a rest position, + and torque can be safely disabled to run calibration. + """ + if self.is_connected: + raise DeviceAlreadyConnectedError(f"{self} already connected") + + self.bus.connect(handshake=False) + if not self.is_calibrated and calibrate: + self.calibrate() + + # Connect the cameras + for cam in self.cameras.values(): + cam.connect() + + self.configure() + logger.info(f"{self} connected.") + + @property + def is_calibrated(self) -> bool: + return self.bus.is_calibrated + + def calibrate(self, limb_name: str = None) -> None: + groups = { + "all": list(self.bus.motors.keys()), + "shoulder": ["shoulder_pitch", "shoulder_yaw", "shoulder_roll"], + "elbow": ["elbow_flex"], + "wrist": ["wrist_roll", "wrist_yaw", "wrist_pitch"], + } + + self.calibration = RangeFinderGUI(self.bus, groups).run() + self._save_calibration() + print("Calibration saved to", self.calibration_fpath) + + def configure(self) -> None: + with self.bus.torque_disabled(): + self.bus.configure_motors(maximum_acceleration=30, acceleration=30) + + def setup_motors(self) -> None: + # TODO: add docstring + for motor in reversed(self.bus.motors): + input(f"Connect the controller board to the '{motor}' motor only and press enter.") + self.bus.setup_motor(motor) + print(f"'{motor}' motor id set to {self.bus.motors[motor].id}") + + def get_observation(self) -> dict[str, Any]: + if not self.is_connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + # Read arm position + start = time.perf_counter() + obs_dict = self.bus.sync_read("Present_Position", self.other_motors) + obs_dict[self.shoulder_pitch] = self.bus.read("Present_Position", self.shoulder_pitch) + obs_dict = {f"{motor}.pos": val for motor, val in obs_dict.items()} + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} read state: {dt_ms:.1f}ms") + + # Capture images from cameras + for cam_key, cam in self.cameras.items(): + start = time.perf_counter() + obs_dict[cam_key] = cam.async_read() + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} read {cam_key}: {dt_ms:.1f}ms") + + return obs_dict + + def send_action(self, action: dict[str, Any]) -> dict[str, Any]: + if not self.is_connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")} + + # Cap goal position when too far away from present position. + # /!\ Slower fps expected due to reading from the follower. + if self.config.max_relative_target is not None: + present_pos = self.bus.sync_read("Present_Position") + goal_present_pos = {key: (g_pos, present_pos[key]) for key, g_pos in goal_pos.items()} + goal_pos = ensure_safe_goal_position(goal_present_pos, self.config.max_relative_target) + + self.bus.sync_write("Goal_Position", goal_pos) + return {f"{motor}.pos": val for motor, val in goal_pos.items()} + + def disconnect(self): + if not self.is_connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + self.bus.disconnect(self.config.disable_torque_on_disconnect) + for cam in self.cameras.values(): + cam.disconnect() + + logger.info(f"{self} disconnected.") diff --git a/src/lerobot/robots/hope_jr/hope_jr_hand.py b/src/lerobot/robots/hope_jr/hope_jr_hand.py new file mode 100644 index 000000000..8dc100e06 --- /dev/null +++ b/src/lerobot/robots/hope_jr/hope_jr_hand.py @@ -0,0 +1,200 @@ +#!/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 +import time +from functools import cached_property +from typing import Any + +from lerobot.cameras.utils import make_cameras_from_configs +from lerobot.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError +from lerobot.motors import Motor, MotorNormMode +from lerobot.motors.calibration_gui import RangeFinderGUI +from lerobot.motors.feetech import ( + FeetechMotorsBus, +) + +from ..robot import Robot +from .config_hope_jr import HopeJrHandConfig + +logger = logging.getLogger(__name__) + +RIGHT_HAND_INVERSIONS = [ + "thumb_mcp", + "thumb_dip", + "index_ulnar_flexor", + "middle_ulnar_flexor", + "ring_ulnar_flexor", + "ring_pip_dip", + "pinky_ulnar_flexor", + "pinky_pip_dip", +] + +LEFT_HAND_INVERSIONS = [ + "thumb_cmc", + "thumb_mcp", + "thumb_dip", + "index_radial_flexor", + "index_pip_dip", + "middle_radial_flexor", + "middle_pip_dip", + "ring_radial_flexor", + "ring_pip_dip", + "pinky_radial_flexor", + # "pinky_pip_dip", +] + + +class HopeJrHand(Robot): + config_class = HopeJrHandConfig + name = "hope_jr_hand" + + def __init__(self, config: HopeJrHandConfig): + super().__init__(config) + self.config = config + self.bus = FeetechMotorsBus( + port=self.config.port, + motors={ + # Thumb + "thumb_cmc": Motor(1, "scs0009", MotorNormMode.RANGE_0_100), + "thumb_mcp": Motor(2, "scs0009", MotorNormMode.RANGE_0_100), + "thumb_pip": Motor(3, "scs0009", MotorNormMode.RANGE_0_100), + "thumb_dip": Motor(4, "scs0009", MotorNormMode.RANGE_0_100), + # Index + "index_radial_flexor": Motor(5, "scs0009", MotorNormMode.RANGE_0_100), + "index_ulnar_flexor": Motor(6, "scs0009", MotorNormMode.RANGE_0_100), + "index_pip_dip": Motor(7, "scs0009", MotorNormMode.RANGE_0_100), + # Middle + "middle_radial_flexor": Motor(8, "scs0009", MotorNormMode.RANGE_0_100), + "middle_ulnar_flexor": Motor(9, "scs0009", MotorNormMode.RANGE_0_100), + "middle_pip_dip": Motor(10, "scs0009", MotorNormMode.RANGE_0_100), + # Ring + "ring_radial_flexor": Motor(11, "scs0009", MotorNormMode.RANGE_0_100), + "ring_ulnar_flexor": Motor(12, "scs0009", MotorNormMode.RANGE_0_100), + "ring_pip_dip": Motor(13, "scs0009", MotorNormMode.RANGE_0_100), + # Pinky + "pinky_radial_flexor": Motor(14, "scs0009", MotorNormMode.RANGE_0_100), + "pinky_ulnar_flexor": Motor(15, "scs0009", MotorNormMode.RANGE_0_100), + "pinky_pip_dip": Motor(16, "scs0009", MotorNormMode.RANGE_0_100), + }, + calibration=self.calibration, + protocol_version=1, + ) + self.cameras = make_cameras_from_configs(config.cameras) + self.inverted_motors = RIGHT_HAND_INVERSIONS if config.side == "right" else LEFT_HAND_INVERSIONS + + @property + def _motors_ft(self) -> dict[str, type]: + return {f"{motor}.pos": float for motor in self.bus.motors} + + @property + def _cameras_ft(self) -> dict[str, tuple]: + return { + cam: (self.config.cameras[cam].height, self.config.cameras[cam].width, 3) for cam in self.cameras + } + + @cached_property + def observation_features(self) -> dict[str, type | tuple]: + return {**self._motors_ft, **self._cameras_ft} + + @cached_property + def action_features(self) -> dict[str, type]: + return self._motors_ft + + @property + def is_connected(self) -> bool: + return self.bus.is_connected and all(cam.is_connected for cam in self.cameras.values()) + + def connect(self, calibrate: bool = True) -> None: + if self.is_connected: + raise DeviceAlreadyConnectedError(f"{self} already connected") + + self.bus.connect() + if not self.is_calibrated and calibrate: + self.calibrate() + + # Connect the cameras + for cam in self.cameras.values(): + cam.connect() + + self.configure() + logger.info(f"{self} connected.") + + @property + def is_calibrated(self) -> bool: + return self.bus.is_calibrated + + def calibrate(self) -> None: + fingers = {} + for finger in ["thumb", "index", "middle", "ring", "pinky"]: + fingers[finger] = [motor for motor in self.bus.motors if motor.startswith(finger)] + + self.calibration = RangeFinderGUI(self.bus, fingers).run() + for motor in self.inverted_motors: + self.calibration[motor].drive_mode = 1 + self._save_calibration() + print("Calibration saved to", self.calibration_fpath) + + def configure(self) -> None: + with self.bus.torque_disabled(): + self.bus.configure_motors() + + def setup_motors(self) -> None: + # TODO: add docstring + for motor in self.bus.motors: + input(f"Connect the controller board to the '{motor}' motor only and press enter.") + self.bus.setup_motor(motor) + print(f"'{motor}' motor id set to {self.bus.motors[motor].id}") + + def get_observation(self) -> dict[str, Any]: + if not self.is_connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + obs_dict = {} + + # Read hand position + start = time.perf_counter() + for motor in self.bus.motors: + obs_dict[f"{motor}.pos"] = self.bus.read("Present_Position", motor) + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} read state: {dt_ms:.1f}ms") + + # Capture images from cameras + for cam_key, cam in self.cameras.items(): + start = time.perf_counter() + obs_dict[cam_key] = cam.async_read() + dt_ms = (time.perf_counter() - start) * 1e3 + logger.debug(f"{self} read {cam_key}: {dt_ms:.1f}ms") + + return obs_dict + + def send_action(self, action: dict[str, Any]) -> dict[str, Any]: + if not self.is_connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + goal_pos = {key.removesuffix(".pos"): val for key, val in action.items() if key.endswith(".pos")} + self.bus.sync_write("Goal_Position", goal_pos) + return action + + def disconnect(self): + if not self.is_connected: + raise DeviceNotConnectedError(f"{self} is not connected.") + + self.bus.disconnect(self.config.disable_torque_on_disconnect) + for cam in self.cameras.values(): + cam.disconnect() + + logger.info(f"{self} disconnected.") diff --git a/src/lerobot/robots/utils.py b/src/lerobot/robots/utils.py index 435303c6e..911d40465 100644 --- a/src/lerobot/robots/utils.py +++ b/src/lerobot/robots/utils.py @@ -49,6 +49,14 @@ def make_robot_from_config(config: RobotConfig) -> Robot: from .viperx import ViperX return ViperX(config) + elif config.type == "hope_jr_hand": + from .hope_jr import HopeJrHand + + return HopeJrHand(config) + elif config.type == "hope_jr_arm": + from .hope_jr import HopeJrArm + + return HopeJrArm(config) elif config.type == "mock_robot": from tests.mocks.mock_robot import MockRobot diff --git a/src/lerobot/teleoperate.py b/src/lerobot/teleoperate.py index e2819345b..168f898c4 100644 --- a/src/lerobot/teleoperate.py +++ b/src/lerobot/teleoperate.py @@ -43,6 +43,7 @@ from lerobot.cameras.realsense.configuration_realsense import RealSenseCameraCon from lerobot.robots import ( # noqa: F401 Robot, RobotConfig, + hope_jr, koch_follower, make_robot_from_config, so100_follower, @@ -52,6 +53,7 @@ from lerobot.teleoperators import ( # noqa: F401 Teleoperator, TeleoperatorConfig, gamepad, + homunculus, koch_leader, make_teleoperator_from_config, so100_leader, diff --git a/src/lerobot/teleoperators/homunculus/__init__.py b/src/lerobot/teleoperators/homunculus/__init__.py new file mode 100644 index 000000000..04b5c0f2b --- /dev/null +++ b/src/lerobot/teleoperators/homunculus/__init__.py @@ -0,0 +1,4 @@ +from .config_homunculus import HomunculusArmConfig, HomunculusGloveConfig +from .homunculus_arm import HomunculusArm +from .homunculus_glove import HomunculusGlove +from .joints_translation import homunculus_glove_to_hope_jr_hand diff --git a/src/lerobot/teleoperators/homunculus/config_homunculus.py b/src/lerobot/teleoperators/homunculus/config_homunculus.py new file mode 100644 index 000000000..da465215a --- /dev/null +++ b/src/lerobot/teleoperators/homunculus/config_homunculus.py @@ -0,0 +1,38 @@ +#!/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. + +from dataclasses import dataclass + +from ..config import TeleoperatorConfig + + +@TeleoperatorConfig.register_subclass("homunculus_glove") +@dataclass +class HomunculusGloveConfig(TeleoperatorConfig): + port: str # Port to connect to the glove + side: str # "left" / "right" + baud_rate: int = 115_200 + + def __post_init__(self): + if self.side not in ["right", "left"]: + raise ValueError(self.side) + + +@TeleoperatorConfig.register_subclass("homunculus_arm") +@dataclass +class HomunculusArmConfig(TeleoperatorConfig): + port: str # Port to connect to the arm + baud_rate: int = 115_200 diff --git a/src/lerobot/teleoperators/homunculus/homunculus_arm.py b/src/lerobot/teleoperators/homunculus/homunculus_arm.py new file mode 100644 index 000000000..dfce0c88e --- /dev/null +++ b/src/lerobot/teleoperators/homunculus/homunculus_arm.py @@ -0,0 +1,310 @@ +#!/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 +import threading +from collections import deque +from pprint import pformat +from typing import Deque, Dict, Optional + +import serial + +from lerobot.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError +from lerobot.motors.motors_bus import MotorCalibration, MotorNormMode +from lerobot.utils.utils import enter_pressed, move_cursor_up + +from ..teleoperator import Teleoperator +from .config_homunculus import HomunculusArmConfig + +logger = logging.getLogger(__name__) + + +class HomunculusArm(Teleoperator): + """ + Homunculus Arm designed by Hugging Face. + """ + + config_class = HomunculusArmConfig + name = "homunculus_arm" + + def __init__(self, config: HomunculusArmConfig): + super().__init__(config) + self.config = config + self.serial = serial.Serial(config.port, config.baud_rate, timeout=1) + self.serial_lock = threading.Lock() + + self.joints = { + "shoulder_pitch": MotorNormMode.RANGE_M100_100, + "shoulder_yaw": MotorNormMode.RANGE_M100_100, + "shoulder_roll": MotorNormMode.RANGE_M100_100, + "elbow_flex": MotorNormMode.RANGE_M100_100, + "wrist_roll": MotorNormMode.RANGE_M100_100, + "wrist_yaw": MotorNormMode.RANGE_M100_100, + "wrist_pitch": MotorNormMode.RANGE_M100_100, + } + n = 50 + # EMA parameters --------------------------------------------------- + self.n: int = n + self.alpha: float = 2 / (n + 1) + # one deque *per joint* so we can inspect raw history if needed + self._buffers: Dict[str, Deque[int]] = { + joint: deque(maxlen=n) + for joint in ( + "shoulder_pitch", + "shoulder_yaw", + "shoulder_roll", + "elbow_flex", + "wrist_roll", + "wrist_yaw", + "wrist_pitch", + ) + } + # running EMA value per joint – lazily initialised on first read + self._ema: Dict[str, Optional[float]] = dict.fromkeys(self._buffers) + + self._state: dict[str, float] | None = None + self.new_state_event = threading.Event() + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._read_loop, daemon=True, name=f"{self} _read_loop") + self.state_lock = threading.Lock() + + @property + def action_features(self) -> dict: + return {f"{joint}.pos": float for joint in self.joints} + + @property + def feedback_features(self) -> dict: + return {} + + @property + def is_connected(self) -> bool: + with self.serial_lock: + return self.serial.is_open and self.thread.is_alive() + + def connect(self, calibrate: bool = True) -> None: + if self.is_connected: + raise DeviceAlreadyConnectedError(f"{self} already connected") + + if not self.serial.is_open: + self.serial.open() + self.thread.start() + + # wait for the thread to ramp up & 1st state to be ready + if not self.new_state_event.wait(timeout=2): + raise TimeoutError(f"{self}: Timed out waiting for state after 2s.") + + if not self.is_calibrated and calibrate: + self.calibrate() + + logger.info(f"{self} connected.") + + @property + def is_calibrated(self) -> bool: + return self.calibration_fpath.is_file() + + def calibrate(self) -> None: + print( + "\nMove all joints through their entire range of motion." + "\nRecording positions. Press ENTER to stop..." + ) + range_mins, range_maxes = self._record_ranges_of_motion() + + self.calibration = {} + for id_, joint in enumerate(self.joints): + self.calibration[joint] = MotorCalibration( + id=id_, + drive_mode=0, + homing_offset=0, + range_min=range_mins[joint], + range_max=range_maxes[joint], + ) + + self._save_calibration() + print("Calibration saved to", self.calibration_fpath) + + # TODO(Steven): This function is copy/paste from the `HomunculusGlove` class. Consider moving it to an utility to reduce duplicated code. + def _record_ranges_of_motion( + self, joints: list[str] | None = None, display_values: bool = True + ) -> tuple[dict[str, int], dict[str, int]]: + """Interactively record the min/max encoder values of each joint. + + Move the joints while the method streams live positions. Press :kbd:`Enter` to finish. + + Args: + joints (list[str] | None, optional): Joints to record. Defaults to every joint (`None`). + display_values (bool, optional): When `True` (default) a live table is printed to the console. + + Raises: + TypeError: `joints` is not `None` or a list. + ValueError: any joint's recorded min and max are the same. + + Returns: + tuple[dict[str, int], dict[str, int]]: Two dictionaries *mins* and *maxes* with the extreme values + observed for each joint. + """ + if joints is None: + joints = list(self.joints) + elif not isinstance(joints, list): + raise TypeError(joints) + + display_len = max(len(key) for key in joints) + + start_positions = self._read(joints, normalize=False) + mins = start_positions.copy() + maxes = start_positions.copy() + + user_pressed_enter = False + while not user_pressed_enter: + positions = self._read(joints, normalize=False) + mins = {joint: int(min(positions[joint], min_)) for joint, min_ in mins.items()} + maxes = {joint: int(max(positions[joint], max_)) for joint, max_ in maxes.items()} + + if display_values: + print("\n-------------------------------------------") + print(f"{'NAME':<{display_len}} | {'MIN':>6} | {'POS':>6} | {'MAX':>6}") + for joint in joints: + print( + f"{joint:<{display_len}} | {mins[joint]:>6} | {positions[joint]:>6} | {maxes[joint]:>6}" + ) + + if enter_pressed(): + user_pressed_enter = True + + if display_values and not user_pressed_enter: + # Move cursor up to overwrite the previous output + move_cursor_up(len(joints) + 3) + + same_min_max = [joint for joint in joints if mins[joint] == maxes[joint]] + if same_min_max: + raise ValueError(f"Some joints have the same min and max values:\n{pformat(same_min_max)}") + + return mins, maxes + + def configure(self) -> None: + pass + + # TODO(Steven): This function is copy/paste from the `HomunculusGlove` class. Consider moving it to an utility to reduce duplicated code. + def _normalize(self, values: dict[str, int]) -> dict[str, float]: + if not self.calibration: + raise RuntimeError(f"{self} has no calibration registered.") + + normalized_values = {} + for joint, val in values.items(): + min_ = self.calibration[joint].range_min + max_ = self.calibration[joint].range_max + drive_mode = self.calibration[joint].drive_mode + bounded_val = min(max_, max(min_, val)) + + if self.joints[joint] is MotorNormMode.RANGE_M100_100: + norm = (((bounded_val - min_) / (max_ - min_)) * 200) - 100 + normalized_values[joint] = -norm if drive_mode else norm + elif self.joints[joint] is MotorNormMode.RANGE_0_100: + norm = ((bounded_val - min_) / (max_ - min_)) * 100 + normalized_values[joint] = 100 - norm if drive_mode else norm + + return normalized_values + + def _apply_ema(self, raw: Dict[str, int]) -> Dict[str, float]: + """Update buffers & running EMA values; return smoothed dict.""" + smoothed: Dict[str, float] = {} + for joint, value in raw.items(): + # maintain raw history + self._buffers[joint].append(value) + + # initialise on first run + if self._ema[joint] is None: + self._ema[joint] = float(value) + else: + self._ema[joint] = self.alpha * value + (1 - self.alpha) * self._ema[joint] + + smoothed[joint] = self._ema[joint] + return smoothed + + def _read( + self, joints: list[str] | None = None, normalize: bool = True, timeout: float = 1 + ) -> dict[str, int | float]: + """ + Return the most recent (single) values from self.last_d, + optionally applying calibration. + """ + if not self.new_state_event.wait(timeout=timeout): + raise TimeoutError(f"{self}: Timed out waiting for state after {timeout}s.") + + with self.state_lock: + state = self._state + + self.new_state_event.clear() + + if state is None: + raise RuntimeError(f"{self} Internal error: Event set but no state available.") + + if joints is not None: + state = {k: v for k, v in state.items() if k in joints} + + if normalize: + state = self._normalize(state) + + state = self._apply_ema(state) + + return state + + def _read_loop(self): + """ + Continuously read from the serial buffer in its own thread and sends values to the main thread through + a queue. + """ + while not self.stop_event.is_set(): + try: + raw_values = None + with self.serial_lock: + if self.serial.in_waiting > 0: + self.serial.flush() + raw_values = self.serial.readline().decode("utf-8").strip().split(" ") + if raw_values is None or len(raw_values) != 21: # 16 raw + 5 angle values + continue + + joint_angles = { + "shoulder_pitch": int(raw_values[19]), + "shoulder_yaw": int(raw_values[18]), + "shoulder_roll": int(raw_values[20]), + "elbow_flex": int(raw_values[17]), + "wrist_roll": int(raw_values[16]), + "wrist_yaw": int(raw_values[1]), + "wrist_pitch": int(raw_values[0]), + } + + with self.state_lock: + self._state = joint_angles + self.new_state_event.set() + + except Exception as e: + logger.debug(f"Error reading frame in background thread for {self}: {e}") + + def get_action(self) -> dict[str, float]: + joint_positions = self._read() + return {f"{joint}.pos": pos for joint, pos in joint_positions.items()} + + def send_feedback(self, feedback: dict[str, float]) -> None: + raise NotImplementedError + + def disconnect(self) -> None: + if not self.is_connected: + DeviceNotConnectedError(f"{self} is not connected.") + + self.stop_event.set() + self.thread.join(timeout=1) + self.serial.close() + logger.info(f"{self} disconnected.") diff --git a/src/lerobot/teleoperators/homunculus/homunculus_glove.py b/src/lerobot/teleoperators/homunculus/homunculus_glove.py new file mode 100644 index 000000000..d367a2a7c --- /dev/null +++ b/src/lerobot/teleoperators/homunculus/homunculus_glove.py @@ -0,0 +1,338 @@ +#!/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 +import threading +from collections import deque +from pprint import pformat +from typing import Deque, Dict, Optional + +import serial + +from lerobot.errors import DeviceAlreadyConnectedError, DeviceNotConnectedError +from lerobot.motors import MotorCalibration +from lerobot.motors.motors_bus import MotorNormMode +from lerobot.teleoperators.homunculus.joints_translation import homunculus_glove_to_hope_jr_hand +from lerobot.utils.utils import enter_pressed, move_cursor_up + +from ..teleoperator import Teleoperator +from .config_homunculus import HomunculusGloveConfig + +logger = logging.getLogger(__name__) + +LEFT_HAND_INVERSIONS = [ + "thumb_cmc", + "index_dip", + "middle_mcp_abduction", + "middle_dip", + "pinky_mcp_abduction", + "pinky_dip", +] + +RIGHT_HAND_INVERSIONS = [ + "thumb_mcp", + "thumb_cmc", + "thumb_pip", + "thumb_dip", + "index_mcp_abduction", + # "index_dip", + "middle_mcp_abduction", + # "middle_dip", + "ring_mcp_abduction", + "ring_mcp_flexion", + # "ring_dip", + "pinky_mcp_abduction", +] + + +class HomunculusGlove(Teleoperator): + """ + Homunculus Glove designed by NepYope & Hugging Face. + """ + + config_class = HomunculusGloveConfig + name = "homunculus_glove" + + def __init__(self, config: HomunculusGloveConfig): + super().__init__(config) + self.config = config + self.serial = serial.Serial(config.port, config.baud_rate, timeout=1) + self.serial_lock = threading.Lock() + + self.joints = { + "thumb_cmc": MotorNormMode.RANGE_0_100, + "thumb_mcp": MotorNormMode.RANGE_0_100, + "thumb_pip": MotorNormMode.RANGE_0_100, + "thumb_dip": MotorNormMode.RANGE_0_100, + "index_mcp_abduction": MotorNormMode.RANGE_M100_100, + "index_mcp_flexion": MotorNormMode.RANGE_0_100, + "index_dip": MotorNormMode.RANGE_0_100, + "middle_mcp_abduction": MotorNormMode.RANGE_M100_100, + "middle_mcp_flexion": MotorNormMode.RANGE_0_100, + "middle_dip": MotorNormMode.RANGE_0_100, + "ring_mcp_abduction": MotorNormMode.RANGE_M100_100, + "ring_mcp_flexion": MotorNormMode.RANGE_0_100, + "ring_dip": MotorNormMode.RANGE_0_100, + "pinky_mcp_abduction": MotorNormMode.RANGE_M100_100, + "pinky_mcp_flexion": MotorNormMode.RANGE_0_100, + "pinky_dip": MotorNormMode.RANGE_0_100, + } + self.inverted_joints = RIGHT_HAND_INVERSIONS if config.side == "right" else LEFT_HAND_INVERSIONS + + n = 10 + # EMA parameters --------------------------------------------------- + self.n: int = n + self.alpha: float = 2 / (n + 1) + # one deque *per joint* so we can inspect raw history if needed + self._buffers: Dict[str, Deque[int]] = {joint: deque(maxlen=n) for joint in self.joints} + # running EMA value per joint – lazily initialised on first read + self._ema: Dict[str, Optional[float]] = dict.fromkeys(self._buffers) + + self._state: dict[str, float] | None = None + self.new_state_event = threading.Event() + self.stop_event = threading.Event() + self.thread = threading.Thread(target=self._read_loop, daemon=True, name=f"{self} _read_loop") + self.state_lock = threading.Lock() + + @property + def action_features(self) -> dict: + return {f"{joint}.pos": float for joint in self.joints} + + @property + def feedback_features(self) -> dict: + return {} + + @property + def is_connected(self) -> bool: + with self.serial_lock: + return self.serial.is_open and self.thread.is_alive() + + def connect(self, calibrate: bool = True) -> None: + if self.is_connected: + raise DeviceAlreadyConnectedError(f"{self} already connected") + + if not self.serial.is_open: + self.serial.open() + self.thread.start() + + # wait for the thread to ramp up & 1st state to be ready + if not self.new_state_event.wait(timeout=2): + raise TimeoutError(f"{self}: Timed out waiting for state after 2s.") + + if not self.is_calibrated and calibrate: + self.calibrate() + + logger.info(f"{self} connected.") + + @property + def is_calibrated(self) -> bool: + return self.calibration_fpath.is_file() + + def calibrate(self) -> None: + range_mins, range_maxes = {}, {} + for finger in ["thumb", "index", "middle", "ring", "pinky"]: + print( + f"\nMove {finger} through its entire range of motion." + "\nRecording positions. Press ENTER to stop..." + ) + finger_joints = [joint for joint in self.joints if joint.startswith(finger)] + finger_mins, finger_maxes = self._record_ranges_of_motion(finger_joints) + range_mins.update(finger_mins) + range_maxes.update(finger_maxes) + + self.calibration = {} + for id_, joint in enumerate(self.joints): + self.calibration[joint] = MotorCalibration( + id=id_, + drive_mode=1 if joint in self.inverted_joints else 0, + homing_offset=0, + range_min=range_mins[joint], + range_max=range_maxes[joint], + ) + + self._save_calibration() + print("Calibration saved to", self.calibration_fpath) + + # TODO(Steven): This function is copy/paste from the `HomunculusArm` class. Consider moving it to an utility to reduce duplicated code. + def _record_ranges_of_motion( + self, joints: list[str] | None = None, display_values: bool = True + ) -> tuple[dict[str, int], dict[str, int]]: + """Interactively record the min/max encoder values of each joint. + + Move the joints while the method streams live positions. Press :kbd:`Enter` to finish. + + Args: + joints (list[str] | None, optional): Joints to record. Defaults to every joint (`None`). + display_values (bool, optional): When `True` (default) a live table is printed to the console. + + Raises: + TypeError: `joints` is not `None` or a list. + ValueError: any joint's recorded min and max are the same. + + Returns: + tuple[dict[str, int], dict[str, int]]: Two dictionaries *mins* and *maxes* with the extreme values + observed for each joint. + """ + if joints is None: + joints = list(self.joints) + elif not isinstance(joints, list): + raise TypeError(joints) + + display_len = max(len(key) for key in joints) + + start_positions = self._read(joints, normalize=False) + mins = start_positions.copy() + maxes = start_positions.copy() + + user_pressed_enter = False + while not user_pressed_enter: + positions = self._read(joints, normalize=False) + mins = {joint: int(min(positions[joint], min_)) for joint, min_ in mins.items()} + maxes = {joint: int(max(positions[joint], max_)) for joint, max_ in maxes.items()} + + if display_values: + print("\n-------------------------------------------") + print(f"{'NAME':<{display_len}} | {'MIN':>6} | {'POS':>6} | {'MAX':>6}") + for joint in joints: + print( + f"{joint:<{display_len}} | {mins[joint]:>6} | {positions[joint]:>6} | {maxes[joint]:>6}" + ) + + if enter_pressed(): + user_pressed_enter = True + + if display_values and not user_pressed_enter: + # Move cursor up to overwrite the previous output + move_cursor_up(len(joints) + 3) + + same_min_max = [joint for joint in joints if mins[joint] == maxes[joint]] + if same_min_max: + raise ValueError(f"Some joints have the same min and max values:\n{pformat(same_min_max)}") + + return mins, maxes + + def configure(self) -> None: + pass + + # TODO(Steven): This function is copy/paste from the `HomunculusArm` class. Consider moving it to an utility to reduce duplicated code. + def _normalize(self, values: dict[str, int]) -> dict[str, float]: + if not self.calibration: + raise RuntimeError(f"{self} has no calibration registered.") + + normalized_values = {} + for joint, val in values.items(): + min_ = self.calibration[joint].range_min + max_ = self.calibration[joint].range_max + drive_mode = self.calibration[joint].drive_mode + bounded_val = min(max_, max(min_, val)) + + if self.joints[joint] is MotorNormMode.RANGE_M100_100: + norm = (((bounded_val - min_) / (max_ - min_)) * 200) - 100 + normalized_values[joint] = -norm if drive_mode else norm + elif self.joints[joint] is MotorNormMode.RANGE_0_100: + norm = ((bounded_val - min_) / (max_ - min_)) * 100 + normalized_values[joint] = 100 - norm if drive_mode else norm + + return normalized_values + + def _apply_ema(self, raw: Dict[str, int]) -> Dict[str, int]: + """Update buffers & running EMA values; return smoothed dict as integers.""" + smoothed: Dict[str, int] = {} + for joint, value in raw.items(): + # maintain raw history + self._buffers[joint].append(value) + + # initialise on first run + if self._ema[joint] is None: + self._ema[joint] = float(value) + else: + self._ema[joint] = self.alpha * value + (1 - self.alpha) * self._ema[joint] + + # Convert back to int for compatibility with normalization + smoothed[joint] = int(round(self._ema[joint])) + return smoothed + + def _read( + self, joints: list[str] | None = None, normalize: bool = True, timeout: float = 1 + ) -> dict[str, int | float]: + """ + Return the most recent (single) values from self.last_d, + optionally applying calibration. + """ + if not self.new_state_event.wait(timeout=timeout): + raise TimeoutError(f"{self}: Timed out waiting for state after {timeout}s.") + + with self.state_lock: + state = self._state + + self.new_state_event.clear() + + if state is None: + raise RuntimeError(f"{self} Internal error: Event set but no state available.") + + if joints is not None: + state = {k: v for k, v in state.items() if k in joints} + + # Apply EMA smoothing to raw values first + state = self._apply_ema(state) + + # Then normalize if requested + if normalize: + state = self._normalize(state) + + return state + + def _read_loop(self): + """ + Continuously read from the serial buffer in its own thread and sends values to the main thread through + a queue. + """ + while not self.stop_event.is_set(): + try: + positions = None + with self.serial_lock: + if self.serial.in_waiting > 0: + self.serial.flush() + positions = self.serial.readline().decode("utf-8").strip().split(" ") + if positions is None or len(positions) != len(self.joints): + continue + + joint_positions = {joint: int(pos) for joint, pos in zip(self.joints, positions, strict=True)} + + with self.state_lock: + self._state = joint_positions + self.new_state_event.set() + + except Exception as e: + logger.debug(f"Error reading frame in background thread for {self}: {e}") + + def get_action(self) -> dict[str, float]: + joint_positions = self._read() + return homunculus_glove_to_hope_jr_hand( + {f"{joint}.pos": pos for joint, pos in joint_positions.items()} + ) + + def send_feedback(self, feedback: dict[str, float]) -> None: + raise NotImplementedError + + def disconnect(self) -> None: + if not self.is_connected: + DeviceNotConnectedError(f"{self} is not connected.") + + self.stop_event.set() + self.thread.join(timeout=1) + self.serial.close() + logger.info(f"{self} disconnected.") diff --git a/src/lerobot/teleoperators/homunculus/joints_translation.py b/src/lerobot/teleoperators/homunculus/joints_translation.py new file mode 100644 index 000000000..f14f7b3ef --- /dev/null +++ b/src/lerobot/teleoperators/homunculus/joints_translation.py @@ -0,0 +1,63 @@ +# 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. + +INDEX_SPLAY = 0.3 +MIDDLE_SPLAY = 0.3 +RING_SPLAY = 0.3 +PINKY_SPLAY = 0.5 + + +def get_ulnar_flexion(flexion: float, abduction: float, splay: float): + return -abduction * splay + flexion * (1 - splay) + + +def get_radial_flexion(flexion: float, abduction: float, splay: float): + return abduction * splay + flexion * (1 - splay) + + +def homunculus_glove_to_hope_jr_hand(glove_action: dict[str, float]) -> dict[str, float]: + return { + "thumb_cmc.pos": glove_action["thumb_cmc.pos"], + "thumb_mcp.pos": glove_action["thumb_mcp.pos"], + "thumb_pip.pos": glove_action["thumb_pip.pos"], + "thumb_dip.pos": glove_action["thumb_dip.pos"], + "index_radial_flexor.pos": get_radial_flexion( + glove_action["index_mcp_flexion.pos"], glove_action["index_mcp_abduction.pos"], INDEX_SPLAY + ), + "index_ulnar_flexor.pos": get_ulnar_flexion( + glove_action["index_mcp_flexion.pos"], glove_action["index_mcp_abduction.pos"], INDEX_SPLAY + ), + "index_pip_dip.pos": glove_action["index_dip.pos"], + "middle_radial_flexor.pos": get_radial_flexion( + glove_action["middle_mcp_flexion.pos"], glove_action["middle_mcp_abduction.pos"], MIDDLE_SPLAY + ), + "middle_ulnar_flexor.pos": get_ulnar_flexion( + glove_action["middle_mcp_flexion.pos"], glove_action["middle_mcp_abduction.pos"], MIDDLE_SPLAY + ), + "middle_pip_dip.pos": glove_action["middle_dip.pos"], + "ring_radial_flexor.pos": get_radial_flexion( + glove_action["ring_mcp_flexion.pos"], glove_action["ring_mcp_abduction.pos"], RING_SPLAY + ), + "ring_ulnar_flexor.pos": get_ulnar_flexion( + glove_action["ring_mcp_flexion.pos"], glove_action["ring_mcp_abduction.pos"], RING_SPLAY + ), + "ring_pip_dip.pos": glove_action["ring_dip.pos"], + "pinky_radial_flexor.pos": get_radial_flexion( + glove_action["pinky_mcp_flexion.pos"], glove_action["pinky_mcp_abduction.pos"], PINKY_SPLAY + ), + "pinky_ulnar_flexor.pos": get_ulnar_flexion( + glove_action["pinky_mcp_flexion.pos"], glove_action["pinky_mcp_abduction.pos"], PINKY_SPLAY + ), + "pinky_pip_dip.pos": glove_action["pinky_dip.pos"], + } diff --git a/src/lerobot/teleoperators/utils.py b/src/lerobot/teleoperators/utils.py index b49addc15..8a667fd41 100644 --- a/src/lerobot/teleoperators/utils.py +++ b/src/lerobot/teleoperators/utils.py @@ -53,5 +53,13 @@ def make_teleoperator_from_config(config: TeleoperatorConfig) -> Teleoperator: from .keyboard.teleop_keyboard import KeyboardEndEffectorTeleop return KeyboardEndEffectorTeleop(config) + elif config.type == "homunculus_glove": + from .homunculus import HomunculusGlove + + return HomunculusGlove(config) + elif config.type == "homunculus_arm": + from .homunculus import HomunculusArm + + return HomunculusArm(config) else: raise ValueError(config.type)