File size: 19,169 Bytes
c1e2af3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# 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