Spaces:
Runtime error
Runtime error
Deploy Kimodo ZeroGPU motion API (part 2)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- kimodo/assets.py +19 -0
- kimodo/assets/skeletons/g1skel34/meshes/g1/waist_yaw_link_rev_1_0.STL +3 -0
- kimodo/assets/skeletons/g1skel34/rest_pose_local_rot.p +0 -0
- kimodo/assets/skeletons/g1skel34/xml/g1.xml +413 -0
- kimodo/assets/skeletons/smplx22/beta.npy +3 -0
- kimodo/assets/skeletons/smplx22/joints.p +0 -0
- kimodo/assets/skeletons/smplx22/mean_hands.npy +3 -0
- kimodo/assets/skeletons/somaskel30/joints.p +0 -0
- kimodo/assets/skeletons/somaskel30/soma_base_fit_mhr_params.npz +3 -0
- kimodo/assets/skeletons/somaskel77/bvh_joints.p +0 -0
- kimodo/assets/skeletons/somaskel77/joints.p +0 -0
- kimodo/assets/skeletons/somaskel77/relaxed_hands_rest_pose.npy +3 -0
- kimodo/assets/skeletons/somaskel77/skin_standard.npz +3 -0
- kimodo/assets/skeletons/somaskel77/somaskel77_standard_tpose.bvh +395 -0
- kimodo/assets/skeletons/somaskel77/standard_t_pose_global_offsets_rots.p +0 -0
- kimodo/constraints.py +625 -0
- kimodo/demo/__init__.py +29 -0
- kimodo/demo/__main__.py +8 -0
- kimodo/demo/app.py +690 -0
- kimodo/demo/config.py +163 -0
- kimodo/demo/embedding_cache.py +253 -0
- kimodo/demo/generation.py +218 -0
- kimodo/demo/queue_manager.py +336 -0
- kimodo/demo/state.py +59 -0
- kimodo/demo/ui.py +0 -0
- kimodo/exports/__init__.py +65 -0
- kimodo/exports/bvh.py +298 -0
- kimodo/exports/motion_convert_lib.py +159 -0
- kimodo/exports/motion_formats.py +78 -0
- kimodo/exports/motion_io.py +443 -0
- kimodo/exports/mujoco.py +588 -0
- kimodo/exports/smplx.py +251 -0
- kimodo/geometry.py +216 -0
- kimodo/meta.py +80 -0
- kimodo/metrics/__init__.py +39 -0
- kimodo/metrics/base.py +66 -0
- kimodo/metrics/constraints.py +87 -0
- kimodo/metrics/foot_skate.py +253 -0
- kimodo/metrics/tmr.py +545 -0
- kimodo/model/__init__.py +31 -0
- kimodo/model/backbone.py +312 -0
- kimodo/model/cfg.py +133 -0
- kimodo/model/common.py +48 -0
- kimodo/model/diffusion.py +133 -0
- kimodo/model/kimodo_model.py +634 -0
- kimodo/model/llm2vec/README.md +1 -0
- kimodo/model/llm2vec/__init__.py +11 -0
- kimodo/model/llm2vec/llm2vec.py +477 -0
- kimodo/model/llm2vec/llm2vec_wrapper.py +95 -0
.gitattributes
CHANGED
|
@@ -101,3 +101,4 @@ kimodo/assets/skeletons/g1skel34/meshes/g1/waist_constraint_L.STL filter=lfs dif
|
|
| 101 |
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_constraint_R.STL filter=lfs diff=lfs merge=lfs -text
|
| 102 |
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_support_link.STL filter=lfs diff=lfs merge=lfs -text
|
| 103 |
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_yaw_link.STL filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 101 |
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_constraint_R.STL filter=lfs diff=lfs merge=lfs -text
|
| 102 |
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_support_link.STL filter=lfs diff=lfs merge=lfs -text
|
| 103 |
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_yaw_link.STL filter=lfs diff=lfs merge=lfs -text
|
| 104 |
+
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_yaw_link_rev_1_0.STL filter=lfs diff=lfs merge=lfs -text
|
kimodo/assets.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
PACKAGE_ROOT = Path(__file__).resolve().parent
|
| 7 |
+
ASSETS_ROOT = PACKAGE_ROOT / "assets"
|
| 8 |
+
DEMO_ASSETS_ROOT = ASSETS_ROOT / "demo"
|
| 9 |
+
DEMO_EXAMPLES_ROOT = DEMO_ASSETS_ROOT / "examples"
|
| 10 |
+
SKELETONS_ROOT = ASSETS_ROOT / "skeletons"
|
| 11 |
+
SOMA_ASSETS_ROOT = ASSETS_ROOT / "SOMA"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def skeleton_asset_path(*parts: str) -> Path:
|
| 15 |
+
return SKELETONS_ROOT.joinpath(*parts)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def demo_asset_path(*parts: str) -> Path:
|
| 19 |
+
return DEMO_ASSETS_ROOT.joinpath(*parts)
|
kimodo/assets/skeletons/g1skel34/meshes/g1/waist_yaw_link_rev_1_0.STL
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ec6db442b11f25eed898b5add07940c85d804f300de24dcbd264ccd8be7d554c
|
| 3 |
+
size 619984
|
kimodo/assets/skeletons/g1skel34/rest_pose_local_rot.p
ADDED
|
Binary file (2.88 kB). View file
|
|
|
kimodo/assets/skeletons/g1skel34/xml/g1.xml
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<mujoco model="g1">
|
| 2 |
+
<compiler angle="radian" meshdir="../meshes/g1"/>
|
| 3 |
+
|
| 4 |
+
<default>
|
| 5 |
+
<default class="g1">
|
| 6 |
+
<geom contype="0" conaffinity="0"/>
|
| 7 |
+
|
| 8 |
+
<joint frictionloss="0.1" solimplimit="0.97 0.995 0.001"/>
|
| 9 |
+
|
| 10 |
+
<default class="hip">
|
| 11 |
+
<default class="hip_pitch">
|
| 12 |
+
<joint axis="0 1 0" range="-2.5307 2.8798" actuatorfrcrange="-88 88" armature="0.01017752004"/>
|
| 13 |
+
</default>
|
| 14 |
+
<default class="hip_roll">
|
| 15 |
+
<joint axis="1 0 0" actuatorfrcrange="-139 139" armature="0.025101925"/>
|
| 16 |
+
</default>
|
| 17 |
+
<default class="hip_yaw">
|
| 18 |
+
<joint axis="0 0 1" range="-2.7576 2.7576" actuatorfrcrange="-88 88" armature="0.01017752004"/>
|
| 19 |
+
</default>
|
| 20 |
+
</default>
|
| 21 |
+
<default class="knee">
|
| 22 |
+
<joint axis="0 1 0" range="-0.087267 2.8798" actuatorfrcrange="-139 139" armature="0.025101925"/>
|
| 23 |
+
</default>
|
| 24 |
+
<default class="ankle">
|
| 25 |
+
<default class="ankle_pitch">
|
| 26 |
+
<joint axis="0 1 0" range="-0.87267 0.5236" actuatorfrcrange="-50 50" armature="0.00721945"/>
|
| 27 |
+
</default>
|
| 28 |
+
<default class="ankle_roll">
|
| 29 |
+
<joint axis="1 0 0" range="-0.2618 0.2618" actuatorfrcrange="-50 50" armature="0.00721945"/>
|
| 30 |
+
</default>
|
| 31 |
+
</default>
|
| 32 |
+
<default class="waist_yaw">
|
| 33 |
+
<joint axis="0 0 1" range="-2.618 2.618" actuatorfrcrange="-88 88" armature="0.01017752004"/>
|
| 34 |
+
</default>
|
| 35 |
+
<default class="waist_pitch">
|
| 36 |
+
<joint axis="0 1 0" range="-0.52 0.52" actuatorfrcrange="-50 50" armature="0.00721945"/>
|
| 37 |
+
</default>
|
| 38 |
+
<default class="waist_roll">
|
| 39 |
+
<joint axis="1 0 0" range="-0.52 0.52" actuatorfrcrange="-50 50" armature="0.00721945"/>
|
| 40 |
+
</default>
|
| 41 |
+
<default class="shoulder">
|
| 42 |
+
<default class="shoulder_pitch">
|
| 43 |
+
<joint axis="0 1 0" range="-3.0892 2.6704" actuatorfrcrange="-25 25" armature="0.003609725"/>
|
| 44 |
+
</default>
|
| 45 |
+
<default class="shoulder_roll">
|
| 46 |
+
<joint axis="1 0 0" actuatorfrcrange="-25 25" armature="0.003609725"/>
|
| 47 |
+
</default>
|
| 48 |
+
<default class="shoulder_yaw">
|
| 49 |
+
<joint axis="0 0 1" range="-2.618 2.618" actuatorfrcrange="-25 25" armature="0.003609725"/>
|
| 50 |
+
</default>
|
| 51 |
+
</default>
|
| 52 |
+
<default class="elbow">
|
| 53 |
+
<joint axis="0 1 0" range="-1.0472 2.0944" actuatorfrcrange="-25 25" armature="0.003609725"/>
|
| 54 |
+
</default>
|
| 55 |
+
<default class="wrist">
|
| 56 |
+
<default class="wrist_roll">
|
| 57 |
+
<joint axis="1 0 0" range="-1.97222 1.97222" actuatorfrcrange="-25 25" armature="0.003609725"/>
|
| 58 |
+
</default>
|
| 59 |
+
<default class="wrist_pitch">
|
| 60 |
+
<joint axis="0 1 0" range="-1.61443 1.61443" actuatorfrcrange="-5 5" armature="0.00425"/>
|
| 61 |
+
</default>
|
| 62 |
+
<default class="wrist_yaw">
|
| 63 |
+
<joint axis="0 0 1" range="-1.61443 1.61443" actuatorfrcrange="-5 5" armature="0.00425"/>
|
| 64 |
+
</default>
|
| 65 |
+
</default>
|
| 66 |
+
|
| 67 |
+
<default class="visual">
|
| 68 |
+
<geom group="2" type="mesh" density="0" material="silver"/>
|
| 69 |
+
</default>
|
| 70 |
+
<default class="collision">
|
| 71 |
+
<geom group="3" rgba=".2 .6 .2 .3" contype="1" conaffinity="1"/>
|
| 72 |
+
<default class="foot">
|
| 73 |
+
<geom size="0.085 0.03 0.005"/>
|
| 74 |
+
</default>
|
| 75 |
+
</default>
|
| 76 |
+
<site group="5" rgba="1 0 0 1"/>
|
| 77 |
+
</default>
|
| 78 |
+
</default>
|
| 79 |
+
|
| 80 |
+
<asset>
|
| 81 |
+
<material name="silver" rgba="0.7 0.7 0.7 1"/>
|
| 82 |
+
<material name="black" rgba="0.2 0.2 0.2 1"/>
|
| 83 |
+
|
| 84 |
+
<mesh name="pelvis" file="pelvis.STL"/>
|
| 85 |
+
<mesh name="pelvis_contour_link" file="pelvis_contour_link.STL"/>
|
| 86 |
+
<mesh name="left_hip_pitch_link" file="left_hip_pitch_link.STL"/>
|
| 87 |
+
<mesh name="left_hip_roll_link" file="left_hip_roll_link.STL"/>
|
| 88 |
+
<mesh name="left_hip_yaw_link" file="left_hip_yaw_link.STL"/>
|
| 89 |
+
<mesh name="left_knee_link" file="left_knee_link.STL"/>
|
| 90 |
+
<mesh name="left_ankle_pitch_link"
|
| 91 |
+
file="left_ankle_pitch_link.STL"/>
|
| 92 |
+
<mesh name="left_ankle_roll_link" file="left_ankle_roll_link.STL"/>
|
| 93 |
+
<mesh name="right_hip_pitch_link" file="right_hip_pitch_link.STL"/>
|
| 94 |
+
<mesh name="right_hip_roll_link" file="right_hip_roll_link.STL"/>
|
| 95 |
+
<mesh name="right_hip_yaw_link" file="right_hip_yaw_link.STL"/>
|
| 96 |
+
<mesh name="right_knee_link" file="right_knee_link.STL"/>
|
| 97 |
+
<mesh name="right_ankle_pitch_link"
|
| 98 |
+
file="right_ankle_pitch_link.STL"/>
|
| 99 |
+
<mesh name="right_ankle_roll_link"
|
| 100 |
+
file="right_ankle_roll_link.STL"/>
|
| 101 |
+
<mesh name="waist_yaw_link" file="waist_yaw_link_rev_1_0.STL"/>
|
| 102 |
+
<mesh name="waist_roll_link" file="waist_roll_link_rev_1_0.STL"/>
|
| 103 |
+
<mesh name="torso_link" file="torso_link_rev_1_0.STL"/>
|
| 104 |
+
<mesh name="logo_link" file="logo_link.STL"/>
|
| 105 |
+
<mesh name="head_link" file="head_link.STL"/>
|
| 106 |
+
<mesh name="left_shoulder_pitch_link"
|
| 107 |
+
file="left_shoulder_pitch_link.STL"/>
|
| 108 |
+
<mesh name="left_shoulder_roll_link"
|
| 109 |
+
file="left_shoulder_roll_link.STL"/>
|
| 110 |
+
<mesh name="left_shoulder_yaw_link"
|
| 111 |
+
file="left_shoulder_yaw_link.STL"/>
|
| 112 |
+
<mesh name="left_elbow_link" file="left_elbow_link.STL"/>
|
| 113 |
+
<mesh name="left_wrist_roll_link" file="left_wrist_roll_link.STL"/>
|
| 114 |
+
<mesh name="left_wrist_pitch_link"
|
| 115 |
+
file="left_wrist_pitch_link.STL"/>
|
| 116 |
+
<mesh name="left_wrist_yaw_link" file="left_wrist_yaw_link.STL"/>
|
| 117 |
+
<mesh name="left_rubber_hand" file="left_rubber_hand.STL"/>
|
| 118 |
+
<mesh name="right_shoulder_pitch_link"
|
| 119 |
+
file="right_shoulder_pitch_link.STL"/>
|
| 120 |
+
<mesh name="right_shoulder_roll_link"
|
| 121 |
+
file="right_shoulder_roll_link.STL"/>
|
| 122 |
+
<mesh name="right_shoulder_yaw_link"
|
| 123 |
+
file="right_shoulder_yaw_link.STL"/>
|
| 124 |
+
<mesh name="right_elbow_link" file="right_elbow_link.STL"/>
|
| 125 |
+
<mesh name="right_wrist_roll_link"
|
| 126 |
+
file="right_wrist_roll_link.STL"/>
|
| 127 |
+
<mesh name="right_wrist_pitch_link"
|
| 128 |
+
file="right_wrist_pitch_link.STL"/>
|
| 129 |
+
<mesh name="right_wrist_yaw_link" file="right_wrist_yaw_link.STL"/>
|
| 130 |
+
<mesh name="right_rubber_hand" file="right_rubber_hand.STL"/>
|
| 131 |
+
</asset>
|
| 132 |
+
|
| 133 |
+
<worldbody>
|
| 134 |
+
<body name="pelvis" pos="0 0 0.793" childclass="g1">
|
| 135 |
+
<inertial pos="0 0 -0.07605" quat="1 0 -0.000399148 0" mass="3.813" diaginertia="0.010549 0.0093089 0.0079184"/>
|
| 136 |
+
<freejoint name="floating_base_joint"/>
|
| 137 |
+
<geom class="visual" material="black" mesh="pelvis"/>
|
| 138 |
+
<geom class="visual" mesh="pelvis_contour_link"/>
|
| 139 |
+
<geom mesh="pelvis_contour_link" class="visual"/>
|
| 140 |
+
<geom name="pelvis_collision" class="collision" type="sphere" size="0.07" pos="0 0 -0.08"/>
|
| 141 |
+
<site name="imu_in_pelvis" size="0.01" pos="0.04525 0 -0.08339"/>
|
| 142 |
+
<site name="pelvis" size="0.01" pos="0 0 0"/>
|
| 143 |
+
<body name="left_hip_pitch_link" pos="0 0.064452 -0.1027">
|
| 144 |
+
<inertial pos="0.002741 0.047791 -0.02606" quat="0.954862 0.293964 0.0302556 0.030122" mass="1.35"
|
| 145 |
+
diaginertia="0.00181517 0.00153422 0.00116212"/>
|
| 146 |
+
<joint name="left_hip_pitch_joint" class="hip_pitch"/>
|
| 147 |
+
<geom class="visual" material="black" mesh="left_hip_pitch_link"/>
|
| 148 |
+
<geom material="black" mesh="left_hip_pitch_link" class="visual"/>
|
| 149 |
+
<body name="left_hip_roll_link" pos="0 0.052 -0.030465" quat="0.996179 0 -0.0873386 0">
|
| 150 |
+
<inertial pos="0.029812 -0.001045 -0.087934" quat="0.977808 -1.97119e-05 0.205576 -0.0403793" mass="1.52"
|
| 151 |
+
diaginertia="0.00254986 0.00241169 0.00148755"/>
|
| 152 |
+
<joint name="left_hip_roll_joint" class="hip_roll" range="-0.5236 2.9671"/>
|
| 153 |
+
<geom class="visual" mesh="left_hip_roll_link"/>
|
| 154 |
+
<geom mesh="left_hip_roll_link" class="visual"/>
|
| 155 |
+
<geom name="left_thigh" class="collision" type="capsule" size="0.05" fromto="0.02 0 0 0.02 0 -0.2"/>
|
| 156 |
+
<body name="left_hip_yaw_link" pos="0.025001 0 -0.12412">
|
| 157 |
+
<inertial pos="-0.057709 -0.010981 -0.15078" quat="0.600598 0.15832 0.223482 0.751181" mass="1.702"
|
| 158 |
+
diaginertia="0.00776166 0.00717575 0.00160139"/>
|
| 159 |
+
<joint name="left_hip_yaw_joint" class="hip_yaw"/>
|
| 160 |
+
<geom class="visual" mesh="left_hip_yaw_link"/>
|
| 161 |
+
<body name="left_knee_link" pos="-0.078273 0.0021489 -0.17734" quat="0.996179 0 0.0873386 0">
|
| 162 |
+
<inertial pos="0.005457 0.003964 -0.12074" quat="0.923418 -0.0327699 0.0158246 0.382067" mass="1.932"
|
| 163 |
+
diaginertia="0.0113804 0.0112778 0.00146458"/>
|
| 164 |
+
<joint name="left_knee_joint" class="knee"/>
|
| 165 |
+
<geom class="visual" mesh="left_knee_link"/>
|
| 166 |
+
<geom name="left_shin" class="collision" type="capsule" size="0.04" fromto="0.02 0 0 0.02 0 -0.25"/>
|
| 167 |
+
<body name="left_ankle_pitch_link" pos="0 -9.4445e-05 -0.30001">
|
| 168 |
+
<inertial pos="-0.007269 0 0.011137" quat="0.603053 0.369225 0.369225 0.603053" mass="0.074"
|
| 169 |
+
diaginertia="1.89e-05 1.40805e-05 6.9195e-06"/>
|
| 170 |
+
<joint name="left_ankle_pitch_joint" class="ankle_pitch"/>
|
| 171 |
+
<geom class="visual" mesh="left_ankle_pitch_link"/>
|
| 172 |
+
<body name="left_ankle_roll_link" pos="0 0 -0.017558">
|
| 173 |
+
<site name="left_foot" rgba="1 0 0 1"/>
|
| 174 |
+
<inertial pos="0.026505 0 -0.016425" quat="-0.000481092 0.728482 -0.000618967 0.685065" mass="0.608"
|
| 175 |
+
diaginertia="0.00167218 0.0016161 0.000217621"/>
|
| 176 |
+
<joint name="left_ankle_roll_joint" class="ankle_roll"/>
|
| 177 |
+
<geom class="visual" material="black" mesh="left_ankle_roll_link"/>
|
| 178 |
+
<geom name="left_foot1_collision" type="capsule" size="0.01" class="collision" fromto="0.1 -0.026 -0.025 0.05 -0.027 -0.025"/>
|
| 179 |
+
<geom name="left_foot2_collision" type="capsule" size="0.01" class="collision" fromto="-0.044 -0.018 -0.025 0.123 -0.018 -0.025"/>
|
| 180 |
+
<geom name="left_foot3_collision" type="capsule" size="0.01" class="collision" fromto="-0.052 -0.01 -0.025 0.13 -0.01 -0.025"/>
|
| 181 |
+
<geom name="left_foot4_collision" type="capsule" size="0.01" class="collision" fromto="-0.054 0 -0.025 0.132 0 -0.025"/>
|
| 182 |
+
<geom name="left_foot5_collision" type="capsule" size="0.01" class="collision" fromto="-0.052 0.01 -0.025 0.13 0.01 -0.025"/>
|
| 183 |
+
<geom name="left_foot6_collision" type="capsule" size="0.01" class="collision" fromto="-0.044 0.018 -0.025 0.123 0.018 -0.025"/>
|
| 184 |
+
<geom name="left_foot7_collision" type="capsule" size="0.01" class="collision" fromto="0.1 0.026 -0.025 0.05 0.026 -0.025"/>
|
| 185 |
+
</body>
|
| 186 |
+
</body>
|
| 187 |
+
</body>
|
| 188 |
+
</body>
|
| 189 |
+
</body>
|
| 190 |
+
</body>
|
| 191 |
+
<body name="right_hip_pitch_link" pos="0 -0.064452 -0.1027">
|
| 192 |
+
<inertial pos="0.002741 -0.047791 -0.02606" quat="0.954862 -0.293964 0.0302556 -0.030122" mass="1.35"
|
| 193 |
+
diaginertia="0.00181517 0.00153422 0.00116212"/>
|
| 194 |
+
<joint name="right_hip_pitch_joint" class="hip_pitch"/>
|
| 195 |
+
<geom class="visual" material="black" mesh="right_hip_pitch_link"/>
|
| 196 |
+
<body name="right_hip_roll_link" pos="0 -0.052 -0.030465" quat="0.996179 0 -0.0873386 0">
|
| 197 |
+
<inertial pos="0.029812 0.001045 -0.087934" quat="0.977808 1.97119e-05 0.205576 0.0403793" mass="1.52"
|
| 198 |
+
diaginertia="0.00254986 0.00241169 0.00148755"/>
|
| 199 |
+
<joint name="right_hip_roll_joint" class="hip_roll" range="-2.9671 0.5236"/>
|
| 200 |
+
<geom class="visual" mesh="right_hip_roll_link"/>
|
| 201 |
+
<geom name="right_thigh" class="collision" type="capsule" size="0.05" fromto="0.02 0 0 0.02 0 -0.2"/>
|
| 202 |
+
<body name="right_hip_yaw_link" pos="0.025001 0 -0.12412">
|
| 203 |
+
<inertial pos="-0.057709 0.010981 -0.15078" quat="0.751181 0.223482 0.15832 0.600598" mass="1.702"
|
| 204 |
+
diaginertia="0.00776166 0.00717575 0.00160139"/>
|
| 205 |
+
<joint name="right_hip_yaw_joint" class="hip_yaw"/>
|
| 206 |
+
<geom class="visual" mesh="right_hip_yaw_link"/>
|
| 207 |
+
<body name="right_knee_link" pos="-0.078273 -0.0021489 -0.17734" quat="0.996179 0 0.0873386 0">
|
| 208 |
+
<inertial pos="0.005457 -0.003964 -0.12074" quat="0.923439 0.0345276 0.0116333 -0.382012" mass="1.932"
|
| 209 |
+
diaginertia="0.011374 0.0112843 0.00146452"/>
|
| 210 |
+
<joint name="right_knee_joint" class="knee"/>
|
| 211 |
+
<geom class="visual" mesh="right_knee_link"/>
|
| 212 |
+
<geom name="right_shin" class="collision" type="capsule" size="0.04" fromto="0.02 0 0 0.02 0 -0.25"/>
|
| 213 |
+
<body name="right_ankle_pitch_link" pos="0 9.4445e-05 -0.30001">
|
| 214 |
+
<inertial pos="-0.007269 0 0.011137" quat="0.603053 0.369225 0.369225 0.603053" mass="0.074"
|
| 215 |
+
diaginertia="1.89e-05 1.40805e-05 6.9195e-06"/>
|
| 216 |
+
<joint name="right_ankle_pitch_joint" class="ankle_pitch"/>
|
| 217 |
+
<geom class="visual" mesh="right_ankle_pitch_link"/>
|
| 218 |
+
<body name="right_ankle_roll_link" pos="0 0 -0.017558">
|
| 219 |
+
<site name="right_foot" pos="0 0 0"/>
|
| 220 |
+
<inertial pos="0.026505 0 -0.016425" quat="0.000481092 0.728482 0.000618967 0.685065" mass="0.608"
|
| 221 |
+
diaginertia="0.00167218 0.0016161 0.000217621"/>
|
| 222 |
+
<joint name="right_ankle_roll_joint" class="ankle_roll"/>
|
| 223 |
+
<geom class="visual" material="black" mesh="right_ankle_roll_link"/>
|
| 224 |
+
<geom name="right_foot1_collision" type="capsule" size="0.01" class="collision" fromto="0.1 -0.026 -0.025 0.05 -0.026 -0.025"/>
|
| 225 |
+
<geom name="right_foot2_collision" type="capsule" size="0.008" class="collision" fromto="-0.044 -0.018 -0.025 0.123 -0.018 -0.025"/>
|
| 226 |
+
<geom name="right_foot3_collision" type="capsule" size="0.01" class="collision" fromto="-0.052 -0.01 -0.025 0.13 -0.01 -0.025"/>
|
| 227 |
+
<geom name="right_foot4_collision" type="capsule" size="0.01" class="collision" fromto="-0.054 0 -0.025 0.132 0 -0.025"/>
|
| 228 |
+
<geom name="right_foot5_collision" type="capsule" size="0.01" class="collision" fromto="-0.052 0.01 -0.025 0.13 0.01 -0.025"/>
|
| 229 |
+
<geom name="right_foot6_collision" type="capsule" size="0.008" class="collision" fromto="-0.044 0.018 -0.025 0.123 0.018 -0.025"/>
|
| 230 |
+
<geom name="right_foot7_collision" type="capsule" size="0.01" class="collision" fromto="0.1 0.026 -0.025 0.05 0.026 -0.025"/>
|
| 231 |
+
</body>
|
| 232 |
+
</body>
|
| 233 |
+
</body>
|
| 234 |
+
</body>
|
| 235 |
+
</body>
|
| 236 |
+
</body>
|
| 237 |
+
<body name="waist_yaw_link">
|
| 238 |
+
<inertial pos="0.003494 0.000233 0.018034" quat="0.289697 0.591001 -0.337795 0.672821" mass="0.214"
|
| 239 |
+
diaginertia="0.000163531 0.000107714 0.000102205"/>
|
| 240 |
+
<joint name="waist_yaw_joint" class="waist_yaw"/>
|
| 241 |
+
<geom class="visual" mesh="waist_yaw_link"/>
|
| 242 |
+
<body name="waist_roll_link" pos="-0.0039635 0 0.044">
|
| 243 |
+
<inertial pos="0 2.3e-05 0" quat="0.5 0.5 -0.5 0.5" mass="0.086" diaginertia="8.245e-06 7.079e-06 6.339e-06"/>
|
| 244 |
+
<joint name="waist_roll_joint" class="waist_roll"/>
|
| 245 |
+
<geom class="visual" mesh="waist_roll_link"/>
|
| 246 |
+
<body name="torso_link">
|
| 247 |
+
<inertial pos="0.00203158 0.000339683 0.184568" quat="0.999803 -6.03319e-05 0.0198256 0.00131986"
|
| 248 |
+
mass="7.818" diaginertia="0.121847 0.109825 0.0273735"/>
|
| 249 |
+
<joint name="waist_pitch_joint" class="waist_pitch"/>
|
| 250 |
+
<geom class="visual" mesh="torso_link"/>
|
| 251 |
+
<geom pos="0.0039635 0 -0.044" quat="1 0 0 0" class="visual" material="black" mesh="logo_link"/>
|
| 252 |
+
<geom pos="0.0039635 0 -0.044" class="visual" material="black" mesh="head_link"/>
|
| 253 |
+
|
| 254 |
+
<geom name="torso_collision1" class="collision" type="capsule" size="0.073" fromto="0.005 -0.032 .22 0.005 0.032 .22"/>
|
| 255 |
+
<geom name="torso_collision2" class="collision" type="capsule" size="0.07" fromto="0.005 -0.028 .13 0.005 0.028 .13"/>
|
| 256 |
+
<geom name="torso_collision3" class="collision" type="capsule" size="0.065" fromto="0.005 -0.02 .06 0.005 0.02 .06"/>
|
| 257 |
+
<geom name="head_collision" class="collision" type="capsule" size="0.068" fromto="0.01 0 .41 0.01 0 .42"/>
|
| 258 |
+
|
| 259 |
+
<site name="imu_in_torso" size="0.01" pos="-0.03959 -0.00224 0.14792"/>
|
| 260 |
+
<site name="mid360" size="0.01" pos="0.0002835 0.00003 0.41618" quat="0.00000094 -0.99979404 0.00004632 0.02029493"/>
|
| 261 |
+
<body name="left_shoulder_pitch_link" pos="0.0039563 0.10022 0.24778"
|
| 262 |
+
quat="0.990264 0.139201 1.38722e-05 -9.86868e-05">
|
| 263 |
+
<inertial pos="0 0.035892 -0.011628" quat="0.654152 0.0130458 -0.326267 0.68225" mass="0.718"
|
| 264 |
+
diaginertia="0.000465864 0.000432842 0.000406394"/>
|
| 265 |
+
<joint name="left_shoulder_pitch_joint" class="shoulder_pitch"/>
|
| 266 |
+
<geom class="visual" mesh="left_shoulder_pitch_link"/>
|
| 267 |
+
<geom size="0.03 0.025" pos="0 0.04 -0.01" quat="0.707107 0 0.707107 0" type="cylinder" class="visual"/>
|
| 268 |
+
<body name="left_shoulder_roll_link" pos="0 0.038 -0.013831" quat="0.990268 -0.139172 0 0">
|
| 269 |
+
<inertial pos="-0.000227 0.00727 -0.063243" quat="0.701256 -0.0196223 -0.00710317 0.712604" mass="0.643"
|
| 270 |
+
diaginertia="0.000691311 0.000618011 0.000388977"/>
|
| 271 |
+
<joint name="left_shoulder_roll_joint" range="-1.5882 2.2515" class="shoulder_roll"/>
|
| 272 |
+
<geom class="visual" mesh="left_shoulder_roll_link"/>
|
| 273 |
+
<geom size="0.03 0.015" pos="-0.004 0.006 -0.053" type="cylinder" class="visual"/>
|
| 274 |
+
<body name="left_shoulder_yaw_link" pos="0 0.00624 -0.1032">
|
| 275 |
+
<inertial pos="0.010773 -0.002949 -0.072009" quat="0.716879 -0.0964829 -0.0679942 0.687134"
|
| 276 |
+
mass="0.734" diaginertia="0.00106187 0.00103217 0.000400661"/>
|
| 277 |
+
<joint name="left_shoulder_yaw_joint" class="shoulder_yaw"/>
|
| 278 |
+
<geom class="visual" mesh="left_shoulder_yaw_link"/>
|
| 279 |
+
<geom name="left_shoulder_yaw_collision" class="collision" type="capsule" size="0.035" fromto="0 0 -0.08 0 0 0.05"/>
|
| 280 |
+
<body name="left_elbow_link" pos="0.015783 0 -0.080518">
|
| 281 |
+
<inertial pos="0.064956 0.004454 -0.010062" quat="0.541765 0.636132 0.388821 0.388129" mass="0.6"
|
| 282 |
+
diaginertia="0.000443035 0.000421612 0.000259353"/>
|
| 283 |
+
<joint name="left_elbow_joint" class="elbow"/>
|
| 284 |
+
<geom class="visual" mesh="left_elbow_link"/>
|
| 285 |
+
<geom name="left_elbow_yaw_collision" class="collision" type="capsule" size="0.035" fromto="-0.01 0 -0.01 0.12 0 -0.01"/>
|
| 286 |
+
<body name="left_wrist_roll_link" pos="0.1 0.00188791 -0.01">
|
| 287 |
+
<inertial pos="0.0171394 0.000537591 4.8864e-07" quat="0.575338 0.411667 -0.574906 0.411094"
|
| 288 |
+
mass="0.085445" diaginertia="5.48211e-05 4.96646e-05 3.57798e-05"/>
|
| 289 |
+
<joint name="left_wrist_roll_joint" class="wrist_roll"/>
|
| 290 |
+
<geom class="visual" mesh="left_wrist_roll_link"/>
|
| 291 |
+
<body name="left_wrist_pitch_link" pos="0.038 0 0">
|
| 292 |
+
<inertial pos="0.0229999 -0.00111685 -0.00111658" quat="0.249998 0.661363 0.293036 0.643608"
|
| 293 |
+
mass="0.48405" diaginertia="0.000430353 0.000429873 0.000164648"/>
|
| 294 |
+
<joint name="left_wrist_pitch_joint" class="wrist_pitch"/>
|
| 295 |
+
<geom class="visual" mesh="left_wrist_pitch_link"/>
|
| 296 |
+
<body name="left_wrist_yaw_link" pos="0.046 0 0">
|
| 297 |
+
<inertial pos="0.0708244 0.000191745 0.00161742" quat="0.510571 0.526295 0.468078 0.493188"
|
| 298 |
+
mass="0.254576" diaginertia="0.000646113 0.000559993 0.000147566"/>
|
| 299 |
+
<joint name="left_wrist_yaw_joint" class="wrist_yaw"/>
|
| 300 |
+
<geom class="visual" mesh="left_wrist_yaw_link"/>
|
| 301 |
+
<geom pos="0.0415 0.003 0" quat="1 0 0 0" class="visual" mesh="left_rubber_hand"/>
|
| 302 |
+
<site name="left_palm" pos="0.08 0 0" size="0.01"/>
|
| 303 |
+
<geom name="left_hand_collision" class="collision" type="capsule" size="0.05" fromto="0.05 0 0 0.1 0 0"/>
|
| 304 |
+
</body>
|
| 305 |
+
</body>
|
| 306 |
+
</body>
|
| 307 |
+
</body>
|
| 308 |
+
</body>
|
| 309 |
+
</body>
|
| 310 |
+
</body>
|
| 311 |
+
<body name="right_shoulder_pitch_link" pos="0.0039563 -0.10021 0.24778"
|
| 312 |
+
quat="0.990264 -0.139201 1.38722e-05 9.86868e-05">
|
| 313 |
+
<inertial pos="0 -0.035892 -0.011628" quat="0.68225 -0.326267 0.0130458 0.654152" mass="0.718"
|
| 314 |
+
diaginertia="0.000465864 0.000432842 0.000406394"/>
|
| 315 |
+
<joint name="right_shoulder_pitch_joint" class="shoulder_pitch"/>
|
| 316 |
+
<geom class="visual" mesh="right_shoulder_pitch_link"/>
|
| 317 |
+
<geom size="0.03 0.025" pos="0 -0.04 -0.01" quat="0.707107 0 0.707107 0" type="cylinder" class="visual"/>
|
| 318 |
+
<body name="right_shoulder_roll_link" pos="0 -0.038 -0.013831" quat="0.990268 0.139172 0 0">
|
| 319 |
+
<inertial pos="-0.000227 -0.00727 -0.063243" quat="0.712604 -0.00710317 -0.0196223 0.701256"
|
| 320 |
+
mass="0.643" diaginertia="0.000691311 0.000618011 0.000388977"/>
|
| 321 |
+
<joint name="right_shoulder_roll_joint" range="-2.2515 1.5882" class="shoulder_roll"/>
|
| 322 |
+
<geom class="visual" mesh="right_shoulder_roll_link"/>
|
| 323 |
+
<geom size="0.03 0.015" pos="-0.004 -0.006 -0.053" type="cylinder" class="visual"/>
|
| 324 |
+
<body name="right_shoulder_yaw_link" pos="0 -0.00624 -0.1032">
|
| 325 |
+
<inertial pos="0.010773 0.002949 -0.072009" quat="0.687134 -0.0679942 -0.0964829 0.716879"
|
| 326 |
+
mass="0.734" diaginertia="0.00106187 0.00103217 0.000400661"/>
|
| 327 |
+
<joint name="right_shoulder_yaw_joint" class="shoulder_yaw"/>
|
| 328 |
+
<geom class="visual" mesh="right_shoulder_yaw_link"/>
|
| 329 |
+
<geom name="right_shoulder_yaw_collision" class="collision" type="capsule" size="0.035" fromto="0 0 -0.08 0 0 0.05"/>
|
| 330 |
+
<body name="right_elbow_link" pos="0.015783 0 -0.080518">
|
| 331 |
+
<inertial pos="0.064956 -0.004454 -0.010062" quat="0.388129 0.388821 0.636132 0.541765" mass="0.6"
|
| 332 |
+
diaginertia="0.000443035 0.000421612 0.000259353"/>
|
| 333 |
+
<joint name="right_elbow_joint" class="elbow"/>
|
| 334 |
+
<geom class="visual" mesh="right_elbow_link"/>
|
| 335 |
+
<geom name="right_elbow_yaw_collision" class="collision" type="capsule" size="0.035" fromto="-0.01 0 -0.01 0.12 0 -0.01"/>
|
| 336 |
+
<body name="right_wrist_roll_link" pos="0.1 -0.00188791 -0.01">
|
| 337 |
+
<inertial pos="0.0171394 -0.000537591 4.8864e-07" quat="0.411667 0.575338 -0.411094 0.574906"
|
| 338 |
+
mass="0.085445" diaginertia="5.48211e-05 4.96646e-05 3.57798e-05"/>
|
| 339 |
+
<joint name="right_wrist_roll_joint" class="wrist_roll"/>
|
| 340 |
+
<geom class="visual" mesh="right_wrist_roll_link"/>
|
| 341 |
+
<body name="right_wrist_pitch_link" pos="0.038 0 0">
|
| 342 |
+
<inertial pos="0.0229999 0.00111685 -0.00111658" quat="0.643608 0.293036 0.661363 0.249998"
|
| 343 |
+
mass="0.48405" diaginertia="0.000430353 0.000429873 0.000164648"/>
|
| 344 |
+
<joint name="right_wrist_pitch_joint" class="wrist_pitch"/>
|
| 345 |
+
<geom class="visual" mesh="right_wrist_pitch_link"/>
|
| 346 |
+
<body name="right_wrist_yaw_link" pos="0.046 0 0">
|
| 347 |
+
<inertial pos="0.0708244 -0.000191745 0.00161742" quat="0.493188 0.468078 0.526295 0.510571"
|
| 348 |
+
mass="0.254576" diaginertia="0.000646113 0.000559993 0.000147566"/>
|
| 349 |
+
<joint name="right_wrist_yaw_joint" class="wrist_yaw"/>
|
| 350 |
+
<geom class="visual" mesh="right_wrist_yaw_link"/>
|
| 351 |
+
<geom pos="0.0415 -0.003 0" quat="1 0 0 0" class="visual" mesh="right_rubber_hand"/>
|
| 352 |
+
<site name="right_palm" pos="0.08 0 0" size="0.01"/>
|
| 353 |
+
<geom name="right_hand_collision" class="collision" type="capsule" size="0.05" fromto="0.05 0 0 0.1 0 0"/>
|
| 354 |
+
</body>
|
| 355 |
+
</body>
|
| 356 |
+
</body>
|
| 357 |
+
</body>
|
| 358 |
+
</body>
|
| 359 |
+
</body>
|
| 360 |
+
</body>
|
| 361 |
+
</body>
|
| 362 |
+
</body>
|
| 363 |
+
</body>
|
| 364 |
+
</body>
|
| 365 |
+
</worldbody>
|
| 366 |
+
|
| 367 |
+
<contact>>
|
| 368 |
+
<!-- left foot - floor -->
|
| 369 |
+
<pair name="left_foot1_floor" geom1="left_foot1_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 370 |
+
<pair name="left_foot2_floor" geom1="left_foot2_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 371 |
+
<pair name="left_foot3_floor" geom1="left_foot3_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 372 |
+
<pair name="left_foot4_floor" geom1="left_foot4_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 373 |
+
<pair name="left_foot5_floor" geom1="left_foot5_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 374 |
+
<pair name="left_foot6_floor" geom1="left_foot6_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 375 |
+
<pair name="left_foot7_floor" geom1="left_foot7_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 376 |
+
<!-- right foot - floor -->
|
| 377 |
+
<pair name="right_foot1_floor" geom1="right_foot1_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 378 |
+
<pair name="right_foot2_floor" geom1="right_foot2_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 379 |
+
<pair name="right_foot3_floor" geom1="right_foot3_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 380 |
+
<pair name="right_foot4_floor" geom1="right_foot4_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 381 |
+
<pair name="right_foot5_floor" geom1="right_foot5_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 382 |
+
<pair name="right_foot6_floor" geom1="right_foot6_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 383 |
+
<pair name="right_foot7_floor" geom1="right_foot7_collision" geom2="floor" solref="0.01 1" friction="0.8 0.8"/>
|
| 384 |
+
</contact>
|
| 385 |
+
|
| 386 |
+
<sensor>
|
| 387 |
+
<framequat name="base_quat" objtype="site" objname="imu_in_pelvis"/>
|
| 388 |
+
<gyro name="base_gyro" site="imu_in_pelvis"/>
|
| 389 |
+
<accelerometer name="base_accel" site="imu_in_pelvis"/>
|
| 390 |
+
|
| 391 |
+
<framequat name="mid360_quat" objtype="site" objname="mid360"/>
|
| 392 |
+
<framepos name="mid360_pos" objtype="site" objname="mid360"/>
|
| 393 |
+
</sensor>
|
| 394 |
+
|
| 395 |
+
<visual>
|
| 396 |
+
<headlight diffuse="0.6 0.6 0.6" ambient="0.3 0.3 0.3" specular="0 0 0"/>
|
| 397 |
+
<rgba haze="0.15 0.25 0.35 1"/>
|
| 398 |
+
<global azimuth="120" elevation="-20"/>
|
| 399 |
+
</visual>
|
| 400 |
+
|
| 401 |
+
<asset>
|
| 402 |
+
<texture type="skybox" builtin="gradient" rgb1="0.3 0.5 0.7" rgb2="0 0 0" width="512" height="3072"/>
|
| 403 |
+
<texture type="2d" name="groundplane" builtin="checker" mark="edge" rgb1="0.2 0.3 0.4" rgb2="0.1 0.2 0.3"
|
| 404 |
+
markrgb="0.8 0.8 0.8" width="300" height="300"/>
|
| 405 |
+
<material name="groundplane" texture="groundplane" texuniform="true" texrepeat="5 5" reflectance="0.2"/>
|
| 406 |
+
</asset>
|
| 407 |
+
|
| 408 |
+
<worldbody>
|
| 409 |
+
<light pos="0 0 1.5" dir="0 0 -1" directional="true"/>
|
| 410 |
+
<geom name="floor" size="0 0 0.01" type="plane" material="groundplane" conaffinity="1"/>
|
| 411 |
+
</worldbody>
|
| 412 |
+
|
| 413 |
+
</mujoco>
|
kimodo/assets/skeletons/smplx22/beta.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:abfc1cc9e819d017580739e4a65b39458ae840808f01819ad54260283575fa11
|
| 3 |
+
size 1328
|
kimodo/assets/skeletons/smplx22/joints.p
ADDED
|
Binary file (1.43 kB). View file
|
|
|
kimodo/assets/skeletons/smplx22/mean_hands.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eff97ce9d98bfb1495501d8f00a883c08bfdfc05079037e9832f5d64b58d0e39
|
| 3 |
+
size 848
|
kimodo/assets/skeletons/somaskel30/joints.p
ADDED
|
Binary file (1.88 kB). View file
|
|
|
kimodo/assets/skeletons/somaskel30/soma_base_fit_mhr_params.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:61f0b96c8386a19d44823faf4399816642b7e20691ed5b398c7402921090096e
|
| 3 |
+
size 1877
|
kimodo/assets/skeletons/somaskel77/bvh_joints.p
ADDED
|
Binary file (3.39 kB). View file
|
|
|
kimodo/assets/skeletons/somaskel77/joints.p
ADDED
|
Binary file (2.97 kB). View file
|
|
|
kimodo/assets/skeletons/somaskel77/relaxed_hands_rest_pose.npy
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:64a3828e0d1ef1f1de8228c74eba8040c0810d898169f1892d8997a213b2b64c
|
| 3 |
+
size 2900
|
kimodo/assets/skeletons/somaskel77/skin_standard.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:90ee2cdf50f168382a7dd7e9c88b118298aba0aca4b72022080c89c4bab0ceb2
|
| 3 |
+
size 531434
|
kimodo/assets/skeletons/somaskel77/somaskel77_standard_tpose.bvh
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
HIERARCHY
|
| 2 |
+
ROOT Root
|
| 3 |
+
{
|
| 4 |
+
OFFSET 0.0 0.0 0.0
|
| 5 |
+
CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation
|
| 6 |
+
JOINT Hips
|
| 7 |
+
{
|
| 8 |
+
OFFSET 0.0 100.0 0.0
|
| 9 |
+
CHANNELS 6 Xposition Yposition Zposition Zrotation Yrotation Xrotation
|
| 10 |
+
JOINT Spine1
|
| 11 |
+
{
|
| 12 |
+
OFFSET -0.013727 5.003763 -0.053727
|
| 13 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 14 |
+
JOINT Spine2
|
| 15 |
+
{
|
| 16 |
+
OFFSET -0.0 7.125301 -0.029825
|
| 17 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 18 |
+
JOINT Chest
|
| 19 |
+
{
|
| 20 |
+
OFFSET -1e-06 7.550063 -0.815971
|
| 21 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 22 |
+
JOINT Neck1
|
| 23 |
+
{
|
| 24 |
+
OFFSET -0.181677 26.311295 -0.553348
|
| 25 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 26 |
+
JOINT Neck2
|
| 27 |
+
{
|
| 28 |
+
OFFSET -3e-06 7.709397 2.302585
|
| 29 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 30 |
+
JOINT Head
|
| 31 |
+
{
|
| 32 |
+
OFFSET -5e-06 6.128916 1.953709
|
| 33 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 34 |
+
JOINT HeadEnd
|
| 35 |
+
{
|
| 36 |
+
OFFSET 0.003598 16.065403 -1.835379
|
| 37 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 38 |
+
}
|
| 39 |
+
JOINT Jaw
|
| 40 |
+
{
|
| 41 |
+
OFFSET 0.002637 0.475592 3.094941
|
| 42 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 43 |
+
}
|
| 44 |
+
JOINT LeftEye
|
| 45 |
+
{
|
| 46 |
+
OFFSET 3.206381 5.380205 7.586883
|
| 47 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 48 |
+
}
|
| 49 |
+
JOINT RightEye
|
| 50 |
+
{
|
| 51 |
+
OFFSET -3.22244 5.361869 7.558234
|
| 52 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
JOINT LeftShoulder
|
| 58 |
+
{
|
| 59 |
+
OFFSET 1.621652 23.237164 5.113413
|
| 60 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 61 |
+
JOINT LeftArm
|
| 62 |
+
{
|
| 63 |
+
OFFSET 14.919846 2e-06 -5.502326
|
| 64 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 65 |
+
JOINT LeftForeArm
|
| 66 |
+
{
|
| 67 |
+
OFFSET 28.739307 0.0 -0.002588
|
| 68 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 69 |
+
JOINT LeftHand
|
| 70 |
+
{
|
| 71 |
+
OFFSET 27.093981 -1e-06 0.002609
|
| 72 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 73 |
+
JOINT LeftHandThumb1
|
| 74 |
+
{
|
| 75 |
+
OFFSET 2.276482 -1.392045 3.191413
|
| 76 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 77 |
+
JOINT LeftHandThumb2
|
| 78 |
+
{
|
| 79 |
+
OFFSET 4.012836 -1.828127 1.641654
|
| 80 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 81 |
+
JOINT LeftHandThumb3
|
| 82 |
+
{
|
| 83 |
+
OFFSET 2.798515 0.0 -3e-06
|
| 84 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 85 |
+
JOINT LeftHandThumbEnd
|
| 86 |
+
{
|
| 87 |
+
OFFSET 3.180793 -4e-06 4e-06
|
| 88 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
JOINT LeftHandIndex1
|
| 94 |
+
{
|
| 95 |
+
OFFSET 3.247555 -0.531998 2.296169
|
| 96 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 97 |
+
JOINT LeftHandIndex2
|
| 98 |
+
{
|
| 99 |
+
OFFSET 6.364578 0.01206 0.1786
|
| 100 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 101 |
+
JOINT LeftHandIndex3
|
| 102 |
+
{
|
| 103 |
+
OFFSET 3.662364 0.0 0.0
|
| 104 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 105 |
+
JOINT LeftHandIndex4
|
| 106 |
+
{
|
| 107 |
+
OFFSET 2.329242 4e-06 4e-06
|
| 108 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 109 |
+
JOINT LeftHandIndexEnd
|
| 110 |
+
{
|
| 111 |
+
OFFSET 2.759615 -0.180537 -0.113024
|
| 112 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
JOINT LeftHandMiddle1
|
| 119 |
+
{
|
| 120 |
+
OFFSET 3.163495 0.240981 1.000332
|
| 121 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 122 |
+
JOINT LeftHandMiddle2
|
| 123 |
+
{
|
| 124 |
+
OFFSET 6.19078 -0.259278 -1.002548
|
| 125 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 126 |
+
JOINT LeftHandMiddle3
|
| 127 |
+
{
|
| 128 |
+
OFFSET 4.35652 -4e-06 -1e-06
|
| 129 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 130 |
+
JOINT LeftHandMiddle4
|
| 131 |
+
{
|
| 132 |
+
OFFSET 2.996877 -8e-06 0.0
|
| 133 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 134 |
+
JOINT LeftHandMiddleEnd
|
| 135 |
+
{
|
| 136 |
+
OFFSET 2.304287 -0.294569 -0.031741
|
| 137 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
JOINT LeftHandRing1
|
| 144 |
+
{
|
| 145 |
+
OFFSET 2.882643 -0.053652 -0.322543
|
| 146 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 147 |
+
JOINT LeftHandRing2
|
| 148 |
+
{
|
| 149 |
+
OFFSET 5.854541 -0.486202 -1.373841
|
| 150 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 151 |
+
JOINT LeftHandRing3
|
| 152 |
+
{
|
| 153 |
+
OFFSET 4.350578 0.0 3e-06
|
| 154 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 155 |
+
JOINT LeftHandRing4
|
| 156 |
+
{
|
| 157 |
+
OFFSET 2.651321 7e-06 2e-06
|
| 158 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 159 |
+
JOINT LeftHandRingEnd
|
| 160 |
+
{
|
| 161 |
+
OFFSET 1.936105 0.077687 -7.1e-05
|
| 162 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
JOINT LeftHandPinky1
|
| 169 |
+
{
|
| 170 |
+
OFFSET 2.8655 -0.310005 -1.600378
|
| 171 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 172 |
+
JOINT LeftHandPinky2
|
| 173 |
+
{
|
| 174 |
+
OFFSET 5.087849 -1.331141 -1.77123
|
| 175 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 176 |
+
JOINT LeftHandPinky3
|
| 177 |
+
{
|
| 178 |
+
OFFSET 3.070974 4e-06 0.0
|
| 179 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 180 |
+
JOINT LeftHandPinky4
|
| 181 |
+
{
|
| 182 |
+
OFFSET 1.549672 0.0 1e-06
|
| 183 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 184 |
+
JOINT LeftHandPinkyEnd
|
| 185 |
+
{
|
| 186 |
+
OFFSET 1.944893 -0.157802 0.057219
|
| 187 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
JOINT RightShoulder
|
| 198 |
+
{
|
| 199 |
+
OFFSET -1.380118 23.180309 5.214158
|
| 200 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 201 |
+
JOINT RightArm
|
| 202 |
+
{
|
| 203 |
+
OFFSET -15.037196 1.2e-05 -5.545604
|
| 204 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 205 |
+
JOINT RightForeArm
|
| 206 |
+
{
|
| 207 |
+
OFFSET -28.736639 2e-06 -0.002597
|
| 208 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 209 |
+
JOINT RightHand
|
| 210 |
+
{
|
| 211 |
+
OFFSET -27.133619 -0.0 0.002613
|
| 212 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 213 |
+
JOINT RightHandThumb1
|
| 214 |
+
{
|
| 215 |
+
OFFSET -2.274032 -1.383988 3.163127
|
| 216 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 217 |
+
JOINT RightHandThumb2
|
| 218 |
+
{
|
| 219 |
+
OFFSET -4.011429 -1.827466 1.640914
|
| 220 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 221 |
+
JOINT RightHandThumb3
|
| 222 |
+
{
|
| 223 |
+
OFFSET -2.794935 -4e-06 -3e-06
|
| 224 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 225 |
+
JOINT RightHandThumbEnd
|
| 226 |
+
{
|
| 227 |
+
OFFSET -3.183852 4e-06 1e-06
|
| 228 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
JOINT RightHandIndex1
|
| 234 |
+
{
|
| 235 |
+
OFFSET -3.253266 -0.520057 2.282866
|
| 236 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 237 |
+
JOINT RightHandIndex2
|
| 238 |
+
{
|
| 239 |
+
OFFSET -6.341917 0.012471 0.178266
|
| 240 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 241 |
+
JOINT RightHandIndex3
|
| 242 |
+
{
|
| 243 |
+
OFFSET -3.654871 -8e-06 -0.0
|
| 244 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 245 |
+
JOINT RightHandIndex4
|
| 246 |
+
{
|
| 247 |
+
OFFSET -2.327586 0.0 1e-06
|
| 248 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 249 |
+
JOINT RightHandIndexEnd
|
| 250 |
+
{
|
| 251 |
+
OFFSET -2.76179 -0.180656 -0.113078
|
| 252 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
JOINT RightHandMiddle1
|
| 259 |
+
{
|
| 260 |
+
OFFSET -3.168106 0.246593 1.00103
|
| 261 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 262 |
+
JOINT RightHandMiddle2
|
| 263 |
+
{
|
| 264 |
+
OFFSET -6.180828 -0.258836 -1.000895
|
| 265 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 266 |
+
JOINT RightHandMiddle3
|
| 267 |
+
{
|
| 268 |
+
OFFSET -4.348901 0.0 -0.0
|
| 269 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 270 |
+
JOINT RightHandMiddle4
|
| 271 |
+
{
|
| 272 |
+
OFFSET -3.00024 -4e-06 -2e-06
|
| 273 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 274 |
+
JOINT RightHandMiddleEnd
|
| 275 |
+
{
|
| 276 |
+
OFFSET -2.30252 -0.29437 -0.031706
|
| 277 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
}
|
| 283 |
+
JOINT RightHandRing1
|
| 284 |
+
{
|
| 285 |
+
OFFSET -2.88569 -0.067952 -0.308858
|
| 286 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 287 |
+
JOINT RightHandRing2
|
| 288 |
+
{
|
| 289 |
+
OFFSET -5.854198 -0.48613 -1.373731
|
| 290 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 291 |
+
JOINT RightHandRing3
|
| 292 |
+
{
|
| 293 |
+
OFFSET -4.33881 -4e-06 -0.0
|
| 294 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 295 |
+
JOINT RightHandRing4
|
| 296 |
+
{
|
| 297 |
+
OFFSET -2.654903 -4e-06 4e-06
|
| 298 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 299 |
+
JOINT RightHandRingEnd
|
| 300 |
+
{
|
| 301 |
+
OFFSET -1.933568 0.077527 -5.2e-05
|
| 302 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
}
|
| 308 |
+
JOINT RightHandPinky1
|
| 309 |
+
{
|
| 310 |
+
OFFSET -2.866425 -0.342796 -1.584145
|
| 311 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 312 |
+
JOINT RightHandPinky2
|
| 313 |
+
{
|
| 314 |
+
OFFSET -5.091371 -1.332055 -1.772385
|
| 315 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 316 |
+
JOINT RightHandPinky3
|
| 317 |
+
{
|
| 318 |
+
OFFSET -3.062664 -4e-06 1e-06
|
| 319 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 320 |
+
JOINT RightHandPinky4
|
| 321 |
+
{
|
| 322 |
+
OFFSET -1.546529 4e-06 -2e-06
|
| 323 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 324 |
+
JOINT RightHandPinkyEnd
|
| 325 |
+
{
|
| 326 |
+
OFFSET -1.945119 -0.157718 0.057211
|
| 327 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 328 |
+
}
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
}
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
JOINT LeftLeg
|
| 341 |
+
{
|
| 342 |
+
OFFSET 10.043214 -8.434526 2.595655
|
| 343 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 344 |
+
JOINT LeftShin
|
| 345 |
+
{
|
| 346 |
+
OFFSET -1e-06 -43.221752 -0.802913
|
| 347 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 348 |
+
JOINT LeftFoot
|
| 349 |
+
{
|
| 350 |
+
OFFSET 1e-06 -42.155094 -3.481523
|
| 351 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 352 |
+
JOINT LeftToeBase
|
| 353 |
+
{
|
| 354 |
+
OFFSET 0.0 -5.059472 13.231529
|
| 355 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 356 |
+
JOINT LeftToeEnd
|
| 357 |
+
{
|
| 358 |
+
OFFSET -0.009607 -1.647619 6.513017
|
| 359 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
}
|
| 365 |
+
JOINT RightLeg
|
| 366 |
+
{
|
| 367 |
+
OFFSET -10.047278 -8.29526 2.620317
|
| 368 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 369 |
+
JOINT RightShin
|
| 370 |
+
{
|
| 371 |
+
OFFSET 1e-06 -43.362206 -0.805556
|
| 372 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 373 |
+
JOINT RightFoot
|
| 374 |
+
{
|
| 375 |
+
OFFSET 2e-06 -42.117393 -3.478398
|
| 376 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 377 |
+
JOINT RightToeBase
|
| 378 |
+
{
|
| 379 |
+
OFFSET -0.0 -5.079609 13.284196
|
| 380 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 381 |
+
JOINT RightToeEnd
|
| 382 |
+
{
|
| 383 |
+
OFFSET 0.009532 -1.634378 6.460591
|
| 384 |
+
CHANNELS 3 Zrotation Yrotation Xrotation
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
}
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
+
}
|
| 391 |
+
}
|
| 392 |
+
MOTION
|
| 393 |
+
Frames: 1
|
| 394 |
+
Frame Time: 0.03333333333333333
|
| 395 |
+
0.0 0.0 0.0 0.0 0.0 0.0 0.0 100.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
|
kimodo/assets/skeletons/somaskel77/standard_t_pose_global_offsets_rots.p
ADDED
|
Binary file (4.65 kB). View file
|
|
|
kimodo/constraints.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Constraint sets for conditioning motion generation (root 2D, full body, end-effectors)."""
|
| 4 |
+
|
| 5 |
+
from typing import Optional, Union
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
from torch import Tensor
|
| 9 |
+
|
| 10 |
+
from kimodo.motion_rep.feature_utils import compute_heading_angle
|
| 11 |
+
from kimodo.skeleton import SkeletonBase, SOMASkeleton30, SOMASkeleton77
|
| 12 |
+
from kimodo.tools import ensure_batched, load_json, save_json
|
| 13 |
+
|
| 14 |
+
from .geometry import axis_angle_to_matrix, matrix_to_axis_angle
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _convert_constraint_local_rots_to_skeleton(local_rot_mats: Tensor, skeleton: SkeletonBase) -> Tensor:
|
| 18 |
+
"""Convert loaded local rotation matrices to match the skeleton's joint count.
|
| 19 |
+
|
| 20 |
+
Handles SOMA 30↔77: constraint files may have been saved with 30 or 77 joints while the session
|
| 21 |
+
skeleton (e.g. from the SOMA30 model) uses SOMASkeleton77.
|
| 22 |
+
"""
|
| 23 |
+
n_joints = local_rot_mats.shape[-3]
|
| 24 |
+
skeleton_joints = skeleton.nbjoints
|
| 25 |
+
if n_joints == skeleton_joints:
|
| 26 |
+
return local_rot_mats
|
| 27 |
+
if n_joints == 77 and skeleton_joints == 30 and isinstance(skeleton, SOMASkeleton30):
|
| 28 |
+
return skeleton.from_SOMASkeleton77(local_rot_mats)
|
| 29 |
+
if n_joints == 30 and skeleton_joints == 77 and isinstance(skeleton, SOMASkeleton77):
|
| 30 |
+
skel30 = SOMASkeleton30()
|
| 31 |
+
return skel30.to_SOMASkeleton77(local_rot_mats)
|
| 32 |
+
raise ValueError(
|
| 33 |
+
f"Constraint joint count ({n_joints}) does not match skeleton joint count "
|
| 34 |
+
f"({skeleton_joints}). Only SOMA 30↔77 conversion is supported."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def create_pairs(tensor_A: Tensor, tensor_B: Tensor) -> Tensor:
|
| 39 |
+
"""Form all (a, b) pairs from two 1D tensors; output shape (len(A)*len(B), 2)."""
|
| 40 |
+
pairs = torch.stack(
|
| 41 |
+
(
|
| 42 |
+
tensor_A[:, None].expand(-1, len(tensor_B)),
|
| 43 |
+
tensor_B.expand(len(tensor_A), -1),
|
| 44 |
+
),
|
| 45 |
+
dim=-1,
|
| 46 |
+
).reshape(-1, 2)
|
| 47 |
+
return pairs
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def compute_global_heading(global_joints_positions: Tensor, skeleton: SkeletonBase) -> Tensor:
|
| 51 |
+
"""Compute global root heading (cos, sin) from global joint positions using skeleton."""
|
| 52 |
+
root_heading_angle = compute_heading_angle(global_joints_positions, skeleton)
|
| 53 |
+
global_root_heading = torch.stack([torch.cos(root_heading_angle), torch.sin(root_heading_angle)], dim=-1)
|
| 54 |
+
return global_root_heading
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _tensor_to(
|
| 58 |
+
t: Tensor,
|
| 59 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 60 |
+
dtype: Optional[torch.dtype] = None,
|
| 61 |
+
) -> Tensor:
|
| 62 |
+
"""Move tensor to device and/or dtype.
|
| 63 |
+
|
| 64 |
+
Returns same tensor if no args.
|
| 65 |
+
"""
|
| 66 |
+
if device is not None and dtype is not None:
|
| 67 |
+
return t.to(device=device, dtype=dtype)
|
| 68 |
+
if device is not None:
|
| 69 |
+
return t.to(device=device)
|
| 70 |
+
if dtype is not None:
|
| 71 |
+
return t.to(dtype=dtype)
|
| 72 |
+
return t
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class Root2DConstraintSet:
|
| 76 |
+
"""Constraint set fixing root (x, z) trajectory and optionally global heading on given
|
| 77 |
+
frames."""
|
| 78 |
+
|
| 79 |
+
name = "root2d"
|
| 80 |
+
|
| 81 |
+
def __init__(
|
| 82 |
+
self,
|
| 83 |
+
skeleton: SkeletonBase,
|
| 84 |
+
frame_indices: Tensor,
|
| 85 |
+
smooth_root_2d: Tensor,
|
| 86 |
+
to_crop: bool = False,
|
| 87 |
+
global_root_heading: Optional[Tensor] = None,
|
| 88 |
+
) -> None:
|
| 89 |
+
self.skeleton = skeleton
|
| 90 |
+
|
| 91 |
+
# if we pass the full smooth root 3D as input
|
| 92 |
+
if smooth_root_2d.shape[-1] == 3:
|
| 93 |
+
smooth_root_2d = smooth_root_2d[..., [0, 1]]
|
| 94 |
+
|
| 95 |
+
if to_crop:
|
| 96 |
+
smooth_root_2d = smooth_root_2d[frame_indices]
|
| 97 |
+
if global_root_heading is not None:
|
| 98 |
+
global_root_heading = global_root_heading[frame_indices]
|
| 99 |
+
else:
|
| 100 |
+
assert len(smooth_root_2d) == len(
|
| 101 |
+
frame_indices
|
| 102 |
+
), "The number of smooth root 2d should be match the number of frames"
|
| 103 |
+
if global_root_heading is not None:
|
| 104 |
+
assert len(global_root_heading) == len(
|
| 105 |
+
frame_indices
|
| 106 |
+
), "The number of global root heading should be match the number of frames"
|
| 107 |
+
|
| 108 |
+
self.smooth_root_2d = smooth_root_2d
|
| 109 |
+
self.global_root_heading = global_root_heading
|
| 110 |
+
self.frame_indices = frame_indices
|
| 111 |
+
|
| 112 |
+
def update_constraints(self, data_dict: dict, index_dict: dict) -> None:
|
| 113 |
+
"""Append this constraint's smooth_root_2d (and optional global_root_heading) to data/index
|
| 114 |
+
dicts."""
|
| 115 |
+
data_dict["smooth_root_2d"].append(self.smooth_root_2d)
|
| 116 |
+
index_dict["smooth_root_2d"].append(self.frame_indices)
|
| 117 |
+
|
| 118 |
+
if self.global_root_heading is not None:
|
| 119 |
+
# constraint the global heading
|
| 120 |
+
data_dict["global_root_heading"].append(self.global_root_heading)
|
| 121 |
+
index_dict["global_root_heading"].append(self.frame_indices)
|
| 122 |
+
|
| 123 |
+
def crop_move(self, start: int, end: int) -> "Root2DConstraintSet":
|
| 124 |
+
"""Return a new constraint set for the cropped frame range [start, end)."""
|
| 125 |
+
mask = (self.frame_indices >= start) & (self.frame_indices < end)
|
| 126 |
+
|
| 127 |
+
if self.global_root_heading is not None:
|
| 128 |
+
masked_global_root_heading = self.global_root_heading[mask]
|
| 129 |
+
else:
|
| 130 |
+
masked_global_root_heading = None
|
| 131 |
+
|
| 132 |
+
return Root2DConstraintSet(
|
| 133 |
+
self.skeleton,
|
| 134 |
+
self.frame_indices[mask] - start,
|
| 135 |
+
self.smooth_root_2d[mask],
|
| 136 |
+
global_root_heading=masked_global_root_heading,
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
def get_save_info(self) -> dict:
|
| 140 |
+
"""Return a dict suitable for JSON serialization (frame_indices, smooth_root_2d, optional
|
| 141 |
+
global_root_heading)."""
|
| 142 |
+
out = {
|
| 143 |
+
"type": self.name,
|
| 144 |
+
"frame_indices": self.frame_indices,
|
| 145 |
+
"smooth_root_2d": self.smooth_root_2d,
|
| 146 |
+
}
|
| 147 |
+
if self.global_root_heading is not None:
|
| 148 |
+
out["global_root_heading"] = self.global_root_heading
|
| 149 |
+
return out
|
| 150 |
+
|
| 151 |
+
def to(
|
| 152 |
+
self,
|
| 153 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 154 |
+
dtype: Optional[torch.dtype] = None,
|
| 155 |
+
) -> "Root2DConstraintSet":
|
| 156 |
+
self.smooth_root_2d = _tensor_to(self.smooth_root_2d, device, dtype)
|
| 157 |
+
self.frame_indices = _tensor_to(self.frame_indices, device, dtype)
|
| 158 |
+
if self.global_root_heading is not None:
|
| 159 |
+
self.global_root_heading = _tensor_to(self.global_root_heading, device, dtype)
|
| 160 |
+
if device is not None and hasattr(self.skeleton, "to"):
|
| 161 |
+
self.skeleton = self.skeleton.to(device)
|
| 162 |
+
return self
|
| 163 |
+
|
| 164 |
+
@classmethod
|
| 165 |
+
def from_dict(cls, skeleton: SkeletonBase, dico: dict) -> "Root2DConstraintSet":
|
| 166 |
+
"""Build a Root2DConstraintSet from a dict (e.g. loaded from JSON)."""
|
| 167 |
+
device = skeleton.device if hasattr(skeleton, "device") else "cpu"
|
| 168 |
+
|
| 169 |
+
if "global_root_heading" in dico:
|
| 170 |
+
global_root_heading = torch.tensor(dico["global_root_heading"], device=device)
|
| 171 |
+
else:
|
| 172 |
+
global_root_heading = None
|
| 173 |
+
|
| 174 |
+
return cls(
|
| 175 |
+
skeleton,
|
| 176 |
+
frame_indices=torch.tensor(dico["frame_indices"]),
|
| 177 |
+
smooth_root_2d=torch.tensor(dico["smooth_root_2d"], device=device),
|
| 178 |
+
global_root_heading=global_root_heading,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class FullBodyConstraintSet:
|
| 183 |
+
"""Constraint set fixing full-body global positions and rotations on given keyframes."""
|
| 184 |
+
|
| 185 |
+
name = "fullbody"
|
| 186 |
+
|
| 187 |
+
def __init__(
|
| 188 |
+
self,
|
| 189 |
+
skeleton: SkeletonBase,
|
| 190 |
+
frame_indices: Tensor,
|
| 191 |
+
global_joints_positions: Tensor,
|
| 192 |
+
global_joints_rots: Tensor,
|
| 193 |
+
smooth_root_2d: Optional[Tensor] = None,
|
| 194 |
+
to_crop: bool = False,
|
| 195 |
+
):
|
| 196 |
+
self.skeleton = skeleton
|
| 197 |
+
self.frame_indices = frame_indices
|
| 198 |
+
|
| 199 |
+
# if we pass the full smooth root 3D as input
|
| 200 |
+
if smooth_root_2d is not None and smooth_root_2d.shape[-1] == 3:
|
| 201 |
+
smooth_root_2d = smooth_root_2d[..., [0, 1]]
|
| 202 |
+
|
| 203 |
+
if to_crop:
|
| 204 |
+
global_joints_positions = global_joints_positions[frame_indices]
|
| 205 |
+
global_joints_rots = global_joints_rots[frame_indices]
|
| 206 |
+
if smooth_root_2d is not None:
|
| 207 |
+
smooth_root_2d = smooth_root_2d[frame_indices]
|
| 208 |
+
else:
|
| 209 |
+
assert len(global_joints_positions) == len(
|
| 210 |
+
frame_indices
|
| 211 |
+
), "The number of global positions should be match the number of frames"
|
| 212 |
+
assert len(global_joints_rots) == len(
|
| 213 |
+
frame_indices
|
| 214 |
+
), "The number of global joint rotations should be match the number of frames"
|
| 215 |
+
|
| 216 |
+
if smooth_root_2d is not None:
|
| 217 |
+
assert len(smooth_root_2d) == len(
|
| 218 |
+
frame_indices
|
| 219 |
+
), "The number of smooth root 2d (if specified) should be match the number of frames"
|
| 220 |
+
|
| 221 |
+
if smooth_root_2d is None:
|
| 222 |
+
# substitute the smooth root 2d with the real root
|
| 223 |
+
smooth_root_2d = global_joints_positions[:, skeleton.root_idx, [0, 2]]
|
| 224 |
+
|
| 225 |
+
# root y: from smooth or pelvis is the same
|
| 226 |
+
self.root_y_pos = global_joints_positions[:, skeleton.root_idx, 1]
|
| 227 |
+
|
| 228 |
+
self.global_joints_positions = global_joints_positions
|
| 229 |
+
self.global_joints_rots = global_joints_rots
|
| 230 |
+
self.global_root_heading = compute_global_heading(global_joints_positions, skeleton)
|
| 231 |
+
self.smooth_root_2d = smooth_root_2d
|
| 232 |
+
|
| 233 |
+
def update_constraints(self, data_dict: dict, index_dict: dict) -> None:
|
| 234 |
+
"""Append global positions, smooth root 2D, root y, and global heading to data/index
|
| 235 |
+
dicts."""
|
| 236 |
+
nbjoints = self.skeleton.nbjoints
|
| 237 |
+
indices_lst = create_pairs(
|
| 238 |
+
self.frame_indices,
|
| 239 |
+
torch.arange(nbjoints, device=self.frame_indices.device),
|
| 240 |
+
)
|
| 241 |
+
data_dict["global_joints_positions"].append(
|
| 242 |
+
self.global_joints_positions.reshape(-1, 3)
|
| 243 |
+
) # flatten the global positions
|
| 244 |
+
index_dict["global_joints_positions"].append(indices_lst)
|
| 245 |
+
|
| 246 |
+
# global rotations are not used here
|
| 247 |
+
|
| 248 |
+
# as we use smooth root, also constraint the smooth root to get the same full body
|
| 249 |
+
# maybe keep storing the hips offset, if we smooth it ourselves
|
| 250 |
+
data_dict["smooth_root_2d"].append(self.smooth_root_2d)
|
| 251 |
+
index_dict["smooth_root_2d"].append(self.frame_indices)
|
| 252 |
+
|
| 253 |
+
# constraint the y pos of the root
|
| 254 |
+
data_dict["root_y_pos"].append(self.root_y_pos)
|
| 255 |
+
index_dict["root_y_pos"].append(self.frame_indices)
|
| 256 |
+
|
| 257 |
+
# constraint the global heading
|
| 258 |
+
data_dict["global_root_heading"].append(self.global_root_heading)
|
| 259 |
+
index_dict["global_root_heading"].append(self.frame_indices)
|
| 260 |
+
|
| 261 |
+
def crop_move(self, start: int, end: int) -> "FullBodyConstraintSet":
|
| 262 |
+
"""Return a new FullBodyConstraintSet for the cropped frame range [start, end)."""
|
| 263 |
+
mask = (self.frame_indices >= start) & (self.frame_indices < end)
|
| 264 |
+
return FullBodyConstraintSet(
|
| 265 |
+
self.skeleton,
|
| 266 |
+
self.frame_indices[mask] - start,
|
| 267 |
+
self.global_joints_positions[mask],
|
| 268 |
+
self.global_joints_rots[mask],
|
| 269 |
+
self.smooth_root_2d[mask],
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
def get_save_info(self) -> dict:
|
| 273 |
+
"""Return a dict for JSON save: type, frame_indices, local_joints_rot, root_positions, smooth_root_2d."""
|
| 274 |
+
local_joints_rot = self.skeleton.global_rots_to_local_rots(self.global_joints_rots)
|
| 275 |
+
if isinstance(self.skeleton, SOMASkeleton30):
|
| 276 |
+
local_joints_rot = self.skeleton.to_SOMASkeleton77(local_joints_rot)
|
| 277 |
+
local_joints_rot = matrix_to_axis_angle(local_joints_rot)
|
| 278 |
+
|
| 279 |
+
root_positions = self.global_joints_positions[:, self.skeleton.root_idx]
|
| 280 |
+
return {
|
| 281 |
+
"type": self.name,
|
| 282 |
+
"frame_indices": self.frame_indices,
|
| 283 |
+
"local_joints_rot": local_joints_rot,
|
| 284 |
+
"root_positions": root_positions,
|
| 285 |
+
"smooth_root_2d": self.smooth_root_2d,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
def to(
|
| 289 |
+
self,
|
| 290 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 291 |
+
dtype: Optional[torch.dtype] = None,
|
| 292 |
+
) -> "FullBodyConstraintSet":
|
| 293 |
+
self.frame_indices = _tensor_to(self.frame_indices, device, dtype)
|
| 294 |
+
self.global_joints_positions = _tensor_to(self.global_joints_positions, device, dtype)
|
| 295 |
+
self.global_joints_rots = _tensor_to(self.global_joints_rots, device, dtype)
|
| 296 |
+
self.root_y_pos = _tensor_to(self.root_y_pos, device, dtype)
|
| 297 |
+
self.global_root_heading = _tensor_to(self.global_root_heading, device, dtype)
|
| 298 |
+
self.smooth_root_2d = _tensor_to(self.smooth_root_2d, device, dtype)
|
| 299 |
+
if device is not None and hasattr(self.skeleton, "to"):
|
| 300 |
+
self.skeleton = self.skeleton.to(device)
|
| 301 |
+
return self
|
| 302 |
+
|
| 303 |
+
@classmethod
|
| 304 |
+
def from_dict(cls, skeleton: SkeletonBase, dico: dict) -> "FullBodyConstraintSet":
|
| 305 |
+
"""Build a FullBodyConstraintSet from a dict (e.g. loaded from JSON)."""
|
| 306 |
+
frame_indices = torch.tensor(dico["frame_indices"])
|
| 307 |
+
device = skeleton.device if hasattr(skeleton, "device") else "cpu"
|
| 308 |
+
local_rot = torch.tensor(dico["local_joints_rot"], device=device)
|
| 309 |
+
local_rot_mats = axis_angle_to_matrix(local_rot)
|
| 310 |
+
local_rot_mats = _convert_constraint_local_rots_to_skeleton(local_rot_mats, skeleton)
|
| 311 |
+
global_joints_rots, global_joints_positions, _ = skeleton.fk(
|
| 312 |
+
local_rot_mats,
|
| 313 |
+
torch.tensor(dico["root_positions"], device=device),
|
| 314 |
+
)
|
| 315 |
+
smooth_root_2d = None
|
| 316 |
+
if "smooth_root_2d" in dico:
|
| 317 |
+
smooth_root_2d = torch.tensor(dico["smooth_root_2d"], device=device)
|
| 318 |
+
|
| 319 |
+
return cls(
|
| 320 |
+
skeleton,
|
| 321 |
+
frame_indices=frame_indices,
|
| 322 |
+
global_joints_positions=global_joints_positions,
|
| 323 |
+
global_joints_rots=global_joints_rots,
|
| 324 |
+
smooth_root_2d=smooth_root_2d,
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class EndEffectorConstraintSet:
|
| 329 |
+
"""Constraint set fixing selected end-effector positions and rotations on given frames."""
|
| 330 |
+
|
| 331 |
+
name = "end-effector"
|
| 332 |
+
|
| 333 |
+
def __init__(
|
| 334 |
+
self,
|
| 335 |
+
skeleton: SkeletonBase,
|
| 336 |
+
frame_indices: Tensor,
|
| 337 |
+
global_joints_positions: Tensor,
|
| 338 |
+
global_joints_rots: Tensor,
|
| 339 |
+
smooth_root_2d: Optional[Tensor],
|
| 340 |
+
*,
|
| 341 |
+
joint_names: list[str],
|
| 342 |
+
to_crop: bool = False,
|
| 343 |
+
) -> None:
|
| 344 |
+
self.skeleton = skeleton
|
| 345 |
+
self.frame_indices = frame_indices
|
| 346 |
+
self.joint_names = joint_names
|
| 347 |
+
|
| 348 |
+
# joint_names are constant for all the frames
|
| 349 |
+
rot_joint_names, pos_joint_names = self.skeleton.expand_joint_names(self.joint_names)
|
| 350 |
+
# indexing works for motion_rep with smooth root only (contains pelvis index)
|
| 351 |
+
self.pos_indices = torch.tensor([self.skeleton.bone_index[jname] for jname in pos_joint_names])
|
| 352 |
+
self.rot_indices = torch.tensor([self.skeleton.bone_index[jname] for jname in rot_joint_names])
|
| 353 |
+
|
| 354 |
+
# if we pass the full smooth root 3D as input
|
| 355 |
+
if smooth_root_2d is not None and smooth_root_2d.shape[-1] == 3:
|
| 356 |
+
smooth_root_2d = smooth_root_2d[..., [0, 1]]
|
| 357 |
+
|
| 358 |
+
if to_crop:
|
| 359 |
+
global_joints_positions = global_joints_positions[frame_indices]
|
| 360 |
+
global_joints_rots = global_joints_rots[frame_indices]
|
| 361 |
+
if smooth_root_2d is not None:
|
| 362 |
+
smooth_root_2d = smooth_root_2d[frame_indices]
|
| 363 |
+
else:
|
| 364 |
+
assert len(global_joints_positions) == len(
|
| 365 |
+
frame_indices
|
| 366 |
+
), "The number of global positions should be match the number of frames"
|
| 367 |
+
assert len(global_joints_rots) == len(
|
| 368 |
+
frame_indices
|
| 369 |
+
), "The number of global joint rotations should be match the number of frames"
|
| 370 |
+
if smooth_root_2d is not None:
|
| 371 |
+
assert len(smooth_root_2d) == len(
|
| 372 |
+
frame_indices
|
| 373 |
+
), "The number of smooth root 2d (if specified) should be match the number of frames"
|
| 374 |
+
|
| 375 |
+
if smooth_root_2d is None:
|
| 376 |
+
# substitute the smooth root 2d with the real root
|
| 377 |
+
smooth_root_2d = global_joints_positions[:, skeleton.root_idx, [0, 2]]
|
| 378 |
+
|
| 379 |
+
# root y: from smooth or pelvis is the same
|
| 380 |
+
self.root_y_pos = global_joints_positions[:, skeleton.root_idx, 1]
|
| 381 |
+
|
| 382 |
+
self.global_joints_positions = global_joints_positions
|
| 383 |
+
self.global_root_heading = compute_global_heading(global_joints_positions, skeleton)
|
| 384 |
+
self.global_joints_rots = global_joints_rots
|
| 385 |
+
self.smooth_root_2d = smooth_root_2d
|
| 386 |
+
|
| 387 |
+
def update_constraints(self, data_dict: dict, index_dict: dict) -> None:
|
| 388 |
+
"""Append constrained joint positions/rots, smooth root 2D, root y, and heading to
|
| 389 |
+
data/index dicts."""
|
| 390 |
+
crop_frames_indexing = torch.arange(len(self.frame_indices), device=self.frame_indices.device)
|
| 391 |
+
|
| 392 |
+
# constraint positions
|
| 393 |
+
pos_indices_real = create_pairs(
|
| 394 |
+
self.frame_indices,
|
| 395 |
+
self.pos_indices,
|
| 396 |
+
)
|
| 397 |
+
pos_indices_crop = create_pairs(
|
| 398 |
+
crop_frames_indexing,
|
| 399 |
+
self.pos_indices,
|
| 400 |
+
)
|
| 401 |
+
data_dict["global_joints_positions"].append(self.global_joints_positions[tuple(pos_indices_crop.T)])
|
| 402 |
+
index_dict["global_joints_positions"].append(pos_indices_real)
|
| 403 |
+
|
| 404 |
+
# constraint rotations
|
| 405 |
+
rot_indices_real = create_pairs(
|
| 406 |
+
self.frame_indices,
|
| 407 |
+
self.rot_indices,
|
| 408 |
+
)
|
| 409 |
+
rot_indices_crop = create_pairs(
|
| 410 |
+
crop_frames_indexing,
|
| 411 |
+
self.rot_indices,
|
| 412 |
+
)
|
| 413 |
+
data_dict["global_joints_rots"].append(self.global_joints_rots[tuple(rot_indices_crop.T)])
|
| 414 |
+
index_dict["global_joints_rots"].append(rot_indices_real)
|
| 415 |
+
|
| 416 |
+
# as we use smooth root, also constraint the smooth root to get the same full body
|
| 417 |
+
# maybe keep storing the hips offset, if we smooth it ourselves
|
| 418 |
+
data_dict["smooth_root_2d"].append(self.smooth_root_2d)
|
| 419 |
+
index_dict["smooth_root_2d"].append(self.frame_indices)
|
| 420 |
+
|
| 421 |
+
# constraint the y pos of the root
|
| 422 |
+
data_dict["root_y_pos"].append(self.root_y_pos)
|
| 423 |
+
index_dict["root_y_pos"].append(self.frame_indices)
|
| 424 |
+
|
| 425 |
+
# constraint the global heading
|
| 426 |
+
data_dict["global_root_heading"].append(self.global_root_heading)
|
| 427 |
+
index_dict["global_root_heading"].append(self.frame_indices)
|
| 428 |
+
|
| 429 |
+
def crop_move(self, start: int, end: int) -> "EndEffectorConstraintSet":
|
| 430 |
+
"""Return a new EndEffectorConstraintSet for the cropped frame range [start, end)."""
|
| 431 |
+
mask = (self.frame_indices >= start) & (self.frame_indices < end)
|
| 432 |
+
|
| 433 |
+
cls = type(self)
|
| 434 |
+
kwargs = {}
|
| 435 |
+
if not hasattr(cls, "joint_names"):
|
| 436 |
+
kwargs["joint_names"] = self.joint_names
|
| 437 |
+
|
| 438 |
+
return cls(
|
| 439 |
+
self.skeleton,
|
| 440 |
+
self.frame_indices[mask] - start,
|
| 441 |
+
self.global_joints_positions[mask],
|
| 442 |
+
self.global_joints_rots[mask],
|
| 443 |
+
self.smooth_root_2d[mask],
|
| 444 |
+
**kwargs,
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
def get_save_info(self) -> dict:
|
| 448 |
+
"""Return a dict for JSON save: type, frame_indices, local_joints_rot, root_positions, smooth_root_2d, joint_names."""
|
| 449 |
+
local_joints_rot = self.skeleton.global_rots_to_local_rots(self.global_joints_rots)
|
| 450 |
+
if isinstance(self.skeleton, SOMASkeleton30):
|
| 451 |
+
local_joints_rot = self.skeleton.to_SOMASkeleton77(local_joints_rot)
|
| 452 |
+
local_joints_rot = matrix_to_axis_angle(local_joints_rot)
|
| 453 |
+
|
| 454 |
+
root_positions = self.global_joints_positions[:, self.skeleton.root_idx]
|
| 455 |
+
output = {
|
| 456 |
+
"type": self.name,
|
| 457 |
+
"frame_indices": self.frame_indices,
|
| 458 |
+
"local_joints_rot": local_joints_rot,
|
| 459 |
+
"root_positions": root_positions,
|
| 460 |
+
"smooth_root_2d": self.smooth_root_2d,
|
| 461 |
+
}
|
| 462 |
+
if not hasattr(self.__class__, "joint_names"):
|
| 463 |
+
# save the joint_names for this base class
|
| 464 |
+
# but not for children
|
| 465 |
+
output["joint_names"] = self.joint_names
|
| 466 |
+
return output
|
| 467 |
+
|
| 468 |
+
def to(
|
| 469 |
+
self,
|
| 470 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 471 |
+
dtype: Optional[torch.dtype] = None,
|
| 472 |
+
) -> "EndEffectorConstraintSet":
|
| 473 |
+
self.frame_indices = _tensor_to(self.frame_indices, device, dtype)
|
| 474 |
+
self.pos_indices = _tensor_to(self.pos_indices, device, dtype)
|
| 475 |
+
self.rot_indices = _tensor_to(self.rot_indices, device, dtype)
|
| 476 |
+
self.root_y_pos = _tensor_to(self.root_y_pos, device, dtype)
|
| 477 |
+
self.global_joints_positions = _tensor_to(self.global_joints_positions, device, dtype)
|
| 478 |
+
self.global_root_heading = _tensor_to(self.global_root_heading, device, dtype)
|
| 479 |
+
self.global_joints_rots = _tensor_to(self.global_joints_rots, device, dtype)
|
| 480 |
+
self.smooth_root_2d = _tensor_to(self.smooth_root_2d, device, dtype)
|
| 481 |
+
if device is not None and hasattr(self.skeleton, "to"):
|
| 482 |
+
self.skeleton = self.skeleton.to(device)
|
| 483 |
+
return self
|
| 484 |
+
|
| 485 |
+
@classmethod
|
| 486 |
+
def from_dict(cls, skeleton: SkeletonBase, dico: dict) -> "EndEffectorConstraintSet":
|
| 487 |
+
"""Build an EndEffectorConstraintSet from a dict (e.g. loaded from JSON)."""
|
| 488 |
+
frame_indices = torch.tensor(dico["frame_indices"])
|
| 489 |
+
device = skeleton.device if hasattr(skeleton, "device") else "cpu"
|
| 490 |
+
local_rot = torch.tensor(dico["local_joints_rot"], device=device)
|
| 491 |
+
local_rot_mats = axis_angle_to_matrix(local_rot)
|
| 492 |
+
local_rot_mats = _convert_constraint_local_rots_to_skeleton(local_rot_mats, skeleton)
|
| 493 |
+
global_joints_rots, global_joints_positions, _ = skeleton.fk(
|
| 494 |
+
local_rot_mats,
|
| 495 |
+
torch.tensor(dico["root_positions"], device=device),
|
| 496 |
+
)
|
| 497 |
+
smooth_root_2d = None
|
| 498 |
+
if "smooth_root_2d" in dico:
|
| 499 |
+
smooth_root_2d = torch.tensor(dico["smooth_root_2d"], device=device)
|
| 500 |
+
|
| 501 |
+
kwargs = {}
|
| 502 |
+
if not hasattr(cls, "joint_names"):
|
| 503 |
+
kwargs["joint_names"] = dico["joint_names"]
|
| 504 |
+
|
| 505 |
+
return cls(
|
| 506 |
+
skeleton,
|
| 507 |
+
frame_indices=frame_indices,
|
| 508 |
+
global_joints_positions=global_joints_positions,
|
| 509 |
+
global_joints_rots=global_joints_rots,
|
| 510 |
+
smooth_root_2d=smooth_root_2d,
|
| 511 |
+
**kwargs,
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
|
| 515 |
+
class LeftHandConstraintSet(EndEffectorConstraintSet):
|
| 516 |
+
"""End-effector constraint for the left hand only."""
|
| 517 |
+
|
| 518 |
+
name = "left-hand"
|
| 519 |
+
joint_names: list[str] = ["LeftHand"]
|
| 520 |
+
|
| 521 |
+
def __init__(self, *args, **kwargs: dict):
|
| 522 |
+
super().__init__(*args, joint_names=self.joint_names, **kwargs)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
class RightHandConstraintSet(EndEffectorConstraintSet):
|
| 526 |
+
"""End-effector constraint for the right hand only."""
|
| 527 |
+
|
| 528 |
+
name = "right-hand"
|
| 529 |
+
joint_names: list[str] = ["RightHand"]
|
| 530 |
+
|
| 531 |
+
def __init__(self, *args, **kwargs: dict):
|
| 532 |
+
super().__init__(*args, joint_names=self.joint_names, **kwargs)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
class LeftFootConstraintSet(EndEffectorConstraintSet):
|
| 536 |
+
"""End-effector constraint for the left foot only."""
|
| 537 |
+
|
| 538 |
+
name = "left-foot"
|
| 539 |
+
joint_names: list[str] = ["LeftFoot"]
|
| 540 |
+
|
| 541 |
+
def __init__(self, *args, **kwargs: dict):
|
| 542 |
+
super().__init__(*args, joint_names=self.joint_names, **kwargs)
|
| 543 |
+
|
| 544 |
+
|
| 545 |
+
class RightFootConstraintSet(EndEffectorConstraintSet):
|
| 546 |
+
"""End-effector constraint for the right foot only."""
|
| 547 |
+
|
| 548 |
+
name = "right-foot"
|
| 549 |
+
joint_names: list[str] = ["RightFoot"]
|
| 550 |
+
|
| 551 |
+
def __init__(self, *args, **kwargs: dict):
|
| 552 |
+
super().__init__(*args, joint_names=self.joint_names, **kwargs)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
TYPE_TO_CLASS = {
|
| 556 |
+
"root2d": Root2DConstraintSet,
|
| 557 |
+
"fullbody": FullBodyConstraintSet,
|
| 558 |
+
"left-hand": LeftHandConstraintSet,
|
| 559 |
+
"right-hand": RightHandConstraintSet,
|
| 560 |
+
"left-foot": LeftFootConstraintSet,
|
| 561 |
+
"right-foot": RightFootConstraintSet,
|
| 562 |
+
"end-effector": EndEffectorConstraintSet,
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
|
| 566 |
+
def load_constraints_lst(
|
| 567 |
+
path_or_data: str | list,
|
| 568 |
+
skeleton: SkeletonBase,
|
| 569 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 570 |
+
dtype: Optional[torch.dtype] = None,
|
| 571 |
+
):
|
| 572 |
+
"""Load a list of constraints from JSON path or list of dicts.
|
| 573 |
+
|
| 574 |
+
Args:
|
| 575 |
+
path_or_data: Path to constraints.json or list of constraint dicts.
|
| 576 |
+
skeleton: Skeleton instance (used for from_dict).
|
| 577 |
+
device: If set, move all constraint tensors and skeleton to this device.
|
| 578 |
+
dtype: If set, cast constraint tensors to this dtype.
|
| 579 |
+
"""
|
| 580 |
+
if isinstance(path_or_data, str):
|
| 581 |
+
saved = load_json(path_or_data)
|
| 582 |
+
else:
|
| 583 |
+
saved = path_or_data
|
| 584 |
+
|
| 585 |
+
constraints_lst = []
|
| 586 |
+
for el in saved:
|
| 587 |
+
cls = TYPE_TO_CLASS[el["type"]]
|
| 588 |
+
c = cls.from_dict(skeleton, el)
|
| 589 |
+
if device is not None or dtype is not None:
|
| 590 |
+
c.to(device=device, dtype=dtype)
|
| 591 |
+
constraints_lst.append(c)
|
| 592 |
+
return constraints_lst
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def save_constraints_lst(path: str, constraints_lst: list) -> list | None:
|
| 596 |
+
"""Save a list of constraint sets to a JSON file.
|
| 597 |
+
|
| 598 |
+
Returns None if list is empty.
|
| 599 |
+
"""
|
| 600 |
+
if not constraints_lst:
|
| 601 |
+
print("The constraints lst is empty. Skip saving")
|
| 602 |
+
return
|
| 603 |
+
|
| 604 |
+
to_save = []
|
| 605 |
+
|
| 606 |
+
def tensor_to_list(obj):
|
| 607 |
+
"""Recursively convert tensors to lists for JSON serialization."""
|
| 608 |
+
if isinstance(obj, Tensor):
|
| 609 |
+
return obj.cpu().tolist()
|
| 610 |
+
elif isinstance(obj, dict):
|
| 611 |
+
return {k: tensor_to_list(v) for k, v in obj.items()}
|
| 612 |
+
elif isinstance(obj, list):
|
| 613 |
+
return [tensor_to_list(v) for v in obj]
|
| 614 |
+
else:
|
| 615 |
+
return obj
|
| 616 |
+
|
| 617 |
+
for constraint in constraints_lst:
|
| 618 |
+
constraint_info = constraint.get_save_info()
|
| 619 |
+
# Convert all tensors to lists for JSON serialization
|
| 620 |
+
constraint_info = tensor_to_list(constraint_info)
|
| 621 |
+
to_save.append(constraint_info)
|
| 622 |
+
|
| 623 |
+
save_json(path, to_save)
|
| 624 |
+
print(f"Saved constraints to {path}")
|
| 625 |
+
return to_save
|
kimodo/demo/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
# ruff: noqa: I001
|
| 5 |
+
import argparse
|
| 6 |
+
|
| 7 |
+
from kimodo.model import DEFAULT_MODEL
|
| 8 |
+
from kimodo.model.registry import resolve_model_name
|
| 9 |
+
|
| 10 |
+
from .app import Demo
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def main() -> None:
|
| 14 |
+
parser = argparse.ArgumentParser(description="Run the kimodo demo UI.")
|
| 15 |
+
parser.add_argument(
|
| 16 |
+
"--model",
|
| 17 |
+
type=str,
|
| 18 |
+
default=DEFAULT_MODEL,
|
| 19 |
+
help="Default model to load (e.g. Kimodo-SOMA-RP-v1, kimodo-soma-rp, or SOMA).",
|
| 20 |
+
)
|
| 21 |
+
args = parser.parse_args()
|
| 22 |
+
|
| 23 |
+
resolved = resolve_model_name(args.model, "Kimodo")
|
| 24 |
+
demo = Demo(default_model_name=resolved)
|
| 25 |
+
demo.run()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
if __name__ == "__main__":
|
| 29 |
+
main()
|
kimodo/demo/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Entry point for `python -m kimodo.demo`."""
|
| 4 |
+
|
| 5 |
+
from kimodo.demo import main
|
| 6 |
+
|
| 7 |
+
if __name__ == "__main__":
|
| 8 |
+
main()
|
kimodo/demo/app.py
ADDED
|
@@ -0,0 +1,690 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import base64
|
| 5 |
+
import os
|
| 6 |
+
import shutil
|
| 7 |
+
import threading
|
| 8 |
+
import time
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
import viser
|
| 15 |
+
from kimodo.assets import DEMO_ASSETS_ROOT
|
| 16 |
+
from kimodo.model.load_model import load_model
|
| 17 |
+
from kimodo.model.registry import resolve_model_name
|
| 18 |
+
from kimodo.skeleton import SkeletonBase, SOMASkeleton30
|
| 19 |
+
from kimodo.tools import load_json
|
| 20 |
+
from kimodo.viz import viser_utils
|
| 21 |
+
from kimodo.viz.viser_utils import (
|
| 22 |
+
Character,
|
| 23 |
+
CharacterMotion,
|
| 24 |
+
EEJointsKeyframeSet,
|
| 25 |
+
FullbodyKeyframeSet,
|
| 26 |
+
RootKeyframe2DSet,
|
| 27 |
+
)
|
| 28 |
+
from viser.theme import TitlebarButton, TitlebarConfig, TitlebarImage
|
| 29 |
+
|
| 30 |
+
from . import generation, ui
|
| 31 |
+
from .config import (
|
| 32 |
+
DARK_THEME,
|
| 33 |
+
DEFAULT_CUR_DURATION,
|
| 34 |
+
DEFAULT_MODEL,
|
| 35 |
+
DEFAULT_PLAYBACK_SPEED,
|
| 36 |
+
DEFAULT_PROMPT,
|
| 37 |
+
DEMO_UI_QUICK_START_MODAL_MD,
|
| 38 |
+
EXAMPLES_ROOT_DIR,
|
| 39 |
+
HF_MODE,
|
| 40 |
+
LIGHT_THEME,
|
| 41 |
+
MAX_ACTIVE_USERS,
|
| 42 |
+
MAX_DURATION,
|
| 43 |
+
MAX_SESSION_MINUTES,
|
| 44 |
+
MIN_DURATION,
|
| 45 |
+
MODEL_EXAMPLES_DIRS,
|
| 46 |
+
MODEL_NAMES,
|
| 47 |
+
SERVER_NAME,
|
| 48 |
+
SERVER_PORT,
|
| 49 |
+
)
|
| 50 |
+
from .embedding_cache import CachedTextEncoder
|
| 51 |
+
from .queue_manager import QueueManager, UserQueue
|
| 52 |
+
from .state import ClientSession, ModelBundle
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
class Demo:
|
| 56 |
+
def __init__(self, default_model_name: str = DEFAULT_MODEL):
|
| 57 |
+
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
| 58 |
+
print(f"Using device: {self.device}")
|
| 59 |
+
self.models: dict[str, ModelBundle] = {}
|
| 60 |
+
self._text_encoder = None
|
| 61 |
+
resolved = resolve_model_name(default_model_name, "Kimodo")
|
| 62 |
+
if resolved not in MODEL_NAMES:
|
| 63 |
+
raise ValueError(f"Unknown model '{default_model_name}'. Expected one of: {MODEL_NAMES}")
|
| 64 |
+
self.default_model_name = resolved
|
| 65 |
+
self.ensure_examples_layout()
|
| 66 |
+
self.load_model(self.default_model_name)
|
| 67 |
+
|
| 68 |
+
# Serialize GPU-bound generation across all clients
|
| 69 |
+
self._generation_lock = threading.Lock()
|
| 70 |
+
self._cuda_healthy = True
|
| 71 |
+
|
| 72 |
+
# Per-client sessions
|
| 73 |
+
self.client_sessions: dict[int, ClientSession] = {}
|
| 74 |
+
self.start_direction_markers: dict[int, viser_utils.WaypointMesh] = {}
|
| 75 |
+
self.grid_handles: dict[int, viser.GridHandle] = {}
|
| 76 |
+
|
| 77 |
+
self.server = viser.ViserServer(
|
| 78 |
+
host=SERVER_NAME,
|
| 79 |
+
port=SERVER_PORT,
|
| 80 |
+
label="Kimodo",
|
| 81 |
+
enable_camera_keyboard_controls=False, # don't move the camera with the arrow keys
|
| 82 |
+
)
|
| 83 |
+
self.server.scene.world_axes.visible = False # used for debugging
|
| 84 |
+
self.server.scene.set_up_direction("+y")
|
| 85 |
+
|
| 86 |
+
# Register callbacks for session handling
|
| 87 |
+
self.server.on_client_connect(self.on_client_connect)
|
| 88 |
+
self.server.on_client_disconnect(self.on_client_disconnect)
|
| 89 |
+
|
| 90 |
+
# HF mode: queue and session limit
|
| 91 |
+
if HF_MODE:
|
| 92 |
+
self.user_queue = UserQueue(MAX_ACTIVE_USERS, MAX_SESSION_MINUTES)
|
| 93 |
+
self.queue_manager = QueueManager(
|
| 94 |
+
queue=self.user_queue,
|
| 95 |
+
server=self.server,
|
| 96 |
+
setup_demo_for_client=self._setup_demo_for_client,
|
| 97 |
+
cleanup_session=self._cleanup_session_for_client,
|
| 98 |
+
)
|
| 99 |
+
else:
|
| 100 |
+
self.user_queue = None
|
| 101 |
+
self.queue_manager = None
|
| 102 |
+
|
| 103 |
+
# create grid and floor
|
| 104 |
+
self.floor_len = 20.0 # meters
|
| 105 |
+
|
| 106 |
+
def ensure_examples_layout(self) -> None:
|
| 107 |
+
os.makedirs(EXAMPLES_ROOT_DIR, exist_ok=True)
|
| 108 |
+
for model_dir in MODEL_EXAMPLES_DIRS.values():
|
| 109 |
+
os.makedirs(model_dir, exist_ok=True)
|
| 110 |
+
|
| 111 |
+
for entry in os.listdir(EXAMPLES_ROOT_DIR):
|
| 112 |
+
if entry in MODEL_EXAMPLES_DIRS:
|
| 113 |
+
continue
|
| 114 |
+
src = os.path.join(EXAMPLES_ROOT_DIR, entry)
|
| 115 |
+
if not os.path.isdir(src):
|
| 116 |
+
continue
|
| 117 |
+
dst = os.path.join(
|
| 118 |
+
MODEL_EXAMPLES_DIRS.get(DEFAULT_MODEL, next(iter(MODEL_EXAMPLES_DIRS.values()))),
|
| 119 |
+
entry,
|
| 120 |
+
)
|
| 121 |
+
if not os.path.exists(dst):
|
| 122 |
+
shutil.move(src, dst)
|
| 123 |
+
|
| 124 |
+
def get_examples_base_dir(self, model_name: str, absolute: bool = True) -> str:
|
| 125 |
+
return MODEL_EXAMPLES_DIRS[model_name]
|
| 126 |
+
|
| 127 |
+
def load_model(self, model_name: str) -> ModelBundle:
|
| 128 |
+
if model_name in self.models:
|
| 129 |
+
return self.models[model_name]
|
| 130 |
+
|
| 131 |
+
print(f"Loading model {model_name}...")
|
| 132 |
+
try:
|
| 133 |
+
model = load_model(
|
| 134 |
+
modelname=model_name,
|
| 135 |
+
device=self.device,
|
| 136 |
+
text_encoder=self._text_encoder,
|
| 137 |
+
)
|
| 138 |
+
except Exception as e:
|
| 139 |
+
print(f"Error loading model: {e}\nMake sure text encoder server is running!")
|
| 140 |
+
raise e
|
| 141 |
+
|
| 142 |
+
if hasattr(model, "text_encoder"):
|
| 143 |
+
if self._text_encoder is None:
|
| 144 |
+
self._text_encoder = model.text_encoder
|
| 145 |
+
model.text_encoder = CachedTextEncoder(model.text_encoder, model_name=model_name)
|
| 146 |
+
|
| 147 |
+
skeleton = model.motion_rep.skeleton
|
| 148 |
+
if isinstance(skeleton, SOMASkeleton30):
|
| 149 |
+
skeleton = skeleton.somaskel77.to(model.device)
|
| 150 |
+
bundle = ModelBundle(
|
| 151 |
+
model=model,
|
| 152 |
+
motion_rep=model.motion_rep,
|
| 153 |
+
skeleton=skeleton,
|
| 154 |
+
model_fps=model.motion_rep.fps,
|
| 155 |
+
)
|
| 156 |
+
self.models[model_name] = bundle
|
| 157 |
+
print(f"Model {model_name} loaded successfully")
|
| 158 |
+
self.prewarm_embedding_cache(model_name, bundle.model)
|
| 159 |
+
return bundle
|
| 160 |
+
|
| 161 |
+
def prewarm_embedding_cache(self, model_name: str, model: object) -> None:
|
| 162 |
+
encoder = getattr(model, "text_encoder", None)
|
| 163 |
+
if not isinstance(encoder, CachedTextEncoder):
|
| 164 |
+
return
|
| 165 |
+
|
| 166 |
+
prompt_set = set()
|
| 167 |
+
prompt_set.add(DEFAULT_PROMPT)
|
| 168 |
+
|
| 169 |
+
examples_dir = MODEL_EXAMPLES_DIRS.get(model_name)
|
| 170 |
+
if examples_dir and os.path.isdir(examples_dir):
|
| 171 |
+
for entry in os.listdir(examples_dir):
|
| 172 |
+
example_dir = os.path.join(examples_dir, entry)
|
| 173 |
+
if not os.path.isdir(example_dir):
|
| 174 |
+
continue
|
| 175 |
+
meta_path = os.path.join(example_dir, "meta.json")
|
| 176 |
+
if not os.path.exists(meta_path):
|
| 177 |
+
continue
|
| 178 |
+
try:
|
| 179 |
+
meta = load_json(meta_path)
|
| 180 |
+
except Exception:
|
| 181 |
+
continue
|
| 182 |
+
for prompt in meta.get("prompts_text", []):
|
| 183 |
+
if isinstance(prompt, str):
|
| 184 |
+
prompt_set.add(prompt)
|
| 185 |
+
|
| 186 |
+
if prompt_set:
|
| 187 |
+
encoder.prewarm(list(prompt_set))
|
| 188 |
+
|
| 189 |
+
def build_constraint_tracks(
|
| 190 |
+
self, client: viser.ClientHandle, skeleton: SkeletonBase
|
| 191 |
+
) -> dict[str, viser_utils.ConstraintSet]:
|
| 192 |
+
return {
|
| 193 |
+
"Full-Body": FullbodyKeyframeSet(
|
| 194 |
+
name="Full-Body",
|
| 195 |
+
server=client,
|
| 196 |
+
skeleton=skeleton,
|
| 197 |
+
),
|
| 198 |
+
"End-Effectors": EEJointsKeyframeSet(
|
| 199 |
+
name="End-Effectors",
|
| 200 |
+
server=client,
|
| 201 |
+
skeleton=skeleton,
|
| 202 |
+
),
|
| 203 |
+
"2D Root": RootKeyframe2DSet(
|
| 204 |
+
name="2D Root",
|
| 205 |
+
server=client,
|
| 206 |
+
skeleton=skeleton,
|
| 207 |
+
),
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
def set_timeline_defaults(self, timeline, model_fps: float) -> None:
|
| 211 |
+
timeline.set_defaults(
|
| 212 |
+
default_text=DEFAULT_PROMPT,
|
| 213 |
+
default_duration=int(DEFAULT_CUR_DURATION * model_fps - 1),
|
| 214 |
+
min_duration=int(MIN_DURATION * model_fps - 1), # 2 seconds minimum,
|
| 215 |
+
max_duration=int(
|
| 216 |
+
MAX_DURATION * model_fps - 1 # - NB_TRANSITION_FRAMES
|
| 217 |
+
), # 10 seconds maximum, minus the transition frames, if needed
|
| 218 |
+
default_num_frames_zoom=int(1.10 * 10 * model_fps), # a bit more than the max
|
| 219 |
+
max_frames_zoom=1000,
|
| 220 |
+
fps=model_fps,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
def _apply_constraint_overlay_visibility(self, session: ClientSession) -> None:
|
| 224 |
+
"""Apply show-all vs show-only-current-frame to constraint overlays."""
|
| 225 |
+
only_frame = session.frame_idx if session.show_only_current_constraint else None
|
| 226 |
+
for constraint in session.constraints.values():
|
| 227 |
+
constraint.set_overlay_visibility(only_frame)
|
| 228 |
+
|
| 229 |
+
def set_constraint_tracks_visible(self, session: ClientSession, visible: bool) -> None:
|
| 230 |
+
timeline = session.client.timeline
|
| 231 |
+
timeline_data = session.timeline_data
|
| 232 |
+
if timeline_data.get("constraint_tracks_visible", True) == visible:
|
| 233 |
+
return
|
| 234 |
+
|
| 235 |
+
with timeline_data["keyframe_update_lock"]:
|
| 236 |
+
if visible:
|
| 237 |
+
for track_id, track_info in timeline_data["tracks"].items():
|
| 238 |
+
timeline.add_track(
|
| 239 |
+
track_info["name"],
|
| 240 |
+
track_type=track_info.get("track_type", "keyframe"),
|
| 241 |
+
color=track_info.get("color"),
|
| 242 |
+
height_scale=track_info.get("height_scale", 1.0),
|
| 243 |
+
uuid=track_id,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
for keyframe_id, keyframe_data in timeline_data["keyframes"].items():
|
| 247 |
+
timeline.add_keyframe(
|
| 248 |
+
track_id=keyframe_data["track_id"],
|
| 249 |
+
frame=keyframe_data["frame"],
|
| 250 |
+
value=keyframe_data.get("value"),
|
| 251 |
+
opacity=keyframe_data.get("opacity", 1.0),
|
| 252 |
+
locked=keyframe_data.get("locked", False),
|
| 253 |
+
uuid=keyframe_id,
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
for interval_id, interval_data in timeline_data["intervals"].items():
|
| 257 |
+
timeline.add_interval(
|
| 258 |
+
track_id=interval_data["track_id"],
|
| 259 |
+
start_frame=interval_data["start_frame_idx"],
|
| 260 |
+
end_frame=interval_data["end_frame_idx"],
|
| 261 |
+
value=interval_data.get("value"),
|
| 262 |
+
opacity=interval_data.get("opacity", 1.0),
|
| 263 |
+
locked=interval_data.get("locked", False),
|
| 264 |
+
uuid=interval_id,
|
| 265 |
+
)
|
| 266 |
+
else:
|
| 267 |
+
for track_id in list(timeline_data["tracks"].keys()):
|
| 268 |
+
timeline.remove_track(track_id)
|
| 269 |
+
|
| 270 |
+
timeline_data["constraint_tracks_visible"] = visible
|
| 271 |
+
|
| 272 |
+
def _cleanup_session_for_client(self, client_id: int) -> None:
|
| 273 |
+
"""Remove session and scene state for a client (e.g. on session expiry)."""
|
| 274 |
+
if client_id in self.client_sessions:
|
| 275 |
+
del self.client_sessions[client_id]
|
| 276 |
+
self.start_direction_markers.pop(client_id, None)
|
| 277 |
+
self.grid_handles.pop(client_id, None)
|
| 278 |
+
|
| 279 |
+
def _setup_demo_for_client(self, client: viser.ClientHandle) -> None:
|
| 280 |
+
"""Initialize scene, GUI, and session state for a client (no modals)."""
|
| 281 |
+
self.setup_scene(client)
|
| 282 |
+
|
| 283 |
+
model_bundle = self.load_model(self.default_model_name)
|
| 284 |
+
|
| 285 |
+
# Initialize each empty constraint track
|
| 286 |
+
constraint_tracks = self.build_constraint_tracks(client, model_bundle.skeleton)
|
| 287 |
+
|
| 288 |
+
# Create GUI elements for this client
|
| 289 |
+
(
|
| 290 |
+
gui_elements,
|
| 291 |
+
timeline_tracks,
|
| 292 |
+
example_dict,
|
| 293 |
+
gui_examples_dropdown,
|
| 294 |
+
gui_save_example_path_text,
|
| 295 |
+
gui_model_selector,
|
| 296 |
+
) = ui.create_gui(
|
| 297 |
+
demo=self,
|
| 298 |
+
client=client,
|
| 299 |
+
model_name=self.default_model_name,
|
| 300 |
+
model_fps=model_bundle.model_fps,
|
| 301 |
+
)
|
| 302 |
+
timeline_data = {
|
| 303 |
+
"tracks": timeline_tracks,
|
| 304 |
+
"tracks_ids": {val["name"]: key for key, val in timeline_tracks.items()},
|
| 305 |
+
"keyframes": {},
|
| 306 |
+
"intervals": {},
|
| 307 |
+
"keyframe_update_lock": threading.Lock(),
|
| 308 |
+
"keyframe_move_timers": {},
|
| 309 |
+
"pending_keyframe_moves": {}, # keyframe_id -> new_frame
|
| 310 |
+
"constraint_tracks_visible": True,
|
| 311 |
+
"dense_path_after_release_timer": None,
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
# Initialize session state
|
| 315 |
+
cur_duration = DEFAULT_CUR_DURATION
|
| 316 |
+
max_frame_idx = int(cur_duration * model_bundle.model_fps - 1)
|
| 317 |
+
|
| 318 |
+
session = ClientSession(
|
| 319 |
+
client=client,
|
| 320 |
+
gui_elements=gui_elements,
|
| 321 |
+
motions={},
|
| 322 |
+
constraints=constraint_tracks,
|
| 323 |
+
timeline_data=timeline_data,
|
| 324 |
+
frame_idx=0,
|
| 325 |
+
playing=False,
|
| 326 |
+
playback_speed=DEFAULT_PLAYBACK_SPEED,
|
| 327 |
+
cur_duration=cur_duration,
|
| 328 |
+
max_frame_idx=max_frame_idx,
|
| 329 |
+
updating_motions=False,
|
| 330 |
+
edit_mode=False,
|
| 331 |
+
model_name=self.default_model_name,
|
| 332 |
+
model_fps=model_bundle.model_fps,
|
| 333 |
+
skeleton=model_bundle.skeleton,
|
| 334 |
+
motion_rep=model_bundle.motion_rep,
|
| 335 |
+
examples_base_dir=self.get_examples_base_dir(self.default_model_name, absolute=True),
|
| 336 |
+
example_dict=example_dict,
|
| 337 |
+
gui_examples_dropdown=gui_examples_dropdown,
|
| 338 |
+
gui_save_example_path_text=gui_save_example_path_text,
|
| 339 |
+
gui_model_selector=gui_model_selector,
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
self.client_sessions[client.client_id] = session
|
| 343 |
+
|
| 344 |
+
# Initialize default character for this client
|
| 345 |
+
self.add_character_motion(client, session.skeleton)
|
| 346 |
+
|
| 347 |
+
def on_client_connect(self, client: viser.ClientHandle) -> None:
|
| 348 |
+
"""Initialize GUI and state for each new client."""
|
| 349 |
+
print(f"Client {client.client_id} connected")
|
| 350 |
+
|
| 351 |
+
if HF_MODE and self.queue_manager is not None:
|
| 352 |
+
self.queue_manager.on_client_connect(client)
|
| 353 |
+
else:
|
| 354 |
+
# Show quick start popup when a browser client connects (non-HF mode).
|
| 355 |
+
with client.gui.add_modal(
|
| 356 |
+
"Welcome — Quick Start",
|
| 357 |
+
size="xl",
|
| 358 |
+
show_close_button=True,
|
| 359 |
+
save_choice="kimodo.demo.quick_start_ack",
|
| 360 |
+
) as modal:
|
| 361 |
+
client.gui.add_markdown(DEMO_UI_QUICK_START_MODAL_MD)
|
| 362 |
+
client.gui.add_button("Got it (don't remind me again)").on_click(lambda _event: modal.close())
|
| 363 |
+
self._setup_demo_for_client(client)
|
| 364 |
+
|
| 365 |
+
def setup_scene(self, client: viser.ClientHandle) -> None:
|
| 366 |
+
self.configure_theme(client)
|
| 367 |
+
client.camera.position = np.array(
|
| 368 |
+
[2.7417358737841426, 1.8790455698853281, 7.675741569777456],
|
| 369 |
+
dtype=np.float64,
|
| 370 |
+
)
|
| 371 |
+
client.camera.look_at = np.array([0.0, 0.0, 0.0], dtype=np.float64)
|
| 372 |
+
client.camera.up_direction = np.array(
|
| 373 |
+
[-1.1102230246251568e-16, 1.0, 1.3596310734468913e-32],
|
| 374 |
+
dtype=np.float64,
|
| 375 |
+
)
|
| 376 |
+
client.camera.fov = np.deg2rad(45.0)
|
| 377 |
+
grid_handle = client.scene.add_grid(
|
| 378 |
+
"/grid",
|
| 379 |
+
width=self.floor_len,
|
| 380 |
+
height=self.floor_len,
|
| 381 |
+
wxyz=viser.transforms.SO3.from_x_radians(-np.pi / 2.0).wxyz,
|
| 382 |
+
position=(0.0, 0.0001, 0.0),
|
| 383 |
+
fade_distance=3 * self.floor_len,
|
| 384 |
+
section_color=LIGHT_THEME["grid"],
|
| 385 |
+
infinite_grid=True,
|
| 386 |
+
)
|
| 387 |
+
self.grid_handles[client.client_id] = grid_handle
|
| 388 |
+
# marker for origin
|
| 389 |
+
origin_waypoint = viser_utils.WaypointMesh(
|
| 390 |
+
"/origin_waypoint",
|
| 391 |
+
client,
|
| 392 |
+
position=np.array([0.0, 0.0, 0.0]),
|
| 393 |
+
heading=np.array([0.0, 1.0]),
|
| 394 |
+
color=(0, 0, 255),
|
| 395 |
+
)
|
| 396 |
+
self.start_direction_markers[client.client_id] = origin_waypoint
|
| 397 |
+
|
| 398 |
+
def on_client_disconnect(self, client: viser.ClientHandle) -> None:
|
| 399 |
+
"""Clean up when client disconnects."""
|
| 400 |
+
print(f"Client {client.client_id} disconnected")
|
| 401 |
+
client_id = client.client_id
|
| 402 |
+
|
| 403 |
+
if HF_MODE and self.queue_manager is not None:
|
| 404 |
+
self.queue_manager.on_client_disconnect(client_id)
|
| 405 |
+
|
| 406 |
+
self._cleanup_session_for_client(client_id)
|
| 407 |
+
|
| 408 |
+
def set_start_direction_visible(self, client_id: int, visible: bool) -> None:
|
| 409 |
+
marker = self.start_direction_markers.get(client_id)
|
| 410 |
+
if marker is None:
|
| 411 |
+
return
|
| 412 |
+
marker.set_visible(visible)
|
| 413 |
+
|
| 414 |
+
def client_active(self, client_id: int) -> bool:
|
| 415 |
+
return client_id in self.client_sessions
|
| 416 |
+
|
| 417 |
+
def add_character_motion(
|
| 418 |
+
self,
|
| 419 |
+
client: viser.ClientHandle,
|
| 420 |
+
skeleton: SkeletonBase,
|
| 421 |
+
joints_pos: Optional[torch.Tensor] = None,
|
| 422 |
+
joints_rot: Optional[torch.Tensor] = None,
|
| 423 |
+
foot_contacts: Optional[torch.Tensor] = None,
|
| 424 |
+
) -> None:
|
| 425 |
+
client_id = client.client_id
|
| 426 |
+
if not self.client_active(client_id):
|
| 427 |
+
return
|
| 428 |
+
session = self.client_sessions[client_id]
|
| 429 |
+
|
| 430 |
+
ci = len(session.motions)
|
| 431 |
+
character_name = f"character{ci}"
|
| 432 |
+
# build character skeleton and skinning mesh
|
| 433 |
+
if "g1" in session.model_name:
|
| 434 |
+
mesh_mode = "g1_stl"
|
| 435 |
+
elif "smplx" in session.model_name:
|
| 436 |
+
mesh_mode = "smplx_skin"
|
| 437 |
+
elif "soma" in session.model_name:
|
| 438 |
+
if session.gui_elements.gui_use_soma_layer_checkbox.value:
|
| 439 |
+
mesh_mode = "soma_layer_skin"
|
| 440 |
+
else:
|
| 441 |
+
mesh_mode = "soma_skin"
|
| 442 |
+
else:
|
| 443 |
+
raise ValueError("The model name is not recognized for skinning.")
|
| 444 |
+
|
| 445 |
+
new_character = Character(
|
| 446 |
+
character_name,
|
| 447 |
+
client,
|
| 448 |
+
skeleton,
|
| 449 |
+
create_skeleton_mesh=True,
|
| 450 |
+
create_skinned_mesh=True,
|
| 451 |
+
visible_skeleton=False, # don't show immediately
|
| 452 |
+
visible_skinned_mesh=False, # don't show immediately
|
| 453 |
+
skinned_mesh_opacity=session.gui_elements.gui_viz_skinned_mesh_opacity_slider.value,
|
| 454 |
+
show_foot_contacts=session.gui_elements.gui_viz_foot_contacts_checkbox.value,
|
| 455 |
+
dark_mode=session.gui_elements.gui_dark_mode_checkbox.value,
|
| 456 |
+
mesh_mode=mesh_mode,
|
| 457 |
+
gui_use_soma_layer_checkbox=session.gui_elements.gui_use_soma_layer_checkbox,
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
# if no motion given, initialize to character default (rest) pose for one frame
|
| 461 |
+
init_joints_pos, init_joints_rot = new_character.get_pose()
|
| 462 |
+
if joints_pos is None:
|
| 463 |
+
joints_pos = init_joints_pos[None].repeat(session.max_frame_idx + 1, 1, 1)
|
| 464 |
+
if joints_rot is None:
|
| 465 |
+
joints_rot = init_joints_rot[None].repeat(session.max_frame_idx + 1, 1, 1, 1)
|
| 466 |
+
|
| 467 |
+
new_motion = CharacterMotion(new_character, joints_pos, joints_rot, foot_contacts)
|
| 468 |
+
# save the motion in our dict
|
| 469 |
+
session.motions[character_name] = new_motion
|
| 470 |
+
|
| 471 |
+
# put the character at the right frame
|
| 472 |
+
new_motion.set_frame(session.frame_idx)
|
| 473 |
+
|
| 474 |
+
# put them visible with a small delay
|
| 475 |
+
# so that the set_frame function has time to finish
|
| 476 |
+
def _set_visibility():
|
| 477 |
+
new_motion.character.set_skinned_mesh_visibility(session.gui_elements.gui_viz_skinned_mesh_checkbox.value)
|
| 478 |
+
new_motion.character.set_skeleton_visibility(session.gui_elements.gui_viz_skeleton_checkbox.value)
|
| 479 |
+
|
| 480 |
+
timer = threading.Timer(
|
| 481 |
+
0.2, # 0.2s delay
|
| 482 |
+
_set_visibility,
|
| 483 |
+
)
|
| 484 |
+
timer.start()
|
| 485 |
+
|
| 486 |
+
def clear_motions(self, client_id: int) -> None:
|
| 487 |
+
if not self.client_active(client_id):
|
| 488 |
+
return
|
| 489 |
+
session = self.client_sessions[client_id]
|
| 490 |
+
for motion in list(session.motions.values()):
|
| 491 |
+
motion.clear()
|
| 492 |
+
session.motions.clear()
|
| 493 |
+
|
| 494 |
+
def compute_model_constraints_lst(
|
| 495 |
+
self,
|
| 496 |
+
session: ClientSession,
|
| 497 |
+
model_bundle: ModelBundle,
|
| 498 |
+
num_frames: int,
|
| 499 |
+
):
|
| 500 |
+
return generation.compute_model_constraints_lst(session, model_bundle, num_frames, self.device)
|
| 501 |
+
|
| 502 |
+
def check_cuda_health(self) -> bool:
|
| 503 |
+
"""Check if CUDA is still functional.
|
| 504 |
+
|
| 505 |
+
Trigger auto-restart if corrupted.
|
| 506 |
+
"""
|
| 507 |
+
if self.device == "cpu":
|
| 508 |
+
return True
|
| 509 |
+
try:
|
| 510 |
+
torch.tensor([1.0], device=self.device) + torch.tensor([1.0], device=self.device)
|
| 511 |
+
return True
|
| 512 |
+
except RuntimeError as e:
|
| 513 |
+
if "device-side assert" in str(e) or "CUDA error" in str(e):
|
| 514 |
+
if self._cuda_healthy:
|
| 515 |
+
self._cuda_healthy = False
|
| 516 |
+
print("FATAL: CUDA context is corrupted (device-side assert). " "The process must be restarted.")
|
| 517 |
+
self._trigger_restart()
|
| 518 |
+
return False
|
| 519 |
+
raise
|
| 520 |
+
|
| 521 |
+
def _trigger_restart(self) -> None:
|
| 522 |
+
"""Exit the process so the HF Space (or systemd/Docker) can restart it."""
|
| 523 |
+
import sys
|
| 524 |
+
|
| 525 |
+
print("Initiating automatic restart due to unrecoverable CUDA error...")
|
| 526 |
+
sys.stdout.flush()
|
| 527 |
+
sys.stderr.flush()
|
| 528 |
+
os._exit(1)
|
| 529 |
+
|
| 530 |
+
def generate(
|
| 531 |
+
self,
|
| 532 |
+
client: viser.ClientHandle,
|
| 533 |
+
prompts: list[str],
|
| 534 |
+
num_frames: list[int],
|
| 535 |
+
num_samples: int,
|
| 536 |
+
seed: int,
|
| 537 |
+
diffusion_steps: int,
|
| 538 |
+
cfg_weight: Optional[list[float]] = None,
|
| 539 |
+
cfg_type: Optional[str] = None,
|
| 540 |
+
postprocess_parameters: Optional[dict] = None,
|
| 541 |
+
transitions_parameters: Optional[dict] = None,
|
| 542 |
+
real_robot_rotations: bool = False,
|
| 543 |
+
) -> None:
|
| 544 |
+
if not self._cuda_healthy:
|
| 545 |
+
raise RuntimeError("CUDA is in a corrupted state. The space is restarting...")
|
| 546 |
+
|
| 547 |
+
locked = self._generation_lock.acquire(blocking=False)
|
| 548 |
+
if not locked:
|
| 549 |
+
waiting_notif = client.add_notification(
|
| 550 |
+
title="Waiting for GPU...",
|
| 551 |
+
body="Another generation is in progress. Yours will start automatically.",
|
| 552 |
+
loading=True,
|
| 553 |
+
with_close_button=False,
|
| 554 |
+
)
|
| 555 |
+
self._generation_lock.acquire()
|
| 556 |
+
waiting_notif.remove()
|
| 557 |
+
|
| 558 |
+
try:
|
| 559 |
+
session = self.client_sessions[client.client_id]
|
| 560 |
+
model_bundle = self.load_model(session.model_name)
|
| 561 |
+
generation.generate(
|
| 562 |
+
client=client,
|
| 563 |
+
session=session,
|
| 564 |
+
model_bundle=model_bundle,
|
| 565 |
+
prompts=prompts,
|
| 566 |
+
num_frames=num_frames,
|
| 567 |
+
num_samples=num_samples,
|
| 568 |
+
seed=seed,
|
| 569 |
+
diffusion_steps=diffusion_steps,
|
| 570 |
+
cfg_weight=cfg_weight,
|
| 571 |
+
cfg_type=cfg_type,
|
| 572 |
+
postprocess_parameters=postprocess_parameters,
|
| 573 |
+
transitions_parameters=transitions_parameters,
|
| 574 |
+
real_robot_rotations=real_robot_rotations,
|
| 575 |
+
device=self.device,
|
| 576 |
+
clear_motions=self.clear_motions,
|
| 577 |
+
add_character_motion=self.add_character_motion,
|
| 578 |
+
)
|
| 579 |
+
finally:
|
| 580 |
+
self._generation_lock.release()
|
| 581 |
+
|
| 582 |
+
def set_frame(self, client_id: int, frame_idx: int, update_timeline: bool = True):
|
| 583 |
+
if not self.client_active(client_id):
|
| 584 |
+
return
|
| 585 |
+
|
| 586 |
+
session = self.client_sessions[client_id]
|
| 587 |
+
|
| 588 |
+
session.frame_idx = frame_idx
|
| 589 |
+
if update_timeline:
|
| 590 |
+
session.client.timeline.set_current_frame(frame_idx)
|
| 591 |
+
for motion in list(session.motions.values()):
|
| 592 |
+
motion.set_frame(frame_idx)
|
| 593 |
+
self._apply_constraint_overlay_visibility(session)
|
| 594 |
+
|
| 595 |
+
def run(self) -> None:
|
| 596 |
+
update_counter = 0
|
| 597 |
+
cuda_check_interval = 300
|
| 598 |
+
while True:
|
| 599 |
+
last_update_time = time.time()
|
| 600 |
+
if self.models:
|
| 601 |
+
# the max playback speed is 2x the model fps (from gui_playback_speed_buttons)
|
| 602 |
+
playback_fps = max(bundle.model_fps for bundle in self.models.values()) * 2.0
|
| 603 |
+
else:
|
| 604 |
+
playback_fps = 60.0
|
| 605 |
+
|
| 606 |
+
# update each client session independently
|
| 607 |
+
# copy to a list first to avoid changing size if client disconnects
|
| 608 |
+
for client_id, session in list(self.client_sessions.items()):
|
| 609 |
+
update_interval = int(playback_fps / (session.playback_speed * session.model_fps))
|
| 610 |
+
new_frame_idx = session.frame_idx
|
| 611 |
+
if session.playing and update_counter % update_interval == 0:
|
| 612 |
+
if session.frame_idx >= session.max_frame_idx:
|
| 613 |
+
new_frame_idx = 0
|
| 614 |
+
else:
|
| 615 |
+
new_frame_idx = session.frame_idx + 1
|
| 616 |
+
|
| 617 |
+
# make sure the client is still active before updating the frame
|
| 618 |
+
if self.client_active(client_id):
|
| 619 |
+
self.set_frame(client_id, new_frame_idx)
|
| 620 |
+
|
| 621 |
+
if update_counter % cuda_check_interval == 0:
|
| 622 |
+
self.check_cuda_health()
|
| 623 |
+
|
| 624 |
+
time_remaining = max(0, 1.0 / playback_fps - (time.time() - last_update_time))
|
| 625 |
+
time.sleep(time_remaining)
|
| 626 |
+
update_counter += 1
|
| 627 |
+
update_counter %= playback_fps # wrap around to 0 every second
|
| 628 |
+
|
| 629 |
+
def configure_theme(
|
| 630 |
+
self,
|
| 631 |
+
client: viser.ClientHandle,
|
| 632 |
+
dark_mode: bool = False,
|
| 633 |
+
titlebar_dark_mode_checkbox_uuid: str | None = None,
|
| 634 |
+
):
|
| 635 |
+
# Sync grid color with theme (light vs dark)
|
| 636 |
+
theme = DARK_THEME if dark_mode else LIGHT_THEME
|
| 637 |
+
grid_handle = self.grid_handles.get(client.client_id)
|
| 638 |
+
if grid_handle is not None:
|
| 639 |
+
grid_handle.section_color = theme["grid"]
|
| 640 |
+
|
| 641 |
+
#
|
| 642 |
+
# setup theme
|
| 643 |
+
#
|
| 644 |
+
buttons = (
|
| 645 |
+
TitlebarButton(
|
| 646 |
+
text="Documentation",
|
| 647 |
+
icon="Description",
|
| 648 |
+
href="https://research.nvidia.com/labs/sil/projects/kimodo/docs/interactive_demo/index.html",
|
| 649 |
+
),
|
| 650 |
+
TitlebarButton(
|
| 651 |
+
text="Project Page",
|
| 652 |
+
icon=None,
|
| 653 |
+
href="https://research.nvidia.com/labs/sil/projects/kimodo/",
|
| 654 |
+
),
|
| 655 |
+
TitlebarButton(
|
| 656 |
+
text="Github",
|
| 657 |
+
icon="GitHub",
|
| 658 |
+
href="https://github.com/nv-tlabs/kimodo",
|
| 659 |
+
),
|
| 660 |
+
)
|
| 661 |
+
assets_dir = DEMO_ASSETS_ROOT
|
| 662 |
+
logo_light_path = assets_dir / "nvidia_logo.png"
|
| 663 |
+
logo_dark_path = assets_dir / "nvidia_logo_dark.png"
|
| 664 |
+
if logo_light_path.exists():
|
| 665 |
+
light_b64 = base64.standard_b64encode(logo_light_path.read_bytes()).decode("ascii")
|
| 666 |
+
dark_b64 = (
|
| 667 |
+
base64.standard_b64encode(logo_dark_path.read_bytes()).decode("ascii")
|
| 668 |
+
if logo_dark_path.exists()
|
| 669 |
+
else None
|
| 670 |
+
)
|
| 671 |
+
image = TitlebarImage(
|
| 672 |
+
image_url_light=f"data:image/png;base64,{light_b64}",
|
| 673 |
+
image_url_dark=(f"data:image/png;base64,{dark_b64}" if dark_b64 else None),
|
| 674 |
+
image_alt="NVIDIA",
|
| 675 |
+
href="https://www.nvidia.com/",
|
| 676 |
+
)
|
| 677 |
+
else:
|
| 678 |
+
image = None
|
| 679 |
+
titlebar_theme = TitlebarConfig(buttons=buttons, image=image, title_text="Kimodo")
|
| 680 |
+
client.gui.set_panel_label("Kimodo")
|
| 681 |
+
client.gui.configure_theme(
|
| 682 |
+
titlebar_content=titlebar_theme,
|
| 683 |
+
control_layout="floating", # "floating", # ['floating', 'collapsible', 'fixed']
|
| 684 |
+
control_width="large", # ['small', 'medium', 'large']
|
| 685 |
+
dark_mode=dark_mode,
|
| 686 |
+
show_logo=False, # hide viser logo on bottom left corner
|
| 687 |
+
show_share_button=False,
|
| 688 |
+
titlebar_dark_mode_checkbox_uuid=titlebar_dark_mode_checkbox_uuid,
|
| 689 |
+
brand_color=(152, 189, 255), # (60, 131, 0), # (R, G, B) tuple
|
| 690 |
+
)
|
kimodo/demo/config.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
|
| 6 |
+
from kimodo.assets import DEMO_EXAMPLES_ROOT
|
| 7 |
+
from kimodo.model.registry import (
|
| 8 |
+
AVAILABLE_MODELS,
|
| 9 |
+
DEFAULT_MODEL,
|
| 10 |
+
FRIENDLY_NAMES,
|
| 11 |
+
get_datasets,
|
| 12 |
+
get_model_info,
|
| 13 |
+
get_models_for_dataset_skeleton,
|
| 14 |
+
get_short_key_from_display_name,
|
| 15 |
+
get_skeleton_display_name,
|
| 16 |
+
get_skeleton_display_names_for_dataset,
|
| 17 |
+
get_skeleton_key_from_display_name,
|
| 18 |
+
get_skeletons_for_dataset,
|
| 19 |
+
get_versions_for_dataset_skeleton,
|
| 20 |
+
resolve_to_short_key,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
SERVER_NAME = os.environ.get("SERVER_NAME", "0.0.0.0")
|
| 24 |
+
SERVER_PORT = int(os.environ.get("SERVER_PORT", "7860"))
|
| 25 |
+
HF_MODE = os.environ.get("HF_MODE", False)
|
| 26 |
+
|
| 27 |
+
# HF mode: user queue and session limit (override via env in Spaces)
|
| 28 |
+
MAX_ACTIVE_USERS = int(os.environ.get("MAX_ACTIVE_USERS", "5"))
|
| 29 |
+
MAX_SESSION_MINUTES = float(os.environ.get("MAX_SESSION_MINUTES", "5.0"))
|
| 30 |
+
|
| 31 |
+
DEFAULT_PLAYBACK_SPEED = 1.0
|
| 32 |
+
# default start duration is 6.0 sec, but model can handle up to 10 sec
|
| 33 |
+
DEFAULT_CUR_DURATION = 6.0
|
| 34 |
+
DEFAULT_PROMPT = "A person walks forward."
|
| 35 |
+
MIN_DURATION = 2.0
|
| 36 |
+
MAX_DURATION = 10.0
|
| 37 |
+
|
| 38 |
+
SHOW_TRANSITION_PARAMS = True
|
| 39 |
+
INIT_POSTPROCESSING = True
|
| 40 |
+
NB_TRANSITION_FRAMES = 5
|
| 41 |
+
|
| 42 |
+
LIGHT_THEME = dict(
|
| 43 |
+
floor=(220, 220, 220),
|
| 44 |
+
grid=(180, 180, 180),
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Dark theme: slightly lighter grid and floor for better visibility and less flat black
|
| 48 |
+
DARK_THEME = dict(
|
| 49 |
+
floor=(48, 48, 52),
|
| 50 |
+
grid=(105, 105, 110),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
EXAMPLES_ROOT_DIR = str(DEMO_EXAMPLES_ROOT)
|
| 54 |
+
|
| 55 |
+
# Model list and paths from kimodo registry (all models: Kimodo + TMR)
|
| 56 |
+
MODEL_NAMES = tuple(AVAILABLE_MODELS)
|
| 57 |
+
MODEL_EXAMPLES_DIRS = {name: os.path.join(EXAMPLES_ROOT_DIR, name) for name in MODEL_NAMES}
|
| 58 |
+
# Display labels for backward compatibility (short_key -> display name)
|
| 59 |
+
MODEL_LABELS = {name: FRIENDLY_NAMES.get(name, f"Model ({name})") for name in MODEL_NAMES}
|
| 60 |
+
MODEL_LABEL_TO_NAME = {label: name for name, label in MODEL_LABELS.items()}
|
| 61 |
+
|
| 62 |
+
# -----------------------------------------------------------------------------
|
| 63 |
+
# Demo UI copy
|
| 64 |
+
# -----------------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
DEMO_UI_QUICK_START_CORE_MD = """
|
| 67 |
+
### Camera
|
| 68 |
+
- **Left-drag**: rotate
|
| 69 |
+
- **Right-drag**: pan
|
| 70 |
+
- **Scroll**: zoom
|
| 71 |
+
|
| 72 |
+
### Playback
|
| 73 |
+
- **Space** to play/pause
|
| 74 |
+
- **←/→** to step frames, or click the frame number.
|
| 75 |
+
- **Scroll up/down** in the timeline: move left/right
|
| 76 |
+
- **Shift + scroll** in the timeline: zoom in/out
|
| 77 |
+
|
| 78 |
+
### Prompts
|
| 79 |
+
- **Double-click** a text prompt to edit it.
|
| 80 |
+
- **Click and drag** the right edge of a prompt box to extend/shorten it.
|
| 81 |
+
- **Click empty space** to add a prompt.
|
| 82 |
+
- **Right-click** a prompt to delete it.
|
| 83 |
+
|
| 84 |
+
### Generate
|
| 85 |
+
- Go to the **Generate** tab to modify options
|
| 86 |
+
- It is also possible to **load** examples
|
| 87 |
+
- Click **Generate** to generate a motion
|
| 88 |
+
|
| 89 |
+
### Constraints
|
| 90 |
+
- This is **optional**: should be use after a first generation
|
| 91 |
+
- **Click** in the timeline tracks (Full-Body / 2D root etc) to add a constraint.
|
| 92 |
+
- **Right-click** on a constraint to delete it.
|
| 93 |
+
- To **edit** a constraint:
|
| 94 |
+
- Move playback to the target frame
|
| 95 |
+
- Click **Enter Editing Mode** in the Constraints tab.
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
DEMO_UI_QUICK_START_MODAL_MD = (
|
| 99 |
+
DEMO_UI_QUICK_START_CORE_MD
|
| 100 |
+
+ """
|
| 101 |
+
|
| 102 |
+
See the **Instructions** tab for the full user manual.
|
| 103 |
+
"""
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
DEMO_UI_INSTRUCTIONS_TAB_MD = (
|
| 107 |
+
"""
|
| 108 |
+
## How to Use This Demo
|
| 109 |
+
|
| 110 |
+
"""
|
| 111 |
+
+ DEMO_UI_QUICK_START_CORE_MD
|
| 112 |
+
+ """
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
### Generating Motion (step-by-step)
|
| 117 |
+
|
| 118 |
+
1. **Edit the text prompts** in the timeline (e.g., "A person walks forward.")
|
| 119 |
+
2. **Modify the duration** by moving the right edge of each prompts (2–10 seconds)
|
| 120 |
+
3. **Add constraints** (optional) to control the motion:
|
| 121 |
+
- Click **Enter Editing Mode** to adjust the character pose
|
| 122 |
+
- Use the timeline to place keyframes or intervals in constraint tracks (see below)
|
| 123 |
+
4. **Click Generate** to create the motion
|
| 124 |
+
5. If generating multiple samples, **click on a mesh** to select which one to keep
|
| 125 |
+
|
| 126 |
+
### Timeline Editing
|
| 127 |
+
|
| 128 |
+
**Adding Constraints:**
|
| 129 |
+
1. Click anywhere on the timeline to add a keyframe at that frame. The keyframe is created based on the current character motion.
|
| 130 |
+
2. Ctrl/Cmd+click+drag to add an interval constraint, or expand a keyframe into an interval
|
| 131 |
+
3. Enter editing mode with the **Enter Editing Mode** button to adjust character pose before/after adding constraints.
|
| 132 |
+
|
| 133 |
+
**Constraint Types:**
|
| 134 |
+
- **Full-Body**: constrains the entire character pose
|
| 135 |
+
- **2D Root**: constrains the character's path on the ground plane
|
| 136 |
+
- Enable **Densify** to create a continuous path
|
| 137 |
+
- **End-Effectors**: constrains hands and feet positions
|
| 138 |
+
- Use separate tracks for Left/Right Hand/Foot
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
**Moving & Deleting:**
|
| 142 |
+
- **Drag keyframes/intervals** to move them to different frames
|
| 143 |
+
- **Right-click** a keyframe or interval to delete it
|
| 144 |
+
- Use **Clear All Constraints** to remove everything
|
| 145 |
+
|
| 146 |
+
**Tips:**
|
| 147 |
+
- The posing skeleton becomes visible in editing mode for precise positioning
|
| 148 |
+
- Use **Snap to constraint** to align the current frame to a constraint
|
| 149 |
+
|
| 150 |
+
### Saving & Loading
|
| 151 |
+
|
| 152 |
+
You can save the current constraints or current motion to load in later from the Load/Save menu.
|
| 153 |
+
Saving an **Example** will save the full constraints, motion, and generation metadata.
|
| 154 |
+
|
| 155 |
+
### Visualization Options
|
| 156 |
+
|
| 157 |
+
Switch to the **Visualize** tab to:
|
| 158 |
+
- Toggle mesh and skeleton visibility
|
| 159 |
+
- Adjust mesh opacity
|
| 160 |
+
- Show/hide foot contact indicators
|
| 161 |
+
- Switch between light and dark modes
|
| 162 |
+
"""
|
| 163 |
+
)
|
kimodo/demo/embedding_cache.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
import contextlib
|
| 5 |
+
import contextvars
|
| 6 |
+
import hashlib
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
from collections import OrderedDict
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from typing import Iterable, Optional
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
import torch
|
| 17 |
+
|
| 18 |
+
from kimodo.sanitize import sanitize_texts
|
| 19 |
+
|
| 20 |
+
_ACTIVE_SESSION = contextvars.ContextVar("kimodo_demo_active_session", default=None)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class CacheStats:
|
| 25 |
+
hits: int = 0
|
| 26 |
+
misses: int = 0
|
| 27 |
+
disk_hits: int = 0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class EmbeddingCache:
|
| 31 |
+
"""Disk-backed text embedding cache with a small in-memory LRU."""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
*,
|
| 36 |
+
model_name: str,
|
| 37 |
+
encoder_id: str,
|
| 38 |
+
base_dir: Optional[str] = None,
|
| 39 |
+
max_mem_entries: int = 128,
|
| 40 |
+
) -> None:
|
| 41 |
+
cache_root = base_dir or os.environ.get(
|
| 42 |
+
"kimodo_EMBED_CACHE_DIR",
|
| 43 |
+
os.path.join("~", ".cache", "kimodo_demo", "embeddings"),
|
| 44 |
+
)
|
| 45 |
+
self.base_dir = os.path.expanduser(cache_root)
|
| 46 |
+
self.model_name = model_name
|
| 47 |
+
self.encoder_id = encoder_id
|
| 48 |
+
self.max_mem_entries = max_mem_entries
|
| 49 |
+
self.stats = CacheStats()
|
| 50 |
+
|
| 51 |
+
self._lock = threading.Lock()
|
| 52 |
+
self._mem_cache: OrderedDict[str, np.ndarray] = OrderedDict()
|
| 53 |
+
self._index = {}
|
| 54 |
+
self._index_loaded = False
|
| 55 |
+
|
| 56 |
+
def _model_dir(self) -> str:
|
| 57 |
+
return os.path.join(self.base_dir, self.model_name)
|
| 58 |
+
|
| 59 |
+
def _index_path(self) -> str:
|
| 60 |
+
return os.path.join(self._model_dir(), "index.json")
|
| 61 |
+
|
| 62 |
+
def _prewarm_marker_path(self, key: str) -> str:
|
| 63 |
+
return os.path.join(self._model_dir(), f"prewarm_{key}.json")
|
| 64 |
+
|
| 65 |
+
def has_prewarm_marker(self, key: str) -> bool:
|
| 66 |
+
return os.path.exists(self._prewarm_marker_path(key))
|
| 67 |
+
|
| 68 |
+
def write_prewarm_marker(self, key: str, *, prompt_count: int) -> None:
|
| 69 |
+
os.makedirs(self._model_dir(), exist_ok=True)
|
| 70 |
+
payload = {"prompt_count": prompt_count, "updated_at": time.time()}
|
| 71 |
+
tmp_path = f"{self._prewarm_marker_path(key)}.tmp"
|
| 72 |
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
| 73 |
+
json.dump(payload, f)
|
| 74 |
+
os.replace(tmp_path, self._prewarm_marker_path(key))
|
| 75 |
+
|
| 76 |
+
def _load_index(self) -> None:
|
| 77 |
+
if self._index_loaded:
|
| 78 |
+
return
|
| 79 |
+
index_path = self._index_path()
|
| 80 |
+
if os.path.exists(index_path):
|
| 81 |
+
try:
|
| 82 |
+
with open(index_path, "r", encoding="utf-8") as f:
|
| 83 |
+
self._index = json.load(f)
|
| 84 |
+
except json.JSONDecodeError:
|
| 85 |
+
self._index = {}
|
| 86 |
+
self._index_loaded = True
|
| 87 |
+
|
| 88 |
+
def _save_index(self) -> None:
|
| 89 |
+
os.makedirs(self._model_dir(), exist_ok=True)
|
| 90 |
+
tmp_path = f"{self._index_path()}.tmp"
|
| 91 |
+
with open(tmp_path, "w", encoding="utf-8") as f:
|
| 92 |
+
json.dump(self._index, f)
|
| 93 |
+
os.replace(tmp_path, self._index_path())
|
| 94 |
+
|
| 95 |
+
def _make_key(self, text: str) -> str:
|
| 96 |
+
key_src = f"{self.model_name}|{self.encoder_id}|{text}"
|
| 97 |
+
return hashlib.sha256(key_src.encode("utf-8")).hexdigest()
|
| 98 |
+
|
| 99 |
+
def _entry_path(self, key: str) -> str:
|
| 100 |
+
return os.path.join(self._model_dir(), f"{key}.npy")
|
| 101 |
+
|
| 102 |
+
def _mem_get(self, key: str) -> Optional[np.ndarray]:
|
| 103 |
+
if key in self._mem_cache:
|
| 104 |
+
self._mem_cache.move_to_end(key)
|
| 105 |
+
return self._mem_cache[key]
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
def _mem_put(self, key: str, value: np.ndarray) -> None:
|
| 109 |
+
self._mem_cache[key] = value
|
| 110 |
+
self._mem_cache.move_to_end(key)
|
| 111 |
+
while len(self._mem_cache) > self.max_mem_entries:
|
| 112 |
+
self._mem_cache.popitem(last=False)
|
| 113 |
+
|
| 114 |
+
def _disk_load(self, key: str) -> Optional[np.ndarray]:
|
| 115 |
+
path = self._entry_path(key)
|
| 116 |
+
if not os.path.exists(path):
|
| 117 |
+
return None
|
| 118 |
+
try:
|
| 119 |
+
return np.load(path)
|
| 120 |
+
except Exception:
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
def _disk_save(self, key: str, value: np.ndarray) -> None:
|
| 124 |
+
os.makedirs(self._model_dir(), exist_ok=True)
|
| 125 |
+
np.save(self._entry_path(key), value)
|
| 126 |
+
self._index[key] = {
|
| 127 |
+
"length": int(value.shape[0]),
|
| 128 |
+
"dtype": str(value.dtype),
|
| 129 |
+
"updated_at": time.time(),
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
def _maybe_use_session_cache(self, texts: list[str]):
|
| 133 |
+
session = _ACTIVE_SESSION.get()
|
| 134 |
+
if session is None:
|
| 135 |
+
return None
|
| 136 |
+
if session.last_prompt_texts == texts and session.last_prompt_embeddings is not None:
|
| 137 |
+
return session.last_prompt_embeddings, session.last_prompt_lengths
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
def _update_session_cache(self, texts: list[str], tensor: torch.Tensor, lengths: list[int]) -> None:
|
| 141 |
+
session = _ACTIVE_SESSION.get()
|
| 142 |
+
if session is None:
|
| 143 |
+
return
|
| 144 |
+
session.last_prompt_texts = texts
|
| 145 |
+
session.last_prompt_embeddings = tensor
|
| 146 |
+
session.last_prompt_lengths = lengths
|
| 147 |
+
|
| 148 |
+
def get_or_encode(self, texts: Iterable[str], encoder):
|
| 149 |
+
if isinstance(texts, str):
|
| 150 |
+
texts = [texts]
|
| 151 |
+
texts = sanitize_texts(list(texts))
|
| 152 |
+
if len(texts) == 0:
|
| 153 |
+
empty = torch.empty()
|
| 154 |
+
return empty, []
|
| 155 |
+
|
| 156 |
+
session_cache = self._maybe_use_session_cache(texts)
|
| 157 |
+
if session_cache is not None:
|
| 158 |
+
return session_cache
|
| 159 |
+
|
| 160 |
+
arrays: list[Optional[np.ndarray]] = [None] * len(texts)
|
| 161 |
+
lengths: list[int] = [0] * len(texts)
|
| 162 |
+
misses: list[tuple[int, str, str]] = []
|
| 163 |
+
|
| 164 |
+
with self._lock:
|
| 165 |
+
self._load_index()
|
| 166 |
+
for idx, text in enumerate(texts):
|
| 167 |
+
key = self._make_key(text)
|
| 168 |
+
cached = self._mem_get(key)
|
| 169 |
+
if cached is not None:
|
| 170 |
+
arrays[idx] = cached
|
| 171 |
+
lengths[idx] = cached.shape[0]
|
| 172 |
+
self.stats.hits += 1
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
cached = self._disk_load(key)
|
| 176 |
+
if cached is not None:
|
| 177 |
+
arrays[idx] = cached
|
| 178 |
+
lengths[idx] = cached.shape[0]
|
| 179 |
+
self._mem_put(key, cached)
|
| 180 |
+
self.stats.disk_hits += 1
|
| 181 |
+
continue
|
| 182 |
+
|
| 183 |
+
misses.append((idx, text, key))
|
| 184 |
+
self.stats.misses += 1
|
| 185 |
+
|
| 186 |
+
if misses:
|
| 187 |
+
miss_texts = [text for _, text, _ in misses]
|
| 188 |
+
miss_tensor, miss_lengths = encoder(miss_texts)
|
| 189 |
+
miss_tensor = miss_tensor.detach().cpu()
|
| 190 |
+
miss_tensor_np = miss_tensor.numpy()
|
| 191 |
+
|
| 192 |
+
with self._lock:
|
| 193 |
+
self._load_index()
|
| 194 |
+
for miss_idx, length in enumerate(miss_lengths):
|
| 195 |
+
idx, _text, key = misses[miss_idx]
|
| 196 |
+
arr = miss_tensor_np[miss_idx, :length].copy()
|
| 197 |
+
arrays[idx] = arr
|
| 198 |
+
lengths[idx] = int(length)
|
| 199 |
+
self._mem_put(key, arr)
|
| 200 |
+
self._disk_save(key, arr)
|
| 201 |
+
self._save_index()
|
| 202 |
+
|
| 203 |
+
max_len = max(lengths) if lengths else 0
|
| 204 |
+
feat_dim = arrays[0].shape[-1] if arrays[0] is not None else 0
|
| 205 |
+
dtype = arrays[0].dtype if arrays[0] is not None else np.float32
|
| 206 |
+
padded = np.zeros((len(texts), max_len, feat_dim), dtype=dtype)
|
| 207 |
+
for idx, arr in enumerate(arrays):
|
| 208 |
+
if arr is None:
|
| 209 |
+
continue
|
| 210 |
+
padded[idx, : arr.shape[0]] = arr
|
| 211 |
+
|
| 212 |
+
result = torch.from_numpy(padded)
|
| 213 |
+
self._update_session_cache(texts, result, lengths)
|
| 214 |
+
return result, lengths
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class CachedTextEncoder:
|
| 218 |
+
"""Wrapper around a text encoder to add disk-backed caching."""
|
| 219 |
+
|
| 220 |
+
def __init__(self, encoder, *, model_name: str, base_dir: Optional[str] = None):
|
| 221 |
+
self.encoder = encoder
|
| 222 |
+
self.model_name = model_name
|
| 223 |
+
encoder_id = f"{type(encoder).__name__}"
|
| 224 |
+
self.cache = EmbeddingCache(model_name=model_name, encoder_id=encoder_id, base_dir=base_dir)
|
| 225 |
+
|
| 226 |
+
def __call__(self, texts):
|
| 227 |
+
return self.cache.get_or_encode(texts, self.encoder)
|
| 228 |
+
|
| 229 |
+
def prewarm(self, texts) -> None:
|
| 230 |
+
if isinstance(texts, str):
|
| 231 |
+
texts = [texts]
|
| 232 |
+
texts = sanitize_texts(list(texts))
|
| 233 |
+
prewarm_key = hashlib.sha256("|".join(texts).encode("utf-8")).hexdigest()
|
| 234 |
+
if self.cache.has_prewarm_marker(prewarm_key):
|
| 235 |
+
return
|
| 236 |
+
self.cache.get_or_encode(texts, self.encoder)
|
| 237 |
+
self.cache.write_prewarm_marker(prewarm_key, prompt_count=len(texts))
|
| 238 |
+
|
| 239 |
+
def to(self, device=None, dtype=None):
|
| 240 |
+
if hasattr(self.encoder, "to"):
|
| 241 |
+
self.encoder.to(device=device, dtype=dtype)
|
| 242 |
+
return self
|
| 243 |
+
|
| 244 |
+
@contextlib.contextmanager
|
| 245 |
+
def session_context(self, session):
|
| 246 |
+
token = _ACTIVE_SESSION.set(session)
|
| 247 |
+
try:
|
| 248 |
+
yield
|
| 249 |
+
finally:
|
| 250 |
+
_ACTIVE_SESSION.reset(token)
|
| 251 |
+
|
| 252 |
+
def __getattr__(self, name):
|
| 253 |
+
return getattr(self.encoder, name)
|
kimodo/demo/generation.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
import viser
|
| 11 |
+
from kimodo.constraints import (
|
| 12 |
+
TYPE_TO_CLASS,
|
| 13 |
+
FullBodyConstraintSet,
|
| 14 |
+
Root2DConstraintSet,
|
| 15 |
+
)
|
| 16 |
+
from kimodo.exports.mujoco import apply_g1_real_robot_projection
|
| 17 |
+
from kimodo.skeleton import G1Skeleton34, SOMASkeleton30
|
| 18 |
+
from kimodo.tools import seed_everything
|
| 19 |
+
|
| 20 |
+
from .embedding_cache import CachedTextEncoder
|
| 21 |
+
from .state import ClientSession, ModelBundle
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def compute_model_constraints_lst(
|
| 25 |
+
session: ClientSession,
|
| 26 |
+
model_bundle: ModelBundle,
|
| 27 |
+
num_frames: int,
|
| 28 |
+
device: str,
|
| 29 |
+
):
|
| 30 |
+
"""Compute the lst of constraints for the model based on the constraints in viser."""
|
| 31 |
+
assert len(session.motions) == 1, "Only one motion allowed for constrained generation"
|
| 32 |
+
if not session.constraints:
|
| 33 |
+
return []
|
| 34 |
+
|
| 35 |
+
model_skeleton = model_bundle.model.skeleton
|
| 36 |
+
# For SOMA, UI uses somaskel77; extract 30-joint subset for the model
|
| 37 |
+
use_skel_slice = isinstance(model_skeleton, SOMASkeleton30) and session.skeleton.nbjoints != model_skeleton.nbjoints
|
| 38 |
+
skel_slice = model_skeleton.get_skel_slice(session.skeleton) if use_skel_slice else None
|
| 39 |
+
|
| 40 |
+
dense_smooth_root_pos_2d = None
|
| 41 |
+
if session.constraints["2D Root"].dense_path:
|
| 42 |
+
# get the full 2d root
|
| 43 |
+
dense_smooth_root_pos_2d = session.constraints["2D Root"].get_constraint_info(device=device)["root_pos"][
|
| 44 |
+
:, [0, 2]
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
model_constraints = []
|
| 48 |
+
for track_name, constraint in session.constraints.items():
|
| 49 |
+
constraint_info = constraint.get_constraint_info(device=device)
|
| 50 |
+
frame_idx = constraint_info["frame_idx"]
|
| 51 |
+
# drop any constraints outside the generation range
|
| 52 |
+
valid_info = [(i, fi) for i, fi in enumerate(frame_idx) if fi < num_frames]
|
| 53 |
+
valid_idx = [i for i, _ in valid_info]
|
| 54 |
+
valid_frame_idx = [fi for _, fi in valid_info]
|
| 55 |
+
|
| 56 |
+
if len(valid_frame_idx) == 0:
|
| 57 |
+
continue
|
| 58 |
+
|
| 59 |
+
frame_indices = torch.tensor(valid_frame_idx)
|
| 60 |
+
if track_name == "2D Root":
|
| 61 |
+
smooth_root_pos_2d = constraint_info["root_pos"][valid_idx][:, [0, 2]].to(device)
|
| 62 |
+
# same as "smooth_root_2d"
|
| 63 |
+
model_constraints.append(
|
| 64 |
+
Root2DConstraintSet(
|
| 65 |
+
model_skeleton,
|
| 66 |
+
frame_indices,
|
| 67 |
+
smooth_root_pos_2d,
|
| 68 |
+
)
|
| 69 |
+
)
|
| 70 |
+
elif track_name == "Full-Body":
|
| 71 |
+
constraint_joints_pos = constraint_info["joints_pos"][valid_idx].to(device)
|
| 72 |
+
constraint_joints_rot = constraint_info["joints_rot"][valid_idx].to(device)
|
| 73 |
+
if skel_slice is not None:
|
| 74 |
+
constraint_joints_pos = constraint_joints_pos[:, skel_slice]
|
| 75 |
+
constraint_joints_rot = constraint_joints_rot[:, skel_slice]
|
| 76 |
+
|
| 77 |
+
smooth_root_pos_2d = None
|
| 78 |
+
if dense_smooth_root_pos_2d is not None:
|
| 79 |
+
smooth_root_pos_2d = dense_smooth_root_pos_2d[frame_indices]
|
| 80 |
+
|
| 81 |
+
model_constraints.append(
|
| 82 |
+
FullBodyConstraintSet(
|
| 83 |
+
model_skeleton,
|
| 84 |
+
frame_indices,
|
| 85 |
+
constraint_joints_pos,
|
| 86 |
+
constraint_joints_rot,
|
| 87 |
+
smooth_root_2d=smooth_root_pos_2d,
|
| 88 |
+
)
|
| 89 |
+
)
|
| 90 |
+
elif track_name == "End-Effectors":
|
| 91 |
+
constraint_joints_pos = constraint_info["joints_pos"][valid_idx].to(device)
|
| 92 |
+
constraint_joints_rot = constraint_info["joints_rot"][valid_idx].to(device)
|
| 93 |
+
if skel_slice is not None:
|
| 94 |
+
constraint_joints_pos = constraint_joints_pos[:, skel_slice]
|
| 95 |
+
constraint_joints_rot = constraint_joints_rot[:, skel_slice]
|
| 96 |
+
|
| 97 |
+
end_effector_type_set_lst = [
|
| 98 |
+
end_effector_type_set
|
| 99 |
+
for i, end_effector_type_set in enumerate(constraint_info["end_effector_type"])
|
| 100 |
+
if i in valid_idx
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
# regroup the end effector data by type
|
| 104 |
+
cls_idx = defaultdict(list)
|
| 105 |
+
for idx, end_effector_type_set in enumerate(end_effector_type_set_lst):
|
| 106 |
+
for end_effector_type in end_effector_type_set:
|
| 107 |
+
cls_idx[TYPE_TO_CLASS[end_effector_type]].append(idx)
|
| 108 |
+
|
| 109 |
+
for cls, lst_idx in cls_idx.items():
|
| 110 |
+
frame_indices_cls = frame_indices[lst_idx]
|
| 111 |
+
smooth_root_pos_2d = None
|
| 112 |
+
if dense_smooth_root_pos_2d is not None:
|
| 113 |
+
smooth_root_pos_2d = dense_smooth_root_pos_2d[frame_indices_cls]
|
| 114 |
+
|
| 115 |
+
constraint_joints_pos_el = constraint_joints_pos[lst_idx]
|
| 116 |
+
constraint_joints_rot_el = constraint_joints_rot[lst_idx]
|
| 117 |
+
|
| 118 |
+
model_constraints.append(
|
| 119 |
+
cls(
|
| 120 |
+
model_skeleton,
|
| 121 |
+
frame_indices_cls,
|
| 122 |
+
constraint_joints_pos_el,
|
| 123 |
+
constraint_joints_rot_el,
|
| 124 |
+
smooth_root_2d=smooth_root_pos_2d,
|
| 125 |
+
)
|
| 126 |
+
)
|
| 127 |
+
else:
|
| 128 |
+
raise ValueError(f"Unsupported constraint type: {constraint.display_name}")
|
| 129 |
+
return model_constraints
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def generate(
|
| 133 |
+
*,
|
| 134 |
+
client: viser.ClientHandle,
|
| 135 |
+
session: ClientSession,
|
| 136 |
+
model_bundle: ModelBundle,
|
| 137 |
+
prompts: list[str],
|
| 138 |
+
num_frames: list[int],
|
| 139 |
+
num_samples: int,
|
| 140 |
+
seed: int,
|
| 141 |
+
diffusion_steps: int,
|
| 142 |
+
cfg_weight: Optional[list[float]] = None,
|
| 143 |
+
cfg_type: Optional[str] = None,
|
| 144 |
+
postprocess_parameters: Optional[dict] = None,
|
| 145 |
+
transitions_parameters: Optional[dict] = None,
|
| 146 |
+
real_robot_rotations: bool = False,
|
| 147 |
+
device: str,
|
| 148 |
+
clear_motions,
|
| 149 |
+
add_character_motion,
|
| 150 |
+
) -> None:
|
| 151 |
+
client_id = client.client_id
|
| 152 |
+
print(
|
| 153 |
+
f"Generating {num_samples} samples for a total of {sum(num_frames)} frames with those prompt: {prompts} (client {client_id})"
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
seed_everything(seed)
|
| 157 |
+
|
| 158 |
+
model_constraints = compute_model_constraints_lst(session, model_bundle, sum(num_frames), device)
|
| 159 |
+
cfg_weight = cfg_weight or [2.0, 2.0]
|
| 160 |
+
postprocess_parameters = postprocess_parameters or {}
|
| 161 |
+
transitions_parameters = transitions_parameters or {}
|
| 162 |
+
|
| 163 |
+
encoder = getattr(model_bundle.model, "text_encoder", None)
|
| 164 |
+
if isinstance(encoder, CachedTextEncoder):
|
| 165 |
+
with encoder.session_context(session):
|
| 166 |
+
pred_joints_output = model_bundle.model(
|
| 167 |
+
prompts,
|
| 168 |
+
num_frames,
|
| 169 |
+
diffusion_steps,
|
| 170 |
+
multi_prompt=True,
|
| 171 |
+
constraint_lst=model_constraints,
|
| 172 |
+
cfg_weight=cfg_weight,
|
| 173 |
+
num_samples=num_samples,
|
| 174 |
+
cfg_type=cfg_type,
|
| 175 |
+
**(postprocess_parameters | transitions_parameters),
|
| 176 |
+
) # [B, T, motion_rep_dim]
|
| 177 |
+
else:
|
| 178 |
+
pred_joints_output = model_bundle.model(
|
| 179 |
+
prompts,
|
| 180 |
+
num_frames,
|
| 181 |
+
diffusion_steps,
|
| 182 |
+
multi_prompt=True,
|
| 183 |
+
constraint_lst=model_constraints,
|
| 184 |
+
cfg_weight=cfg_weight,
|
| 185 |
+
num_samples=num_samples,
|
| 186 |
+
cfg_type=cfg_type,
|
| 187 |
+
**(postprocess_parameters | transitions_parameters),
|
| 188 |
+
) # [B, T, motion_rep_dim]
|
| 189 |
+
|
| 190 |
+
joints_pos = pred_joints_output["posed_joints"] # [B, T, J, 3]
|
| 191 |
+
joints_rot = pred_joints_output["global_rot_mats"]
|
| 192 |
+
foot_contacts = pred_joints_output.get("foot_contacts")
|
| 193 |
+
|
| 194 |
+
# Optionally project G1 to real robot DoF (1-DoF per joint, clamped) for display.
|
| 195 |
+
if real_robot_rotations and isinstance(session.skeleton, G1Skeleton34):
|
| 196 |
+
joints_pos, joints_rot = apply_g1_real_robot_projection(
|
| 197 |
+
session.skeleton,
|
| 198 |
+
pred_joints_output["posed_joints"],
|
| 199 |
+
pred_joints_output["global_rot_mats"],
|
| 200 |
+
clamp_to_limits=True,
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Display on characters (callbacks keep this module UI-agnostic).
|
| 204 |
+
clear_motions(client_id)
|
| 205 |
+
# Keep one sample centered at the origin so constraints align.
|
| 206 |
+
spread_factor = 1.0 # meters
|
| 207 |
+
center_idx = num_samples // 2
|
| 208 |
+
x_trans = (np.arange(num_samples) - center_idx) * spread_factor
|
| 209 |
+
for i in range(num_samples):
|
| 210 |
+
cur_joints_pos = joints_pos[i]
|
| 211 |
+
cur_joints_pos[..., 0] += x_trans[i]
|
| 212 |
+
add_character_motion(
|
| 213 |
+
client,
|
| 214 |
+
session.skeleton,
|
| 215 |
+
cur_joints_pos,
|
| 216 |
+
joints_rot[i],
|
| 217 |
+
foot_contacts[i],
|
| 218 |
+
)
|
kimodo/demo/queue_manager.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""HF mode user queue and session time limit."""
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import threading
|
| 7 |
+
import time
|
| 8 |
+
from collections.abc import Callable
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import viser
|
| 12 |
+
|
| 13 |
+
from .config import DEMO_UI_QUICK_START_MODAL_MD, MAX_SESSION_MINUTES
|
| 14 |
+
|
| 15 |
+
# Link for "Duplicate this Space" on Hugging Face (used in queue and expiry modals).
|
| 16 |
+
DUPLICATE_SPACE_URL = "https://huggingface.co/spaces/nvidia/Kimodo?duplicate=true"
|
| 17 |
+
GITHUB_REPO_URL = "https://github.com/nv-tlabs/kimodo"
|
| 18 |
+
|
| 19 |
+
# How often to refresh queue modal content (position, total, estimated wait).
|
| 20 |
+
QUEUE_MODAL_REFRESH_INTERVAL_SEC = 15
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class UserQueue:
|
| 24 |
+
"""Thread-safe queue: active users (with activation timestamp) and waiting queue."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, max_active: int, max_minutes: float) -> None:
|
| 27 |
+
self._max_active = max_active
|
| 28 |
+
self._max_minutes = max_minutes
|
| 29 |
+
self._max_seconds = max_minutes * 60.0
|
| 30 |
+
self._active: dict[int, float] = {} # client_id -> activation timestamp
|
| 31 |
+
self._queued: list[int] = []
|
| 32 |
+
self._lock = threading.Lock()
|
| 33 |
+
|
| 34 |
+
def try_activate(self, client_id: int) -> bool:
|
| 35 |
+
"""If a slot is free, add client as active and return True.
|
| 36 |
+
|
| 37 |
+
Else return False.
|
| 38 |
+
"""
|
| 39 |
+
with self._lock:
|
| 40 |
+
if len(self._active) < self._max_active:
|
| 41 |
+
self._active[client_id] = time.time()
|
| 42 |
+
return True
|
| 43 |
+
return False
|
| 44 |
+
|
| 45 |
+
def enqueue(self, client_id: int) -> None:
|
| 46 |
+
with self._lock:
|
| 47 |
+
if client_id not in self._queued:
|
| 48 |
+
self._queued.append(client_id)
|
| 49 |
+
|
| 50 |
+
def remove(self, client_id: int) -> bool:
|
| 51 |
+
"""Remove from active or queue.
|
| 52 |
+
|
| 53 |
+
Returns True if was active.
|
| 54 |
+
"""
|
| 55 |
+
with self._lock:
|
| 56 |
+
was_active = client_id in self._active
|
| 57 |
+
self._active.pop(client_id, None)
|
| 58 |
+
if client_id in self._queued:
|
| 59 |
+
self._queued.remove(client_id)
|
| 60 |
+
return was_active
|
| 61 |
+
|
| 62 |
+
def promote_next(self) -> int | None:
|
| 63 |
+
"""If queue non-empty, pop first, activate them, return their client_id.
|
| 64 |
+
|
| 65 |
+
Else None.
|
| 66 |
+
"""
|
| 67 |
+
with self._lock:
|
| 68 |
+
if not self._queued:
|
| 69 |
+
return None
|
| 70 |
+
client_id = self._queued.pop(0)
|
| 71 |
+
self._active[client_id] = time.time()
|
| 72 |
+
return client_id
|
| 73 |
+
|
| 74 |
+
def get_queue_position(self, client_id: int) -> tuple[int, int] | None:
|
| 75 |
+
"""(1-based position, total_in_queue) or None if not queued."""
|
| 76 |
+
with self._lock:
|
| 77 |
+
if client_id not in self._queued:
|
| 78 |
+
return None
|
| 79 |
+
pos = self._queued.index(client_id)
|
| 80 |
+
return (pos + 1, len(self._queued))
|
| 81 |
+
|
| 82 |
+
def get_estimated_wait_seconds(self, client_id: int) -> float:
|
| 83 |
+
"""Estimated seconds until this queued client gets a slot."""
|
| 84 |
+
with self._lock:
|
| 85 |
+
if client_id not in self._queued:
|
| 86 |
+
return 0.0
|
| 87 |
+
pos = self._queued.index(client_id) + 1 # 1-based
|
| 88 |
+
# Expiry times of active users (when they free a slot)
|
| 89 |
+
now = time.time()
|
| 90 |
+
expiries = sorted(now + self._max_seconds - (now - t) for t in self._active.values())
|
| 91 |
+
if not expiries:
|
| 92 |
+
return 0.0
|
| 93 |
+
# Nth slot to free (1-indexed) wraps over expiries
|
| 94 |
+
idx = (pos - 1) % len(expiries)
|
| 95 |
+
cycles = (pos - 1) // len(expiries)
|
| 96 |
+
slot_free_time = expiries[idx] + cycles * self._max_seconds
|
| 97 |
+
return max(0.0, slot_free_time - now)
|
| 98 |
+
|
| 99 |
+
def is_active(self, client_id: int) -> bool:
|
| 100 |
+
with self._lock:
|
| 101 |
+
return client_id in self._active
|
| 102 |
+
|
| 103 |
+
def was_active(self, client_id: int) -> bool:
|
| 104 |
+
"""True if client is currently active (for use when already holding lock)."""
|
| 105 |
+
return client_id in self._active
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _format_wait(seconds: float) -> str:
|
| 109 |
+
if seconds < 60:
|
| 110 |
+
return "less than a minute"
|
| 111 |
+
mins = int(math.ceil(seconds / 60))
|
| 112 |
+
return f"~{mins} minute{'s' if mins != 1 else ''}"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _queue_modal_markdown(position: int, total: int, estimated_wait_sec: float) -> str:
|
| 116 |
+
wait_str = _format_wait(estimated_wait_sec)
|
| 117 |
+
mins = int(MAX_SESSION_MINUTES) if MAX_SESSION_MINUTES == int(MAX_SESSION_MINUTES) else MAX_SESSION_MINUTES
|
| 118 |
+
return f"""## Kimodo Demo — Please Wait
|
| 119 |
+
|
| 120 |
+
This demo runs with limited capacity.
|
| 121 |
+
Each user gets **{mins} minute{"s" if mins != 1 else ""}** of interactive time.
|
| 122 |
+
|
| 123 |
+
**Your position in queue:** {position} / {total}
|
| 124 |
+
|
| 125 |
+
**Estimated wait:** {wait_str}
|
| 126 |
+
|
| 127 |
+
Please keep this tab open — the demo will start automatically when it's your turn.
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
*Want unlimited access? [Duplicate this Space]({DUPLICATE_SPACE_URL}) or clone the [GitHub repo]({GITHUB_REPO_URL}) to run locally!*
|
| 131 |
+
"""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _welcome_modal_markdown() -> str:
|
| 135 |
+
mins = int(MAX_SESSION_MINUTES) if MAX_SESSION_MINUTES == int(MAX_SESSION_MINUTES) else MAX_SESSION_MINUTES
|
| 136 |
+
return f"""## Welcome to Kimodo Demo
|
| 137 |
+
|
| 138 |
+
You have been granted a **{mins}-minute** demo session.
|
| 139 |
+
Your session timer has started.
|
| 140 |
+
|
| 141 |
+
Click the button below to begin!
|
| 142 |
+
"""
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _expiry_modal_markdown() -> str:
|
| 146 |
+
mins = int(MAX_SESSION_MINUTES) if MAX_SESSION_MINUTES == int(MAX_SESSION_MINUTES) else MAX_SESSION_MINUTES
|
| 147 |
+
return f"""## Session Expired
|
| 148 |
+
|
| 149 |
+
Your {mins}-minute demo session has ended.
|
| 150 |
+
Thank you for trying Kimodo!
|
| 151 |
+
|
| 152 |
+
Refresh this page to rejoin the queue, or [duplicate this Space]({DUPLICATE_SPACE_URL}) for unlimited access.
|
| 153 |
+
"""
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class QueueManager:
|
| 157 |
+
"""Orchestrates HF mode: queue modals, welcome modal, session timer, promotion."""
|
| 158 |
+
|
| 159 |
+
def __init__(
|
| 160 |
+
self,
|
| 161 |
+
queue: UserQueue,
|
| 162 |
+
server: viser.ViserServer,
|
| 163 |
+
setup_demo_for_client: Callable[[viser.ClientHandle], None],
|
| 164 |
+
cleanup_session: Callable[[int], None],
|
| 165 |
+
) -> None:
|
| 166 |
+
self._queue = queue
|
| 167 |
+
self._server = server
|
| 168 |
+
self._setup_demo_for_client = setup_demo_for_client
|
| 169 |
+
self._cleanup_session = cleanup_session
|
| 170 |
+
self._max_seconds = queue._max_seconds
|
| 171 |
+
|
| 172 |
+
self._queue_modal_handles: dict[int, tuple[Any, Any]] = {}
|
| 173 |
+
self._welcome_modal_handles: dict[int, Any] = {}
|
| 174 |
+
self._expiry_timers: dict[int, threading.Timer] = {}
|
| 175 |
+
self._lock = threading.Lock()
|
| 176 |
+
self._refresh_stop = threading.Event()
|
| 177 |
+
self._refresh_thread = threading.Thread(
|
| 178 |
+
target=self._queue_modal_refresh_loop,
|
| 179 |
+
name="queue-modal-refresh",
|
| 180 |
+
daemon=True,
|
| 181 |
+
)
|
| 182 |
+
self._refresh_thread.start()
|
| 183 |
+
|
| 184 |
+
def _queue_modal_refresh_loop(self) -> None:
|
| 185 |
+
"""Periodically refresh queue modals so position, total, and estimated wait stay current."""
|
| 186 |
+
while not self._refresh_stop.wait(timeout=QUEUE_MODAL_REFRESH_INTERVAL_SEC):
|
| 187 |
+
self._update_all_queue_modals()
|
| 188 |
+
|
| 189 |
+
def on_client_connect(self, client: viser.ClientHandle) -> None:
|
| 190 |
+
"""Handle new connection: activate if slot free, else enqueue and show queue modal."""
|
| 191 |
+
client_id = client.client_id
|
| 192 |
+
if self._queue.try_activate(client_id):
|
| 193 |
+
try:
|
| 194 |
+
self._setup_demo_for_client(client)
|
| 195 |
+
except RuntimeError as e:
|
| 196 |
+
if "CUDA error" in str(e):
|
| 197 |
+
print(f"CUDA error while setting up client {client_id}: {e}")
|
| 198 |
+
return
|
| 199 |
+
raise
|
| 200 |
+
self._start_session_timer(client_id)
|
| 201 |
+
self._show_welcome_modal(client)
|
| 202 |
+
else:
|
| 203 |
+
self._queue.enqueue(client_id)
|
| 204 |
+
self._show_queue_modal(client)
|
| 205 |
+
self._update_all_queue_modals()
|
| 206 |
+
|
| 207 |
+
def on_client_disconnect(self, client_id: int) -> None:
|
| 208 |
+
"""Remove from queue/active, cancel timer, promote next if was active.
|
| 209 |
+
|
| 210 |
+
Session/scene cleanup is done by the demo's on_client_disconnect.
|
| 211 |
+
"""
|
| 212 |
+
with self._lock:
|
| 213 |
+
self._expiry_timers.pop(client_id, None)
|
| 214 |
+
self._queue_modal_handles.pop(client_id, None)
|
| 215 |
+
self._welcome_modal_handles.pop(client_id, None)
|
| 216 |
+
was_active = self._queue.remove(client_id)
|
| 217 |
+
if was_active:
|
| 218 |
+
self._promote_next_user()
|
| 219 |
+
else:
|
| 220 |
+
self._update_all_queue_modals()
|
| 221 |
+
|
| 222 |
+
def _show_queue_modal(self, client: viser.ClientHandle) -> None:
|
| 223 |
+
client_id = client.client_id
|
| 224 |
+
pos, total = self._queue.get_queue_position(client_id) or (0, 0)
|
| 225 |
+
wait_sec = self._queue.get_estimated_wait_seconds(client_id)
|
| 226 |
+
md_content = _queue_modal_markdown(pos, total, wait_sec)
|
| 227 |
+
|
| 228 |
+
modal = client.gui.add_modal(
|
| 229 |
+
"Kimodo Demo — Please Wait",
|
| 230 |
+
size="xl",
|
| 231 |
+
show_close_button=False,
|
| 232 |
+
)
|
| 233 |
+
with modal:
|
| 234 |
+
md_handle = client.gui.add_markdown(md_content)
|
| 235 |
+
with self._lock:
|
| 236 |
+
self._queue_modal_handles[client_id] = (modal, md_handle)
|
| 237 |
+
|
| 238 |
+
def _show_quick_start_modal(self, client: viser.ClientHandle) -> None:
|
| 239 |
+
"""Show the quick start instructions modal (same as non-HF mode)."""
|
| 240 |
+
with client.gui.add_modal(
|
| 241 |
+
"Welcome — Quick Start",
|
| 242 |
+
size="xl",
|
| 243 |
+
show_close_button=True,
|
| 244 |
+
save_choice="kimodo.demo.quick_start_ack",
|
| 245 |
+
) as quick_start_modal:
|
| 246 |
+
client.gui.add_markdown(DEMO_UI_QUICK_START_MODAL_MD)
|
| 247 |
+
client.gui.add_button("Got it (don't remind me again)").on_click(lambda _: quick_start_modal.close())
|
| 248 |
+
|
| 249 |
+
def _show_welcome_modal(self, client: viser.ClientHandle) -> None:
|
| 250 |
+
client_id = client.client_id
|
| 251 |
+
|
| 252 |
+
def _on_start_demo(_: Any) -> None:
|
| 253 |
+
modal.close()
|
| 254 |
+
self._show_quick_start_modal(client)
|
| 255 |
+
|
| 256 |
+
modal = client.gui.add_modal(
|
| 257 |
+
"Welcome to Kimodo Demo",
|
| 258 |
+
size="xl",
|
| 259 |
+
show_close_button=True,
|
| 260 |
+
)
|
| 261 |
+
with modal:
|
| 262 |
+
client.gui.add_markdown(_welcome_modal_markdown())
|
| 263 |
+
client.gui.add_button("Start Demo").on_click(_on_start_demo)
|
| 264 |
+
with self._lock:
|
| 265 |
+
self._welcome_modal_handles[client_id] = modal
|
| 266 |
+
|
| 267 |
+
def _update_all_queue_modals(self) -> None:
|
| 268 |
+
with self._lock:
|
| 269 |
+
handles = list(self._queue_modal_handles.items())
|
| 270 |
+
for client_id, (modal, md_handle) in handles:
|
| 271 |
+
pos_total = self._queue.get_queue_position(client_id)
|
| 272 |
+
if pos_total is None:
|
| 273 |
+
continue
|
| 274 |
+
pos, total = pos_total
|
| 275 |
+
wait_sec = self._queue.get_estimated_wait_seconds(client_id)
|
| 276 |
+
try:
|
| 277 |
+
md_handle.content = _queue_modal_markdown(pos, total, wait_sec)
|
| 278 |
+
except Exception:
|
| 279 |
+
pass
|
| 280 |
+
|
| 281 |
+
def _promote_next_user(self) -> None:
|
| 282 |
+
promoted_id = self._queue.promote_next()
|
| 283 |
+
if promoted_id is None:
|
| 284 |
+
return
|
| 285 |
+
clients = self._server.get_clients()
|
| 286 |
+
client = clients.get(promoted_id)
|
| 287 |
+
if client is None:
|
| 288 |
+
return
|
| 289 |
+
with self._lock:
|
| 290 |
+
old = self._queue_modal_handles.pop(promoted_id, None)
|
| 291 |
+
if old is not None:
|
| 292 |
+
try:
|
| 293 |
+
old[0].close()
|
| 294 |
+
except Exception:
|
| 295 |
+
pass
|
| 296 |
+
try:
|
| 297 |
+
self._setup_demo_for_client(client)
|
| 298 |
+
except RuntimeError as e:
|
| 299 |
+
if "CUDA error" in str(e):
|
| 300 |
+
print(f"CUDA error while setting up client {promoted_id}: {e}")
|
| 301 |
+
return
|
| 302 |
+
raise
|
| 303 |
+
self._start_session_timer(promoted_id)
|
| 304 |
+
self._show_welcome_modal(client)
|
| 305 |
+
self._update_all_queue_modals()
|
| 306 |
+
|
| 307 |
+
def _start_session_timer(self, client_id: int) -> None:
|
| 308 |
+
def on_expiry() -> None:
|
| 309 |
+
self._on_session_expired(client_id)
|
| 310 |
+
|
| 311 |
+
t = threading.Timer(self._max_seconds, on_expiry)
|
| 312 |
+
t.daemon = True
|
| 313 |
+
with self._lock:
|
| 314 |
+
self._expiry_timers[client_id] = t
|
| 315 |
+
t.start()
|
| 316 |
+
|
| 317 |
+
def _on_session_expired(self, client_id: int) -> None:
|
| 318 |
+
with self._lock:
|
| 319 |
+
self._expiry_timers.pop(client_id, None)
|
| 320 |
+
if not self._queue.is_active(client_id):
|
| 321 |
+
return
|
| 322 |
+
self._queue.remove(client_id)
|
| 323 |
+
clients = self._server.get_clients()
|
| 324 |
+
client = clients.get(client_id)
|
| 325 |
+
if client is not None:
|
| 326 |
+
try:
|
| 327 |
+
with client.gui.add_modal(
|
| 328 |
+
"Session Expired",
|
| 329 |
+
size="lg",
|
| 330 |
+
show_close_button=False,
|
| 331 |
+
) as modal_ctx:
|
| 332 |
+
client.gui.add_markdown(_expiry_modal_markdown())
|
| 333 |
+
except Exception:
|
| 334 |
+
pass
|
| 335 |
+
self._cleanup_session(client_id)
|
| 336 |
+
self._promote_next_user()
|
kimodo/demo/state.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
import kimodo.viz.viser_utils as viser_utils
|
| 10 |
+
import viser
|
| 11 |
+
from kimodo.skeleton import SkeletonBase
|
| 12 |
+
from kimodo.viz.viser_utils import GuiElements
|
| 13 |
+
|
| 14 |
+
from .config import (
|
| 15 |
+
DEFAULT_CUR_DURATION,
|
| 16 |
+
DEFAULT_MODEL,
|
| 17 |
+
DEFAULT_PLAYBACK_SPEED,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class ModelBundle:
|
| 23 |
+
model: object
|
| 24 |
+
motion_rep: object
|
| 25 |
+
skeleton: SkeletonBase
|
| 26 |
+
model_fps: float
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class ClientSession:
|
| 31 |
+
"""Per-client session data."""
|
| 32 |
+
|
| 33 |
+
client: viser.ClientHandle
|
| 34 |
+
gui_elements: GuiElements
|
| 35 |
+
motions: dict # character_name -> CharacterMotion
|
| 36 |
+
constraints: dict[str, viser_utils.ConstraintSet] = field(default_factory=dict)
|
| 37 |
+
timeline_data: object = None
|
| 38 |
+
frame_idx: int = 0
|
| 39 |
+
playing: bool = False
|
| 40 |
+
playback_speed: float = DEFAULT_PLAYBACK_SPEED
|
| 41 |
+
cur_duration: float = DEFAULT_CUR_DURATION
|
| 42 |
+
max_frame_idx: int = 100 # will be updated based on model_fps
|
| 43 |
+
updating_motions: bool = False
|
| 44 |
+
edit_mode: bool = False
|
| 45 |
+
model_name: str = DEFAULT_MODEL
|
| 46 |
+
model_fps: float = 0.0
|
| 47 |
+
skeleton: SkeletonBase | None = None
|
| 48 |
+
motion_rep: object | None = None
|
| 49 |
+
examples_base_dir: str = ""
|
| 50 |
+
example_dict: dict[str, str] = field(default_factory=dict)
|
| 51 |
+
gui_examples_dropdown: Optional[viser.GuiInputHandle] = None
|
| 52 |
+
gui_save_example_path_text: Optional[viser.GuiInputHandle] = None
|
| 53 |
+
gui_model_selector: Optional[viser.GuiInputHandle] = None
|
| 54 |
+
last_prompt_texts: Optional[list[str]] = None
|
| 55 |
+
last_prompt_embeddings: Optional[torch.Tensor] = None
|
| 56 |
+
last_prompt_lengths: Optional[list[int]] = None
|
| 57 |
+
edit_mode_snapshot: Optional[dict[int, dict[str, object]]] = None
|
| 58 |
+
undo_drag_snapshot: Optional[dict[str, object]] = None
|
| 59 |
+
show_only_current_constraint: bool = False # False = Show All, True = Show only Current
|
kimodo/demo/ui.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
kimodo/exports/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Export utilities: MuJoCo, BVH, SMPLX/AMASS, and motion I/O helpers."""
|
| 4 |
+
|
| 5 |
+
from .bvh import bvh_to_kimodo_motion, motion_to_bvh_bytes, read_bvh_frame_time_seconds, save_motion_bvh
|
| 6 |
+
from .motion_convert_lib import convert_motion_files
|
| 7 |
+
from .motion_formats import (
|
| 8 |
+
infer_npz_kind,
|
| 9 |
+
infer_source_format_from_path,
|
| 10 |
+
infer_target_format_from_path,
|
| 11 |
+
resolve_source_fps,
|
| 12 |
+
)
|
| 13 |
+
from .motion_io import (
|
| 14 |
+
KIMODO_CONVERT_TARGET_FPS,
|
| 15 |
+
amass_npz_to_bytes,
|
| 16 |
+
complete_motion_dict,
|
| 17 |
+
g1_csv_to_bytes,
|
| 18 |
+
kimodo_npz_to_bytes,
|
| 19 |
+
load_amass_npz,
|
| 20 |
+
load_g1_csv,
|
| 21 |
+
load_kimodo_npz,
|
| 22 |
+
load_kimodo_npz_as_torch,
|
| 23 |
+
load_motion_file,
|
| 24 |
+
motion_dict_to_numpy,
|
| 25 |
+
save_kimodo_npz,
|
| 26 |
+
save_kimodo_npz_at_target_fps,
|
| 27 |
+
)
|
| 28 |
+
from .mujoco import MujocoQposConverter, apply_g1_real_robot_projection
|
| 29 |
+
from .smplx import (
|
| 30 |
+
AMASSConverter,
|
| 31 |
+
amass_npz_to_kimodo_motion,
|
| 32 |
+
get_amass_parameters,
|
| 33 |
+
kimodo_y_up_to_amass_coord_rotation_matrix,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
"AMASSConverter",
|
| 38 |
+
"KIMODO_CONVERT_TARGET_FPS",
|
| 39 |
+
"MujocoQposConverter",
|
| 40 |
+
"amass_npz_to_bytes",
|
| 41 |
+
"amass_npz_to_kimodo_motion",
|
| 42 |
+
"apply_g1_real_robot_projection",
|
| 43 |
+
"bvh_to_kimodo_motion",
|
| 44 |
+
"complete_motion_dict",
|
| 45 |
+
"convert_motion_files",
|
| 46 |
+
"g1_csv_to_bytes",
|
| 47 |
+
"get_amass_parameters",
|
| 48 |
+
"infer_npz_kind",
|
| 49 |
+
"infer_source_format_from_path",
|
| 50 |
+
"infer_target_format_from_path",
|
| 51 |
+
"kimodo_npz_to_bytes",
|
| 52 |
+
"kimodo_y_up_to_amass_coord_rotation_matrix",
|
| 53 |
+
"load_amass_npz",
|
| 54 |
+
"load_g1_csv",
|
| 55 |
+
"load_kimodo_npz",
|
| 56 |
+
"load_kimodo_npz_as_torch",
|
| 57 |
+
"load_motion_file",
|
| 58 |
+
"motion_dict_to_numpy",
|
| 59 |
+
"motion_to_bvh_bytes",
|
| 60 |
+
"read_bvh_frame_time_seconds",
|
| 61 |
+
"resolve_source_fps",
|
| 62 |
+
"save_kimodo_npz",
|
| 63 |
+
"save_kimodo_npz_at_target_fps",
|
| 64 |
+
"save_motion_bvh",
|
| 65 |
+
]
|
kimodo/exports/bvh.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Export utilities for converting internal motion representations into common file formats.
|
| 4 |
+
|
| 5 |
+
This module is intended to hold lightweight serialization / export helpers that can be reused
|
| 6 |
+
outside of interactive demos.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import tempfile
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Tuple, Union
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
|
| 17 |
+
from kimodo.geometry import matrix_to_quaternion as _matrix_to_quaternion
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _strip_end_site_blocks(bvh_text: str) -> str:
|
| 21 |
+
"""Remove all 'End Site { ... }' blocks from BVH text so output matches original format.
|
| 22 |
+
|
| 23 |
+
bvhio adds an End Site for every leaf joint when writing; we do not set EndSite on joints, so we
|
| 24 |
+
post-process the string to remove these blocks for Blender/original compatibility.
|
| 25 |
+
"""
|
| 26 |
+
lines = bvh_text.splitlines(keepends=True)
|
| 27 |
+
result = []
|
| 28 |
+
i = 0
|
| 29 |
+
while i < len(lines):
|
| 30 |
+
line = lines[i]
|
| 31 |
+
if "End Site" in line:
|
| 32 |
+
# Skip this line and the following block { ... }; brace-count to find closing }
|
| 33 |
+
i += 1
|
| 34 |
+
if i < len(lines) and "{" in lines[i]:
|
| 35 |
+
i += 1
|
| 36 |
+
depth = 1
|
| 37 |
+
while i < len(lines) and depth > 0:
|
| 38 |
+
if "{" in lines[i]:
|
| 39 |
+
depth += 1
|
| 40 |
+
if "}" in lines[i]:
|
| 41 |
+
depth -= 1
|
| 42 |
+
i += 1
|
| 43 |
+
continue
|
| 44 |
+
result.append(line)
|
| 45 |
+
i += 1
|
| 46 |
+
return "".join(result)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _coerce_batch(name: str, x: torch.Tensor, *, expected_ndim: int) -> torch.Tensor:
|
| 50 |
+
"""Coerce (T, ...) or (1, T, ...) into (T, ...)."""
|
| 51 |
+
if x.ndim == expected_ndim:
|
| 52 |
+
return x
|
| 53 |
+
if x.ndim == expected_ndim + 1:
|
| 54 |
+
if int(x.shape[0]) != 1:
|
| 55 |
+
raise ValueError(
|
| 56 |
+
f"{name} has batch dimension B={int(x.shape[0])}, but BVH export " "only supports a single clip (B==1)."
|
| 57 |
+
)
|
| 58 |
+
return x[0]
|
| 59 |
+
raise ValueError(f"{name} must have shape (T, ...) or (1, T, ...); got {tuple(x.shape)}")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def motion_to_bvh(
|
| 63 |
+
local_rot_mats: torch.Tensor,
|
| 64 |
+
root_positions: torch.Tensor,
|
| 65 |
+
*,
|
| 66 |
+
skeleton,
|
| 67 |
+
fps: float,
|
| 68 |
+
standard_tpose: bool = False,
|
| 69 |
+
) -> str:
|
| 70 |
+
"""Convert local rotations and root positions to BVH format; return UTF-8 string.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
local_rot_mats: (T, J, 3, 3) or (1, T, J, 3, 3) local rotation matrices.
|
| 74 |
+
root_positions: (T, 3) or (1, T, 3) root joint positions (e.g. from posed joints).
|
| 75 |
+
skeleton: Skeleton with bone_order_names, bvh_neutral_joints, etc.
|
| 76 |
+
fps: Frames per second for the motion.
|
| 77 |
+
standard_tpose: If True, export with the rest pose being the standard T-pose rather than the rest pose consistent with the BONES-SEED dataset.
|
| 78 |
+
Notes:
|
| 79 |
+
BVH is plain-text. Root is named "Root" with ZYX rotation order; leaf joints
|
| 80 |
+
have no End Site block.
|
| 81 |
+
"""
|
| 82 |
+
try:
|
| 83 |
+
import bvhio # type: ignore[import-not-found]
|
| 84 |
+
import glm # type: ignore[import-not-found]
|
| 85 |
+
from SpatialTransform import Pose # type: ignore[import-not-found]
|
| 86 |
+
except Exception as e: # pragma: no cover
|
| 87 |
+
raise ImportError(
|
| 88 |
+
"BVH export requires `bvhio` (and its deps `PyGLM` + `SpatialTransform`). "
|
| 89 |
+
"Install with: `pip install bvhio`."
|
| 90 |
+
) from e
|
| 91 |
+
|
| 92 |
+
local_rot_mats = local_rot_mats.detach()
|
| 93 |
+
root_positions = root_positions.detach()
|
| 94 |
+
# SOMA: accept either somaskel30 (convert to 77) or somaskel77 (use as-is)
|
| 95 |
+
if skeleton.name == "somaskel30":
|
| 96 |
+
local_rot_mats = skeleton.to_SOMASkeleton77(local_rot_mats)
|
| 97 |
+
skeleton = skeleton.somaskel77
|
| 98 |
+
|
| 99 |
+
if standard_tpose:
|
| 100 |
+
neutral = skeleton.neutral_joints.detach().cpu().numpy()
|
| 101 |
+
else:
|
| 102 |
+
# transform local rots to the original rest pose consistent with the BONES-SEED dataset
|
| 103 |
+
local_rot_mats, _ = skeleton.from_standard_tpose(local_rot_mats)
|
| 104 |
+
neutral = skeleton.bvh_neutral_joints.detach().cpu().numpy()
|
| 105 |
+
|
| 106 |
+
joint_names = list(skeleton.bone_order_names)
|
| 107 |
+
parents = skeleton.joint_parents.detach().cpu().numpy().astype(int)
|
| 108 |
+
root_idx = int(skeleton.root_idx)
|
| 109 |
+
|
| 110 |
+
local_rot_mats = _coerce_batch("local_rot_mats", local_rot_mats, expected_ndim=4)
|
| 111 |
+
T, J = local_rot_mats.shape[:2]
|
| 112 |
+
q_wxyz = _matrix_to_quaternion(local_rot_mats).detach().cpu().numpy() # [T, J, 4]
|
| 113 |
+
|
| 114 |
+
root_xyz = _coerce_batch("root_positions", root_positions, expected_ndim=2)
|
| 115 |
+
root_xyz = root_xyz.cpu().numpy() # [T, 3]
|
| 116 |
+
|
| 117 |
+
# Build BVH hierarchy: Root (wrapper at origin) -> Hips (pelvis with offset in meters) -> ...
|
| 118 |
+
# Offsets are in meters to match the original format.
|
| 119 |
+
children: dict[int, list[int]] = {i: [] for i in range(J)}
|
| 120 |
+
for i, p in enumerate(parents):
|
| 121 |
+
if p >= 0:
|
| 122 |
+
children[int(p)].append(int(i))
|
| 123 |
+
|
| 124 |
+
_ROOT_CHANNELS = [
|
| 125 |
+
"Xposition",
|
| 126 |
+
"Yposition",
|
| 127 |
+
"Zposition",
|
| 128 |
+
"Zrotation",
|
| 129 |
+
"Yrotation",
|
| 130 |
+
"Xrotation",
|
| 131 |
+
]
|
| 132 |
+
_JOINT_CHANNELS = ["Zrotation", "Yrotation", "Xrotation"]
|
| 133 |
+
|
| 134 |
+
# Scale from meters to centimeters (match original SEED data BVH scale).
|
| 135 |
+
neutral = neutral * 100
|
| 136 |
+
root_xyz = root_xyz * 100
|
| 137 |
+
|
| 138 |
+
# Hips offset from Root: use skeleton neutral; if root is at origin (zeros), use a
|
| 139 |
+
# nominal pelvis height so the hierarchy is non-degenerate in Blender.
|
| 140 |
+
hips_offset = neutral[root_idx]
|
| 141 |
+
if (hips_offset == 0).all():
|
| 142 |
+
hips_offset = np.array([0.0, 100.0, 0.0], dtype=neutral.dtype) # 1 m in cm
|
| 143 |
+
|
| 144 |
+
def _make_joint(i: int) -> "bvhio.BvhJoint":
|
| 145 |
+
name = joint_names[i]
|
| 146 |
+
j = bvhio.BvhJoint(name, offset=glm.vec3(0, 0, 0))
|
| 147 |
+
if i == root_idx:
|
| 148 |
+
# Hips: offset from Root (origin) in cm
|
| 149 |
+
off = hips_offset
|
| 150 |
+
j.Offset = glm.vec3(float(off[0]), float(off[1]), float(off[2]))
|
| 151 |
+
j.Channels = _ROOT_CHANNELS.copy()
|
| 152 |
+
else:
|
| 153 |
+
p = int(parents[i])
|
| 154 |
+
off = neutral[i] - neutral[p]
|
| 155 |
+
j.Offset = glm.vec3(float(off[0]), float(off[1]), float(off[2]))
|
| 156 |
+
j.Channels = _JOINT_CHANNELS.copy()
|
| 157 |
+
|
| 158 |
+
for c in children[i]:
|
| 159 |
+
j.Children.append(_make_joint(c))
|
| 160 |
+
return j
|
| 161 |
+
|
| 162 |
+
# Wrapper Root at origin; single child is Hips (skeleton root).
|
| 163 |
+
root_wrapper = bvhio.BvhJoint("Root", offset=glm.vec3(0.0, 0.0, 0.0))
|
| 164 |
+
root_wrapper.Channels = _ROOT_CHANNELS.copy()
|
| 165 |
+
root_wrapper.Children.append(_make_joint(root_idx))
|
| 166 |
+
root_joint = root_wrapper
|
| 167 |
+
|
| 168 |
+
# Populate keyframes: Root = identity/zero, Hips = root motion, others = local rotation.
|
| 169 |
+
bvh_layout = root_joint.layout()
|
| 170 |
+
name_to_id = {n: idx for idx, n in enumerate(joint_names)}
|
| 171 |
+
ordered_joint_ids = []
|
| 172 |
+
for bj, _, _ in bvh_layout:
|
| 173 |
+
if bj.Name == "Root":
|
| 174 |
+
ordered_joint_ids.append(None)
|
| 175 |
+
else:
|
| 176 |
+
ordered_joint_ids.append(name_to_id[bj.Name])
|
| 177 |
+
|
| 178 |
+
bvh_joints = [bj for bj, _, _ in bvh_layout]
|
| 179 |
+
for bj in bvh_joints:
|
| 180 |
+
bj.Keyframes = [None] * T # type: ignore[list-item]
|
| 181 |
+
|
| 182 |
+
identity_quat = glm.quat(1.0, 0.0, 0.0, 0.0)
|
| 183 |
+
zero_vec = glm.vec3(0.0, 0.0, 0.0)
|
| 184 |
+
for t in range(T):
|
| 185 |
+
for bj, jid in zip(bvh_joints, ordered_joint_ids):
|
| 186 |
+
if jid is None:
|
| 187 |
+
position = zero_vec
|
| 188 |
+
rotation = identity_quat
|
| 189 |
+
elif jid == root_idx:
|
| 190 |
+
pos = root_xyz[t]
|
| 191 |
+
position = glm.vec3(float(pos[0]), float(pos[1]), float(pos[2]))
|
| 192 |
+
qw, qx, qy, qz = q_wxyz[t, jid]
|
| 193 |
+
rotation = glm.quat(float(qw), float(qx), float(qy), float(qz))
|
| 194 |
+
else:
|
| 195 |
+
position = zero_vec
|
| 196 |
+
qw, qx, qy, qz = q_wxyz[t, jid]
|
| 197 |
+
rotation = glm.quat(float(qw), float(qx), float(qy), float(qz))
|
| 198 |
+
bj.Keyframes[t] = Pose(position, rotation) # type: ignore[index]
|
| 199 |
+
|
| 200 |
+
container = bvhio.BvhContainer(root_joint, frameCount=T, frameTime=1.0 / float(fps))
|
| 201 |
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".bvh", delete=False, encoding="utf-8") as f:
|
| 202 |
+
tmp_path = f.name
|
| 203 |
+
try:
|
| 204 |
+
bvhio.writeBvh(tmp_path, container, percision=6)
|
| 205 |
+
bvh_text = Path(tmp_path).read_text(encoding="utf-8")
|
| 206 |
+
return _strip_end_site_blocks(bvh_text)
|
| 207 |
+
finally:
|
| 208 |
+
try:
|
| 209 |
+
os.remove(tmp_path)
|
| 210 |
+
except Exception:
|
| 211 |
+
pass
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def motion_to_bvh_bytes(
|
| 215 |
+
local_rot_mats: torch.Tensor,
|
| 216 |
+
root_positions: torch.Tensor,
|
| 217 |
+
*,
|
| 218 |
+
skeleton,
|
| 219 |
+
fps: float,
|
| 220 |
+
standard_tpose: bool = False,
|
| 221 |
+
) -> bytes:
|
| 222 |
+
"""Convert local rotations and root positions to BVH bytes (UTF-8).
|
| 223 |
+
|
| 224 |
+
Convenience wrapper around :func:`motion_to_bvh`.
|
| 225 |
+
"""
|
| 226 |
+
return motion_to_bvh(
|
| 227 |
+
local_rot_mats,
|
| 228 |
+
root_positions,
|
| 229 |
+
skeleton=skeleton,
|
| 230 |
+
fps=fps,
|
| 231 |
+
standard_tpose=standard_tpose,
|
| 232 |
+
).encode("utf-8")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def save_motion_bvh(
|
| 236 |
+
path: Union[str, Path],
|
| 237 |
+
local_rot_mats: torch.Tensor,
|
| 238 |
+
root_positions: torch.Tensor,
|
| 239 |
+
*,
|
| 240 |
+
skeleton,
|
| 241 |
+
fps: float,
|
| 242 |
+
standard_tpose: bool = False,
|
| 243 |
+
) -> None:
|
| 244 |
+
"""Write local rotations and root positions to a BVH file at the given path."""
|
| 245 |
+
Path(path).write_text(
|
| 246 |
+
motion_to_bvh(local_rot_mats, root_positions, skeleton=skeleton, fps=fps, standard_tpose=standard_tpose),
|
| 247 |
+
encoding="utf-8",
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def read_bvh_frame_time_seconds(path: Union[str, Path]) -> float:
|
| 252 |
+
"""Read ``Frame Time`` from a BVH file (seconds per frame)."""
|
| 253 |
+
with open(path, encoding="utf-8") as f:
|
| 254 |
+
for line in f:
|
| 255 |
+
if "Frame Time:" in line:
|
| 256 |
+
parts = line.split()
|
| 257 |
+
return float(parts[-1])
|
| 258 |
+
raise ValueError(f"Could not find 'Frame Time:' in {path}")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def bvh_to_kimodo_motion(
|
| 262 |
+
path: Union[str, Path],
|
| 263 |
+
skeleton=None,
|
| 264 |
+
*,
|
| 265 |
+
standard_tpose: bool = False,
|
| 266 |
+
) -> Tuple:
|
| 267 |
+
"""Load a Kimodo-style SOMA BVH into a Kimodo motion dict.
|
| 268 |
+
|
| 269 |
+
Expects the same hierarchy as :func:`save_motion_bvh` (``Root`` wrapper + SOMA77 joints).
|
| 270 |
+
The frame rate is always read from the BVH ``Frame Time`` header. Callers
|
| 271 |
+
that need a different playback rate should resample the returned motion dict
|
| 272 |
+
(see :func:`~kimodo.exports.motion_io.resample_motion_dict_to_kimodo_fps`).
|
| 273 |
+
|
| 274 |
+
Returns:
|
| 275 |
+
``(motion_dict, source_fps)`` where ``source_fps`` is the native BVH
|
| 276 |
+
frame rate read from the file header.
|
| 277 |
+
"""
|
| 278 |
+
from kimodo.exports.motion_io import complete_motion_dict
|
| 279 |
+
from kimodo.skeleton.bvh import parse_bvh_motion
|
| 280 |
+
from kimodo.skeleton.registry import build_skeleton
|
| 281 |
+
|
| 282 |
+
if skeleton is None:
|
| 283 |
+
skeleton = build_skeleton(77)
|
| 284 |
+
device = skeleton.neutral_joints.device
|
| 285 |
+
|
| 286 |
+
local_rot_mats, root_trans, bvh_fps = parse_bvh_motion(str(path))
|
| 287 |
+
local_rot_mats = local_rot_mats.to(device=device)
|
| 288 |
+
root_trans = root_trans.to(device=device)
|
| 289 |
+
|
| 290 |
+
if int(local_rot_mats.shape[1]) != int(skeleton.nbjoints):
|
| 291 |
+
raise ValueError(
|
| 292 |
+
f"BVH has {local_rot_mats.shape[1]} joints but skeleton has {skeleton.nbjoints}; "
|
| 293 |
+
"use a Kimodo-exported SOMA BVH or matching skeleton."
|
| 294 |
+
)
|
| 295 |
+
if not standard_tpose:
|
| 296 |
+
local_rot_mats, _ = skeleton.to_standard_tpose(local_rot_mats)
|
| 297 |
+
|
| 298 |
+
return complete_motion_dict(local_rot_mats, root_trans, skeleton, float(bvh_fps)), bvh_fps
|
kimodo/exports/motion_convert_lib.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Library API for converting between Kimodo NPZ, AMASS NPZ, SOMA BVH, and G1 MuJoCo CSV."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import warnings
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
from kimodo.exports.bvh import bvh_to_kimodo_motion, save_motion_bvh
|
| 12 |
+
from kimodo.exports.motion_formats import (
|
| 13 |
+
infer_source_format_from_path,
|
| 14 |
+
infer_target_format_from_path,
|
| 15 |
+
resolve_source_fps,
|
| 16 |
+
)
|
| 17 |
+
from kimodo.exports.motion_io import (
|
| 18 |
+
load_amass_npz,
|
| 19 |
+
load_g1_csv,
|
| 20 |
+
load_kimodo_npz_as_torch,
|
| 21 |
+
save_kimodo_npz_at_target_fps,
|
| 22 |
+
)
|
| 23 |
+
from kimodo.exports.mujoco import MujocoQposConverter
|
| 24 |
+
from kimodo.exports.smplx import AMASSConverter
|
| 25 |
+
from kimodo.skeleton.registry import build_skeleton
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def convert_motion_files(
|
| 29 |
+
input_path: str,
|
| 30 |
+
output_path: str,
|
| 31 |
+
*,
|
| 32 |
+
from_fmt: str | None = None,
|
| 33 |
+
to_fmt: str | None = None,
|
| 34 |
+
source_fps: float | None = None,
|
| 35 |
+
z_up: bool = True,
|
| 36 |
+
mujoco_rest_zero: bool = False,
|
| 37 |
+
bvh_standard_tpose: bool = False,
|
| 38 |
+
) -> None:
|
| 39 |
+
"""Convert a motion file between Kimodo-supported formats.
|
| 40 |
+
|
| 41 |
+
Supported pairs (hub-and-spoke through Kimodo NPZ):
|
| 42 |
+
|
| 43 |
+
- amass <-> kimodo
|
| 44 |
+
- soma-bvh <-> kimodo
|
| 45 |
+
- g1-csv <-> kimodo
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
input_path: Source file (``.npz``, ``.bvh``, or ``.csv``).
|
| 49 |
+
output_path: Destination file.
|
| 50 |
+
from_fmt: Source format; inferred from extension/contents when ``None``.
|
| 51 |
+
to_fmt: Target format; inferred from extension when ``None``.
|
| 52 |
+
source_fps: Source motion frame rate (Hz). If provided, trusted as-is.
|
| 53 |
+
If ``None``, auto-detected from BVH ``Frame Time``, AMASS
|
| 54 |
+
``mocap_frame_rate``, or default 30.
|
| 55 |
+
z_up: For AMASS conversions, apply the Z-up <-> Kimodo Y-up transform.
|
| 56 |
+
mujoco_rest_zero: For G1 CSV, joint angles relative to MuJoCo rest pose.
|
| 57 |
+
bvh_standard_tpose: If input or output is BVH: the BVH file uses the standard T-pose
|
| 58 |
+
as its rest pose instead of the BONES-SEED rest pose.
|
| 59 |
+
"""
|
| 60 |
+
from_fmt = from_fmt or infer_source_format_from_path(input_path)
|
| 61 |
+
to_fmt = to_fmt or infer_target_format_from_path(output_path, from_fmt)
|
| 62 |
+
|
| 63 |
+
_validate_output_extension(to_fmt, output_path)
|
| 64 |
+
|
| 65 |
+
pair = (from_fmt, to_fmt)
|
| 66 |
+
|
| 67 |
+
if pair == ("amass", "kimodo"):
|
| 68 |
+
sk = build_skeleton(22)
|
| 69 |
+
effective_source = source_fps
|
| 70 |
+
if effective_source is None:
|
| 71 |
+
with np.load(input_path, allow_pickle=True) as z:
|
| 72 |
+
effective_source = float(z["mocap_frame_rate"]) if "mocap_frame_rate" in z.files else 30.0
|
| 73 |
+
motion = load_amass_npz(input_path, source_fps=effective_source, z_up=z_up)
|
| 74 |
+
save_kimodo_npz_at_target_fps(motion, sk, effective_source, output_path)
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
if pair == ("kimodo", "amass"):
|
| 78 |
+
data, J = load_kimodo_npz_as_torch(input_path, ensure_complete=False)
|
| 79 |
+
if J != 22:
|
| 80 |
+
raise ValueError(f"Kimodo→AMASS requires 22 joints (SMPL-X); this file has J={J}.")
|
| 81 |
+
sk = build_skeleton(22)
|
| 82 |
+
effective_source = resolve_source_fps(source_fps, "kimodo", input_path, None)
|
| 83 |
+
converter = AMASSConverter(fps=effective_source, skeleton=sk)
|
| 84 |
+
converter.convert_save_npz(data, output_path, z_up=z_up)
|
| 85 |
+
return
|
| 86 |
+
|
| 87 |
+
if pair == ("soma-bvh", "kimodo"):
|
| 88 |
+
sk = build_skeleton(77)
|
| 89 |
+
motion, bvh_fps = bvh_to_kimodo_motion(input_path, skeleton=sk, standard_tpose=bvh_standard_tpose)
|
| 90 |
+
effective_source = source_fps if source_fps is not None else bvh_fps
|
| 91 |
+
save_kimodo_npz_at_target_fps(motion, sk, effective_source, output_path)
|
| 92 |
+
return
|
| 93 |
+
|
| 94 |
+
if pair == ("kimodo", "soma-bvh"):
|
| 95 |
+
data, J = load_kimodo_npz_as_torch(input_path, ensure_complete=False)
|
| 96 |
+
if J == 30:
|
| 97 |
+
warnings.warn(
|
| 98 |
+
f"Input has 30 joints (somaskel30); expanding to somaskel77 for BVH export.",
|
| 99 |
+
UserWarning,
|
| 100 |
+
stacklevel=2,
|
| 101 |
+
)
|
| 102 |
+
sk = build_skeleton(30)
|
| 103 |
+
elif J == 77:
|
| 104 |
+
sk = build_skeleton(77)
|
| 105 |
+
else:
|
| 106 |
+
raise ValueError(f"Kimodo→BVH requires a SOMA skeleton (30 or 77 joints); this file has J={J}.")
|
| 107 |
+
effective_source = resolve_source_fps(source_fps, "kimodo", input_path, None)
|
| 108 |
+
save_motion_bvh(
|
| 109 |
+
output_path,
|
| 110 |
+
data["local_rot_mats"],
|
| 111 |
+
data["root_positions"],
|
| 112 |
+
skeleton=sk,
|
| 113 |
+
fps=effective_source,
|
| 114 |
+
standard_tpose=bvh_standard_tpose,
|
| 115 |
+
)
|
| 116 |
+
return
|
| 117 |
+
|
| 118 |
+
if pair == ("g1-csv", "kimodo"):
|
| 119 |
+
sk = build_skeleton(34)
|
| 120 |
+
effective_source = resolve_source_fps(source_fps, "g1-csv", input_path, None)
|
| 121 |
+
motion = load_g1_csv(input_path, source_fps=effective_source, mujoco_rest_zero=mujoco_rest_zero)
|
| 122 |
+
save_kimodo_npz_at_target_fps(motion, sk, effective_source, output_path)
|
| 123 |
+
return
|
| 124 |
+
|
| 125 |
+
if pair == ("kimodo", "g1-csv"):
|
| 126 |
+
data, J = load_kimodo_npz_as_torch(input_path, ensure_complete=False)
|
| 127 |
+
if J != 34:
|
| 128 |
+
raise ValueError(f"Kimodo→CSV requires G1 with 34 joints; this file has J={J}.")
|
| 129 |
+
sk = build_skeleton(34)
|
| 130 |
+
effective_source = resolve_source_fps(source_fps, "kimodo", input_path, None)
|
| 131 |
+
converter = MujocoQposConverter(sk)
|
| 132 |
+
qpos = converter.dict_to_qpos(
|
| 133 |
+
{k: v for k, v in data.items() if k in ("local_rot_mats", "root_positions")},
|
| 134 |
+
device=str(sk.neutral_joints.device),
|
| 135 |
+
numpy=True,
|
| 136 |
+
mujoco_rest_zero=mujoco_rest_zero,
|
| 137 |
+
)
|
| 138 |
+
converter.save_csv(qpos, output_path)
|
| 139 |
+
return
|
| 140 |
+
|
| 141 |
+
raise ValueError(
|
| 142 |
+
f"Unsupported conversion {from_fmt!r} → {to_fmt!r}. "
|
| 143 |
+
"Supported: amass↔kimodo (SMPL-X NPZ), soma-bvh↔kimodo, g1-csv↔kimodo."
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def _validate_output_extension(to_fmt: str, output_path: str) -> None:
|
| 148 |
+
lower = output_path.lower()
|
| 149 |
+
if to_fmt == "kimodo" and lower.endswith(".npz"):
|
| 150 |
+
return
|
| 151 |
+
if to_fmt == "amass":
|
| 152 |
+
if not lower.endswith(".npz"):
|
| 153 |
+
raise ValueError("AMASS output must use a .npz path.")
|
| 154 |
+
elif to_fmt == "soma-bvh":
|
| 155 |
+
if not lower.endswith(".bvh"):
|
| 156 |
+
raise ValueError("SOMA BVH output must use a .bvh path.")
|
| 157 |
+
elif to_fmt == "g1-csv":
|
| 158 |
+
if not lower.endswith(".csv"):
|
| 159 |
+
raise ValueError("G1 CSV output must use a .csv path.")
|
kimodo/exports/motion_formats.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Infer motion file formats from paths and NPZ contents."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from typing import Literal
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
MotionSourceFormat = Literal["amass", "kimodo", "soma-bvh", "g1-csv"]
|
| 13 |
+
MotionTargetFormat = Literal["amass", "kimodo", "soma-bvh", "g1-csv"]
|
| 14 |
+
NpzMotionKind = Literal["amass", "kimodo"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def infer_npz_kind(path: str) -> NpzMotionKind:
|
| 18 |
+
"""Classify a ``.npz`` as AMASS SMPL-X or Kimodo from required array keys."""
|
| 19 |
+
with np.load(path, allow_pickle=False) as z:
|
| 20 |
+
keys = set(z.files)
|
| 21 |
+
if "trans" in keys and "pose_body" in keys and "root_orient" in keys:
|
| 22 |
+
return "amass"
|
| 23 |
+
if "local_rot_mats" in keys or "posed_joints" in keys:
|
| 24 |
+
return "kimodo"
|
| 25 |
+
raise ValueError(
|
| 26 |
+
f"Unrecognized NPZ {path!r}: expected AMASS keys (trans, pose_body, ...) "
|
| 27 |
+
"or Kimodo keys (local_rot_mats, posed_joints, ...)."
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def infer_source_format_from_path(path: str) -> MotionSourceFormat:
|
| 32 |
+
"""Infer converter input format from file extension and NPZ contents when needed."""
|
| 33 |
+
ext = os.path.splitext(path)[1].lower()
|
| 34 |
+
if ext == ".bvh":
|
| 35 |
+
return "soma-bvh"
|
| 36 |
+
if ext == ".csv":
|
| 37 |
+
return "g1-csv"
|
| 38 |
+
if ext == ".npz":
|
| 39 |
+
return infer_npz_kind(path) # type: ignore[return-value]
|
| 40 |
+
raise ValueError(f"Cannot infer format from extension of {path!r}")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def infer_target_format_from_path(path: str, from_fmt: MotionSourceFormat) -> MotionTargetFormat:
|
| 44 |
+
"""Infer converter output format from destination path and source format."""
|
| 45 |
+
ext = os.path.splitext(path)[1].lower()
|
| 46 |
+
if ext == ".bvh":
|
| 47 |
+
return "soma-bvh"
|
| 48 |
+
if ext == ".csv":
|
| 49 |
+
return "g1-csv"
|
| 50 |
+
if ext == ".npz":
|
| 51 |
+
if from_fmt == "amass":
|
| 52 |
+
return "kimodo"
|
| 53 |
+
if from_fmt == "kimodo":
|
| 54 |
+
return "amass"
|
| 55 |
+
if from_fmt in ("g1-csv", "soma-bvh"):
|
| 56 |
+
return "kimodo"
|
| 57 |
+
raise ValueError(
|
| 58 |
+
"Ambiguous .npz output: set --to to 'kimodo' or 'amass' when the input format is not amass/kimodo."
|
| 59 |
+
)
|
| 60 |
+
raise ValueError(f"Cannot infer output format from extension of {path!r}")
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def resolve_source_fps(
|
| 64 |
+
fps: float | None,
|
| 65 |
+
from_kind: str,
|
| 66 |
+
input_path: str,
|
| 67 |
+
data: dict | None,
|
| 68 |
+
) -> float:
|
| 69 |
+
"""Resolve source frame rate (Hz) for conversion when ``fps`` is not overridden."""
|
| 70 |
+
if fps is not None:
|
| 71 |
+
return float(fps)
|
| 72 |
+
if data is not None and "mocap_frame_rate" in data:
|
| 73 |
+
return float(np.asarray(data["mocap_frame_rate"]).item())
|
| 74 |
+
if from_kind == "soma-bvh":
|
| 75 |
+
from kimodo.exports.bvh import read_bvh_frame_time_seconds
|
| 76 |
+
|
| 77 |
+
return 1.0 / read_bvh_frame_time_seconds(input_path)
|
| 78 |
+
return 30.0
|
kimodo/exports/motion_io.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Assemble Kimodo NPZ-compatible motion dicts from local rotations + root trajectory."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import warnings
|
| 9 |
+
from typing import Any, Dict, Tuple
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
from kimodo.geometry import matrix_to_quaternion, quaternion_to_matrix
|
| 15 |
+
from kimodo.motion_rep.feature_utils import compute_heading_angle, compute_vel_xyz
|
| 16 |
+
from kimodo.motion_rep.feet import foot_detect_from_pos_and_vel
|
| 17 |
+
from kimodo.motion_rep.smooth_root import get_smooth_root_pos
|
| 18 |
+
from kimodo.skeleton import SkeletonBase
|
| 19 |
+
from kimodo.skeleton.registry import build_skeleton
|
| 20 |
+
from kimodo.tools import to_numpy
|
| 21 |
+
|
| 22 |
+
# Default motion rate for Kimodo NPZ produced by format conversion (matches common model FPS).
|
| 23 |
+
KIMODO_CONVERT_TARGET_FPS = 30.0
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _quaternion_slerp(q0: torch.Tensor, q1: torch.Tensor, t: torch.Tensor) -> torch.Tensor:
|
| 27 |
+
"""Spherical linear interpolation; ``q0``, ``q1`` (..., 4) wxyz; ``t`` broadcastable to (...,
|
| 28 |
+
1)."""
|
| 29 |
+
if t.dim() < q0.dim():
|
| 30 |
+
t = t.unsqueeze(-1)
|
| 31 |
+
dot = (q0 * q1).sum(dim=-1, keepdim=True)
|
| 32 |
+
q1 = torch.where(dot < 0, -q1, q1)
|
| 33 |
+
dot = torch.abs(dot).clamp(-1.0, 1.0)
|
| 34 |
+
theta_0 = torch.acos(dot)
|
| 35 |
+
sin_theta = torch.sin(theta_0)
|
| 36 |
+
s0 = torch.sin((1.0 - t) * theta_0) / sin_theta.clamp(min=1e-8)
|
| 37 |
+
s1 = torch.sin(t * theta_0) / sin_theta.clamp(min=1e-8)
|
| 38 |
+
q = s0 * q0 + s1 * q1
|
| 39 |
+
return q / torch.linalg.norm(q, dim=-1, keepdim=True).clamp(min=1e-8)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def resample_motion_dict_to_kimodo_fps(
|
| 43 |
+
motion_dict: Dict[str, torch.Tensor],
|
| 44 |
+
skeleton: SkeletonBase,
|
| 45 |
+
source_fps: float,
|
| 46 |
+
target_fps: float = KIMODO_CONVERT_TARGET_FPS,
|
| 47 |
+
) -> Tuple[Dict[str, torch.Tensor], bool]:
|
| 48 |
+
"""Resample a Kimodo motion dict to ``target_fps``.
|
| 49 |
+
|
| 50 |
+
When the fps ratio is close to an integer (e.g. 120 / 30 = 4), the faster
|
| 51 |
+
stepping method is used (take every *step*-th frame). Otherwise falls back
|
| 52 |
+
to linear interp (root) + quaternion slerp (joints).
|
| 53 |
+
|
| 54 |
+
Re-runs :func:`complete_motion_dict` at the target rate so derived channels stay consistent.
|
| 55 |
+
|
| 56 |
+
Returns:
|
| 57 |
+
The motion dict and ``True`` if time resampling was applied, else ``False`` (already at
|
| 58 |
+
``target_fps`` with matching frame count; only re-derived via FK).
|
| 59 |
+
"""
|
| 60 |
+
local_rot_mats = motion_dict["local_rot_mats"]
|
| 61 |
+
root_positions = motion_dict["root_positions"]
|
| 62 |
+
local_rot_mats, root_positions = _coerce_time_local_root(local_rot_mats, root_positions)
|
| 63 |
+
t_in = int(local_rot_mats.shape[0])
|
| 64 |
+
if t_in < 1:
|
| 65 |
+
raise ValueError("Motion must have at least one frame.")
|
| 66 |
+
if source_fps <= 0:
|
| 67 |
+
raise ValueError(f"source_fps must be positive; got {source_fps}")
|
| 68 |
+
|
| 69 |
+
t_out = max(1, int(round(t_in * target_fps / source_fps)))
|
| 70 |
+
if t_out == t_in and abs(float(source_fps) - float(target_fps)) < 1e-3:
|
| 71 |
+
return complete_motion_dict(local_rot_mats, root_positions, skeleton, float(target_fps)), False
|
| 72 |
+
|
| 73 |
+
ratio = source_fps / target_fps
|
| 74 |
+
step = round(ratio)
|
| 75 |
+
if step >= 2 and abs(ratio - step) < 0.05:
|
| 76 |
+
local_out = local_rot_mats[::step]
|
| 77 |
+
root_out = root_positions[::step]
|
| 78 |
+
else:
|
| 79 |
+
device = local_rot_mats.device
|
| 80 |
+
dtype = local_rot_mats.dtype
|
| 81 |
+
u = torch.linspace(0, t_in - 1, t_out, device=device, dtype=dtype)
|
| 82 |
+
i0 = u.floor().long().clamp(0, t_in - 1)
|
| 83 |
+
i1 = torch.minimum(i0 + 1, torch.tensor(t_in - 1, device=device))
|
| 84 |
+
tau_1d = (u - i0.float()).unsqueeze(-1)
|
| 85 |
+
rp0 = root_positions[i0]
|
| 86 |
+
rp1 = root_positions[i1]
|
| 87 |
+
root_out = (1.0 - tau_1d) * rp0 + tau_1d * rp1
|
| 88 |
+
|
| 89 |
+
quats = matrix_to_quaternion(local_rot_mats)
|
| 90 |
+
q0 = quats[i0]
|
| 91 |
+
q1 = quats[i1]
|
| 92 |
+
tau_q = (u - i0.float()).view(t_out, 1, 1)
|
| 93 |
+
quat_out = _quaternion_slerp(q0, q1, tau_q)
|
| 94 |
+
local_out = quaternion_to_matrix(quat_out)
|
| 95 |
+
|
| 96 |
+
return complete_motion_dict(local_out, root_out, skeleton, float(target_fps)), True
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def warn_kimodo_npz_framerate(source_fps: float, t_before: int, t_after: int) -> None:
|
| 100 |
+
"""Emit a warning after time resampling for Kimodo NPZ (linear root, quaternion slerp per
|
| 101 |
+
joint)."""
|
| 102 |
+
warnings.warn(
|
| 103 |
+
f"Resampled motion to {KIMODO_CONVERT_TARGET_FPS:.0f} Hz for Kimodo NPZ "
|
| 104 |
+
f"(source ~{source_fps:.4g} Hz, {t_before} input frames → {t_after} output frames). "
|
| 105 |
+
"Pass --source-fps if the detected source rate is wrong.",
|
| 106 |
+
UserWarning,
|
| 107 |
+
stacklevel=3,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _coerce_time_local_root(
|
| 112 |
+
local_rot_mats: torch.Tensor,
|
| 113 |
+
root_positions: torch.Tensor,
|
| 114 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 115 |
+
"""Normalize to shapes (T, J, 3, 3) and (T, 3)."""
|
| 116 |
+
if local_rot_mats.dim() == 5:
|
| 117 |
+
if int(local_rot_mats.shape[0]) != 1:
|
| 118 |
+
raise ValueError(f"local_rot_mats batch size must be 1 for single clip; got {local_rot_mats.shape[0]}")
|
| 119 |
+
local_rot_mats = local_rot_mats[0]
|
| 120 |
+
if root_positions.dim() == 3:
|
| 121 |
+
if int(root_positions.shape[0]) != 1:
|
| 122 |
+
raise ValueError(f"root_positions batch size must be 1; got {root_positions.shape[0]}")
|
| 123 |
+
root_positions = root_positions[0]
|
| 124 |
+
if local_rot_mats.dim() != 4:
|
| 125 |
+
raise ValueError(f"local_rot_mats must be (T,J,3,3); got {tuple(local_rot_mats.shape)}")
|
| 126 |
+
if root_positions.dim() != 2 or int(root_positions.shape[-1]) != 3:
|
| 127 |
+
raise ValueError(f"root_positions must be (T,3); got {tuple(root_positions.shape)}")
|
| 128 |
+
if int(local_rot_mats.shape[0]) != int(root_positions.shape[0]):
|
| 129 |
+
raise ValueError("local_rot_mats and root_positions must have the same number of frames")
|
| 130 |
+
return local_rot_mats, root_positions
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def complete_motion_dict(
|
| 134 |
+
local_rot_mats: torch.Tensor,
|
| 135 |
+
root_positions: torch.Tensor,
|
| 136 |
+
skeleton: SkeletonBase,
|
| 137 |
+
fps: float,
|
| 138 |
+
) -> Dict[str, torch.Tensor]:
|
| 139 |
+
"""Build the Kimodo motion output dict from local rotations and root positions.
|
| 140 |
+
|
| 141 |
+
Matches keys written by CLI generation (see docs/source/user_guide/output_formats.md).
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
local_rot_mats: (T, J, 3, 3) or (1, T, J, 3, 3) local rotation matrices.
|
| 145 |
+
root_positions: (T, 3) or (1, T, 3) root / pelvis world positions (meters).
|
| 146 |
+
skeleton: Skeleton instance (SOMA77, G1, SMPL-X, etc.).
|
| 147 |
+
fps: Sampling rate (Hz).
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
Dict with tensors ``posed_joints``, ``global_rot_mats``, ``local_rot_mats``,
|
| 151 |
+
``foot_contacts``, ``smooth_root_pos``, ``root_positions``, ``global_root_heading``.
|
| 152 |
+
"""
|
| 153 |
+
device = local_rot_mats.device
|
| 154 |
+
dtype = local_rot_mats.dtype
|
| 155 |
+
local_rot_mats, root_positions = _coerce_time_local_root(
|
| 156 |
+
local_rot_mats.to(device=device, dtype=dtype),
|
| 157 |
+
root_positions.to(device=device, dtype=dtype),
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
global_rot_mats, posed_joints, _ = skeleton.fk(local_rot_mats, root_positions)
|
| 161 |
+
|
| 162 |
+
smooth_root_pos = get_smooth_root_pos(root_positions.unsqueeze(0)).squeeze(0)
|
| 163 |
+
|
| 164 |
+
lengths = torch.tensor([posed_joints.shape[0]], device=device)
|
| 165 |
+
velocities = compute_vel_xyz(posed_joints.unsqueeze(0), fps, lengths=lengths).squeeze(0)
|
| 166 |
+
|
| 167 |
+
heading_angle = compute_heading_angle(posed_joints.unsqueeze(0), skeleton).squeeze(0)
|
| 168 |
+
global_root_heading = torch.stack([torch.cos(heading_angle), torch.sin(heading_angle)], dim=-1)
|
| 169 |
+
|
| 170 |
+
foot_contacts = foot_detect_from_pos_and_vel(
|
| 171 |
+
posed_joints.unsqueeze(0),
|
| 172 |
+
velocities.unsqueeze(0),
|
| 173 |
+
skeleton,
|
| 174 |
+
0.15,
|
| 175 |
+
0.10,
|
| 176 |
+
).squeeze(0)
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"posed_joints": posed_joints,
|
| 180 |
+
"global_rot_mats": global_rot_mats,
|
| 181 |
+
"local_rot_mats": local_rot_mats,
|
| 182 |
+
"foot_contacts": foot_contacts,
|
| 183 |
+
"smooth_root_pos": smooth_root_pos,
|
| 184 |
+
"root_positions": root_positions,
|
| 185 |
+
"global_root_heading": global_root_heading,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def motion_dict_to_numpy(d: Dict[str, Any]) -> Dict[str, np.ndarray]:
|
| 190 |
+
"""Convert motion dict values to numpy arrays for ``np.savez``."""
|
| 191 |
+
out: Dict[str, np.ndarray] = {}
|
| 192 |
+
for k, v in d.items():
|
| 193 |
+
if hasattr(v, "detach"):
|
| 194 |
+
out[k] = to_numpy(v)
|
| 195 |
+
elif isinstance(v, np.ndarray):
|
| 196 |
+
out[k] = v
|
| 197 |
+
else:
|
| 198 |
+
out[k] = np.asarray(v)
|
| 199 |
+
return out
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def save_kimodo_npz(path: str, motion_dict: Dict[str, Any]) -> None:
|
| 203 |
+
"""Save a Kimodo-compatible motion dict to ``.npz`` (numpy arrays)."""
|
| 204 |
+
np.savez(path, **motion_dict_to_numpy(motion_dict))
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def load_kimodo_npz(path: str) -> Dict[str, np.ndarray]:
|
| 208 |
+
"""Load arrays from a Kimodo ``.npz`` file."""
|
| 209 |
+
with np.load(path, allow_pickle=False) as data:
|
| 210 |
+
return {k: np.asarray(data[k]) for k in data.files}
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def load_g1_csv(
|
| 214 |
+
path: str,
|
| 215 |
+
source_fps: float = KIMODO_CONVERT_TARGET_FPS,
|
| 216 |
+
*,
|
| 217 |
+
mujoco_rest_zero: bool = False,
|
| 218 |
+
) -> Dict[str, torch.Tensor]:
|
| 219 |
+
"""Load a G1 MuJoCo ``qpos`` CSV (``(T, 36)``) into a Kimodo motion dict.
|
| 220 |
+
|
| 221 |
+
Args:
|
| 222 |
+
path: CSV path (comma-separated, no header).
|
| 223 |
+
source_fps: Source frame rate (Hz) of the CSV data.
|
| 224 |
+
mujoco_rest_zero: Must match how the CSV was written (see :class:`MujocoQposConverter`).
|
| 225 |
+
"""
|
| 226 |
+
from kimodo.exports.mujoco import MujocoQposConverter
|
| 227 |
+
|
| 228 |
+
qpos = np.loadtxt(path, delimiter=",")
|
| 229 |
+
if qpos.ndim != 2 or qpos.shape[-1] != 36:
|
| 230 |
+
raise ValueError(f"Expected G1 CSV with shape (T, 36); got {qpos.shape}")
|
| 231 |
+
sk = build_skeleton(34)
|
| 232 |
+
converter = MujocoQposConverter(sk)
|
| 233 |
+
return converter.qpos_to_motion_dict(qpos, float(source_fps), mujoco_rest_zero=mujoco_rest_zero)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def load_amass_npz(
|
| 237 |
+
path: str,
|
| 238 |
+
source_fps: float | None = None,
|
| 239 |
+
*,
|
| 240 |
+
z_up: bool = True,
|
| 241 |
+
) -> Dict[str, torch.Tensor]:
|
| 242 |
+
"""Load an AMASS-style SMPL-X ``.npz`` into a Kimodo motion dict (22 joints).
|
| 243 |
+
|
| 244 |
+
Args:
|
| 245 |
+
path: NPZ with ``trans``, ``root_orient``, ``pose_body``, etc.
|
| 246 |
+
source_fps: Source frame rate (Hz); if ``None``, uses ``mocap_frame_rate``
|
| 247 |
+
from the file when present, else 30 Hz.
|
| 248 |
+
z_up: If ``True``, apply AMASS Z-up to Kimodo Y-up transform (same as CLI).
|
| 249 |
+
"""
|
| 250 |
+
from kimodo.exports.smplx import amass_npz_to_kimodo_motion
|
| 251 |
+
|
| 252 |
+
sk = build_skeleton(22)
|
| 253 |
+
return amass_npz_to_kimodo_motion(path, sk, source_fps=source_fps, z_up=z_up)
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def load_kimodo_npz_as_torch(
|
| 257 |
+
path: str,
|
| 258 |
+
source_fps: float = KIMODO_CONVERT_TARGET_FPS,
|
| 259 |
+
*,
|
| 260 |
+
ensure_complete: bool = True,
|
| 261 |
+
) -> tuple[Dict[str, torch.Tensor], int]:
|
| 262 |
+
"""Load a Kimodo NPZ and return all arrays as torch tensors on the skeleton device.
|
| 263 |
+
|
| 264 |
+
Args:
|
| 265 |
+
path: Kimodo NPZ file path.
|
| 266 |
+
source_fps: Source frame rate (Hz) used for derived channels when
|
| 267 |
+
``ensure_complete=True``.
|
| 268 |
+
ensure_complete: If ``True`` and the NPZ lacks derived channels
|
| 269 |
+
(``posed_joints``, ``global_rot_mats``, …), run :func:`complete_motion_dict`
|
| 270 |
+
to fill them from ``local_rot_mats`` + ``root_positions``.
|
| 271 |
+
If ``False``, load all arrays verbatim (requires ``local_rot_mats``).
|
| 272 |
+
|
| 273 |
+
Returns:
|
| 274 |
+
``(tensor_dict, num_joints)``
|
| 275 |
+
"""
|
| 276 |
+
raw = load_kimodo_npz(path)
|
| 277 |
+
if "local_rot_mats" in raw:
|
| 278 |
+
j = int(raw["local_rot_mats"].shape[1])
|
| 279 |
+
elif "posed_joints" in raw:
|
| 280 |
+
j = int(raw["posed_joints"].shape[1])
|
| 281 |
+
else:
|
| 282 |
+
raise ValueError("Kimodo NPZ must contain 'local_rot_mats' or 'posed_joints'.")
|
| 283 |
+
sk = build_skeleton(j)
|
| 284 |
+
device = sk.neutral_joints.device
|
| 285 |
+
dtype = torch.float32
|
| 286 |
+
|
| 287 |
+
if not ensure_complete:
|
| 288 |
+
if "local_rot_mats" not in raw:
|
| 289 |
+
raise ValueError("Kimodo NPZ must contain 'local_rot_mats' (and typically 'root_positions').")
|
| 290 |
+
out: Dict[str, torch.Tensor] = {}
|
| 291 |
+
for k, v in raw.items():
|
| 292 |
+
out[k] = torch.from_numpy(np.asarray(v)).to(device=device, dtype=dtype)
|
| 293 |
+
return out, j
|
| 294 |
+
|
| 295 |
+
if "posed_joints" in raw and "global_rot_mats" in raw:
|
| 296 |
+
out = {}
|
| 297 |
+
for k, v in raw.items():
|
| 298 |
+
out[k] = torch.from_numpy(np.asarray(v)).to(device=device, dtype=dtype)
|
| 299 |
+
return out, j
|
| 300 |
+
|
| 301 |
+
if "local_rot_mats" not in raw or "root_positions" not in raw:
|
| 302 |
+
raise ValueError("Kimodo NPZ must contain posed_joints+global_rot_mats, or local_rot_mats+root_positions.")
|
| 303 |
+
local = torch.from_numpy(np.asarray(raw["local_rot_mats"])).to(device=device, dtype=dtype)
|
| 304 |
+
root = torch.from_numpy(np.asarray(raw["root_positions"])).to(device=device, dtype=dtype)
|
| 305 |
+
return complete_motion_dict(local, root, sk, float(source_fps)), j
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
def save_kimodo_npz_at_target_fps(
|
| 309 |
+
motion: Dict[str, torch.Tensor],
|
| 310 |
+
skeleton: SkeletonBase,
|
| 311 |
+
source_fps: float,
|
| 312 |
+
output_path: str,
|
| 313 |
+
target_fps: float = KIMODO_CONVERT_TARGET_FPS,
|
| 314 |
+
) -> None:
|
| 315 |
+
"""Resample a motion dict to ``target_fps`` when needed, then save Kimodo NPZ."""
|
| 316 |
+
t_before = int(motion["local_rot_mats"].shape[0])
|
| 317 |
+
motion, did_resample = resample_motion_dict_to_kimodo_fps(motion, skeleton, source_fps, target_fps)
|
| 318 |
+
t_after = int(motion["local_rot_mats"].shape[0])
|
| 319 |
+
if did_resample:
|
| 320 |
+
warn_kimodo_npz_framerate(source_fps, t_before, t_after)
|
| 321 |
+
save_kimodo_npz(output_path, motion)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def kimodo_npz_to_bytes(motion_dict: Dict[str, Any]) -> bytes:
|
| 325 |
+
"""Serialize a Kimodo motion dict to in-memory NPZ bytes."""
|
| 326 |
+
import io
|
| 327 |
+
|
| 328 |
+
buf = io.BytesIO()
|
| 329 |
+
np.savez(buf, **motion_dict_to_numpy(motion_dict))
|
| 330 |
+
return buf.getvalue()
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def g1_csv_to_bytes(motion_dict: Dict[str, Any], skeleton: SkeletonBase, device: Any) -> bytes:
|
| 334 |
+
"""Convert a motion dict to G1 MuJoCo CSV bytes via :class:`MujocoQposConverter`."""
|
| 335 |
+
import io
|
| 336 |
+
|
| 337 |
+
from kimodo.exports.mujoco import MujocoQposConverter
|
| 338 |
+
|
| 339 |
+
converter = MujocoQposConverter(skeleton)
|
| 340 |
+
qpos = converter.dict_to_qpos(
|
| 341 |
+
{k: v for k, v in motion_dict.items() if k in ("local_rot_mats", "root_positions")},
|
| 342 |
+
device,
|
| 343 |
+
numpy=True,
|
| 344 |
+
)
|
| 345 |
+
buf = io.StringIO()
|
| 346 |
+
np.savetxt(buf, qpos, delimiter=",")
|
| 347 |
+
return buf.getvalue().encode("utf-8")
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def amass_npz_to_bytes(motion_dict: Dict[str, Any], skeleton: SkeletonBase, fps: float) -> bytes:
|
| 351 |
+
"""Convert a motion dict to AMASS NPZ bytes via :class:`AMASSConverter`."""
|
| 352 |
+
import io
|
| 353 |
+
|
| 354 |
+
from kimodo.exports.smplx import AMASSConverter
|
| 355 |
+
|
| 356 |
+
converter = AMASSConverter(skeleton=skeleton, fps=fps)
|
| 357 |
+
buf = io.BytesIO()
|
| 358 |
+
converter.convert_save_npz(
|
| 359 |
+
{k: v for k, v in motion_dict.items() if k in ("local_rot_mats", "root_positions")},
|
| 360 |
+
buf,
|
| 361 |
+
)
|
| 362 |
+
return buf.getvalue()
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def _read_amass_source_fps(path: str) -> float:
|
| 366 |
+
"""Read the source frame rate from an AMASS NPZ, defaulting to 30 Hz."""
|
| 367 |
+
with np.load(path, allow_pickle=True) as z:
|
| 368 |
+
if "mocap_frame_rate" in z.files:
|
| 369 |
+
return float(z["mocap_frame_rate"])
|
| 370 |
+
return 30.0
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
def load_motion_file(
|
| 374 |
+
path: str,
|
| 375 |
+
source_fps: float | None = None,
|
| 376 |
+
target_fps: float | None = None,
|
| 377 |
+
*,
|
| 378 |
+
z_up: bool = True,
|
| 379 |
+
mujoco_rest_zero: bool = False,
|
| 380 |
+
) -> tuple[Dict[str, torch.Tensor], int]:
|
| 381 |
+
"""Load a motion file and return a Kimodo motion dict plus joint count.
|
| 382 |
+
|
| 383 |
+
Supports SOMA BVH (``.bvh``), G1 MuJoCo CSV (``.csv``), Kimodo NPZ, and AMASS SMPL-X NPZ
|
| 384 |
+
(``.npz``).
|
| 385 |
+
|
| 386 |
+
The motion is loaded at its native (or overridden) source rate, then
|
| 387 |
+
resampled to ``target_fps`` when they differ.
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
path: Path to ``.bvh``, ``.csv``, or ``.npz``.
|
| 391 |
+
source_fps: Source frame rate (Hz). If provided, trusted as-is.
|
| 392 |
+
If ``None``, auto-detected per format: BVH ``Frame Time`` header,
|
| 393 |
+
AMASS ``mocap_frame_rate``, or :data:`KIMODO_CONVERT_TARGET_FPS`
|
| 394 |
+
(30 Hz) for CSV / Kimodo NPZ.
|
| 395 |
+
target_fps: Desired output frame rate (Hz). Defaults to
|
| 396 |
+
:data:`KIMODO_CONVERT_TARGET_FPS` (30 Hz). The motion is
|
| 397 |
+
resampled when ``source_fps`` and ``target_fps`` differ.
|
| 398 |
+
z_up: AMASS NPZ only; passed to :func:`load_amass_npz`.
|
| 399 |
+
mujoco_rest_zero: G1 CSV only; passed to :func:`load_g1_csv`.
|
| 400 |
+
|
| 401 |
+
Returns:
|
| 402 |
+
``(motion_dict, num_joints)`` with the same keys as :func:`complete_motion_dict`.
|
| 403 |
+
"""
|
| 404 |
+
from kimodo.exports.motion_formats import infer_npz_kind
|
| 405 |
+
|
| 406 |
+
if target_fps is None:
|
| 407 |
+
target_fps = KIMODO_CONVERT_TARGET_FPS
|
| 408 |
+
|
| 409 |
+
ext = os.path.splitext(path)[1].lower()
|
| 410 |
+
if ext == ".bvh":
|
| 411 |
+
from kimodo.exports.bvh import bvh_to_kimodo_motion
|
| 412 |
+
|
| 413 |
+
motion_dict, bvh_fps = bvh_to_kimodo_motion(path)
|
| 414 |
+
effective_source = source_fps if source_fps is not None else bvh_fps
|
| 415 |
+
num_joints = int(motion_dict["local_rot_mats"].shape[1])
|
| 416 |
+
elif ext == ".csv":
|
| 417 |
+
effective_source = source_fps if source_fps is not None else KIMODO_CONVERT_TARGET_FPS
|
| 418 |
+
motion_dict = load_g1_csv(path, source_fps=effective_source, mujoco_rest_zero=mujoco_rest_zero)
|
| 419 |
+
num_joints = 34
|
| 420 |
+
elif ext == ".npz":
|
| 421 |
+
kind = infer_npz_kind(path)
|
| 422 |
+
if kind == "amass":
|
| 423 |
+
effective_source = source_fps if source_fps is not None else _read_amass_source_fps(path)
|
| 424 |
+
motion_dict = load_amass_npz(path, source_fps=effective_source, z_up=z_up)
|
| 425 |
+
num_joints = 22
|
| 426 |
+
else:
|
| 427 |
+
effective_source = source_fps if source_fps is not None else KIMODO_CONVERT_TARGET_FPS
|
| 428 |
+
motion_dict, num_joints = load_kimodo_npz_as_torch(path, source_fps=effective_source)
|
| 429 |
+
else:
|
| 430 |
+
raise ValueError(f"Unsupported motion file {path!r}; expected .bvh, .csv, or .npz")
|
| 431 |
+
|
| 432 |
+
if abs(effective_source - target_fps) > 0.5:
|
| 433 |
+
sk = build_skeleton(num_joints)
|
| 434 |
+
motion_dict, did_resample = resample_motion_dict_to_kimodo_fps(motion_dict, sk, effective_source, target_fps)
|
| 435 |
+
if did_resample:
|
| 436 |
+
t_out = int(motion_dict["local_rot_mats"].shape[0])
|
| 437 |
+
warnings.warn(
|
| 438 |
+
f"Resampled motion from {effective_source:.4g} Hz to " f"{target_fps:.0f} Hz ({t_out} frames).",
|
| 439 |
+
UserWarning,
|
| 440 |
+
stacklevel=2,
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
return motion_dict, num_joints
|
kimodo/exports/mujoco.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Convert kimodo motion (y-up, z-forward) to MuJoCo qpos (z-up, x-forward) for G1 skeleton."""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
import xml.etree.ElementTree as ET
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
from scipy.spatial.transform import Rotation
|
| 12 |
+
|
| 13 |
+
from kimodo.assets import skeleton_asset_path
|
| 14 |
+
from kimodo.geometry import (
|
| 15 |
+
axis_angle_to_matrix,
|
| 16 |
+
matrix_to_axis_angle,
|
| 17 |
+
matrix_to_quaternion,
|
| 18 |
+
quaternion_to_matrix,
|
| 19 |
+
)
|
| 20 |
+
from kimodo.skeleton import G1Skeleton34, SkeletonBase, global_rots_to_local_rots
|
| 21 |
+
from kimodo.tools import ensure_batched, to_numpy, to_torch
|
| 22 |
+
|
| 23 |
+
# Cache so that the same (skeleton, xml_path) returns the same converter instance.
|
| 24 |
+
_converter_cache: dict[tuple[int, str], "MujocoQposConverter"] = {}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MujocoQposConverter:
|
| 28 |
+
"""Fast batch converter from our dictionary format to mujoco qpos with precomputed transforms.
|
| 29 |
+
|
| 30 |
+
In mujoco, the coordination is z up and x forward, right handed.
|
| 31 |
+
|
| 32 |
+
Features (30 joints):
|
| 33 |
+
- root (pelvis, 7 = translation + rotation) + 29 dof joints (29)
|
| 34 |
+
|
| 35 |
+
In kimodo, the coordinate system is y up and z forward, right handed.
|
| 36 |
+
Features (34 joints):
|
| 37 |
+
- root (pelvis) + (34 - 1) joints; among these joints, 4 are end-effector joints added by kimodo.
|
| 38 |
+
|
| 39 |
+
Cached by (input_skeleton id, xml_path); repeated calls with the same args return the same instance.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __new__(
|
| 43 |
+
cls,
|
| 44 |
+
input_skeleton: SkeletonBase,
|
| 45 |
+
xml_path: str = str(skeleton_asset_path("g1skel34", "xml", "g1.xml")),
|
| 46 |
+
):
|
| 47 |
+
key = (id(input_skeleton), xml_path)
|
| 48 |
+
if key not in _converter_cache:
|
| 49 |
+
inst = object.__new__(cls)
|
| 50 |
+
_converter_cache[key] = inst
|
| 51 |
+
return _converter_cache[key]
|
| 52 |
+
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
input_skeleton: SkeletonBase,
|
| 56 |
+
xml_path: str = str(skeleton_asset_path("g1skel34", "xml", "g1.xml")),
|
| 57 |
+
):
|
| 58 |
+
"""Initialize converter with precomputed transforms.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
xml_path: Path to the mujoco XML file containing joint definitions
|
| 62 |
+
"""
|
| 63 |
+
if getattr(self, "_initialized", False):
|
| 64 |
+
return
|
| 65 |
+
self.xml_path = xml_path
|
| 66 |
+
self.skeleton = input_skeleton
|
| 67 |
+
self._prepare_transforms()
|
| 68 |
+
self._subtree_joints = {}
|
| 69 |
+
self._initialized = True
|
| 70 |
+
|
| 71 |
+
def _prepare_transforms(self):
|
| 72 |
+
"""Precompute all necessary transforms for efficient batch processing."""
|
| 73 |
+
# Define coordinate transformations between mujoco and kimodo space
|
| 74 |
+
# 1) R_zup_to_yup: rotation around x-axis by -90 degrees
|
| 75 |
+
# 2) x_forward_to_y_forward: rotation around z-axis by -90 degrees
|
| 76 |
+
# Combined transformation matrix: mujoco_to_kimodo = R_zup_to_yup * x_forward_to_y_forward
|
| 77 |
+
self.mujoco_to_kimodo_matrix = torch.tensor(
|
| 78 |
+
[[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 0.0]], dtype=torch.float32
|
| 79 |
+
)
|
| 80 |
+
self.kimodo_to_mujoco_matrix = self.mujoco_to_kimodo_matrix.T # Inverse transformation: kimodo_to_mujoco
|
| 81 |
+
|
| 82 |
+
# Parse XML once and extract joint information
|
| 83 |
+
tree = ET.parse(self.xml_path)
|
| 84 |
+
root = tree.getroot()
|
| 85 |
+
|
| 86 |
+
xml_classes = [x for x in tree.findall(".//default") if "class" in x.attrib]
|
| 87 |
+
joint_axes = dict()
|
| 88 |
+
class_ranges: dict[str, tuple[float, float]] = {}
|
| 89 |
+
for xml_class in xml_classes:
|
| 90 |
+
j = xml_class.findall("joint")
|
| 91 |
+
if j:
|
| 92 |
+
joint_axes[xml_class.get("class")] = j[0].get("axis")
|
| 93 |
+
range_str = j[0].get("range")
|
| 94 |
+
if range_str:
|
| 95 |
+
range_vals = [float(x) for x in range_str.split()]
|
| 96 |
+
if len(range_vals) == 2:
|
| 97 |
+
class_ranges[xml_class.get("class")] = (
|
| 98 |
+
range_vals[0],
|
| 99 |
+
range_vals[1],
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
mujoco_hinge_joints = root.find("worldbody").findall(".//joint") # skip the base joint
|
| 103 |
+
self._mujoco_joint_axis_values_kimodo_space = torch.zeros(
|
| 104 |
+
(len(mujoco_hinge_joints), 3), dtype=torch.float32
|
| 105 |
+
) # mujoco order but kimodo space
|
| 106 |
+
self._mujoco_joint_axis_values_mujoco_space = torch.zeros(
|
| 107 |
+
(len(mujoco_hinge_joints), 3), dtype=torch.float32
|
| 108 |
+
) # mujoco order but mujoco space
|
| 109 |
+
|
| 110 |
+
# for the below indices, mujoco_indices_to_kimodo_indices does not include mujoco root (30 - 1 = 29 elements),
|
| 111 |
+
# while kimodo_indices_to_mujoco_indices inclues the kimodo root (32 elements).
|
| 112 |
+
self._mujoco_indices_to_kimodo_indices = torch.zeros((len(mujoco_hinge_joints),), dtype=torch.int32)
|
| 113 |
+
self._kimodo_indices_to_mujoco_indices = (
|
| 114 |
+
torch.ones((self.skeleton.nbjoints,), dtype=torch.int32) * -1
|
| 115 |
+
) # -1 means not in the csv skeleton
|
| 116 |
+
|
| 117 |
+
self._nb_joints_mujoco = len(mujoco_hinge_joints) + 1
|
| 118 |
+
self._nb_joints_kimodo = self.skeleton.nbjoints
|
| 119 |
+
self._mujoco_joint_including_root_parent_list = torch.full(
|
| 120 |
+
(len(mujoco_hinge_joints) + 1,), -1, dtype=torch.int32
|
| 121 |
+
)
|
| 122 |
+
self._mujoco_joint_including_root_list = ["pelvis_skel"]
|
| 123 |
+
|
| 124 |
+
for joint_id_in_csv, joint in enumerate(mujoco_hinge_joints):
|
| 125 |
+
joint_name_in_skeleton = joint.get("name").replace("_joint", "_skel")
|
| 126 |
+
joint_parent_name_in_skeleton = self.skeleton.bone_parents[joint_name_in_skeleton]
|
| 127 |
+
|
| 128 |
+
self._mujoco_joint_including_root_list.append(joint_name_in_skeleton)
|
| 129 |
+
self._mujoco_joint_including_root_parent_list[joint_id_in_csv + 1] = (
|
| 130 |
+
self._mujoco_joint_including_root_list.index(joint_parent_name_in_skeleton)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
joint_idx_in_kimodo_skeleton = self.skeleton.bone_order_names.index(joint_name_in_skeleton)
|
| 134 |
+
axis_values = [float(x) for x in (joint.get("axis") or joint_axes[joint.get("class")]).split(" ")]
|
| 135 |
+
|
| 136 |
+
# the mapped axis in kimodo skeleton space is calculated as bones_axis = mujoco_to_kimodo.apply(axis_values)
|
| 137 |
+
# [1, 0, 0] -> [0, 0, 1]; [0, 1, 0] -> [1, 0, 0]; [0, 0, 1] -> [0, 1, 0]
|
| 138 |
+
mujoco_joint_axis_mapping_kimodo_space = [
|
| 139 |
+
torch.tensor([0, 0, 1]),
|
| 140 |
+
torch.tensor([1, 0, 0]),
|
| 141 |
+
torch.tensor([0, 1, 0]),
|
| 142 |
+
][np.argmax(axis_values)]
|
| 143 |
+
|
| 144 |
+
self._mujoco_joint_axis_values_kimodo_space[joint_id_in_csv] = mujoco_joint_axis_mapping_kimodo_space
|
| 145 |
+
self._mujoco_joint_axis_values_mujoco_space[joint_id_in_csv] = torch.tensor(axis_values)
|
| 146 |
+
|
| 147 |
+
self._mujoco_indices_to_kimodo_indices[joint_id_in_csv] = joint_idx_in_kimodo_skeleton
|
| 148 |
+
self._kimodo_indices_to_mujoco_indices[joint_idx_in_kimodo_skeleton] = (
|
| 149 |
+
joint_id_in_csv + 1
|
| 150 |
+
) # +1 for the root
|
| 151 |
+
self._kimodo_indices_to_mujoco_indices[0] = 0 # the root joint mapping
|
| 152 |
+
|
| 153 |
+
# Joint limits (min, max) in radians for each mujoco hinge, for clamping
|
| 154 |
+
self._joint_limits_min = torch.full((len(mujoco_hinge_joints),), float("-inf"), dtype=torch.float32)
|
| 155 |
+
self._joint_limits_max = torch.full((len(mujoco_hinge_joints),), float("inf"), dtype=torch.float32)
|
| 156 |
+
for joint_id_in_csv, joint in enumerate(mujoco_hinge_joints):
|
| 157 |
+
range_vals = None
|
| 158 |
+
if joint.get("range"):
|
| 159 |
+
range_vals = [float(x) for x in joint.get("range").split()]
|
| 160 |
+
elif joint.get("class") and joint.get("class") in class_ranges:
|
| 161 |
+
lo, hi = class_ranges[joint.get("class")]
|
| 162 |
+
range_vals = [lo, hi]
|
| 163 |
+
if range_vals is not None and len(range_vals) == 2:
|
| 164 |
+
self._joint_limits_min[joint_id_in_csv] = range_vals[0]
|
| 165 |
+
self._joint_limits_max[joint_id_in_csv] = range_vals[1]
|
| 166 |
+
|
| 167 |
+
# load the offset matrices from the xml
|
| 168 |
+
R_zup_to_yup = Rotation.from_euler("x", -90, degrees=True)
|
| 169 |
+
x_forward_to_y_forward = Rotation.from_euler("z", -90, degrees=True)
|
| 170 |
+
mujoco_to_kimodo = R_zup_to_yup * x_forward_to_y_forward
|
| 171 |
+
|
| 172 |
+
self._rot_offsets_q2t = torch.zeros(len(self._kimodo_indices_to_mujoco_indices), 3, 3, dtype=torch.float32)
|
| 173 |
+
self._rot_offsets_q2t[...] = torch.eye(3)[None]
|
| 174 |
+
|
| 175 |
+
self._rot_offsets_f2q = torch.zeros(len(self._kimodo_indices_to_mujoco_indices), 3, 3, dtype=torch.float32)
|
| 176 |
+
self._rot_offsets_f2q[...] = torch.eye(3)[None]
|
| 177 |
+
parent_map = {child: parent for parent in root.iter() for child in parent}
|
| 178 |
+
for i, joint in enumerate(mujoco_hinge_joints):
|
| 179 |
+
body = parent_map[joint]
|
| 180 |
+
if "quat" in body.attrib:
|
| 181 |
+
rot = Rotation.from_quat(
|
| 182 |
+
[float(x) for x in body.get("quat").strip().split(" ")],
|
| 183 |
+
scalar_first=True,
|
| 184 |
+
)
|
| 185 |
+
idx = self._mujoco_indices_to_kimodo_indices[i]
|
| 186 |
+
self._rot_offsets_q2t[idx] = torch.from_numpy(rot.as_matrix())
|
| 187 |
+
rot = mujoco_to_kimodo * rot * mujoco_to_kimodo.inv()
|
| 188 |
+
self._rot_offsets_f2q[idx] = torch.from_numpy(rot.as_matrix().T)
|
| 189 |
+
|
| 190 |
+
# Hinge axis in f2q space so extraction uses the same frame as joint_rot_f2q.
|
| 191 |
+
# Then extract(offset) gives the angle s.t. axis_angle(angle * axis_f2q) = offset, and
|
| 192 |
+
# reconstruction R_local = offset.T @ axis_angle(angle * axis_f2q) = I when input is identity.
|
| 193 |
+
axis_kimodo = self._mujoco_joint_axis_values_kimodo_space
|
| 194 |
+
self._mujoco_joint_axis_values_f2q_space = torch.zeros_like(axis_kimodo)
|
| 195 |
+
for i in range(len(mujoco_hinge_joints)):
|
| 196 |
+
j = self._mujoco_indices_to_kimodo_indices[i].item()
|
| 197 |
+
axis_f2q = torch.mv(self._rot_offsets_f2q[j], axis_kimodo[i])
|
| 198 |
+
n = axis_f2q.norm()
|
| 199 |
+
if n > 1e-8:
|
| 200 |
+
axis_f2q = axis_f2q / n
|
| 201 |
+
self._mujoco_joint_axis_values_f2q_space[i] = axis_f2q
|
| 202 |
+
|
| 203 |
+
# Rest-pose DOFs: angle we extract when R_local = I (t-pose). MuJoCo limits are
|
| 204 |
+
# relative to joint zero (rest pose), so we must clamp in MuJoCo space: convert
|
| 205 |
+
# joint_dofs to mujoco_angle = joint_dofs - rest_dofs, clamp, then back.
|
| 206 |
+
rest_rot_f2q = self._rot_offsets_f2q[self._mujoco_indices_to_kimodo_indices]
|
| 207 |
+
rest_rot_f2q = rest_rot_f2q.unsqueeze(0).unsqueeze(0)
|
| 208 |
+
self._rest_dofs = self._local_rots_f2q_to_joint_dofs(rest_rot_f2q).squeeze(0).squeeze(0)
|
| 209 |
+
# Axis-angle rest DOFs: angle s.t. axis_angle(angle * axis_f2q) = offset. Used in
|
| 210 |
+
# project_to_real_robot_rotations so extract+reconstruct round-trip and t-pose is preserved.
|
| 211 |
+
rest_rot_f2q_flat = self._rot_offsets_f2q[self._mujoco_indices_to_kimodo_indices]
|
| 212 |
+
full_aa = matrix_to_axis_angle(rest_rot_f2q_flat)
|
| 213 |
+
self._rest_dofs_axis_angle = (full_aa * self._mujoco_joint_axis_values_f2q_space).sum(dim=-1)
|
| 214 |
+
|
| 215 |
+
def dict_to_qpos(
|
| 216 |
+
self,
|
| 217 |
+
output: dict,
|
| 218 |
+
device: Optional[str] = None,
|
| 219 |
+
root_quat_w_first: bool = True,
|
| 220 |
+
numpy: bool = True,
|
| 221 |
+
mujoco_rest_zero: bool = False,
|
| 222 |
+
):
|
| 223 |
+
"""Convert kimodo output dict to mujoco qpos format.
|
| 224 |
+
|
| 225 |
+
Args:
|
| 226 |
+
output: dict with keys "local_rot_mats" and "root_positions".
|
| 227 |
+
device: device to use for the output.
|
| 228 |
+
root_quat_w_first: If True, quaternion in qpos is (w,x,y,z).
|
| 229 |
+
numpy: If True, convert the output to numpy array.
|
| 230 |
+
mujoco_rest_zero: If True, joint angles are written so that kimodo rest (t-pose)
|
| 231 |
+
maps to q=0 in MuJoCo. If False, write raw joint_dofs.
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
qpos: (B, T, 7+J) mujoco qpos format.
|
| 235 |
+
"""
|
| 236 |
+
local_rot_mats = to_torch(output["local_rot_mats"], device)
|
| 237 |
+
root_positions = to_torch(output["root_positions"], device)
|
| 238 |
+
|
| 239 |
+
qpos = self.to_qpos(
|
| 240 |
+
local_rot_mats,
|
| 241 |
+
root_positions,
|
| 242 |
+
root_quat_w_first=root_quat_w_first,
|
| 243 |
+
mujoco_rest_zero=mujoco_rest_zero,
|
| 244 |
+
)
|
| 245 |
+
if numpy:
|
| 246 |
+
qpos = to_numpy(qpos)
|
| 247 |
+
return qpos
|
| 248 |
+
|
| 249 |
+
def qpos_to_motion_dict(
|
| 250 |
+
self,
|
| 251 |
+
qpos: torch.Tensor | np.ndarray,
|
| 252 |
+
source_fps: float,
|
| 253 |
+
*,
|
| 254 |
+
root_quat_w_first: bool = True,
|
| 255 |
+
mujoco_rest_zero: bool = False,
|
| 256 |
+
):
|
| 257 |
+
"""Inverse of :meth:`to_qpos` / :meth:`dict_to_qpos` for MuJoCo CSV ``(T, 36)`` rows.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
qpos: Shape ``(T, 36)`` or ``(1, T, 36)`` (root xyz, root quat wxyz, 29 joint angles).
|
| 261 |
+
source_fps: Source frame rate (Hz) of the qpos data.
|
| 262 |
+
root_quat_w_first: Must match how the CSV was written (default ``True``).
|
| 263 |
+
mujoco_rest_zero: Must match :meth:`dict_to_qpos` / :meth:`to_qpos`.
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
Kimodo motion dict (see :func:`kimodo.exports.motion_io.complete_motion_dict`).
|
| 267 |
+
"""
|
| 268 |
+
from kimodo.exports.motion_io import complete_motion_dict
|
| 269 |
+
|
| 270 |
+
qpos = to_torch(qpos, None)
|
| 271 |
+
if qpos.dim() == 2:
|
| 272 |
+
qpos = qpos.unsqueeze(0)
|
| 273 |
+
device = qpos.device
|
| 274 |
+
dtype = qpos.dtype
|
| 275 |
+
batch_size, num_frames, ncols = qpos.shape
|
| 276 |
+
if ncols != 36:
|
| 277 |
+
raise ValueError(f"Expected qpos last dim 36; got {ncols}")
|
| 278 |
+
|
| 279 |
+
kimodo_to_mujoco_matrix = self.kimodo_to_mujoco_matrix.to(device=device, dtype=dtype)
|
| 280 |
+
mujoco_to_kimodo_matrix = kimodo_to_mujoco_matrix.T
|
| 281 |
+
|
| 282 |
+
root_mujoco = qpos[..., :3]
|
| 283 |
+
root_positions = torch.matmul(mujoco_to_kimodo_matrix[None, None, ...], root_mujoco[..., None]).squeeze(-1)
|
| 284 |
+
|
| 285 |
+
quat = qpos[..., 3:7]
|
| 286 |
+
if root_quat_w_first:
|
| 287 |
+
root_rot_mujoco = quaternion_to_matrix(quat)
|
| 288 |
+
else:
|
| 289 |
+
quat_wxyz = quat[..., [3, 0, 1, 2]]
|
| 290 |
+
root_rot_mujoco = quaternion_to_matrix(quat_wxyz)
|
| 291 |
+
|
| 292 |
+
O0 = self._rot_offsets_f2q[0].to(device=device, dtype=dtype)
|
| 293 |
+
# root_rot_mujoco is (..., 3, 3) after optional batch unsqueeze (e.g. (1, T, 3, 3)).
|
| 294 |
+
# Use ``...il`` so ``k`` sums with ``kl``; ``...ik`` incorrectly keeps ``k`` in the output.
|
| 295 |
+
R_f2q_root = torch.einsum(
|
| 296 |
+
"ij,...jk,kl->...il",
|
| 297 |
+
mujoco_to_kimodo_matrix,
|
| 298 |
+
root_rot_mujoco,
|
| 299 |
+
kimodo_to_mujoco_matrix,
|
| 300 |
+
)
|
| 301 |
+
R_kimodo_root = torch.einsum("ij,...jk->...ik", O0.T, R_f2q_root)
|
| 302 |
+
|
| 303 |
+
joint_dofs = qpos[..., 7:]
|
| 304 |
+
if mujoco_rest_zero:
|
| 305 |
+
rest_dofs = self._rest_dofs.to(device=device, dtype=dtype)
|
| 306 |
+
angles = joint_dofs + rest_dofs[None, None, :]
|
| 307 |
+
use_relative = True
|
| 308 |
+
else:
|
| 309 |
+
angles = joint_dofs
|
| 310 |
+
use_relative = False
|
| 311 |
+
|
| 312 |
+
nb_joints = self.skeleton.nbjoints
|
| 313 |
+
template = torch.eye(3, device=device, dtype=dtype).expand(batch_size, num_frames, nb_joints, 3, 3).contiguous()
|
| 314 |
+
template[:, :, 0] = R_kimodo_root
|
| 315 |
+
|
| 316 |
+
local_rot_mats = self._joint_dofs_to_local_rot_mats(
|
| 317 |
+
angles,
|
| 318 |
+
template,
|
| 319 |
+
device,
|
| 320 |
+
dtype,
|
| 321 |
+
use_relative=use_relative,
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
if batch_size != 1:
|
| 325 |
+
raise ValueError(f"Only a single clip is supported; got batch_size={batch_size}")
|
| 326 |
+
|
| 327 |
+
return complete_motion_dict(local_rot_mats[0], root_positions[0], self.skeleton, source_fps)
|
| 328 |
+
|
| 329 |
+
def save_csv(self, qpos: torch.Tensor | np.ndarray, csv_path):
|
| 330 |
+
# comment this
|
| 331 |
+
qpos = to_numpy(qpos)
|
| 332 |
+
shape = qpos.shape
|
| 333 |
+
if len(shape) == 2:
|
| 334 |
+
# only one motion: save it
|
| 335 |
+
np.savetxt(csv_path, qpos, delimiter=",")
|
| 336 |
+
if len(shape) == 3:
|
| 337 |
+
# batch of motions
|
| 338 |
+
if shape[0] == 1:
|
| 339 |
+
# if only one motion, just save it
|
| 340 |
+
np.savetxt(csv_path, qpos[0], delimiter=",")
|
| 341 |
+
else:
|
| 342 |
+
csv_path_base, ext = os.path.splitext(csv_path)
|
| 343 |
+
for i in range(shape[0]):
|
| 344 |
+
self.save_csv(qpos[i], csv_path_base + "_" + str(i).zfill(2) + ext)
|
| 345 |
+
|
| 346 |
+
def _local_rots_to_joint_dofs(
|
| 347 |
+
self,
|
| 348 |
+
local_rot_mats: torch.Tensor,
|
| 349 |
+
axis_vals: torch.Tensor,
|
| 350 |
+
) -> torch.Tensor:
|
| 351 |
+
"""Extract per-joint single-DoF angles (radians) via Euler projection (for to_qpos/f2q)."""
|
| 352 |
+
x_joint_dof = torch.atan2(local_rot_mats[..., 2, 1], local_rot_mats[..., 2, 2])
|
| 353 |
+
y_joint_dof = torch.atan2(local_rot_mats[..., 0, 2], local_rot_mats[..., 0, 0])
|
| 354 |
+
z_joint_dof = torch.atan2(local_rot_mats[..., 1, 0], local_rot_mats[..., 1, 1])
|
| 355 |
+
xyz_joint_dofs = torch.stack([x_joint_dof, y_joint_dof, z_joint_dof], dim=-1)
|
| 356 |
+
axis_vals = axis_vals.to(device=local_rot_mats.device, dtype=local_rot_mats.dtype)
|
| 357 |
+
joint_dofs = (xyz_joint_dofs * axis_vals[None, None, :, :]).sum(dim=-1)
|
| 358 |
+
return joint_dofs
|
| 359 |
+
|
| 360 |
+
def _local_rots_to_joint_dofs_axis_angle(
|
| 361 |
+
self,
|
| 362 |
+
local_rot_mats: torch.Tensor,
|
| 363 |
+
axis_vals: torch.Tensor,
|
| 364 |
+
) -> torch.Tensor:
|
| 365 |
+
"""Extract per-joint single-DoF angles (radians) via axis-angle; round-trips with
|
| 366 |
+
axis_angle_to_matrix.
|
| 367 |
+
|
| 368 |
+
Args:
|
| 369 |
+
local_rot_mats: (..., num_hinges, 3, 3) in same frame as axis_vals.
|
| 370 |
+
axis_vals: (num_hinges, 3) unit axis per hinge.
|
| 371 |
+
Returns:
|
| 372 |
+
joint_dofs: (..., num_hinges) signed angle = dot(axis_angle(R), axis).
|
| 373 |
+
"""
|
| 374 |
+
axis_vals = axis_vals.to(device=local_rot_mats.device, dtype=local_rot_mats.dtype)
|
| 375 |
+
full_aa = matrix_to_axis_angle(local_rot_mats)
|
| 376 |
+
joint_dofs = (full_aa * axis_vals).sum(dim=-1)
|
| 377 |
+
return joint_dofs
|
| 378 |
+
|
| 379 |
+
def _local_rots_f2q_to_joint_dofs(self, local_rot_mats_f2q: torch.Tensor) -> torch.Tensor:
|
| 380 |
+
"""Extract per-joint single-DoF angles from local rotations in f2q space (for to_qpos)."""
|
| 381 |
+
axis_vals = self._mujoco_joint_axis_values_f2q_space
|
| 382 |
+
return self._local_rots_to_joint_dofs(local_rot_mats_f2q, axis_vals)
|
| 383 |
+
|
| 384 |
+
def _clamp_to_limits(self, joint_dofs: torch.Tensor) -> torch.Tensor:
|
| 385 |
+
"""Clamp joint angles to XML limits (radians).
|
| 386 |
+
|
| 387 |
+
Angles are in kimodo convention (0 = rest).
|
| 388 |
+
"""
|
| 389 |
+
device = joint_dofs.device
|
| 390 |
+
lo = self._joint_limits_min.to(device=device, dtype=joint_dofs.dtype)
|
| 391 |
+
hi = self._joint_limits_max.to(device=device, dtype=joint_dofs.dtype)
|
| 392 |
+
return torch.clamp(joint_dofs, lo[None, None, :], hi[None, None, :])
|
| 393 |
+
|
| 394 |
+
def _clamp_joint_dofs(self, joint_dofs: torch.Tensor, rest_dofs: torch.Tensor) -> torch.Tensor:
|
| 395 |
+
"""Clamp joint angles to MuJoCo limits (radians), with rest_dofs conversion."""
|
| 396 |
+
device = joint_dofs.device
|
| 397 |
+
rest_dofs = rest_dofs.to(device=device, dtype=joint_dofs.dtype)
|
| 398 |
+
mujoco_dofs = joint_dofs - rest_dofs[None, None, :]
|
| 399 |
+
lo = self._joint_limits_min.to(device=device, dtype=joint_dofs.dtype)
|
| 400 |
+
hi = self._joint_limits_max.to(device=device, dtype=joint_dofs.dtype)
|
| 401 |
+
mujoco_dofs = torch.clamp(mujoco_dofs, lo[None, None, :], hi[None, None, :])
|
| 402 |
+
return mujoco_dofs + rest_dofs[None, None, :]
|
| 403 |
+
|
| 404 |
+
def _joint_dofs_to_local_rot_mats(
|
| 405 |
+
self,
|
| 406 |
+
joint_dofs: torch.Tensor,
|
| 407 |
+
original_local_rot_mats: torch.Tensor,
|
| 408 |
+
device: torch.device,
|
| 409 |
+
dtype: torch.dtype,
|
| 410 |
+
use_relative: bool = False,
|
| 411 |
+
) -> torch.Tensor:
|
| 412 |
+
"""Reconstruct full local rotation matrices from 1-DoF angles."""
|
| 413 |
+
out = original_local_rot_mats.clone()
|
| 414 |
+
axis_kimodo = self._mujoco_joint_axis_values_kimodo_space.to(device=device, dtype=dtype)
|
| 415 |
+
for i in range(joint_dofs.shape[-1]):
|
| 416 |
+
j = self._mujoco_indices_to_kimodo_indices[i].item()
|
| 417 |
+
angle = joint_dofs[..., i]
|
| 418 |
+
axis = axis_kimodo[i]
|
| 419 |
+
if use_relative:
|
| 420 |
+
axis_angle = angle[..., None] * axis[None, None, :]
|
| 421 |
+
R_local = axis_angle_to_matrix(axis_angle)
|
| 422 |
+
else:
|
| 423 |
+
rot_offsets_f2q = self._rot_offsets_f2q.to(device=device, dtype=dtype)
|
| 424 |
+
axis_in_f2q = torch.mv(rot_offsets_f2q[j], axis)
|
| 425 |
+
axis_angle = angle[..., None] * axis_in_f2q[None, None, :]
|
| 426 |
+
R_f2q = axis_angle_to_matrix(axis_angle)
|
| 427 |
+
R_local = torch.einsum("ij,btjk->btik", rot_offsets_f2q[j].T, R_f2q)
|
| 428 |
+
out[:, :, j, :, :] = R_local
|
| 429 |
+
return out
|
| 430 |
+
|
| 431 |
+
@ensure_batched(local_rot_mats=5, root_positions=3, lengths=1)
|
| 432 |
+
def project_to_real_robot_rotations(
|
| 433 |
+
self,
|
| 434 |
+
local_rot_mats: torch.Tensor,
|
| 435 |
+
root_positions: torch.Tensor,
|
| 436 |
+
clamp_to_limits: bool = True,
|
| 437 |
+
mujoco_rest_zero: bool = False,
|
| 438 |
+
) -> dict:
|
| 439 |
+
"""Project full 3D local rotations to G1 real robot DoF and back to 3D for viz.
|
| 440 |
+
|
| 441 |
+
Joint angles are extracted along each hinge axis, optionally clamped to XML limits, then
|
| 442 |
+
reconstructed to 3D rotations. When mujoco_rest_zero=False (default), raw angles are used
|
| 443 |
+
(baked-with-quat). When True, angles are relative to rest (0 = T-pose in MuJoCo).
|
| 444 |
+
"""
|
| 445 |
+
device = local_rot_mats.device
|
| 446 |
+
dtype = local_rot_mats.dtype
|
| 447 |
+
|
| 448 |
+
# Transform to f2q frame and extract 1-DoF angles (axis-angle projection).
|
| 449 |
+
local_rot_f2q = torch.matmul(self._rot_offsets_f2q.to(device=device, dtype=dtype), local_rot_mats)
|
| 450 |
+
hinge_rots = local_rot_f2q[:, :, self._mujoco_indices_to_kimodo_indices, :, :]
|
| 451 |
+
axis_f2q = self._mujoco_joint_axis_values_f2q_space.to(device=device, dtype=dtype)
|
| 452 |
+
joint_dofs = self._local_rots_to_joint_dofs_axis_angle(hinge_rots, axis_f2q)
|
| 453 |
+
|
| 454 |
+
# Optionally express angles relative to rest (MuJoCo q=0 at T-pose).
|
| 455 |
+
if mujoco_rest_zero:
|
| 456 |
+
rest_dofs = self._rest_dofs_axis_angle.to(device=device, dtype=dtype)
|
| 457 |
+
angles = joint_dofs - rest_dofs[None, None, :]
|
| 458 |
+
use_relative = True
|
| 459 |
+
else:
|
| 460 |
+
angles = joint_dofs
|
| 461 |
+
use_relative = False
|
| 462 |
+
|
| 463 |
+
if clamp_to_limits:
|
| 464 |
+
if mujoco_rest_zero:
|
| 465 |
+
angles = self._clamp_to_limits(angles)
|
| 466 |
+
else:
|
| 467 |
+
rest_dofs_aa = self._rest_dofs_axis_angle.to(device=device, dtype=dtype)
|
| 468 |
+
angles = self._clamp_joint_dofs(angles, rest_dofs_aa)
|
| 469 |
+
|
| 470 |
+
# Reconstruct 3D local rotations from 1-DoF angles and run FK.
|
| 471 |
+
local_rot_mats_proj = self._joint_dofs_to_local_rot_mats(
|
| 472 |
+
angles, local_rot_mats, device, dtype, use_relative=use_relative
|
| 473 |
+
)
|
| 474 |
+
global_rot_mats, posed_joints, _ = self.skeleton.fk(local_rot_mats_proj, root_positions)
|
| 475 |
+
return {
|
| 476 |
+
"local_rot_mats": local_rot_mats_proj,
|
| 477 |
+
"global_rot_mats": global_rot_mats,
|
| 478 |
+
"posed_joints": posed_joints,
|
| 479 |
+
"root_positions": root_positions,
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
@ensure_batched(local_rot_mats=5, root_positions=3, lengths=1)
|
| 483 |
+
def to_qpos(
|
| 484 |
+
self,
|
| 485 |
+
local_rot_mats: torch.Tensor,
|
| 486 |
+
root_positions: torch.Tensor,
|
| 487 |
+
root_quat_w_first: bool = True,
|
| 488 |
+
mujoco_rest_zero: bool = False,
|
| 489 |
+
) -> torch.Tensor:
|
| 490 |
+
"""Fast batch conversion from kimodo features to mujoco qpos format.
|
| 491 |
+
|
| 492 |
+
Args:
|
| 493 |
+
local_rot_mats: (B, T, J, 3, 3) local rotation matrices (kimodo convention).
|
| 494 |
+
root_positions: (B, T, 3) root positions.
|
| 495 |
+
root_quat_w_first: If True, quaternion in qpos is (w,x,y,z).
|
| 496 |
+
mujoco_rest_zero: If True, joint angles are written so that kimodo rest (t-pose)
|
| 497 |
+
maps to q=0 in MuJoCo. If False, write raw joint_dofs.
|
| 498 |
+
|
| 499 |
+
Returns:
|
| 500 |
+
torch.Tensor of shape [batch, numFrames, 36] containing mujoco qpos data:
|
| 501 |
+
- root_trans (3) + root_quat (4) + joint_dofs (29) = 36 columns
|
| 502 |
+
"""
|
| 503 |
+
|
| 504 |
+
batch_size, num_frames, nb_joints = local_rot_mats.shape[:3]
|
| 505 |
+
device, dtype = local_rot_mats.device, local_rot_mats.dtype
|
| 506 |
+
|
| 507 |
+
local_rot_mats = torch.matmul(self._rot_offsets_f2q.to(device), local_rot_mats)
|
| 508 |
+
|
| 509 |
+
batch_size, num_frames = root_positions.shape[0], root_positions.shape[1]
|
| 510 |
+
|
| 511 |
+
# Move precomputed matrices to the same device/dtype
|
| 512 |
+
kimodo_to_mujoco_matrix = self.kimodo_to_mujoco_matrix.to(device=device, dtype=dtype)
|
| 513 |
+
|
| 514 |
+
# Initialize output tensor: [batch, numFrames, 36]
|
| 515 |
+
qpos = torch.zeros((batch_size, num_frames, 36), dtype=dtype, device=device)
|
| 516 |
+
|
| 517 |
+
# Convert root translation: apply coordinate transformation
|
| 518 |
+
root_positions_mujoco = torch.matmul(kimodo_to_mujoco_matrix[None, None, ...], root_positions[..., None])
|
| 519 |
+
qpos[:, :, :3] = root_positions_mujoco.view(batch_size, num_frames, 3)
|
| 520 |
+
|
| 521 |
+
# Convert root rotation: apply coordinate transformation to rotation matrix
|
| 522 |
+
root_rot = local_rot_mats[:, :, 0, :] # [batch, numFrames, 3, 3]
|
| 523 |
+
|
| 524 |
+
# Apply coordinate transformation: R_mujoco = kimodo_to_mujoco * R_kimodo * kimodo_to_mujoco^T
|
| 525 |
+
mujoco_to_kimodo_matrix = kimodo_to_mujoco_matrix.T
|
| 526 |
+
root_rot_mujoco = torch.matmul(
|
| 527 |
+
torch.matmul(kimodo_to_mujoco_matrix[None, None, ...], root_rot),
|
| 528 |
+
mujoco_to_kimodo_matrix[None, None, ...],
|
| 529 |
+
)
|
| 530 |
+
root_rot_quat = matrix_to_quaternion(root_rot_mujoco) # [w, x, y, z]
|
| 531 |
+
if root_quat_w_first:
|
| 532 |
+
qpos[:, :, 3:7] = root_rot_quat[:, :, [0, 1, 2, 3]] # [w, x, y, z]
|
| 533 |
+
else:
|
| 534 |
+
qpos[:, :, 3:7] = root_rot_quat[:, :, [1, 2, 3, 0]] # [w, x, y, z] -> [x, y, z, w]
|
| 535 |
+
|
| 536 |
+
# Joint DOFs: raw angles or relative to rest (rest = q=0 in MuJoCo).
|
| 537 |
+
joint_rot_f2q = local_rot_mats[:, :, self._mujoco_indices_to_kimodo_indices, :, :]
|
| 538 |
+
joint_dofs = self._local_rots_f2q_to_joint_dofs(joint_rot_f2q)
|
| 539 |
+
if mujoco_rest_zero:
|
| 540 |
+
rest_dofs = self._rest_dofs.to(device=device, dtype=dtype)
|
| 541 |
+
qpos[:, :, 7:] = joint_dofs - rest_dofs[None, None, :]
|
| 542 |
+
else:
|
| 543 |
+
qpos[:, :, 7:] = joint_dofs
|
| 544 |
+
return qpos
|
| 545 |
+
|
| 546 |
+
|
| 547 |
+
def apply_g1_real_robot_projection(
|
| 548 |
+
skeleton: G1Skeleton34,
|
| 549 |
+
joints_pos: torch.Tensor,
|
| 550 |
+
joints_rot: torch.Tensor,
|
| 551 |
+
clamp_to_limits: bool = True,
|
| 552 |
+
) -> tuple[torch.Tensor, torch.Tensor]:
|
| 553 |
+
"""Project G1 motion to real robot DoF (1-DoF per joint) with optional axis limits.
|
| 554 |
+
|
| 555 |
+
Extracts a single angle per hinge along its axis (1-DoF), optionally clamps to
|
| 556 |
+
joint limits from the MuJoCo XML (when clamp_to_limits=True), then reconstructs
|
| 557 |
+
3D rotations and runs FK. T-pose (identity local rotations) is preserved.
|
| 558 |
+
|
| 559 |
+
Args:
|
| 560 |
+
skeleton: G1 skeleton instance.
|
| 561 |
+
joints_pos: (T, J, 3) or (B, T, J, 3) joint positions in global space.
|
| 562 |
+
joints_rot: (T, J, 3, 3) or (B, T, J, 3, 3) global rotation matrices.
|
| 563 |
+
clamp_to_limits: If True, clamp joint angles to XML axis limits (default True).
|
| 564 |
+
|
| 565 |
+
Returns:
|
| 566 |
+
(posed_joints, global_rot_mats) as tensors, same shape as inputs (batch preserved).
|
| 567 |
+
"""
|
| 568 |
+
|
| 569 |
+
local_rot_mats = global_rots_to_local_rots(joints_rot, skeleton)
|
| 570 |
+
root_positions = joints_pos[..., skeleton.root_idx, :]
|
| 571 |
+
|
| 572 |
+
# Converter expects batch dim (B, T, ...); add and remove if single sequence.
|
| 573 |
+
single_sequence = local_rot_mats.dim() == 4
|
| 574 |
+
if single_sequence:
|
| 575 |
+
local_rot_mats = local_rot_mats.unsqueeze(0)
|
| 576 |
+
root_positions = root_positions.unsqueeze(0)
|
| 577 |
+
|
| 578 |
+
converter = MujocoQposConverter(skeleton)
|
| 579 |
+
projected = converter.project_to_real_robot_rotations(
|
| 580 |
+
local_rot_mats, root_positions, clamp_to_limits=clamp_to_limits
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
out_pos = projected["posed_joints"]
|
| 584 |
+
out_rot = projected["global_rot_mats"]
|
| 585 |
+
if single_sequence:
|
| 586 |
+
out_pos = out_pos.squeeze(0)
|
| 587 |
+
out_rot = out_rot.squeeze(0)
|
| 588 |
+
return out_pos, out_rot
|
kimodo/exports/smplx.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Convert kimodo motion to AMASS/SMPL-X compatible parameters (axis-angle, Y-up or Z-up)."""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import einops
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
from kimodo.assets import skeleton_asset_path
|
| 13 |
+
from kimodo.geometry import axis_angle_to_matrix, matrix_to_axis_angle
|
| 14 |
+
from kimodo.tools import ensure_batched, to_numpy, to_torch
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def kimodo_y_up_to_amass_coord_rotation_matrix() -> np.ndarray:
|
| 18 |
+
"""3x3 rotation mapping Kimodo Y-up (+Z forward) to AMASS Z-up (+Y forward).
|
| 19 |
+
|
| 20 |
+
Used by :func:`get_amass_parameters` and :func:`amass_arrays_to_kimodo_motion` (inverse).
|
| 21 |
+
"""
|
| 22 |
+
y_up_to_z_up = np.array(
|
| 23 |
+
[
|
| 24 |
+
[1.0, 0.0, 0.0],
|
| 25 |
+
[0.0, 0.0, -1.0],
|
| 26 |
+
[0.0, 1.0, 0.0],
|
| 27 |
+
],
|
| 28 |
+
dtype=np.float32,
|
| 29 |
+
)
|
| 30 |
+
rot_z_180 = np.array(
|
| 31 |
+
[
|
| 32 |
+
[-1.0, 0.0, 0.0],
|
| 33 |
+
[0.0, -1.0, 0.0],
|
| 34 |
+
[0.0, 0.0, 1.0],
|
| 35 |
+
],
|
| 36 |
+
dtype=np.float32,
|
| 37 |
+
)
|
| 38 |
+
return np.matmul(rot_z_180, y_up_to_z_up).astype(np.float32)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@ensure_batched(local_rot_mats=5, root_positions=3, lengths=1)
|
| 42 |
+
def get_amass_parameters(
|
| 43 |
+
local_rot_mats,
|
| 44 |
+
root_positions,
|
| 45 |
+
skeleton,
|
| 46 |
+
z_up=True,
|
| 47 |
+
):
|
| 48 |
+
"""Convert local rot mats and root positions to AMASS-style trans and pose_body; optional z_up
|
| 49 |
+
coordinate transform.
|
| 50 |
+
|
| 51 |
+
Our method generates motions with Y-up and +Z forward; if z_up=True, transform to Z-up and +Y
|
| 52 |
+
forward as in AMASS.
|
| 53 |
+
"""
|
| 54 |
+
# Our method generate motions with Y-up and +Z forward
|
| 55 |
+
# if z_up = True, we transform this to: Z-up with +Y forward, as in AMASS
|
| 56 |
+
# Remove the root offset; SMPL-X FK adds pelvis offset back.
|
| 57 |
+
pelvis_offset = skeleton.neutral_joints[skeleton.root_idx].cpu().numpy()
|
| 58 |
+
trans = root_positions - pelvis_offset
|
| 59 |
+
|
| 60 |
+
root_rot_mats = to_numpy(local_rot_mats[:, :, 0])
|
| 61 |
+
local_rot_axis_angle = to_numpy(matrix_to_axis_angle(to_torch(local_rot_mats)))
|
| 62 |
+
pose_body = einops.rearrange(local_rot_axis_angle[:, :, 1:], "b t j d -> b t (j d)")
|
| 63 |
+
|
| 64 |
+
# Optionally convert from Y-up to Z-up coordinates.
|
| 65 |
+
if z_up:
|
| 66 |
+
y_up_to_z_up = kimodo_y_up_to_amass_coord_rotation_matrix()
|
| 67 |
+
root_rot_mats = np.matmul(y_up_to_z_up, root_rot_mats)
|
| 68 |
+
trans = np.matmul(trans + pelvis_offset, y_up_to_z_up.T) - pelvis_offset
|
| 69 |
+
|
| 70 |
+
root_orient = to_numpy(matrix_to_axis_angle(to_torch(root_rot_mats)))
|
| 71 |
+
return trans, root_orient, pose_body
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def amass_arrays_to_kimodo_motion(
|
| 75 |
+
trans: np.ndarray,
|
| 76 |
+
root_orient: np.ndarray,
|
| 77 |
+
pose_body: np.ndarray,
|
| 78 |
+
skeleton,
|
| 79 |
+
source_fps: float,
|
| 80 |
+
*,
|
| 81 |
+
z_up: bool = True,
|
| 82 |
+
):
|
| 83 |
+
"""Inverse of :func:`get_amass_parameters` for a single sequence (AMASS → Kimodo motion dict).
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
trans: ``(T, 3)`` AMASS root translation (same as ``trans`` in AMASS NPZ).
|
| 87 |
+
root_orient: ``(T, 3)`` axis-angle root orientation in AMASS coordinates (z-up when ``z_up``).
|
| 88 |
+
pose_body: ``(T, 63)`` body pose axis-angle (21 joints × 3).
|
| 89 |
+
skeleton: :class:`~kimodo.skeleton.definitions.SMPLXSkeleton22` instance.
|
| 90 |
+
source_fps: Source frame rate (Hz) of the AMASS recording.
|
| 91 |
+
z_up: If ``True``, invert the same Y-up↔Z-up transform as ``get_amass_parameters(..., z_up=True)``.
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
Motion dict compatible with :func:`kimodo.exports.motion_io.save_kimodo_npz`.
|
| 95 |
+
"""
|
| 96 |
+
from kimodo.exports.motion_io import complete_motion_dict
|
| 97 |
+
|
| 98 |
+
trans = np.asarray(trans, dtype=np.float32)
|
| 99 |
+
root_orient = np.asarray(root_orient, dtype=np.float32)
|
| 100 |
+
pose_body = np.asarray(pose_body, dtype=np.float32)
|
| 101 |
+
if trans.ndim != 2 or trans.shape[-1] != 3:
|
| 102 |
+
raise ValueError(f"trans must be (T, 3); got {trans.shape}")
|
| 103 |
+
if root_orient.shape != trans.shape:
|
| 104 |
+
raise ValueError(f"root_orient shape {root_orient.shape} must match trans {trans.shape}")
|
| 105 |
+
t = trans.shape[0]
|
| 106 |
+
if pose_body.shape != (t, 63):
|
| 107 |
+
raise ValueError(f"pose_body must be (T, 63); got {pose_body.shape}")
|
| 108 |
+
|
| 109 |
+
pelvis_offset = skeleton.neutral_joints[skeleton.root_idx].detach().cpu().numpy().astype(np.float32)
|
| 110 |
+
device = skeleton.neutral_joints.device
|
| 111 |
+
dtype = torch.float32
|
| 112 |
+
|
| 113 |
+
Y_np = kimodo_y_up_to_amass_coord_rotation_matrix()
|
| 114 |
+
if z_up:
|
| 115 |
+
y_up_to_z_up = torch.from_numpy(Y_np).to(device=device, dtype=dtype)
|
| 116 |
+
# trans_amass = root_kimodo @ Y.T - pelvis_offset => root_kimodo = (trans_amass + pelvis_offset) @ Y
|
| 117 |
+
root_positions_np = (trans + pelvis_offset) @ Y_np
|
| 118 |
+
else:
|
| 119 |
+
root_positions_np = trans + pelvis_offset
|
| 120 |
+
|
| 121 |
+
root_positions = torch.from_numpy(root_positions_np).to(device=device, dtype=dtype)
|
| 122 |
+
|
| 123 |
+
R_amass_root = axis_angle_to_matrix(torch.from_numpy(root_orient).to(device=device, dtype=dtype))
|
| 124 |
+
if z_up:
|
| 125 |
+
R_kimodo_root = torch.einsum("ij,tjk->tik", y_up_to_z_up.T, R_amass_root)
|
| 126 |
+
else:
|
| 127 |
+
R_kimodo_root = R_amass_root
|
| 128 |
+
|
| 129 |
+
nb = skeleton.nbjoints
|
| 130 |
+
if nb != 22:
|
| 131 |
+
raise ValueError(f"Expected SMPL-X body skeleton with 22 joints; got {nb}")
|
| 132 |
+
|
| 133 |
+
local_rot_mats = torch.zeros((t, nb, 3, 3), device=device, dtype=dtype)
|
| 134 |
+
local_rot_mats[:, 0] = R_kimodo_root
|
| 135 |
+
|
| 136 |
+
pose_aa = torch.from_numpy(pose_body.reshape(t, 21, 3)).to(device=device, dtype=dtype)
|
| 137 |
+
local_rot_mats[:, 1:] = axis_angle_to_matrix(pose_aa.reshape(-1, 3)).reshape(t, 21, 3, 3)
|
| 138 |
+
|
| 139 |
+
return complete_motion_dict(local_rot_mats, root_positions, skeleton, source_fps)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def amass_npz_to_kimodo_motion(npz_path: str, skeleton, source_fps: Optional[float] = None, *, z_up: bool = True):
|
| 143 |
+
"""Load an AMASS-style ``.npz`` and return a Kimodo motion dict.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
npz_path: Path to AMASS NPZ (``trans``, ``root_orient``, ``pose_body``, ...).
|
| 147 |
+
skeleton: SMPL-X skeleton instance.
|
| 148 |
+
source_fps: Source frame rate (Hz); if ``None``, uses ``mocap_frame_rate``
|
| 149 |
+
from the file when present, else ``30.0``.
|
| 150 |
+
z_up: Same meaning as :func:`amass_arrays_to_kimodo_motion`.
|
| 151 |
+
"""
|
| 152 |
+
with np.load(npz_path, allow_pickle=True) as data:
|
| 153 |
+
trans = np.asarray(data["trans"], dtype=np.float32)
|
| 154 |
+
root_orient = np.asarray(data["root_orient"], dtype=np.float32)
|
| 155 |
+
pose_body = np.asarray(data["pose_body"], dtype=np.float32)
|
| 156 |
+
if source_fps is None:
|
| 157 |
+
source_fps = float(data["mocap_frame_rate"]) if "mocap_frame_rate" in data.files else 30.0
|
| 158 |
+
|
| 159 |
+
return amass_arrays_to_kimodo_motion(trans, root_orient, pose_body, skeleton, source_fps, z_up=z_up)
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
class AMASSConverter:
|
| 163 |
+
def __init__(
|
| 164 |
+
self,
|
| 165 |
+
fps,
|
| 166 |
+
skeleton,
|
| 167 |
+
beta_path=str(skeleton_asset_path("smplx22", "beta.npy")),
|
| 168 |
+
mean_hands_path=str(skeleton_asset_path("smplx22", "mean_hands.npy")),
|
| 169 |
+
):
|
| 170 |
+
self.fps = fps
|
| 171 |
+
self.skeleton = skeleton
|
| 172 |
+
# Load betas
|
| 173 |
+
if os.path.exists(beta_path):
|
| 174 |
+
# only use first 16 betas to match AMASS
|
| 175 |
+
betas = np.load(beta_path)[:16]
|
| 176 |
+
else:
|
| 177 |
+
betas = np.zeros(16)
|
| 178 |
+
|
| 179 |
+
# Load mean hands
|
| 180 |
+
if os.path.exists(mean_hands_path):
|
| 181 |
+
mean_hands = np.load(mean_hands_path)
|
| 182 |
+
else:
|
| 183 |
+
mean_hands = np.zeros(90)
|
| 184 |
+
|
| 185 |
+
self.default_frame_params = {
|
| 186 |
+
"pose_jaw": np.zeros(3),
|
| 187 |
+
"pose_eye": np.zeros(6),
|
| 188 |
+
"pose_hand": mean_hands,
|
| 189 |
+
}
|
| 190 |
+
self.output_dict_base = {
|
| 191 |
+
"gender": "neutral",
|
| 192 |
+
"surface_model_type": "smplx",
|
| 193 |
+
"betas": betas,
|
| 194 |
+
"num_betas": len(betas),
|
| 195 |
+
"mocap_frame_rate": float(fps),
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
def convert_save_npz(self, output: dict, npz_path, z_up=True):
|
| 199 |
+
trans, root_orient, pose_body = get_amass_parameters(
|
| 200 |
+
output["local_rot_mats"],
|
| 201 |
+
output["root_positions"],
|
| 202 |
+
self.skeleton,
|
| 203 |
+
z_up=z_up,
|
| 204 |
+
)
|
| 205 |
+
nb_frames = trans.shape[-2]
|
| 206 |
+
|
| 207 |
+
amass_output_base = self.output_dict_base.copy()
|
| 208 |
+
for key, val in self.default_frame_params.items():
|
| 209 |
+
amass_output_base[key] = einops.repeat(val, "d -> t d", t=nb_frames)
|
| 210 |
+
|
| 211 |
+
amass_output_base["mocap_time_length"] = nb_frames / self.fps
|
| 212 |
+
self.save_npz(trans, root_orient, pose_body, amass_output_base, npz_path)
|
| 213 |
+
|
| 214 |
+
def save_npz(self, trans, root_orient, pose_body, base_output, npz_path):
|
| 215 |
+
shape = trans.shape
|
| 216 |
+
if len(shape) == 3 and shape[0] == 1:
|
| 217 |
+
# if only one motion, squeeze the data
|
| 218 |
+
trans = trans[0]
|
| 219 |
+
root_orient = root_orient[0]
|
| 220 |
+
pose_body = pose_body[0]
|
| 221 |
+
shape = trans.shape
|
| 222 |
+
if len(shape) == 2:
|
| 223 |
+
amass_output = {
|
| 224 |
+
"trans": trans,
|
| 225 |
+
"root_orient": root_orient,
|
| 226 |
+
"pose_body": pose_body,
|
| 227 |
+
} | base_output
|
| 228 |
+
np.savez(npz_path, **amass_output)
|
| 229 |
+
|
| 230 |
+
elif len(shape) == 3:
|
| 231 |
+
# real batch of motions
|
| 232 |
+
npz_path_base, ext = os.path.splitext(npz_path)
|
| 233 |
+
for i in range(shape[0]):
|
| 234 |
+
npz_path_i = npz_path_base + "_" + str(i).zfill(2) + ext
|
| 235 |
+
self.save_npz(trans[i], root_orient[i], pose_body[i], base_output, npz_path_i)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# amass_output = {
|
| 239 |
+
# "gender": "neutral",
|
| 240 |
+
# "surface_model_type": "smplx",
|
| 241 |
+
# "mocap_frame_rate": float(fps),
|
| 242 |
+
# "mocap_time_length": len(motion) / float(fps)
|
| 243 |
+
# "trans": trans,
|
| 244 |
+
# "betas": betas,
|
| 245 |
+
# "num_betas": len(betas),
|
| 246 |
+
# "root_orient": np.array([T, 3]), # axis angle
|
| 247 |
+
# "pose_body": np.array([T, 63]), # 63=21*3, axis angle 21 = 22 - root
|
| 248 |
+
# "pose_hand": np.array([T, 90]), # 90=30*3=15*2*3 axis angle (load from mean_hands)
|
| 249 |
+
# "pose_jaw": np.array([T, 3]), # all zeros is fine
|
| 250 |
+
# "pose_eye": np.array([T, 6]), # all zeros is fine`
|
| 251 |
+
# }
|
kimodo/geometry.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Rotation and representation conversions: axis-angle, quaternion, matrix, 6D continuous."""
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def angle_to_Y_rotation_matrix(angle: torch.Tensor) -> torch.Tensor:
|
| 10 |
+
"""Build a rotation matrix around the Y axis from a scalar angle (radians).
|
| 11 |
+
|
| 12 |
+
Shape: angle.shape + (3, 3).
|
| 13 |
+
"""
|
| 14 |
+
cos, sin = torch.cos(angle), torch.sin(angle)
|
| 15 |
+
one, zero = torch.ones_like(angle), torch.zeros_like(angle)
|
| 16 |
+
mat = torch.stack((cos, zero, sin, zero, one, zero, -sin, zero, cos), -1)
|
| 17 |
+
mat = mat.reshape(angle.shape + (3, 3))
|
| 18 |
+
return mat
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def matrix_to_cont6d(matrix: torch.Tensor) -> torch.Tensor:
|
| 22 |
+
"""Convert rotation matrix to 6D continuous representation (first two columns).
|
| 23 |
+
|
| 24 |
+
Shape: (..., 3, 3) -> (..., 6).
|
| 25 |
+
"""
|
| 26 |
+
cont_6d = torch.concat([matrix[..., 0], matrix[..., 1]], dim=-1)
|
| 27 |
+
return cont_6d
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def cont6d_to_matrix(cont6d: torch.Tensor) -> torch.Tensor:
|
| 31 |
+
"""Convert 6D continuous representation to rotation matrix (Gram–Schmidt on two columns).
|
| 32 |
+
|
| 33 |
+
Last dim must be 6.
|
| 34 |
+
"""
|
| 35 |
+
assert cont6d.shape[-1] == 6, "The last dimension must be 6"
|
| 36 |
+
x_raw = cont6d[..., 0:3]
|
| 37 |
+
y_raw = cont6d[..., 3:6]
|
| 38 |
+
|
| 39 |
+
x = x_raw / torch.norm(x_raw, dim=-1, keepdim=True)
|
| 40 |
+
z = torch.cross(x, y_raw, dim=-1)
|
| 41 |
+
z = z / torch.norm(z, dim=-1, keepdim=True)
|
| 42 |
+
|
| 43 |
+
y = torch.cross(z, x, dim=-1)
|
| 44 |
+
|
| 45 |
+
x = x[..., None]
|
| 46 |
+
y = y[..., None]
|
| 47 |
+
z = z[..., None]
|
| 48 |
+
|
| 49 |
+
mat = torch.cat([x, y, z], dim=-1)
|
| 50 |
+
return mat
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def axis_angle_to_matrix(axis_angle: torch.Tensor) -> torch.Tensor:
|
| 54 |
+
"""Convert axis-angle to rotation matrix.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
axis_angle: (..., 3) axis-angle vectors (angle = norm, axis = normalized)
|
| 58 |
+
Returns:
|
| 59 |
+
rotmat: (..., 3, 3) rotation matrices
|
| 60 |
+
"""
|
| 61 |
+
eps = 1e-6
|
| 62 |
+
angle = torch.norm(axis_angle, dim=-1, keepdim=True) # (..., 1)
|
| 63 |
+
axis = axis_angle / (angle + eps)
|
| 64 |
+
|
| 65 |
+
x, y, z = axis.unbind(-1)
|
| 66 |
+
|
| 67 |
+
zero = torch.zeros_like(x)
|
| 68 |
+
K = torch.stack([zero, -z, y, z, zero, -x, -y, x, zero], dim=-1).reshape(*axis.shape[:-1], 3, 3)
|
| 69 |
+
|
| 70 |
+
eye = torch.eye(3, device=axis.device, dtype=axis.dtype)
|
| 71 |
+
eye = eye.expand(*axis.shape[:-1], 3, 3)
|
| 72 |
+
|
| 73 |
+
sin = torch.sin(angle)[..., None]
|
| 74 |
+
cos = torch.cos(angle)[..., None]
|
| 75 |
+
|
| 76 |
+
R = eye + sin * K + (1 - cos) * (K @ K)
|
| 77 |
+
return R
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def matrix_to_axis_angle(R: torch.Tensor) -> torch.Tensor:
|
| 81 |
+
"""Convert rotation matrix to axis-angle via quaternions (more numerically stable).
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
R: (..., 3, 3) rotation matrices
|
| 85 |
+
Returns:
|
| 86 |
+
axis_angle: (..., 3)
|
| 87 |
+
"""
|
| 88 |
+
# Go through quaternions for numerical stability
|
| 89 |
+
quat = matrix_to_quaternion(R) # (..., 4) with (w, x, y, z)
|
| 90 |
+
return quaternion_to_axis_angle(quat)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def quaternion_to_axis_angle(quat: torch.Tensor) -> torch.Tensor:
|
| 94 |
+
"""Convert quaternion to axis-angle representation.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
quat: (..., 4) quaternions with real part first (w, x, y, z)
|
| 98 |
+
Returns:
|
| 99 |
+
axis_angle: (..., 3)
|
| 100 |
+
"""
|
| 101 |
+
eps = 1e-6
|
| 102 |
+
|
| 103 |
+
# Ensure canonical form to avoid sign ambiguity.
|
| 104 |
+
# Primary: prefer w > 0. When w ≈ 0 (angle ≈ π), prefer first nonzero xyz > 0.
|
| 105 |
+
w = quat[..., 0:1]
|
| 106 |
+
xyz = quat[..., 1:]
|
| 107 |
+
|
| 108 |
+
# Find first significant component of xyz for tie-breaking when w ≈ 0
|
| 109 |
+
first_significant = xyz[..., 0:1] # use x component as tie-breaker
|
| 110 |
+
|
| 111 |
+
# Flip if: w < 0, OR (w ≈ 0 AND first xyz component < 0)
|
| 112 |
+
should_flip = (w < -eps) | ((w.abs() <= eps) & (first_significant < 0))
|
| 113 |
+
quat = torch.where(should_flip, -quat, quat)
|
| 114 |
+
|
| 115 |
+
w = quat[..., 0]
|
| 116 |
+
xyz = quat[..., 1:]
|
| 117 |
+
|
| 118 |
+
# sin(angle/2) = ||xyz||
|
| 119 |
+
sin_half_angle = xyz.norm(dim=-1)
|
| 120 |
+
|
| 121 |
+
# angle = 2 * atan2(sin(angle/2), cos(angle/2))
|
| 122 |
+
# This is more stable than 2 * acos(w) near angle=0
|
| 123 |
+
angle = 2.0 * torch.atan2(sin_half_angle, w)
|
| 124 |
+
|
| 125 |
+
# axis = xyz / sin(angle/2), but handle small angles
|
| 126 |
+
# For small angles: axis-angle ≈ 2 * xyz (since sin(x) ≈ x for small x)
|
| 127 |
+
small_angle = sin_half_angle.abs() < eps
|
| 128 |
+
|
| 129 |
+
# Safe division
|
| 130 |
+
scale = torch.where(
|
| 131 |
+
small_angle,
|
| 132 |
+
2.0 * torch.ones_like(angle), # small angle: axis_angle ≈ 2 * xyz
|
| 133 |
+
angle / sin_half_angle.clamp(min=eps),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
return xyz * scale.unsqueeze(-1)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _sqrt_positive_part(x: torch.Tensor) -> torch.Tensor:
|
| 140 |
+
"""Returns torch.sqrt(torch.max(0, x)) subgradient is zero where x is 0."""
|
| 141 |
+
return torch.sqrt(x * (x > 0).to(x.dtype))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def matrix_to_quaternion(matrix: torch.Tensor) -> torch.Tensor:
|
| 145 |
+
"""Convert rotations given as rotation matrices to quaternions.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
matrix: Rotation matrices as tensor of shape (..., 3, 3).
|
| 149 |
+
Returns:
|
| 150 |
+
quaternions with real part first, as tensor of shape (..., 4).
|
| 151 |
+
"""
|
| 152 |
+
if matrix.size(-1) != 3 or matrix.size(-2) != 3:
|
| 153 |
+
raise ValueError(f"Invalid rotation matrix shape {matrix.shape}.")
|
| 154 |
+
|
| 155 |
+
batch_dim = matrix.shape[:-2]
|
| 156 |
+
m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(matrix.reshape(batch_dim + (9,)), dim=-1)
|
| 157 |
+
|
| 158 |
+
q_abs = _sqrt_positive_part(
|
| 159 |
+
torch.stack(
|
| 160 |
+
[
|
| 161 |
+
1.0 + m00 + m11 + m22,
|
| 162 |
+
1.0 + m00 - m11 - m22,
|
| 163 |
+
1.0 - m00 + m11 - m22,
|
| 164 |
+
1.0 - m00 - m11 + m22,
|
| 165 |
+
],
|
| 166 |
+
dim=-1,
|
| 167 |
+
)
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
quat_by_rijk = torch.stack(
|
| 171 |
+
[
|
| 172 |
+
torch.stack([q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01], dim=-1),
|
| 173 |
+
torch.stack([m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20], dim=-1),
|
| 174 |
+
torch.stack([m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21], dim=-1),
|
| 175 |
+
torch.stack([m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2], dim=-1),
|
| 176 |
+
],
|
| 177 |
+
dim=-2,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
flr = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
|
| 181 |
+
quat_candidates = quat_by_rijk / (2.0 * q_abs[..., None].max(flr))
|
| 182 |
+
|
| 183 |
+
return (
|
| 184 |
+
(F.one_hot(q_abs.argmax(dim=-1), num_classes=4)[..., None] * quat_candidates)
|
| 185 |
+
.sum(dim=-2)
|
| 186 |
+
.reshape(batch_dim + (4,))
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def quaternion_to_matrix(quaternions: torch.Tensor) -> torch.Tensor:
|
| 191 |
+
"""Convert rotations given as quaternions to rotation matrices.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
quaternions: quaternions with real part first,
|
| 195 |
+
as tensor of shape (..., 4).
|
| 196 |
+
Returns:
|
| 197 |
+
Rotation matrices as tensor of shape (..., 3, 3).
|
| 198 |
+
"""
|
| 199 |
+
r, i, j, k = torch.unbind(quaternions, -1)
|
| 200 |
+
two_s = 2.0 / (quaternions * quaternions).sum(-1)
|
| 201 |
+
|
| 202 |
+
o = torch.stack(
|
| 203 |
+
(
|
| 204 |
+
1 - two_s * (j * j + k * k),
|
| 205 |
+
two_s * (i * j - k * r),
|
| 206 |
+
two_s * (i * k + j * r),
|
| 207 |
+
two_s * (i * j + k * r),
|
| 208 |
+
1 - two_s * (i * i + k * k),
|
| 209 |
+
two_s * (j * k - i * r),
|
| 210 |
+
two_s * (i * k - j * r),
|
| 211 |
+
two_s * (j * k + i * r),
|
| 212 |
+
1 - two_s * (i * i + j * j),
|
| 213 |
+
),
|
| 214 |
+
-1,
|
| 215 |
+
)
|
| 216 |
+
return o.reshape(quaternions.shape[:-1] + (3, 3))
|
kimodo/meta.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Parse and normalize prompt text/duration data from meta dicts."""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
from kimodo.tools import load_json
|
| 9 |
+
|
| 10 |
+
from .sanitize import sanitize_text, sanitize_texts
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_prompts_from_meta(meta_path: str, **kwargs):
|
| 14 |
+
"""Load prompts from a meta dict or file. If fps is provided, the durations are converted to
|
| 15 |
+
frames.
|
| 16 |
+
|
| 17 |
+
Args:
|
| 18 |
+
meta_path: Path to the meta file.
|
| 19 |
+
**kwargs: Additional arguments to pass to parse_prompts_from_meta.
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
texts: List of texts.
|
| 23 |
+
durations: List of durations in seconds or frames.
|
| 24 |
+
"""
|
| 25 |
+
if not os.path.exists(meta_path):
|
| 26 |
+
raise FileNotFoundError(f"meta.json not found in input folder: {meta_path}")
|
| 27 |
+
|
| 28 |
+
meta = load_json(meta_path)
|
| 29 |
+
return parse_prompts_from_meta(meta, **kwargs)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def parse_prompts_from_meta(
|
| 33 |
+
meta: dict[str, Any],
|
| 34 |
+
fps: Optional[float] = None,
|
| 35 |
+
sanitize: bool = False,
|
| 36 |
+
) -> tuple[list[str], list[float]]:
|
| 37 |
+
"""Parse prompt texts and durations from a meta dict into normalized lists. If fps is provided,
|
| 38 |
+
the durations are converted to frames.
|
| 39 |
+
|
| 40 |
+
Accepts either:
|
| 41 |
+
- Single prompt: "text" (str) and "duration" (float) in seconds.
|
| 42 |
+
- Multiple prompts: "texts" (list of str) and "durations" (list of float) in seconds.
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
(texts, durations): texts as list of str, durations as list of float (seconds or frames).
|
| 46 |
+
Lengths of both lists are equal.
|
| 47 |
+
|
| 48 |
+
Raises:
|
| 49 |
+
ValueError: If meta does not contain a recognized format.
|
| 50 |
+
"""
|
| 51 |
+
# Single prompt
|
| 52 |
+
if "text" in meta and "duration" in meta:
|
| 53 |
+
text = meta["text"]
|
| 54 |
+
duration = float(meta["duration"])
|
| 55 |
+
if fps is not None:
|
| 56 |
+
duration = int(duration * fps)
|
| 57 |
+
if isinstance(text, list):
|
| 58 |
+
raise ValueError("meta has 'text' but it is a list; use 'texts' for multiple prompts")
|
| 59 |
+
|
| 60 |
+
if sanitize:
|
| 61 |
+
text = sanitize_text(text)
|
| 62 |
+
return ([text], [duration])
|
| 63 |
+
|
| 64 |
+
# Multiple prompts
|
| 65 |
+
if "texts" in meta and "durations" in meta:
|
| 66 |
+
texts = meta["texts"]
|
| 67 |
+
durations = meta["durations"]
|
| 68 |
+
if not isinstance(texts, list) or not isinstance(durations, list):
|
| 69 |
+
raise ValueError("meta 'texts' and 'durations' must be lists")
|
| 70 |
+
if len(texts) != len(durations):
|
| 71 |
+
raise ValueError(f"meta 'texts' and 'durations' length mismatch: {len(texts)} vs {len(durations)}")
|
| 72 |
+
durations = [float(d) for d in durations]
|
| 73 |
+
if fps is not None:
|
| 74 |
+
durations = [int(d * fps) for d in durations]
|
| 75 |
+
|
| 76 |
+
if sanitize:
|
| 77 |
+
texts = sanitize_texts(texts)
|
| 78 |
+
return texts, durations
|
| 79 |
+
|
| 80 |
+
raise ValueError("meta must contain either 'text' and 'duration', or 'texts' and 'durations'.")
|
kimodo/metrics/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Evaluation metrics for motion quality (foot skate, contact consistency, constraint following)."""
|
| 4 |
+
|
| 5 |
+
from .base import (
|
| 6 |
+
Metric,
|
| 7 |
+
aggregate_metrics,
|
| 8 |
+
clear_metrics,
|
| 9 |
+
compute_metrics,
|
| 10 |
+
)
|
| 11 |
+
from .constraints import ContraintFollow
|
| 12 |
+
from .foot_skate import (
|
| 13 |
+
FootContactConsistency,
|
| 14 |
+
FootSkateFromContacts,
|
| 15 |
+
FootSkateFromHeight,
|
| 16 |
+
FootSkateRatio,
|
| 17 |
+
)
|
| 18 |
+
from .tmr import (
|
| 19 |
+
TMR_EmbeddingMetric,
|
| 20 |
+
TMR_Metric,
|
| 21 |
+
compute_tmr_per_sample_retrieval,
|
| 22 |
+
compute_tmr_retrieval_metrics,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
__all__ = [
|
| 26 |
+
"Metric",
|
| 27 |
+
"ContraintFollow",
|
| 28 |
+
"FootContactConsistency",
|
| 29 |
+
"FootSkateFromContacts",
|
| 30 |
+
"FootSkateFromHeight",
|
| 31 |
+
"FootSkateRatio",
|
| 32 |
+
"TMR_EmbeddingMetric",
|
| 33 |
+
"TMR_Metric",
|
| 34 |
+
"aggregate_metrics",
|
| 35 |
+
"clear_metrics",
|
| 36 |
+
"compute_metrics",
|
| 37 |
+
"compute_tmr_per_sample_retrieval",
|
| 38 |
+
"compute_tmr_retrieval_metrics",
|
| 39 |
+
]
|
kimodo/metrics/base.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Base metric class and batch/aggregate helpers."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from typing import Dict, List
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Metric:
|
| 14 |
+
"""Base class for metrics that accumulate results over multiple __call__ and expose
|
| 15 |
+
aggregate()."""
|
| 16 |
+
|
| 17 |
+
def __init__(self, **kwargs):
|
| 18 |
+
self.clear()
|
| 19 |
+
|
| 20 |
+
def __call__(self, *args, **kwargs):
|
| 21 |
+
"""Compute metric for current batch, append to saved_metrics, and return the batch
|
| 22 |
+
result."""
|
| 23 |
+
metrics = self._compute(*args, **kwargs)
|
| 24 |
+
for key, val in metrics.items():
|
| 25 |
+
self.saved_metrics[key].append(val.detach().cpu().float())
|
| 26 |
+
return metrics
|
| 27 |
+
|
| 28 |
+
def _compute(self, **kwargs):
|
| 29 |
+
"""Subclasses implement this to compute metric dict from batch inputs."""
|
| 30 |
+
raise NotImplementedError()
|
| 31 |
+
|
| 32 |
+
def clear(self):
|
| 33 |
+
"""Reset all accumulated metric values."""
|
| 34 |
+
self.saved_metrics = defaultdict(list)
|
| 35 |
+
|
| 36 |
+
def aggregate(self):
|
| 37 |
+
"""Return a dict of concatenated/stacked tensors over all accumulated batches."""
|
| 38 |
+
output = {}
|
| 39 |
+
for key, lst in self.saved_metrics.items():
|
| 40 |
+
try:
|
| 41 |
+
output[key] = torch.cat(lst)
|
| 42 |
+
except RuntimeError:
|
| 43 |
+
output[key] = torch.stack(lst)
|
| 44 |
+
return output
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def compute_metrics(metrics_list: List[Metric], metrics_in: Dict) -> Dict:
|
| 48 |
+
"""Run each metric on metrics_in and return the combined dict of batch results."""
|
| 49 |
+
metrics_out = {}
|
| 50 |
+
for metric in metrics_list:
|
| 51 |
+
metrics_out.update(metric(**metrics_in))
|
| 52 |
+
return metrics_out
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def aggregate_metrics(metrics_list: List[Metric]) -> Dict:
|
| 56 |
+
"""Return combined aggregated results (concatenated over batches) for all metrics."""
|
| 57 |
+
metrics_out = {}
|
| 58 |
+
for metric in metrics_list:
|
| 59 |
+
metrics_out.update(metric.aggregate())
|
| 60 |
+
return metrics_out
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def clear_metrics(metrics_list: List[Metric]) -> None:
|
| 64 |
+
"""Clear accumulated values for all metrics in the list."""
|
| 65 |
+
for metric in metrics_list:
|
| 66 |
+
metric.clear()
|
kimodo/metrics/constraints.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Constraint-following metrics."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from typing import Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
import torch
|
| 11 |
+
from torch import Tensor
|
| 12 |
+
|
| 13 |
+
from kimodo.constraints import (
|
| 14 |
+
EndEffectorConstraintSet,
|
| 15 |
+
FullBodyConstraintSet,
|
| 16 |
+
Root2DConstraintSet,
|
| 17 |
+
)
|
| 18 |
+
from kimodo.tools import ensure_batched
|
| 19 |
+
|
| 20 |
+
from .base import Metric
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ContraintFollow(Metric):
|
| 24 |
+
"""Constraint-following metric dispatcher for kimodo constraint sets."""
|
| 25 |
+
|
| 26 |
+
def __init__(
|
| 27 |
+
self,
|
| 28 |
+
skeleton,
|
| 29 |
+
root_threshold: float = 0.10,
|
| 30 |
+
**kwargs,
|
| 31 |
+
):
|
| 32 |
+
super().__init__(**kwargs)
|
| 33 |
+
self.skeleton = skeleton
|
| 34 |
+
self.root_threshold = root_threshold
|
| 35 |
+
|
| 36 |
+
@ensure_batched(posed_joints=4, constraints_lst=2, lengths=1)
|
| 37 |
+
def _compute(
|
| 38 |
+
self,
|
| 39 |
+
posed_joints: Tensor,
|
| 40 |
+
constraints_lst: Optional[List],
|
| 41 |
+
lengths: Optional[Tensor] = None,
|
| 42 |
+
**kwargs,
|
| 43 |
+
) -> Dict:
|
| 44 |
+
if not constraints_lst:
|
| 45 |
+
return {}
|
| 46 |
+
|
| 47 |
+
root_idx = self.skeleton.root_idx
|
| 48 |
+
output = defaultdict(list)
|
| 49 |
+
|
| 50 |
+
for posed_joints_s, constraint_lst_s, lengths_s in zip(posed_joints, constraints_lst, lengths):
|
| 51 |
+
output_seq = defaultdict(list)
|
| 52 |
+
for constraint in constraint_lst_s:
|
| 53 |
+
frame_idx = constraint.frame_indices.to(device=posed_joints_s.device, dtype=torch.long)
|
| 54 |
+
assert frame_idx.max() < lengths_s, "The constraint is defined outsite the lenght of the motion."
|
| 55 |
+
if frame_idx.numel() == 0:
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
if isinstance(constraint, Root2DConstraintSet):
|
| 59 |
+
pred_root2d = posed_joints_s[frame_idx, root_idx][:, [0, 2]]
|
| 60 |
+
target = constraint.smooth_root_2d.to(posed_joints_s.device)
|
| 61 |
+
|
| 62 |
+
dist = torch.norm(pred_root2d - target, dim=-1)
|
| 63 |
+
output_seq["constraint_root2d_err"].append(dist)
|
| 64 |
+
hit = (dist <= self.root_threshold).float()
|
| 65 |
+
output_seq["constraint_root2d_acc"].append(hit)
|
| 66 |
+
|
| 67 |
+
elif isinstance(constraint, FullBodyConstraintSet):
|
| 68 |
+
pred = posed_joints_s[frame_idx]
|
| 69 |
+
target = constraint.global_joints_positions.to(posed_joints_s.device)
|
| 70 |
+
err = torch.norm(pred - target, dim=-1)
|
| 71 |
+
output_seq["constraint_fullbody_keyframe"].append(err)
|
| 72 |
+
|
| 73 |
+
elif isinstance(constraint, EndEffectorConstraintSet):
|
| 74 |
+
pos_idx = constraint.pos_indices.to(device=posed_joints_s.device, dtype=torch.long)
|
| 75 |
+
pred = posed_joints_s[frame_idx].index_select(1, pos_idx)
|
| 76 |
+
target = constraint.global_joints_positions.to(posed_joints_s.device).index_select(1, pos_idx)
|
| 77 |
+
err = torch.norm(pred - target, dim=-1)
|
| 78 |
+
output_seq["constraint_end_effector"].append(err)
|
| 79 |
+
|
| 80 |
+
# in case we have several same constraints in the list
|
| 81 |
+
for key, val in output_seq.items():
|
| 82 |
+
output[key].append(torch.cat(val).mean())
|
| 83 |
+
|
| 84 |
+
reduced = {}
|
| 85 |
+
for key, vals in output.items():
|
| 86 |
+
reduced[key] = torch.stack(vals, dim=0)
|
| 87 |
+
return reduced
|
kimodo/metrics/foot_skate.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Foot skate and contact consistency metrics."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from typing import Dict, Optional
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from torch import Tensor
|
| 11 |
+
|
| 12 |
+
from kimodo.motion_rep.feature_utils import compute_vel_xyz
|
| 13 |
+
from kimodo.motion_rep.feet import foot_detect_from_pos_and_vel
|
| 14 |
+
from kimodo.skeleton import SkeletonBase
|
| 15 |
+
from kimodo.tools import ensure_batched
|
| 16 |
+
|
| 17 |
+
from .base import Metric
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_four_contacts(fidx: list):
|
| 21 |
+
if len(fidx) == 4:
|
| 22 |
+
return fidx
|
| 23 |
+
if len(fidx) == 6:
|
| 24 |
+
# For soma77
|
| 25 |
+
# remove "LeftToeEnd" and "RightToeEnd"
|
| 26 |
+
fidx = fidx[:2] + fidx[3:5]
|
| 27 |
+
return fidx
|
| 28 |
+
raise ValueError("Expects 4 or 6 foot joints (heel/toe per foot)")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class FootSkateFromHeight(Metric):
|
| 32 |
+
"""When toe joint is near the floor, measures mean velocity of the toes."""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
skeleton: SkeletonBase,
|
| 37 |
+
fps: float,
|
| 38 |
+
height_thresh: float = 0.05,
|
| 39 |
+
**kwargs,
|
| 40 |
+
):
|
| 41 |
+
super().__init__(**kwargs)
|
| 42 |
+
self.height_thresh = height_thresh
|
| 43 |
+
self.skeleton = skeleton
|
| 44 |
+
self.fps = fps
|
| 45 |
+
|
| 46 |
+
@ensure_batched(posed_joints=4, lengths=1)
|
| 47 |
+
def _compute(
|
| 48 |
+
self,
|
| 49 |
+
posed_joints: Tensor,
|
| 50 |
+
lengths: Optional[Tensor] = None,
|
| 51 |
+
**kwargs,
|
| 52 |
+
) -> Dict:
|
| 53 |
+
fidx = self.skeleton.foot_joint_idx
|
| 54 |
+
fidx = get_four_contacts(fidx)
|
| 55 |
+
|
| 56 |
+
feet_pos = posed_joints[:, :, fidx]
|
| 57 |
+
toe_pos = feet_pos[:, :, [1, 3]]
|
| 58 |
+
|
| 59 |
+
toe_on_floor = (toe_pos[..., 1] < self.height_thresh)[:, :-1] # y-up [B, T, 2] where [left right]
|
| 60 |
+
|
| 61 |
+
dt = 1.0 / self.fps
|
| 62 |
+
toe_vel = torch.norm(toe_pos[:, 1:] - toe_pos[:, :-1], dim=-1) / dt # [B, nframes-1, 2]
|
| 63 |
+
|
| 64 |
+
# compute err
|
| 65 |
+
contact_toe_vel = toe_vel * toe_on_floor # vel when corresponding toe is on ground
|
| 66 |
+
|
| 67 |
+
# account for generated length
|
| 68 |
+
# since they are velocities use length-1 to avoid inaccurate vel going one frame past len
|
| 69 |
+
device = toe_on_floor.device
|
| 70 |
+
len_mask = torch.arange(toe_on_floor.shape[1], device=device)[None, :, None].expand(toe_on_floor.shape) < (
|
| 71 |
+
lengths[:, None, None] - 1
|
| 72 |
+
)
|
| 73 |
+
toe_on_floor = toe_on_floor * len_mask
|
| 74 |
+
contact_toe_vel = contact_toe_vel * len_mask
|
| 75 |
+
|
| 76 |
+
mean_vel = torch.sum(contact_toe_vel, (1, 2)) / (torch.sum(toe_on_floor, (1, 2)) + 1e-6)
|
| 77 |
+
return {"foot_skate_from_height": mean_vel}
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class FootSkateFromContacts(Metric):
|
| 81 |
+
"""Measures velocity of the toes and ankles when predicted to be in contact."""
|
| 82 |
+
|
| 83 |
+
def __init__(
|
| 84 |
+
self,
|
| 85 |
+
skeleton: SkeletonBase,
|
| 86 |
+
fps: float,
|
| 87 |
+
**kwargs,
|
| 88 |
+
):
|
| 89 |
+
super().__init__(**kwargs)
|
| 90 |
+
self.skeleton = skeleton
|
| 91 |
+
self.fps = fps
|
| 92 |
+
|
| 93 |
+
@ensure_batched(posed_joints=4, foot_contacts=3, lengths=1)
|
| 94 |
+
def _compute(
|
| 95 |
+
self,
|
| 96 |
+
posed_joints: Tensor,
|
| 97 |
+
foot_contacts: Tensor,
|
| 98 |
+
lengths: Optional[Tensor] = None,
|
| 99 |
+
**kwargs,
|
| 100 |
+
) -> Dict:
|
| 101 |
+
fidx = self.skeleton.foot_joint_idx
|
| 102 |
+
fidx = get_four_contacts(fidx)
|
| 103 |
+
|
| 104 |
+
feet_pos = posed_joints[:, :, fidx]
|
| 105 |
+
dt = 1.0 / self.fps
|
| 106 |
+
foot_vel = torch.norm(feet_pos[:, 1:] - feet_pos[:, :-1], dim=-1) / dt
|
| 107 |
+
|
| 108 |
+
if foot_contacts.shape[-1] == 6:
|
| 109 |
+
# For soma77
|
| 110 |
+
# remove "LeftToeEnd" and "RightToeEnd"
|
| 111 |
+
foot_contacts = foot_contacts[..., [0, 1, 3, 4]]
|
| 112 |
+
|
| 113 |
+
foot_contacts = foot_contacts[:, :-1]
|
| 114 |
+
vel_err = foot_vel * foot_contacts
|
| 115 |
+
|
| 116 |
+
# account for generated length
|
| 117 |
+
# since they are velocities use length-1 to avoid inaccurate vel going one frame past len
|
| 118 |
+
device = foot_contacts.device
|
| 119 |
+
len_mask = torch.arange(foot_contacts.shape[1], device=device)[None, :, None].expand(foot_contacts.shape) < (
|
| 120 |
+
lengths[:, None, None] - 1
|
| 121 |
+
)
|
| 122 |
+
foot_contacts = foot_contacts * len_mask
|
| 123 |
+
vel_err = vel_err * len_mask
|
| 124 |
+
|
| 125 |
+
mean_vel = torch.sum(vel_err, (1, 2)) / (torch.sum(foot_contacts, (1, 2)) + 1e-6) # mean over contacting frames
|
| 126 |
+
|
| 127 |
+
# Compute max velocity error across all feet and frames (per batch)
|
| 128 |
+
max_vel = vel_err.amax(dim=(1, 2)) # [B]
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
"foot_skate_from_pred_contacts": mean_vel,
|
| 132 |
+
"foot_skate_max_vel": max_vel,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
class FootSkateRatio(Metric):
|
| 137 |
+
"""Compute fraction of frames where the foot skates when it is on the ground.
|
| 138 |
+
|
| 139 |
+
Inspired by GMD: https://github.com/korrawe/guided-motion-diffusion/blob/main/data_loaders/humanml/utils/metrics.py#L204
|
| 140 |
+
"""
|
| 141 |
+
|
| 142 |
+
def __init__(
|
| 143 |
+
self,
|
| 144 |
+
skeleton: SkeletonBase,
|
| 145 |
+
fps: float,
|
| 146 |
+
height_thresh=0.05,
|
| 147 |
+
vel_thresh=0.2,
|
| 148 |
+
**kwargs,
|
| 149 |
+
):
|
| 150 |
+
super().__init__(**kwargs)
|
| 151 |
+
self.height_thresh = height_thresh
|
| 152 |
+
self.vel_thresh = vel_thresh
|
| 153 |
+
|
| 154 |
+
self.skeleton = skeleton
|
| 155 |
+
self.fps = fps
|
| 156 |
+
|
| 157 |
+
@ensure_batched(posed_joints=4, foot_contacts=3, lengths=1)
|
| 158 |
+
def _compute(
|
| 159 |
+
self,
|
| 160 |
+
posed_joints: Tensor,
|
| 161 |
+
foot_contacts: Tensor,
|
| 162 |
+
lengths: Optional[Tensor] = None,
|
| 163 |
+
**kwargs,
|
| 164 |
+
) -> Dict:
|
| 165 |
+
fidx = self.skeleton.foot_joint_idx
|
| 166 |
+
fidx = get_four_contacts(fidx)
|
| 167 |
+
|
| 168 |
+
feet_pos = posed_joints[:, :, fidx]
|
| 169 |
+
toe_pos = feet_pos[:, :, [1, 3]]
|
| 170 |
+
|
| 171 |
+
toe_on_floor = toe_pos[..., 1] < self.height_thresh # y-up [B, T, 2] where [left right]
|
| 172 |
+
# current and next frame on floor to consider it in contact
|
| 173 |
+
toe_on_floor = torch.logical_and(toe_on_floor[:, :-1], toe_on_floor[:, 1:]) # [B, T-1, 2]
|
| 174 |
+
|
| 175 |
+
dt = 1.0 / self.fps
|
| 176 |
+
toe_vel = torch.norm(toe_pos[:, 1:] - toe_pos[:, :-1], dim=-1) / dt # [B, nframes-1, 2]
|
| 177 |
+
|
| 178 |
+
# compute err
|
| 179 |
+
contact_toe_vel = toe_vel * toe_on_floor # vel when corresponding toe is on ground
|
| 180 |
+
|
| 181 |
+
# account for generated length
|
| 182 |
+
# since they are velocities use length-1 to avoid inaccurate vel going one frame past len
|
| 183 |
+
device = toe_on_floor.device
|
| 184 |
+
len_mask = torch.arange(toe_on_floor.shape[1], device=device)[None, :, None].expand(toe_on_floor.shape) < (
|
| 185 |
+
lengths[:, None, None] - 1
|
| 186 |
+
)
|
| 187 |
+
toe_on_floor = toe_on_floor * len_mask
|
| 188 |
+
contact_toe_vel = contact_toe_vel * len_mask
|
| 189 |
+
|
| 190 |
+
# skating if velocity during contact > thresh
|
| 191 |
+
toe_skate = contact_toe_vel > self.vel_thresh
|
| 192 |
+
skate_ratio = torch.sum(toe_skate, (1, 2)) / (torch.sum(toe_on_floor, (1, 2)) + 1e-6)
|
| 193 |
+
return {"foot_skate_ratio": skate_ratio}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
class FootContactConsistency(Metric):
|
| 197 |
+
"""Measures consistency between heuristic detected foot contacts (from height and velocity) and
|
| 198 |
+
predicted foot contacts.
|
| 199 |
+
|
| 200 |
+
i.e. accuracy of how well predicted matches heuristic.
|
| 201 |
+
"""
|
| 202 |
+
|
| 203 |
+
def __init__(
|
| 204 |
+
self,
|
| 205 |
+
skeleton: SkeletonBase,
|
| 206 |
+
fps: float,
|
| 207 |
+
vel_thresh: float = 0.15,
|
| 208 |
+
height_thresh: float = 0.10,
|
| 209 |
+
**kwargs,
|
| 210 |
+
):
|
| 211 |
+
super().__init__(**kwargs)
|
| 212 |
+
self.vel_thresh = vel_thresh
|
| 213 |
+
self.height_thresh = height_thresh
|
| 214 |
+
|
| 215 |
+
self.skeleton = skeleton
|
| 216 |
+
self.fps = fps
|
| 217 |
+
|
| 218 |
+
@ensure_batched(posed_joints=4, foot_contacts=3, lengths=1)
|
| 219 |
+
def _compute(
|
| 220 |
+
self,
|
| 221 |
+
posed_joints: Tensor,
|
| 222 |
+
foot_contacts: Tensor,
|
| 223 |
+
lengths: Optional[Tensor] = None,
|
| 224 |
+
**kwargs,
|
| 225 |
+
) -> Dict:
|
| 226 |
+
velocity = compute_vel_xyz(posed_joints, float(self.fps), lengths=lengths)
|
| 227 |
+
heuristic_contacts = foot_detect_from_pos_and_vel(
|
| 228 |
+
posed_joints,
|
| 229 |
+
velocity,
|
| 230 |
+
self.skeleton,
|
| 231 |
+
self.vel_thresh,
|
| 232 |
+
self.height_thresh,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if foot_contacts.shape[-1] == 6:
|
| 236 |
+
# For soma77
|
| 237 |
+
# remove "LeftToeEnd" and "RightToeEnd"
|
| 238 |
+
foot_contacts = foot_contacts[..., [0, 1, 3, 4]]
|
| 239 |
+
|
| 240 |
+
num_contacts = foot_contacts.shape[-1]
|
| 241 |
+
incorrect = torch.logical_xor(heuristic_contacts, foot_contacts)
|
| 242 |
+
# account for generated length
|
| 243 |
+
# since they are velocities, use length-1 to avoid inaccurate vel going one frame past len
|
| 244 |
+
device = foot_contacts.device
|
| 245 |
+
len_mask = torch.arange(foot_contacts.shape[1], device=device)[None, :, None].expand(foot_contacts.shape) < (
|
| 246 |
+
lengths[:, None, None] - 1
|
| 247 |
+
)
|
| 248 |
+
incorrect = incorrect * len_mask
|
| 249 |
+
|
| 250 |
+
incorrect_ratio = torch.sum(incorrect, (1, 2)) / (num_contacts * (lengths - 1))
|
| 251 |
+
accuracy = 1 - incorrect_ratio
|
| 252 |
+
|
| 253 |
+
return {"foot_contact_consistency": accuracy}
|
kimodo/metrics/tmr.py
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""TMR evaluation metrics: text-motion retrieval, R-Precision, and related scores."""
|
| 4 |
+
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
from typing import Any, Dict, List, Optional
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
from scipy import linalg
|
| 13 |
+
from torch import Tensor
|
| 14 |
+
|
| 15 |
+
from kimodo.model.tmr import TMR
|
| 16 |
+
|
| 17 |
+
from .base import Metric
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# Scores are between 0 and 1
|
| 21 |
+
def get_score_matrix_unit(x, y):
|
| 22 |
+
sim_matrix = np.einsum("b i, c i -> b c", x, y)
|
| 23 |
+
scores = sim_matrix / 2 + 0.5
|
| 24 |
+
return scores
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_scores_unit(x, y):
|
| 28 |
+
similarity = np.einsum("... i, ... i", x, y)
|
| 29 |
+
scores = similarity / 2 + 0.5
|
| 30 |
+
return scores
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def compute_tmr_per_sample_retrieval(
|
| 34 |
+
motion_emb: np.ndarray,
|
| 35 |
+
text_emb: np.ndarray,
|
| 36 |
+
sample_ids: List[str],
|
| 37 |
+
texts: List[str],
|
| 38 |
+
top_k: int = 5,
|
| 39 |
+
) -> List[Dict[str, Any]]:
|
| 40 |
+
"""For each sample (text query i), compute t2m rank of motion i and top-k retrieved motions with
|
| 41 |
+
ids and texts.
|
| 42 |
+
|
| 43 |
+
Returns list of dicts: [{"rank": int, "top_k": [{"id": str, "text": str}, ...]}, ...].
|
| 44 |
+
"""
|
| 45 |
+
motion_emb = np.asarray(motion_emb).squeeze()
|
| 46 |
+
text_emb = np.asarray(text_emb).squeeze()
|
| 47 |
+
if motion_emb.ndim == 1:
|
| 48 |
+
motion_emb = motion_emb[np.newaxis, :]
|
| 49 |
+
if text_emb.ndim == 1:
|
| 50 |
+
text_emb = text_emb[np.newaxis, :]
|
| 51 |
+
n = motion_emb.shape[0]
|
| 52 |
+
assert text_emb.shape[0] == n and len(sample_ids) == n and len(texts) == n
|
| 53 |
+
scores = get_score_matrix_unit(text_emb, motion_emb)
|
| 54 |
+
out: List[Dict[str, Any]] = []
|
| 55 |
+
for i in range(n):
|
| 56 |
+
row = np.asarray(scores[i])
|
| 57 |
+
order = np.argsort(row)[::-1]
|
| 58 |
+
rank = int(np.where(order == i)[0][0]) + 1
|
| 59 |
+
top_indices = order[:top_k]
|
| 60 |
+
top_k_list = [{"id": sample_ids[j], "text": texts[j]} for j in top_indices]
|
| 61 |
+
out.append({"rank": rank, "top_k": top_k_list})
|
| 62 |
+
return out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class TMR_Metric(Metric):
|
| 66 |
+
def __init__(
|
| 67 |
+
self,
|
| 68 |
+
tmr_model: TMR,
|
| 69 |
+
ranks: List = [1, 2, 3, 5, 10],
|
| 70 |
+
ranks_rounding=2,
|
| 71 |
+
**kwargs,
|
| 72 |
+
):
|
| 73 |
+
super().__init__(**kwargs)
|
| 74 |
+
self.tmr_model = tmr_model
|
| 75 |
+
self.ranks = ranks
|
| 76 |
+
self.ranks_rounding = ranks_rounding
|
| 77 |
+
|
| 78 |
+
def clear(self):
|
| 79 |
+
self.saved_metrics = defaultdict(list)
|
| 80 |
+
self.saved_text_latents = []
|
| 81 |
+
self.saved_motion_gen_latents = []
|
| 82 |
+
self.saved_motion_gt_latents = []
|
| 83 |
+
|
| 84 |
+
def _compute(
|
| 85 |
+
self,
|
| 86 |
+
motion_rep,
|
| 87 |
+
pred_joints_output: Dict,
|
| 88 |
+
gt_joints_output: Dict,
|
| 89 |
+
text_x_dict: Dict,
|
| 90 |
+
lengths: Tensor,
|
| 91 |
+
**kwargs,
|
| 92 |
+
) -> Dict:
|
| 93 |
+
pred_posed_joints = pred_joints_output["posed_joints"]
|
| 94 |
+
original_skeleton = motion_rep.skeleton if motion_rep is not None else None
|
| 95 |
+
latents_motion = self.tmr_model.encode_motion(
|
| 96 |
+
pred_posed_joints,
|
| 97 |
+
lengths=lengths,
|
| 98 |
+
original_skeleton=original_skeleton,
|
| 99 |
+
unit_vector=True,
|
| 100 |
+
)
|
| 101 |
+
latents_motion = latents_motion.cpu().numpy()
|
| 102 |
+
|
| 103 |
+
if isinstance(text_x_dict, dict) and "texts" in text_x_dict:
|
| 104 |
+
latents_text = self.tmr_model.encode_raw_text(text_x_dict["texts"], unit_vector=True)
|
| 105 |
+
else:
|
| 106 |
+
latents_text = self.tmr_model.encode_text(text_x_dict, unit_vector=True)
|
| 107 |
+
if latents_text.dim() == 1:
|
| 108 |
+
latents_text = latents_text.unsqueeze(0)
|
| 109 |
+
latents_text = latents_text.cpu().numpy()
|
| 110 |
+
|
| 111 |
+
self.saved_text_latents.append(latents_text)
|
| 112 |
+
self.saved_motion_gen_latents.append(latents_motion)
|
| 113 |
+
|
| 114 |
+
scores_text = get_scores_unit(latents_motion, latents_text)
|
| 115 |
+
output = {"TMR/t2m_sim": scores_text}
|
| 116 |
+
|
| 117 |
+
if gt_joints_output is not None and "posed_joints" in gt_joints_output:
|
| 118 |
+
gt_posed_joints = gt_joints_output["posed_joints"]
|
| 119 |
+
gt_latents_motion = self.tmr_model.encode_motion(
|
| 120 |
+
gt_posed_joints,
|
| 121 |
+
lengths=lengths,
|
| 122 |
+
original_skeleton=original_skeleton,
|
| 123 |
+
unit_vector=True,
|
| 124 |
+
)
|
| 125 |
+
gt_latents_motion = gt_latents_motion.cpu().numpy()
|
| 126 |
+
self.saved_motion_gt_latents.append(gt_latents_motion)
|
| 127 |
+
|
| 128 |
+
gt_scores_text = get_scores_unit(gt_latents_motion, latents_text)
|
| 129 |
+
scores_motion = get_scores_unit(latents_motion, gt_latents_motion)
|
| 130 |
+
|
| 131 |
+
output["TMR/t2m_gt_sim"] = gt_scores_text
|
| 132 |
+
output["TMR/m2m_sim"] = scores_motion
|
| 133 |
+
|
| 134 |
+
# pytorch tensors
|
| 135 |
+
for key, val in output.items():
|
| 136 |
+
output[key] = torch.tensor(val)
|
| 137 |
+
return output
|
| 138 |
+
|
| 139 |
+
def aggregate(self):
|
| 140 |
+
output = {}
|
| 141 |
+
for key, lst in self.saved_metrics.items():
|
| 142 |
+
output[key] = np.concatenate(lst)
|
| 143 |
+
|
| 144 |
+
assert self.saved_text_latents, "Should call the metric at least once."
|
| 145 |
+
|
| 146 |
+
text_latents = np.concatenate(self.saved_text_latents)
|
| 147 |
+
motion_gen_latents = np.concatenate(self.saved_motion_gen_latents)
|
| 148 |
+
|
| 149 |
+
batch_size = len(text_latents)
|
| 150 |
+
assert text_latents.shape == motion_gen_latents.shape
|
| 151 |
+
|
| 152 |
+
scores_t2m = get_score_matrix_unit(text_latents, motion_gen_latents)
|
| 153 |
+
scores_t2t = get_score_matrix_unit(text_latents, text_latents)
|
| 154 |
+
|
| 155 |
+
t2m_metrics = contrastive_metrics(
|
| 156 |
+
scores=scores_t2m,
|
| 157 |
+
scores_t2t=scores_t2t,
|
| 158 |
+
threshold=0.99,
|
| 159 |
+
rounding=2,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
for key, val in t2m_metrics.items():
|
| 163 |
+
output["TMR/t2m_R/" + key] = val
|
| 164 |
+
|
| 165 |
+
mu_gen, cov_gen = calculate_activation_statistics(motion_gen_latents)
|
| 166 |
+
mu_text, cov_text = calculate_activation_statistics(text_latents)
|
| 167 |
+
|
| 168 |
+
fid_gen_text = calculate_frechet_distance(mu_gen, cov_gen, mu_text, cov_text)
|
| 169 |
+
output["TMR/FID/gen_text"] = fid_gen_text
|
| 170 |
+
|
| 171 |
+
if self.saved_motion_gt_latents:
|
| 172 |
+
motion_gt_latents = np.concatenate(self.saved_motion_gt_latents)
|
| 173 |
+
assert motion_gt_latents.shape == motion_gen_latents.shape
|
| 174 |
+
|
| 175 |
+
scores_m2gm = get_score_matrix_unit(motion_gen_latents, motion_gt_latents)
|
| 176 |
+
scores_t2gm = get_score_matrix_unit(text_latents, motion_gt_latents)
|
| 177 |
+
|
| 178 |
+
m2gm_metrics = contrastive_metrics(
|
| 179 |
+
scores=scores_m2gm,
|
| 180 |
+
scores_t2t=scores_t2t,
|
| 181 |
+
threshold=0.99,
|
| 182 |
+
rounding=2,
|
| 183 |
+
)
|
| 184 |
+
for key, val in m2gm_metrics.items():
|
| 185 |
+
output["TMR/m2m_R/" + key] = val
|
| 186 |
+
|
| 187 |
+
t2gm_metrics = contrastive_metrics(
|
| 188 |
+
scores=scores_t2gm,
|
| 189 |
+
scores_t2t=scores_t2t,
|
| 190 |
+
threshold=0.99,
|
| 191 |
+
rounding=2,
|
| 192 |
+
)
|
| 193 |
+
for key, val in t2gm_metrics.items():
|
| 194 |
+
output["TMR/t2m_gt_R/" + key] = val
|
| 195 |
+
|
| 196 |
+
mu_gt_motion, cov_gt_motion = calculate_activation_statistics(motion_gt_latents)
|
| 197 |
+
fid_gen_motion = calculate_frechet_distance(
|
| 198 |
+
mu_gen,
|
| 199 |
+
cov_gen,
|
| 200 |
+
mu_gt_motion,
|
| 201 |
+
cov_gt_motion,
|
| 202 |
+
)
|
| 203 |
+
output["TMR/FID/gen_gt"] = fid_gen_motion
|
| 204 |
+
|
| 205 |
+
fid_gt_text = calculate_frechet_distance(
|
| 206 |
+
mu_gt_motion,
|
| 207 |
+
cov_gt_motion,
|
| 208 |
+
mu_text,
|
| 209 |
+
cov_text,
|
| 210 |
+
)
|
| 211 |
+
output["TMR/FID/gt_text"] = fid_gt_text
|
| 212 |
+
|
| 213 |
+
for key, val in output.items():
|
| 214 |
+
if isinstance(val, (int, float, np.integer, np.floating)):
|
| 215 |
+
val = torch.tensor([val for _ in range(batch_size)])
|
| 216 |
+
|
| 217 |
+
if isinstance(val, np.ndarray):
|
| 218 |
+
val = torch.from_numpy(val)
|
| 219 |
+
|
| 220 |
+
output[key] = val.cpu().float()
|
| 221 |
+
return output
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
class TMR_EmbeddingMetric(Metric):
|
| 225 |
+
"""TMR metrics from precomputed motion and text embeddings (no model load).
|
| 226 |
+
|
| 227 |
+
Use in the loop: pass motion_emb and text_emb per sample; aggregate() computes retrieval metrics.
|
| 228 |
+
"""
|
| 229 |
+
|
| 230 |
+
def __init__(self, ranks_rounding: int = 2, **kwargs):
|
| 231 |
+
super().__init__(**kwargs)
|
| 232 |
+
self.ranks_rounding = ranks_rounding
|
| 233 |
+
|
| 234 |
+
def clear(self):
|
| 235 |
+
self.saved_metrics = defaultdict(list)
|
| 236 |
+
self.saved_text_latents = []
|
| 237 |
+
self.saved_motion_gen_latents = []
|
| 238 |
+
self.saved_motion_gt_latents = []
|
| 239 |
+
|
| 240 |
+
def _compute(
|
| 241 |
+
self,
|
| 242 |
+
motion_emb=None,
|
| 243 |
+
text_emb=None,
|
| 244 |
+
gt_motion_emb=None,
|
| 245 |
+
**kwargs,
|
| 246 |
+
) -> Dict:
|
| 247 |
+
if motion_emb is None or text_emb is None:
|
| 248 |
+
return {}
|
| 249 |
+
motion_emb = np.asarray(motion_emb)
|
| 250 |
+
text_emb = np.asarray(text_emb)
|
| 251 |
+
if motion_emb.ndim == 1:
|
| 252 |
+
motion_emb = motion_emb[np.newaxis, :]
|
| 253 |
+
if text_emb.ndim == 1:
|
| 254 |
+
text_emb = text_emb[np.newaxis, :]
|
| 255 |
+
self.saved_text_latents.append(text_emb)
|
| 256 |
+
self.saved_motion_gen_latents.append(motion_emb)
|
| 257 |
+
if gt_motion_emb is not None:
|
| 258 |
+
gt_motion_emb = np.asarray(gt_motion_emb)
|
| 259 |
+
if gt_motion_emb.ndim == 1:
|
| 260 |
+
gt_motion_emb = gt_motion_emb[np.newaxis, :]
|
| 261 |
+
self.saved_motion_gt_latents.append(gt_motion_emb)
|
| 262 |
+
scores = get_scores_unit(motion_emb, text_emb)
|
| 263 |
+
return {"TMR/t2m_sim": torch.tensor(scores, dtype=torch.float32)}
|
| 264 |
+
|
| 265 |
+
def aggregate(self):
|
| 266 |
+
output = {}
|
| 267 |
+
for key, lst in self.saved_metrics.items():
|
| 268 |
+
output[key] = np.concatenate(lst)
|
| 269 |
+
if not self.saved_text_latents:
|
| 270 |
+
return output
|
| 271 |
+
text_latents = np.concatenate(self.saved_text_latents)
|
| 272 |
+
motion_gen_latents = np.concatenate(self.saved_motion_gen_latents)
|
| 273 |
+
batch_size = len(text_latents)
|
| 274 |
+
assert text_latents.shape == motion_gen_latents.shape
|
| 275 |
+
scores_t2m = get_score_matrix_unit(text_latents, motion_gen_latents)
|
| 276 |
+
scores_t2t = get_score_matrix_unit(text_latents, text_latents)
|
| 277 |
+
t2m_metrics = contrastive_metrics(
|
| 278 |
+
scores=scores_t2m,
|
| 279 |
+
scores_t2t=scores_t2t,
|
| 280 |
+
threshold=0.99,
|
| 281 |
+
rounding=self.ranks_rounding,
|
| 282 |
+
)
|
| 283 |
+
for key, val in t2m_metrics.items():
|
| 284 |
+
output["TMR/t2m_R/" + key] = val
|
| 285 |
+
if batch_size >= 2:
|
| 286 |
+
mu_gen, cov_gen = calculate_activation_statistics(motion_gen_latents)
|
| 287 |
+
mu_text, cov_text = calculate_activation_statistics(text_latents)
|
| 288 |
+
output["TMR/FID/gen_text"] = calculate_frechet_distance(mu_gen, cov_gen, mu_text, cov_text)
|
| 289 |
+
else:
|
| 290 |
+
output["TMR/FID/gen_text"] = float("nan")
|
| 291 |
+
if self.saved_motion_gt_latents:
|
| 292 |
+
motion_gt_latents = np.concatenate(self.saved_motion_gt_latents)
|
| 293 |
+
assert motion_gt_latents.shape == motion_gen_latents.shape
|
| 294 |
+
scores_m2gm = get_score_matrix_unit(motion_gen_latents, motion_gt_latents)
|
| 295 |
+
scores_t2gm = get_score_matrix_unit(text_latents, motion_gt_latents)
|
| 296 |
+
m2gm_metrics = contrastive_metrics(
|
| 297 |
+
scores=scores_m2gm,
|
| 298 |
+
scores_t2t=scores_t2t,
|
| 299 |
+
threshold=0.99,
|
| 300 |
+
rounding=self.ranks_rounding,
|
| 301 |
+
)
|
| 302 |
+
for key, val in m2gm_metrics.items():
|
| 303 |
+
output["TMR/m2m_R/" + key] = val
|
| 304 |
+
t2gm_metrics = contrastive_metrics(
|
| 305 |
+
scores=scores_t2gm,
|
| 306 |
+
scores_t2t=scores_t2t,
|
| 307 |
+
threshold=0.99,
|
| 308 |
+
rounding=self.ranks_rounding,
|
| 309 |
+
)
|
| 310 |
+
for key, val in t2gm_metrics.items():
|
| 311 |
+
output["TMR/t2m_gt_R/" + key] = val
|
| 312 |
+
if batch_size >= 2:
|
| 313 |
+
mu_gt_motion, cov_gt_motion = calculate_activation_statistics(motion_gt_latents)
|
| 314 |
+
output["TMR/FID/gen_gt"] = calculate_frechet_distance(mu_gen, cov_gen, mu_gt_motion, cov_gt_motion)
|
| 315 |
+
output["TMR/FID/gt_text"] = calculate_frechet_distance(mu_gt_motion, cov_gt_motion, mu_text, cov_text)
|
| 316 |
+
else:
|
| 317 |
+
output["TMR/FID/gen_gt"] = float("nan")
|
| 318 |
+
output["TMR/FID/gt_text"] = float("nan")
|
| 319 |
+
for key, val in output.items():
|
| 320 |
+
if isinstance(val, (int, float, np.integer, np.floating)):
|
| 321 |
+
val = torch.tensor([val for _ in range(batch_size)])
|
| 322 |
+
if isinstance(val, np.ndarray):
|
| 323 |
+
val = torch.from_numpy(val)
|
| 324 |
+
output[key] = val.cpu().float()
|
| 325 |
+
return output
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def compute_tmr_retrieval_metrics(
|
| 329 |
+
motion_emb: np.ndarray,
|
| 330 |
+
text_emb: np.ndarray,
|
| 331 |
+
gt_motion_emb: Optional[np.ndarray] = None,
|
| 332 |
+
rounding: int = 2,
|
| 333 |
+
) -> Dict[str, float]:
|
| 334 |
+
"""Compute TMR retrieval metrics from precomputed embeddings."""
|
| 335 |
+
if motion_emb.shape != text_emb.shape:
|
| 336 |
+
raise ValueError(f"Expected same shape for motion/text embeddings, got {motion_emb.shape} vs {text_emb.shape}")
|
| 337 |
+
|
| 338 |
+
scores_t2m = get_score_matrix_unit(text_emb, motion_emb)
|
| 339 |
+
scores_t2t = get_score_matrix_unit(text_emb, text_emb)
|
| 340 |
+
|
| 341 |
+
output: Dict[str, float] = {}
|
| 342 |
+
t2m_metrics = contrastive_metrics(
|
| 343 |
+
scores=scores_t2m,
|
| 344 |
+
scores_t2t=scores_t2t,
|
| 345 |
+
threshold=0.99,
|
| 346 |
+
rounding=rounding,
|
| 347 |
+
)
|
| 348 |
+
for key, val in t2m_metrics.items():
|
| 349 |
+
output[f"TMR/t2m_R/{key}"] = float(val)
|
| 350 |
+
|
| 351 |
+
n_samples = len(motion_emb)
|
| 352 |
+
if n_samples >= 2:
|
| 353 |
+
mu_gen, cov_gen = calculate_activation_statistics(motion_emb)
|
| 354 |
+
mu_text, cov_text = calculate_activation_statistics(text_emb)
|
| 355 |
+
output["TMR/FID/gen_text"] = float(calculate_frechet_distance(mu_gen, cov_gen, mu_text, cov_text))
|
| 356 |
+
else:
|
| 357 |
+
output["TMR/FID/gen_text"] = float("nan")
|
| 358 |
+
|
| 359 |
+
if gt_motion_emb is not None:
|
| 360 |
+
if gt_motion_emb.shape != motion_emb.shape:
|
| 361 |
+
raise ValueError(f"Expected gt motion embeddings shape {motion_emb.shape}, got {gt_motion_emb.shape}")
|
| 362 |
+
|
| 363 |
+
scores_m2gm = get_score_matrix_unit(motion_emb, gt_motion_emb)
|
| 364 |
+
scores_t2gm = get_score_matrix_unit(text_emb, gt_motion_emb)
|
| 365 |
+
|
| 366 |
+
m2gm_metrics = contrastive_metrics(
|
| 367 |
+
scores=scores_m2gm,
|
| 368 |
+
scores_t2t=scores_t2t,
|
| 369 |
+
threshold=0.99,
|
| 370 |
+
rounding=rounding,
|
| 371 |
+
)
|
| 372 |
+
for key, val in m2gm_metrics.items():
|
| 373 |
+
output[f"TMR/m2m_R/{key}"] = float(val)
|
| 374 |
+
|
| 375 |
+
t2gm_metrics = contrastive_metrics(
|
| 376 |
+
scores=scores_t2gm,
|
| 377 |
+
scores_t2t=scores_t2t,
|
| 378 |
+
threshold=0.99,
|
| 379 |
+
rounding=rounding,
|
| 380 |
+
)
|
| 381 |
+
for key, val in t2gm_metrics.items():
|
| 382 |
+
output[f"TMR/t2m_gt_R/{key}"] = float(val)
|
| 383 |
+
|
| 384 |
+
if n_samples >= 2:
|
| 385 |
+
mu_gt_motion, cov_gt_motion = calculate_activation_statistics(gt_motion_emb)
|
| 386 |
+
output["TMR/FID/gen_gt"] = float(calculate_frechet_distance(mu_gen, cov_gen, mu_gt_motion, cov_gt_motion))
|
| 387 |
+
output["TMR/FID/gt_text"] = float(calculate_frechet_distance(mu_gt_motion, cov_gt_motion, mu_text, cov_text))
|
| 388 |
+
else:
|
| 389 |
+
output["TMR/FID/gen_gt"] = float("nan")
|
| 390 |
+
output["TMR/FID/gt_text"] = float("nan")
|
| 391 |
+
|
| 392 |
+
return output
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def all_contrastive_metrics(sims, emb=None, threshold=None, rounding=2, return_cols=False):
|
| 396 |
+
text_selfsim = None
|
| 397 |
+
if emb is not None:
|
| 398 |
+
text_selfsim = emb @ emb.T
|
| 399 |
+
|
| 400 |
+
t2m_m, t2m_cols = contrastive_metrics(sims, text_selfsim, threshold, return_cols=True, rounding=rounding)
|
| 401 |
+
m2t_m, m2t_cols = contrastive_metrics(sims.T, text_selfsim, threshold, return_cols=True, rounding=rounding)
|
| 402 |
+
|
| 403 |
+
all_m = {}
|
| 404 |
+
for key in t2m_m:
|
| 405 |
+
all_m[f"t2m/{key}"] = t2m_m[key]
|
| 406 |
+
all_m[f"m2t/{key}"] = m2t_m[key]
|
| 407 |
+
|
| 408 |
+
all_m["t2m/len"] = float(len(sims))
|
| 409 |
+
all_m["m2t/len"] = float(len(sims[0]))
|
| 410 |
+
if return_cols:
|
| 411 |
+
return all_m, t2m_cols, m2t_cols
|
| 412 |
+
return all_m
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
def contrastive_metrics(
|
| 416 |
+
scores,
|
| 417 |
+
scores_t2t=None,
|
| 418 |
+
threshold=None,
|
| 419 |
+
rounding=2,
|
| 420 |
+
):
|
| 421 |
+
n, m = scores.shape
|
| 422 |
+
assert n == m
|
| 423 |
+
num_queries = n
|
| 424 |
+
|
| 425 |
+
dists = -scores
|
| 426 |
+
sorted_dists = np.sort(dists, axis=1)
|
| 427 |
+
# GT is in the diagonal
|
| 428 |
+
gt_dists = np.diag(dists)[:, None]
|
| 429 |
+
|
| 430 |
+
if scores_t2t is not None and threshold is not None:
|
| 431 |
+
real_threshold = 2 * threshold - 1
|
| 432 |
+
idx = np.argwhere(scores_t2t > real_threshold)
|
| 433 |
+
partition = np.unique(idx[:, 0], return_index=True)[1]
|
| 434 |
+
# take as GT the minimum score of similar values
|
| 435 |
+
gt_dists = np.minimum.reduceat(dists[tuple(idx.T)], partition)
|
| 436 |
+
gt_dists = gt_dists[:, None]
|
| 437 |
+
|
| 438 |
+
rows, cols = np.where((sorted_dists - gt_dists) == 0) # find column position of GT
|
| 439 |
+
|
| 440 |
+
# if there are ties
|
| 441 |
+
if rows.size > num_queries:
|
| 442 |
+
assert np.unique(rows).size == num_queries, "issue in metric evaluation"
|
| 443 |
+
avg_cols = break_ties_average(sorted_dists, gt_dists)
|
| 444 |
+
cols = avg_cols
|
| 445 |
+
|
| 446 |
+
msg = "expected ranks to match queries ({} vs {}) "
|
| 447 |
+
assert cols.size == num_queries, msg
|
| 448 |
+
|
| 449 |
+
metrics = {}
|
| 450 |
+
vals = [str(x).zfill(2) for x in [1, 2, 3, 5, 10]]
|
| 451 |
+
for val in vals:
|
| 452 |
+
metrics[f"R{val}"] = 100 * float(np.sum(cols < int(val))) / num_queries
|
| 453 |
+
|
| 454 |
+
metrics["MedR"] = float(np.median(cols) + 1)
|
| 455 |
+
metrics["len"] = num_queries
|
| 456 |
+
|
| 457 |
+
if rounding is not None:
|
| 458 |
+
for key in metrics:
|
| 459 |
+
metrics[key] = round(metrics[key], rounding)
|
| 460 |
+
return metrics
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def break_ties_average(sorted_dists, gt_dists):
|
| 464 |
+
# fast implementation, based on this code:
|
| 465 |
+
# https://stackoverflow.com/a/49239335
|
| 466 |
+
locs = np.argwhere((sorted_dists - gt_dists) == 0)
|
| 467 |
+
|
| 468 |
+
# Find the split indices
|
| 469 |
+
steps = np.diff(locs[:, 0])
|
| 470 |
+
splits = np.nonzero(steps)[0] + 1
|
| 471 |
+
splits = np.insert(splits, 0, 0)
|
| 472 |
+
|
| 473 |
+
# Compute the result columns
|
| 474 |
+
summed_cols = np.add.reduceat(locs[:, 1], splits)
|
| 475 |
+
counts = np.diff(np.append(splits, locs.shape[0]))
|
| 476 |
+
avg_cols = summed_cols / counts
|
| 477 |
+
return avg_cols
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def calculate_activation_statistics(activations):
|
| 481 |
+
"""
|
| 482 |
+
Params:
|
| 483 |
+
-- activation: num_samples x dim_feat
|
| 484 |
+
Returns:
|
| 485 |
+
-- mu: dim_feat
|
| 486 |
+
-- sigma: dim_feat x dim_feat
|
| 487 |
+
"""
|
| 488 |
+
mu = np.mean(activations, axis=0)
|
| 489 |
+
cov = np.cov(activations, rowvar=False)
|
| 490 |
+
return mu, cov
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
def calculate_frechet_distance(mu1, sigma1, mu2, sigma2, eps=1e-6):
|
| 494 |
+
"""Numpy implementation of the Frechet Distance. The Frechet distance between two multivariate
|
| 495 |
+
Gaussians X_1 ~ N(mu_1, C_1)
|
| 496 |
+
|
| 497 |
+
and X_2 ~ N(mu_2, C_2) is
|
| 498 |
+
d^2 = ||mu_1 - mu_2||^2 + Tr(C_1 + C_2 - 2*sqrt(C_1*C_2)).
|
| 499 |
+
Stable version by Dougal J. Sutherland.
|
| 500 |
+
Params:
|
| 501 |
+
-- mu1 : Numpy array containing the activations of a layer of the
|
| 502 |
+
inception net (like returned by the function 'get_predictions')
|
| 503 |
+
for generated samples.
|
| 504 |
+
-- mu2 : The sample mean over activations, precalculated on an
|
| 505 |
+
representative dataset set.
|
| 506 |
+
-- sigma1: The covariance matrix over activations for generated samples.
|
| 507 |
+
-- sigma2: The covariance matrix over activations, precalculated on an
|
| 508 |
+
representative dataset set.
|
| 509 |
+
Returns:
|
| 510 |
+
-- : The Frechet Distance.
|
| 511 |
+
"""
|
| 512 |
+
|
| 513 |
+
mu1 = np.atleast_1d(mu1)
|
| 514 |
+
mu2 = np.atleast_1d(mu2)
|
| 515 |
+
|
| 516 |
+
sigma1 = np.atleast_2d(sigma1)
|
| 517 |
+
sigma2 = np.atleast_2d(sigma2)
|
| 518 |
+
|
| 519 |
+
assert mu1.shape == mu2.shape, "Training and test mean vectors have different lengths"
|
| 520 |
+
assert sigma1.shape == sigma2.shape, "Training and test covariances have different dimensions"
|
| 521 |
+
|
| 522 |
+
diff = mu1 - mu2
|
| 523 |
+
|
| 524 |
+
# Product might be almost singular
|
| 525 |
+
covmean, _ = linalg.sqrtm(sigma1.dot(sigma2), disp=False)
|
| 526 |
+
if not np.isfinite(covmean).all():
|
| 527 |
+
msg = ("fid calculation produces singular product; " "adding %s to diagonal of cov estimates") % eps
|
| 528 |
+
print(msg)
|
| 529 |
+
offset = np.eye(sigma1.shape[0]) * eps
|
| 530 |
+
covmean = linalg.sqrtm((sigma1 + offset).dot(sigma2 + offset))
|
| 531 |
+
|
| 532 |
+
# Numerical error might give slight imaginary component
|
| 533 |
+
if np.iscomplexobj(covmean):
|
| 534 |
+
if not np.allclose(np.diagonal(covmean).imag, 0, atol=1e-3):
|
| 535 |
+
# try again with diagonal %s
|
| 536 |
+
offset = np.eye(sigma1.shape[0]) * eps
|
| 537 |
+
covmean = linalg.sqrtm((sigma1 + offset).dot(sigma2 + offset))
|
| 538 |
+
if not np.allclose(np.diagonal(covmean).imag, 0, atol=1e-3):
|
| 539 |
+
m = np.max(np.abs(covmean.imag))
|
| 540 |
+
raise ValueError("Imaginary component {}".format(m))
|
| 541 |
+
covmean = covmean.real
|
| 542 |
+
|
| 543 |
+
tr_covmean = np.trace(covmean)
|
| 544 |
+
|
| 545 |
+
return diff.dot(diff) + np.trace(sigma1) + np.trace(sigma2) - 2 * tr_covmean
|
kimodo/model/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Kimodo model package: main model class, text encoders, and loading utilities."""
|
| 4 |
+
|
| 5 |
+
from .common import resolve_target
|
| 6 |
+
from .kimodo_model import Kimodo
|
| 7 |
+
from .llm2vec import LLM2VecEncoder
|
| 8 |
+
from .load_model import load_model
|
| 9 |
+
from .loading import (
|
| 10 |
+
AVAILABLE_MODELS,
|
| 11 |
+
DEFAULT_MODEL,
|
| 12 |
+
DEFAULT_TEXT_ENCODER_URL,
|
| 13 |
+
MODEL_NAMES,
|
| 14 |
+
load_checkpoint_state_dict,
|
| 15 |
+
)
|
| 16 |
+
from .tmr import TMR
|
| 17 |
+
from .twostage_denoiser import TwostageDenoiser
|
| 18 |
+
|
| 19 |
+
__all__ = [
|
| 20 |
+
"Kimodo",
|
| 21 |
+
"LLM2VecEncoder",
|
| 22 |
+
"TMR",
|
| 23 |
+
"TwostageDenoiser",
|
| 24 |
+
"load_model",
|
| 25 |
+
"load_checkpoint_state_dict",
|
| 26 |
+
"resolve_target",
|
| 27 |
+
"AVAILABLE_MODELS",
|
| 28 |
+
"DEFAULT_MODEL",
|
| 29 |
+
"DEFAULT_TEXT_ENCODER_URL",
|
| 30 |
+
"MODEL_NAMES",
|
| 31 |
+
]
|
kimodo/model/backbone.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Transformer backbone: padding, masking, and encoder stack for the denoiser."""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional, Union
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from omegaconf import ListConfig
|
| 10 |
+
from pydantic.dataclasses import dataclass
|
| 11 |
+
from torch import Tensor, nn
|
| 12 |
+
from torch.nn import TransformerEncoder, TransformerEncoderLayer
|
| 13 |
+
|
| 14 |
+
from kimodo.tools import validate
|
| 15 |
+
|
| 16 |
+
log = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def pad_x_and_mask_to_fixed_size(x: Tensor, mask: Tensor, size: int):
|
| 20 |
+
"""Pad a feature vector x and the mask to always have the same size.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
x (torch.Tensor): [B, T, D]
|
| 24 |
+
mask (torch.Tensor): [B, T]
|
| 25 |
+
size (int)
|
| 26 |
+
Returns:
|
| 27 |
+
torch.Tensor: [B, size, D]
|
| 28 |
+
torch.Tensor: [B, size]
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
batch_size, cur_max_size, dim = x.shape[0], x.shape[1], x.shape[2]
|
| 32 |
+
|
| 33 |
+
if cur_max_size == size:
|
| 34 |
+
# already padded to this size, probably in the collate function
|
| 35 |
+
return x, mask
|
| 36 |
+
|
| 37 |
+
if cur_max_size > size:
|
| 38 |
+
# This issue should have been handled in the collate function
|
| 39 |
+
# usefull as a check for test time
|
| 40 |
+
log.warn("The size of the tensor is larger than the maximum size. Cropping the input..")
|
| 41 |
+
cur_max_size = size
|
| 42 |
+
|
| 43 |
+
new_x = torch.zeros(
|
| 44 |
+
(batch_size, size, dim),
|
| 45 |
+
dtype=x.dtype,
|
| 46 |
+
device=x.device,
|
| 47 |
+
)
|
| 48 |
+
new_x[:, :cur_max_size] = x
|
| 49 |
+
|
| 50 |
+
# same for the mask
|
| 51 |
+
new_mask = torch.zeros(
|
| 52 |
+
(batch_size, size),
|
| 53 |
+
dtype=mask.dtype,
|
| 54 |
+
device=mask.device,
|
| 55 |
+
)
|
| 56 |
+
new_mask[:, :cur_max_size] = mask
|
| 57 |
+
return new_x, new_mask
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass(frozen=True, config=dict(extra="forbid", arbitrary_types_allowed=True))
|
| 61 |
+
class TransformerEncoderBlockConfig:
|
| 62 |
+
"""Configuration for the transformer encoder backbone."""
|
| 63 |
+
|
| 64 |
+
# input features dimension
|
| 65 |
+
input_dim: int
|
| 66 |
+
# output features dimension
|
| 67 |
+
output_dim: int
|
| 68 |
+
|
| 69 |
+
# skeleton object
|
| 70 |
+
skeleton: object
|
| 71 |
+
|
| 72 |
+
# dimension of the text embeddings
|
| 73 |
+
llm_shape: Union[list[int], ListConfig]
|
| 74 |
+
|
| 75 |
+
# mask the text or not
|
| 76 |
+
use_text_mask: bool
|
| 77 |
+
|
| 78 |
+
# latent dimension of the model
|
| 79 |
+
latent_dim: int
|
| 80 |
+
# dimension of the feedforward network in transformer
|
| 81 |
+
ff_size: int
|
| 82 |
+
# num layers in transformer
|
| 83 |
+
num_layers: int
|
| 84 |
+
# num heads in transformer
|
| 85 |
+
num_heads: int
|
| 86 |
+
# activation in transformer
|
| 87 |
+
activation: str
|
| 88 |
+
# dropout rate for the transformer
|
| 89 |
+
dropout: float
|
| 90 |
+
# dropout rate for the positional embeddings
|
| 91 |
+
pe_dropout: float
|
| 92 |
+
# use norm first or not
|
| 93 |
+
norm_first: bool = False
|
| 94 |
+
# artificially extend the number of text tokens
|
| 95 |
+
num_text_tokens_override: Optional[int] = None
|
| 96 |
+
|
| 97 |
+
# Input first heading angle
|
| 98 |
+
input_first_heading_angle: bool = False
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class TransformerEncoderBlock(nn.Module):
|
| 102 |
+
@validate(TransformerEncoderBlockConfig, save_args=True, super_init=True)
|
| 103 |
+
def __init__(self, conf):
|
| 104 |
+
self.nbjoints = self.skeleton.nbjoints
|
| 105 |
+
llm_dim = self.llm_shape[-1]
|
| 106 |
+
self.embed_text = nn.Linear(llm_dim, self.latent_dim)
|
| 107 |
+
|
| 108 |
+
self.sequence_pos_encoder = PositionalEncoding(self.latent_dim, self.pe_dropout)
|
| 109 |
+
|
| 110 |
+
# maximum number of tokens
|
| 111 |
+
self.num_text_tokens = self.llm_shape[0]
|
| 112 |
+
if self.num_text_tokens_override is not None:
|
| 113 |
+
self.num_text_tokens = self.num_text_tokens_override
|
| 114 |
+
|
| 115 |
+
self.embed_timestep = TimestepEmbedder(self.latent_dim, self.sequence_pos_encoder)
|
| 116 |
+
|
| 117 |
+
self.input_linear = nn.Linear(self.input_dim, self.latent_dim)
|
| 118 |
+
self.output_linear = nn.Linear(self.latent_dim, self.output_dim)
|
| 119 |
+
self.linear_first_heading_angle = nn.Linear(2, self.latent_dim)
|
| 120 |
+
|
| 121 |
+
trans_enc_layer = TransformerEncoderLayer(
|
| 122 |
+
d_model=self.latent_dim,
|
| 123 |
+
nhead=self.num_heads,
|
| 124 |
+
dim_feedforward=self.ff_size,
|
| 125 |
+
dropout=self.dropout,
|
| 126 |
+
activation=self.activation,
|
| 127 |
+
batch_first=True,
|
| 128 |
+
norm_first=self.norm_first,
|
| 129 |
+
)
|
| 130 |
+
self.seqTransEncoder = TransformerEncoder(
|
| 131 |
+
trans_enc_layer,
|
| 132 |
+
num_layers=self.num_layers,
|
| 133 |
+
enable_nested_tensor=False,
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
def forward(
|
| 137 |
+
self,
|
| 138 |
+
x: Tensor,
|
| 139 |
+
x_pad_mask: torch.Tensor,
|
| 140 |
+
text_feat: torch.Tensor,
|
| 141 |
+
text_feat_pad_mask: torch.Tensor,
|
| 142 |
+
timesteps: Tensor,
|
| 143 |
+
first_heading_angle: Optional[Tensor] = None,
|
| 144 |
+
) -> Tensor:
|
| 145 |
+
"""
|
| 146 |
+
Args:
|
| 147 |
+
x (torch.Tensor): [B, T, dim_motion] current noisy motion
|
| 148 |
+
x_pad_mask (torch.Tensor): [B, T] attention mask, positions with True are allowed to attend, False are not
|
| 149 |
+
text_feat (torch.Tensor): [B, max_text_len, llm_dim] embedded text prompts
|
| 150 |
+
text_feat_pad_mask (torch.Tensor): [B, max_text_len] attention mask, positions with True are allowed to attend, False are not
|
| 151 |
+
timesteps (torch.Tensor): [B,] current denoising step
|
| 152 |
+
|
| 153 |
+
Returns:
|
| 154 |
+
torch.Tensor: [B, T, output_dim]
|
| 155 |
+
"""
|
| 156 |
+
batch_size = len(x)
|
| 157 |
+
x = self.input_linear(x) # [B, T, D]
|
| 158 |
+
|
| 159 |
+
# Pad the text tokens + mask to always have the same size == self.num_text_tokens
|
| 160 |
+
# done here if it was not done in the collate function
|
| 161 |
+
if self.num_text_tokens is not None:
|
| 162 |
+
text_feat, text_feat_pad_mask = pad_x_and_mask_to_fixed_size(
|
| 163 |
+
text_feat,
|
| 164 |
+
text_feat_pad_mask,
|
| 165 |
+
self.num_text_tokens,
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# Encode the text features and the time information
|
| 169 |
+
emb_text = self.embed_text(text_feat) # [B, max_text_len, D]
|
| 170 |
+
emb_time = self.embed_timestep(timesteps) # [B, 1, D]
|
| 171 |
+
|
| 172 |
+
# Create mask for the time information
|
| 173 |
+
time_mask = torch.ones((batch_size, 1), dtype=bool, device=x.device)
|
| 174 |
+
|
| 175 |
+
# Create the prefix features (text, time, etc): [B, max_text_len + 1 + etc]
|
| 176 |
+
prefix_feats = torch.cat((emb_text, emb_time), axis=1)
|
| 177 |
+
|
| 178 |
+
# Behavior from old code: not use text mask -> True for all the tokens
|
| 179 |
+
if not self.use_text_mask:
|
| 180 |
+
text_feat_pad_mask = torch.ones(
|
| 181 |
+
(batch_size, emb_text.shape[1]),
|
| 182 |
+
dtype=torch.bool,
|
| 183 |
+
device=x.device,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
prefix_mask = torch.cat((text_feat_pad_mask, time_mask), axis=1)
|
| 187 |
+
|
| 188 |
+
# add the input first heading angle
|
| 189 |
+
if self.input_first_heading_angle:
|
| 190 |
+
assert first_heading_angle is not None, "The first heading angle is mandatory for this model"
|
| 191 |
+
# cos(angle) / sin(angle)
|
| 192 |
+
first_heading_angle_feats = torch.stack(
|
| 193 |
+
[
|
| 194 |
+
torch.cos(first_heading_angle),
|
| 195 |
+
torch.sin(first_heading_angle),
|
| 196 |
+
],
|
| 197 |
+
axis=-1,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
first_heading_angle_feats = self.linear_first_heading_angle(first_heading_angle_feats)
|
| 201 |
+
first_heading_angle_feats = first_heading_angle_feats[:, None] # for cat
|
| 202 |
+
first_heading_angle_mask = torch.ones(
|
| 203 |
+
(batch_size, 1),
|
| 204 |
+
dtype=bool,
|
| 205 |
+
device=x.device,
|
| 206 |
+
)
|
| 207 |
+
prefix_feats = torch.cat((prefix_feats, first_heading_angle_feats), axis=1)
|
| 208 |
+
prefix_mask = torch.cat((prefix_mask, first_heading_angle_mask), axis=1)
|
| 209 |
+
|
| 210 |
+
# compute the number of prefix features
|
| 211 |
+
pose_start_ind = prefix_feats.shape[1]
|
| 212 |
+
|
| 213 |
+
# Concatenate prefix and x: [B, len(prefix) + T, D]
|
| 214 |
+
xseq = torch.cat((prefix_feats, x), axis=1)
|
| 215 |
+
|
| 216 |
+
# Concatenate the masks and negate them: [B, len(prefix) + T]
|
| 217 |
+
src_key_padding_mask = ~torch.cat((prefix_mask, x_pad_mask), axis=1)
|
| 218 |
+
|
| 219 |
+
# Add positional encoding
|
| 220 |
+
xseq = self.sequence_pos_encoder(xseq)
|
| 221 |
+
|
| 222 |
+
# Input to the transformer and keep the motion indexes
|
| 223 |
+
if isinstance(self.seqTransEncoder, nn.TransformerEncoder):
|
| 224 |
+
assert not self.seqTransEncoder.use_nested_tensor, "Flash attention should be disabled due to bug!"
|
| 225 |
+
|
| 226 |
+
output = self.seqTransEncoder(
|
| 227 |
+
xseq,
|
| 228 |
+
src_key_padding_mask=src_key_padding_mask,
|
| 229 |
+
)
|
| 230 |
+
output = output[:, pose_start_ind:] # [B, T, D]
|
| 231 |
+
output = self.output_linear(output) # [B, T, OD]
|
| 232 |
+
return output
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class PositionalEncoding(nn.Module):
|
| 236 |
+
"""Non-learned positional encoding."""
|
| 237 |
+
|
| 238 |
+
def __init__(
|
| 239 |
+
self,
|
| 240 |
+
d_model: int,
|
| 241 |
+
dropout: Optional[float] = 0.1,
|
| 242 |
+
max_len: Optional[int] = 5000,
|
| 243 |
+
):
|
| 244 |
+
"""
|
| 245 |
+
Args:
|
| 246 |
+
d_model (int): input dim
|
| 247 |
+
dropout (Optional[float] = 0.1): dropout probability on output
|
| 248 |
+
max_len (Optional[int] = 5000): maximum sequence length
|
| 249 |
+
"""
|
| 250 |
+
super(PositionalEncoding, self).__init__()
|
| 251 |
+
self.dropout = nn.Dropout(p=dropout)
|
| 252 |
+
|
| 253 |
+
pe = torch.zeros(max_len, d_model)
|
| 254 |
+
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
|
| 255 |
+
|
| 256 |
+
# Note: have to replace torch.exp() and math.log() with torch.pow()
|
| 257 |
+
# due to MKL exp() and ln() throws floating point exceptions on certain CPUs
|
| 258 |
+
# see corresponding commit and MR
|
| 259 |
+
div_term = torch.pow(10000.0, -torch.arange(0, d_model, 2).float() / d_model)
|
| 260 |
+
# div_term = torch.exp(
|
| 261 |
+
# torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model)
|
| 262 |
+
# )
|
| 263 |
+
|
| 264 |
+
pe[:, 0::2] = torch.sin(position * div_term)
|
| 265 |
+
pe[:, 1::2] = torch.cos(position * div_term)
|
| 266 |
+
pe = pe.unsqueeze(0) # [1, T, D]
|
| 267 |
+
|
| 268 |
+
self.register_buffer("pe", pe, persistent=False)
|
| 269 |
+
|
| 270 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 271 |
+
"""Apply positional encoding to input sequence.
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
x (torch.Tensor): [B, T, D] input motion sequence
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
torch.Tensor: [B, T, D] input motion with PE added to it (and optionally dropout)
|
| 278 |
+
"""
|
| 279 |
+
x = x + self.pe[:, : x.shape[1], :]
|
| 280 |
+
return self.dropout(x)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
class TimestepEmbedder(nn.Module):
|
| 284 |
+
"""Encoder for diffusion step."""
|
| 285 |
+
|
| 286 |
+
def __init__(self, latent_dim: int, sequence_pos_encoder: PositionalEncoding):
|
| 287 |
+
"""
|
| 288 |
+
Args:
|
| 289 |
+
latent_dim (int): dim to encode to
|
| 290 |
+
sequence_pos_encoder (PositionalEncoding): the PE to use on timesteps
|
| 291 |
+
"""
|
| 292 |
+
super().__init__()
|
| 293 |
+
self.latent_dim = latent_dim
|
| 294 |
+
self.sequence_pos_encoder = sequence_pos_encoder
|
| 295 |
+
|
| 296 |
+
time_embed_dim = self.latent_dim
|
| 297 |
+
self.time_embed = nn.Sequential(
|
| 298 |
+
nn.Linear(self.latent_dim, time_embed_dim),
|
| 299 |
+
nn.SiLU(),
|
| 300 |
+
nn.Linear(time_embed_dim, time_embed_dim),
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
def forward(self, timesteps: torch.Tensor) -> torch.Tensor:
|
| 304 |
+
"""Embed timesteps by adding PE then going through linear layers.
|
| 305 |
+
|
| 306 |
+
Args:
|
| 307 |
+
timesteps (torch.Tensor): [B]
|
| 308 |
+
|
| 309 |
+
Returns:
|
| 310 |
+
torch.Tensor: [B, 1, D]
|
| 311 |
+
"""
|
| 312 |
+
return self.time_embed(self.sequence_pos_encoder.pe.transpose(0, 1)[timesteps])
|
kimodo/model/cfg.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Classifier-free guidance wrapper for the denoiser at sampling time."""
|
| 4 |
+
|
| 5 |
+
from typing import Dict, Optional, Tuple, Union
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
import torch.nn as nn
|
| 9 |
+
|
| 10 |
+
CFG_TYPES = ["nocfg", "regular", "separated"]
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ClassifierFreeGuidedModel(nn.Module):
|
| 14 |
+
"""Wrapper around denoiser to use classifier-free guidance at sampling time."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, model: nn.Module, cfg_type: Optional[str] = "separated"):
|
| 17 |
+
"""Wrap the denoiser for classifier-free guidance; cfg_type in CFG_TYPES (e.g. 'regular',
|
| 18 |
+
'nocfg')."""
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.model = model
|
| 21 |
+
assert cfg_type in CFG_TYPES, f"Invalid cfg_type: {cfg_type}"
|
| 22 |
+
self.cfg_type_default = cfg_type
|
| 23 |
+
|
| 24 |
+
def forward(
|
| 25 |
+
self,
|
| 26 |
+
cfg_weight: Union[float, Tuple[float, float]],
|
| 27 |
+
x: torch.Tensor,
|
| 28 |
+
x_pad_mask: torch.Tensor,
|
| 29 |
+
text_feat: torch.Tensor,
|
| 30 |
+
text_feat_pad_mask: torch.Tensor,
|
| 31 |
+
timesteps: torch.Tensor,
|
| 32 |
+
first_heading_angle: Optional[torch.Tensor] = None,
|
| 33 |
+
motion_mask: Optional[torch.Tensor] = None,
|
| 34 |
+
observed_motion: Optional[torch.Tensor] = None,
|
| 35 |
+
cfg_type: Optional[str] = None,
|
| 36 |
+
) -> torch.Tensor:
|
| 37 |
+
"""
|
| 38 |
+
Args:
|
| 39 |
+
cfg_weight (float): guidance weight float or tuple of floats with (text, constraint) weights if using separated cfg
|
| 40 |
+
x (torch.Tensor): [B, T, dim_motion] current noisy motion
|
| 41 |
+
x_pad_mask (torch.Tensor): [B, T] attention mask, positions with True are allowed to attend, False are not
|
| 42 |
+
text_feat (torch.Tensor): [B, max_text_len, llm_dim] embedded text prompts
|
| 43 |
+
text_feat_pad_mask (torch.Tensor): [B, max_text_len] attention mask, positions with True are allowed to attend, False are not
|
| 44 |
+
timesteps (torch.Tensor): [B,] current denoising step
|
| 45 |
+
motion_mask
|
| 46 |
+
observed_motion
|
| 47 |
+
neutral_joints (torch.Tensor): [B, nbjoints] The neutral joints of the motions
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
torch.Tensor: same size as input x
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
if cfg_type is None:
|
| 54 |
+
cfg_type = self.cfg_type_default
|
| 55 |
+
|
| 56 |
+
assert cfg_type in CFG_TYPES, f"Invalid cfg_type: {cfg_type}"
|
| 57 |
+
|
| 58 |
+
# batched conditional and uncond pass together
|
| 59 |
+
if cfg_type == "nocfg":
|
| 60 |
+
return self.model(
|
| 61 |
+
x,
|
| 62 |
+
x_pad_mask,
|
| 63 |
+
text_feat,
|
| 64 |
+
text_feat_pad_mask,
|
| 65 |
+
timesteps,
|
| 66 |
+
first_heading_angle=first_heading_angle,
|
| 67 |
+
motion_mask=motion_mask,
|
| 68 |
+
observed_motion=observed_motion,
|
| 69 |
+
)
|
| 70 |
+
elif cfg_type == "regular":
|
| 71 |
+
assert isinstance(cfg_weight, (float, int)), "cfg_weight must be a single float for regular CFG"
|
| 72 |
+
# out_uncond + w * (out_text_and_constraint - out_uncond)
|
| 73 |
+
text_feat = torch.concatenate([text_feat, 0 * text_feat], dim=0)
|
| 74 |
+
if motion_mask is not None:
|
| 75 |
+
motion_mask = torch.concatenate([motion_mask, 0 * motion_mask], dim=0)
|
| 76 |
+
if observed_motion is not None:
|
| 77 |
+
observed_motion = torch.concatenate([observed_motion, observed_motion], dim=0)
|
| 78 |
+
if first_heading_angle is not None:
|
| 79 |
+
first_heading_angle = torch.concatenate([first_heading_angle, first_heading_angle], dim=0)
|
| 80 |
+
|
| 81 |
+
out_cond_uncond = self.model(
|
| 82 |
+
torch.concatenate([x, x], dim=0),
|
| 83 |
+
torch.concatenate([x_pad_mask, x_pad_mask], dim=0),
|
| 84 |
+
text_feat,
|
| 85 |
+
torch.concatenate([text_feat_pad_mask, False * text_feat_pad_mask], dim=0),
|
| 86 |
+
torch.concatenate([timesteps, timesteps], dim=0),
|
| 87 |
+
first_heading_angle=first_heading_angle,
|
| 88 |
+
motion_mask=motion_mask,
|
| 89 |
+
observed_motion=observed_motion,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
out, out_uncond = torch.chunk(out_cond_uncond, 2)
|
| 93 |
+
out_new = out_uncond + (cfg_weight * (out - out_uncond))
|
| 94 |
+
elif cfg_type == "separated":
|
| 95 |
+
assert len(cfg_weight) == 2, "cfg_weight must be a tuple of two floats for separated CFG"
|
| 96 |
+
# out_uncond + w_text * (out_text - out_uncond) + w_constraint * (out_constraint - out_uncond)
|
| 97 |
+
text_feat = torch.concatenate([text_feat, 0 * text_feat, 0 * text_feat], dim=0)
|
| 98 |
+
if motion_mask is not None:
|
| 99 |
+
motion_mask = torch.concatenate([0 * motion_mask, motion_mask, 0 * motion_mask], dim=0)
|
| 100 |
+
if observed_motion is not None:
|
| 101 |
+
observed_motion = torch.concatenate([observed_motion, observed_motion, observed_motion], dim=0)
|
| 102 |
+
if first_heading_angle is not None:
|
| 103 |
+
first_heading_angle = torch.concatenate(
|
| 104 |
+
[first_heading_angle, first_heading_angle, first_heading_angle],
|
| 105 |
+
dim=0,
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
out_cond_uncond = self.model(
|
| 109 |
+
torch.concatenate([x, x, x], dim=0),
|
| 110 |
+
torch.concatenate([x_pad_mask, x_pad_mask, x_pad_mask], dim=0),
|
| 111 |
+
text_feat,
|
| 112 |
+
torch.concatenate(
|
| 113 |
+
[
|
| 114 |
+
text_feat_pad_mask,
|
| 115 |
+
False * text_feat_pad_mask,
|
| 116 |
+
False * text_feat_pad_mask,
|
| 117 |
+
],
|
| 118 |
+
dim=0,
|
| 119 |
+
),
|
| 120 |
+
torch.concatenate([timesteps, timesteps, timesteps], dim=0),
|
| 121 |
+
first_heading_angle=first_heading_angle,
|
| 122 |
+
motion_mask=motion_mask,
|
| 123 |
+
observed_motion=observed_motion,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
out_text, out_constraint, out_uncond = torch.chunk(out_cond_uncond, 3)
|
| 127 |
+
out_new = (
|
| 128 |
+
out_uncond + (cfg_weight[0] * (out_text - out_uncond)) + (cfg_weight[1] * (out_constraint - out_uncond))
|
| 129 |
+
)
|
| 130 |
+
else:
|
| 131 |
+
raise ValueError(f"Invalid cfg_type: {cfg_type}")
|
| 132 |
+
|
| 133 |
+
return out_new
|
kimodo/model/common.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Config hydration: env vars, _target_ resolution, and recursive instantiation."""
|
| 4 |
+
|
| 5 |
+
import importlib
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_env_var(name: str, default=None):
|
| 10 |
+
"""Read env var by name and by lowercased name; return default if neither set."""
|
| 11 |
+
return os.getenv(name, os.getenv(name.lower(), default))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def resolve_target(target: str):
|
| 15 |
+
"""Import module and return the attribute named by a dotted path (e.g. 'pkg.mod.Class')."""
|
| 16 |
+
module_name, attr_name = target.rsplit(".", 1)
|
| 17 |
+
module = importlib.import_module(module_name)
|
| 18 |
+
return getattr(module, attr_name)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def materialize_value(value):
|
| 22 |
+
"""Recursively turn dicts with '_target_' into instances; lists/dicts traversed; leaves
|
| 23 |
+
unchanged."""
|
| 24 |
+
if isinstance(value, dict):
|
| 25 |
+
if "_target_" in value:
|
| 26 |
+
return instantiate_from_dict(value)
|
| 27 |
+
return {k: materialize_value(v) for k, v in value.items()}
|
| 28 |
+
if isinstance(value, list):
|
| 29 |
+
return [materialize_value(v) for v in value]
|
| 30 |
+
return value
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def instantiate_from_dict(node, overrides=None):
|
| 34 |
+
"""Build an instance from a config dict: '_target_' gives the class, other keys are kwargs; overrides merged in."""
|
| 35 |
+
if not isinstance(node, dict) or "_target_" not in node:
|
| 36 |
+
raise ValueError("Config node must be a dict with a '_target_' key.")
|
| 37 |
+
|
| 38 |
+
target = resolve_target(node["_target_"])
|
| 39 |
+
kwargs = {}
|
| 40 |
+
for key, value in node.items():
|
| 41 |
+
if key == "_target_":
|
| 42 |
+
continue
|
| 43 |
+
kwargs[key] = materialize_value(value)
|
| 44 |
+
|
| 45 |
+
if overrides:
|
| 46 |
+
kwargs.update({k: v for k, v in overrides.items() if v is not None})
|
| 47 |
+
|
| 48 |
+
return target(**kwargs)
|
kimodo/model/diffusion.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Diffusion process and DDIM sampling for motion generation."""
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
from typing import Optional, Tuple
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch import nn
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_beta_schedule(
|
| 13 |
+
num_diffusion_timesteps: int,
|
| 14 |
+
max_beta: Optional[float] = 0.999,
|
| 15 |
+
) -> torch.Tensor:
|
| 16 |
+
"""Get cosine beta schedule."""
|
| 17 |
+
|
| 18 |
+
def alpha_bar(t):
|
| 19 |
+
return math.cos((t + 0.008) / 1.008 * math.pi / 2) ** 2
|
| 20 |
+
|
| 21 |
+
betas = []
|
| 22 |
+
for i in range(num_diffusion_timesteps):
|
| 23 |
+
t1 = i / num_diffusion_timesteps
|
| 24 |
+
t2 = (i + 1) / num_diffusion_timesteps
|
| 25 |
+
betas.append(min(1 - alpha_bar(t2) / alpha_bar(t1), max_beta))
|
| 26 |
+
return torch.tensor(betas, dtype=torch.float)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class Diffusion(torch.nn.Module):
|
| 30 |
+
"""Cosine-schedule diffusion process: betas, alphas, and DDIM step mapping."""
|
| 31 |
+
|
| 32 |
+
def __init__(self, num_base_steps: int):
|
| 33 |
+
"""Set up cosine beta schedule and precompute diffusion variables for num_base_steps."""
|
| 34 |
+
super().__init__()
|
| 35 |
+
self.num_base_steps = num_base_steps
|
| 36 |
+
betas_base = get_beta_schedule(self.num_base_steps)
|
| 37 |
+
self.register_buffer("betas_base", betas_base, persistent=False)
|
| 38 |
+
alphas_cumprod_base = torch.cumprod(1.0 - self.betas_base, dim=0)
|
| 39 |
+
self.register_buffer("alphas_cumprod_base", alphas_cumprod_base, persistent=False)
|
| 40 |
+
use_timesteps, _ = self.space_timesteps(self.num_base_steps)
|
| 41 |
+
self.calc_diffusion_vars(use_timesteps)
|
| 42 |
+
|
| 43 |
+
def extra_repr(self) -> str:
|
| 44 |
+
return f"num_base_steps={self.num_base_steps}"
|
| 45 |
+
|
| 46 |
+
@property
|
| 47 |
+
def device(self):
|
| 48 |
+
return self.betas_base.device
|
| 49 |
+
|
| 50 |
+
def space_timesteps(self, num_denoising_steps: int) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 51 |
+
"""Return (use_timesteps, map_tensor) for a subsampled denoising schedule of
|
| 52 |
+
num_denoising_steps."""
|
| 53 |
+
nsteps_train = self.num_base_steps
|
| 54 |
+
frac_stride = (nsteps_train - 1) / max(1, num_denoising_steps - 1)
|
| 55 |
+
use_timesteps = torch.round(torch.arange(nsteps_train, device=self.device) * frac_stride).to(torch.long)
|
| 56 |
+
use_timesteps = torch.clamp(use_timesteps, max=nsteps_train - 1)
|
| 57 |
+
map_tensor = torch.arange(nsteps_train, device=self.device, dtype=torch.long)[use_timesteps]
|
| 58 |
+
return use_timesteps, map_tensor
|
| 59 |
+
|
| 60 |
+
def calc_diffusion_vars(self, use_timesteps: torch.Tensor) -> None:
|
| 61 |
+
"""Update buffers (betas, alphas, alphas_cumprod, etc.) for the given subsampled
|
| 62 |
+
timesteps."""
|
| 63 |
+
alphas_cumprod = self.alphas_cumprod_base[use_timesteps]
|
| 64 |
+
last_alpha_cumprod = torch.cat([torch.tensor([1.0]).to(alphas_cumprod), alphas_cumprod[:-1]])
|
| 65 |
+
betas = 1.0 - alphas_cumprod / last_alpha_cumprod
|
| 66 |
+
self.register_buffer("betas", betas, persistent=False)
|
| 67 |
+
|
| 68 |
+
alphas = 1.0 - self.betas
|
| 69 |
+
self.register_buffer("alphas", alphas, persistent=False)
|
| 70 |
+
alphas_cumprod = torch.cumprod(self.alphas, dim=0)
|
| 71 |
+
alphas_cumprod = torch.clamp(alphas_cumprod, min=1e-9)
|
| 72 |
+
self.register_buffer("alphas_cumprod", alphas_cumprod, persistent=False)
|
| 73 |
+
|
| 74 |
+
alphas_cumprod_prev = torch.cat([torch.tensor([1.0]).to(self.alphas_cumprod), self.alphas_cumprod[:-1]])
|
| 75 |
+
self.register_buffer("alphas_cumprod_prev", alphas_cumprod_prev, persistent=False)
|
| 76 |
+
|
| 77 |
+
sqrt_recip_alphas_cumprod = torch.rsqrt(self.alphas_cumprod)
|
| 78 |
+
self.register_buffer("sqrt_recip_alphas_cumprod", sqrt_recip_alphas_cumprod, persistent=False)
|
| 79 |
+
|
| 80 |
+
sqrt_recipm1_alphas_cumprod = torch.rsqrt(self.alphas_cumprod / (1.0 - self.alphas_cumprod))
|
| 81 |
+
self.register_buffer("sqrt_recipm1_alphas_cumprod", sqrt_recipm1_alphas_cumprod, persistent=False)
|
| 82 |
+
|
| 83 |
+
posterior_variance = self.betas * (1.0 - self.alphas_cumprod_prev) / (1.0 - self.alphas_cumprod)
|
| 84 |
+
self.register_buffer("posterior_variance", posterior_variance, persistent=False)
|
| 85 |
+
|
| 86 |
+
sqrt_alphas_cumprod = torch.rsqrt(1.0 / self.alphas_cumprod)
|
| 87 |
+
self.register_buffer("sqrt_alphas_cumprod", sqrt_alphas_cumprod, persistent=False)
|
| 88 |
+
|
| 89 |
+
sqrt_one_minus_alphas_cumprod = torch.rsqrt(1.0 / (1.0 - self.alphas_cumprod))
|
| 90 |
+
self.register_buffer(
|
| 91 |
+
"sqrt_one_minus_alphas_cumprod",
|
| 92 |
+
sqrt_one_minus_alphas_cumprod,
|
| 93 |
+
persistent=False,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def q_sample(
|
| 97 |
+
self,
|
| 98 |
+
x_start: torch.Tensor,
|
| 99 |
+
t: torch.Tensor,
|
| 100 |
+
noise: torch.Tensor = None,
|
| 101 |
+
):
|
| 102 |
+
if noise is None:
|
| 103 |
+
noise = torch.randn_like(x_start)
|
| 104 |
+
assert noise.shape == x_start.shape
|
| 105 |
+
|
| 106 |
+
xt = (
|
| 107 |
+
self.sqrt_alphas_cumprod[t, None, None] * x_start
|
| 108 |
+
+ self.sqrt_one_minus_alphas_cumprod[t, None, None] * noise
|
| 109 |
+
)
|
| 110 |
+
return xt
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class DDIMSampler(nn.Module):
|
| 114 |
+
"""Deterministic DDIM sampler (eta = 0)."""
|
| 115 |
+
|
| 116 |
+
def __init__(self, diffusion: Diffusion):
|
| 117 |
+
super().__init__()
|
| 118 |
+
self.diffusion = diffusion
|
| 119 |
+
|
| 120 |
+
def __call__(
|
| 121 |
+
self,
|
| 122 |
+
use_timesteps: torch.Tensor,
|
| 123 |
+
x_t: torch.Tensor,
|
| 124 |
+
pred_xstart: torch.Tensor,
|
| 125 |
+
t: torch.Tensor,
|
| 126 |
+
) -> torch.Tensor:
|
| 127 |
+
self.diffusion.calc_diffusion_vars(use_timesteps)
|
| 128 |
+
eps = (
|
| 129 |
+
self.diffusion.sqrt_recip_alphas_cumprod[t, None, None] * x_t - pred_xstart
|
| 130 |
+
) / self.diffusion.sqrt_recipm1_alphas_cumprod[t, None, None]
|
| 131 |
+
alpha_bar_prev = self.diffusion.alphas_cumprod_prev[t, None, None]
|
| 132 |
+
x = pred_xstart * torch.sqrt(alpha_bar_prev) + torch.sqrt(1 - alpha_bar_prev) * eps
|
| 133 |
+
return x
|
kimodo/model/kimodo_model.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""Kimodo model: denoiser, text encoder, diffusion sampling, and post-processing."""
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Dict, List, Optional, Tuple, Union
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch import nn
|
| 10 |
+
from tqdm.auto import tqdm
|
| 11 |
+
|
| 12 |
+
from kimodo.constraints import EndEffectorConstraintSet, FullBodyConstraintSet
|
| 13 |
+
from kimodo.motion_rep.feature_utils import compute_heading_angle, length_to_mask
|
| 14 |
+
from kimodo.postprocess import post_process_motion
|
| 15 |
+
from kimodo.sanitize import sanitize_texts
|
| 16 |
+
from kimodo.skeleton import SOMASkeleton30
|
| 17 |
+
from kimodo.tools import to_numpy
|
| 18 |
+
|
| 19 |
+
from .cfg import ClassifierFreeGuidedModel
|
| 20 |
+
from .diffusion import DDIMSampler, Diffusion
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class Kimodo(nn.Module):
|
| 26 |
+
"""Helper class for test time."""
|
| 27 |
+
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
denoiser: nn.Module,
|
| 31 |
+
text_encoder: nn.Module,
|
| 32 |
+
num_base_steps: int,
|
| 33 |
+
device: Optional[Union[str, torch.device]] = None,
|
| 34 |
+
cfg_type: Optional[str] = "separated",
|
| 35 |
+
):
|
| 36 |
+
super().__init__()
|
| 37 |
+
|
| 38 |
+
self.denoiser = denoiser.eval()
|
| 39 |
+
|
| 40 |
+
if cfg_type is None:
|
| 41 |
+
cfg_type = "nocfg"
|
| 42 |
+
|
| 43 |
+
# Add Classifier-free guidance to the model if needed
|
| 44 |
+
self.denoiser = ClassifierFreeGuidedModel(self.denoiser, cfg_type=cfg_type)
|
| 45 |
+
|
| 46 |
+
self.motion_rep = denoiser.motion_rep
|
| 47 |
+
self.skeleton = self.motion_rep.skeleton
|
| 48 |
+
|
| 49 |
+
self.fps = denoiser.motion_rep.fps
|
| 50 |
+
|
| 51 |
+
self.diffusion = Diffusion(num_base_steps=num_base_steps)
|
| 52 |
+
self.sampler = DDIMSampler(self.diffusion)
|
| 53 |
+
self.text_encoder = text_encoder
|
| 54 |
+
|
| 55 |
+
self.device = device
|
| 56 |
+
# for classifier-free guidance
|
| 57 |
+
|
| 58 |
+
self.to(device)
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def output_skeleton(self):
|
| 62 |
+
"""Skeleton used for model output (somaskel77 for SOMA, else unchanged)."""
|
| 63 |
+
if isinstance(self.skeleton, SOMASkeleton30):
|
| 64 |
+
return self.skeleton.somaskel77
|
| 65 |
+
return self.skeleton
|
| 66 |
+
|
| 67 |
+
def train(self, mode: bool):
|
| 68 |
+
self.denoiser.train(mode)
|
| 69 |
+
return self
|
| 70 |
+
|
| 71 |
+
def eval(self):
|
| 72 |
+
self.denoiser.eval()
|
| 73 |
+
return self
|
| 74 |
+
|
| 75 |
+
def denoising_step(
|
| 76 |
+
self,
|
| 77 |
+
motion: torch.Tensor,
|
| 78 |
+
pad_mask: torch.Tensor,
|
| 79 |
+
text_feat: torch.Tensor,
|
| 80 |
+
text_pad_mask: torch.Tensor,
|
| 81 |
+
t: torch.Tensor,
|
| 82 |
+
first_heading_angle: Optional[torch.Tensor],
|
| 83 |
+
motion_mask: torch.Tensor,
|
| 84 |
+
observed_motion: torch.Tensor,
|
| 85 |
+
num_denoising_steps: torch.Tensor,
|
| 86 |
+
cfg_weight: Union[float, Tuple[float, float]],
|
| 87 |
+
guide_masks: Optional[Dict] = None,
|
| 88 |
+
cfg_type: Optional[str] = None,
|
| 89 |
+
) -> torch.Tensor:
|
| 90 |
+
"""Single denoising step.
|
| 91 |
+
|
| 92 |
+
Returns:
|
| 93 |
+
torch.Tensor: [B, T, D] noisy motion input to t-1
|
| 94 |
+
"""
|
| 95 |
+
# subsample timesteps
|
| 96 |
+
# NOTE: do this at every step due to ONNX export, i.e. num_samp_stepsmay change dynamically when
|
| 97 |
+
# running onnx version so need to account for that.
|
| 98 |
+
num_denoising_steps = num_denoising_steps[0]
|
| 99 |
+
use_timesteps, map_tensor = self.diffusion.space_timesteps(num_denoising_steps)
|
| 100 |
+
self.diffusion.calc_diffusion_vars(use_timesteps)
|
| 101 |
+
|
| 102 |
+
# first compute initial clean prediction from denoiser
|
| 103 |
+
t_map = map_tensor[t]
|
| 104 |
+
|
| 105 |
+
with torch.inference_mode():
|
| 106 |
+
pred_clean = self.denoiser(
|
| 107 |
+
cfg_weight,
|
| 108 |
+
motion,
|
| 109 |
+
pad_mask,
|
| 110 |
+
text_feat,
|
| 111 |
+
text_pad_mask,
|
| 112 |
+
t_map,
|
| 113 |
+
first_heading_angle,
|
| 114 |
+
motion_mask,
|
| 115 |
+
observed_motion,
|
| 116 |
+
cfg_type=cfg_type,
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
# sampler computes next step noisy motion
|
| 120 |
+
x_tm1 = self.sampler(use_timesteps, motion, pred_clean, t)
|
| 121 |
+
return x_tm1
|
| 122 |
+
|
| 123 |
+
def _multiprompt(
|
| 124 |
+
self,
|
| 125 |
+
prompts: list[str],
|
| 126 |
+
num_frames: int | list[int],
|
| 127 |
+
num_denoising_steps: int,
|
| 128 |
+
constraint_lst: Optional[list] = [],
|
| 129 |
+
cfg_weight: Optional[float] = [2.0, 2.0],
|
| 130 |
+
num_samples: Optional[int] = None,
|
| 131 |
+
cfg_type: Optional[str] = None,
|
| 132 |
+
return_numpy: bool = False,
|
| 133 |
+
first_heading_angle: Optional[torch.Tensor] = None,
|
| 134 |
+
# for transitioning
|
| 135 |
+
num_transition_frames: int = 5,
|
| 136 |
+
# for postprocess
|
| 137 |
+
post_processing: bool = False,
|
| 138 |
+
root_margin: float = 0.04,
|
| 139 |
+
# progress bar
|
| 140 |
+
progress_bar=tqdm,
|
| 141 |
+
) -> torch.Tensor:
|
| 142 |
+
device = self.device
|
| 143 |
+
|
| 144 |
+
bs = num_samples
|
| 145 |
+
texts = sanitize_texts(prompts)
|
| 146 |
+
|
| 147 |
+
if isinstance(num_frames, int):
|
| 148 |
+
# same duration for all the segments
|
| 149 |
+
num_frames = [num_frames for _ in range(num_samples)]
|
| 150 |
+
|
| 151 |
+
tosqueeze = False
|
| 152 |
+
if num_samples is None:
|
| 153 |
+
num_samples = 1
|
| 154 |
+
tosqueeze = True
|
| 155 |
+
|
| 156 |
+
if constraint_lst is None:
|
| 157 |
+
constraint_lst = []
|
| 158 |
+
|
| 159 |
+
# Generate one chunck at a time
|
| 160 |
+
current_frame = 0
|
| 161 |
+
generated_motions = []
|
| 162 |
+
|
| 163 |
+
for idx, (text, num_frame) in enumerate(zip(texts, num_frames)):
|
| 164 |
+
texts_bs = [text for _ in range(num_samples)]
|
| 165 |
+
|
| 166 |
+
lengths = torch.tensor(
|
| 167 |
+
[num_frame for _ in range(num_samples)],
|
| 168 |
+
device=device,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
is_first_motion = not generated_motions
|
| 172 |
+
|
| 173 |
+
observed_motion, motion_mask = None, None
|
| 174 |
+
|
| 175 |
+
# filter the constraint_lst to only keep the relevent ones
|
| 176 |
+
constraint_lst_base = [
|
| 177 |
+
constraint.crop_move(current_frame, current_frame + num_frame) for constraint in constraint_lst
|
| 178 |
+
] # this move temporally but not spatially
|
| 179 |
+
|
| 180 |
+
observed_motion, motion_mask = self.motion_rep.create_conditions_from_constraints_batched(
|
| 181 |
+
constraint_lst_base,
|
| 182 |
+
lengths,
|
| 183 |
+
to_normalize=False, # don't normalize yet, it needs to be moved around
|
| 184 |
+
device=device,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
if not is_first_motion:
|
| 188 |
+
nb_transition_frames = num_transition_frames
|
| 189 |
+
|
| 190 |
+
if nb_transition_frames < 1:
|
| 191 |
+
raise ValueError(f"num_transition_frames must be at least 1, got {nb_transition_frames}")
|
| 192 |
+
|
| 193 |
+
latest_motions = generated_motions.pop()
|
| 194 |
+
# remove the transition part of A (will be put back afterward)
|
| 195 |
+
generated_motions.append(latest_motions[:, :-nb_transition_frames])
|
| 196 |
+
latest_frames = latest_motions[:, -nb_transition_frames:]
|
| 197 |
+
|
| 198 |
+
last_output = self.motion_rep.inverse(
|
| 199 |
+
latest_frames,
|
| 200 |
+
is_normalized=False,
|
| 201 |
+
return_numpy=False,
|
| 202 |
+
)
|
| 203 |
+
smooth_root_2d = last_output["smooth_root_pos"][..., [0, 2]]
|
| 204 |
+
|
| 205 |
+
# add constraints at the begining to allow natural transitions
|
| 206 |
+
constraint_lst_transition = []
|
| 207 |
+
for batch_id in range(bs):
|
| 208 |
+
new_constraint = FullBodyConstraintSet(
|
| 209 |
+
self.skeleton,
|
| 210 |
+
torch.arange(num_transition_frames),
|
| 211 |
+
last_output["posed_joints"][batch_id, :num_transition_frames],
|
| 212 |
+
last_output["global_rot_mats"][batch_id, :num_transition_frames],
|
| 213 |
+
smooth_root_2d[batch_id, :num_transition_frames],
|
| 214 |
+
)
|
| 215 |
+
# separate end-effector constraint to capture hand/feet rotations
|
| 216 |
+
new_ee_constraint = EndEffectorConstraintSet(
|
| 217 |
+
self.skeleton,
|
| 218 |
+
torch.arange(num_transition_frames),
|
| 219 |
+
last_output["posed_joints"][batch_id, :num_transition_frames],
|
| 220 |
+
last_output["global_rot_mats"][batch_id, :num_transition_frames],
|
| 221 |
+
smooth_root_2d[batch_id, :num_transition_frames],
|
| 222 |
+
joint_names=["LeftHand", "RightHand", "LeftFoot", "RightFoot"],
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
constraint_lst_transition.append([new_constraint, new_ee_constraint])
|
| 226 |
+
|
| 227 |
+
transition_lengths = torch.tensor(
|
| 228 |
+
[nb_transition_frames for _ in range(num_samples)],
|
| 229 |
+
device=device,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
observed_motion_transition, motion_mask_transition = (
|
| 233 |
+
self.motion_rep.create_conditions_from_constraints_batched(
|
| 234 |
+
constraint_lst_transition,
|
| 235 |
+
transition_lengths,
|
| 236 |
+
to_normalize=False, # don't normalize yet
|
| 237 |
+
device=device,
|
| 238 |
+
)
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# concatenate the obversed motion / motion mask
|
| 242 |
+
observed_motion = torch.cat([observed_motion_transition, observed_motion], axis=1)
|
| 243 |
+
motion_mask = torch.cat([motion_mask_transition, motion_mask], axis=1)
|
| 244 |
+
|
| 245 |
+
# we need to move each observed motion in the batch to the new starting points
|
| 246 |
+
last_smooth_root_2d = smooth_root_2d[:, 0]
|
| 247 |
+
observed_motion = self.motion_rep.translate_2d(
|
| 248 |
+
observed_motion, -last_smooth_root_2d
|
| 249 |
+
) # equivalent to: self.motion_rep.translate_2d_to_zero(observed_motion)
|
| 250 |
+
|
| 251 |
+
# remove dummy values after moving
|
| 252 |
+
observed_motion = observed_motion * motion_mask
|
| 253 |
+
|
| 254 |
+
lengths = lengths + transition_lengths
|
| 255 |
+
first_heading_angle = compute_heading_angle(last_output["posed_joints"], self.skeleton)[:, 0]
|
| 256 |
+
else:
|
| 257 |
+
if first_heading_angle is None:
|
| 258 |
+
# Start at 0 angle, but this will change afterward
|
| 259 |
+
first_heading_angle = torch.tensor([0.0] * bs, device=device)
|
| 260 |
+
else:
|
| 261 |
+
first_heading_angle = torch.as_tensor(first_heading_angle, device=device)
|
| 262 |
+
if first_heading_angle.numel() == 1:
|
| 263 |
+
first_heading_angle = first_heading_angle.repeat(bs)
|
| 264 |
+
|
| 265 |
+
observed_motion = self.motion_rep.normalize(observed_motion)
|
| 266 |
+
|
| 267 |
+
max_frames = max(lengths)
|
| 268 |
+
motion_pad_mask = length_to_mask(lengths)
|
| 269 |
+
|
| 270 |
+
motion = self._generate(
|
| 271 |
+
texts_bs,
|
| 272 |
+
max_frames,
|
| 273 |
+
num_denoising_steps=num_denoising_steps,
|
| 274 |
+
pad_mask=motion_pad_mask,
|
| 275 |
+
first_heading_angle=first_heading_angle,
|
| 276 |
+
motion_mask=motion_mask,
|
| 277 |
+
observed_motion=observed_motion,
|
| 278 |
+
cfg_weight=cfg_weight,
|
| 279 |
+
cfg_type=cfg_type,
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
motion = self.motion_rep.unnormalize(motion)
|
| 283 |
+
|
| 284 |
+
if not is_first_motion:
|
| 285 |
+
motion_with_transition = self.motion_rep.translate_2d(
|
| 286 |
+
motion,
|
| 287 |
+
last_smooth_root_2d,
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
if post_processing:
|
| 291 |
+
# Per-segment postprocessing: inverse, postprocess, re-encode.
|
| 292 |
+
# The full transition+segment is postprocessed together so the
|
| 293 |
+
# transition constraints keep the junction smooth.
|
| 294 |
+
seg_output = self.motion_rep.inverse(
|
| 295 |
+
motion_with_transition, is_normalized=False, return_numpy=False,
|
| 296 |
+
)
|
| 297 |
+
seg_constraints = [list(cl) for cl in constraint_lst_transition]
|
| 298 |
+
for bi in range(bs):
|
| 299 |
+
seg_constraints[bi].extend(
|
| 300 |
+
[c.crop_move(current_frame - nb_transition_frames,
|
| 301 |
+
current_frame - nb_transition_frames + num_frame + nb_transition_frames)
|
| 302 |
+
for c in constraint_lst]
|
| 303 |
+
)
|
| 304 |
+
corrected = post_process_motion(
|
| 305 |
+
seg_output["local_rot_mats"],
|
| 306 |
+
seg_output["root_positions"],
|
| 307 |
+
seg_output["foot_contacts"],
|
| 308 |
+
self.skeleton,
|
| 309 |
+
seg_constraints,
|
| 310 |
+
root_margin=root_margin,
|
| 311 |
+
)
|
| 312 |
+
seg_output.update(corrected)
|
| 313 |
+
motion = self.motion_rep(
|
| 314 |
+
seg_output["local_rot_mats"],
|
| 315 |
+
seg_output["root_positions"],
|
| 316 |
+
to_normalize=False,
|
| 317 |
+
lengths=lengths,
|
| 318 |
+
)
|
| 319 |
+
else:
|
| 320 |
+
motion = motion_with_transition[:, num_transition_frames:]
|
| 321 |
+
transition_frames = motion_with_transition[:, :num_transition_frames]
|
| 322 |
+
|
| 323 |
+
# linearly combine the previously generated transitions with the newly generated ones
|
| 324 |
+
alpha = torch.linspace(1, 0, num_transition_frames, device=device)[:, None]
|
| 325 |
+
new_transition_frames = (
|
| 326 |
+
latest_frames[:, :num_transition_frames] * alpha + (1 - alpha) * transition_frames
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
# add new transitions frames for A (merging with B prediction of the history)
|
| 330 |
+
generated_motions.append(new_transition_frames)
|
| 331 |
+
|
| 332 |
+
elif post_processing:
|
| 333 |
+
# First segment: postprocess immediately
|
| 334 |
+
seg_output = self.motion_rep.inverse(
|
| 335 |
+
motion, is_normalized=False, return_numpy=False,
|
| 336 |
+
)
|
| 337 |
+
seg_constraints = constraint_lst_base if constraint_lst_base else []
|
| 338 |
+
corrected = post_process_motion(
|
| 339 |
+
seg_output["local_rot_mats"],
|
| 340 |
+
seg_output["root_positions"],
|
| 341 |
+
seg_output["foot_contacts"],
|
| 342 |
+
self.skeleton,
|
| 343 |
+
seg_constraints,
|
| 344 |
+
root_margin=root_margin,
|
| 345 |
+
)
|
| 346 |
+
seg_output.update(corrected)
|
| 347 |
+
motion = self.motion_rep(
|
| 348 |
+
seg_output["local_rot_mats"],
|
| 349 |
+
seg_output["root_positions"],
|
| 350 |
+
to_normalize=False,
|
| 351 |
+
lengths=lengths,
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
generated_motions.append(motion)
|
| 355 |
+
current_frame += num_frame
|
| 356 |
+
|
| 357 |
+
generated_motions = torch.cat(generated_motions, axis=1) # temporal axis (b, t, d)
|
| 358 |
+
|
| 359 |
+
if tosqueeze:
|
| 360 |
+
generated_motions = generated_motions[0]
|
| 361 |
+
|
| 362 |
+
output = self.motion_rep.inverse(
|
| 363 |
+
generated_motions,
|
| 364 |
+
is_normalized=False,
|
| 365 |
+
return_numpy=False,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Post-processing: already applied per-segment inside the loop above,
|
| 369 |
+
# so no additional post-processing pass is needed here.
|
| 370 |
+
|
| 371 |
+
# Convert SOMA output to somaskel77 for external API
|
| 372 |
+
if isinstance(self.skeleton, SOMASkeleton30):
|
| 373 |
+
output = self.skeleton.output_to_SOMASkeleton77(output)
|
| 374 |
+
|
| 375 |
+
# Convert to numpy if requested
|
| 376 |
+
if return_numpy:
|
| 377 |
+
output = to_numpy(output)
|
| 378 |
+
return output
|
| 379 |
+
|
| 380 |
+
def __call__(
|
| 381 |
+
self,
|
| 382 |
+
prompts: str | list[str],
|
| 383 |
+
num_frames: int | list[int],
|
| 384 |
+
num_denoising_steps: int,
|
| 385 |
+
multi_prompt: bool = False,
|
| 386 |
+
constraint_lst: Optional[list] = [],
|
| 387 |
+
cfg_weight: Optional[float] = [2.0, 2.0],
|
| 388 |
+
num_samples: Optional[int] = None,
|
| 389 |
+
cfg_type: Optional[str] = None,
|
| 390 |
+
return_numpy: bool = False,
|
| 391 |
+
first_heading_angle: Optional[torch.Tensor] = None,
|
| 392 |
+
# for transitioning
|
| 393 |
+
num_transition_frames: int = 5,
|
| 394 |
+
# for postprocess
|
| 395 |
+
post_processing: bool = False,
|
| 396 |
+
root_margin: float = 0.04,
|
| 397 |
+
# progress bar
|
| 398 |
+
progress_bar=tqdm,
|
| 399 |
+
) -> dict:
|
| 400 |
+
"""Generate motion from text prompts and optional kinematic constraints.
|
| 401 |
+
|
| 402 |
+
When a single prompt/num_frames pair is given, one motion is generated.
|
| 403 |
+
Passing lists of prompts and/or num_frames produces a batch of
|
| 404 |
+
independent motions. With ``multi_prompt=True``, the prompts are
|
| 405 |
+
treated as sequential segments that are generated and stitched together
|
| 406 |
+
with smooth transitions.
|
| 407 |
+
|
| 408 |
+
Args:
|
| 409 |
+
prompts: One or more text descriptions of the desired motion.
|
| 410 |
+
A single string generates one sample; a list generates a batch
|
| 411 |
+
(or sequential segments when ``multi_prompt=True``).
|
| 412 |
+
num_frames: Duration of the generated motion in frames. Can be a
|
| 413 |
+
single int applied to every prompt or a per-prompt list.
|
| 414 |
+
num_denoising_steps: Number of DDIM denoising steps. More steps
|
| 415 |
+
generally improve quality at the cost of speed.
|
| 416 |
+
multi_prompt: If ``True``, treat ``prompts`` as an ordered sequence
|
| 417 |
+
of segments and concatenate them with transitions.
|
| 418 |
+
constraint_lst: Per-sample list of kinematic constraints (e.g.
|
| 419 |
+
keyframe poses, end-effector targets, 2-D paths). Pass an
|
| 420 |
+
empty list for unconstrained generation.
|
| 421 |
+
cfg_weight: Classifier-free guidance scale(s). A two-element list
|
| 422 |
+
``[text_cfg, constraint_cfg]`` controls text and constraint
|
| 423 |
+
guidance independently.
|
| 424 |
+
num_samples: Number of samples to generate.
|
| 425 |
+
cfg_type: Override the default CFG strategy set at init
|
| 426 |
+
(e.g. ``"separated"``).
|
| 427 |
+
return_numpy: If ``True``, convert all output tensors to numpy
|
| 428 |
+
arrays.
|
| 429 |
+
first_heading_angle: Initial body heading in radians. Shape
|
| 430 |
+
``(B,)`` or scalar. Defaults to ``0`` (facing +Z).
|
| 431 |
+
num_transition_frames: Number of overlapping frames used to blend
|
| 432 |
+
consecutive segments in multi-prompt mode.
|
| 433 |
+
post_processing: If ``True``, apply post-processing
|
| 434 |
+
(foot-skate cleanup and constraint enforcement).
|
| 435 |
+
root_margin: Horizontal margin (in meters) used by the post-processor
|
| 436 |
+
to determine when to correct root motion. When root deviates more than
|
| 437 |
+
margin from the constraint, the post-processor will correct it.
|
| 438 |
+
progress_bar: Callable wrapping an iterable to display progress
|
| 439 |
+
(default: ``tqdm``). Pass a no-op to silence output.
|
| 440 |
+
|
| 441 |
+
Returns:
|
| 442 |
+
dict: A dictionary of motion tensors (or numpy arrays if
|
| 443 |
+
``return_numpy=True``) with the following keys:
|
| 444 |
+
|
| 445 |
+
- ``local_rot_mats`` – Local joint rotations as rotation matrices.
|
| 446 |
+
- ``global_rot_mats`` – Global joint rotations as rotation matrices.
|
| 447 |
+
- ``posed_joints`` – Joint positions in world space.
|
| 448 |
+
- ``root_positions`` – Root joint positions.
|
| 449 |
+
- ``smooth_root_pos`` – Smoothed root trajectory.
|
| 450 |
+
- ``foot_contacts`` – Boolean foot-contact labels [left heel, left toe, right heel, right toe].
|
| 451 |
+
- ``global_root_heading`` – Root heading angle over time.
|
| 452 |
+
"""
|
| 453 |
+
device = self.device
|
| 454 |
+
|
| 455 |
+
if multi_prompt:
|
| 456 |
+
# multi prompt generation
|
| 457 |
+
return self._multiprompt(
|
| 458 |
+
prompts,
|
| 459 |
+
num_frames,
|
| 460 |
+
num_denoising_steps,
|
| 461 |
+
constraint_lst,
|
| 462 |
+
cfg_weight,
|
| 463 |
+
num_samples,
|
| 464 |
+
cfg_type,
|
| 465 |
+
return_numpy,
|
| 466 |
+
first_heading_angle,
|
| 467 |
+
num_transition_frames,
|
| 468 |
+
post_processing,
|
| 469 |
+
root_margin,
|
| 470 |
+
progress_bar,
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
# Input checking
|
| 474 |
+
tosqueeze = False
|
| 475 |
+
if isinstance(prompts, list) and isinstance(num_frames, list):
|
| 476 |
+
assert len(prompts) == len(num_frames), "The number of prompts should match the number of num_frames."
|
| 477 |
+
num_samples = len(prompts)
|
| 478 |
+
elif isinstance(prompts, list):
|
| 479 |
+
num_samples = len(prompts)
|
| 480 |
+
num_frames = [num_frames for _ in range(num_samples)]
|
| 481 |
+
elif isinstance(num_frames, list):
|
| 482 |
+
num_samples = len(num_frames)
|
| 483 |
+
prompts = [prompts for _ in range(num_samples)]
|
| 484 |
+
else:
|
| 485 |
+
if num_samples is None:
|
| 486 |
+
tosqueeze = True
|
| 487 |
+
num_samples = 1
|
| 488 |
+
prompts = [prompts for _ in range(num_samples)]
|
| 489 |
+
num_frames = [num_frames for _ in range(num_samples)]
|
| 490 |
+
|
| 491 |
+
bs = num_samples
|
| 492 |
+
texts = sanitize_texts(prompts)
|
| 493 |
+
|
| 494 |
+
lengths = torch.tensor(
|
| 495 |
+
num_frames,
|
| 496 |
+
device=device,
|
| 497 |
+
)
|
| 498 |
+
max_frames = max(lengths)
|
| 499 |
+
motion_pad_mask = length_to_mask(lengths)
|
| 500 |
+
|
| 501 |
+
if first_heading_angle is None:
|
| 502 |
+
# Start at 0 angle
|
| 503 |
+
first_heading_angle = torch.tensor([0.0] * bs, device=device)
|
| 504 |
+
else:
|
| 505 |
+
first_heading_angle = torch.as_tensor(first_heading_angle, device=device)
|
| 506 |
+
if first_heading_angle.numel() == 1:
|
| 507 |
+
first_heading_angle = first_heading_angle.repeat(bs)
|
| 508 |
+
|
| 509 |
+
observed_motion, motion_mask = None, None
|
| 510 |
+
if constraint_lst:
|
| 511 |
+
observed_motion, motion_mask = self.motion_rep.create_conditions_from_constraints_batched(
|
| 512 |
+
constraint_lst,
|
| 513 |
+
lengths,
|
| 514 |
+
to_normalize=True,
|
| 515 |
+
device=device,
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
motion = self._generate(
|
| 519 |
+
texts,
|
| 520 |
+
max_frames,
|
| 521 |
+
num_denoising_steps=num_denoising_steps,
|
| 522 |
+
pad_mask=motion_pad_mask,
|
| 523 |
+
first_heading_angle=first_heading_angle,
|
| 524 |
+
motion_mask=motion_mask,
|
| 525 |
+
observed_motion=observed_motion,
|
| 526 |
+
cfg_weight=cfg_weight,
|
| 527 |
+
cfg_type=cfg_type,
|
| 528 |
+
progress_bar=progress_bar,
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
if tosqueeze:
|
| 532 |
+
motion = motion[0]
|
| 533 |
+
|
| 534 |
+
output = self.motion_rep.inverse(
|
| 535 |
+
motion,
|
| 536 |
+
is_normalized=True,
|
| 537 |
+
return_numpy=False, # Keep as tensor for potential post-processing
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
# Apply post-processing if requested
|
| 541 |
+
if post_processing:
|
| 542 |
+
corrected = post_process_motion(
|
| 543 |
+
output["local_rot_mats"],
|
| 544 |
+
output["root_positions"],
|
| 545 |
+
output["foot_contacts"],
|
| 546 |
+
self.skeleton,
|
| 547 |
+
constraint_lst,
|
| 548 |
+
root_margin=root_margin,
|
| 549 |
+
)
|
| 550 |
+
# key frame outputs / foot contacts are not changed
|
| 551 |
+
output.update(corrected)
|
| 552 |
+
|
| 553 |
+
# Convert SOMA output to somaskel77 for external API
|
| 554 |
+
if isinstance(self.skeleton, SOMASkeleton30):
|
| 555 |
+
output = self.skeleton.output_to_SOMASkeleton77(output)
|
| 556 |
+
|
| 557 |
+
# Convert to numpy if requested
|
| 558 |
+
if return_numpy:
|
| 559 |
+
output = to_numpy(output)
|
| 560 |
+
return output
|
| 561 |
+
|
| 562 |
+
def _generate(
|
| 563 |
+
self,
|
| 564 |
+
texts: List[str],
|
| 565 |
+
max_frames: int,
|
| 566 |
+
num_denoising_steps: int,
|
| 567 |
+
pad_mask: torch.Tensor,
|
| 568 |
+
first_heading_angle: Optional[torch.Tensor],
|
| 569 |
+
motion_mask: torch.Tensor,
|
| 570 |
+
observed_motion: torch.Tensor,
|
| 571 |
+
cfg_weight: Optional[float] = 2.0,
|
| 572 |
+
text_feat: Optional[torch.Tensor] = None,
|
| 573 |
+
text_pad_mask: Optional[torch.Tensor] = None,
|
| 574 |
+
guide_masks: Optional[Dict] = None,
|
| 575 |
+
cfg_type: Optional[str] = None,
|
| 576 |
+
progress_bar=tqdm,
|
| 577 |
+
) -> torch.Tensor:
|
| 578 |
+
"""Sample full denoising loop.
|
| 579 |
+
|
| 580 |
+
Args:
|
| 581 |
+
texts (List[str]): batch of text prompts to use for sampling (if text_feat is not passed in)
|
| 582 |
+
"""
|
| 583 |
+
|
| 584 |
+
device = self.device
|
| 585 |
+
if text_feat is None:
|
| 586 |
+
assert text_pad_mask is None
|
| 587 |
+
log.info("Encoding text...")
|
| 588 |
+
text_feat, text_length = self.text_encoder(texts)
|
| 589 |
+
text_feat = text_feat.to(device)
|
| 590 |
+
|
| 591 |
+
# handle empty string (set to zero)
|
| 592 |
+
empty_text_mask = [len(text.strip()) == 0 for text in texts]
|
| 593 |
+
text_feat[empty_text_mask] = 0
|
| 594 |
+
|
| 595 |
+
# Create the pad mask for the text
|
| 596 |
+
batch_size, maxlen = text_feat.shape[:2]
|
| 597 |
+
tensor_text_length = torch.tensor(text_length, device=device)
|
| 598 |
+
tensor_text_length[empty_text_mask] = 0
|
| 599 |
+
text_pad_mask = torch.arange(maxlen, device=device).expand(batch_size, maxlen) < tensor_text_length[:, None]
|
| 600 |
+
|
| 601 |
+
if motion_mask is not None:
|
| 602 |
+
if motion_mask.dtype == torch.bool:
|
| 603 |
+
motion_mask = 1 * motion_mask
|
| 604 |
+
|
| 605 |
+
batch_size = text_feat.shape[0]
|
| 606 |
+
|
| 607 |
+
# sample loop
|
| 608 |
+
indices = list(range(num_denoising_steps))[::-1]
|
| 609 |
+
shape = (batch_size, max_frames, self.motion_rep.motion_rep_dim)
|
| 610 |
+
cur_mot = torch.randn(shape, device=self.device)
|
| 611 |
+
num_denoising_steps = torch.tensor(
|
| 612 |
+
[num_denoising_steps], device=self.device
|
| 613 |
+
) # this and t need to be tensor for onnx export
|
| 614 |
+
# init diffusion with correct num steps before looping
|
| 615 |
+
use_timesteps = self.diffusion.space_timesteps(num_denoising_steps[0])[0]
|
| 616 |
+
self.diffusion.calc_diffusion_vars(use_timesteps)
|
| 617 |
+
for i in progress_bar(indices):
|
| 618 |
+
t = torch.tensor([i] * cur_mot.size(0), device=self.device)
|
| 619 |
+
with torch.inference_mode():
|
| 620 |
+
cur_mot = self.denoising_step(
|
| 621 |
+
cur_mot,
|
| 622 |
+
pad_mask,
|
| 623 |
+
text_feat,
|
| 624 |
+
text_pad_mask,
|
| 625 |
+
t,
|
| 626 |
+
first_heading_angle,
|
| 627 |
+
motion_mask,
|
| 628 |
+
observed_motion,
|
| 629 |
+
num_denoising_steps,
|
| 630 |
+
cfg_weight,
|
| 631 |
+
guide_masks=guide_masks,
|
| 632 |
+
cfg_type=cfg_type,
|
| 633 |
+
)
|
| 634 |
+
return cur_mot
|
kimodo/model/llm2vec/README.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
This is a patched version of the original [LLM2Vec](https://github.com/McGill-NLP/llm2vec) codebase so that `McGill-NLP/LLM2Vec-Meta-Llama-3-8B-Instruct-mntp-supervised` works with `transformers==5.0.0rc3`.
|
kimodo/model/llm2vec/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""LLM2Vec text encoder and wrapper for Kimodo."""
|
| 4 |
+
|
| 5 |
+
from .llm2vec import LLM2Vec
|
| 6 |
+
from .llm2vec_wrapper import LLM2VecEncoder
|
| 7 |
+
|
| 8 |
+
__all__ = [
|
| 9 |
+
"LLM2Vec",
|
| 10 |
+
"LLM2VecEncoder",
|
| 11 |
+
]
|
kimodo/model/llm2vec/llm2vec.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2024 McGill NLP
|
| 2 |
+
# SPDX-License-Identifier: MIT
|
| 3 |
+
#
|
| 4 |
+
# Permission is hereby granted, free of charge, to any person obtaining a
|
| 5 |
+
# copy of this software and associated documentation files (the "Software"),
|
| 6 |
+
# to deal in the Software without restriction, including without limitation
|
| 7 |
+
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
| 8 |
+
# and/or sell copies of the Software, and to permit persons to whom the
|
| 9 |
+
# Software is furnished to do so, subject to the following conditions:
|
| 10 |
+
#
|
| 11 |
+
# The above copyright notice and this permission notice shall be included in
|
| 12 |
+
# all copies or substantial portions of the Software.
|
| 13 |
+
#
|
| 14 |
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 15 |
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 16 |
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
| 17 |
+
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 18 |
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
| 19 |
+
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
| 20 |
+
# DEALINGS IN THE SOFTWARE.
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 24 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 25 |
+
#
|
| 26 |
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
| 27 |
+
# you may not use this file except in compliance with the License.
|
| 28 |
+
# You may obtain a copy of the License at
|
| 29 |
+
#
|
| 30 |
+
# http://www.apache.org/licenses/LICENSE-2.0
|
| 31 |
+
#
|
| 32 |
+
# Unless required by applicable law or agreed to in writing, software
|
| 33 |
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
| 34 |
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 35 |
+
# See the License for the specific language governing permissions and
|
| 36 |
+
# limitations under the License.
|
| 37 |
+
|
| 38 |
+
import json
|
| 39 |
+
import logging
|
| 40 |
+
import os
|
| 41 |
+
from functools import partial
|
| 42 |
+
from typing import Dict, List, Optional, Union
|
| 43 |
+
|
| 44 |
+
import numpy as np
|
| 45 |
+
import torch
|
| 46 |
+
import torch.multiprocessing as mp
|
| 47 |
+
from peft import PeftModel
|
| 48 |
+
from torch import Tensor, device, nn
|
| 49 |
+
from tqdm.autonotebook import tqdm, trange
|
| 50 |
+
from transformers import (
|
| 51 |
+
AutoConfig,
|
| 52 |
+
AutoModel,
|
| 53 |
+
AutoTokenizer,
|
| 54 |
+
GemmaConfig,
|
| 55 |
+
LlamaConfig,
|
| 56 |
+
MistralConfig,
|
| 57 |
+
PretrainedConfig,
|
| 58 |
+
Qwen2Config,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
logger = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def batch_to_device(batch, target_device: device):
|
| 65 |
+
"""Send a pytorch batch to a device (CPU/GPU)"""
|
| 66 |
+
for key in batch:
|
| 67 |
+
if isinstance(batch[key], Tensor):
|
| 68 |
+
batch[key] = batch[key].to(target_device)
|
| 69 |
+
return batch
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class LLM2Vec(nn.Module):
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
model: AutoModel,
|
| 76 |
+
tokenizer: AutoTokenizer,
|
| 77 |
+
pooling_mode: str = "mean",
|
| 78 |
+
max_length: int = 512,
|
| 79 |
+
doc_max_length: int = 400,
|
| 80 |
+
skip_instruction: bool = True,
|
| 81 |
+
):
|
| 82 |
+
super().__init__()
|
| 83 |
+
self.model = model
|
| 84 |
+
self.tokenizer = tokenizer
|
| 85 |
+
self.pooling_mode = pooling_mode
|
| 86 |
+
self.skip_instruction = skip_instruction
|
| 87 |
+
self.max_length = max_length
|
| 88 |
+
self.doc_max_length = doc_max_length
|
| 89 |
+
self.config = model.config
|
| 90 |
+
|
| 91 |
+
@classmethod
|
| 92 |
+
def _get_model_class(cls, config_class_name, enable_bidirectional):
|
| 93 |
+
if not enable_bidirectional:
|
| 94 |
+
return AutoModel
|
| 95 |
+
if config_class_name == "MistralConfig":
|
| 96 |
+
from .models.bidirectional_mistral import MistralBiModel
|
| 97 |
+
|
| 98 |
+
return MistralBiModel
|
| 99 |
+
elif config_class_name == "LlamaConfig":
|
| 100 |
+
from .models.bidirectional_llama import LlamaBiModel
|
| 101 |
+
|
| 102 |
+
return LlamaBiModel
|
| 103 |
+
elif config_class_name == "GemmaConfig":
|
| 104 |
+
from .models.bidirectional_gemma import GemmaBiModel
|
| 105 |
+
|
| 106 |
+
return GemmaBiModel
|
| 107 |
+
elif config_class_name == "Qwen2Config":
|
| 108 |
+
from .models.bidirectional_qwen2 import Qwen2BiModel
|
| 109 |
+
|
| 110 |
+
return Qwen2BiModel
|
| 111 |
+
else:
|
| 112 |
+
raise ValueError(f"{config_class_name} is not supported yet with bidirectional models.")
|
| 113 |
+
|
| 114 |
+
@classmethod
|
| 115 |
+
def from_pretrained(
|
| 116 |
+
cls,
|
| 117 |
+
base_model_name_or_path,
|
| 118 |
+
peft_model_name_or_path=None,
|
| 119 |
+
merge_peft=False,
|
| 120 |
+
enable_bidirectional=True,
|
| 121 |
+
**kwargs,
|
| 122 |
+
):
|
| 123 |
+
# pop out encoder args
|
| 124 |
+
keys = ["pooling_mode", "max_length", "doc_max_length", "skip_instruction"]
|
| 125 |
+
encoder_args = {key: kwargs.pop(key, None) for key in keys if kwargs.get(key) is not None}
|
| 126 |
+
|
| 127 |
+
tokenizer = AutoTokenizer.from_pretrained(base_model_name_or_path)
|
| 128 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 129 |
+
tokenizer.padding_side = "left"
|
| 130 |
+
|
| 131 |
+
config = AutoConfig.from_pretrained(base_model_name_or_path)
|
| 132 |
+
config_class_name = config.__class__.__name__
|
| 133 |
+
|
| 134 |
+
model_class = cls._get_model_class(config_class_name, enable_bidirectional=enable_bidirectional)
|
| 135 |
+
|
| 136 |
+
model = model_class.from_pretrained(base_model_name_or_path, **kwargs)
|
| 137 |
+
|
| 138 |
+
if os.path.isdir(base_model_name_or_path) and os.path.exists(f"{base_model_name_or_path}/config.json"):
|
| 139 |
+
with open(f"{base_model_name_or_path}/config.json", "r") as fIn:
|
| 140 |
+
config_dict = json.load(fIn)
|
| 141 |
+
config = PretrainedConfig.from_dict(config_dict)
|
| 142 |
+
model.config._name_or_path = config._name_or_path
|
| 143 |
+
|
| 144 |
+
# For special case where config.json and adapter weights are in the same directory
|
| 145 |
+
if hasattr(model, "peft_config"):
|
| 146 |
+
model = PeftModel.from_pretrained(
|
| 147 |
+
model,
|
| 148 |
+
base_model_name_or_path,
|
| 149 |
+
)
|
| 150 |
+
model = model.merge_and_unload()
|
| 151 |
+
|
| 152 |
+
if peft_model_name_or_path is not None:
|
| 153 |
+
model = PeftModel.from_pretrained(
|
| 154 |
+
model,
|
| 155 |
+
peft_model_name_or_path,
|
| 156 |
+
)
|
| 157 |
+
if merge_peft:
|
| 158 |
+
model = model.merge_and_unload()
|
| 159 |
+
|
| 160 |
+
config = {}
|
| 161 |
+
config_addr = peft_model_name_or_path if peft_model_name_or_path is not None else base_model_name_or_path
|
| 162 |
+
if os.path.exists(f"{config_addr}/llm2vec_config.json"):
|
| 163 |
+
with open(f"{config_addr}/llm2vec_config.json", "r") as fIn:
|
| 164 |
+
llm2vec_config = json.load(fIn)
|
| 165 |
+
config.update(llm2vec_config)
|
| 166 |
+
|
| 167 |
+
for key, value in encoder_args.items():
|
| 168 |
+
config[key] = value
|
| 169 |
+
|
| 170 |
+
return cls(model=model, tokenizer=tokenizer, **config)
|
| 171 |
+
|
| 172 |
+
def prepare_for_tokenization(self, text):
|
| 173 |
+
if self.model.config._name_or_path == "meta-llama/Meta-Llama-3-8B-Instruct":
|
| 174 |
+
text = "<|start_header_id|>user<|end_header_id|>\n\n" + text.strip() + "<|eot_id|>"
|
| 175 |
+
return text
|
| 176 |
+
if self.model.config._name_or_path in [
|
| 177 |
+
"mistralai/Mistral-7B-Instruct-v0.2",
|
| 178 |
+
"meta-llama/Llama-2-7b-chat-hf",
|
| 179 |
+
]:
|
| 180 |
+
text = "[INST] " + text.strip() + " [/INST]"
|
| 181 |
+
if self.model.config._name_or_path in [
|
| 182 |
+
"google/gemma-2-9b-it",
|
| 183 |
+
]:
|
| 184 |
+
text = "<bos><start_of_turn>user\n" + text.strip() + "<end_of_turn>"
|
| 185 |
+
if self.model.config._name_or_path in [
|
| 186 |
+
"Qwen/Qwen2-1.5B-Instruct",
|
| 187 |
+
"Qwen/Qwen2-7B-Instruct",
|
| 188 |
+
]:
|
| 189 |
+
text = "<|im_start|>user\n" + text.strip() + "<|im_end|>"
|
| 190 |
+
if self.pooling_mode == "eos_token":
|
| 191 |
+
if self.model.config._name_or_path == "meta-llama/Meta-Llama-3-8B":
|
| 192 |
+
text = text.strip() + "<|end_of_text|>"
|
| 193 |
+
elif isinstance(self.model.config, LlamaConfig) or isinstance(self.model.config, MistralConfig):
|
| 194 |
+
text = text.strip() + " </s>"
|
| 195 |
+
elif isinstance(self.model.config, GemmaConfig):
|
| 196 |
+
text = text.strip() + "<eos>"
|
| 197 |
+
elif isinstance(self.model.config, Qwen2Config):
|
| 198 |
+
text = text.strip() + "<|endoftext|>"
|
| 199 |
+
return text
|
| 200 |
+
|
| 201 |
+
def tokenize(self, texts):
|
| 202 |
+
texts_2 = []
|
| 203 |
+
original_texts = []
|
| 204 |
+
for text in texts:
|
| 205 |
+
t = text.split("!@#$%^&*()")
|
| 206 |
+
texts_2.append(t[1] if len(t) > 1 else "")
|
| 207 |
+
original_texts.append("".join(t))
|
| 208 |
+
|
| 209 |
+
original = self.tokenizer(
|
| 210 |
+
original_texts,
|
| 211 |
+
return_tensors="pt",
|
| 212 |
+
padding=True,
|
| 213 |
+
truncation=True,
|
| 214 |
+
max_length=self.max_length,
|
| 215 |
+
)
|
| 216 |
+
embed_mask = None
|
| 217 |
+
for t_i, t in enumerate(texts_2):
|
| 218 |
+
ids = self.tokenizer(
|
| 219 |
+
[t],
|
| 220 |
+
return_tensors="pt",
|
| 221 |
+
padding=True,
|
| 222 |
+
truncation=True,
|
| 223 |
+
max_length=self.max_length,
|
| 224 |
+
add_special_tokens=False,
|
| 225 |
+
)
|
| 226 |
+
if embed_mask is None:
|
| 227 |
+
e_m = torch.zeros_like(original["attention_mask"][t_i])
|
| 228 |
+
if len(ids["input_ids"][0]) > 0:
|
| 229 |
+
e_m[-len(ids["input_ids"][0]) :] = torch.ones(len(ids["input_ids"][0]))
|
| 230 |
+
embed_mask = e_m.unsqueeze(0)
|
| 231 |
+
else:
|
| 232 |
+
e_m = torch.zeros_like(original["attention_mask"][t_i])
|
| 233 |
+
if len(ids["input_ids"][0]) > 0:
|
| 234 |
+
e_m[-len(ids["input_ids"][0]) :] = torch.ones(len(ids["input_ids"][0]))
|
| 235 |
+
embed_mask = torch.cat((embed_mask, e_m.unsqueeze(0)), dim=0)
|
| 236 |
+
|
| 237 |
+
original["embed_mask"] = embed_mask
|
| 238 |
+
return original
|
| 239 |
+
|
| 240 |
+
def _skip_instruction(self, sentence_feature):
|
| 241 |
+
assert sentence_feature["attention_mask"].shape == sentence_feature["embed_mask"].shape
|
| 242 |
+
sentence_feature["attention_mask"] = sentence_feature["embed_mask"]
|
| 243 |
+
|
| 244 |
+
def forward(self, sentence_feature: Dict[str, Tensor]):
|
| 245 |
+
embed_mask = None
|
| 246 |
+
if "embed_mask" in sentence_feature:
|
| 247 |
+
embed_mask = sentence_feature.pop("embed_mask")
|
| 248 |
+
reps = self.model(**sentence_feature)
|
| 249 |
+
sentence_feature["embed_mask"] = embed_mask
|
| 250 |
+
|
| 251 |
+
return self.get_pooling(sentence_feature, reps.last_hidden_state)
|
| 252 |
+
|
| 253 |
+
def get_pooling(self, features, last_hidden_states): # All models padded from left
|
| 254 |
+
assert self.tokenizer.padding_side == "left", "Pooling modes are implemented for padding from left."
|
| 255 |
+
if self.skip_instruction:
|
| 256 |
+
self._skip_instruction(features)
|
| 257 |
+
seq_lengths = features["attention_mask"].sum(dim=-1)
|
| 258 |
+
if self.pooling_mode == "mean":
|
| 259 |
+
return torch.stack(
|
| 260 |
+
[last_hidden_states[i, -length:, :].mean(dim=0) for i, length in enumerate(seq_lengths)],
|
| 261 |
+
dim=0,
|
| 262 |
+
)
|
| 263 |
+
elif self.pooling_mode == "weighted_mean":
|
| 264 |
+
bs, l, _ = last_hidden_states.shape
|
| 265 |
+
complete_weights = torch.zeros(bs, l, device=last_hidden_states.device)
|
| 266 |
+
for i, seq_l in enumerate(seq_lengths):
|
| 267 |
+
if seq_l > 0:
|
| 268 |
+
complete_weights[i, -seq_l:] = torch.arange(seq_l) + 1
|
| 269 |
+
complete_weights[i] /= torch.clamp(complete_weights[i].sum(), min=1e-9)
|
| 270 |
+
return torch.sum(last_hidden_states * complete_weights.unsqueeze(-1), dim=1)
|
| 271 |
+
elif self.pooling_mode == "eos_token" or self.pooling_mode == "last_token":
|
| 272 |
+
return last_hidden_states[:, -1]
|
| 273 |
+
elif self.pooling_mode == "bos_token":
|
| 274 |
+
return last_hidden_states[features["input_ids"] == self.tokenizer.bos_token_id]
|
| 275 |
+
else:
|
| 276 |
+
raise ValueError(f"{self.pooling_mode} is not implemented yet.")
|
| 277 |
+
|
| 278 |
+
def _convert_to_str(self, instruction, text):
|
| 279 |
+
tokenized_q = self.tokenizer(
|
| 280 |
+
text,
|
| 281 |
+
return_tensors="pt",
|
| 282 |
+
padding=True,
|
| 283 |
+
truncation=True,
|
| 284 |
+
max_length=self.max_length,
|
| 285 |
+
add_special_tokens=False,
|
| 286 |
+
)
|
| 287 |
+
tokenized_q_length = len(tokenized_q["input_ids"][0])
|
| 288 |
+
|
| 289 |
+
while tokenized_q_length > self.doc_max_length:
|
| 290 |
+
reduction_ratio = self.doc_max_length / tokenized_q_length
|
| 291 |
+
reduced_length = int(len(text.split()) * reduction_ratio)
|
| 292 |
+
text = " ".join(text.split()[:reduced_length])
|
| 293 |
+
tokenized_q = self.tokenizer(
|
| 294 |
+
text,
|
| 295 |
+
return_tensors="pt",
|
| 296 |
+
padding=True,
|
| 297 |
+
truncation=True,
|
| 298 |
+
max_length=self.max_length,
|
| 299 |
+
add_special_tokens=False,
|
| 300 |
+
)
|
| 301 |
+
tokenized_q_length = len(tokenized_q["input_ids"][0])
|
| 302 |
+
|
| 303 |
+
return f"{instruction.strip()} !@#$%^&*(){text}" if instruction else f"!@#$%^&*(){text}"
|
| 304 |
+
|
| 305 |
+
def encode(
|
| 306 |
+
self,
|
| 307 |
+
sentences: Union[str, List[str]],
|
| 308 |
+
batch_size: int = 32,
|
| 309 |
+
show_progress_bar: bool = True,
|
| 310 |
+
convert_to_numpy: bool = False,
|
| 311 |
+
convert_to_tensor: bool = False,
|
| 312 |
+
device: Optional[str] = None,
|
| 313 |
+
):
|
| 314 |
+
"""
|
| 315 |
+
Encode a list of sentences to their respective embeddings. The sentences can be a list of strings or a string.
|
| 316 |
+
Args:
|
| 317 |
+
sentences: sentence or sentences to encode.
|
| 318 |
+
batch_size: batch size for turning sentence tokens into embeddings.
|
| 319 |
+
show_progress_bar: whether to show progress bars during encoding steps.
|
| 320 |
+
convert_to_numpy: If true, return numpy arrays instead of torch tensors.
|
| 321 |
+
convert_to_tensor: If true, return torch tensors (default).
|
| 322 |
+
device: torch backend device identifier (e.g., 'cuda', 'cpu','mps' etc.). If not specified,
|
| 323 |
+
the default is to use cuda when available, otherwise cpu. Note that only the choice of 'cuda' supports
|
| 324 |
+
multiprocessing as currently implemented.
|
| 325 |
+
|
| 326 |
+
Returns: embeddings of the sentences. Embeddings are detached and always on the CPU (see _encode implementation).
|
| 327 |
+
|
| 328 |
+
"""
|
| 329 |
+
if isinstance(sentences[0], str) and isinstance(sentences[-1], int):
|
| 330 |
+
sentences = [sentences]
|
| 331 |
+
# required for MEDI version of MTEB
|
| 332 |
+
if isinstance(sentences[0], str):
|
| 333 |
+
sentences = [[""] + [sentence] for sentence in sentences]
|
| 334 |
+
|
| 335 |
+
if device is None:
|
| 336 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 337 |
+
|
| 338 |
+
concatenated_input_texts = []
|
| 339 |
+
for sentence in sentences:
|
| 340 |
+
assert isinstance(sentence[0], str)
|
| 341 |
+
assert isinstance(sentence[1], str)
|
| 342 |
+
concatenated_input_texts.append(self._convert_to_str(sentence[0], sentence[1]))
|
| 343 |
+
sentences = concatenated_input_texts
|
| 344 |
+
|
| 345 |
+
self.eval()
|
| 346 |
+
|
| 347 |
+
if convert_to_tensor:
|
| 348 |
+
convert_to_numpy = False
|
| 349 |
+
|
| 350 |
+
length_sorted_idx = np.argsort([-self._text_length(sen) for sen in sentences])
|
| 351 |
+
sentences_sorted = [sentences[idx] for idx in length_sorted_idx]
|
| 352 |
+
all_embeddings = []
|
| 353 |
+
|
| 354 |
+
if torch.cuda.device_count() <= 1:
|
| 355 |
+
# This branch also support mps devices
|
| 356 |
+
self.to(device)
|
| 357 |
+
for start_index in trange(
|
| 358 |
+
0,
|
| 359 |
+
len(sentences),
|
| 360 |
+
batch_size,
|
| 361 |
+
desc="Batches",
|
| 362 |
+
disable=not show_progress_bar,
|
| 363 |
+
):
|
| 364 |
+
sentences_batch = sentences_sorted[start_index : start_index + batch_size]
|
| 365 |
+
embeddings = self._encode(sentences_batch, device=device, convert_to_numpy=convert_to_numpy)
|
| 366 |
+
all_embeddings.append(embeddings)
|
| 367 |
+
else:
|
| 368 |
+
num_proc = torch.cuda.device_count()
|
| 369 |
+
cuda_compatible_multiprocess = mp.get_context("spawn")
|
| 370 |
+
with cuda_compatible_multiprocess.Pool(num_proc) as p:
|
| 371 |
+
sentences_batches = [
|
| 372 |
+
sentences_sorted[start_index : start_index + batch_size]
|
| 373 |
+
for start_index in range(0, len(sentences), batch_size)
|
| 374 |
+
]
|
| 375 |
+
|
| 376 |
+
progress_bar = tqdm(
|
| 377 |
+
total=len(sentences_batches),
|
| 378 |
+
desc="Batches",
|
| 379 |
+
disable=not show_progress_bar,
|
| 380 |
+
)
|
| 381 |
+
results = []
|
| 382 |
+
|
| 383 |
+
def update(*args):
|
| 384 |
+
progress_bar.update()
|
| 385 |
+
|
| 386 |
+
for batch in sentences_batches:
|
| 387 |
+
results.append(
|
| 388 |
+
p.apply_async(
|
| 389 |
+
self._encode,
|
| 390 |
+
args=(batch, None, convert_to_numpy, True),
|
| 391 |
+
callback=update,
|
| 392 |
+
)
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
all_embeddings = [result.get() for result in results]
|
| 396 |
+
progress_bar.close()
|
| 397 |
+
|
| 398 |
+
all_embeddings = torch.cat(all_embeddings, dim=0)
|
| 399 |
+
all_embeddings = all_embeddings[np.argsort(length_sorted_idx)]
|
| 400 |
+
all_embeddings = all_embeddings.to(torch.float32)
|
| 401 |
+
if convert_to_numpy:
|
| 402 |
+
all_embeddings = np.asarray([emb.numpy() for emb in all_embeddings])
|
| 403 |
+
return all_embeddings
|
| 404 |
+
|
| 405 |
+
def save(self, output_path, merge_before_save=False, save_config=True):
|
| 406 |
+
if merge_before_save and isinstance(self.model, PeftModel):
|
| 407 |
+
self.model = self.model.merge_and_unload()
|
| 408 |
+
# Fixes the issue of saving - https://huggingface.co/McGill-NLP/LLM2Vec-Mistral-7B-Instruct-v2-mntp-unsup-simcse/discussions/1
|
| 409 |
+
if hasattr(self.model, "_hf_peft_config_loaded"):
|
| 410 |
+
self.model._hf_peft_config_loaded = False
|
| 411 |
+
|
| 412 |
+
self.model.save_pretrained(output_path)
|
| 413 |
+
self.tokenizer.save_pretrained(output_path)
|
| 414 |
+
|
| 415 |
+
llm2vec_config = {
|
| 416 |
+
"pooling_mode": self.pooling_mode,
|
| 417 |
+
"max_length": self.max_length,
|
| 418 |
+
"doc_max_length": self.doc_max_length,
|
| 419 |
+
"skip_instruction": self.skip_instruction,
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
if save_config:
|
| 423 |
+
os.makedirs(output_path, exist_ok=True)
|
| 424 |
+
with open(f"{output_path}/llm2vec_config.json", "w") as fOut:
|
| 425 |
+
json.dump(llm2vec_config, fOut, indent=4)
|
| 426 |
+
|
| 427 |
+
def _encode(
|
| 428 |
+
self,
|
| 429 |
+
sentences_batch,
|
| 430 |
+
device: Optional[str] = None,
|
| 431 |
+
convert_to_numpy: bool = False,
|
| 432 |
+
multiprocessing=False,
|
| 433 |
+
):
|
| 434 |
+
if multiprocessing:
|
| 435 |
+
# multiprocessing only supports CUDA devices at this time, so we ignore the value of device
|
| 436 |
+
# and use cuda:rank for the device
|
| 437 |
+
rank = mp.current_process()._identity[0]
|
| 438 |
+
if device is None and torch.cuda.is_available():
|
| 439 |
+
device = f"cuda:{rank % torch.cuda.device_count()}"
|
| 440 |
+
|
| 441 |
+
self.to(device)
|
| 442 |
+
features = self.tokenize([self.prepare_for_tokenization(sentence) for sentence in sentences_batch])
|
| 443 |
+
features = batch_to_device(features, device)
|
| 444 |
+
|
| 445 |
+
with torch.no_grad():
|
| 446 |
+
embeddings = self.forward(features)
|
| 447 |
+
embeddings = embeddings.detach()
|
| 448 |
+
embeddings = embeddings.cpu()
|
| 449 |
+
|
| 450 |
+
return embeddings
|
| 451 |
+
|
| 452 |
+
def _text_length(self, text: Union[List[int], List[List[int]]]):
|
| 453 |
+
"""Help function to get the length for the input text.
|
| 454 |
+
|
| 455 |
+
Text can be either a string (which means a single text) a list of ints (which means a single
|
| 456 |
+
tokenized text), or a tuple of list of ints (representing several text inputs to the model).
|
| 457 |
+
"""
|
| 458 |
+
if (
|
| 459 |
+
isinstance(text, str) or (isinstance(text, list) and isinstance(text[0], int)) or len(text) == 0
|
| 460 |
+
): # Single text, list of ints, or empty
|
| 461 |
+
return len(text)
|
| 462 |
+
if isinstance(text, dict): # {key: value} case
|
| 463 |
+
return len(next(iter(text.values())))
|
| 464 |
+
elif not hasattr(text, "__len__"): # Object has no len() method
|
| 465 |
+
return 1
|
| 466 |
+
else:
|
| 467 |
+
return sum([len(t) for t in text])
|
| 468 |
+
|
| 469 |
+
def resize_token_embeddings(
|
| 470 |
+
self,
|
| 471 |
+
new_num_tokens: Optional[int] = None,
|
| 472 |
+
pad_to_multiple_of: Optional[int] = None,
|
| 473 |
+
) -> nn.Embedding:
|
| 474 |
+
return self.model.resize_token_embeddings(new_num_tokens=new_num_tokens, pad_to_multiple_of=pad_to_multiple_of)
|
| 475 |
+
|
| 476 |
+
def gradient_checkpointing_enable(self, gradient_checkpointing_kwargs=None):
|
| 477 |
+
self.model.gradient_checkpointing_enable(gradient_checkpointing_kwargs=gradient_checkpointing_kwargs)
|
kimodo/model/llm2vec/llm2vec_wrapper.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
| 2 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 3 |
+
"""LLM2Vec encoder wrapper for Kimodo text conditioning."""
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import torch
|
| 9 |
+
|
| 10 |
+
from .llm2vec import LLM2Vec
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LLM2VecEncoder:
|
| 14 |
+
"""LLM2Vec text embeddings."""
|
| 15 |
+
|
| 16 |
+
def __init__(
|
| 17 |
+
self,
|
| 18 |
+
base_model_name_or_path: str,
|
| 19 |
+
peft_model_name_or_path: str,
|
| 20 |
+
dtype: str,
|
| 21 |
+
llm_dim: int,
|
| 22 |
+
device: str = "auto",
|
| 23 |
+
) -> None:
|
| 24 |
+
torch_dtype = getattr(torch, dtype)
|
| 25 |
+
self.llm_dim = llm_dim
|
| 26 |
+
|
| 27 |
+
cache_dir = os.environ.get("HUGGINGFACE_CACHE_DIR")
|
| 28 |
+
|
| 29 |
+
if "TEXT_ENCODERS_DIR" in os.environ:
|
| 30 |
+
base_model_name_or_path = os.path.join(os.environ["TEXT_ENCODERS_DIR"], base_model_name_or_path)
|
| 31 |
+
peft_model_name_or_path = os.path.join(os.environ["TEXT_ENCODERS_DIR"], peft_model_name_or_path)
|
| 32 |
+
|
| 33 |
+
self.model = LLM2Vec.from_pretrained(
|
| 34 |
+
base_model_name_or_path=base_model_name_or_path,
|
| 35 |
+
peft_model_name_or_path=peft_model_name_or_path,
|
| 36 |
+
torch_dtype=torch_dtype,
|
| 37 |
+
cache_dir=cache_dir,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
env_device = os.environ.get("TEXT_ENCODER_DEVICE")
|
| 41 |
+
if env_device:
|
| 42 |
+
device = env_device
|
| 43 |
+
if device == "auto":
|
| 44 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 45 |
+
self._device = device
|
| 46 |
+
if device is not None:
|
| 47 |
+
self.model = self.model.to(device)
|
| 48 |
+
|
| 49 |
+
self.model.eval()
|
| 50 |
+
for p in self.model.parameters():
|
| 51 |
+
p.requires_grad = False
|
| 52 |
+
|
| 53 |
+
def to(self, device: torch.device):
|
| 54 |
+
self.model = self.model.to(device)
|
| 55 |
+
self._device = str(device) if not isinstance(device, str) else device
|
| 56 |
+
return self
|
| 57 |
+
|
| 58 |
+
def eval(self):
|
| 59 |
+
self.model.eval()
|
| 60 |
+
return self
|
| 61 |
+
|
| 62 |
+
def get_device(self):
|
| 63 |
+
return self.model.model.device
|
| 64 |
+
|
| 65 |
+
def __call__(self, text: list[str] | str):
|
| 66 |
+
is_string = False
|
| 67 |
+
if isinstance(text, str):
|
| 68 |
+
text = [text]
|
| 69 |
+
is_string = True
|
| 70 |
+
|
| 71 |
+
with torch.no_grad():
|
| 72 |
+
encoded_text = self.model.encode(
|
| 73 |
+
text,
|
| 74 |
+
# IMPORTANT: different batch sizes unexpectedly change the output embeddings, so we always set it to 1
|
| 75 |
+
# here for repeatability no matter how many texts are being encoded. This
|
| 76 |
+
# is a fundamental issue with transformers, and is especially bad at lower
|
| 77 |
+
# precisions (https://github.com/huggingface/transformers/issues/25420#issuecomment-1775317535)
|
| 78 |
+
# note: this is an internal batch size used by llm2vec - the text list can still be of arbitrary length.
|
| 79 |
+
batch_size=1,
|
| 80 |
+
show_progress_bar=False,
|
| 81 |
+
device=self._device,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
assert len(encoded_text.shape)
|
| 85 |
+
assert self.llm_dim == encoded_text.shape[-1]
|
| 86 |
+
|
| 87 |
+
encoded_text = encoded_text[:, None]
|
| 88 |
+
lengths = np.ones(len(encoded_text), dtype=int).tolist()
|
| 89 |
+
|
| 90 |
+
if is_string:
|
| 91 |
+
encoded_text = encoded_text[0]
|
| 92 |
+
lengths = lengths[0]
|
| 93 |
+
|
| 94 |
+
encoded_text = torch.tensor(encoded_text).to(self._device)
|
| 95 |
+
return encoded_text, lengths
|