cs686's picture
Deploy ARDY ZeroGPU Blender motion API
c1e2af3 verified
Raw
History Blame Contribute Delete
19.2 kB
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
from typing import Optional, Tuple
import numpy as np
import torch
from scipy.spatial.transform import Rotation
from ardy.assets import skeleton_asset_path
from ardy.data_processing.bvh import Bvh
_DEFAULT_G1_XML = str(skeleton_asset_path("g1skel34", "xml", "g1.xml"))
class Bone:
"""Abstraction for a bone in character skeleton."""
def __init__(self):
# original bone info
self.id = None
self.name = None
self.orient = np.identity(3)
self.dof_index = []
self.channels = [] # bvh only
self.lb = []
self.ub = []
self.parent = None
self.child = []
# asf specific
self.dir = np.zeros(3)
self.len = 0
# bvh specific
self.offset = np.zeros(3) # default offset for position
self.offset_rot = None # rotation for custom nv bvh
# inferred info
self.pos = np.zeros(3)
self.end = np.zeros(3)
def __repr__(self):
return f"{self.name}"
class Skeleton:
"""Abstraction for character skeleton."""
def __init__(self):
self.bones = []
self.name2bone = {}
self.mass_scale = 1.0
self.len_scale = 1.0
self.dof_name = ["x", "y", "z"]
self.root = None
def get_bones_names(self):
return [x.name for x in self.bones]
def get_parent_indices(self):
parent_indices = [-1] * len(self.bones)
for bone in self.bones:
if bone.parent:
parent_indices[bone.id] = bone.parent.id
return parent_indices
def get_neutral_joints(self):
joints = []
for bone in self.bones:
joints.append(bone.pos)
joints = np.stack(joints, axis=0)
return joints
def load_from_bvh(self, fname, exclude_bones=None, spec_channels=None):
if exclude_bones is None:
exclude_bones = {}
if spec_channels is None:
spec_channels = dict()
with open(fname) as f:
mocap = Bvh(f.read())
joint_names = list(
filter(
lambda x: all([t not in x for t in exclude_bones]),
mocap.get_joints_names(),
)
)
dof_ind = {"x": 0, "y": 1, "z": 2}
self.len_scale = 1.0
self.root = Bone()
self.root.id = 0
self.root.name = joint_names[0]
self.root.channels = mocap.joint_channels(self.root.name)
self.root.offset = np.array(mocap.joint_offset(self.root.name)) * self.len_scale
self.root.offset_rot = mocap.joint_offset_rot(self.root.name)
if self.root.offset_rot is not None:
self.root.offset_rot = np.array(self.root.offset_rot)
self.name2bone[self.root.name] = self.root
self.bones.append(self.root)
for i, joint in enumerate(joint_names[1:]):
bone = Bone()
bone.id = i + 1
bone.name = joint
bone.channels = spec_channels[joint] if joint in spec_channels.keys() else mocap.joint_channels(joint)
bone.dof_index = [dof_ind[x[0].lower()] for x in bone.channels]
bone.offset = np.array(mocap.joint_offset(joint)) * self.len_scale
bone.offset_rot = mocap.joint_offset_rot(joint)
if bone.offset_rot is not None:
bone.offset_rot = np.array(bone.offset_rot)
bone.lb = [-180.0] * 3
bone.ub = [180.0] * 3
self.bones.append(bone)
self.name2bone[joint] = bone
# for bone in self.bones:
# print(bone.name, bone.channels, bone.offset)
for bone in self.bones[1:]:
parent_name = mocap.joint_parent(bone.name).name
if parent_name in self.name2bone.keys():
bone_p = self.name2bone[parent_name]
bone_p.child.append(bone)
bone.parent = bone_p
self.forward_bvh(self.root)
for bone in self.bones:
if len(bone.child) == 0:
child_vals = [str(node) for node in mocap.get_joint(bone.name).children]
if "End Site" in child_vals:
end_site_idx = child_vals.index("End Site")
end_site_offset = mocap.get_joint(bone.name).children[end_site_idx]["OFFSET"]
bone.end = bone.pos + np.array([float(x) for x in end_site_offset]) * self.len_scale
else:
pass
else:
bone.end = sum([bone_c.pos for bone_c in bone.child]) / len(bone.child)
def load_from_xml(self, fname, exclude_bones=None, spec_channels=None):
if exclude_bones is None:
exclude_bones = {}
if spec_channels is None:
spec_channels = dict()
import xml.etree.ElementTree as ET
tree = ET.parse(fname)
root = tree.getroot()
def map_bone_name(body_name):
if body_name == "torso_link":
return "waist_pitch_skel"
elif "_link" in body_name:
return body_name.replace("_link", "_skel")
else:
return body_name + "_skel"
bodies = root.find("worldbody").findall(".//body")
name_to_body = {map_bone_name(body.get("name")): body for body in bodies}
joint_names = [body.get("name") for body in bodies]
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
def mujoco_joint_translation(body_name):
body = name_to_body[body_name]
if "pos" in body.attrib:
result = np.array([100 * float(x) for x in body.get("pos").strip().split(" ")])
return mujoco_to_ardy.apply(result)
else:
return np.zeros(3)
parent_map = {
map_bone_name(child.get("name")): map_bone_name(parent.get("name"))
for parent in root.iter()
if parent.get("name")
for child in parent
if child.get("name")
}
self.len_scale = 1.0
self.root = Bone()
self.root.id = 0
self.root.name = map_bone_name(joint_names[0])
self.root.offset = mujoco_joint_translation(self.root.name)
self.root.offset_rot = np.eye(3)
self.name2bone[self.root.name] = self.root
self.bones.append(self.root)
for i, joint in enumerate(joint_names[1:]):
bone = Bone()
bone.id = i + 1
bone.name = map_bone_name(joint)
bone.offset = mujoco_joint_translation(bone.name)
bone.offset_rot = np.eye(3)
self.bones.append(bone)
self.name2bone[bone.name] = bone
for bone in self.bones[1:]:
parent_name = parent_map[bone.name]
if parent_name in self.name2bone.keys():
bone_p = self.name2bone[parent_name]
bone_p.child.append(bone)
bone.parent = bone_p
self.forward_bvh(self.root)
def forward_bvh(self, bone):
if bone.parent:
bone.pos = bone.parent.pos + bone.offset
else:
bone.pos = bone.offset
for bone_c in bone.child:
self.forward_bvh(bone_c)
class SkeletonG1(Skeleton):
"""Add functionality specific to the G1 including loading from XML specific to G1 and adding
dummy joints."""
def load_from_xml(self, fname, exclude_bones=None, spec_channels=None):
if exclude_bones is None:
exclude_bones = {}
if spec_channels is None:
spec_channels = dict()
import xml.etree.ElementTree as ET
tree = ET.parse(fname)
root = tree.getroot()
def map_bone_name(body_name):
if body_name == "torso_link":
return "waist_pitch_skel"
elif "_link" in body_name:
return body_name.replace("_link", "_skel")
else:
return body_name + "_skel"
bodies = root.find("worldbody").findall(".//body")
name_to_body = {map_bone_name(body.get("name")): body for body in bodies}
joint_names = [body.get("name") for body in bodies]
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
def mujoco_joint_translation(body_name):
body = name_to_body[body_name]
if "pos" in body.attrib:
result = np.array([100 * float(x) for x in body.get("pos").strip().split(" ")])
return mujoco_to_ardy.apply(result)
else:
return np.zeros(3)
parent_map = {
map_bone_name(child.get("name")): map_bone_name(parent.get("name"))
for parent in root.iter()
if parent.get("name")
for child in parent
if child.get("name")
}
self.len_scale = 1.0
self.root = Bone()
self.root.id = 0
self.root.name = map_bone_name(joint_names[0])
self.root.offset = mujoco_joint_translation(self.root.name)
self.root.offset_rot = np.eye(3)
self.name2bone[self.root.name] = self.root
self.bones.append(self.root)
for i, joint in enumerate(joint_names[1:]):
bone = Bone()
bone.id = i + 1
bone.name = map_bone_name(joint)
bone.offset = mujoco_joint_translation(bone.name)
bone.offset_rot = np.eye(3)
self.bones.append(bone)
self.name2bone[bone.name] = bone
for bone in self.bones[1:]:
parent_name = parent_map[bone.name]
if parent_name in self.name2bone.keys():
bone_p = self.name2bone[parent_name]
bone_p.child.append(bone)
bone.parent = bone_p
self.forward_bvh(self.root)
def add_joint(self, parent_name, bone_name, offset):
new_bone = Bone()
new_bone.name = bone_name
new_bone.parent = next((x for x in self.bones if x.name == parent_name))
new_bone.parent.child.append(new_bone)
new_bone.offset = offset
new_bone.offset_rot = np.eye(3)
parent_index = next((i for i, x in enumerate(self.bones) if x.name == parent_name))
self.bones.insert(parent_index + 1, new_bone)
self.name2bone[bone_name] = new_bone
def load_bvh_animation(
fname: str,
skeleton: Skeleton,
rot_order: Optional[str] = "native",
backend: Optional[str] = "np",
return_quat: Optional[bool] = False,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Given a bvh file for one motion sequence, and Skeleton object containing the neutral
skeleton, return root translation and joint rotation matrics for the motion sequence.
Args:
fname (str): full path of the bvh file
skeleton (Skeleton): Skeleton object initialized by the bvh file, containing the neutral skeleton
rot_order (str): one of "native", "ZXY", "YXZ", ....
return_quat (bool): if True, return joint rotation quaternions instead of rotation matrices
Returns:
torch.Tensor: [T, 3] root translations
torch.Tensor: [T, J, 3, 3] joint rotation matrix
"""
with open(fname) as f:
mocap = Bvh(f.read(), backend=backend)
# assume all joints are same ordering, load in with native ordering
root_channels = mocap.joint_channels(skeleton.root.name)
pos_channels = [channel for channel in root_channels if channel.endswith("position")]
rot_channels = [channel for channel in root_channels if channel.endswith("rotation")]
root_trans = np.array(mocap.frames_joint_channels(skeleton.root.name, pos_channels))
if backend == "np":
# NOTE: assumes rot channel ordering is the same for all joints
joint_eulers = mocap.frames_joints_channels(skeleton.get_bones_names(), rot_channels)
joint_eulers = np.deg2rad(joint_eulers)
elif backend == "graph":
joint_eulers = []
for bone in skeleton.bones:
bone_channels = mocap.joint_channels(bone.name)
bone_rot_channels = [channel for channel in bone_channels if channel.endswith("rotation")]
assert bone_rot_channels == rot_channels, "Rotation channel ordering is not consistent across joints!"
# use native rotation order
euler = np.deg2rad(np.array(mocap.frames_joint_channels(bone.name, rot_channels)))
joint_eulers.append(euler)
joint_eulers = np.stack(joint_eulers, axis=1)
else:
raise ValueError(f"Unknown backend for BVH loading: {backend}")
if rot_order == "native":
rot_order = ""
for axis in rot_channels:
rot_order += axis[0]
else:
# need to reorder dims
ordered_joint_eulers = []
for axis in rot_order:
i = rot_channels.index(axis + "rotation")
ordered_joint_eulers.append(joint_eulers[..., i])
joint_eulers = np.stack(ordered_joint_eulers, axis=-1)
rotations = Rotation.from_euler(rot_order, joint_eulers.reshape(-1, 3))
if return_quat:
joint_rots = rotations.as_quat(scalar_first=True).reshape(joint_eulers.shape[:-1] + (4,))
else:
joint_rots = rotations.as_matrix().reshape(joint_eulers.shape[:-1] + (3, 3))
return root_trans, joint_rots
def load_csv_g1_animation(
fname: str,
skeleton: Skeleton,
csv_format: str = "retargeted_from_gmr",
xml_path: str = _DEFAULT_G1_XML,
) -> Tuple[torch.Tensor, torch.Tensor]:
"""Load a csv file that's in the format of mujoco qpos (32 joints, dof 29) return root
translation and joint rotation matrics for the motion sequence.
Args:
fname (str): full path of the csv file
skeleton (Skeleton): Skeleton object initialized by the bvh file, containing the neutral skeleton
csv_format (str): format of the csv file ["retargeted_from_gmr", "retargeted_from_unitree"]
xml_path (str): full path of the xml file for the skeleton
Returns:
torch.Tensor: [T, 3] root translations
torch.Tensor: [T, J, 3, 3] joint rotation matrix
"""
assert csv_format in ["retargeted_from_gmr", "retargeted_from_unitree"]
import xml.etree.ElementTree as ET
with open(fname) as f:
csv_raw_data = f.readlines()
if csv_format == "retargeted_from_gmr":
csv_raw_data = np.array(
[np.array([float(i) for i in row.split(",")[1:]]) for row in csv_raw_data[1:]]
) # drop the title row, and the frame column (first column)
root_trans = csv_raw_data[:, :3] # cm since it will be converted back to m outside this func
xyz = csv_raw_data[:, 3:6]
root_rot = Rotation.from_euler("xyz", xyz, degrees=True)
elif csv_format == "retargeted_from_unitree":
csv_raw_data = np.array([np.array([float(i) for i in row.split(",")]) for row in csv_raw_data])
root_trans = (
np.array(csv_raw_data[:, :3]) * 100
) # m -> cm since it will be converted back to m outside this func
root_rot = Rotation.from_quat(csv_raw_data[:, 3:7], scalar_first=False) # [x, y, z, w] is the order in csv file
# there are two transformations from the mujoco coordinate to the ardy coordinate (both are right hand system):
# 1) the mujoco is x facing, and ardy is y facing in the mujoco coordinate (or z facing in the ardy system).
# 2) the source csv uses z up, and the ardy uses y up;
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
# for root transform, we do the similar transformation for the root rotations.
root_trans = mujoco_to_ardy.apply(root_trans)
root_rot = mujoco_to_ardy * root_rot * mujoco_to_ardy.inv()
# get the joint rotations from the csv file
rotations = Rotation.identity(csv_raw_data.shape[0] * len(skeleton.bones))
joint_rots = rotations.as_matrix().reshape(csv_raw_data.shape[0], len(skeleton.bones), 3, 3)
joint_rots[:, 0] = root_rot.as_matrix() # the rotation of pelvis
# load the mujoco xml file for joint axis; find all joint, get name and axis
tree = ET.parse(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")
parent_map = {child: parent for parent in root.iter() for child in parent}
for joint_id_in_csv, joint in enumerate(root.find("worldbody").findall(".//joint")): # skip the base joint
# the order in the xml file is the order of the data shows up; note that in the xml file, the joint ends with
# "_joint", but the skeleton.get_bones_names() joints end with "_skel"
assert joint.get("name").endswith("_joint")
joint_name_in_skeleton = joint.get("name").replace("_joint", "_skel")
assert joint_name_in_skeleton in skeleton.get_bones_names()
axis_values = [float(x) for x in (joint.get("axis") or joint_axes[joint.get("class")]).split(" ")]
assert sum(axis_values) == 1 and all(x in [0, 1] for x in axis_values), (
f"Invalid axis: {axis_values}: one and only one of the axis values should be 1; others should be 0s."
)
# the mapped axis in the ardy's g1 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]
axis_in_ardy = ["x", "y", "z"][np.argmax(axis_values)]
if csv_format == "retargeted_from_gmr":
joint_dof = csv_raw_data[:, joint_id_in_csv + 6] * np.pi / 180
elif csv_format == "retargeted_from_unitree":
joint_dof = csv_raw_data[:, joint_id_in_csv + 7]
else:
raise ValueError(f"Unknown csv format: {csv_format}")
# Single-axis sequence needs angles of shape (N, 1) under SciPy's array-API
# from_euler, which reads the last axis as the axis count (not a bare (N,)).
joint_rot = Rotation.from_euler(axis_in_ardy, joint_dof[:, None], degrees=False)
body = parent_map[joint]
if "quat" in body.attrib:
joint_extra_rot = Rotation.from_quat(
[float(x) for x in body.get("quat").strip().split(" ")],
scalar_first=True,
)
joint_rot = joint_extra_rot * joint_rot
joint_rots[:, skeleton.get_bones_names().index(joint_name_in_skeleton)] = (
mujoco_to_ardy * joint_rot * mujoco_to_ardy.inv()
).as_matrix()
return root_trans, joint_rots