ZibinDong's picture
Publish audited ActionCodec2 release files
1bfc3f0 verified
Raw History Blame Contribute Delete
18.7 kB
"""Canonical physical preprocessing for routed ActionCodec2 profiles."""
from __future__ import annotations
import math
import numpy as np
from scipy.interpolate import CubicSpline, PchipInterpolator
from scipy.spatial.transform import Rotation, RotationSpline
from ._so3 import (
euler_xyz_to_quaternion as _euler_xyz_to_quaternion,
)
from ._so3 import (
multiply_quaternions as _quaternion_multiply,
)
from ._so3 import (
normalize_quaternion as _normalize_quaternion,
)
from ._so3 import (
quaternion_to_rotvec as _quaternion_to_rotvec,
)
from ._so3 import (
rotvec_to_quaternion as _rotvec_to_quaternion,
)
from .native_backend import native_kernel
def integrate_additive(values: np.ndarray, order: int) -> np.ndarray:
"""Integrate along the penultimate (time) axis from a zero boundary."""
result = np.asarray(values, dtype=np.float32)
kernel = native_kernel("additive_transform")
if kernel is not None:
shape = result.shape
return kernel(result.reshape(-1, *shape[-2:]), order, True).reshape(shape)
for _ in range(order):
result = np.cumsum(result, axis=-2, dtype=np.float32)
return result
def difference_additive(values: np.ndarray, order: int) -> np.ndarray:
"""Take zero-anchored finite differences along the time axis."""
result = np.asarray(values, dtype=np.float32)
kernel = native_kernel("additive_transform")
if kernel is not None:
shape = result.shape
return kernel(result.reshape(-1, *shape[-2:]), order, False).reshape(shape)
for _ in range(order):
previous = np.concatenate((np.zeros_like(result[..., :1, :]), result[..., :-1, :]), axis=-2)
result = result - previous
return result
def wrap_to_pi(values: np.ndarray) -> np.ndarray:
"""Return angles in radians folded into ``(-pi, pi]``."""
angles = np.asarray(values, dtype=np.float64)
kernel = native_kernel("wrap_angles")
if kernel is not None:
return kernel(angles)
return np.asarray(np.pi - np.mod(np.pi - angles, 2.0 * np.pi), dtype=np.float32)
_INTERPOLATION_METHODS = {"linear", "pchip", "cubic"}
def _validate_rate(value: float, name: str) -> float:
rate = float(value)
if not math.isfinite(rate) or rate <= 0.0:
raise ValueError(f"{name} must be positive and finite")
return rate
def _quaternion_slerp(left: np.ndarray, right: np.ndarray, weight: np.ndarray) -> np.ndarray:
first = _normalize_quaternion(np.asarray(left, dtype=np.float64))
second = _normalize_quaternion(np.asarray(right, dtype=np.float64))
dot = np.sum(first * second, axis=1)
flip = dot < 0.0
second[flip] *= -1.0
dot = np.clip(np.abs(dot), 0.0, 1.0)
output = np.empty_like(first)
near = dot > 0.9995
output[near] = first[near] + weight[near, None] * (second[near] - first[near])
regular = ~near
if np.any(regular):
angle = np.arccos(dot[regular])
denominator = np.sin(angle)
w = weight[regular]
output[regular] = (
np.sin((1.0 - w) * angle)[:, None] / denominator[:, None] * first[regular]
+ np.sin(w * angle)[:, None] / denominator[:, None] * second[regular]
)
return _normalize_quaternion(output)
def resampled_horizon(length: int, *, source_hz: float, target_hz: float) -> int:
"""Return the target control-step count for a regular source horizon."""
if isinstance(length, bool) or not isinstance(length, (int, np.integer)) or length < 1:
raise ValueError("a trajectory must contain at least one sample")
source_rate = _validate_rate(source_hz, "source_hz")
target_rate = _validate_rate(target_hz, "target_hz")
scaled = int(math.floor(int(length) * target_rate / source_rate + 0.5))
return max(1, scaled)
def _target_times(
length: int,
source_hz: float,
target_hz: float,
*,
target_length: int | None,
) -> np.ndarray:
count = (
resampled_horizon(length, source_hz=source_hz, target_hz=target_hz)
if target_length is None
else target_length
)
if isinstance(count, bool) or not isinstance(count, (int, np.integer)) or count < 1:
raise ValueError("target_length must be a positive integer")
return np.arange(int(count), dtype=np.float64) / target_hz
def _guarded_natural_cubic(
source_time: np.ndarray,
values: np.ndarray,
target_time: np.ndarray,
) -> np.ndarray:
sampled = np.asarray(
CubicSpline(source_time, values, axis=0, bc_type="natural")(target_time),
dtype=np.float64,
)
lower = np.min(values, axis=0)
upper = np.max(values, axis=0)
tolerance = 1e-9 + 0.05 * (upper - lower)
fallback_axes = np.any(
(sampled < lower[None] - tolerance[None]) | (sampled > upper[None] + tolerance[None]),
axis=0,
)
if np.any(fallback_axes):
sampled[:, fallback_axes] = PchipInterpolator(
source_time, values[:, fallback_axes], axis=0
)(target_time)
return sampled
def resample_uniform(
values: np.ndarray,
*,
source_hz: float,
target_hz: float,
kind: str = "linear",
target_length: int | None = None,
method: str = "linear",
) -> np.ndarray:
"""Resample one regular control-horizon trajectory onto a target clock."""
source_rate = _validate_rate(source_hz, "source_hz")
target_rate = _validate_rate(target_hz, "target_hz")
array = np.asarray(values, dtype=np.float64)
if array.ndim != 2 or len(array) < 1 or not np.all(np.isfinite(array)):
raise ValueError("values must be a non-empty finite [T, D] array")
if kind not in {"linear", "hold", "pose_xyzw", "pose_euler_xyz"}:
raise ValueError("unsupported resampling kind")
if method not in _INTERPOLATION_METHODS:
raise ValueError(f"method must be one of {sorted(_INTERPOLATION_METHODS)}")
if kind == "pose_xyzw" and array.shape[1] != 7:
raise ValueError("pose_xyzw values must have shape [T, 7]")
if kind == "pose_euler_xyz" and array.shape[1] != 6:
raise ValueError("pose_euler_xyz values must have shape [T, 6]")
kernel = native_kernel("resample_uniform_batch")
if kernel is not None:
if target_length is not None and (
isinstance(target_length, bool)
or not isinstance(target_length, (int, np.integer))
or target_length < 1
):
raise ValueError("target_length must be a positive integer")
if kind == "pose_euler_xyz":
array = np.concatenate((array[:, :3], _euler_xyz_to_quaternion(array[:, 3:6])), axis=1)
kind = "pose_xyzw"
return kernel(
array[None],
source_rate,
target_rate,
kind,
-1 if target_length is None else int(target_length),
method,
)[0]
standard_length = resampled_horizon(len(array), source_hz=source_rate, target_hz=target_rate)
if source_rate == target_rate and target_length in {None, standard_length}:
if kind == "pose_euler_xyz":
array = np.concatenate((array[:, :3], _euler_xyz_to_quaternion(array[:, 3:6])), axis=1)
return np.asarray(array, dtype=np.float32)
target_time = _target_times(len(array), source_rate, target_rate, target_length=target_length)
source_time = np.arange(len(array), dtype=np.float64) / source_rate
interpolation_time = np.minimum(target_time, source_time[-1])
source_position = target_time * source_rate
left = np.minimum(np.floor(source_position).astype(np.int64), len(array) - 1)
right = np.minimum(left + 1, len(array) - 1)
weight = source_position - left
if kind == "hold":
result = array[left]
else:
pose = kind in {"pose_xyzw", "pose_euler_xyz"}
continuous = array[:, :3] if pose else array
if len(array) == 1:
sampled = np.repeat(continuous, len(target_time), axis=0)
elif method == "pchip":
sampled = PchipInterpolator(source_time, continuous, axis=0)(interpolation_time)
elif method == "cubic":
sampled = _guarded_natural_cubic(source_time, continuous, interpolation_time)
else:
sampled = array[left, : continuous.shape[1]] + weight[:, None] * (
array[right, : continuous.shape[1]] - array[left, : continuous.shape[1]]
)
if not pose:
result = sampled
else:
quaternion = (
array[:, 3:7] if kind == "pose_xyzw" else _euler_xyz_to_quaternion(array[:, 3:6])
)
if len(array) == 1:
rotation = np.repeat(quaternion, len(target_time), axis=0)
elif method == "linear":
rotation = _quaternion_slerp(quaternion[left], quaternion[right], weight)
else:
rotation = RotationSpline(source_time, Rotation.from_quat(quaternion))(
interpolation_time
).as_quat()
result = np.concatenate((sampled, rotation), axis=1)
return np.asarray(result, dtype=np.float32)
def eef_pose_absolute(poses: np.ndarray) -> np.ndarray:
"""Convert canonical ``xyz + xyzw`` state to ``xyz + rotation_vector``."""
values = np.asarray(poses, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != 7 or not np.all(np.isfinite(values)):
raise ValueError("poses must be a finite [T, 7] array")
kernel = native_kernel("eef_pose_absolute_batch")
if kernel is not None and len(values):
return kernel(values[None])[0]
return np.asarray(
np.concatenate((values[:, :3], _quaternion_to_rotvec(values[:, 3:7])), axis=1),
dtype=np.float32,
)
def eef_pose_components(poses: np.ndarray, representation: str) -> np.ndarray:
"""Convert canonical ``xyz + xyzw`` poses to one registered source layout."""
values = np.asarray(poses, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != 7 or not np.all(np.isfinite(values)):
raise ValueError("poses must be a finite [T, 7] array")
quaternion = _normalize_quaternion(values[:, 3:7])
if representation == "rotation_vector":
rotation = _quaternion_to_rotvec(quaternion)
elif representation == "euler_xyz":
rotation = Rotation.from_quat(quaternion).as_euler("xyz")
elif representation == "quaternion_xyzw":
rotation = quaternion
else:
raise ValueError("unsupported EEF rotation representation")
return np.asarray(np.concatenate((values[:, :3], rotation), axis=1), dtype=np.float32)
def eef_pose_from_components(
position: np.ndarray,
rotation: np.ndarray,
representation: str,
) -> np.ndarray:
"""Build canonical ``xyz + xyzw`` poses from an explicit source layout."""
translation = np.asarray(position, dtype=np.float64)
orientation = np.asarray(rotation, dtype=np.float64)
if translation.ndim != 2 or translation.shape[1] != 3 or len(translation) < 1:
raise ValueError("position must have non-empty shape [T, 3]")
if len(orientation) != len(translation) or not np.all(np.isfinite(translation)):
raise ValueError("position and rotation must have matching finite trajectories")
if representation == "rotation_vector":
if orientation.ndim != 2 or orientation.shape[1] != 3:
raise ValueError("rotation_vector must have shape [T, 3]")
quaternion = _rotvec_to_quaternion(orientation)
elif representation == "euler_xyz":
if orientation.ndim != 2 or orientation.shape[1] != 3:
raise ValueError("euler_xyz must have shape [T, 3]")
quaternion = _euler_xyz_to_quaternion(orientation)
elif representation == "quaternion_xyzw":
if orientation.ndim != 2 or orientation.shape[1] != 4:
raise ValueError("quaternion_xyzw must have shape [T, 4]")
quaternion = _normalize_quaternion(orientation)
else:
raise ValueError("unsupported EEF rotation representation")
if not np.all(np.isfinite(orientation)):
raise ValueError("position and rotation must be finite")
return np.asarray(np.concatenate((translation, quaternion), axis=1), dtype=np.float32)
def eef_pose_local_absolute(
poses: np.ndarray, *, initial_pose: np.ndarray | None = None
) -> np.ndarray:
"""Express measured poses relative to an explicit or inferred initial pose."""
values = np.asarray(poses, dtype=np.float64)
if (
values.ndim != 2
or values.shape[1] != 7
or len(values) < 1
or not np.all(np.isfinite(values))
):
raise ValueError("poses must be a finite non-empty [T, 7] array")
if initial_pose is None:
initial = values[:1]
else:
initial = np.asarray(initial_pose, dtype=np.float64)
if initial.shape == (7,):
initial = initial[None]
if initial.shape != (1, 7) or not np.all(np.isfinite(initial)):
raise ValueError("initial_pose must be a finite [7] pose")
quaternion = _normalize_quaternion(values[:, 3:7])
initial_quaternion = _normalize_quaternion(initial[:, 3:7])
inverse_start = initial_quaternion.copy()
inverse_start[:, :3] *= -1.0
relative = _quaternion_multiply(np.broadcast_to(inverse_start, quaternion.shape), quaternion)
return np.asarray(
np.concatenate((values[:, :3] - initial[:, :3], _quaternion_to_rotvec(relative)), axis=1),
dtype=np.float32,
)
def eef_pose_deltas(poses: np.ndarray) -> np.ndarray:
"""Differentiate a local ``xyz + xyzw`` path from zero/identity."""
values = np.asarray(poses, dtype=np.float64)
if (
values.ndim != 2
or values.shape[1] != 7
or len(values) < 1
or not np.all(np.isfinite(values))
):
raise ValueError("poses must be a finite non-empty [T, 7] array")
kernel = native_kernel("eef_pose_deltas_batch")
if kernel is not None:
return kernel(values[None])[0]
quaternion = _normalize_quaternion(values[:, 3:7])
previous_position = np.concatenate((np.zeros((1, 3)), values[:-1, :3]), axis=0)
identity = np.asarray([[0.0, 0.0, 0.0, 1.0]], dtype=np.float64)
previous_quaternion = np.concatenate((identity, quaternion[:-1]), axis=0)
inverse_previous = previous_quaternion.copy()
inverse_previous[:, :3] *= -1.0
increment = _quaternion_multiply(inverse_previous, quaternion)
return np.asarray(
np.concatenate(
(values[:, :3] - previous_position, _quaternion_to_rotvec(increment)), axis=1
),
dtype=np.float32,
)
def eef_pose_from_local_absolute(poses: np.ndarray, *, initial_pose: np.ndarray) -> np.ndarray:
"""Attach an external initial pose to a local ``xyz + xyzw`` path."""
values = np.asarray(poses, dtype=np.float64)
initial = np.asarray(initial_pose, dtype=np.float64)
if (
values.ndim != 2
or values.shape[1] != 7
or len(values) < 1
or not np.all(np.isfinite(values))
):
raise ValueError("poses must be a finite non-empty [T, 7] array")
if initial.shape == (7,):
initial = initial[None]
if initial.shape != (1, 7) or not np.all(np.isfinite(initial)):
raise ValueError("initial_pose must be a finite [7] pose")
local_quaternion = _normalize_quaternion(values[:, 3:7])
initial_quaternion = _normalize_quaternion(initial[:, 3:7])
quaternion = _quaternion_multiply(
np.broadcast_to(initial_quaternion, local_quaternion.shape), local_quaternion
)
return np.asarray(
np.concatenate((values[:, :3] + initial[:, :3], quaternion), axis=1),
dtype=np.float32,
)
def integrate_eef_deltas(deltas: np.ndarray) -> np.ndarray:
"""Integrate ``[dx,dy,dz,body-rotvec]`` from a zero/identity pose."""
values = np.asarray(deltas, dtype=np.float64)
if values.ndim != 2 or values.shape[1] != 6 or not np.all(np.isfinite(values)):
raise ValueError("deltas must be a finite [T, 6] array")
if len(values) < 1:
raise ValueError("deltas must contain at least one step")
kernel = native_kernel("integrate_eef_deltas_batch")
if kernel is not None:
return kernel(values[None])[0]
translation = np.cumsum(values[:, :3], axis=0)
increments = _rotvec_to_quaternion(values[:, 3:6])
orientation = np.empty((len(values), 4), dtype=np.float64)
current = np.asarray([[0.0, 0.0, 0.0, 1.0]], dtype=np.float64)
for index, increment in enumerate(increments):
current = _quaternion_multiply(current, increment[None])
orientation[index] = current[0]
return np.asarray(np.concatenate((translation, orientation), axis=1), dtype=np.float32)
def normalize_gripper_actions(
values: np.ndarray,
*,
closed_value: float,
open_value: float,
) -> np.ndarray:
"""Map one raw controller gripper channel to the canonical open fraction."""
array = np.asarray(values, dtype=np.float32)
if array.ndim != 2 or array.shape[1] != 1 or not np.all(np.isfinite(array)):
raise ValueError("gripper action must be a finite [T, 1] array")
if not (math.isfinite(closed_value) and math.isfinite(open_value)):
raise ValueError("gripper anchors must be finite")
if closed_value == open_value:
raise ValueError("gripper closed/open anchors must differ")
kernel = native_kernel("gripper_transform")
if kernel is not None:
return kernel(array, "normalize", closed_value, open_value)
fraction = (array - closed_value) / (open_value - closed_value)
return np.clip(fraction, 0.0, 1.0).astype(np.float32)
def discretize_gripper_actions(
values: np.ndarray,
*,
mode: str,
threshold: float = 0.8,
) -> np.ndarray:
"""Quantize the canonical open fraction for the codec."""
array = np.asarray(values, dtype=np.float32)
if array.ndim != 2 or array.shape[1] != 1 or not np.all(np.isfinite(array)):
raise ValueError("gripper fraction must be a finite [T, 1] array")
if mode == "binary":
if not math.isfinite(threshold):
raise ValueError("gripper threshold must be finite")
kernel = native_kernel("gripper_transform")
if kernel is not None:
return kernel(array, mode, threshold, 0)
return (array >= threshold).astype(np.float32)
if mode == "eleven":
kernel = native_kernel("gripper_transform")
if kernel is not None:
return kernel(array, mode, threshold, 0)
return (np.rint(array * 10.0) / 10.0).astype(np.float32)
if mode == "twenty":
return (np.rint(array * 20.0) / 20.0).astype(np.float32)
raise ValueError("gripper mode must be 'binary', 'eleven' or 'twenty'")