ZibinDong's picture
Publish audited ActionCodec2 release files
1bfc3f0 verified
Raw History Blame Contribute Delete
29 kB
"""User-facing action layouts compiled into shared joint/EEF trajectories."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import yaml
from ._validation import optional_finite_array, validate_dimension_partition
from .batch_semantics import (
canonicalize_batch,
canonicalize_component_batch,
externalize_canonical_batch,
resample_canonical_batch,
)
from .configuration import (
ConfigError,
_integer,
_mapping,
_number,
_reject_unknown,
_required,
_UniqueKeyLoader,
)
from .semantic import wrap_to_pi as wrap_to_pi
from .trajectory import ActiveTrajectory
_NAME = re.compile(r"[a-z][a-z0-9_]*\Z")
_SPACES = {"eef", "joint"}
_INPUTS = {"absolute", "delta"}
_ROTATIONS = {"rotation_vector", "euler_xyz", "quaternion_xyzw"}
_JOINT_WRAPS = {"pi", "none"}
_INTERPOLATIONS = {"linear", "pchip", "cubic"}
_GRIPPER_DIM = {"eef": 6, "joint": 7}
def canonical_preset_name(name: str) -> str:
"""Preserve the historical six-coordinate alias without implying a gripper slot."""
return "single_joint6_abs_no_gripper" if name == "single_joint6_abs_continuous_gripper" else name
def _format_dims(dims: Sequence[int]) -> str:
values = tuple(dims)
if len(values) == 1:
return str(values[0])
if values == tuple(range(values[0], values[-1] + 1)):
return f"{values[0]}..{values[-1]}"
return ",".join(map(str, values))
def _dims(value: object, path: str) -> tuple[int, ...]:
if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
raise ConfigError(f"{path} must be a list of dimensions")
output = tuple(_integer(item, f"{path}[{index}]") for index, item in enumerate(value))
if not output:
raise ConfigError(f"{path} must not be empty")
if len(set(output)) != len(output):
raise ConfigError(f"{path} contains a repeated dimension")
return output
@dataclass(frozen=True)
class ComponentSpec:
"""One independently encoded arm/body component in an input tensor."""
name: str
space: str
joint_dims: tuple[int, ...] | None = None
joint_wrap: str | None = None
position_dims: tuple[int, ...] | None = None
rotation_dims: tuple[int, ...] | None = None
rotation_format: str = "rotation_vector"
gripper_dim: int | None = None
gripper_closed_value: float = 0.0
gripper_open_value: float = 1.0
gripper_threshold: float | None = None
gripper_open_is_high: bool = True
def __post_init__(self) -> None:
if not isinstance(self.gripper_open_is_high, bool):
raise ConfigError("gripper_open_is_high must be boolean")
if self.gripper_threshold is not None:
_number(self.gripper_threshold, "gripper_threshold")
if not isinstance(self.name, str) or _NAME.fullmatch(self.name) is None:
raise ConfigError(f"component name must match {_NAME.pattern!r}")
if self.space not in _SPACES:
raise ConfigError(f"component {self.name!r}.space must be joint or eef")
if self.rotation_format not in _ROTATIONS:
raise ConfigError(
f"component {self.name!r}.rotation_format must be one of {sorted(_ROTATIONS)}"
)
for label, dims in (
("joint_dims", self.joint_dims),
("position_dims", self.position_dims),
("rotation_dims", self.rotation_dims),
):
if dims is not None:
_dims(dims, f"component {self.name}.{label}")
self._validate_motion_dims()
if self.space == "joint":
if self.joint_wrap is None:
object.__setattr__(self, "joint_wrap", "pi")
if self.joint_wrap not in _JOINT_WRAPS:
raise ConfigError(
f"component {self.name!r}.joint_wrap must be one of {sorted(_JOINT_WRAPS)}"
)
elif self.joint_wrap is not None:
raise ConfigError(
f"component {self.name!r}.joint_wrap applies only to joint components"
)
raw_dims = self.source_dims
if self.gripper_dim is not None:
_integer(self.gripper_dim, f"component {self.name}.gripper_dim")
if self.gripper_dim in raw_dims:
raise ConfigError(f"component {self.name!r}.gripper_dim overlaps motion dims")
for anchor in (self.gripper_closed_value, self.gripper_open_value):
_number(anchor, f"component {self.name}.gripper anchor")
if self.gripper_closed_value == self.gripper_open_value:
raise ConfigError(
f"component {self.name!r}.gripper_closed_value and gripper_open_value "
"must differ"
)
def _validate_motion_dims(self) -> None:
if self.space == "joint":
if self.joint_dims is None or not 1 <= len(self.joint_dims) <= 7:
raise ConfigError(f"joint component {self.name!r} requires 1..7 joint_dims")
if self.position_dims is not None or self.rotation_dims is not None:
raise ConfigError(f"joint component {self.name!r} cannot define EEF dims")
return
if self.joint_dims is not None:
raise ConfigError(f"EEF component {self.name!r} cannot define joint_dims")
if self.position_dims is None or len(self.position_dims) != 3:
raise ConfigError(f"EEF component {self.name!r} requires three position_dims")
expected = 4 if self.rotation_format == "quaternion_xyzw" else 3
if self.rotation_dims is None or len(self.rotation_dims) != expected:
raise ConfigError(
f"EEF component {self.name!r} requires {expected} rotation_dims for "
f"{self.rotation_format}"
)
if set(self.position_dims).intersection(self.rotation_dims):
raise ConfigError(f"EEF component {self.name!r} reuses a pose dimension")
@property
def source_dims(self) -> tuple[int, ...]:
"""All source motion columns in canonical component order."""
if self.space == "joint":
assert self.joint_dims is not None
return self.joint_dims
assert self.position_dims is not None and self.rotation_dims is not None
return (*self.position_dims, *self.rotation_dims)
@property
def all_source_dims(self) -> tuple[int, ...]:
"""Motion plus optional binary-gripper source columns."""
return (
self.source_dims if self.gripper_dim is None else (*self.source_dims, self.gripper_dim)
)
@property
def active_dims(self) -> tuple[int, ...]:
"""Shared-profile slots emitted by this component."""
motion_width = len(self.joint_dims or ()) if self.space == "joint" else 6
dims = tuple(range(motion_width))
return dims if self.gripper_dim is None else (*dims, _GRIPPER_DIM[self.space])
@classmethod
def from_dict(cls, name: str, payload: object, *, path: str) -> ComponentSpec:
values = _mapping(payload, path)
allowed = {
"space",
"joint_dims",
"joint_wrap",
"position_dims",
"rotation_dims",
"rotation_format",
"gripper_dim",
"gripper_closed_value",
"gripper_open_value",
"gripper_threshold",
"gripper_open_is_high",
}
_reject_unknown(values, allowed, path)
gripper_dim = values.get("gripper_dim")
if gripper_dim is not None:
gripper_dim = _integer(gripper_dim, f"{path}.gripper_dim")
return cls(
name=name,
space=str(_required(values, "space", path)),
joint_dims=None
if "joint_dims" not in values
else _dims(values["joint_dims"], f"{path}.joint_dims"),
joint_wrap=None if values.get("joint_wrap") is None else str(values["joint_wrap"]),
position_dims=None
if "position_dims" not in values
else _dims(values["position_dims"], f"{path}.position_dims"),
rotation_dims=None
if "rotation_dims" not in values
else _dims(values["rotation_dims"], f"{path}.rotation_dims"),
rotation_format=str(values.get("rotation_format", "rotation_vector")),
gripper_dim=gripper_dim,
gripper_threshold=(values.get("gripper_threshold") if values.get("gripper_threshold") is not None
else (0.8 if values.get("gripper_open_is_high", True) else 0.2)
if "gripper_open_is_high" in values or "gripper_threshold" in values else None),
gripper_open_is_high=values.get("gripper_open_is_high", True),
gripper_closed_value=_number(
values.get("gripper_closed_value", 0.0), f"{path}.gripper_closed_value"
),
gripper_open_value=_number(
values.get("gripper_open_value", 1.0), f"{path}.gripper_open_value"
),
)
def to_dict(self) -> dict[str, object]:
payload: dict[str, object] = {"space": self.space}
if self.gripper_dim is not None and self.gripper_threshold is not None:
payload.update(gripper_threshold=self.gripper_threshold,
gripper_open_is_high=self.gripper_open_is_high)
if self.space == "joint":
payload["joint_dims"] = list(self.joint_dims or ())
payload["joint_wrap"] = self.joint_wrap
else:
payload.update(
{
"position_dims": list(self.position_dims or ()),
"rotation_dims": list(self.rotation_dims or ()),
"rotation_format": self.rotation_format,
}
)
if self.gripper_dim is not None:
payload.update(
{
"gripper_dim": self.gripper_dim,
"gripper_closed_value": self.gripper_closed_value,
"gripper_open_value": self.gripper_open_value,
}
)
return payload
@dataclass(frozen=True)
class PresetConfig:
"""A dataset- and embodiment-independent physical ``[B,T,D]`` contract."""
name: str
input_dim: int
input: str
components: tuple[ComponentSpec, ...]
source_hz: float = 15.0
target_hz: float = 15.0
interpolation: str = "cubic"
token_order: str = "time_major"
def __post_init__(self) -> None:
object.__setattr__(self, "name", canonical_preset_name(self.name))
if not isinstance(self.name, str) or _NAME.fullmatch(self.name) is None:
raise ConfigError(f"preset.name must match {_NAME.pattern!r}")
_integer(self.input_dim, "preset.input_dim", positive=True)
if self.input not in _INPUTS:
raise ConfigError("preset.input must be absolute or delta")
if not self.components:
raise ConfigError("preset.components must not be empty")
if not all(isinstance(component, ComponentSpec) for component in self.components):
raise ConfigError("preset.components must contain ComponentSpec values")
names = [component.name for component in self.components]
if len(set(names)) != len(names):
raise ConfigError("preset.components contains a duplicate name")
self._validate_source_partition()
for rate, path in ((self.source_hz, "source_hz"), (self.target_hz, "target_hz")):
checked = _number(rate, f"preset.{path}")
if checked <= 0.0:
raise ConfigError(f"preset.{path} must be positive and finite")
if self.interpolation not in _INTERPOLATIONS:
raise ConfigError(f"preset.interpolation must be one of {sorted(_INTERPOLATIONS)}")
if self.token_order not in {"time_major", "component_major"}:
raise ConfigError("preset.token_order must be time_major or component_major")
if self.input == "delta" and any(
component.space == "eef" and component.rotation_format != "rotation_vector"
for component in self.components
):
raise ConfigError("EEF delta components require rotation_format=rotation_vector")
def _validate_source_partition(self) -> None:
validate_dimension_partition(
((component.name, component.all_source_dims) for component in self.components),
self.input_dim,
label="input dim",
error_type=ConfigError,
)
@classmethod
def from_dict(cls, payload: object, *, path: str = "preset") -> PresetConfig:
values = _mapping(payload, path)
allowed = {
"name",
"input_dim",
"input",
"source_hz",
"target_hz",
"interpolation",
"components",
"token_order",
}
_reject_unknown(values, allowed, path)
raw_components = _mapping(_required(values, "components", path), f"{path}.components")
components = tuple(
ComponentSpec.from_dict(name, raw, path=f"{path}.components.{name}")
for name, raw in raw_components.items()
)
return cls(
name=str(_required(values, "name", path)),
input_dim=_integer(
_required(values, "input_dim", path), f"{path}.input_dim", positive=True
),
input=str(_required(values, "input", path)),
components=components,
source_hz=_number(values.get("source_hz", 15.0), f"{path}.source_hz"),
target_hz=_number(values.get("target_hz", 15.0), f"{path}.target_hz"),
interpolation=str(values.get("interpolation", "cubic")),
token_order=str(values.get("token_order", "time_major")),
)
@classmethod
def from_yaml(cls, path: str | Path) -> PresetConfig:
"""Load one strict user preset without executing arbitrary code."""
source = Path(path)
try:
payload = yaml.load(source.read_text(encoding="utf-8"), Loader=_UniqueKeyLoader)
except yaml.YAMLError as error:
raise ConfigError(f"cannot parse {source}: {error}") from error
return cls.from_dict(payload, path=str(source))
def save_yaml(self, path: str | Path) -> Path:
"""Save the complete reproducible preset and return its path."""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(yaml.safe_dump(self.to_dict(), sort_keys=False), encoding="utf-8")
return target
def to_dict(self) -> dict[str, object]:
payload = {
"name": self.name,
"input_dim": self.input_dim,
"input": self.input,
"source_hz": self.source_hz,
"target_hz": self.target_hz,
"interpolation": self.interpolation,
"token_order": self.token_order,
"components": {component.name: component.to_dict() for component in self.components},
}
return payload
def summary(self) -> str:
"""Return a compact action-space label for discovery UIs."""
spaces = "+".join(dict.fromkeys(component.space for component in self.components))
return (
f"{self.name} (D={self.input_dim}, {self.input}, {spaces}, {self.source_hz:g} Hz input)"
)
def describe(self) -> str:
"""Describe every source tensor column and its physical interpretation."""
motion = "position" if self.input == "absolute" else "per-step delta"
lines = [self.summary()]
for component in self.components:
if component.space == "joint":
assert component.joint_dims is not None
lines.append(
f" dims {_format_dims(component.joint_dims)}: {component.name} "
f"generalized-joint {motion} [rad or registered canonical unit]"
)
if self.input == "absolute":
lines.append(
" angles in (-pi, pi]; decode returns that interval"
if component.joint_wrap == "pi"
else " continuous angles, may exceed one turn; decode never wraps"
)
else:
assert component.position_dims is not None
assert component.rotation_dims is not None
lines.append(
f" dims {_format_dims(component.position_dims)}: {component.name} "
f"EEF translation {motion} (x, y, z in listed order) [m]"
)
rotation_motion = "orientation" if self.input == "absolute" else "increment"
rotation_unit = (
"unit xyzw quaternion"
if component.rotation_format == "quaternion_xyzw"
else "rad"
)
lines.append(
f" dims {_format_dims(component.rotation_dims)}: {component.name} EEF "
f"{rotation_motion} [{component.rotation_format}, {rotation_unit}]"
)
if self.input == "delta":
lines.append(
" rotation_vector = axis * angle [rad], right-multiplicative "
"body-frame increment: R_next = R_previous @ Exp(rotvec)"
)
elif component.rotation_format == "rotation_vector":
lines.append(" rotation_vector = axis * angle [rad], not Euler angles")
elif component.rotation_format == "euler_xyz":
lines.append(" euler_xyz = extrinsic x, y, z Euler angles [rad]")
if component.gripper_dim is not None:
if component.gripper_threshold is not None:
comparison = ">=" if component.gripper_open_is_high else "<="
semantics = f"binary; open when raw {comparison} {component.gripper_threshold:g}"
else:
semantics = (f"raw {component.gripper_closed_value:g} = closed, "
f"raw {component.gripper_open_value:g} = open; "
"levels are determined by the loaded profile")
lines.append(f" dim {component.gripper_dim}: {component.name} gripper command ({semantics})")
else:
lines.append(
f" {component.name}: no gripper channel; all listed columns are motion"
)
if self.input == "absolute":
lines.append(
" encode/decode require the same current_state (B,D), immediately before row 0"
)
else:
lines.append(
" encode/decode: omit current_state; deltas are per-step increments, not velocities"
)
lines.append(
f" resampling: {self.source_hz:g} Hz default -> {self.target_hz:g} Hz codec "
f"clock ({self.interpolation})"
)
return "\n".join(lines)
def canonicalize(
self,
values: np.ndarray,
*,
source_hz: float | None = None,
current_state: np.ndarray | None = None,
) -> dict[str, list[ActiveTrajectory]]:
"""Convert a batch into local paths for the two shared profiles."""
batches = self._canonicalize_batch(values, source_hz=source_hz, current_state=current_state)
return {
component.name: [
ActiveTrajectory(row, component.active_dims) for row in batches[component.name]
]
for component in self.components
}
def _canonicalize_batch(
self,
values: np.ndarray,
*,
source_hz: float | None = None,
current_state: np.ndarray | None = None,
) -> dict[str, np.ndarray]:
array = np.asarray(values, dtype=np.float32)
if array.ndim != 3 or len(array) < 1 or array.shape[1] < 1:
raise ValueError("preset input must have non-empty shape [B, T, D]")
if array.shape[2] != self.input_dim:
raise ValueError(
f"preset {self.name!r} expects input_dim={self.input_dim}, got {array.shape[2]}"
)
if not np.all(np.isfinite(array)):
raise ValueError("preset input contains a non-finite value")
state_array = optional_finite_array(current_state, shape=(len(array), self.input_dim))
source_hz = self.source_hz if source_hz is None else source_hz
return canonicalize_batch(self, array, source_hz=source_hz, current_state=state_array)
def canonicalize_episode(
self,
values: np.ndarray,
*,
source_hz: float | None = None,
current_state: np.ndarray | None = None,
) -> dict[str, ActiveTrajectory]:
"""Canonicalize one variable-length episode and optional ``(D,)`` anchor."""
episode = np.asarray(values, dtype=np.float32)
if episode.ndim != 2 or len(episode) < 1 or episode.shape[1] != self.input_dim:
raise ValueError(
f"preset {self.name!r} expects a non-empty [T, {self.input_dim}] episode"
)
state = optional_finite_array(current_state, shape=(self.input_dim,))
canonical = self._canonicalize_batch(
episode[None],
source_hz=source_hz,
current_state=None if state is None else state[None],
)
return {
component.name: ActiveTrajectory(canonical[component.name][0], component.active_dims)
for component in self.components
}
def profile_trajectories(
self, values: np.ndarray, *, current_state: np.ndarray | None = None
) -> dict[str, list[ActiveTrajectory]]:
"""Canonicalize one episode and group its components by joint/EEF profile."""
canonical = self.canonicalize_episode(values, current_state=current_state)
output: dict[str, list[ActiveTrajectory]] = {}
for component in self.components:
output.setdefault(component.space, []).append(canonical[component.name])
return output
def _canonicalize_component(
self,
episode: np.ndarray,
component: ComponentSpec,
*,
source_hz: float,
current_state: np.ndarray | None,
) -> ActiveTrajectory:
values = canonicalize_component_batch(
self,
np.asarray(episode, dtype=np.float32)[None],
component,
source_hz=source_hz,
current_state=None
if current_state is None
else np.asarray(current_state, dtype=np.float32)[None],
)
return ActiveTrajectory(values[0], component.active_dims)
def format_action_spaces(
presets: Mapping[str, PresetConfig],
*,
selected: str | None = None,
action_dim: int | None = None,
second_order_boundaries: Mapping[str, Mapping[str, int]] | None = None,
codec_name: str = "ActionCodec2",
chunk_horizon_decode: bool = False,
) -> str:
"""Explain registered tensor layouts without inferring semantics from shape."""
if action_dim is not None:
action_dim = _integer(action_dim, "action_dim", positive=True)
if not presets:
return "No action spaces are registered."
candidates = sorted(
(
preset
for preset in presets.values()
if action_dim is None or preset.input_dim == action_dim
),
key=lambda preset: (preset.input_dim, preset.name),
)
lines = [
f"{codec_name} — action layout guide",
f"Selected action_space: {selected or 'none (choose a layout before encoding)'}",
"",
"Input: NumPy arrays or Torch tensors, (T,D) or (B,T,D).",
" T = timesteps, D = columns per action, B = batch size; column indices start at 0.",
" Any positive T is accepted. A batch needs equal T; encode unequal-length episodes separately.",
" decode returns (B,T,D), including B=1 for a single episode; values are quantized, not lossless.",
"",
"How to choose:",
" joint = robot joint coordinates; EEF = end-effector (tool) position and orientation.",
" absolute = target coordinates; delta = per-step motion increments, not velocities.",
" Match column order, units, rotation convention, gripper commands and controller fps.",
" Shape alone cannot identify physical meaning: layouts sharing D are only candidates.",
" Consult your dataset/controller documentation if these meanings are unknown.",
" Supply physical units, not normalized [-1,1] features; convert degrees to radians.",
" Non-radian joint coordinates need an explicit mapping to the registered canonical units.",
" Pass the actual sampling rate as fps=...; defaults and codec rates appear below.",
(
" Rows are independent chunks: decode(rows, horizon=T, fps=...) needs the chunk T."
if chunk_horizon_decode
else " Resampling can change decoded T; token coverage determines the returned length."
),
"",
f"Registered configurations: {len(candidates)} shown / {len(presets)} total"
+ ("" if action_dim is None else f" (D={action_dim} candidates, not automatic selection)"),
]
if not candidates:
widths = ", ".join(map(str, sorted({preset.input_dim for preset in presets.values()})))
lines.extend(
[
f" No registered action space matches D={action_dim}. Available D: {widths}.",
" Confirm your column meanings and register a custom layout; do not pad/truncate to force a match.",
]
)
for preset in candidates:
name = preset.name
marker = " [selected]" if name == selected else ""
description = preset.describe().splitlines()
lines.extend(["", f"- {description[0]}{marker}"])
lines.extend(f" {line.strip()}" for line in description[1:])
if second_order_boundaries is not None:
boundaries = [
f"{component!r}: (B,{width})"
for component, width in second_order_boundaries.get(name, {}).items()
]
lines.append(
" previous_first_order: "
+ (
"required in encode/decode, {" + ", ".join(boundaries) + "}; "
f"reconstructed increments at {preset.target_hz:g} Hz, B=1 for a single episode"
if boundaries
else "not required by this artifact for this layout"
)
)
lines.extend(
[
"",
"Next steps:",
f" Load: {codec_name}.from_pretrained(path, action_space='NAME')",
" Switch: codec = codec.for_action_space('NAME')",
" Filter: codec.print_action_spaces(action_dim=D)",
" Config: codec.action_spaces['NAME'].to_dict() # exact settings for a listed layout",
" Custom: codec.register_action_space(preset) # explicit column definitions, then switch",
" Custom layouts can reorder columns; each joint component supports 1..7 coordinates.",
" EEF absolute rotations support rotation_vector, euler_xyz or quaternion_xyzw (unit x,y,z,w).",
" EEF delta rotations require rotation_vector with the convention shown above.",
" For absolute layouts, current_state is (B,D), even when actions are (T,D).",
]
)
return "\n".join(lines)
def resample_canonical_trajectory(
trajectory: ActiveTrajectory,
component: ComponentSpec,
*,
source_hz: float,
target_hz: float,
target_length: int | None = None,
method: str = "linear",
) -> ActiveTrajectory:
"""Rate-convert one decoded canonical component without changing slots."""
sampled = resample_canonical_batch(
np.asarray(trajectory.values, dtype=np.float32)[None],
component,
source_hz=source_hz,
target_hz=target_hz,
target_length=target_length,
method=method,
)
return ActiveTrajectory(sampled[0], trajectory.active_dims)
def externalize_canonical_trajectory(
trajectory: ActiveTrajectory,
component: ComponentSpec,
*,
current_state: np.ndarray | None,
) -> np.ndarray:
"""Return deltas, or absolute values when an external anchor is supplied."""
return externalize_canonical_batch(
np.asarray(trajectory.values, dtype=np.float32)[None],
component,
current_state=None
if current_state is None
else np.asarray(current_state, dtype=np.float32)[None],
)[0]