cs686 commited on
Commit
7e976d8
·
verified ·
1 Parent(s): a550c4e

Deploy Kimodo ZeroGPU motion API (part 2)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. kimodo/assets.py +19 -0
  3. kimodo/assets/skeletons/g1skel34/meshes/g1/waist_yaw_link_rev_1_0.STL +3 -0
  4. kimodo/assets/skeletons/g1skel34/rest_pose_local_rot.p +0 -0
  5. kimodo/assets/skeletons/g1skel34/xml/g1.xml +413 -0
  6. kimodo/assets/skeletons/smplx22/beta.npy +3 -0
  7. kimodo/assets/skeletons/smplx22/joints.p +0 -0
  8. kimodo/assets/skeletons/smplx22/mean_hands.npy +3 -0
  9. kimodo/assets/skeletons/somaskel30/joints.p +0 -0
  10. kimodo/assets/skeletons/somaskel30/soma_base_fit_mhr_params.npz +3 -0
  11. kimodo/assets/skeletons/somaskel77/bvh_joints.p +0 -0
  12. kimodo/assets/skeletons/somaskel77/joints.p +0 -0
  13. kimodo/assets/skeletons/somaskel77/relaxed_hands_rest_pose.npy +3 -0
  14. kimodo/assets/skeletons/somaskel77/skin_standard.npz +3 -0
  15. kimodo/assets/skeletons/somaskel77/somaskel77_standard_tpose.bvh +395 -0
  16. kimodo/assets/skeletons/somaskel77/standard_t_pose_global_offsets_rots.p +0 -0
  17. kimodo/constraints.py +625 -0
  18. kimodo/demo/__init__.py +29 -0
  19. kimodo/demo/__main__.py +8 -0
  20. kimodo/demo/app.py +690 -0
  21. kimodo/demo/config.py +163 -0
  22. kimodo/demo/embedding_cache.py +253 -0
  23. kimodo/demo/generation.py +218 -0
  24. kimodo/demo/queue_manager.py +336 -0
  25. kimodo/demo/state.py +59 -0
  26. kimodo/demo/ui.py +0 -0
  27. kimodo/exports/__init__.py +65 -0
  28. kimodo/exports/bvh.py +298 -0
  29. kimodo/exports/motion_convert_lib.py +159 -0
  30. kimodo/exports/motion_formats.py +78 -0
  31. kimodo/exports/motion_io.py +443 -0
  32. kimodo/exports/mujoco.py +588 -0
  33. kimodo/exports/smplx.py +251 -0
  34. kimodo/geometry.py +216 -0
  35. kimodo/meta.py +80 -0
  36. kimodo/metrics/__init__.py +39 -0
  37. kimodo/metrics/base.py +66 -0
  38. kimodo/metrics/constraints.py +87 -0
  39. kimodo/metrics/foot_skate.py +253 -0
  40. kimodo/metrics/tmr.py +545 -0
  41. kimodo/model/__init__.py +31 -0
  42. kimodo/model/backbone.py +312 -0
  43. kimodo/model/cfg.py +133 -0
  44. kimodo/model/common.py +48 -0
  45. kimodo/model/diffusion.py +133 -0
  46. kimodo/model/kimodo_model.py +634 -0
  47. kimodo/model/llm2vec/README.md +1 -0
  48. kimodo/model/llm2vec/__init__.py +11 -0
  49. kimodo/model/llm2vec/llm2vec.py +477 -0
  50. 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