feat(openarm): add end-effector kinematics support

Expose optional URDF-based forward/inverse kinematics for the OpenArm
follower so it can be recorded/commanded in end-effector (Cartesian)
space, mirroring the SO-100 kinematics pattern.

- config: add optional urdf_path / target_frame_name fields
- robot: add arm_motor_names property and make_kinematics() helper that
  builds a RobotKinematics solver (lazy import; placo only needed when used)

No behavior change when urdf_path is unset (joint-space only).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Martino Russi
2026-07-31 15:24:25 +02:00
parent 228ecd480f
commit ea5cabe6ff
2 changed files with 39 additions and 1 deletions
@@ -74,6 +74,14 @@ class OpenArmFollowerConfigBase:
# Set to a positive scalar for all motors, or a dict mapping motor names to limits # Set to a positive scalar for all motors, or a dict mapping motor names to limits
max_relative_target: float | dict[str, float] | None = None max_relative_target: float | dict[str, float] | None = None
# End-effector kinematics (optional). Point `urdf_path` at the OpenArm URDF and set
# `target_frame_name` to the end-effector link in that URDF to enable forward/inverse
# kinematics in Cartesian (EE) space -- via `lerobot.model.RobotKinematics` and the
# shared FK/IK processor steps. Left as None, the robot behaves exactly as before
# (joint space only). The URDF is user-supplied, so no large asset is vendored here.
urdf_path: str | None = None
target_frame_name: str | None = None
# Camera configurations # Camera configurations
cameras: dict[str, CameraConfig] = field(default_factory=dict) cameras: dict[str, CameraConfig] = field(default_factory=dict)
@@ -17,7 +17,7 @@
import logging import logging
import time import time
from functools import cached_property from functools import cached_property
from typing import Any from typing import TYPE_CHECKING, Any
from lerobot.cameras import make_cameras_from_configs from lerobot.cameras import make_cameras_from_configs
from lerobot.lerobot_types import RobotAction, RobotObservation from lerobot.lerobot_types import RobotAction, RobotObservation
@@ -25,6 +25,9 @@ from lerobot.motors import Motor, MotorCalibration, MotorNormMode
from lerobot.motors.damiao import DamiaoMotorsBus from lerobot.motors.damiao import DamiaoMotorsBus
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
if TYPE_CHECKING:
from lerobot.model import RobotKinematics
from ..robot import Robot from ..robot import Robot
from ..utils import ensure_safe_goal_position from ..utils import ensure_safe_goal_position
from .config_openarm_follower import ( from .config_openarm_follower import (
@@ -120,6 +123,33 @@ class OpenArmFollower(Robot):
"""Action features.""" """Action features."""
return self._motors_ft return self._motors_ft
@property
def arm_motor_names(self) -> list[str]:
"""Arm joints forming the kinematic chain to the end-effector (excludes the gripper)."""
return [motor for motor in self.bus.motors if motor != "gripper"]
def make_kinematics(self) -> "RobotKinematics":
"""Build a solver for end-effector forward/inverse kinematics.
Requires ``config.urdf_path`` (path to the OpenArm URDF) and
``config.target_frame_name`` (the end-effector link in that URDF). Pair the returned
solver with the shared FK/IK processor steps in
``lerobot.robots.so_follower.robot_kinematic_processor`` to record or command the arm
in end-effector (Cartesian) space instead of raw joint angles.
"""
from lerobot.model import RobotKinematics
if self.config.urdf_path is None or self.config.target_frame_name is None:
raise ValueError(
"OpenArm end-effector kinematics require config.urdf_path and "
"config.target_frame_name (the OpenArm URDF and its end-effector link)."
)
return RobotKinematics(
urdf_path=self.config.urdf_path,
target_frame_name=self.config.target_frame_name,
joint_names=self.arm_motor_names,
)
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
"""Check if robot is connected.""" """Check if robot is connected."""