File size: 16,707 Bytes
0651b21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
"""PyTorch and USD/Gf utility functions for IsaacGym-based RL.

Provides quaternion arithmetic (multiply, apply, rotate, conjugate, inverse
transform, combine), Euler-angle conversions, tensor helpers (clamp, scale,
unscale, random float/direction), and a USD ``Gf.Matrix4d`` construction
helper.  Most functions are compiled with ``@torch.jit.script`` for
performance.

SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
"""

import numpy as np
from pxr import Gf
import torch


def set_env_attr(self, attr_name, attr_val, env_ids):
    """Set a per-environment attribute on an env object for the given env ids.

    If the attribute already exists it is indexed by ``env_ids``; otherwise
    the value is set as a plain attribute (useful for first-time initialisation
    before the buffer exists).

    Args:
        self: Environment object that owns the attribute.
        attr_name: Name of the attribute to set.
        attr_val: Value(s) to assign.
        env_ids: Integer indices of the environments to update.
    """
    if hasattr(self, attr_name):
        getattr(self, attr_name)[env_ids] = attr_val
    else:
        setattr(self, attr_name, attr_val)


def to_torch(
    x: np.ndarray | torch.Tensor | list, device: torch.device | str, dtype=None, requires_grad=False
):
    """Convert a list, NumPy array, or Tensor to a ``torch.Tensor`` on ``device``.

    Args:
        x: Input data.  Lists are first converted to ``np.ndarray``.
        device: Target device (e.g. ``"cuda:0"`` or ``torch.device("cpu")``).
        dtype: Desired dtype.  Defaults to ``torch.float`` for non-Tensor
            inputs; for existing Tensors the current dtype is preserved when
            ``dtype`` is ``None``.
        requires_grad: Whether the result should track gradients.

    Returns:
        A ``torch.Tensor`` on ``device`` with the requested dtype and
        ``requires_grad`` setting.
    """
    # Convert list to np.ndarray for shape and dtype handling
    if isinstance(x, list):
        x = np.array(x)

    if not isinstance(x, torch.Tensor):
        if dtype is None:
            dtype = torch.float
        x = torch.tensor(x, device=device, dtype=dtype, requires_grad=requires_grad)
    else:
        # torch.Tensor
        if dtype is None:
            x = x.to(device=device)
            if x.requires_grad != requires_grad:
                x = x.detach().requires_grad_(requires_grad)
        else:
            x = x.to(dtype=dtype, device=device)
            if x.requires_grad != requires_grad:
                x = x.detach().requires_grad_(requires_grad)
    return x


@torch.jit.script
def quat_mul(a, b):
    """Multiply two batches of quaternions (xyzw convention).

    Args:
        a: Quaternion tensor of shape ``(..., 4)`` in xyzw order.
        b: Quaternion tensor of the same shape as ``a``.

    Returns:
        Product quaternion tensor of the same shape as ``a``.
    """
    assert a.shape == b.shape
    shape = a.shape
    a = a.reshape(-1, 4)
    b = b.reshape(-1, 4)

    x1, y1, z1, w1 = a[:, 0], a[:, 1], a[:, 2], a[:, 3]
    x2, y2, z2, w2 = b[:, 0], b[:, 1], b[:, 2], b[:, 3]
    ww = (z1 + x1) * (x2 + y2)
    yy = (w1 - y1) * (w2 + z2)
    zz = (w1 + y1) * (w2 - z2)
    xx = ww + yy + zz
    qq = 0.5 * (xx + (z1 - x1) * (x2 - y2))
    w = qq - ww + (z1 - y1) * (y2 - z2)
    x = qq - xx + (x1 + w1) * (x2 + w2)
    y = qq - yy + (w1 - x1) * (y2 + z2)
    z = qq - zz + (z1 + y1) * (w2 - x2)

    quat = torch.stack([x, y, z, w], dim=-1).view(shape)

    return quat


@torch.jit.script
def normalize(x, eps: float = 1e-9):
    """L2-normalize a tensor along its last dimension.

    Args:
        x: Input tensor of any shape.
        eps: Minimum norm value to clamp to, preventing division by zero.

    Returns:
        Tensor of the same shape as ``x`` with unit L2 norm along the last dim.
    """
    return x / x.norm(p=2, dim=-1).clamp(min=eps, max=None).unsqueeze(-1)


@torch.jit.script
def quat_apply(a, b):
    """Rotate a 3-D vector by a quaternion (xyzw convention).

    Args:
        a: Quaternion tensor of shape ``(..., 4)`` in xyzw order.
        b: Vector tensor of shape ``(..., 3)``.

    Returns:
        Rotated vector tensor of the same shape as ``b``.
    """
    shape = b.shape
    a = a.reshape(-1, 4)
    b = b.reshape(-1, 3)
    xyz = a[:, :3]
    t = xyz.cross(b, dim=-1) * 2
    return (b + a[:, 3:] * t + xyz.cross(t, dim=-1)).view(shape)


@torch.jit.script
def quat_rotate(q, v):
    """Rotate batched 3-D vectors by batched quaternions (xyzw convention).

    Uses the expanded form ``v' = 2(q_w^2 - 0.5)v + 2(q·v)q + 2q_w(q×v)``.

    Args:
        q: Quaternion tensor of shape ``(N, 4)`` in xyzw order.
        v: Vector tensor of shape ``(N, 3)``.

    Returns:
        Rotated vector tensor of shape ``(N, 3)``.
    """
    shape = q.shape
    q_w = q[:, -1]
    q_vec = q[:, :3]
    a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1)
    b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0
    c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0
    return a + b + c


# @torch.jit.script
def quat_rotate_inverse(q, v):
    """Rotate batched 3-D vectors by the *inverse* of batched quaternions.

    Equivalent to rotating by the conjugate quaternion (i.e. the transpose of
    the rotation matrix).

    Args:
        q: Quaternion tensor of shape ``(N, 4)`` in xyzw order.
        v: Vector tensor of shape ``(N, 3)``.

    Returns:
        Inversely-rotated vector tensor of shape ``(N, 3)``.
    """
    shape = q.shape
    q_w = q[:, -1]
    q_vec = q[:, :3]
    a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1)
    b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0
    c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0
    return a - b + c


@torch.jit.script
def quat_conjugate(a):
    """Return the conjugate of a batch of quaternions (xyzw convention).

    The conjugate negates the imaginary (xyz) part while keeping the real (w)
    part, yielding the inverse rotation for unit quaternions.

    Args:
        a: Quaternion tensor of shape ``(..., 4)`` in xyzw order.

    Returns:
        Conjugate quaternion tensor of the same shape.
    """
    shape = a.shape
    a = a.reshape(-1, 4)
    return torch.cat((-a[:, :3], a[:, -1:]), dim=-1).view(shape)


@torch.jit.script
def quat_unit(a):
    """Normalize a batch of quaternions to unit length.

    Args:
        a: Quaternion tensor of shape ``(..., 4)``.

    Returns:
        Unit-length quaternion tensor of the same shape.
    """
    return normalize(a)


@torch.jit.script
def quat_from_angle_axis(angle, axis):
    """Construct a unit quaternion from an angle-axis representation.

    Args:
        angle: Rotation angle in radians, shape ``(N,)``.
        axis: Rotation axes, shape ``(N, 3)``.  Need not be unit vectors.

    Returns:
        Unit quaternion tensor of shape ``(N, 4)`` in xyzw order.
    """
    theta = (angle / 2).unsqueeze(-1)
    xyz = normalize(axis) * theta.sin()
    w = theta.cos()
    return quat_unit(torch.cat([xyz, w], dim=-1))


@torch.jit.script
def normalize_angle(x):
    """Wrap angles into the range ``(-pi, pi]``.

    Args:
        x: Angle tensor (radians), any shape.

    Returns:
        Wrapped angle tensor of the same shape.
    """
    return torch.atan2(torch.sin(x), torch.cos(x))


@torch.jit.script
def tf_inverse(q, t):
    """Compute the inverse of a rigid transform (q, t).

    Args:
        q: Rotation quaternion, shape ``(N, 4)`` in xyzw order.
        t: Translation vector, shape ``(N, 3)``.

    Returns:
        Tuple ``(q_inv, t_inv)`` representing the inverse transform.
    """
    q_inv = quat_conjugate(q)
    return q_inv, -quat_apply(q_inv, t)


@torch.jit.script
def tf_apply(q, t, v):
    """Apply a rigid transform (q, t) to a batch of points v.

    Computes ``R(q) * v + t``.

    Args:
        q: Rotation quaternion, shape ``(N, 4)`` in xyzw order.
        t: Translation vector, shape ``(N, 3)``.
        v: Points to transform, shape ``(N, 3)``.

    Returns:
        Transformed points of shape ``(N, 3)``.
    """
    return quat_apply(q, v) + t


@torch.jit.script
def tf_vector(q, v):
    """Rotate a vector by a quaternion (no translation).

    Args:
        q: Quaternion, shape ``(..., 4)`` in xyzw order.
        v: Vector to rotate, shape ``(..., 3)``.

    Returns:
        Rotated vector of the same shape as ``v``.
    """
    return quat_apply(q, v)


@torch.jit.script
def tf_combine(q1, t1, q2, t2):
    """Compose two rigid transforms T1 followed by T2.

    Computes the combined rotation ``q1 * q2`` and the combined translation
    ``R(q1) * t2 + t1``.

    Args:
        q1: First rotation quaternion, shape ``(N, 4)`` in xyzw order.
        t1: First translation, shape ``(N, 3)``.
        q2: Second rotation quaternion, shape ``(N, 4)`` in xyzw order.
        t2: Second translation, shape ``(N, 3)``.

    Returns:
        Tuple ``(q_combined, t_combined)`` of the composed transform.
    """
    return quat_mul(q1, q2), quat_apply(q1, t2) + t1


@torch.jit.script
def get_basis_vector(q, v):
    """Rotate a basis vector ``v`` by quaternion ``q``.

    Args:
        q: Quaternion tensor, shape ``(N, 4)`` in xyzw order.
        v: Basis vector, shape ``(N, 3)``.

    Returns:
        Rotated vector of shape ``(N, 3)``.
    """
    return quat_rotate(q, v)


def get_axis_params(value, axis_idx, x_value=0.0, dtype=np.float64, n_dims=3):
    """Construct a parameter list for a USD ``Vec`` along a specific axis.

    Creates an n-dimensional vector that is ``value`` along ``axis_idx`` and
    zero everywhere else, then overrides index 0 with ``x_value``.

    Args:
        value: Scalar value to place at position ``axis_idx``.
        axis_idx: Index of the axis to set to ``value``.
        x_value: Value to assign to index 0 after the axis fill.
        dtype: NumPy dtype of the output array.
        n_dims: Total number of dimensions in the vector.

    Returns:
        List of ``n_dims`` floats suitable for passing to a USD ``Vec``
        constructor.
    """
    zs = np.zeros((n_dims,))
    assert axis_idx < n_dims, "the axis dim should be within the vector dimensions"
    zs[axis_idx] = 1.0
    params = np.where(zs == 1.0, value, zs)
    params[0] = x_value
    return list(params.astype(dtype))


@torch.jit.script
def copysign(a, b):
    # type: (float, Tensor) -> Tensor
    """Copy the sign of tensor ``b`` onto scalar ``a``.

    Returns a tensor of the same shape as ``b`` with magnitude ``|a|`` and
    sign matching each element of ``b``.

    Args:
        a: Scalar magnitude.
        b: Tensor whose signs are copied, shape ``(N,)``.

    Returns:
        Tensor of shape ``(N,)`` equal to ``|a| * sign(b)``.
    """
    a = torch.tensor(a, device=b.device, dtype=torch.float).repeat(b.shape[0])
    return torch.abs(a) * torch.sign(b)


@torch.jit.script
def get_euler_xyz(q):
    """Extract Euler XYZ angles (roll, pitch, yaw) from a batch of quaternions.

    Uses the standard ZYX intrinsic decomposition.  Handles the gimbal-lock
    singularity at |sinp| >= 1 via ``copysign``.

    Args:
        q: Quaternion tensor of shape ``(N, 4)`` in xyzw order.

    Returns:
        Tuple ``(roll, pitch, yaw)`` each of shape ``(N,)`` in radians,
        mapped to ``[0, 2*pi)``.
    """
    qx, qy, qz, qw = 0, 1, 2, 3
    # roll (x-axis rotation)
    sinr_cosp = 2.0 * (q[:, qw] * q[:, qx] + q[:, qy] * q[:, qz])
    cosr_cosp = (
        q[:, qw] * q[:, qw] - q[:, qx] * q[:, qx] - q[:, qy] * q[:, qy] + q[:, qz] * q[:, qz]
    )
    roll = torch.atan2(sinr_cosp, cosr_cosp)

    # pitch (y-axis rotation)
    sinp = 2.0 * (q[:, qw] * q[:, qy] - q[:, qz] * q[:, qx])
    pitch = torch.where(torch.abs(sinp) >= 1, copysign(np.pi / 2.0, sinp), torch.asin(sinp))

    # yaw (z-axis rotation)
    siny_cosp = 2.0 * (q[:, qw] * q[:, qz] + q[:, qx] * q[:, qy])
    cosy_cosp = (
        q[:, qw] * q[:, qw] + q[:, qx] * q[:, qx] - q[:, qy] * q[:, qy] - q[:, qz] * q[:, qz]
    )
    yaw = torch.atan2(siny_cosp, cosy_cosp)

    return roll % (2 * np.pi), pitch % (2 * np.pi), yaw % (2 * np.pi)


@torch.jit.script
def quat_from_euler_xyz(roll, pitch, yaw):
    """Construct unit quaternions from intrinsic XYZ Euler angles.

    Args:
        roll: Rotation around X axis (radians), shape ``(N,)``.
        pitch: Rotation around Y axis (radians), shape ``(N,)``.
        yaw: Rotation around Z axis (radians), shape ``(N,)``.

    Returns:
        Unit quaternion tensor of shape ``(N, 4)`` in xyzw order.
    """
    cy = torch.cos(yaw * 0.5)
    sy = torch.sin(yaw * 0.5)
    cr = torch.cos(roll * 0.5)
    sr = torch.sin(roll * 0.5)
    cp = torch.cos(pitch * 0.5)
    sp = torch.sin(pitch * 0.5)

    qw = cy * cr * cp + sy * sr * sp
    qx = cy * sr * cp - sy * cr * sp
    qy = cy * cr * sp + sy * sr * cp
    qz = sy * cr * cp - cy * sr * sp

    return torch.stack([qx, qy, qz, qw], dim=-1)


@torch.jit.script
def torch_rand_float(lower, upper, shape, device):
    # type: (float, float, Tuple[int, int], str) -> Tensor
    """Sample uniform random floats in ``[lower, upper)``.

    Args:
        lower: Lower bound of the uniform distribution.
        upper: Upper bound of the uniform distribution.
        shape: Output shape as a 2-tuple ``(rows, cols)``.
        device: Target device string (e.g. ``"cuda:0"``).

    Returns:
        Float tensor of the given shape on ``device``.
    """
    return (upper - lower) * torch.rand(*shape, device=device) + lower


@torch.jit.script
def torch_random_dir_2(shape, device):
    # type: (Tuple[int, int], str) -> Tensor
    """Sample uniformly random unit vectors in 2-D.

    Args:
        shape: Shape of the angle samples as a 2-tuple ``(rows, 1)``.
        device: Target device string.

    Returns:
        Float tensor of shape ``(rows, 2)`` containing ``(cos θ, sin θ)``
        with ``θ`` drawn uniformly from ``[-π, π)``.
    """
    angle = torch_rand_float(-np.pi, np.pi, shape, device).squeeze(-1)
    return torch.stack([torch.cos(angle), torch.sin(angle)], dim=-1)


@torch.jit.script
def tensor_clamp(t, min_t, max_t):
    """Element-wise clamp of tensor ``t`` to the range ``[min_t, max_t]``.

    Unlike ``torch.clamp`` this version accepts tensors for the bounds so that
    per-element limits are supported.

    Args:
        t: Input tensor.
        min_t: Lower bound tensor, same shape or broadcastable to ``t``.
        max_t: Upper bound tensor, same shape or broadcastable to ``t``.

    Returns:
        Clamped tensor of the same shape as ``t``.
    """
    return torch.max(torch.min(t, max_t), min_t)


@torch.jit.script
def scale(x, lower, upper):
    """Map values from ``[-1, 1]`` to ``[lower, upper]``.

    Args:
        x: Input tensor in the normalised range ``[-1, 1]``.
        lower: Target range lower bound.
        upper: Target range upper bound.

    Returns:
        Tensor scaled to ``[lower, upper]``.
    """
    return 0.5 * (x + 1.0) * (upper - lower) + lower


@torch.jit.script
def unscale(x, lower, upper):
    """Map values from ``[lower, upper]`` to ``[-1, 1]``.

    Inverse of :func:`scale`.

    Args:
        x: Input tensor in the range ``[lower, upper]``.
        lower: Source range lower bound.
        upper: Source range upper bound.

    Returns:
        Tensor normalised to ``[-1, 1]``.
    """
    return (2.0 * x - upper - lower) / (upper - lower)


def unscale_np(x, lower, upper):
    """NumPy equivalent of :func:`unscale`.

    Args:
        x: Input array in the range ``[lower, upper]``.
        lower: Source range lower bound.
        upper: Source range upper bound.

    Returns:
        Array normalised to ``[-1, 1]``.
    """
    return (2.0 * x - upper - lower) / (upper - lower)


def euler_xyz_to_gf_matrix(angles):
    """Convert Euler XYZ angles (in radians) to a USD Gf.Matrix4d.

    Args:
        angles: [roll, pitch, yaw] in radians. Can be list, numpy array, or torch.Tensor.

    Returns:
        Gf.Matrix4d with the rotation applied.
    """
    if isinstance(angles, list | np.ndarray):
        angles = torch.tensor(angles, dtype=torch.float32)
    if angles.dim() == 1:
        angles = angles.unsqueeze(0)  # [1, 3]

    roll, pitch, yaw = angles[:, 0], angles[:, 1], angles[:, 2]
    quat = quat_from_euler_xyz(roll, pitch, yaw)  # [1, 4] as [qx, qy, qz, qw]

    # Convert to Gf.Quatd (w, x, y, z order)
    qx, qy, qz, qw = quat[0].tolist()
    gf_quat = Gf.Quatd(qw, qx, qy, qz)

    m = Gf.Matrix4d()
    m.SetRotate(Gf.Rotation(gf_quat))
    return m