Spaces:
Running on Zero
Running on Zero
File size: 7,908 Bytes
6d5047c | 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 | # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Motion representation helpers: velocity, heading, masks, and rotation of features."""
from typing import List, Optional, Union
import einops
import torch
from kimodo.geometry import cont6d_to_matrix, matrix_to_cont6d
from kimodo.skeleton import SkeletonBase
from kimodo.tools import ensure_batched
def diff_angles(angles: torch.Tensor, fps: float) -> torch.Tensor:
"""Compute frame-to-frame angular differences in radians, scaled by fps.
Args:
angles: [..., T] batched sequences of rotation angles in radians.
fps: Sampling rate used to convert frame differences to per-second rate.
Returns:
[..., T-1] difference between consecutive angles (rad/s).
"""
cos = torch.cos(angles)
sin = torch.sin(angles)
cos_diff = cos[..., 1:] * cos[..., :-1] + sin[..., 1:] * sin[..., :-1]
sin_diff = sin[..., 1:] * cos[..., :-1] - cos[..., 1:] * sin[..., :-1]
# should be close to angles.diff() but more robust
# multiply by fps = 1 / dt
angles_diff = fps * torch.arctan2(sin_diff, cos_diff)
return angles_diff
@ensure_batched(positions=4, lengths=1)
def compute_vel_xyz(
positions: torch.Tensor,
fps: float,
lengths: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Compute the velocities from positions: dx/dt. Works with batches. The last velocity is duplicated to keep the same size.
Args:
positions (torch.Tensor): [..., T, J, 3] xyz positions of a human skeleton
fps (float): frame per seconds
lengths (Optional[torch.Tensor]): [...] size of each input batched. If not provided, positions should not be batched
Returns:
velocity (torch.Tensor): [..., T, J, 3] velocities computed from the positions
"""
device = positions.device
if lengths is None:
assert positions.shape[0] == 1, "If lengths is not provided, the input should not be batched."
lengths = torch.tensor([len(positions)], device=device)
# useful for indexing
range_len = torch.arange(len(lengths))
# compute velocities with fps
velocity = fps * (positions[:, 1:] - positions[:, :-1])
# pading the velocity vector
vel_pad = torch.zeros_like(velocity[:, 0])
velocity, _ = einops.pack([velocity, vel_pad], "batch * nbjoints dim")
# repeat the last velocities
# with special care for different lengths with batches
velocity[(range_len, lengths - 1)] = velocity[(range_len, lengths - 2)]
return velocity
@ensure_batched(root_rot_angles=2, lengths=1)
def compute_vel_angle(
root_rot_angles: torch.Tensor,
fps: float,
lengths: Optional[torch.Tensor] = None,
) -> torch.Tensor:
"""Compute the local root rotation velocity: dtheta/dt.
Args:
root_rot_angles (torch.Tensor): [..., T] rotation angle (in radian)
fps (float): frame per seconds
lengths (Optional[torch.Tensor]): [...] size of each input batched. If not provided, root_rot_angles should not be batched
Returns:
local_root_rot_vel (torch.Tensor): [..., T] local root rotation velocity (in radian/s)
"""
device = root_rot_angles.device
if lengths is None:
assert root_rot_angles.shape[0] == 1, "If lengths is not provided, the input should not be batched."
lengths = torch.tensor([len(root_rot_angles)], device=device)
# useful for indexing
range_len = torch.arange(len(lengths))
local_root_rot_vel = diff_angles(root_rot_angles, fps)
pad_rot_vel_angles = torch.zeros_like(root_rot_angles[:, 0])
local_root_rot_vel, _ = einops.pack(
[local_root_rot_vel, pad_rot_vel_angles],
"batch *",
)
# repeat the last rotation angle
# with special care for different lengths with batches
local_root_rot_vel[(range_len, lengths - 1)] = local_root_rot_vel[(range_len, lengths - 2)]
return local_root_rot_vel
@ensure_batched(posed_joints=4)
def compute_heading_angle(posed_joints: torch.Tensor, skeleton: SkeletonBase) -> torch.Tensor:
"""Compute the heading direction from joint positions using the hip vector.
Args:
posed_joints: [B, T, J, 3] global joint positions.
skeleton: Skeleton instance used to get hip joint indices.
Returns:
[B] heading angle in radians.
"""
# compute root heading for the sequence from hip positions
r_hip, l_hip = skeleton.hip_joint_idx
diff = posed_joints[:, :, r_hip] - posed_joints[:, :, l_hip]
heading_angle = torch.atan2(diff[..., 2], -diff[..., 0])
return heading_angle
def length_to_mask(
length: Union[torch.Tensor, List],
max_len: Optional[int] = None,
device=None,
) -> torch.Tensor:
"""Convert sequence lengths to a boolean validity mask.
Args:
length: Sequence lengths, either a tensor ``[B]`` or a Python list.
max_len: Optional mask width. If omitted, uses ``max(length)``.
device: Optional device. When ``length`` is a list, this controls where
the new tensor is created.
Returns:
A boolean tensor of shape ``[B, max_len]`` where ``True`` marks valid
timesteps.
"""
if isinstance(length, list):
if device is None:
device = "cpu"
length = torch.tensor(length, device=device)
# Use requested device for output; move length if needed so mask and length match
if device is not None:
target = torch.device(device)
if length.device != target:
length = length.to(target)
device = length.device
if max_len is None:
max_len = max(length)
mask = torch.arange(max_len, device=device).expand(len(length), max_len) < length.unsqueeze(1)
return mask
class RotateFeatures:
"""Helper that applies a global heading rotation to motion features."""
def __init__(self, angle: torch.Tensor):
"""Precompute 2D and 3D rotation matrices for a batch of angles.
Args:
angle: Rotation angle(s) in radians, shaped ``[B]``.
"""
self.angle = angle
## Create the necessary rotations matrices
cos, sin = torch.cos(angle), torch.sin(angle)
one, zero = torch.ones_like(angle), torch.zeros_like(angle)
# 2D rotation transposed (sin are -sin)
self.corrective_mat_2d_T = torch.stack((cos, sin, -sin, cos), -1).reshape(angle.shape + (2, 2))
# 3D rotation on Y axis
self.corrective_mat_Y = torch.stack((cos, zero, sin, zero, one, zero, -sin, zero, cos), -1).reshape(
angle.shape + (3, 3)
)
self.corrective_mat_Y_T = self.corrective_mat_Y.transpose(1, 2).contiguous()
def rotate_positions(self, positions: torch.Tensor):
"""Rotate 3D positions around the Y axis."""
return positions @ self.corrective_mat_Y_T
def rotate_2d_positions(self, positions_2d: torch.Tensor):
"""Rotate 2D ``(x, z)`` vectors in the ground plane."""
return positions_2d @ self.corrective_mat_2d_T
def rotate_rotations(self, rotations: torch.Tensor):
"""Left-multiply global rotation matrices by the heading correction."""
# "Rotate" the global rotations
# which means add an extra Y rotation after the transform
# so at the left R' = R_y R
# (since we use the convention x' = R x)
# "bik,btdkj->btdij"
B, T, J = rotations.shape[:3]
BTJ = B * T * J
return (
self.corrective_mat_Y[:, None, None].expand(B, T, J, 3, 3).reshape(BTJ, 3, 3) @ rotations.reshape(BTJ, 3, 3)
).reshape(B, T, J, 3, 3)
def rotate_6d_rotations(self, rotations_6d: torch.Tensor):
"""Rotate 6D rotation features via matrix conversion."""
return matrix_to_cont6d(self.rotate_rotations(cont6d_to_matrix(rotations_6d)))
|