cs686's picture
Deploy ARDY ZeroGPU Blender motion API
c1e2af3 verified
Raw
History Blame Contribute Delete
11.8 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
import os
import xml.etree.ElementTree as ET
from typing import Optional
import numpy as np
import torch
from scipy.spatial.transform import Rotation
from ardy.assets import skeleton_asset_path
from ardy.geometry import matrix_to_quaternion
from ardy.skeleton import SkeletonBase
from ardy.tools import ensure_batched, to_numpy, to_torch
# Default G1 mujoco XML ships in the ardy package skeleton assets.
_DEFAULT_G1_XML = str(skeleton_asset_path("g1skel34", "xml", "g1.xml"))
class MujocoQposConverter(torch.nn.Module):
"""Fast batch converter from our dictionary format to mujoco qpos with precomputed transforms.
In mujoco, the coordination is z up and x forward, right handed
features (30 joints):
root (pelvis, 7 = translation + rotation) + 29 dof joints (29)
In ardy, the coordinate system is y up and z forward, right handed
features (34 joints):
root (pelvis) + (34 - 1) joints; among these joints, 4 are end-effector joints added by ardy.
"""
def __init__(
self,
input_skeleton: SkeletonBase,
xml_path: str = _DEFAULT_G1_XML,
dead_joint_rotation_scheme: str = "dummy",
):
"""Initialize converter with precomputed transforms.
Args:
xml_path: Path to the mujoco XML file containing joint definitions
dead_joint_rotation_scheme: Scheme for handling dead joints (end-effectors joints);
if "dummy", the dead joints's global rotations are set to identity matrix;
if "parent", the dead joints's global rotations are set to the parent's rotation.
"""
super().__init__()
self.xml_path = xml_path
self.skeleton = input_skeleton
self._prepare_transforms()
self._subtree_joints = {}
self._dead_joint_rotation_scheme = dead_joint_rotation_scheme
def _prepare_transforms(self):
"""Precompute all necessary transforms for efficient batch processing."""
# Define coordinate transformations between mujoco and ardy space
# 1) R_zup_to_yup: rotation around x-axis by -90 degrees
# 2) x_forward_to_y_forward: rotation around z-axis by -90 degrees
# Combined transformation matrix: mujoco_to_ardy = R_zup_to_yup * x_forward_to_y_forward
self.mujoco_to_ardy_matrix = torch.tensor(
[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], dtype=torch.float32
)
self.ardy_to_mujoco_matrix = self.mujoco_to_ardy_matrix.T # Inverse transformation: ardy_to_mujoco
# Parse XML once and extract joint information
tree = ET.parse(self.xml_path)
root = tree.getroot()
xml_classes = [x for x in tree.findall(".//default") if "class" in x.attrib]
joint_axes = dict()
for xml_class in xml_classes:
j = xml_class.findall("joint")
if j:
joint_axes[xml_class.get("class")] = j[0].get("axis")
mujoco_hinge_joints = root.find("worldbody").findall(".//joint") # skip the base joint
self._mujoco_joint_axis_values_ardy_space = torch.zeros(
(len(mujoco_hinge_joints), 3), dtype=torch.float32
) # mujoco order but ardy space
self._mujoco_joint_axis_values_mujoco_space = torch.zeros(
(len(mujoco_hinge_joints), 3), dtype=torch.float32
) # mujoco order but mujoco space
# for the below indices, mujoco_indices_to_ardy_indices does not include mujoco root (30 - 1 = 29 elements),
# while ardy_indices_to_mujoco_indices inclues the ardy root (32 elements).
self._mujoco_indices_to_ardy_indices = torch.zeros((len(mujoco_hinge_joints),), dtype=torch.int32)
self._ardy_indices_to_mujoco_indices = (
torch.ones((self.skeleton.nbjoints,), dtype=torch.int32) * -1
) # -1 means not in the csv skeleton
self._nb_joints_mujoco = len(mujoco_hinge_joints) + 1
self._nb_joints_ardy = self.skeleton.nbjoints
self._mujoco_joint_including_root_parent_list = torch.full(
(len(mujoco_hinge_joints) + 1,), -1, dtype=torch.int32
)
self._mujoco_joint_including_root_list = ["pelvis_skel"]
for joint_id_in_csv, joint in enumerate(mujoco_hinge_joints):
joint_name_in_skeleton = joint.get("name").replace("_joint", "_skel")
joint_parent_name_in_skeleton = self.skeleton.bone_parents[joint_name_in_skeleton]
self._mujoco_joint_including_root_list.append(joint_name_in_skeleton)
self._mujoco_joint_including_root_parent_list[joint_id_in_csv + 1] = (
self._mujoco_joint_including_root_list.index(joint_parent_name_in_skeleton)
)
joint_idx_in_ardy_skeleton = self.skeleton.bone_order_names.index(joint_name_in_skeleton)
axis_values = [float(x) for x in (joint.get("axis") or joint_axes[joint.get("class")]).split(" ")]
# the mapped axis in ardy skeleton space is calculated as bones_axis = mujoco_to_ardy.apply(axis_values)
# [1, 0, 0] -> [0, 0, 1]; [0, 1, 0] -> [1, 0, 0]; [0, 0, 1] -> [0, 1, 0]
mujoco_joint_axis_mapping_ardy_space = [
torch.tensor([0, 0, 1]),
torch.tensor([1, 0, 0]),
torch.tensor([0, 1, 0]),
][np.argmax(axis_values)]
self._mujoco_joint_axis_values_ardy_space[joint_id_in_csv] = mujoco_joint_axis_mapping_ardy_space
self._mujoco_joint_axis_values_mujoco_space[joint_id_in_csv] = torch.tensor(axis_values)
self._mujoco_indices_to_ardy_indices[joint_id_in_csv] = joint_idx_in_ardy_skeleton
self._ardy_indices_to_mujoco_indices[joint_idx_in_ardy_skeleton] = joint_id_in_csv + 1 # +1 for the root
self._ardy_indices_to_mujoco_indices[0] = 0 # the root joint mapping
# load the offset matrices from the xml
R_zup_to_yup = Rotation.from_euler("x", -90, degrees=True)
x_forward_to_y_forward = Rotation.from_euler("z", -90, degrees=True)
mujoco_to_ardy = R_zup_to_yup * x_forward_to_y_forward
self._rot_offsets_q2t = torch.zeros(len(self._ardy_indices_to_mujoco_indices), 3, 3, dtype=torch.float32)
self._rot_offsets_q2t[...] = torch.eye(3)[None]
self._rot_offsets_f2q = torch.zeros(len(self._ardy_indices_to_mujoco_indices), 3, 3, dtype=torch.float32)
self._rot_offsets_f2q[...] = torch.eye(3)[None]
parent_map = {child: parent for parent in root.iter() for child in parent}
for i, joint in enumerate(mujoco_hinge_joints):
body = parent_map[joint]
if "quat" in body.attrib:
rot = Rotation.from_quat(
[float(x) for x in body.get("quat").strip().split(" ")],
scalar_first=True,
)
idx = self._mujoco_indices_to_ardy_indices[i]
self._rot_offsets_q2t[idx] = torch.from_numpy(rot.as_matrix())
rot = mujoco_to_ardy * rot * mujoco_to_ardy.inv()
self._rot_offsets_f2q[idx] = torch.from_numpy(rot.as_matrix().T)
def dict_to_qpos(
self,
output: dict,
device: Optional[str] = None,
root_quat_w_first: bool = True,
numpy: bool = True,
):
local_rot_mats = to_torch(output["local_rot_mats"], device)
root_positions = to_torch(output["root_positions"], device)
qpos = self.to_qpos(
local_rot_mats,
root_positions,
root_quat_w_first=root_quat_w_first,
)
if numpy:
qpos = to_numpy(qpos)
return qpos
def save_csv(self, qpos: torch.Tensor | np.ndarray, csv_path):
# comment this
qpos = to_numpy(qpos)
shape = qpos.shape
if len(shape) == 2:
# only one motion: save it
np.savetxt(csv_path, qpos, delimiter=",")
if len(shape) == 3:
# batch of motions
if shape[0] == 1:
# if only one motion, just save it
np.savetxt(csv_path, qpos[0], delimiter=",")
else:
csv_path_base, ext = os.path.splitext(csv_path)
for i in range(shape[0]):
self.save_csv(qpos[i], csv_path_base + "_" + str(i).zfill(2) + ext)
@ensure_batched(local_rot_mats=5, root_positions=3, lengths=1)
def to_qpos(
self,
local_rot_mats: torch.Tensor,
root_positions: torch.Tensor,
root_quat_w_first: bool = True,
) -> torch.Tensor:
"""Fast batch conversion from ARDY features to mujoco qpos format.
Args:
local_rot_mats (torch.Tensor): [batch, numFrames, numJoints, 3, 3]
local joint rotation matrices in ARDY coordinates
root_positions (torch.Tensor): [batch, numFrames, 3] root joint
positions in ARDY coordinates
root_quat_w_first (bool): store the root quaternion as [w, x, y, z]
(mujoco convention) instead of [x, y, z, w]
Returns:
torch.Tensor of shape [batch, numFrames, 36] containing mujoco qpos data:
- root_trans (3) + root_quat (4) + joint_dofs (29) = 36 columns
"""
batch_size, num_frames, nb_joints = local_rot_mats.shape[:3]
device, dtype = local_rot_mats.device, local_rot_mats.dtype
local_rot_mats = torch.matmul(self._rot_offsets_f2q.to(device), local_rot_mats)
batch_size, num_frames = root_positions.shape[0], root_positions.shape[1]
# Move precomputed matrices to the same device/dtype
ardy_to_mujoco_matrix = self.ardy_to_mujoco_matrix.to(device=device, dtype=dtype)
# Initialize output tensor: [batch, numFrames, 36]
qpos = torch.zeros((batch_size, num_frames, 36), dtype=dtype, device=device)
# Convert root translation: apply coordinate transformation
root_positions_mujoco = torch.matmul(ardy_to_mujoco_matrix[None, None, ...], root_positions[..., None])
qpos[:, :, :3] = root_positions_mujoco.view(batch_size, num_frames, 3)
# Convert root rotation: apply coordinate transformation to rotation matrix
root_rot = local_rot_mats[:, :, 0, :] # [batch, numFrames, 3, 3]
# Apply coordinate transformation: R_mujoco = ardy_to_mujoco * R_ardy * ardy_to_mujoco^T
mujoco_to_ardy_matrix = ardy_to_mujoco_matrix.T
root_rot_mujoco = torch.matmul(
torch.matmul(ardy_to_mujoco_matrix[None, None, ...], root_rot),
mujoco_to_ardy_matrix[None, None, ...],
)
root_rot_quat = matrix_to_quaternion(root_rot_mujoco) # [w, x, y, z]
if root_quat_w_first:
qpos[:, :, 3:7] = root_rot_quat[:, :, [0, 1, 2, 3]] # [w, x, y, z]
else:
qpos[:, :, 3:7] = root_rot_quat[:, :, [1, 2, 3, 0]] # [w, x, y, z] -> [x, y, z, w]
# Convert joint DOFs using precomputed mappings
joint_rot_mujoco = local_rot_mats[
:, :, self._mujoco_indices_to_ardy_indices, :
] # mujoco joint order but ardy feature space
x_joint_dof = torch.atan2(joint_rot_mujoco[..., 2, 1], joint_rot_mujoco[..., 2, 2])
y_joint_dof = torch.atan2(joint_rot_mujoco[..., 0, 2], joint_rot_mujoco[..., 0, 0])
z_joint_dof = torch.atan2(joint_rot_mujoco[..., 1, 0], joint_rot_mujoco[..., 1, 1])
xyz_joint_dofs = torch.stack([x_joint_dof, y_joint_dof, z_joint_dof], dim=-1)
joint_dofs = (xyz_joint_dofs * self._mujoco_joint_axis_values_ardy_space[None, None, :, :].to(device)).sum(
dim=-1
)
qpos[:, :, 7:] = joint_dofs
return qpos