# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Convert kimodo motion to AMASS/SMPL-X compatible parameters (axis-angle, Y-up or Z-up)."""
import os
from typing import Optional
import einops
import numpy as np
import torch
from kimodo.assets import skeleton_asset_path
from kimodo.geometry import axis_angle_to_matrix, matrix_to_axis_angle
from kimodo.tools import ensure_batched, to_numpy, to_torch
[docs]
def kimodo_y_up_to_amass_coord_rotation_matrix() -> np.ndarray:
"""3x3 rotation mapping Kimodo Y-up (+Z forward) to AMASS Z-up (+Y forward).
Used by :func:`get_amass_parameters` and :func:`amass_arrays_to_kimodo_motion` (inverse).
"""
y_up_to_z_up = np.array(
[
[1.0, 0.0, 0.0],
[0.0, 0.0, -1.0],
[0.0, 1.0, 0.0],
],
dtype=np.float32,
)
rot_z_180 = np.array(
[
[-1.0, 0.0, 0.0],
[0.0, -1.0, 0.0],
[0.0, 0.0, 1.0],
],
dtype=np.float32,
)
return np.matmul(rot_z_180, y_up_to_z_up).astype(np.float32)
[docs]
@ensure_batched(local_rot_mats=5, root_positions=3, lengths=1)
def get_amass_parameters(
local_rot_mats,
root_positions,
skeleton,
z_up=True,
):
"""Convert local rot mats and root positions to AMASS-style trans and pose_body; optional z_up
coordinate transform.
Our method generates motions with Y-up and +Z forward; if z_up=True, transform to Z-up and +Y
forward as in AMASS.
"""
# Our method generate motions with Y-up and +Z forward
# if z_up = True, we transform this to: Z-up with +Y forward, as in AMASS
# Remove the root offset; SMPL-X FK adds pelvis offset back.
pelvis_offset = skeleton.neutral_joints[skeleton.root_idx].cpu().numpy()
trans = root_positions - pelvis_offset
root_rot_mats = to_numpy(local_rot_mats[:, :, 0])
local_rot_axis_angle = to_numpy(matrix_to_axis_angle(to_torch(local_rot_mats)))
pose_body = einops.rearrange(local_rot_axis_angle[:, :, 1:], "b t j d -> b t (j d)")
# Optionally convert from Y-up to Z-up coordinates.
if z_up:
y_up_to_z_up = kimodo_y_up_to_amass_coord_rotation_matrix()
root_rot_mats = np.matmul(y_up_to_z_up, root_rot_mats)
trans = np.matmul(trans + pelvis_offset, y_up_to_z_up.T) - pelvis_offset
root_orient = to_numpy(matrix_to_axis_angle(to_torch(root_rot_mats)))
return trans, root_orient, pose_body
[docs]
def amass_arrays_to_kimodo_motion(
trans: np.ndarray,
root_orient: np.ndarray,
pose_body: np.ndarray,
skeleton,
source_fps: float,
*,
z_up: bool = True,
):
"""Inverse of :func:`get_amass_parameters` for a single sequence (AMASS → Kimodo motion dict).
Args:
trans: ``(T, 3)`` AMASS root translation (same as ``trans`` in AMASS NPZ).
root_orient: ``(T, 3)`` axis-angle root orientation in AMASS coordinates (z-up when ``z_up``).
pose_body: ``(T, 63)`` body pose axis-angle (21 joints × 3).
skeleton: :class:`~kimodo.skeleton.definitions.SMPLXSkeleton22` instance.
source_fps: Source frame rate (Hz) of the AMASS recording.
z_up: If ``True``, invert the same Y-up↔Z-up transform as ``get_amass_parameters(..., z_up=True)``.
Returns:
Motion dict compatible with :func:`kimodo.exports.motion_io.save_kimodo_npz`.
"""
from kimodo.exports.motion_io import complete_motion_dict
trans = np.asarray(trans, dtype=np.float32)
root_orient = np.asarray(root_orient, dtype=np.float32)
pose_body = np.asarray(pose_body, dtype=np.float32)
if trans.ndim != 2 or trans.shape[-1] != 3:
raise ValueError(f"trans must be (T, 3); got {trans.shape}")
if root_orient.shape != trans.shape:
raise ValueError(f"root_orient shape {root_orient.shape} must match trans {trans.shape}")
t = trans.shape[0]
if pose_body.shape != (t, 63):
raise ValueError(f"pose_body must be (T, 63); got {pose_body.shape}")
pelvis_offset = skeleton.neutral_joints[skeleton.root_idx].detach().cpu().numpy().astype(np.float32)
device = skeleton.neutral_joints.device
dtype = torch.float32
Y_np = kimodo_y_up_to_amass_coord_rotation_matrix()
if z_up:
y_up_to_z_up = torch.from_numpy(Y_np).to(device=device, dtype=dtype)
# trans_amass = root_kimodo @ Y.T - pelvis_offset => root_kimodo = (trans_amass + pelvis_offset) @ Y
root_positions_np = (trans + pelvis_offset) @ Y_np
else:
root_positions_np = trans + pelvis_offset
root_positions = torch.from_numpy(root_positions_np).to(device=device, dtype=dtype)
R_amass_root = axis_angle_to_matrix(torch.from_numpy(root_orient).to(device=device, dtype=dtype))
if z_up:
R_kimodo_root = torch.einsum("ij,tjk->tik", y_up_to_z_up.T, R_amass_root)
else:
R_kimodo_root = R_amass_root
nb = skeleton.nbjoints
if nb != 22:
raise ValueError(f"Expected SMPL-X body skeleton with 22 joints; got {nb}")
local_rot_mats = torch.zeros((t, nb, 3, 3), device=device, dtype=dtype)
local_rot_mats[:, 0] = R_kimodo_root
pose_aa = torch.from_numpy(pose_body.reshape(t, 21, 3)).to(device=device, dtype=dtype)
local_rot_mats[:, 1:] = axis_angle_to_matrix(pose_aa.reshape(-1, 3)).reshape(t, 21, 3, 3)
return complete_motion_dict(local_rot_mats, root_positions, skeleton, source_fps)
[docs]
def amass_npz_to_kimodo_motion(npz_path: str, skeleton, source_fps: Optional[float] = None, *, z_up: bool = True):
"""Load an AMASS-style ``.npz`` and return a Kimodo motion dict.
Args:
npz_path: Path to AMASS NPZ (``trans``, ``root_orient``, ``pose_body``, ...).
skeleton: SMPL-X skeleton instance.
source_fps: Source frame rate (Hz); if ``None``, uses ``mocap_frame_rate``
from the file when present, else ``30.0``.
z_up: Same meaning as :func:`amass_arrays_to_kimodo_motion`.
"""
with np.load(npz_path, allow_pickle=True) as data:
trans = np.asarray(data["trans"], dtype=np.float32)
root_orient = np.asarray(data["root_orient"], dtype=np.float32)
pose_body = np.asarray(data["pose_body"], dtype=np.float32)
if source_fps is None:
source_fps = float(data["mocap_frame_rate"]) if "mocap_frame_rate" in data.files else 30.0
return amass_arrays_to_kimodo_motion(trans, root_orient, pose_body, skeleton, source_fps, z_up=z_up)
[docs]
class AMASSConverter:
[docs]
def __init__(
self,
fps,
skeleton,
beta_path=str(skeleton_asset_path("smplx22", "beta.npy")),
mean_hands_path=str(skeleton_asset_path("smplx22", "mean_hands.npy")),
):
self.fps = fps
self.skeleton = skeleton
# Load betas
if os.path.exists(beta_path):
# only use first 16 betas to match AMASS
betas = np.load(beta_path)[:16]
else:
betas = np.zeros(16)
# Load mean hands
if os.path.exists(mean_hands_path):
mean_hands = np.load(mean_hands_path)
else:
mean_hands = np.zeros(90)
self.default_frame_params = {
"pose_jaw": np.zeros(3),
"pose_eye": np.zeros(6),
"pose_hand": mean_hands,
}
self.output_dict_base = {
"gender": "neutral",
"surface_model_type": "smplx",
"betas": betas,
"num_betas": len(betas),
"mocap_frame_rate": float(fps),
}
[docs]
def convert_save_npz(self, output: dict, npz_path, z_up=True):
trans, root_orient, pose_body = get_amass_parameters(
output["local_rot_mats"],
output["root_positions"],
self.skeleton,
z_up=z_up,
)
nb_frames = trans.shape[-2]
amass_output_base = self.output_dict_base.copy()
for key, val in self.default_frame_params.items():
amass_output_base[key] = einops.repeat(val, "d -> t d", t=nb_frames)
amass_output_base["mocap_time_length"] = nb_frames / self.fps
self.save_npz(trans, root_orient, pose_body, amass_output_base, npz_path)
[docs]
def save_npz(self, trans, root_orient, pose_body, base_output, npz_path):
shape = trans.shape
if len(shape) == 3 and shape[0] == 1:
# if only one motion, squeeze the data
trans = trans[0]
root_orient = root_orient[0]
pose_body = pose_body[0]
shape = trans.shape
if len(shape) == 2:
amass_output = {
"trans": trans,
"root_orient": root_orient,
"pose_body": pose_body,
} | base_output
np.savez(npz_path, **amass_output)
elif len(shape) == 3:
# real batch of motions
npz_path_base, ext = os.path.splitext(npz_path)
for i in range(shape[0]):
npz_path_i = npz_path_base + "_" + str(i).zfill(2) + ext
self.save_npz(trans[i], root_orient[i], pose_body[i], base_output, npz_path_i)
# amass_output = {
# "gender": "neutral",
# "surface_model_type": "smplx",
# "mocap_frame_rate": float(fps),
# "mocap_time_length": len(motion) / float(fps)
# "trans": trans,
# "betas": betas,
# "num_betas": len(betas),
# "root_orient": np.array([T, 3]), # axis angle
# "pose_body": np.array([T, 63]), # 63=21*3, axis angle 21 = 22 - root
# "pose_hand": np.array([T, 90]), # 90=30*3=15*2*3 axis angle (load from mean_hands)
# "pose_jaw": np.array([T, 3]), # all zeros is fine
# "pose_eye": np.array([T, 6]), # all zeros is fine`
# }