Co-authored-by: Michel Aractingi <michel.aractingi@huggingface.co> Co-authored-by: Eugene Mironov <helper2424@gmail.com> Co-authored-by: s1lent4gnt <kmeftah.khalil@gmail.com> Co-authored-by: Ke Wang <superwk1017@gmail.com> Co-authored-by: Yoel Chornton <yoel.chornton@gmail.com> Co-authored-by: imstevenpmwork <steven.palma@huggingface.co> Co-authored-by: Simon Alibert <simon.alibert@huggingface.co>
484 lines
17 KiB
Python
484 lines
17 KiB
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 numpy as np
|
||
from numpy.typing import NDArray
|
||
from scipy.spatial.transform import Rotation
|
||
|
||
|
||
def skew_symmetric(w: NDArray[np.float32]) -> NDArray[np.float32]:
|
||
"""Creates the skew-symmetric matrix from a 3D vector."""
|
||
return np.array([[0, -w[2], w[1]], [w[2], 0, -w[0]], [-w[1], w[0], 0]])
|
||
|
||
|
||
def rodrigues_rotation(w: NDArray[np.float32], theta: float) -> NDArray[np.float32]:
|
||
"""Computes the rotation matrix using Rodrigues' formula."""
|
||
w_hat = skew_symmetric(w)
|
||
return np.eye(3) + np.sin(theta) * w_hat + (1 - np.cos(theta)) * w_hat @ w_hat
|
||
|
||
|
||
def screw_axis_to_transform(s: NDArray[np.float32], theta: float) -> NDArray[np.float32]:
|
||
"""Converts a screw axis to a 4x4 transformation matrix."""
|
||
screw_axis_rot = s[:3]
|
||
screw_axis_trans = s[3:]
|
||
|
||
# Pure translation
|
||
if np.allclose(screw_axis_rot, 0) and np.linalg.norm(screw_axis_trans) == 1:
|
||
transform = np.eye(4)
|
||
transform[:3, 3] = screw_axis_trans * theta
|
||
|
||
# Rotation (and potentially translation)
|
||
elif np.linalg.norm(screw_axis_rot) == 1:
|
||
w_hat = skew_symmetric(screw_axis_rot)
|
||
rot_mat = np.eye(3) + np.sin(theta) * w_hat + (1 - np.cos(theta)) * w_hat @ w_hat
|
||
t = (
|
||
np.eye(3) * theta + (1 - np.cos(theta)) * w_hat + (theta - np.sin(theta)) * w_hat @ w_hat
|
||
) @ screw_axis_trans
|
||
transform = np.eye(4)
|
||
transform[:3, :3] = rot_mat
|
||
transform[:3, 3] = t
|
||
else:
|
||
raise ValueError("Invalid screw axis parameters")
|
||
return transform
|
||
|
||
|
||
def pose_difference_se3(pose1: NDArray[np.float32], pose2: NDArray[np.float32]) -> NDArray[np.float32]:
|
||
"""
|
||
Calculates the SE(3) difference between two 4x4 homogeneous transformation matrices.
|
||
SE(3) (Special Euclidean Group) represents rigid body transformations in 3D space,
|
||
combining rotation (SO(3)) and translation.
|
||
|
||
Each 4x4 matrix has the following structure:
|
||
[R11 R12 R13 tx]
|
||
[R21 R22 R23 ty]
|
||
[R31 R32 R33 tz]
|
||
[ 0 0 0 1]
|
||
|
||
where R is the 3x3 rotation matrix and [tx,ty,tz] is the translation vector.
|
||
|
||
Args:
|
||
pose1: A 4x4 numpy array representing the first pose.
|
||
pose2: A 4x4 numpy array representing the second pose.
|
||
|
||
Returns:
|
||
A 6D numpy array concatenating translation and rotation differences.
|
||
First 3 elements are the translational difference (position).
|
||
Last 3 elements are the rotational difference in axis-angle representation.
|
||
"""
|
||
rot1 = pose1[:3, :3]
|
||
rot2 = pose2[:3, :3]
|
||
|
||
translation_diff = pose1[:3, 3] - pose2[:3, 3]
|
||
|
||
# Calculate rotational difference using scipy's Rotation library
|
||
rot_diff = Rotation.from_matrix(rot1 @ rot2.T)
|
||
rotation_diff = rot_diff.as_rotvec() # Axis-angle representation
|
||
|
||
return np.concatenate([translation_diff, rotation_diff])
|
||
|
||
|
||
def se3_error(target_pose: NDArray[np.float32], current_pose: NDArray[np.float32]) -> NDArray[np.float32]:
|
||
pos_error = target_pose[:3, 3] - current_pose[:3, 3]
|
||
|
||
rot_target = target_pose[:3, :3]
|
||
rot_current = current_pose[:3, :3]
|
||
rot_error_mat = rot_target @ rot_current.T
|
||
rot_error = Rotation.from_matrix(rot_error_mat).as_rotvec()
|
||
|
||
return np.concatenate([pos_error, rot_error])
|
||
|
||
|
||
class RobotKinematics:
|
||
"""Robot kinematics class supporting multiple robot models."""
|
||
|
||
# Robot measurements dictionary
|
||
ROBOT_MEASUREMENTS = {
|
||
"koch": {
|
||
"gripper": [0.239, -0.001, 0.024],
|
||
"wrist": [0.209, 0, 0.024],
|
||
"forearm": [0.108, 0, 0.02],
|
||
"humerus": [0, 0, 0.036],
|
||
"shoulder": [0, 0, 0],
|
||
"base": [0, 0, 0.02],
|
||
},
|
||
"moss": {
|
||
"gripper": [0.246, 0.013, 0.111],
|
||
"wrist": [0.245, 0.002, 0.064],
|
||
"forearm": [0.122, 0, 0.064],
|
||
"humerus": [0.001, 0.001, 0.063],
|
||
"shoulder": [0, 0, 0],
|
||
"base": [0, 0, 0.02],
|
||
},
|
||
"so_old_calibration": {
|
||
"gripper": [0.320, 0, 0.050],
|
||
"wrist": [0.278, 0, 0.050],
|
||
"forearm": [0.143, 0, 0.044],
|
||
"humerus": [0.031, 0, 0.072],
|
||
"shoulder": [0, 0, 0],
|
||
"base": [0, 0, 0.02],
|
||
},
|
||
"so_new_calibration": {
|
||
"gripper": [0.33, 0.0, 0.285],
|
||
"wrist": [0.30, 0.0, 0.267],
|
||
"forearm": [0.25, 0.0, 0.266],
|
||
"humerus": [0.06, 0.0, 0.264],
|
||
"shoulder": [0.0, 0.0, 0.238],
|
||
"base": [0.0, 0.0, 0.12],
|
||
},
|
||
}
|
||
|
||
def __init__(self, robot_type: str = "so100"):
|
||
"""Initialize kinematics for the specified robot type.
|
||
|
||
Args:
|
||
robot_type: String specifying the robot model ("koch", "so100", or "moss")
|
||
"""
|
||
if robot_type not in self.ROBOT_MEASUREMENTS:
|
||
raise ValueError(
|
||
f"Unknown robot type: {robot_type}. Available types: {list(self.ROBOT_MEASUREMENTS.keys())}"
|
||
)
|
||
|
||
self.robot_type = robot_type
|
||
self.measurements = self.ROBOT_MEASUREMENTS[robot_type]
|
||
|
||
# Initialize all transformation matrices and screw axes
|
||
self._setup_transforms()
|
||
|
||
def _create_translation_matrix(
|
||
self, x: float = 0.0, y: float = 0.0, z: float = 0.0
|
||
) -> NDArray[np.float32]:
|
||
"""Create a 4x4 translation matrix."""
|
||
return np.array([[1, 0, 0, x], [0, 1, 0, y], [0, 0, 1, z], [0, 0, 0, 1]])
|
||
|
||
def _setup_transforms(self):
|
||
"""Setup all transformation matrices and screw axes for the robot."""
|
||
# Set up rotation matrices (constant across robot types)
|
||
|
||
# Gripper orientation
|
||
self.gripper_X0 = np.array(
|
||
[
|
||
[1, 0, 0, 0],
|
||
[0, 0, 1, 0],
|
||
[0, -1, 0, 0],
|
||
[0, 0, 0, 1],
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
|
||
# Wrist orientation
|
||
self.wrist_X0 = np.array(
|
||
[
|
||
[0, -1, 0, 0],
|
||
[1, 0, 0, 0],
|
||
[0, 0, 1, 0],
|
||
[0, 0, 0, 1],
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
|
||
# Base orientation
|
||
self.base_X0 = np.array(
|
||
[
|
||
[0, 0, 1, 0],
|
||
[1, 0, 0, 0],
|
||
[0, 1, 0, 0],
|
||
[0, 0, 0, 1],
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
|
||
# Gripper
|
||
# Screw axis of gripper frame wrt base frame
|
||
self.S_BG = np.array(
|
||
[
|
||
1,
|
||
0,
|
||
0,
|
||
0,
|
||
self.measurements["gripper"][2],
|
||
-self.measurements["gripper"][1],
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
|
||
# Gripper origin to centroid transform
|
||
self.X_GoGc = self._create_translation_matrix(x=0.07)
|
||
|
||
# Gripper origin to tip transform
|
||
self.X_GoGt = self._create_translation_matrix(x=0.12)
|
||
|
||
# 0-position gripper frame pose wrt base
|
||
self.X_BoGo = self._create_translation_matrix(
|
||
x=self.measurements["gripper"][0],
|
||
y=self.measurements["gripper"][1],
|
||
z=self.measurements["gripper"][2],
|
||
)
|
||
|
||
# Wrist
|
||
# Screw axis of wrist frame wrt base frame
|
||
self.S_BR = np.array(
|
||
[0, 1, 0, -self.measurements["wrist"][2], 0, self.measurements["wrist"][0]], dtype=np.float32
|
||
)
|
||
|
||
# 0-position origin to centroid transform
|
||
self.X_RoRc = self._create_translation_matrix(x=0.0035, y=-0.002)
|
||
|
||
# 0-position wrist frame pose wrt base
|
||
self.X_BR = self._create_translation_matrix(
|
||
x=self.measurements["wrist"][0],
|
||
y=self.measurements["wrist"][1],
|
||
z=self.measurements["wrist"][2],
|
||
)
|
||
|
||
# Forearm
|
||
# Screw axis of forearm frame wrt base frame
|
||
self.S_BF = np.array(
|
||
[
|
||
0,
|
||
1,
|
||
0,
|
||
-self.measurements["forearm"][2],
|
||
0,
|
||
self.measurements["forearm"][0],
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
|
||
# Forearm origin + centroid transform
|
||
self.X_ForearmFc = self._create_translation_matrix(x=0.036)
|
||
|
||
# 0-position forearm frame pose wrt base
|
||
self.X_BF = self._create_translation_matrix(
|
||
x=self.measurements["forearm"][0],
|
||
y=self.measurements["forearm"][1],
|
||
z=self.measurements["forearm"][2],
|
||
)
|
||
|
||
# Humerus
|
||
# Screw axis of humerus frame wrt base frame
|
||
self.S_BH = np.array(
|
||
[
|
||
0,
|
||
-1,
|
||
0,
|
||
self.measurements["humerus"][2],
|
||
0,
|
||
-self.measurements["humerus"][0],
|
||
],
|
||
dtype=np.float32,
|
||
)
|
||
|
||
# Humerus origin to centroid transform
|
||
self.X_HoHc = self._create_translation_matrix(x=0.0475)
|
||
|
||
# 0-position humerus frame pose wrt base
|
||
self.X_BH = self._create_translation_matrix(
|
||
x=self.measurements["humerus"][0],
|
||
y=self.measurements["humerus"][1],
|
||
z=self.measurements["humerus"][2],
|
||
)
|
||
|
||
# Shoulder
|
||
# Screw axis of shoulder frame wrt Base frame
|
||
self.S_BS = np.array([0, 0, -1, 0, 0, 0], dtype=np.float32)
|
||
|
||
# Shoulder origin to centroid transform
|
||
self.X_SoSc = self._create_translation_matrix(x=-0.017, z=0.0235)
|
||
|
||
# 0-position shoulder frame pose wrt base
|
||
self.X_BS = self._create_translation_matrix(
|
||
x=self.measurements["shoulder"][0],
|
||
y=self.measurements["shoulder"][1],
|
||
z=self.measurements["shoulder"][2],
|
||
)
|
||
|
||
# Base
|
||
# Base origin to centroid transform
|
||
self.X_BoBc = self._create_translation_matrix(y=0.015)
|
||
|
||
# World to base transform
|
||
self.X_WoBo = self._create_translation_matrix(
|
||
x=self.measurements["base"][0],
|
||
y=self.measurements["base"][1],
|
||
z=self.measurements["base"][2],
|
||
)
|
||
|
||
# Pre-compute gripper post-multiplication matrix
|
||
self._fk_gripper_post = self.X_GoGc @ self.X_BoGo @ self.gripper_X0
|
||
|
||
def forward_kinematics(
|
||
self,
|
||
robot_pos_deg: NDArray[np.float32],
|
||
frame: str = "gripper_tip",
|
||
) -> NDArray[np.float32]:
|
||
"""Generic forward kinematics.
|
||
|
||
Args:
|
||
robot_pos_deg: Joint positions in degrees. Can be ``None`` when
|
||
computing the *base* frame as it does not depend on joint
|
||
angles.
|
||
frame: Target frame. One of
|
||
``{"base", "shoulder", "humerus", "forearm", "wrist", "gripper", "gripper_tip"}``.
|
||
|
||
Returns
|
||
-------
|
||
NDArray[np.float32]
|
||
4×4 homogeneous transformation matrix of the requested frame
|
||
expressed in the world coordinate system.
|
||
"""
|
||
frame = frame.lower()
|
||
if frame not in {
|
||
"base",
|
||
"shoulder",
|
||
"humerus",
|
||
"forearm",
|
||
"wrist",
|
||
"gripper",
|
||
"gripper_tip",
|
||
}:
|
||
raise ValueError(
|
||
f"Unknown frame '{frame}'. Valid options are base, shoulder, humerus, forearm, wrist, gripper, gripper_tip."
|
||
)
|
||
|
||
# Base frame does not rely on joint angles.
|
||
if frame == "base":
|
||
return self.X_WoBo @ self.X_BoBc @ self.base_X0
|
||
|
||
robot_pos_rad = robot_pos_deg / 180 * np.pi
|
||
|
||
# Extract joint angles (note the sign convention for shoulder lift).
|
||
theta_shoulder_pan = robot_pos_rad[0]
|
||
theta_shoulder_lift = -robot_pos_rad[1]
|
||
theta_elbow_flex = robot_pos_rad[2]
|
||
theta_wrist_flex = robot_pos_rad[3]
|
||
theta_wrist_roll = robot_pos_rad[4]
|
||
|
||
# Start with the world-to-base transform; incrementally add successive links.
|
||
transformation_matrix = self.X_WoBo @ screw_axis_to_transform(self.S_BS, theta_shoulder_pan)
|
||
if frame == "shoulder":
|
||
return transformation_matrix @ self.X_SoSc @ self.X_BS
|
||
|
||
transformation_matrix = transformation_matrix @ screw_axis_to_transform(
|
||
self.S_BH, theta_shoulder_lift
|
||
)
|
||
if frame == "humerus":
|
||
return transformation_matrix @ self.X_HoHc @ self.X_BH
|
||
|
||
transformation_matrix = transformation_matrix @ screw_axis_to_transform(self.S_BF, theta_elbow_flex)
|
||
if frame == "forearm":
|
||
return transformation_matrix @ self.X_ForearmFc @ self.X_BF
|
||
|
||
transformation_matrix = transformation_matrix @ screw_axis_to_transform(self.S_BR, theta_wrist_flex)
|
||
if frame == "wrist":
|
||
return transformation_matrix @ self.X_RoRc @ self.X_BR @ self.wrist_X0
|
||
|
||
transformation_matrix = transformation_matrix @ screw_axis_to_transform(self.S_BG, theta_wrist_roll)
|
||
if frame == "gripper":
|
||
return transformation_matrix @ self._fk_gripper_post
|
||
else: # frame == "gripper_tip"
|
||
return transformation_matrix @ self.X_GoGt @ self.X_BoGo @ self.gripper_X0
|
||
|
||
def compute_jacobian(
|
||
self, robot_pos_deg: NDArray[np.float32], frame: str = "gripper_tip"
|
||
) -> NDArray[np.float32]:
|
||
"""Finite differences to compute the Jacobian.
|
||
J(i, j) represents how the ith component of the end-effector's velocity changes wrt a small change
|
||
in the jth joint's velocity.
|
||
|
||
Args:
|
||
robot_pos_deg: Current joint positions in degrees
|
||
fk_func: Forward kinematics function to use (defaults to fk_gripper)
|
||
"""
|
||
|
||
eps = 1e-8
|
||
jac = np.zeros(shape=(6, 5))
|
||
delta = np.zeros(len(robot_pos_deg[:-1]), dtype=np.float64)
|
||
for el_ix in range(len(robot_pos_deg[:-1])):
|
||
delta *= 0
|
||
delta[el_ix] = eps / 2
|
||
sdot = (
|
||
pose_difference_se3(
|
||
self.forward_kinematics(robot_pos_deg[:-1] + delta, frame),
|
||
self.forward_kinematics(robot_pos_deg[:-1] - delta, frame),
|
||
)
|
||
/ eps
|
||
)
|
||
jac[:, el_ix] = sdot
|
||
return jac
|
||
|
||
def compute_positional_jacobian(
|
||
self, robot_pos_deg: NDArray[np.float32], frame: str = "gripper_tip"
|
||
) -> NDArray[np.float32]:
|
||
"""Finite differences to compute the positional Jacobian.
|
||
J(i, j) represents how the ith component of the end-effector's position changes wrt a small change
|
||
in the jth joint's velocity.
|
||
|
||
Args:
|
||
robot_pos_deg: Current joint positions in degrees
|
||
fk_func: Forward kinematics function to use (defaults to fk_gripper)
|
||
"""
|
||
eps = 1e-8
|
||
jac = np.zeros(shape=(3, 5))
|
||
delta = np.zeros(len(robot_pos_deg[:-1]), dtype=np.float64)
|
||
for el_ix in range(len(robot_pos_deg[:-1])):
|
||
delta *= 0
|
||
delta[el_ix] = eps / 2
|
||
sdot = (
|
||
self.forward_kinematics(robot_pos_deg[:-1] + delta, frame)[:3, 3]
|
||
- self.forward_kinematics(robot_pos_deg[:-1] - delta, frame)[:3, 3]
|
||
) / eps
|
||
jac[:, el_ix] = sdot
|
||
return jac
|
||
|
||
def ik(
|
||
self,
|
||
current_joint_pos: NDArray[np.float32],
|
||
desired_ee_pose: NDArray[np.float32],
|
||
position_only: bool = True,
|
||
frame: str = "gripper_tip",
|
||
max_iterations: int = 5,
|
||
learning_rate: float = 1,
|
||
) -> NDArray[np.float32]:
|
||
"""Inverse kinematics using gradient descent.
|
||
|
||
Args:
|
||
current_joint_state: Initial joint positions in degrees
|
||
desired_ee_pose: Target end-effector pose as a 4x4 transformation matrix
|
||
position_only: If True, only match end-effector position, not orientation
|
||
frame: Target frame. One of
|
||
``{"base", "shoulder", "humerus", "forearm", "wrist", "gripper", "gripper_tip"}``.
|
||
max_iterations: Maximum number of iterations to run
|
||
learning_rate: Learning rate for gradient descent
|
||
|
||
Returns:
|
||
Joint positions in degrees that achieve the desired end-effector pose
|
||
"""
|
||
# Do gradient descent.
|
||
current_joint_state = current_joint_pos.copy()
|
||
for _ in range(max_iterations):
|
||
current_ee_pose = self.forward_kinematics(current_joint_state, frame)
|
||
if not position_only:
|
||
error = se3_error(desired_ee_pose, current_ee_pose)
|
||
jac = self.compute_jacobian(current_joint_state, frame)
|
||
else:
|
||
error = desired_ee_pose[:3, 3] - current_ee_pose[:3, 3]
|
||
jac = self.compute_positional_jacobian(current_joint_state, frame)
|
||
delta_angles = np.linalg.pinv(jac) @ error
|
||
current_joint_state[:-1] += learning_rate * delta_angles
|
||
|
||
if np.linalg.norm(error) < 5e-3:
|
||
return current_joint_state
|
||
return current_joint_state
|