File size: 18,725 Bytes
1bfc3f0
895e804
 
 
 
 
 
 
 
 
1bfc3f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895e804
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
 
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
895e804
 
1bfc3f0
895e804
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
1bfc3f0
895e804
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
 
 
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
1bfc3f0
895e804
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
1bfc3f0
 
 
895e804
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
1bfc3f0
895e804
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bfc3f0
895e804
 
 
 
 
1bfc3f0
 
 
895e804
 
 
 
 
 
 
1bfc3f0
895e804
 
1bfc3f0
895e804
 
1bfc3f0
 
895e804
1bfc3f0
895e804
 
 
1bfc3f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
"""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'")