multimodalart HF Staff commited on
Commit
5ea5618
·
verified ·
1 Parent(s): 1c10755

Upload folder using huggingface_hub

Browse files
README.md CHANGED
@@ -1,13 +1,39 @@
1
  ---
2
- title: Gnm
3
- emoji: 🏃
4
- colorFrom: purple
5
  colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.20.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GNM Head — Interactive 3D Face Model
3
+ emoji: 🧬
4
+ colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
  sdk_version: 6.20.0
 
8
  app_file: app.py
9
+ short_description: Reactive 3D demo of Google's GNM parametric head model
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 30m
12
+ license: apache-2.0
13
  ---
14
 
15
+ # GNM Head Interactive 3D Face Model
16
+
17
+ A reactive Gradio demo of **[GNM](https://github.com/google/GNM)** (Generative
18
+ aNthropometric Model), Google's state-of-the-art parametric 3D statistical model
19
+ of the human head. This Space replicates the official interactive slider demo
20
+ from [`gnm/shape/demos/gnm_head_demo.ipynb`](https://github.com/google/GNM/blob/main/gnm/shape/README.md#demo):
21
+ move any slider and the 3D head mesh regenerates live.
22
+
23
+ ## Controls
24
+
25
+ - **Identity** — first 10 components of the linear identity basis (head shape).
26
+ - **Expression** — selected blendshape components per region (left/right eye,
27
+ lower face, tongue, pupil).
28
+ - **Pose** — joint rotations for neck and head (degrees), plus a gaze direction
29
+ control with vergence.
30
+ - **Translation** — global position.
31
+
32
+ The model is pure NumPy (a few matrix multiplies, ~40 ms per evaluation), so the
33
+ Space runs on `cpu-basic` with no GPU required.
34
+
35
+ ## Credits
36
+
37
+ Model and reference demo: [google/GNM](https://github.com/google/GNM),
38
+ Apache 2.0. This Space vendors the upstream `gnm.shape` NumPy backend and its
39
+ model data (`gnm_head.npz`).
app.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reactive Gradio demo for the GNM (Generative aNthropometric Model) head model.
2
+
3
+ Replicates the interactive slider demo from
4
+ `gnm/shape/demos/gnm_head_demo.ipynb` in the upstream repo
5
+ (https://github.com/google/GNM). Moving any slider re-evaluates the GNM
6
+ mesh-generating function and updates a live 3D viewer.
7
+
8
+ GNM Head is a pure-NumPy parametric statistical model of the human head.
9
+ Evaluating it is a few matrix multiplies (~40 ms on CPU), so this Space runs
10
+ happily on `cpu-basic` with no GPU.
11
+ """
12
+
13
+ import re
14
+ import tempfile
15
+
16
+ import gradio as gr
17
+ import numpy as np
18
+ import trimesh
19
+ from scipy.spatial.transform import Rotation
20
+
21
+ from gnm.shape import gnm_numpy
22
+ from gnm.shape.visualization import vertex_colors as vertex_colors_module
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Load the GNM head model once at module scope (fast, CPU-only).
26
+ # ---------------------------------------------------------------------------
27
+ GNM = gnm_numpy.GNM.from_local(
28
+ version=gnm_numpy.GNMMajorVersion.V3,
29
+ variant=gnm_numpy.GNMVariant.HEAD,
30
+ )
31
+
32
+ # Mesh topology used by the upstream viewer: all triangles except the outer
33
+ # eye shells, so the irises/scleras are visible.
34
+ TRIANGLES = np.asarray(GNM.triangles_group("~eye_exteriors"))
35
+
36
+ # Per-vertex colors highlighting eyes, teeth, tongue (matches the notebook).
37
+ VERTEX_COLORS = vertex_colors_module.get_vertex_colors(gnm_np=GNM)
38
+ if "pupils" in GNM.vertex_group_names:
39
+ VERTEX_COLORS[GNM.vertex_group_indices("pupils")] = 0.0
40
+ VERTEX_RGBA = (
41
+ np.clip(
42
+ np.concatenate(
43
+ [VERTEX_COLORS, np.ones((VERTEX_COLORS.shape[0], 1))], axis=1
44
+ ),
45
+ 0.0,
46
+ 1.0,
47
+ )
48
+ * 255
49
+ ).astype(np.uint8)
50
+
51
+
52
+ def _find_component_indices(names, region, suffix=r"_[0-9][0-9][0-9]"):
53
+ """Indices of basis components whose name matches `<region><suffix>`."""
54
+ regex = re.compile(f"{region}{suffix}")
55
+ return [i for i, n in enumerate(names) if regex.match(n)]
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Which basis components each slider group controls (mirrors the notebook).
60
+ # ---------------------------------------------------------------------------
61
+ _id_names = list(GNM.identity_names)
62
+ _ex_names = list(GNM.expression_names)
63
+
64
+ IDENTITY_INDICES = _find_component_indices(_id_names, "head")[:10]
65
+
66
+ _tongue_mean = _find_component_indices(_ex_names, "tongue_mean", suffix="")
67
+ _tongue = _find_component_indices(_ex_names, "tongue")
68
+ _all_tongue = (_tongue_mean + _tongue)
69
+
70
+ EXPRESSION_GROUPS = {
71
+ "Left eye": _find_component_indices(_ex_names, "left_eye_region")[:3],
72
+ "Right eye": _find_component_indices(_ex_names, "right_eye_region")[:3],
73
+ "Lower face": _find_component_indices(_ex_names, "lower_face_region")[:7],
74
+ "Tongue": _all_tongue[:4],
75
+ "Pupil": _find_component_indices(_ex_names, "pupils")[:1],
76
+ }
77
+ # Flat ordered list of expression indices exposed as sliders.
78
+ EXPRESSION_INDICES = [i for g in EXPRESSION_GROUPS.values() for i in g]
79
+
80
+ # Pose slider limits (degrees), as in the notebook.
81
+ POSE_LIMITS = {
82
+ "neck": {"X": 90, "Y": 90, "Z": 90},
83
+ "head": {"X": 45, "Y": 45, "Z": 15},
84
+ "gaze": {"X": 25, "Y": 30, "Vergence": 10},
85
+ }
86
+
87
+ NUM_IDENTITY = len(IDENTITY_INDICES) # 10
88
+ NUM_EXPRESSION = len(EXPRESSION_INDICES) # 16
89
+ NUM_POSE = 9 # neck(3) + head(3) + gaze(3)
90
+ NUM_TRANSLATION = 3
91
+
92
+ TOTAL_SLIDERS = NUM_IDENTITY + NUM_EXPRESSION + NUM_POSE + NUM_TRANSLATION
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Parameter assembly + mesh generation.
97
+ # ---------------------------------------------------------------------------
98
+ def _build_rotations(pose_vals):
99
+ """Port of the notebook's pose->axis-angle conversion.
100
+
101
+ pose_vals is a flat list of 9 degrees:
102
+ [neck X,Y,Z, head X,Y,Z, gaze X, gaze Y, gaze Vergence].
103
+ """
104
+ neck = pose_vals[0:3]
105
+ head = pose_vals[3:6]
106
+ gaze_x, gaze_y, verg = pose_vals[6:9]
107
+
108
+ rotations_euler = np.zeros((GNM.num_joints, 3))
109
+ # Joint 0 = neck, joint 1 = head.
110
+ rotations_euler[0] = np.radians(neck)
111
+ rotations_euler[1] = np.radians(head)
112
+
113
+ # Both eyes (joints 2, 3) share gaze X/Y; vergence splits them apart.
114
+ rotations_euler[2, 0] = np.radians(gaze_x)
115
+ rotations_euler[3, 0] = np.radians(gaze_x)
116
+ rotations_euler[2, 1] = np.radians(gaze_y) + np.radians(verg)
117
+ rotations_euler[3, 1] = np.radians(gaze_y) - np.radians(verg)
118
+
119
+ return np.array(
120
+ [Rotation.from_euler("XYZ", r).as_rotvec() for r in rotations_euler]
121
+ )
122
+
123
+
124
+ def generate_head(*slider_values) -> str:
125
+ """Generate a GNM head mesh (.glb) from the slider parameters.
126
+
127
+ Args:
128
+ slider_values: flat sequence of slider values in this order:
129
+ 10 identity, 16 expression, 9 pose (degrees), 3 translation.
130
+
131
+ Returns:
132
+ Path to a temporary .glb file containing the colored head mesh.
133
+ """
134
+ vals = list(slider_values)
135
+ idx = 0
136
+ id_vals = vals[idx:idx + NUM_IDENTITY]; idx += NUM_IDENTITY
137
+ ex_vals = vals[idx:idx + NUM_EXPRESSION]; idx += NUM_EXPRESSION
138
+ pose_vals = vals[idx:idx + NUM_POSE]; idx += NUM_POSE
139
+ trans_vals = vals[idx:idx + NUM_TRANSLATION]
140
+
141
+ identity = np.zeros(GNM.identity_dim)
142
+ for i, v in zip(IDENTITY_INDICES, id_vals):
143
+ identity[i] = float(v)
144
+
145
+ expression = np.zeros(GNM.expression_dim)
146
+ for i, v in zip(EXPRESSION_INDICES, ex_vals):
147
+ expression[i] = float(v)
148
+
149
+ rotations = _build_rotations([float(v) for v in pose_vals])
150
+ translation = np.array([float(v) for v in trans_vals])
151
+
152
+ vertices = np.asarray(GNM(identity, expression, rotations, translation))
153
+
154
+ mesh = trimesh.Trimesh(
155
+ vertices=vertices,
156
+ faces=TRIANGLES,
157
+ vertex_colors=VERTEX_RGBA,
158
+ process=False,
159
+ )
160
+ # GNM is Y-up / +Z-forward; rotate so the face looks toward the camera in
161
+ # the default Model3D view.
162
+ out = tempfile.NamedTemporaryFile(suffix=".glb", delete=False)
163
+ mesh.export(out.name)
164
+ return out.name
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # UI
169
+ # ---------------------------------------------------------------------------
170
+ CSS = """
171
+ #col-container { max-width: 1200px; margin: 0 auto; }
172
+ .dark .gradio-container { color: var(--body-text-color); }
173
+ """
174
+
175
+ INTRO = """
176
+ # 🧬 GNM — Generative aNthropometric Model (Head)
177
+
178
+ Interactive replica of the [official reactive demo](https://github.com/google/GNM/blob/main/gnm/shape/README.md#demo)
179
+ (`gnm_head_demo.ipynb`). **GNM Head** is a state-of-the-art parametric 3D
180
+ statistical model of the human head from [google/GNM](https://github.com/google/GNM),
181
+ with disentangled control over **identity**, **expression**, **pose** and
182
+ **translation**. Move any slider to see it re-generate the 3D mesh live.
183
+
184
+ The exposed parameters mirror the notebook: the first 10 identity (head)
185
+ components, a few expression components per region (eyes, lower face, tongue,
186
+ pupil), joint rotations (neck / head / gaze), and global translation.
187
+ """
188
+
189
+
190
+ def _slider(label, minimum, maximum, value=0.0, step=None):
191
+ kw = dict(label=label, minimum=minimum, maximum=maximum, value=value)
192
+ if step is not None:
193
+ kw["step"] = step
194
+ return gr.Slider(**kw)
195
+
196
+
197
+ with gr.Blocks(title="GNM Head") as demo:
198
+ with gr.Column(elem_id="col-container"):
199
+ gr.Markdown(INTRO)
200
+
201
+ with gr.Row():
202
+ # --- Controls -------------------------------------------------
203
+ with gr.Column(scale=3):
204
+ identity_sliders = []
205
+ expression_sliders = []
206
+ pose_sliders = []
207
+ translation_sliders = []
208
+
209
+ with gr.Tabs():
210
+ with gr.Tab("Identity"):
211
+ gr.Markdown(
212
+ "First 10 components of the linear **identity** "
213
+ "basis (head shape). Typical range −3 … +3."
214
+ )
215
+ with gr.Row():
216
+ for k in range(NUM_IDENTITY):
217
+ identity_sliders.append(
218
+ _slider(f"i{k}", -3.0, 3.0, 0.0, 0.05)
219
+ )
220
+
221
+ with gr.Tab("Expression"):
222
+ gr.Markdown(
223
+ "Selected **expression** blendshape components "
224
+ "per region. Typical range −3 … +3."
225
+ )
226
+ for group_name, group_idx in EXPRESSION_GROUPS.items():
227
+ with gr.Row():
228
+ gr.Markdown(f"**{group_name}**")
229
+ with gr.Row():
230
+ for j in range(len(group_idx)):
231
+ expression_sliders.append(
232
+ _slider(
233
+ f"{group_name[:2].lower()}{j}",
234
+ -3.0, 3.0, 0.0, 0.05,
235
+ )
236
+ )
237
+
238
+ with gr.Tab("Pose"):
239
+ gr.Markdown(
240
+ "Joint **rotations** in degrees (neck / head) and "
241
+ "**gaze** direction (both eyes locked, with a "
242
+ "vergence control)."
243
+ )
244
+ for joint, limits in POSE_LIMITS.items():
245
+ with gr.Row():
246
+ gr.Markdown(f"**{joint.capitalize()}**")
247
+ with gr.Row():
248
+ for name, limit in limits.items():
249
+ pose_sliders.append(
250
+ _slider(
251
+ name, -limit, limit, 0, 1
252
+ )
253
+ )
254
+
255
+ with gr.Tab("Translation"):
256
+ gr.Markdown("Global **translation** (meters).")
257
+ with gr.Row():
258
+ for d in ["X", "Y", "Z"]:
259
+ translation_sliders.append(
260
+ _slider(d, -0.2, 0.2, 0.0, 0.01)
261
+ )
262
+
263
+ reset_btn = gr.Button("↺ Reset all sliders", variant="secondary")
264
+
265
+ # --- Viewer ---------------------------------------------------
266
+ with gr.Column(scale=2):
267
+ model_out = gr.Model3D(
268
+ label="GNM head mesh",
269
+ clear_color=[0.94, 0.94, 0.94, 1.0],
270
+ height=560,
271
+ camera_position=(0, 90, None),
272
+ )
273
+
274
+ all_sliders = (
275
+ identity_sliders
276
+ + expression_sliders
277
+ + pose_sliders
278
+ + translation_sliders
279
+ )
280
+
281
+ # Reactive: any slider change regenerates the mesh.
282
+ for s in all_sliders:
283
+ s.change(
284
+ fn=generate_head,
285
+ inputs=all_sliders,
286
+ outputs=model_out,
287
+ show_progress="hidden",
288
+ )
289
+
290
+ def _reset():
291
+ return [0.0] * len(all_sliders)
292
+
293
+ reset_btn.click(_reset, inputs=None, outputs=all_sliders).then(
294
+ fn=generate_head, inputs=all_sliders, outputs=model_out
295
+ )
296
+
297
+ # Presets: full rows (every slider gets a concrete value, no None).
298
+ # Offsets into the flat slider vector:
299
+ _EX0 = NUM_IDENTITY # expression start
300
+ _PO0 = NUM_IDENTITY + NUM_EXPRESSION # pose start
301
+ _TR0 = NUM_IDENTITY + NUM_EXPRESSION + NUM_POSE # translation start
302
+
303
+ def _preset(identity=None, expression=None, pose=None, translation=None):
304
+ row = [0.0] * TOTAL_SLIDERS
305
+ for i, v in (identity or {}).items():
306
+ row[i] = v
307
+ for i, v in (expression or {}).items():
308
+ row[_EX0 + i] = v
309
+ for i, v in (pose or {}).items():
310
+ row[_PO0 + i] = v
311
+ for i, v in (translation or {}).items():
312
+ row[_TR0 + i] = v
313
+ return row
314
+
315
+ presets = [
316
+ _preset(), # neutral template face
317
+ _preset(identity={0: 2.5, 1: -2.0, 2: 1.5, 4: -1.5}), # identity A
318
+ _preset(identity={0: -2.5, 3: 2.0, 5: -1.8, 6: 1.5}), # identity B
319
+ # Pose: neck Y (index 1), head X (index 3), gaze X (index 6).
320
+ _preset(pose={1: 35, 3: -15, 6: 15}), # looking aside
321
+ _preset(pose={0: -25, 4: 20}), # looking down/up
322
+ ]
323
+
324
+ gr.Examples(
325
+ label="Presets (click to load)",
326
+ examples=presets,
327
+ inputs=all_sliders,
328
+ outputs=model_out,
329
+ fn=generate_head,
330
+ cache_examples=True,
331
+ cache_mode="lazy",
332
+ )
333
+
334
+ # Render the neutral template on load.
335
+ demo.load(fn=generate_head, inputs=all_sliders, outputs=model_out)
336
+
337
+
338
+ if __name__ == "__main__":
339
+ demo.launch(mcp_server=True, theme=gr.themes.Citrus(), css=CSS)
gnm/shape/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
gnm/shape/data/landmarks/head_sparse_68.txt ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 8777 0.068 8841 0.079 11165 0.853
2
+ 9838 0.151 9835 0.045 9836 0.804
3
+ 9986 0.037 9984 0.216 9982 0.746
4
+ 10087 0.099 10090 0.828 9996 0.074
5
+ 6846 0.866 6849 0.102 8028 0.031
6
+ 8738 0.084 8736 0.158 8737 0.758
7
+ 10085 0.084 10084 0.057 9845 0.859
8
+ 9747 0.146 9754 0.407 9740 0.447
9
+ 9975 0.156 12257 0.138 12258 0.706
10
+ 3612 0.447 3626 0.407 3619 0.146
11
+ 3854 0.746 3856 0.216 3858 0.037
12
+ 3868 0.074 3962 0.828 3959 0.099
13
+ 1900 0.031 721 0.102 718 0.866
14
+ 2609 0.758 2608 0.158 2610 0.084
15
+ 3717 0.859 3956 0.057 3957 0.084
16
+ 3708 0.804 3707 0.045 3710 0.151
17
+ 5037 0.853 2713 0.079 2649 0.068
18
+ 7635 0.443 7636 0.143 7458 0.414
19
+ 7578 0.141 7575 0.065 7574 0.794
20
+ 7093 0.226 7090 0.351 7671 0.423
21
+ 7572 0.053 7566 0.041 7565 0.907
22
+ 7111 0.004 7108 0.996 7640 0.000
23
+ 1512 0.000 980 0.996 983 0.004
24
+ 1437 0.907 1438 0.041 1444 0.053
25
+ 1543 0.423 962 0.351 965 0.226
26
+ 1446 0.794 1447 0.065 1450 0.141
27
+ 1330 0.414 1508 0.143 1507 0.443
28
+ 12319 0.995 9970 0.005 9971 0.000
29
+ 12329 0.995 6766 0.004 6764 0.000
30
+ 12310 0.996 10070 0.003 10052 0.000
31
+ 12287 0.000 12296 0.997 10055 0.003
32
+ 10105 0.995 10106 0.003 9931 0.002
33
+ 9478 0.126 10221 0.355 10225 0.519
34
+ 12297 0.000 12298 0.998 10219 0.002
35
+ 3350 0.170 3354 0.588 4097 0.242
36
+ 3803 0.002 3978 0.003 3977 0.995
37
+ 7426 0.992 7425 0.002 6708 0.006
38
+ 7288 0.996 7289 0.002 7290 0.003
39
+ 7164 0.004 7165 0.001 7404 0.995
40
+ 11028 0.008 11027 0.974 6758 0.017
41
+ 6736 0.005 6739 0.988 7181 0.008
42
+ 6722 0.002 7307 0.994 7308 0.004
43
+ 630 0.017 4899 0.974 4900 0.008
44
+ 1276 0.995 1037 0.001 1036 0.004
45
+ 1162 0.003 1161 0.002 1160 0.996
46
+ 580 0.006 1297 0.002 1298 0.992
47
+ 1180 0.004 1179 0.994 594 0.002
48
+ 1053 0.008 611 0.988 608 0.005
49
+ 10683 0.409 10653 0.584 10652 0.006
50
+ 10720 0.000 10719 0.996 10673 0.004
51
+ 10732 0.000 10731 0.997 10672 0.003
52
+ 10711 0.002 12276 0.998 12279 0.000
53
+ 4544 0.003 4603 0.997 4604 0.000
54
+ 4545 0.004 4591 0.996 4592 0.000
55
+ 4524 0.006 4525 0.584 4555 0.409
56
+ 4685 0.003 4598 0.001 4599 0.996
57
+ 4691 0.002 4593 0.995 4594 0.002
58
+ 10702 0.000 12270 1.000 12284 0.000
59
+ 10722 0.002 10721 0.995 10819 0.002
60
+ 10727 0.996 10726 0.001 10813 0.003
61
+ 11285 0.558 11286 0.312 10694 0.129
62
+ 10805 0.997 10806 0.002 10801 0.000
63
+ 10709 0.003 12285 0.997 12272 0.000
64
+ 4673 0.000 4678 0.002 4677 0.997
65
+ 4566 0.129 5158 0.312 5157 0.558
66
+ 4689 0.998 4690 0.001 4687 0.001
67
+ 12271 0.996 10704 0.004 10703 0.000
68
+ 10815 0.001 10818 0.001 10817 0.998
gnm/shape/data/versions/gnm_catalog.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """GNM catalog constants."""
16
+
17
+ MAINTAINED_MAJOR_VERSIONS = ["v3"]
18
+ MAJOR_VERSION_TO_VARIANTS_MAP = {"v3": ["head"]}
19
+ ALL_VARIANTS = ["head"]
20
+ VARIANT_TO_MODEL_FILE_NAME_MAP = {"head": "gnm_head"}
21
+ VERSION_TO_FULL_VERSION_MAP = {"v3": "v3_0"}
gnm/shape/data/versions/gnm_specs.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """GNM versions and variants specifications."""
16
+
17
+ import enum
18
+
19
+
20
+ class GNMVersion(enum.StrEnum):
21
+ V3_0 = '3.0'
22
+
23
+ class GNMMajorVersion(enum.StrEnum):
24
+ V3 = '3'
25
+
26
+ class GNMVariant(enum.StrEnum):
27
+ HEAD = 'head'
28
+
29
+ class GNMBodyPart(enum.StrEnum):
30
+ HEAD = 'head'
31
+
32
+ GNM_VARIANT_TO_BODY_PART_MAP = {
33
+ GNMVariant.HEAD: GNMBodyPart.HEAD,
34
+ }
gnm/shape/data/versions/v3_0/gnm_head.npz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e50a702789af51347531e13721df1e5a26dcfc30ced9e243c7cede9fcac5db43
3
+ size 53305389
gnm/shape/fitting_utils/project_on_pca.py ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Projection on a linear vertex basis."""
16
+
17
+ import dataclasses
18
+
19
+ from gnm.shape.fitting_utils import regularized_least_squares
20
+ import numpy as np
21
+
22
+ _EPSILON = 1e-16
23
+
24
+
25
+ @dataclasses.dataclass(frozen=True)
26
+ class PCAProjectionResult:
27
+ """The result of projecting a mesh on a linear vertex basis.
28
+
29
+ Attributes:
30
+ coefficients: The coefficients of the vertex basis. Note that if
31
+ `num_components` is given, then the coefficients will be truncated to that
32
+ number of components.
33
+ reconstruction: The reconstructed vertices.
34
+ """
35
+
36
+ coefficients: np.ndarray
37
+ reconstruction: np.ndarray | None = None
38
+
39
+
40
+ def project_on_linear_vertex_basis(
41
+ vertices: np.ndarray,
42
+ mean_vertex_positions: np.ndarray,
43
+ vertex_basis: np.ndarray,
44
+ indices: np.ndarray | None = None,
45
+ regularization: float = 0.0,
46
+ num_components: int | None = None,
47
+ compute_reconstruction: bool = True,
48
+ ) -> PCAProjectionResult:
49
+ """Finds the coefficients of the vertex basis that best fit the vertices.
50
+
51
+ Solves a least-squares problem to find the coefficients of the vertex basis
52
+ that best fit the vertices. The formulation is:
53
+
54
+ || mu + B @ c - v ||_2^2 + lambda * ||c||_2^2
55
+ || B @ c - (v - mu) ||_2^2 + lambda * ||c||_2^2
56
+
57
+ where mu contains the mean vertex positions, (V, 3), B is the vertex basis we
58
+ are projecting on, (I, V, 3), c contains the desired coefficients, (I,), and v
59
+ are the target vertices, (V, 3).
60
+
61
+ Args:
62
+ vertices: The vertices to project, ([A1, ..., An], V, 3).
63
+ mean_vertex_positions: The mean vertex positions, (V, 3).
64
+ vertex_basis: The vertex basis, (I, V, 3).
65
+ indices: The indices of the vertices to project, (V,). If None, all vertices
66
+ are used to compute the least-squares error.
67
+ regularization: The regularization weight for the least-squares problem.
68
+ num_components: The number of components to use for the projection. If None,
69
+ all components are used.
70
+ compute_reconstruction: If True, compute the reconstructed vertices using
71
+ the estimated coefficients and given vertex basis.
72
+
73
+ Returns:
74
+ An instance of `PCAProjectionResult` containing the coefficients and the
75
+ reconstructed vertices. Note that the coefficients and reconstructions in
76
+ `PCAProjectionResult` are *always* batched.
77
+ """
78
+ if indices is None:
79
+ indices = np.arange(len(mean_vertex_positions))
80
+
81
+ # If the number of components is not given, use all components.
82
+ if num_components is None:
83
+ num_components = vertex_basis.shape[0]
84
+
85
+ # Add a batch dimension if the vertices are not batched.
86
+ if len(vertices.shape) == 2:
87
+ vertices = np.expand_dims(vertices, axis=0)
88
+
89
+ # Compute the offset from the mean vertex positions, ([A1, ..., An], V, 3]).
90
+ offsets_from_mean = vertices - mean_vertex_positions
91
+ offsets_from_mean = np.take(offsets_from_mean, indices, axis=-2)
92
+
93
+ # Flatten the shape offsets to (prod(A1, ..., An), V * 3).
94
+ num_batch_elements = np.prod(offsets_from_mean.shape[:-2])
95
+ offsets_from_mean = np.reshape(
96
+ offsets_from_mean, [num_batch_elements, len(indices) * 3]
97
+ )
98
+
99
+ # Transpose the shape offsets to (V * 3, prod(A1, ..., An)).
100
+ offsets_from_mean = np.swapaxes(offsets_from_mean, 0, 1)
101
+
102
+ # Index the basis using the selected vertex indices and the desired number of
103
+ # components, flatten the vertex and coordinate axis, and transpose to
104
+ # (V*3, I).
105
+ vertex_basis_subset = vertex_basis[:num_components]
106
+ basis_for_fitting = np.reshape(
107
+ vertex_basis_subset[:, indices], [num_components, len(indices) * 3]
108
+ )
109
+ basis_for_fitting = np.swapaxes(basis_for_fitting, 0, 1)
110
+
111
+ coefficients, *_ = regularized_least_squares.regularized_least_squares(
112
+ basis_for_fitting, offsets_from_mean, regularization
113
+ )
114
+
115
+ # Reshape the coefficients to ([A1, ..., An], I).
116
+ coefficients = np.swapaxes(coefficients, 0, 1)
117
+ coefficients = np.reshape(
118
+ coefficients, [*vertices.shape[:-2], num_components]
119
+ )
120
+
121
+ reconstruction = None
122
+ if compute_reconstruction:
123
+ reconstruction = mean_vertex_positions + np.einsum(
124
+ '...i,ivm->...vm', coefficients, vertex_basis_subset
125
+ )
126
+
127
+ return PCAProjectionResult(
128
+ coefficients=coefficients, reconstruction=reconstruction
129
+ )
130
+
131
+
132
+ class PCABasisProjection:
133
+ """Projects vertices on a linear vertex basis.
134
+
135
+ This class pre-computes the matrix used to project vertices on a linear vertex
136
+ basis for repeated solves.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ mean_vertex_positions: np.ndarray,
142
+ vertex_basis: np.ndarray,
143
+ vertex_indices: np.ndarray | None = None,
144
+ regularization: float = 0.0,
145
+ num_components: int | None = None,
146
+ compute_reconstruction: bool = True,
147
+ ):
148
+ """Builds the instance and pre-computes the projection matrix.
149
+
150
+ Args:
151
+ mean_vertex_positions: The mean vertex positions for the basis, (V, 3).
152
+ vertex_basis: The vertex basis, (I, V, 3).
153
+ vertex_indices: The indices of the vertices to project, (V,). If None, all
154
+ vertices are used to compute the least-squares error.
155
+ regularization: The regularization weight for the least-squares problem.
156
+ num_components: The number of components to use for the projection. If
157
+ None, all components are used.
158
+ compute_reconstruction: If True, compute the reconstructed vertices using
159
+ the estimated coefficients and given vertex basis.
160
+ """
161
+
162
+ self._compute_reconstruction = compute_reconstruction
163
+
164
+ if vertex_indices is None:
165
+ vertex_indices = np.arange(len(mean_vertex_positions))
166
+
167
+ # If the number of components is not given, use all components.
168
+ if num_components is None:
169
+ num_components = vertex_basis.shape[0]
170
+
171
+ self._num_components = num_components
172
+ self._vertex_indices = vertex_indices
173
+ self._mean_vertex_positions = mean_vertex_positions.astype(np.float32)
174
+
175
+ regularization_sqrt = np.sqrt(regularization + _EPSILON)
176
+ self._regularization_sqrt = regularization_sqrt
177
+
178
+ # Index the basis using the selected vertex indices and the desired number
179
+ # of components, flatten the vertex and coordinate axis, and transpose to
180
+ # (V*3, I).
181
+ vertex_basis_subset = vertex_basis[:num_components]
182
+ self._vertex_basis_subset = vertex_basis_subset
183
+
184
+ basis_for_fitting = np.reshape(
185
+ vertex_basis_subset[:, vertex_indices],
186
+ [num_components, len(vertex_indices) * 3],
187
+ )
188
+ basis_for_fitting = np.swapaxes(basis_for_fitting, 0, 1)
189
+
190
+ # Append a diagonal array multiplied with the regularization weight.
191
+ left_hand_side_regularization_term = regularization_sqrt * np.eye(
192
+ basis_for_fitting.shape[0],
193
+ basis_for_fitting.shape[1],
194
+ dtype=basis_for_fitting.dtype,
195
+ )
196
+ left_hand_side = np.concatenate(
197
+ [basis_for_fitting, left_hand_side_regularization_term]
198
+ ).astype(np.float32)
199
+
200
+ # Contains the array used to project the vertices on the basis.
201
+ self._basis_projection_matrix = np.linalg.pinv(left_hand_side).astype(
202
+ np.float32
203
+ )
204
+
205
+ def __call__(
206
+ self,
207
+ vertices: np.ndarray,
208
+ ) -> PCAProjectionResult:
209
+ """Finds the coefficients of the vertex basis that best fit the vertices.
210
+
211
+ Args:
212
+ vertices: The vertices to project, ([A1, ..., An], V, 3).
213
+
214
+ Returns:
215
+ An instance of `PCAProjectionResult` containing the coefficients and the
216
+ reconstructed vertices.
217
+ """
218
+ # Add a batch dimension if the vertices are not batched.
219
+ if len(vertices.shape) == 2:
220
+ vertices = np.expand_dims(vertices, axis=0)
221
+
222
+ # Compute the offset from the mean vertex positions, ([A1, ..., An], V, 3]).
223
+ shape_offsets = (vertices - self._mean_vertex_positions).astype(np.float32)
224
+ shape_offsets = np.take(shape_offsets, self._vertex_indices, axis=-2)
225
+
226
+ # Flatten the shape offsets to (prod(A1, ..., An), V * 3).
227
+ num_batch_elements = np.prod(shape_offsets.shape[:-2])
228
+ shape_offsets = np.reshape(
229
+ shape_offsets, [num_batch_elements, len(self._vertex_indices) * 3]
230
+ )
231
+
232
+ # Transpose the shape offsets to (V * 3, prod(A1, ..., An)).
233
+ shape_offsets = np.swapaxes(shape_offsets, 0, 1)
234
+
235
+ # Append an array full of zeros to the right-hand side for the
236
+ # regularization term.
237
+ right_hand_side_regularization_term = np.zeros_like(shape_offsets)
238
+ right_hand_side = np.concatenate(
239
+ [shape_offsets, right_hand_side_regularization_term]
240
+ )
241
+
242
+ # Compute the coefficients, (I, [A1, ..., An]]).
243
+ coefficients = np.einsum(
244
+ 'iv,v...->i...',
245
+ self._basis_projection_matrix,
246
+ right_hand_side,
247
+ )
248
+ coefficients = np.swapaxes(coefficients, 0, 1)
249
+ coefficients = np.reshape(
250
+ coefficients, [*vertices.shape[:-2], self._num_components]
251
+ )
252
+
253
+ reconstruction = None
254
+ if self._compute_reconstruction:
255
+ reconstruction = self._mean_vertex_positions + np.einsum(
256
+ '...i,ivm->...vm', coefficients, self._vertex_basis_subset
257
+ )
258
+
259
+ return PCAProjectionResult(
260
+ coefficients=coefficients, reconstruction=reconstruction
261
+ )
gnm/shape/fitting_utils/regularized_least_squares.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """A wrapper of `np.linalg.lstsq` that supports regularization."""
16
+
17
+ import numpy as np
18
+ import numpy.typing as npt
19
+
20
+ _EPSILON = 1e-16
21
+
22
+
23
+ def regularized_least_squares(
24
+ left_hand_side_data_term: npt.NDArray[np.floating],
25
+ right_hand_side_data_term: npt.NDArray[np.floating],
26
+ regularization: float = 0.0,
27
+ rcond: float | None = None,
28
+ ) -> tuple[
29
+ npt.NDArray[np.floating],
30
+ npt.NDArray[np.floating],
31
+ int,
32
+ npt.NDArray[np.floating],
33
+ ]:
34
+ """Solves a regularized least-squares problem.
35
+
36
+ Returns the solution of a problem of the form:
37
+
38
+ min_x || A x - b ||_2^2 + gamma * ||x||_2^2.
39
+
40
+ To solve this problem using a standard least-squares function, we convert it
41
+ to:
42
+
43
+ min_x || [A; sqrt(gamma) * I] x - [b; 0] ||_2^2
44
+
45
+ where [;] means that we concatenate the two arrays along the rows.
46
+
47
+ Args:
48
+ left_hand_side_data_term: The left hand side array, A, (N, M).
49
+ right_hand_side_data_term: The right hand side term, b, with shape (N,) or
50
+ (N, K).
51
+ regularization: The scalar regularization that will be used.
52
+ rcond: Cut-off ratio for small singular values of A.
53
+
54
+ Returns:
55
+ A tuple that contains the least-squares solution, the residuals for each
56
+ data term, the rank of the left hand side matrix and its singular values.
57
+ """
58
+
59
+ regularization_sqrt = np.sqrt(regularization + _EPSILON)
60
+
61
+ # Append a diagonal array multiplied with the regularization weight.
62
+ left_hand_side_regularization_term = regularization_sqrt * np.eye(
63
+ left_hand_side_data_term.shape[0],
64
+ left_hand_side_data_term.shape[1],
65
+ dtype=left_hand_side_data_term.dtype,
66
+ )
67
+ left_hand_side = np.concatenate(
68
+ [left_hand_side_data_term, left_hand_side_regularization_term]
69
+ )
70
+
71
+ # Append an array full of zeros to the right-hand side for the regularization
72
+ # term.
73
+ right_hand_side_regularization_term = np.zeros_like(right_hand_side_data_term)
74
+ right_hand_side = np.concatenate(
75
+ [right_hand_side_data_term, right_hand_side_regularization_term]
76
+ )
77
+
78
+ return np.linalg.lstsq(left_hand_side, right_hand_side, rcond)
79
+
80
+
81
+ class RegularizedLeastSquares:
82
+ """A class for repeated solves using the same left-hand side."""
83
+
84
+ def __init__(
85
+ self,
86
+ left_hand_side_data_term: npt.NDArray[np.floating],
87
+ regularization: float = 0.0,
88
+ ):
89
+ """Pre-computes the left-hand side of the regularized least-squares problem.
90
+
91
+ Args:
92
+ left_hand_side_data_term: The left hand side array, A, (N, M).
93
+ regularization: The scalar regularization that will be used.
94
+ """
95
+ regularization_sqrt = np.sqrt(regularization + _EPSILON)
96
+
97
+ # Append a diagonal array multiplied with the regularization weight.
98
+ left_hand_side_regularization_term = regularization_sqrt * np.eye(
99
+ left_hand_side_data_term.shape[0],
100
+ left_hand_side_data_term.shape[1],
101
+ dtype=left_hand_side_data_term.dtype,
102
+ )
103
+ left_hand_side = np.concatenate(
104
+ [left_hand_side_data_term, left_hand_side_regularization_term]
105
+ )
106
+
107
+ # Compute the pseudo-inverse of the left-hand side.
108
+ self._left_hand_side = (
109
+ np.linalg.pinv(left_hand_side.T @ left_hand_side) @ left_hand_side.T
110
+ )
111
+
112
+ def solve(
113
+ self, right_hand_side_data_term: npt.NDArray[np.floating]
114
+ ) -> npt.NDArray[np.floating]:
115
+ """Solves the least-squares problem for a given target.
116
+
117
+ Args:
118
+ right_hand_side_data_term: The right hand side term, b, with shape (N,)
119
+ or (N, K).
120
+
121
+ Returns:
122
+ The least-squares solution, x.
123
+ """
124
+ right_hand_side_regularization_term = np.zeros_like(
125
+ right_hand_side_data_term
126
+ )
127
+ right_hand_side = np.concatenate(
128
+ [right_hand_side_data_term, right_hand_side_regularization_term],
129
+ axis=0,
130
+ )
131
+ return self._left_hand_side @ right_hand_side
gnm/shape/gnm_base.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Base GNM class."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import abc
20
+ from collections.abc import Mapping
21
+ import dataclasses
22
+ from typing import Any, Self
23
+
24
+ from gnm.shape import gnm_data_loader
25
+ from gnm.shape.data.versions import gnm_specs
26
+
27
+
28
+ @dataclasses.dataclass(init=False)
29
+ class GNMBase(abc.ABC):
30
+ """Base GNM class."""
31
+
32
+ version: gnm_specs.GNMVersion
33
+ variant: gnm_specs.GNMVariant
34
+
35
+ @classmethod
36
+ def from_local(
37
+ cls,
38
+ version: gnm_specs.GNMMajorVersion,
39
+ variant: gnm_specs.GNMVariant,
40
+ ) -> Self:
41
+ """Creates a GNM instance from a local model file."""
42
+ data_dict = gnm_data_loader.load_model_from_runfile(version, variant)
43
+ return cls._from_model_data(data_dict) # pyrefly: ignore[bad-return]
44
+
45
+ @classmethod
46
+ def from_gnm(cls, gnm: GNMBase) -> Self:
47
+ """Creates a GNM instance from another GNM instance."""
48
+ data_dict = gnm.to_numpy_data_dict()
49
+ return cls._from_model_data(data_dict) # pyrefly: ignore[bad-return]
50
+
51
+ @abc.abstractmethod
52
+ def to_numpy_data_dict(self) -> dict[str, Any]:
53
+ """Returns a dictionary of the GNM data represented as NumPy arrays."""
54
+ raise NotImplementedError()
55
+
56
+ @classmethod
57
+ @abc.abstractmethod
58
+ def _from_model_data(
59
+ cls,
60
+ data_dict: Mapping[str, Any],
61
+ ) -> Self:
62
+ """Creates a GNM instance from a model data."""
63
+ pass
64
+
65
+ @property
66
+ def major_version(self) -> str:
67
+ """Returns the major version of the model."""
68
+ return gnm_data_loader.full_version_to_major(self.version)
69
+
70
+ @property
71
+ def body_part(self) -> gnm_specs.GNMBodyPart:
72
+ """Returns the body part of the GNM model."""
73
+ return gnm_specs.GNM_VARIANT_TO_BODY_PART_MAP[self.variant]
gnm/shape/gnm_common.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Backend-agnostic core math functions of the GNM model using etils.enp."""
16
+
17
+ from typing import Any, Sequence
18
+ from etils import enp
19
+
20
+ enpt = enp.typing
21
+
22
+ _EPSILON = 1e-8
23
+
24
+
25
+ def take(
26
+ array: enpt.FloatArray['...'],
27
+ indices: enpt.IntArray['...'],
28
+ axis: int = 0,
29
+ xnp: Any = None,
30
+ ) -> enpt.FloatArray['...']:
31
+ """Extracts elements from an array along a specified axis.
32
+
33
+ This is a backend-agnostic wrapper around backend specific operations.
34
+ Specifically, on PyTorch it uses index_select, while on other backends it uses
35
+ the backend's take.
36
+
37
+ Args:
38
+ array: The source array to select values from.
39
+ indices: 1D or multi-dimensional array of indices to extract.
40
+ axis: The axis along which to select values.
41
+ xnp: Optional array module (e.g. numpy, jax.numpy, torch) to use. If not
42
+ provided, it is auto-detected from the array argument.
43
+
44
+ Returns:
45
+ The array with selected indices along the specified axis.
46
+ """
47
+ if xnp is None:
48
+ xnp = enp.lazy.get_xnp(array)
49
+ if enp.lazy.is_torch_xnp(xnp):
50
+ axis = axis % array.ndim
51
+ indices_shape = indices.shape
52
+ if len(indices_shape) > 1:
53
+ indices_flat = indices.reshape(-1)
54
+ taken = xnp.index_select(array, axis, indices_flat)
55
+ out_shape = array.shape[:axis] + indices_shape + array.shape[axis + 1 :]
56
+ return taken.reshape(out_shape)
57
+ else:
58
+ return xnp.index_select(array, axis, indices)
59
+ else:
60
+ return xnp.take(array, indices, axis=axis)
61
+
62
+
63
+ def eye(
64
+ size: int,
65
+ dtype: Any,
66
+ reference_array: enpt.FloatArray['...'],
67
+ xnp: Any = None,
68
+ ) -> enpt.FloatArray['...']:
69
+ """Creates a 2D identity matrix.
70
+
71
+ Args:
72
+ size: The number of rows (and columns) in the output matrix.
73
+ dtype: The data type of the output matrix.
74
+ reference_array: An array used to determine the device (e.g. for PyTorch).
75
+ xnp: Optional array module (e.g. numpy, jax.numpy, torch) to use. If not
76
+ provided, it is auto-detected from reference_array.
77
+
78
+ Returns:
79
+ A 2D identity matrix of shape (size, size).
80
+ """
81
+ if xnp is None:
82
+ xnp = enp.lazy.get_xnp(reference_array)
83
+ if enp.lazy.is_torch(reference_array):
84
+ return xnp.eye(size, dtype=dtype, device=reference_array.device)
85
+ else:
86
+ return xnp.eye(size, dtype=dtype)
87
+
88
+
89
+ def reshape_with_batch_dims(
90
+ array: enpt.FloatArray['...'],
91
+ target_suffix: tuple[int, ...],
92
+ reference_array: enpt.FloatArray['...'],
93
+ num_reference_non_batch_dims: int,
94
+ ) -> enpt.FloatArray['...']:
95
+ """Reshapes an array by prepending the batch shape of a reference array.
96
+
97
+ This function extracts the batch dimensions of `reference_array` (all but the
98
+ last `num_reference_non_batch_dims` dimensions) and prepends them to the
99
+ `target_suffix` shape. The input `array` is then reshaped to this combined
100
+ shape.
101
+
102
+ Args:
103
+ array: The array to reshape.
104
+ target_suffix: The shape suffix to append after the batch dimensions.
105
+ reference_array: The array from which to extract the batch dimensions.
106
+ num_reference_non_batch_dims: The number of trailing dimensions in
107
+ `reference_array` that represent non-batch dimensions.
108
+
109
+ Returns:
110
+ The reshaped array with shape `(*batch_shape, *target_suffix)`.
111
+ """
112
+ xnp = enp.lazy.get_xnp(reference_array)
113
+ shape = _graph_shape(reference_array)
114
+ batch_shape = shape[:-num_reference_non_batch_dims]
115
+
116
+ if enp.lazy.is_tf(reference_array):
117
+ output_shape = enp.lazy.tf.concat(
118
+ [batch_shape, list(target_suffix)], axis=0
119
+ )
120
+ else:
121
+ output_shape = batch_shape + target_suffix
122
+ return xnp.reshape(array, output_shape)
123
+
124
+
125
+ def zeros_with_batch_dims(
126
+ reference_array: enpt.FloatArray['...'],
127
+ num_reference_non_batch_dims: int,
128
+ suffix_shape: tuple[int, ...],
129
+ dtype: Any,
130
+ ) -> enpt.FloatArray['...']:
131
+ """Creates a zeros array with the batch shape of a reference array.
132
+
133
+ This function extracts the batch dimensions of `reference_array` (all but the
134
+ last `num_reference_non_batch_dims` dimensions) and creates an array of zeros
135
+ with the combined shape `(*batch_shape, *suffix_shape)`.
136
+
137
+ Args:
138
+ reference_array: The array from which to extract the batch dimensions.
139
+ num_reference_non_batch_dims: The number of trailing dimensions in
140
+ `reference_array` that represent non-batch dimensions.
141
+ suffix_shape: The shape suffix of the output array.
142
+ dtype: The data type of the output array.
143
+
144
+ Returns:
145
+ An array of zeros of shape `(*batch_shape, *suffix_shape)` on the same
146
+ device as `reference_array`.
147
+ """
148
+ xnp = enp.lazy.get_xnp(reference_array)
149
+
150
+ shape = _graph_shape(reference_array)
151
+ batch_shape = shape[:-num_reference_non_batch_dims]
152
+ array_kwargs = dict(dtype=dtype)
153
+
154
+ if enp.lazy.is_tf_xnp(xnp):
155
+ full_shape = enp.lazy.tf.concat([batch_shape, list(suffix_shape)], axis=0)
156
+ else:
157
+ full_shape = batch_shape + suffix_shape
158
+ if enp.lazy.is_torch(reference_array):
159
+ array_kwargs['device'] = reference_array.device
160
+ return xnp.zeros(full_shape, **array_kwargs)
161
+
162
+
163
+ def axis_angle_to_rotation_matrix(
164
+ axis_angle: enpt.FloatArray['... 3'],
165
+ epsilon: float = _EPSILON,
166
+ ) -> enpt.FloatArray['... 3 3']:
167
+ """Builds a 3x3 rotation matrix from an axis-angle vector.
168
+
169
+ Uses Rodrigues' rotation formula:
170
+ R = I + sin(theta) * K + (1 - cos(theta)) * K^2
171
+ where K is the skew-symmetric cross-product matrix of the unit rotation axis.
172
+
173
+ Args:
174
+ axis_angle: The rotation vector where the direction represents the rotation
175
+ axis and the magnitude represents the rotation angle in radians. Shape:
176
+ `(..., 3)`.
177
+ epsilon: A small value to avoid division by zero when the axis-angle vector
178
+ has zero magnitude.
179
+
180
+ Returns:
181
+ A batch of 3x3 rotation matrices. Shape: `(..., 3, 3)`.
182
+ """
183
+ xnp = enp.get_np_module(axis_angle)
184
+
185
+ norm_squared = xnp.sum(xnp.square(axis_angle), axis=-1, keepdims=True)
186
+ angle = xnp.sqrt(
187
+ xnp.maximum(norm_squared, xnp.asarray(epsilon, dtype=norm_squared.dtype))
188
+ )
189
+ axis = axis_angle / angle
190
+
191
+ sin_angle, cos_angle = xnp.sin(angle), xnp.cos(angle)
192
+ sin_angle, cos_angle = sin_angle[..., None], cos_angle[..., None]
193
+
194
+ matrix = xnp.broadcast_to(
195
+ eye(3, dtype=angle.dtype, reference_array=axis_angle, xnp=xnp),
196
+ (*axis_angle.shape[:-1], 3, 3),
197
+ )
198
+
199
+ skew_01 = -axis[..., 2]
200
+ skew_02 = axis[..., 1]
201
+ skew_10 = axis[..., 2]
202
+ skew_12 = -axis[..., 0]
203
+ skew_20 = -axis[..., 1]
204
+ skew_21 = axis[..., 0]
205
+ row0 = xnp.stack([xnp.zeros_like(skew_01), skew_01, skew_02], axis=-1)
206
+ row1 = xnp.stack([skew_10, xnp.zeros_like(skew_12), skew_12], axis=-1)
207
+ row2 = xnp.stack([skew_20, skew_21, xnp.zeros_like(skew_21)], axis=-1)
208
+ skew_symmetric_axis_matrix = xnp.stack([row0, row1, row2], axis=-2)
209
+
210
+ matrix = (
211
+ matrix
212
+ + sin_angle * skew_symmetric_axis_matrix
213
+ + (1.0 - cos_angle)
214
+ * xnp.matmul(skew_symmetric_axis_matrix, skew_symmetric_axis_matrix)
215
+ )
216
+
217
+ return matrix
218
+
219
+
220
+ def joint_transforms_world(
221
+ joints: enpt.FloatArray['... J 3'],
222
+ rotations: enpt.FloatArray['... J 3'],
223
+ translation: enpt.FloatArray['... 3'],
224
+ joint_parent_indices: Sequence[int],
225
+ ) -> enpt.FloatArray['... J 4 4']:
226
+ """Computes the world-space transformation matrices for each joint.
227
+
228
+ This function traverses the joint hierarchy and applies forward kinematics
229
+ to compute the global/world space transforms of each joint, combining
230
+ joint rotations and joint translations.
231
+
232
+ Args:
233
+ joints: The bind-pose joints positions. Shape: `(..., J, 3)`.
234
+ rotations: The joint rotations as axis-angle vectors. Shape: `(..., J, 3)`.
235
+ translation: The translation vector for the root joint. Shape: `(..., 3)`.
236
+ joint_parent_indices: A sequence of parent indices for each joint. The root
237
+ joint must have itself or a sentinel as parent (usually index 0 has parent
238
+ 0).
239
+
240
+ Returns:
241
+ A batch of 4x4 transformation matrices in world space for each joint.
242
+ Shape: `(..., J, 4, 4)`.
243
+ """
244
+ xnp = enp.get_np_module(joints)
245
+ num_joints = len(joint_parent_indices)
246
+
247
+ # Gather the parent's joints position for each joint.
248
+ # Under JAX/TF/NumPy, we can index with parents.
249
+ # But we must exclude the root (index 0 has parent 0, but is not used).
250
+ parent_indices_except_root = xnp.asarray(
251
+ joint_parent_indices[1:], dtype=xnp.int32
252
+ )
253
+ joint_parents = take(joints, parent_indices_except_root, axis=-2, xnp=xnp)
254
+
255
+ # Since we cannot do in-place assignments, we construct the local
256
+ # transformation matrices by combining rotation matrices and translation
257
+ # vectors.
258
+ local_rotations = axis_angle_to_rotation_matrix(rotations)
259
+
260
+ # Construct local translations.
261
+ root_translation = joints[..., 0, :] + translation
262
+ non_root_translations = joints[..., 1:, :] - joint_parents
263
+ local_translations = xnp.concatenate(
264
+ [root_translation[..., None, :], non_root_translations], axis=-2
265
+ )
266
+
267
+ # Combine rotation and translation.
268
+ # local_transforms: (..., J, 3, 4)
269
+ local_transforms_3x4 = xnp.concatenate(
270
+ [local_rotations, local_translations[..., None]], axis=-1
271
+ )
272
+
273
+ # Append [0, 0, 0, 1] row.
274
+ bottom_row = zeros_with_batch_dims(
275
+ joints, 2, (num_joints, 1, 4), dtype=joints.dtype
276
+ )
277
+ # Set the last element to 1.0.
278
+ bottom_row = bottom_row + xnp.asarray(
279
+ [0.0, 0.0, 0.0, 1.0], dtype=joints.dtype
280
+ )
281
+
282
+ local_transforms = xnp.concatenate(
283
+ [local_transforms_3x4, bottom_row], axis=-2
284
+ )
285
+
286
+ # Compute forward kinematics.
287
+ # We cannot loop with direct array assignment in TF/JAX graph mode.
288
+ # We must stack them sequentially.
289
+ # FK: T_world[i] = T_world[parent[i]] @ T_local[i]
290
+ # Since skeleton is a tree, we can build the list of transforms sequentially.
291
+ world_transforms_list = [local_transforms[..., 0, :, :]]
292
+ for joint_index in range(1, num_joints):
293
+ parent_transform = world_transforms_list[joint_parent_indices[joint_index]]
294
+ current_transform = (
295
+ parent_transform @ local_transforms[..., joint_index, :, :]
296
+ )
297
+ world_transforms_list.append(current_transform)
298
+
299
+ return xnp.stack(world_transforms_list, axis=-3)
300
+
301
+
302
+ def linear_blend_skinning(
303
+ local_vertices: enpt.FloatArray['... V 3'],
304
+ joint_positions: enpt.FloatArray['... J 3'],
305
+ rotations: enpt.FloatArray['... J 3'],
306
+ translation: enpt.FloatArray['... 3'],
307
+ skinning_weights: enpt.FloatArray['J V'],
308
+ joint_parent_indices: Sequence[int],
309
+ ) -> enpt.FloatArray['... V 3']:
310
+ """Poses the GNM vertices using Linear Blend Skinning (LBS).
311
+
312
+ Linear Blend Skinning computes posed vertex positions by taking a weighted
313
+ sum of the transformations applied to each joint, where the weights are
314
+ defined per vertex and per joint.
315
+
316
+ Args:
317
+ local_vertices: The vertex positions in the local character space, with
318
+ shape `(..., V, 3)`.
319
+ joint_positions: The joint positions in bind pose, with shape `(..., J, 3)`.
320
+ rotations: Joint rotations as axis-angle vectors, with shape `(..., J, 3)`.
321
+ translation: Translation vector for the root joint, with shape: `(..., 3)`.
322
+ skinning_weights: Skinning weights defining joint influence per vertex with
323
+ shape `(J, V)`.
324
+ joint_parent_indices: Sequence of parent indices for each joint.
325
+
326
+ Returns:
327
+ The posed skinned vertices with shape `(..., V, 3)`.
328
+ """
329
+ xnp = enp.get_np_module(local_vertices)
330
+ num_joints = skinning_weights.shape[0]
331
+
332
+ # The local-to-world transforms of each joint, after posing, (N, J, 4, 4).
333
+ joint_transforms_world_local = joint_transforms_world(
334
+ joint_positions, rotations, translation, joint_parent_indices
335
+ )
336
+
337
+ # deltas = T_world[..., :3, :3] @ joints
338
+ deltas = xnp.einsum(
339
+ '...jik,...jk->...ji',
340
+ joint_transforms_world_local[..., :3, :3],
341
+ joint_positions,
342
+ )
343
+ deltas = deltas[..., None] # (..., J, 3, 1)
344
+
345
+ offset_3x3 = zeros_with_batch_dims(
346
+ local_vertices, 2, (num_joints, 3, 3), dtype=local_vertices.dtype
347
+ )
348
+ bottom_row = zeros_with_batch_dims(
349
+ local_vertices, 2, (num_joints, 1, 4), dtype=local_vertices.dtype
350
+ )
351
+
352
+ offset = xnp.concatenate([offset_3x3, deltas], axis=-1)
353
+ offset = xnp.concatenate([offset, bottom_row], axis=-2)
354
+ joint_transforms = joint_transforms_world_local - offset
355
+
356
+ # Convert the vertices to homogeneous coordinates.
357
+ ones = (
358
+ zeros_with_batch_dims(
359
+ local_vertices,
360
+ 2,
361
+ (local_vertices.shape[-2], 1),
362
+ dtype=local_vertices.dtype,
363
+ )
364
+ + 1.0
365
+ )
366
+ vertices_h = xnp.concatenate([local_vertices, ones], axis=-1)
367
+
368
+ # Perform Linear Blend Skinning.
369
+ vertices_skinned = xnp.einsum(
370
+ 'jv,...jmn,...vn->...vm',
371
+ skinning_weights,
372
+ joint_transforms,
373
+ vertices_h,
374
+ )[..., :3]
375
+
376
+ return vertices_skinned
377
+
378
+
379
+ def vertex_positions_bind_pose(
380
+ identity: enpt.FloatArray['... I'] | None,
381
+ expression: enpt.FloatArray['... E'] | None,
382
+ template_vertex_positions: enpt.FloatArray['V 3'],
383
+ vertex_identity_basis: enpt.FloatArray['I V 3'],
384
+ expression_basis: enpt.FloatArray['E V 3'],
385
+ ) -> enpt.FloatArray['... V 3']:
386
+ """Computes vertices in the bind pose, with identity and expression applied.
387
+
388
+ This function computes the neutral shape of the model by applying the identity
389
+ (shape) blendshapes and expression (expression) blendshapes to the template
390
+ vertices.
391
+
392
+ Args:
393
+ identity: The identity parameter vector. Shape: `(..., I)`.
394
+ expression: The expression parameter vector. Shape: `(..., E)`.
395
+ template_vertex_positions: The base template vertices. Shape: `(V, 3)`.
396
+ vertex_identity_basis: The identity displacement basis. Shape: `(I, V, 3)`.
397
+ expression_basis: The expression displacement basis. Shape: `(E, V, 3)`.
398
+
399
+ Returns:
400
+ The template vertices with shape and expression offsets applied. Shape:
401
+ `(..., V, 3)`.
402
+ """
403
+ xnp = enp.get_np_module(template_vertex_positions)
404
+
405
+ # Apply linear identity and expression bases.
406
+ identity_deltas = 0.0
407
+ if identity is not None:
408
+ identity_deltas = xnp.einsum(
409
+ '...i,ijk->...jk', identity, vertex_identity_basis
410
+ )
411
+
412
+ expression_deltas = 0.0
413
+ if expression is not None:
414
+ expression_deltas = xnp.einsum(
415
+ '...i,ijk->...jk', expression, expression_basis
416
+ )
417
+
418
+ return template_vertex_positions + identity_deltas + expression_deltas
419
+
420
+
421
+ def joint_positions_bind_pose(
422
+ identity: enpt.FloatArray['... I'] | None,
423
+ template_joint_positions: enpt.FloatArray['J 3'],
424
+ joint_identity_basis: enpt.FloatArray['I J 3'],
425
+ ) -> enpt.FloatArray['... J 3']:
426
+ """Joint positions in the bind pose, with identity basis applied.
427
+
428
+ Computes joint locations in the bind pose by applying the identity shape
429
+ regressor basis to the base template joint positions.
430
+
431
+ Args:
432
+ identity: The identity parameter vector. Shape: `(..., I)`.
433
+ template_joint_positions: Base template joint positions. Shape: `(J, 3)`.
434
+ joint_identity_basis: Identity regressor basis for joints. Shape: `(I, J,
435
+ 3)`.
436
+
437
+ Returns:
438
+ The template joints with shape offsets applied. Shape: `(..., J, 3)`.
439
+ """
440
+ xnp = enp.get_np_module(template_joint_positions)
441
+ deltas = 0.0
442
+ if identity is not None:
443
+ deltas = xnp.einsum('...i,ijk->...jk', identity, joint_identity_basis)
444
+
445
+ return template_joint_positions + deltas
446
+
447
+
448
+ def compute_pose_correctives(
449
+ rotations: enpt.FloatArray['... J 3'] | None,
450
+ pose_correctives_regressor: enpt.FloatArray['F F2'] | None,
451
+ template_vertex_positions: enpt.FloatArray['V 3'],
452
+ num_joints: int,
453
+ num_vertices: int,
454
+ ) -> enpt.FloatArray['... V 3']:
455
+ """Applies pose-dependent corrective shape offsets to vertices.
456
+
457
+ Pose correctives modify the mesh vertices based on joint rotation angles to
458
+ fix skinning artifacts (e.g. at elbows, knees).
459
+
460
+ Args:
461
+ rotations: Joint rotations as axis-angle vectors, with shape `(..., J, 3)`.
462
+ pose_correctives_regressor: The regressor mapping pose features to vertex
463
+ offsets, with shape `(F, F2)`.
464
+ template_vertex_positions: The base template vertices, used as reference for
465
+ device and shape, with shape: `(V, 3)`.
466
+ num_joints: The total number of joints.
467
+ num_vertices: The total number of vertices.
468
+
469
+ Returns:
470
+ The corrective vertex offsets. Shape: `(..., V, 3)`.
471
+ """
472
+ xnp = enp.get_np_module(template_vertex_positions)
473
+
474
+ if pose_correctives_regressor is None or rotations is None:
475
+ return zeros_with_batch_dims(
476
+ template_vertex_positions,
477
+ 2,
478
+ (num_vertices, 3),
479
+ dtype=template_vertex_positions.dtype,
480
+ )
481
+
482
+ rotation_matrices = axis_angle_to_rotation_matrix(rotations)
483
+
484
+ # Construct eye matrices.
485
+ eye_matrix = eye(3, dtype=rotations.dtype, reference_array=rotations, xnp=xnp)
486
+ # Broadcast to matching shape.
487
+ eye_reshaped = xnp.reshape(eye_matrix, (1,) * (rotations.ndim - 1) + (3, 3))
488
+ pose_features = rotation_matrices - eye_reshaped
489
+
490
+ pose_features = reshape_with_batch_dims(
491
+ pose_features, (num_joints * 9,), rotations, 2
492
+ )
493
+
494
+ pose_deltas = xnp.einsum(
495
+ '...f,fv->...v', pose_features, pose_correctives_regressor
496
+ )
497
+ return reshape_with_batch_dims(pose_deltas, (num_vertices, 3), rotations, 2)
498
+
499
+
500
+ def _graph_shape(array: enpt.FloatArray['...']) -> tuple[int, ...]:
501
+ """Returns the shape of an array, supporting graph-mode arrays."""
502
+ if enp.lazy.is_tf(array):
503
+ return enp.lazy.tf.shape(array)
504
+ else:
505
+ return array.shape
gnm/shape/gnm_data_loader.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """GNM data loader."""
16
+
17
+ # from collections.abc import Mapping, Sequence
18
+ from collections.abc import Sequence
19
+ import functools
20
+ from typing import Any
21
+
22
+ from absl import logging
23
+ from etils import epath
24
+ from gnm.shape import gnm_data_schema
25
+ from gnm.shape.data.versions import gnm_catalog
26
+ from gnm.shape.data.versions import gnm_specs
27
+ import numpy as np
28
+
29
+ _pkg = __package__ or 'gnm.shape'
30
+ _MODELS_VERSIONS_DIR = epath.resource_path(f'{_pkg}.data.versions')
31
+ _VARIANT_TO_MODEL_FILE_NAME_MAP = gnm_catalog.VARIANT_TO_MODEL_FILE_NAME_MAP
32
+
33
+
34
+ class GNMModelDataNotLinkedError(Exception):
35
+ """Raised when a GNM model data is not linked into the binary."""
36
+
37
+ pass
38
+
39
+
40
+ def _get_model_path_from_version_and_variant(
41
+ version: gnm_specs.GNMMajorVersion,
42
+ variant: gnm_specs.GNMVariant,
43
+ ) -> epath.Path:
44
+ """Returns the GNM model runfiles path for given variant and version."""
45
+ version_value = major_to_newest_full_version(version).value.replace('.', '_')
46
+ version_dir_name = f'v{version_value}'
47
+ model_file_name = f'{_VARIANT_TO_MODEL_FILE_NAME_MAP[variant]}.npz'
48
+ return _MODELS_VERSIONS_DIR / version_dir_name / model_file_name
49
+
50
+
51
+ def major_to_newest_full_version(
52
+ major: gnm_specs.GNMMajorVersion,
53
+ ) -> gnm_specs.GNMVersion:
54
+ """Returns the newest GNMVersion for a given GNMMajorVersion."""
55
+ minors = [
56
+ e for e in gnm_specs.GNMVersion if e.value.split('.')[0] == major.value
57
+ ]
58
+ return sorted(minors, key=lambda e: int(e.value.split('.')[1]))[-1]
59
+
60
+
61
+ def full_version_to_major(
62
+ version: gnm_specs.GNMVersion,
63
+ ) -> gnm_specs.GNMMajorVersion:
64
+ """Returns the major version of a GNMVersion."""
65
+ return gnm_specs.GNMMajorVersion(version.value.split('.')[0])
66
+
67
+
68
+ @functools.lru_cache
69
+ def load_model_from_runfile(
70
+ version: gnm_specs.GNMMajorVersion, variant: gnm_specs.GNMVariant
71
+ ) -> dict[str, Any]:
72
+ """Loads GNM model data from a runfile for the given version/variant."""
73
+ model_file = _get_model_path_from_version_and_variant(version, variant)
74
+
75
+ logging.info(
76
+ 'Loading GNM model version %s, variant %s from runfiles: %s',
77
+ version,
78
+ variant,
79
+ model_file,
80
+ )
81
+ with model_file.open('rb') as f:
82
+ data_dict = dict(np.load(f))
83
+
84
+ # Validate the data.
85
+ valid, missing, extra = _validate_gnm_data(data_dict)
86
+ if not valid:
87
+ raise ValueError(
88
+ f'Validation failed for version {version}, variant {variant}.'
89
+ f' Missing: {missing}, Extra: {extra}'
90
+ )
91
+
92
+ return _standardize_gnm_data_types(data_dict)
93
+
94
+
95
+ def _validate_gnm_data(
96
+ data: dict[str, Any],
97
+ ) -> tuple[bool, Sequence[str], Sequence[str]]:
98
+ """Validates the GNM data dict.
99
+
100
+ It returns any extra or missing fields and a boolean indicating if the data
101
+ dict has exactly the expected fields.
102
+
103
+ Args:
104
+ data: The GNM data dict to validate.
105
+
106
+ Returns:
107
+ A tuple of (bool, Sequence[str], Sequence[str]) indicating if the data dict
108
+ has exactly the expected fields, the missing fields and the extra fields.
109
+ """
110
+ expected_fields = gnm_data_schema.GNM_DATA_ATTRIBUTES
111
+ missing_fields = list(set(expected_fields) - set(data.keys()))
112
+ extra_fields = list(set(data.keys()) - set(expected_fields))
113
+ return not missing_fields and not extra_fields, missing_fields, extra_fields
114
+
115
+
116
+ def _standardize_gnm_data_types(data: dict[str, Any]) -> dict[str, Any]:
117
+ """Standardizes the GNM data data types in-place.
118
+
119
+ The data loaded from the .npz model files are defined as Numpy arrays. This
120
+ function converts the items to their expected Python types.
121
+
122
+ Args:
123
+ data: The GNM data dict to standardize.
124
+
125
+ Returns:
126
+ The GNM data dict with standardized data types.
127
+ """
128
+ keys_to_standardize = (
129
+ 'version',
130
+ 'variant',
131
+ 'identity_names',
132
+ 'joint_names',
133
+ 'expression_names',
134
+ 'mesh_component_names',
135
+ 'vertex_group_names',
136
+ )
137
+ for k in keys_to_standardize:
138
+ if k not in data:
139
+ raise ValueError(f'Required attribute {k} not found in GNM data.')
140
+
141
+ try:
142
+ data['version'] = gnm_specs.GNMVersion(str(data['version']))
143
+ except ValueError as e:
144
+ version_val = data['version']
145
+ raise ValueError(f'Unknown GNM version: {version_val}') from e
146
+ try:
147
+ data['variant'] = gnm_specs.GNMVariant(str(data['variant']))
148
+ except ValueError as e:
149
+ variant_val = data['variant']
150
+ raise ValueError(f'Unknown GNM variant: {variant_val}') from e
151
+ for key in (
152
+ 'identity_names',
153
+ 'joint_names',
154
+ 'expression_names',
155
+ 'mesh_component_names',
156
+ 'vertex_group_names',
157
+ ):
158
+ data[key] = [str(v) for v in data[key]]
159
+
160
+ return data
161
+
162
+
163
+ def _rename_legacy_basis_names(data: dict[str, Any]) -> None:
164
+ """Renames legacy identity and expression basis names to their new names."""
165
+ if 'identity_names' in data:
166
+ identity_renames = [
167
+ ('eyeballs_', 'eyes_'),
168
+ ]
169
+ new_identity_names = []
170
+ for name in data['identity_names']:
171
+ name_str = str(name)
172
+ for old_prefix, new_prefix in identity_renames:
173
+ if name_str.startswith(old_prefix) and not name_str.startswith(
174
+ new_prefix
175
+ ):
176
+ name_str = new_prefix + name_str[len(old_prefix) :]
177
+ break
178
+ new_identity_names.append(name_str)
179
+ data['identity_names'] = new_identity_names
180
+
181
+ if 'expression_names' in data:
182
+ expression_renames = [
183
+ ('left_eye_', 'left_eye_region_'),
184
+ ('right_eye_', 'right_eye_region_'),
185
+ ('mouth_', 'lower_face_region_'),
186
+ ('eyeballs_', 'pupils_'),
187
+ ]
188
+ new_expression_names = []
189
+ for name in data['expression_names']:
190
+ name_str = str(name)
191
+ for old_prefix, new_prefix in expression_renames:
192
+ if name_str.startswith(old_prefix) and not name_str.startswith(
193
+ new_prefix
194
+ ):
195
+ name_str = new_prefix + name_str[len(old_prefix) :]
196
+ break
197
+ new_expression_names.append(name_str)
198
+ data['expression_names'] = new_expression_names
199
+
200
+
201
+ def _populate_legacy_vertex_group_aliases(data: dict[str, Any]) -> None:
202
+ """Populates standardized aliases for legacy vertex groups."""
203
+ if 'vertex_group_names' not in data or 'vertex_groups' not in data:
204
+ return
205
+
206
+ vertex_group_names_list = [str(v) for v in data['vertex_group_names']]
207
+ vertex_group_weights_array = np.array(data['vertex_groups'], dtype=np.float32)
208
+ if (
209
+ vertex_group_weights_array.ndim != 2
210
+ or len(vertex_group_names_list) != vertex_group_weights_array.shape[0]
211
+ ):
212
+ return
213
+
214
+ index_lookup = {name: i for i, name in enumerate(vertex_group_names_list)}
215
+ extra_group_names = []
216
+ extra_group_weights = []
217
+
218
+ # Mapping to retrieve new vertex group name by combinating legacy ones.
219
+ # Note that this list is not exhaustive, and mostly here to support the
220
+ # standard API calls in third_party/py/gnm.
221
+ vertex_group_mappings = {
222
+ 'upper_teeth_and_gums': ('upper_teeth',),
223
+ 'lower_teeth_and_gums': ('lower_teeth',),
224
+ 'eyes': ('left_eye', 'right_eye'),
225
+ 'eye_interiors': ('eyeball_interior',),
226
+ 'eye_exteriors': ('eyeball_exterior',),
227
+ 'scleras': ('sclera',),
228
+ 'irises': ('iris',),
229
+ 'pupils': ('pupil',),
230
+ 'ears': ('left_ear', 'right_ear'),
231
+ }
232
+ for target_group_name, source_group_names in vertex_group_mappings.items():
233
+ if target_group_name not in index_lookup and all(
234
+ name in index_lookup for name in source_group_names
235
+ ):
236
+ source_weights_list = [
237
+ vertex_group_weights_array[index_lookup[name]]
238
+ for name in source_group_names
239
+ ]
240
+ extra_group_names.append(target_group_name)
241
+ extra_group_weights.append(np.maximum.reduce(source_weights_list))
242
+
243
+ if extra_group_names:
244
+ data['vertex_group_names'] = vertex_group_names_list + extra_group_names
245
+ data['vertex_groups'] = np.concatenate(
246
+ (vertex_group_weights_array, np.stack(extra_group_weights)), axis=0
247
+ )
gnm/shape/gnm_data_schema.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """GNM data schema."""
16
+
17
+ GNM_DATA_ATTRIBUTES = [
18
+ 'version',
19
+ 'variant',
20
+ 'template_vertex_positions',
21
+ 'template_joint_positions',
22
+ 'vertex_identity_basis',
23
+ 'joint_identity_basis',
24
+ 'expression_basis',
25
+ 'identity_names',
26
+ 'joint_names',
27
+ 'expression_names',
28
+ 'joint_parent_indices',
29
+ 'skinning_weights',
30
+ 'quads',
31
+ 'triangles',
32
+ 'quad_uvs',
33
+ 'triangle_uvs',
34
+ 'mesh_component_names',
35
+ 'mirror_indices',
36
+ 'joint_regressor',
37
+ 'pose_correctives_regressor',
38
+ 'bone_aligned_template_joint_orientations',
39
+ 'vertex_groups',
40
+ 'vertex_group_names',
41
+ ]
gnm/shape/gnm_landmarks.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """GNM landmarks definitions and loaders."""
16
+
17
+ import dataclasses
18
+ import enum
19
+ from etils import epath
20
+ from gnm.shape.data.versions import gnm_specs
21
+ import numpy as np
22
+
23
+ _pkg = __package__ or 'gnm.shape'
24
+ _LANDMARKS_DIR = epath.resource_path(_pkg) / 'data' / 'landmarks'
25
+
26
+
27
+ class GNMLandmarksType(enum.StrEnum):
28
+ """Available GNM landmarks types."""
29
+
30
+ HEAD_SPARSE_68 = 'head_sparse_68'
31
+
32
+
33
+ _LANDMARKS_TYPE_TO_BODY_PART_MAP = {
34
+ GNMLandmarksType.HEAD_SPARSE_68: gnm_specs.GNMBodyPart.HEAD,
35
+ }
36
+
37
+
38
+ @dataclasses.dataclass(frozen=True)
39
+ class LandmarksConfiguration:
40
+ """Configuration holding landmark definition indices and barycentric weights.
41
+ """
42
+
43
+ indices: np.ndarray
44
+ weights: np.ndarray
45
+
46
+
47
+ class GNMLandmarksDataNotLinkedError(Exception):
48
+ """Raised when GNM landmark definition data is not linked into the binary."""
49
+
50
+ pass
51
+
52
+
53
+ def _check_landmarks_data_linked(landmarks_type: GNMLandmarksType) -> None:
54
+ """Validates that the GNM landmarks file exists in the binary package."""
55
+ landmark_file = _get_landmark_path(landmarks_type)
56
+ if not landmark_file.exists():
57
+ file_name = f'{landmarks_type}.txt'
58
+ build_target = f'//third_party/py/gnm/shape/data/landmarks:{file_name}'
59
+ raise GNMLandmarksDataNotLinkedError(
60
+ f'The GNM landmark data for {landmarks_type} is not linked into the'
61
+ ' binary package. Please check the BUILD file and make sure to include'
62
+ f' \'data = ["{build_target}"]\'.'
63
+ )
64
+
65
+
66
+ def _get_landmark_path(landmarks_type: GNMLandmarksType) -> epath.Path:
67
+ """Returns the path to the landmark definition file."""
68
+ file_name = f'{landmarks_type}.txt'
69
+ return _LANDMARKS_DIR / file_name
70
+
71
+
72
+ def check_body_part_compatibility(
73
+ landmarks_type: GNMLandmarksType,
74
+ body_part: gnm_specs.GNMBodyPart,
75
+ ) -> None:
76
+ """Checks if the landmark type is compatible with the model body part."""
77
+ expected_body_part = _LANDMARKS_TYPE_TO_BODY_PART_MAP[landmarks_type]
78
+ if body_part != expected_body_part:
79
+ raise ValueError(
80
+ f'Landmark type {landmarks_type} is only compatible with GNM models'
81
+ f' of body part {expected_body_part}, but got'
82
+ f' body part {body_part}.'
83
+ )
84
+
85
+
86
+ def load_landmarks(
87
+ landmarks_type: GNMLandmarksType,
88
+ ) -> LandmarksConfiguration:
89
+ """Loads landmark indices and weights for the given landmark type.
90
+
91
+ Args:
92
+ landmarks_type: The type of landmarks to load.
93
+
94
+ Returns:
95
+ A LandmarksConfiguration instance containing indices and weights arrays.
96
+ """
97
+ landmark_file = _get_landmark_path(landmarks_type)
98
+ with landmark_file.open('r') as f:
99
+ data = np.loadtxt(f)
100
+ indices = data[:, ::2].astype(np.int32)
101
+ weights = data[:, 1::2].astype(np.float32)
102
+ return LandmarksConfiguration(indices=indices, weights=weights)
gnm/shape/gnm_numpy.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """NumPy implementation of the GNM model .
16
+
17
+ Example usage:
18
+ ```
19
+ gnm = gnm_numpy.from_local(version=GNMVersion.V3, variant=GNMVariant.HEAD)
20
+
21
+ # Generate random identity, expression, rotations, translation parameters
22
+ identity = np.random.normal(size=gnm.identity_dim)
23
+ expression = np.zeros(gnm.expression_dim)
24
+ rotations = np.random.uniform(-1, 1, size=(gnm.num_joints, 3)) * 0.15
25
+ translation = np.random.uniform(-1, 1, size=(3,)) * 0.15
26
+
27
+ vertices = gnm(identity, expression, rotations, translation)
28
+ ```
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections.abc import Mapping
34
+ import dataclasses
35
+ from typing import Any
36
+
37
+ from absl import logging
38
+ from gnm.shape import gnm_common
39
+ from gnm.shape import gnm_landmarks
40
+ from gnm.shape import gnm_xnp
41
+ from gnm.shape.data.versions import gnm_specs
42
+ import numpy as np
43
+ import numpy.typing as npt
44
+
45
+ GNMVersion = gnm_specs.GNMVersion
46
+ GNMMajorVersion = gnm_specs.GNMMajorVersion
47
+ GNMVariant = gnm_specs.GNMVariant
48
+ GNMBodyPart = gnm_specs.GNMBodyPart
49
+ GNMLandmarksType = gnm_landmarks.GNMLandmarksType
50
+
51
+ _rotation_matrix = gnm_common.axis_angle_to_rotation_matrix
52
+
53
+
54
+ @dataclasses.dataclass(frozen=False, kw_only=True, init=False)
55
+ class GNM(gnm_xnp.GNM):
56
+ """NumPy implementation of the GNM parametric model.
57
+
58
+ GNM is a mesh-generating function. Given identity, expression, joint
59
+ rotation, and translation parameters, it produces vertices of a mesh.
60
+
61
+ The GNM class also surfaces useful data for down-stream users, e.g. the
62
+ names of each expression dimension, and the topology of the mesh.
63
+
64
+ Shape dimensions are denoted:
65
+ * V: Number of vertices.
66
+ * J: Number of joints.
67
+ * I: Identity basis dimensionality.
68
+ * E: Expression basis dimensionality.
69
+ * Q: The number of quads in the mesh topology.
70
+ * T: The number of triangles, in a triangulated version of the mesh topology.
71
+ * G: Number of vertex groups.
72
+ * P: Number of separate parts of the mesh.
73
+
74
+ Attributes:
75
+ version: The version of the loaded GNM model.
76
+ variant: The variant of the loaded GNM model.
77
+ template_vertex_positions: Vertex positions in the template mesh, (V, 3).
78
+ template_joint_positions: Joint positions in the template GNM, (J, 3).
79
+ vertex_identity_basis: The vertex identity basis of the model, (I, V, 3).
80
+ joint_identity_basis: The joint identity basis of the model, (I, J, 3).
81
+ expression_basis: The vertex expression basis, aka blend-shapes, (E, V, 3).
82
+ identity_names: The name of each identity in the identity basis.
83
+ joint_names: The name of each joint in the skeleton.
84
+ expression_names: The name of each expression in the expression basis.
85
+ joint_parent_indices: Parent's index for each joint in the skeleton, (J).
86
+ skinning_weights: The model's skinning weights, (J, V).
87
+ quads: The mesh topology as quads, (Q, 4).
88
+ triangles: The mesh topology as triangles, (T, 3).
89
+ quad_uvs: Texture coordinates per quad, (Q, 4, 2).
90
+ triangle_uvs: Texture coordinates per triangle, (T, 3, 2).
91
+ mesh_component_names: The vertex group name corresponding to each separate
92
+ mesh part.
93
+ mirror_indices: The index of each vertex on the other side of the mesh.
94
+ joint_regressor: Mapping from vertices to joints, (J, V).
95
+ pose_correctives_regressor: Matrix for pose correctives, (9*J, 3*V).
96
+ bone_aligned_template_joint_orientations: The bone-aligned rotations for
97
+ each joint, (J, 3, 3). If they do not exist in the GNM npz, they are set
98
+ to the identity matrix. Note that these are not used to compute the GNM
99
+ joint and vertex positions.
100
+ vertex_groups: The weights in each vertex group, (G, V).
101
+ vertex_group_names: The name of each vertex group, (G,).
102
+ joint_connections: The joint connections of the GNM rig.
103
+ joint_children_indices: A dictionary that contains the joint indices of the
104
+ children of each joint.
105
+ skinning_segmentation: A decomposition of the vertices into separate parts
106
+ based on skinning weights.
107
+ num_vertices: The number of vertices in the mesh V.
108
+ num_joints: The number of joints in the skeleton J.
109
+ identity_dim: The dimensionality of the linear identity basis I.
110
+ expression_dim: The dimensionality of the linear expression basis E.
111
+ edge_list: The quad topology represented as a list of directed edges (E, 2).
112
+ vertex_uvs: Per-vertex UV texture coordinates shaped (V, 2).
113
+ """
114
+
115
+ @classmethod
116
+ def _from_model_data(
117
+ cls,
118
+ model_data: Mapping[str, Any],
119
+ ) -> GNM:
120
+ """Creates a GNM instance from model data."""
121
+ return cls._from_model_data_with_xnp(model_data, xnp=np)
122
+
123
+ def compute_vertex_normals(
124
+ self,
125
+ vertices: npt.NDArray[np.floating],
126
+ ) -> npt.NDArray[np.floating]:
127
+ """Compute vertex normals for GNM mesh."""
128
+ batch_dims = vertices.shape[:-2]
129
+ num_vertices = vertices.shape[-2]
130
+ vertices_flat = vertices.reshape(-1, num_vertices, 3)
131
+
132
+ face_vertices = vertices_flat[:, self.triangles, :]
133
+ v0 = face_vertices[..., 0, :]
134
+ v1 = face_vertices[..., 1, :]
135
+ v2 = face_vertices[..., 2, :]
136
+ face_normals_area = np.cross(v1 - v0, v2 - v0, axis=-1)
137
+
138
+ vertex_normals = np.zeros_like(vertices_flat)
139
+ np.add.at(
140
+ vertex_normals,
141
+ (slice(None), self.triangles, slice(None)),
142
+ face_normals_area[:, :, None, :],
143
+ )
144
+
145
+ normal_magnitudes = np.linalg.norm(vertex_normals, axis=-1, keepdims=True)
146
+ if np.any(np.isclose(normal_magnitudes, 0.0)):
147
+ logging.warning(
148
+ 'Some vertex normals have zero magnitude. This is unexpected and'
149
+ ' likely indicates triangle collapse.'
150
+ )
151
+ vertex_normals = vertex_normals / np.maximum(normal_magnitudes, 1e-8)
152
+ return vertex_normals.reshape(batch_dims + (num_vertices, 3))
gnm/shape/gnm_test_utils.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Test utilities for GNM, primarily for mocking TFHub access."""
16
+
17
+ from gnm.shape import gnm_numpy
18
+ import numpy as np
19
+ import numpy.typing as npt
20
+
21
+ DTypeLike = npt.DTypeLike
22
+
23
+
24
+ def default_gnm_parameters(
25
+ gnm: gnm_numpy.GNM,
26
+ batch_shape: tuple[int, ...] | None = None,
27
+ dtype: DTypeLike = np.float32,
28
+ ) -> dict[str, npt.NDArray[np.floating]]:
29
+ """Returns default GNM parameters.
30
+
31
+ Args:
32
+ gnm: The GNM model to use.
33
+ batch_shape: The batch shape to use for the parameters.
34
+ dtype: The dtype to use for the parameters.
35
+
36
+ Returns:
37
+ A dictionary of GNM parameters.
38
+ """
39
+ if batch_shape is None:
40
+ batch_shape = tuple()
41
+
42
+ parameters = {
43
+ 'identity': np.zeros(batch_shape + (gnm.identity_dim,)),
44
+ 'expression': np.zeros(batch_shape + (gnm.expression_dim,)),
45
+ 'rotations': np.zeros(batch_shape + (gnm.num_joints, 3)),
46
+ 'translation': np.zeros(batch_shape + (3,)),
47
+ }
48
+
49
+ return {k: v.astype(dtype) for k, v in parameters.items()}
50
+
51
+
52
+ def random_gnm_parameters(
53
+ gnm: gnm_numpy.GNM,
54
+ batch_shape: tuple[int, ...] | None = None,
55
+ seed: int | np.random.Generator | None = None,
56
+ identity_range: tuple[float, float] = (-1.0, 1.0),
57
+ expression_range: tuple[float, float] = (-1.0, 1.0),
58
+ rotation_range_degrees: tuple[float, float] = (-30.0, 30.0),
59
+ translation_range_m: tuple[float, float] = (-0.01, 0.01),
60
+ dtype: DTypeLike = np.float32,
61
+ ) -> dict[str, npt.NDArray[np.floating]]:
62
+ """Returns random GNM parameters sampled from a uniform distribution.
63
+
64
+ Args:
65
+ gnm: The GNM model to use.
66
+ batch_shape: The batch shape to use for the parameters.
67
+ seed: The random seed to use. If None, a default seed is used.
68
+ identity_range: The range of values to sample identity from.
69
+ expression_range: The range of values to sample expression from.
70
+ rotation_range_degrees: The range of values to sample rotation from (in
71
+ degrees).
72
+ translation_range_m: The range of values to sample translation from (in
73
+ meters).
74
+ dtype: The dtype to use for the parameters.
75
+
76
+ Returns:
77
+ A dictionary of GNM parameters.
78
+ """
79
+ if seed is None:
80
+ seed = np.random.default_rng(0)
81
+ if batch_shape is None:
82
+ batch_shape = tuple()
83
+
84
+ rng = np.random.default_rng(seed)
85
+
86
+ rotation_range_radians = np.deg2rad(rotation_range_degrees)
87
+
88
+ parameters = {
89
+ 'identity': rng.uniform(
90
+ *identity_range, size=batch_shape + (gnm.identity_dim,)
91
+ ),
92
+ 'expression': rng.uniform(
93
+ *expression_range, size=batch_shape + (gnm.expression_dim,)
94
+ ),
95
+ 'rotations': rng.uniform(
96
+ *rotation_range_radians, size=batch_shape + (gnm.num_joints, 3)
97
+ ),
98
+ 'translation': rng.uniform(*translation_range_m, size=batch_shape + (3,)),
99
+ }
100
+
101
+ return {k: v.astype(dtype) for k, v in parameters.items()}
gnm/shape/gnm_utils.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Utility functions for manipulating GNM identity and expression basis."""
16
+
17
+ from collections.abc import Mapping, Sequence
18
+ import enum
19
+ from typing import Any
20
+
21
+ from gnm.shape import gnm_numpy
22
+ import numpy as np
23
+ import numpy.typing as npt
24
+ from scipy import linalg
25
+
26
+
27
+ class BasisType(enum.StrEnum):
28
+ IDENTITY = 'identity'
29
+ EXPRESSION = 'expression'
30
+
31
+
32
+ BASIS_DIM_ATTRIBUTE_MAP = {
33
+ BasisType.IDENTITY: 'identity_dim',
34
+ BasisType.EXPRESSION: 'expression_dim',
35
+ }
36
+
37
+ _BASIS_NAMES_ATTRIBUTE_MAP = {
38
+ BasisType.IDENTITY: 'identity_names',
39
+ BasisType.EXPRESSION: 'expression_names',
40
+ }
41
+
42
+ _BASIS_BASIS_ATTRIBUTE_MAP = {
43
+ BasisType.IDENTITY: 'vertex_identity_basis',
44
+ BasisType.EXPRESSION: 'expression_basis',
45
+ }
46
+
47
+
48
+ # Used to compute the expression components stddevs.
49
+ _EXPRESSION_REGION_VERTEX_GROUP_MAP = {
50
+ 'left_eye_region': ['expression_basis_left_eye'],
51
+ 'right_eye_region': ['expression_basis_right_eye'],
52
+ 'lower_face_region': [
53
+ 'expression_basis_mouth_nose_ears',
54
+ 'lower_teeth_and_gums',
55
+ 'tongue',
56
+ ],
57
+ 'tongue': ['tongue'],
58
+ 'pupils': ['eye_interiors'],
59
+ }
60
+
61
+ # Used to compute the identity components stddevs.
62
+ _IDENTITY_REGION_VERTEX_GROUP_MAP = {}
63
+
64
+ _BASIS_REGION_VERTEX_GROUP_MAP = {
65
+ BasisType.IDENTITY: _IDENTITY_REGION_VERTEX_GROUP_MAP,
66
+ BasisType.EXPRESSION: _EXPRESSION_REGION_VERTEX_GROUP_MAP,
67
+ }
68
+
69
+ _TONGUE_MEAN_NAME = 'tongue_mean'
70
+
71
+
72
+ # gnm_utils supports only the following variants.
73
+ _SUPPORTED_HEAD_VARIANTS = frozenset([
74
+ gnm_numpy.GNMVariant.HEAD,
75
+ ])
76
+
77
+ _SUPPORTED_GNM_VARIANTS = frozenset(_SUPPORTED_HEAD_VARIANTS)
78
+
79
+
80
+ def _get_major_version(version_str: str) -> int:
81
+ """Extracts the major version from a version string."""
82
+ try:
83
+ v = version_str.lower().lstrip('v')
84
+ return int(v.split('.')[0])
85
+ except ValueError:
86
+ return 1
87
+
88
+
89
+ def validate_gnm(gnm: gnm_numpy.GNM) -> None:
90
+ """Validates that the GNM model is supported by gnm_utils."""
91
+ variant = getattr(gnm, 'variant', None)
92
+ if variant not in _SUPPORTED_GNM_VARIANTS:
93
+ raise ValueError(f"GNM variant '{variant}' is not supported by gnm_utils.")
94
+
95
+
96
+ def _validate_conversion(
97
+ from_gnm: gnm_numpy.GNM,
98
+ to_gnm: gnm_numpy.GNM,
99
+ basis_type: BasisType,
100
+ ) -> None:
101
+ """Validates that the GNM models can be converted between each other."""
102
+ del basis_type
103
+
104
+ validate_gnm(from_gnm)
105
+ validate_gnm(to_gnm)
106
+
107
+
108
+ def get_gnm_attribute(gnm: gnm_numpy.GNM, attribute: str) -> Any:
109
+ """Returns the attribute from the GNM model."""
110
+ if (data := getattr(gnm, attribute, None)) is None:
111
+ raise ValueError(
112
+ f'No attribute {attribute} in GNM model version {gnm.version} and'
113
+ f' variant {gnm.variant}.'
114
+ )
115
+ return data
116
+
117
+
118
+ def _get_corresponding_indices(
119
+ basis_type: BasisType,
120
+ from_gnm: gnm_numpy.GNM,
121
+ to_gnm: gnm_numpy.GNM,
122
+ ) -> tuple[np.ndarray, np.ndarray]:
123
+ """Get indices mapping common names subset from `from_basis` to `to_basis`."""
124
+ attribute_name = _BASIS_NAMES_ATTRIBUTE_MAP[basis_type]
125
+ from_names = np.array(get_gnm_attribute(from_gnm, attribute_name))
126
+ to_names = np.array(get_gnm_attribute(to_gnm, attribute_name))
127
+
128
+ # Check uniqueness of names.
129
+ from_names_unique = np.unique(from_names).size == from_names.size
130
+ to_names_unique = np.unique(to_names).size == to_names.size
131
+ if not (from_names_unique and to_names_unique):
132
+ raise ValueError('Names must be unique.')
133
+
134
+ from_indices, to_indices = np.where(from_names[:, None] == to_names[None])
135
+ return from_indices.astype(np.int32), to_indices.astype(np.int32)
136
+
137
+
138
+ def components_names(
139
+ basis_type: BasisType, gnm: gnm_numpy.GNM
140
+ ) -> Sequence[str]:
141
+ """Returns the names of the basis components."""
142
+ attribute_name = _BASIS_NAMES_ATTRIBUTE_MAP[basis_type]
143
+ return get_gnm_attribute(gnm, attribute_name)
144
+
145
+
146
+ def region_names_from_components_names(names: Sequence[str]) -> Sequence[str]:
147
+ """Returns the names of the basis regions keeping the components order."""
148
+ regions_names = ['_'.join(n.split('_')[:-1]) for n in names]
149
+ _, indices = np.unique(regions_names, return_index=True)
150
+ return [regions_names[i] for i in np.sort(indices)]
151
+
152
+
153
+ def region_to_component_indices_map(
154
+ basis_type: BasisType, gnm: gnm_numpy.GNM
155
+ ) -> Mapping[str, np.ndarray]:
156
+ """Return a map from basis region name to component indices."""
157
+ names = components_names(basis_type, gnm)
158
+ region_names = region_names_from_components_names(names)
159
+
160
+ region_indices_map = {}
161
+ for r in region_names:
162
+ indices = [index for index, name in enumerate(names) if name.startswith(r)]
163
+ region_indices_map[r] = np.array(indices, dtype=np.int32)
164
+ return region_indices_map
165
+
166
+
167
+ def convert_coefficients(
168
+ coefficients: npt.NDArray[np.floating],
169
+ *,
170
+ basis_type: BasisType,
171
+ from_gnm: gnm_numpy.GNM,
172
+ to_gnm: gnm_numpy.GNM,
173
+ ) -> npt.NDArray[np.floating]:
174
+ """Convert coefficients from one GNM model to another.
175
+
176
+ Args:
177
+ coefficients: Coefficients corresponding to the basis in `from_gnm`, shape
178
+ ([A1, ..., An], C_from).
179
+ basis_type: Type of the basis, either identity or expression.
180
+ from_gnm: GNM model instance to convert from.
181
+ to_gnm: GNM model instance to convert to.
182
+
183
+ Returns:
184
+ Coefficients corresponding to the basis in `to_gnm`, shape
185
+ ([A1, ..., An], C_to).
186
+ """
187
+ if from_gnm is None or to_gnm is None:
188
+ raise ValueError('Both from_gnm and to_gnm must be provided.')
189
+
190
+ _validate_conversion(from_gnm, to_gnm, basis_type)
191
+
192
+ batch_shape = coefficients.shape[:-1]
193
+
194
+ attribute_name = BASIS_DIM_ATTRIBUTE_MAP[basis_type]
195
+ from_dim = get_gnm_attribute(from_gnm, attribute_name)
196
+ to_dim = get_gnm_attribute(to_gnm, attribute_name)
197
+
198
+ if coefficients.shape[-1] != from_dim:
199
+ raise ValueError(
200
+ f'Dimension mismatch: {coefficients.shape[-1]} vs {from_dim}.'
201
+ )
202
+
203
+ to_indices, from_indices = _get_corresponding_indices(
204
+ basis_type, from_gnm=to_gnm, to_gnm=from_gnm
205
+ )
206
+
207
+ # Convert.
208
+ converted = np.zeros(batch_shape + (to_dim,), dtype=coefficients.dtype)
209
+ converted[..., to_indices] = coefficients[..., from_indices]
210
+ return converted
211
+
212
+
213
+ def _coefficients_to_regions(
214
+ coefficients: np.ndarray, basis_type: BasisType, gnm: gnm_numpy.GNM
215
+ ) -> dict[str, np.ndarray]:
216
+ """Splits the coefficients ([A1, ..., An], C) to face regions."""
217
+ validate_gnm(gnm)
218
+ attribute_name = BASIS_DIM_ATTRIBUTE_MAP[basis_type]
219
+ coeffs_dim = get_gnm_attribute(gnm, attribute_name)
220
+ if coefficients.shape[-1] != coeffs_dim:
221
+ raise ValueError(
222
+ f'Unexpected coefficients dimension of {coefficients.shape[-1]}.'
223
+ f' Expected {coeffs_dim} for the basis type {basis_type} and GNM model'
224
+ f' version {gnm.version} and variant {gnm.variant}.'
225
+ )
226
+
227
+ region_indices = region_to_component_indices_map(basis_type, gnm)
228
+ regions = {
229
+ r: coefficients[..., indices] for r, indices in region_indices.items()
230
+ }
231
+ for region_name, region_coefficients in regions.items():
232
+ assert region_coefficients.base is None or (
233
+ region_coefficients.base is not coefficients
234
+ and region_coefficients.base is not coefficients.base
235
+ ), (
236
+ f'Region {region_name} is a view of the original array. This is most'
237
+ ' likely a bug and may cause unexpected behavior.'
238
+ )
239
+ return regions
240
+
241
+
242
+ def _regions_to_coefficients(
243
+ regions: Mapping[str, np.ndarray],
244
+ basis_type: BasisType,
245
+ gnm: gnm_numpy.GNM,
246
+ ) -> np.ndarray:
247
+ """Combines face regions to a single basis array ([A1, ..., An], C])."""
248
+ validate_gnm(gnm)
249
+ attribute_name = BASIS_DIM_ATTRIBUTE_MAP[basis_type]
250
+ coeffs_dim = get_gnm_attribute(gnm, attribute_name)
251
+
252
+ if not regions:
253
+ return np.zeros((coeffs_dim,), dtype=np.float32)
254
+
255
+ region_indices = region_to_component_indices_map(basis_type, gnm)
256
+
257
+ tmp_region_name = list(regions.keys())[0]
258
+ batch_shape = regions[tmp_region_name].shape[:-1]
259
+ dtype = regions[tmp_region_name].dtype
260
+
261
+ coeffs = np.zeros(batch_shape + (coeffs_dim,), dtype=dtype)
262
+ for region_name, values in regions.items():
263
+ if region_name not in region_indices:
264
+ gnm_type_name = getattr(gnm, 'model_type', 'unknown')
265
+ raise ValueError(f'No region {region_name} in model {gnm_type_name}.')
266
+ else:
267
+ coeffs[..., region_indices[region_name]] = values
268
+
269
+ return coeffs
270
+
271
+
272
+ def _region_components(
273
+ basis_type: BasisType, gnm: gnm_numpy.GNM
274
+ ) -> dict[str, np.ndarray]:
275
+ """Return per-region basis components, each region shape (Ri, V, 3)."""
276
+ validate_gnm(gnm)
277
+ attribute_name = _BASIS_BASIS_ATTRIBUTE_MAP[basis_type]
278
+ basis = get_gnm_attribute(gnm, attribute_name)
279
+ region_indices = region_to_component_indices_map(basis_type, gnm)
280
+ return {r: basis[indices] for r, indices in region_indices.items()}
281
+
282
+
283
+ def compute_scaling_for_basis(
284
+ vertex_basis: npt.NDArray[np.floating],
285
+ ) -> npt.NDArray[np.floating]:
286
+ """Computes the scaling matrix to make basis equivalent to vertex losses.
287
+
288
+ Args:
289
+ vertex_basis: The basis to compute the scaling for, shape (D, V, 3).
290
+
291
+ Returns:
292
+ A scaling matrix to apply to differences between basis components,
293
+ shape (D, D).
294
+ """
295
+ basis_dim = vertex_basis.shape[0]
296
+ vertex_basis = vertex_basis.reshape(basis_dim, -1).astype(np.float64)
297
+
298
+ basis_dot_transpose = vertex_basis @ vertex_basis.T
299
+ return linalg.sqrtm(basis_dot_transpose).astype(np.float32)
300
+
301
+
302
+ def scaling_for_expression_loss(
303
+ gnm: gnm_numpy.GNM,
304
+ ) -> npt.NDArray[np.floating]:
305
+ """Scaling matrix to make expression losses equivalent to vertex losses.
306
+
307
+ The loss between expression components is: || e1 - e2 ||^2.
308
+ The loss between vertices is: || v1 - v2 ||^2.
309
+ || v1 - v2 ||^2 = || (template + B_v @ e1 - (template + B_v @ c2) ||^2 =
310
+ || B_v @ (c1 - c2) ||^2 =
311
+ (c1 - c2)^T @ B_v^T @ B_v @ (c1 - c2) =
312
+ (c1 - c2)^T @ M @ (c1 - c2), M = B_v^T @ B_v (1)
313
+
314
+ This if we want to make the expression loss equivalent to the vertex loss,
315
+ we need to scale the expression loss by: M = matrix_sqrt(B_v^T @ B_v).
316
+
317
+
318
+ Args:
319
+ gnm: The GNM model instance to compute the scaling for.
320
+
321
+ Returns:
322
+ A scaling matrix to apply to differences between expression components,
323
+ shape (E, E).
324
+ """
325
+ validate_gnm(gnm)
326
+ return compute_scaling_for_basis(
327
+ get_gnm_attribute(gnm, _BASIS_BASIS_ATTRIBUTE_MAP[BasisType.EXPRESSION])
328
+ )
329
+
330
+
331
+ def scaling_for_identity_loss(
332
+ gnm: gnm_numpy.GNM,
333
+ ) -> npt.NDArray[np.floating]:
334
+ """Scaling matrix to make identity losses equivalent to vertex losses."""
335
+ validate_gnm(gnm)
336
+ return compute_scaling_for_basis(
337
+ get_gnm_attribute(gnm, _BASIS_BASIS_ATTRIBUTE_MAP[BasisType.IDENTITY])
338
+ )
339
+
340
+
341
+ def identity_to_regions(
342
+ identity: np.ndarray, gnm: gnm_numpy.GNM
343
+ ) -> dict[str, np.ndarray]:
344
+ """Splits the identity ([A1, ..., An], I]) to face regions."""
345
+ return _coefficients_to_regions(
346
+ identity, basis_type=BasisType.IDENTITY, gnm=gnm
347
+ )
348
+
349
+
350
+ def expression_to_regions(
351
+ expression: np.ndarray, gnm: gnm_numpy.GNM
352
+ ) -> dict[str, np.ndarray]:
353
+ """Splits the expression ([A1, ..., An], E]) to face regions."""
354
+ return _coefficients_to_regions(
355
+ expression, basis_type=BasisType.EXPRESSION, gnm=gnm
356
+ )
357
+
358
+
359
+ def expression_regions_indices(
360
+ gnm: gnm_numpy.GNM,
361
+ ) -> dict[str, np.ndarray]:
362
+ """Returns mapping from region name to expression component indices."""
363
+ validate_gnm(gnm)
364
+ return dict(region_to_component_indices_map(BasisType.EXPRESSION, gnm))
365
+
366
+
367
+ def identity_regions_indices(
368
+ gnm: gnm_numpy.GNM,
369
+ ) -> dict[str, np.ndarray]:
370
+ """Returns mapping from region name to identity component indices."""
371
+ validate_gnm(gnm)
372
+ return dict(region_to_component_indices_map(BasisType.IDENTITY, gnm))
373
+
374
+
375
+ def identity_dim(gnm: gnm_numpy.GNM) -> int:
376
+ """Returns the identity dimension of the GNM model."""
377
+ validate_gnm(gnm)
378
+ return get_gnm_attribute(gnm, 'identity_dim')
379
+
380
+
381
+ def expression_dim(gnm: gnm_numpy.GNM) -> int:
382
+ """Returns the expression dimension of the GNM model."""
383
+ validate_gnm(gnm)
384
+ return get_gnm_attribute(gnm, 'expression_dim')
385
+
386
+
387
+ def expression_regions_dims(gnm: gnm_numpy.GNM) -> dict[str, int]:
388
+ """Returns mapping from region name to expression component dimension."""
389
+ validate_gnm(gnm)
390
+ region_indices = region_to_component_indices_map(BasisType.EXPRESSION, gnm)
391
+ return {k: v.size for k, v in region_indices.items()}
392
+
393
+
394
+ def identity_regions_dims(gnm: gnm_numpy.GNM) -> dict[str, int]:
395
+ """Returns mapping from region name to identity component dimension."""
396
+ validate_gnm(gnm)
397
+ region_indices = region_to_component_indices_map(BasisType.IDENTITY, gnm)
398
+ return {k: v.size for k, v in region_indices.items()}
399
+
400
+
401
+ def joint_rotations_to_regions(
402
+ joint_rotations: np.ndarray, gnm: gnm_numpy.GNM
403
+ ) -> dict[str, np.ndarray]:
404
+ """Splits the joint rotations ([A1, ..., An], J, 3]) to invidual regions."""
405
+ validate_gnm(gnm)
406
+ joint_names: list[str] = get_gnm_attribute(gnm, 'joint_names')
407
+ if len(joint_names) != joint_rotations.shape[-2]:
408
+ raise ValueError(
409
+ f'Number of joints in the GNM model ({len(joint_names)}) does not'
410
+ ' match the number of joints in the input rotations'
411
+ f' ({joint_rotations.shape[-2]}).'
412
+ )
413
+ return {
414
+ joint_name: np.copy(joint_rotations[..., joint_index, :])
415
+ for joint_index, joint_name in enumerate(joint_names)
416
+ }
417
+
418
+
419
+ def regions_to_identity(
420
+ regions: Mapping[str, np.ndarray], gnm: gnm_numpy.GNM
421
+ ) -> np.ndarray:
422
+ """Combines face regions to single identity array ([A1, ..., An], I])."""
423
+ return _regions_to_coefficients(regions, BasisType.IDENTITY, gnm)
424
+
425
+
426
+ def regions_to_expression(
427
+ regions: Mapping[str, np.ndarray], gnm: gnm_numpy.GNM
428
+ ) -> np.ndarray:
429
+ """Combines face regions to single expression array ([A1, ..., An], E])."""
430
+ return _regions_to_coefficients(regions, BasisType.EXPRESSION, gnm)
431
+
432
+
433
+ def regions_to_joint_rotations(
434
+ regions: Mapping[str, np.ndarray], gnm: gnm_numpy.GNM
435
+ ) -> np.ndarray:
436
+ """Combines joint rotations to single rotations array.
437
+
438
+ Returns array of shape ([A1, ..., An], J, 3]).
439
+ """
440
+ validate_gnm(gnm)
441
+ joint_names: list[str] = get_gnm_attribute(gnm, 'joint_names')
442
+ if not regions:
443
+ raise ValueError('No regions provided.')
444
+
445
+ tmp_region_name = list(regions.keys())[0]
446
+ batch_shape = regions[tmp_region_name].shape[:-1]
447
+ dtype = regions[tmp_region_name].dtype
448
+
449
+ missing_joint_value = np.zeros((*batch_shape, 3), dtype=dtype)
450
+ return np.stack(
451
+ [
452
+ regions.get(
453
+ joint_name,
454
+ missing_joint_value,
455
+ )
456
+ for joint_name in joint_names
457
+ ],
458
+ axis=-2,
459
+ )
460
+
461
+
462
+ def region_identity_components(
463
+ gnm: gnm_numpy.GNM,
464
+ ) -> dict[str, np.ndarray]:
465
+ """Return per-region identity components, each region shape (Ri, V, 3)."""
466
+ return _region_components(BasisType.IDENTITY, gnm)
467
+
468
+
469
+ def region_expression_components(
470
+ gnm: gnm_numpy.GNM,
471
+ ) -> dict[str, np.ndarray]:
472
+ """Return per-region expression components, each region shape (Ri, V, 3)."""
473
+ return _region_components(BasisType.EXPRESSION, gnm)
474
+
475
+
476
+ def _sigmas(basis_type: BasisType, gnm: gnm_numpy.GNM) -> np.ndarray:
477
+ """Return the standard deviations of basis components, shape (C, )."""
478
+ validate_gnm(gnm)
479
+
480
+ if basis_type == BasisType.IDENTITY:
481
+ raise NotImplementedError('Identity sigmas are not implemented yet.')
482
+
483
+ region_group = _BASIS_REGION_VERTEX_GROUP_MAP[basis_type]
484
+ regions = {}
485
+ for name, components in _region_components(basis_type, gnm).items():
486
+ vertex_indices = gnm.vertex_group_indices(*region_group[name])
487
+ regions[name] = np.linalg.norm(components[:, vertex_indices], axis=(1, 2))
488
+ region_sigmas = _regions_to_coefficients(regions, basis_type, gnm)
489
+
490
+ # Treat tongue mean in expressions individually, it is not a real component.
491
+ if basis_type == BasisType.EXPRESSION:
492
+ expression_names = gnm.expression_names
493
+ if _TONGUE_MEAN_NAME in expression_names:
494
+ # Convert to list to use .index()
495
+ expression_names_list = list(expression_names)
496
+ region_sigmas[expression_names_list.index(_TONGUE_MEAN_NAME)] = 1.0
497
+
498
+ if np.any(np.isclose(region_sigmas, 0.0, atol=1e-8)):
499
+ raise RuntimeError('Expression components have zero standard deviation.')
500
+
501
+ return region_sigmas
502
+
503
+
504
+ def expression_sigmas(gnm: gnm_numpy.GNM) -> np.ndarray:
505
+ """Return the standard deviations of expression components, shape (E, )."""
506
+ return _sigmas(BasisType.EXPRESSION, gnm)
507
+
508
+
509
+ def _normalize_coefficients(
510
+ coefficients: np.ndarray, basis_type: BasisType, gnm: gnm_numpy.GNM
511
+ ) -> np.ndarray:
512
+ """Normalize coefficients ([A1, ..., An], C) by corresponding sigmas."""
513
+ s = _sigmas(basis_type, gnm)
514
+ return coefficients / s
515
+
516
+
517
+ def _denormalize_coefficients(
518
+ coefficients: np.ndarray, basis_type: BasisType, gnm: gnm_numpy.GNM
519
+ ) -> np.ndarray:
520
+ """Denormalize coefficients ([A1, ..., An], C) by corresponding sigmas."""
521
+ s = _sigmas(basis_type, gnm)
522
+ return coefficients * s
523
+
524
+
525
+ def normalize_expression(
526
+ expression: np.ndarray, gnm: gnm_numpy.GNM
527
+ ) -> np.ndarray:
528
+ """Normalize expression coefficients ([A1, ..., An], E)."""
529
+ return _normalize_coefficients(expression, BasisType.EXPRESSION, gnm)
530
+
531
+
532
+ def denormalize_expression(
533
+ expression: np.ndarray, gnm: gnm_numpy.GNM
534
+ ) -> np.ndarray:
535
+ """Denormalize expression coefficients ([A1, ..., An], E)."""
536
+ return _denormalize_coefficients(expression, BasisType.EXPRESSION, gnm)
537
+
538
+
539
+ def identity_region_names(gnm: gnm_numpy.GNM) -> Sequence[str]:
540
+ """Gets identity basis region names ordered as they appear in the basis."""
541
+ validate_gnm(gnm)
542
+ names = components_names(BasisType.IDENTITY, gnm)
543
+ return region_names_from_components_names(names)
544
+
545
+
546
+ def expression_region_names(gnm: gnm_numpy.GNM) -> Sequence[str]:
547
+ """Gets expression basis region names ordered as they appear in the basis."""
548
+ validate_gnm(gnm)
549
+ names = components_names(BasisType.EXPRESSION, gnm)
550
+ return region_names_from_components_names(names)
gnm/shape/gnm_xnp.py ADDED
@@ -0,0 +1,738 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Unified backend-agnostic implementation of the GNM model using etils.enp."""
16
+
17
+ from __future__ import annotations
18
+
19
+ import abc
20
+ import collections
21
+ from collections.abc import Mapping, Sequence
22
+ import dataclasses
23
+ import functools
24
+ from typing import Any, Self
25
+
26
+ from absl import logging
27
+ from etils import enp
28
+ from gnm.shape import gnm_base
29
+ from gnm.shape import gnm_common
30
+ from gnm.shape import gnm_landmarks
31
+ from gnm.shape.data.versions import gnm_specs
32
+ import numpy as np
33
+ import numpy.typing as npt
34
+
35
+ enpt = enp.typing
36
+
37
+ _NONZERO_THRESHOLD = 1e-4
38
+ _EPSILON = 1e-8
39
+
40
+
41
+ @dataclasses.dataclass(frozen=False, kw_only=True, init=False)
42
+ class GNM(gnm_base.GNMBase):
43
+ """Backend-agnostic implementation of the GNM parametric model.
44
+
45
+ This class is abstract. Use a concrete backend subclass (gnm_numpy.GNM,
46
+ gnm_pytorch.GNM, or gnm_jax.GNM).
47
+
48
+ GNM is a mesh-generating function. Given identity, expression, joint
49
+ rotation, and translation parameters, it produces vertices of a mesh.
50
+
51
+ The GNM class also surfaces useful data for down-stream users, e.g. the
52
+ names of each expression dimension, and the topology of the mesh.
53
+
54
+ Shape dimensions are denoted:
55
+ * V: Number of vertices.
56
+ * J: Number of joints.
57
+ * I: Identity basis dimensionality.
58
+ * E: Expression basis dimensionality.
59
+ * Q: The number of quads in the mesh topology.
60
+ * T: The number of triangles, in a triangulated version of the mesh topology.
61
+ * G: Number of vertex groups.
62
+ * P: Number of separate parts of the mesh.
63
+
64
+ Attributes:
65
+ version: The version of the loaded GNM model.
66
+ variant: The variant of the loaded GNM model.
67
+ cl_number: The CL used to create this model.
68
+ template_vertex_positions: Vertex positions in the template mesh, (V, 3).
69
+ template_joint_positions: Joint positions in the template GNM, (J, 3).
70
+ vertex_identity_basis: The vertex identity basis of the model, (I, V, 3).
71
+ joint_identity_basis: The joint identity basis of the model, (I, J, 3).
72
+ expression_basis: The vertex expression basis, aka blend-shapes, (E, V, 3).
73
+ identity_names: The name of each identity in the identity basis.
74
+ joint_names: The name of each joint in the skeleton.
75
+ expression_names: The name of each expression in the expression basis.
76
+ joint_parent_indices: Parent's index for each joint in the skeleton, (J).
77
+ skinning_weights: The model's skinning weights, (J, V).
78
+ quads: The mesh topology as quads, (Q, 4).
79
+ triangles: The mesh topology as triangles, (T, 3).
80
+ quad_uvs: Texture coordinates per quad, (Q, 4, 2).
81
+ triangle_uvs: Texture coordinates per triangle, (T, 3, 2).
82
+ mesh_component_names: The vertex group name corresponding to each separate
83
+ mesh part.
84
+ mirror_indices: The index of each vertex on the other side of the mesh.
85
+ joint_regressor: Mapping from vertices to joints, (J, V).
86
+ pose_correctives_regressor: Matrix for pose correctives, (9*J, 3*V).
87
+ bone_aligned_template_joint_orientations: The bone-aligned rotations for
88
+ each joint, (J, 3, 3). If they do not exist in the GNM npz, they are set
89
+ to the identity matrix. Note that these are not used to compute the GNM
90
+ joint and vertex positions.
91
+ vertex_groups: The weights in each vertex group, (G, V).
92
+ vertex_group_names: The name of each vertex group, (G,).
93
+ joint_connections: The joint connections of the GNM rig.
94
+ joint_children_indices: A dictionary that contains the joint indices of the
95
+ children of each joint.
96
+ skinning_segmentation: A decomposition of the vertices into separate parts
97
+ based on skinning weights.
98
+ num_vertices: The number of vertices in the mesh V.
99
+ num_joints: The number of joints in the skeleton J.
100
+ identity_dim: The dimensionality of the linear identity basis I.
101
+ expression_dim: The dimensionality of the linear expression basis E.
102
+ edge_list: The quad topology represented as a list of directed edges (E, 2).
103
+ vertex_uvs: Per-vertex UV texture coordinates shaped (V, 2).
104
+ """
105
+
106
+ _shape_error_type = ValueError
107
+
108
+ version: gnm_specs.GNMVersion
109
+ variant: gnm_specs.GNMVariant
110
+ template_vertex_positions: enpt.FloatArray
111
+ template_joint_positions: enpt.FloatArray
112
+ vertex_identity_basis: enpt.FloatArray
113
+ joint_identity_basis: enpt.FloatArray
114
+ expression_basis: enpt.FloatArray
115
+ identity_names: Sequence[str]
116
+ joint_names: Sequence[str]
117
+ expression_names: Sequence[str]
118
+ joint_parent_indices: Sequence[int]
119
+ skinning_weights: enpt.FloatArray
120
+ quads: enpt.IntArray
121
+ triangles: enpt.IntArray
122
+ quad_uvs: enpt.FloatArray
123
+ triangle_uvs: enpt.FloatArray
124
+ mesh_component_names: Sequence[str]
125
+ mirror_indices: enpt.IntArray
126
+ joint_regressor: enpt.FloatArray
127
+ pose_correctives_regressor: enpt.FloatArray
128
+ bone_aligned_template_joint_orientations: enpt.FloatArray
129
+ vertex_groups: enpt.FloatArray
130
+ vertex_group_names: Sequence[str]
131
+
132
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
133
+ del args, kwargs
134
+ raise TypeError(
135
+ f'{self.__class__.__name__} cannot be instantiated directly via'
136
+ ' constructor. Please use a class factory method such as from_local()'
137
+ ' or from_model_data().'
138
+ )
139
+
140
+ def __post_init__(self):
141
+ self._vertex_group_names_lookup: dict[str, int] = {
142
+ name: i for i, name in enumerate(self.vertex_group_names)
143
+ }
144
+ self._xnp = enp.get_np_module(self.template_vertex_positions)
145
+ self._landmarks: dict[
146
+ gnm_landmarks.GNMLandmarksType, tuple[enpt.IntArray, enpt.FloatArray]
147
+ ] = {}
148
+
149
+ @property
150
+ def xnp(self) -> enp.NpModule:
151
+ """Returns the backend module dynamically from G-Nome array state."""
152
+ return self._xnp
153
+
154
+ def __getstate__(self):
155
+ state = self.__dict__.copy()
156
+ state.pop('_xnp', None)
157
+ return state
158
+
159
+ def __setstate__(self, state):
160
+ self.__dict__.update(state)
161
+ self._xnp = enp.get_np_module(self.template_vertex_positions)
162
+
163
+ @classmethod
164
+ def _prepare_init_kwargs(
165
+ cls,
166
+ model_data: Mapping[str, Any],
167
+ xnp: enp.NpModule,
168
+ ) -> dict[str, Any]:
169
+ """Prepares and casts GNM initialization arguments using xnp."""
170
+ init_kwargs = {}
171
+
172
+ # Identify type convert functions.
173
+ def as_float_array(val):
174
+ return xnp.asarray(val, dtype=xnp.float32) if val is not None else None
175
+
176
+ def as_int_array(val):
177
+ return xnp.asarray(val, dtype=xnp.int32) if val is not None else None
178
+
179
+ def as_original(val):
180
+ return val
181
+
182
+ field_converters = {
183
+ 'version': as_original,
184
+ 'variant': as_original,
185
+ 'cl_number': as_original,
186
+ 'template_vertex_positions': as_float_array,
187
+ 'template_joint_positions': as_float_array,
188
+ 'vertex_identity_basis': as_float_array,
189
+ 'joint_identity_basis': as_float_array,
190
+ 'expression_basis': as_float_array,
191
+ 'identity_names': as_original,
192
+ 'joint_names': as_original,
193
+ 'expression_names': as_original,
194
+ 'joint_parent_indices': as_original,
195
+ 'skinning_weights': as_float_array,
196
+ 'quads': as_int_array,
197
+ 'triangles': as_int_array,
198
+ 'quad_uvs': as_float_array,
199
+ 'triangle_uvs': as_float_array,
200
+ 'mesh_component_names': as_original,
201
+ 'mirror_indices': as_int_array,
202
+ 'joint_regressor': as_float_array,
203
+ 'pose_correctives_regressor': as_float_array,
204
+ 'bone_aligned_template_joint_orientations': as_float_array,
205
+ 'vertex_groups': as_float_array,
206
+ 'vertex_group_names': as_original,
207
+ }
208
+
209
+ for field_name, converter in field_converters.items():
210
+ val = model_data.get(field_name, None)
211
+ init_kwargs[field_name] = converter(val)
212
+
213
+ return init_kwargs
214
+
215
+ @classmethod
216
+ def _from_model_data_with_xnp(
217
+ cls,
218
+ model_data: Mapping[str, Any],
219
+ xnp: enp.NpModule,
220
+ ) -> Self:
221
+ """Creates a GNM instance from a model data dictionary and array module."""
222
+ init_kwargs = cls._prepare_init_kwargs(model_data, xnp)
223
+ # pylint: disable=no-value-for-parameter
224
+ instance = super(GNM, cls).__new__(cls)
225
+ # pylint: enable=no-value-for-parameter
226
+ for k, v in init_kwargs.items():
227
+ object.__setattr__(instance, k, v)
228
+ instance.__post_init__()
229
+ return instance
230
+
231
+ @classmethod
232
+ @abc.abstractmethod
233
+ def _from_model_data(
234
+ cls,
235
+ data_dict: Mapping[str, Any],
236
+ ) -> Self:
237
+ """Creates a GNM instance from a model data dictionary."""
238
+ raise NotImplementedError(
239
+ f'{cls.__name__} is an abstract backend-agnostic base class and'
240
+ ' cannot be loaded directly. Use a concrete backend subclass (e.g.,'
241
+ ' gnm_numpy.GNM, gnm_pytorch.GNM).'
242
+ )
243
+
244
+ def to_numpy_data_dict(self) -> dict[str, Any]:
245
+ """Returns a dictionary of the GNM data represented as NumPy arrays."""
246
+ data_dict = {}
247
+ for field in dataclasses.fields(self):
248
+ val = getattr(self, field.name)
249
+ if enp.lazy.is_array(val):
250
+ # Convert JAX, PyTorch or TF tensors to numpy.
251
+ if enp.lazy.is_tf(val):
252
+ val = val.numpy()
253
+ elif enp.lazy.is_jax(val):
254
+ val = np.array(val)
255
+ elif enp.lazy.is_torch(val):
256
+ val = val.detach().cpu().numpy()
257
+ data_dict[field.name] = val
258
+ return data_dict
259
+
260
+ def _check_parameter_shapes(
261
+ self,
262
+ identity: enpt.FloatArray['...'] | None = None,
263
+ expression: enpt.FloatArray['...'] | None = None,
264
+ rotations: enpt.FloatArray['...'] | None = None,
265
+ translation: enpt.FloatArray['...'] | None = None,
266
+ ) -> None:
267
+ error_type = self._shape_error_type
268
+ if identity is not None:
269
+ if identity.ndim < 1 or identity.shape[-1] != self.identity_dim:
270
+ raise error_type(
271
+ f'identity shape mismatch: expected last dim {self.identity_dim},'
272
+ f' got {identity.shape}'
273
+ )
274
+ if expression is not None:
275
+ if expression.ndim < 1 or expression.shape[-1] != self.expression_dim:
276
+ raise error_type(
277
+ 'expression shape mismatch: expected last dim'
278
+ f' {self.expression_dim}, got {expression.shape}'
279
+ )
280
+ if rotations is not None:
281
+ if (
282
+ rotations.ndim < 2
283
+ or rotations.shape[-2] != self.num_joints
284
+ or rotations.shape[-1] != 3
285
+ ):
286
+ raise error_type(
287
+ 'rotations shape mismatch: expected last dims'
288
+ f' {(self.num_joints, 3)}, got {rotations.shape}'
289
+ )
290
+ if translation is not None:
291
+ if translation.ndim < 1 or translation.shape[-1] != 3:
292
+ raise error_type(
293
+ 'translation shape mismatch: expected last dim 3, got'
294
+ f' {translation.shape}'
295
+ )
296
+
297
+ @enp.check_and_normalize_arrays(strict=False)
298
+ def __call__(
299
+ self,
300
+ identity: enpt.FloatArray['A1 ... An I'] | None = None,
301
+ expression: enpt.FloatArray['A1 ... An E'] | None = None,
302
+ rotations: enpt.FloatArray['A1 ... An J 3'] | None = None,
303
+ translation: enpt.FloatArray['A1 ... An 3'] | None = None,
304
+ ) -> enpt.FloatArray['A1 ... An V 3']:
305
+ """Evaluates the GNM mesh-generating function."""
306
+ self._check_parameter_shapes(identity, expression, rotations, translation)
307
+ batch_shape = _check_batch_dims(
308
+ identity, expression, rotations, translation
309
+ )
310
+
311
+ # Fill in missing parameters.
312
+ if identity is None:
313
+ identity = gnm_common.zeros_with_batch_dims(
314
+ self.template_vertex_positions,
315
+ 2,
316
+ (*batch_shape, self.identity_dim),
317
+ dtype=self.template_vertex_positions.dtype,
318
+ )
319
+ if expression is None:
320
+ expression = gnm_common.zeros_with_batch_dims(
321
+ self.template_vertex_positions,
322
+ 2,
323
+ (*batch_shape, self.expression_dim),
324
+ dtype=self.template_vertex_positions.dtype,
325
+ )
326
+ if rotations is None:
327
+ rotations = gnm_common.zeros_with_batch_dims(
328
+ self.template_vertex_positions,
329
+ 2,
330
+ (*batch_shape, self.num_joints, 3),
331
+ dtype=self.template_vertex_positions.dtype,
332
+ )
333
+ if translation is None:
334
+ translation = gnm_common.zeros_with_batch_dims(
335
+ self.template_vertex_positions,
336
+ 2,
337
+ (*batch_shape, 3),
338
+ dtype=self.template_vertex_positions.dtype,
339
+ )
340
+
341
+ # Bind pose vertex positions.
342
+ vertices = self.vertex_positions_bind_pose(identity, expression)
343
+
344
+ # Bind pose joint positions.
345
+ joints = self.joint_positions_bind_pose(identity)
346
+
347
+ # Pose correctives.
348
+ pose_correctives = self.compute_pose_correctives(rotations)
349
+ vertices = vertices + pose_correctives
350
+
351
+ return self.vertex_positions_world(vertices, joints, rotations, translation)
352
+
353
+ @enp.check_and_normalize_arrays(strict=False)
354
+ def vertices_and_landmarks(
355
+ self,
356
+ landmarks_type: gnm_landmarks.GNMLandmarksType,
357
+ identity: enpt.FloatArray['A1 ... An I'] | None = None,
358
+ expression: enpt.FloatArray['A1 ... An E'] | None = None,
359
+ rotations: enpt.FloatArray['A1 ... An J 3'] | None = None,
360
+ translation: enpt.FloatArray['A1 ... An 3'] | None = None,
361
+ ) -> tuple[
362
+ enpt.FloatArray['A1 ... An V 3'],
363
+ enpt.FloatArray['A1 ... An L 3'],
364
+ ]:
365
+ """Evaluates the GNM mesh function and extracts 3D landmarks."""
366
+ gnm_landmarks.check_body_part_compatibility(landmarks_type, self.body_part)
367
+ if landmarks_type not in self._landmarks:
368
+ config = gnm_landmarks.load_landmarks(landmarks_type)
369
+ self._landmarks[landmarks_type] = (
370
+ self.xnp.asarray(config.indices, dtype=self.xnp.int32),
371
+ self.xnp.asarray(config.weights, dtype=self.xnp.float32),
372
+ )
373
+ indices, weights = self._landmarks[landmarks_type]
374
+
375
+ vertices = self(
376
+ identity=identity,
377
+ expression=expression,
378
+ rotations=rotations,
379
+ translation=translation,
380
+ )
381
+ weights = enp.compat.astype(weights, vertices.dtype)
382
+
383
+ face_vertices = gnm_common.take(vertices, indices, axis=-2, xnp=self.xnp)
384
+ landmarks = self.xnp.sum(face_vertices * weights[..., None], axis=-2)
385
+ return vertices, landmarks
386
+
387
+ def vertex_positions_world(
388
+ self,
389
+ vertices: enpt.FloatArray['A1 ... An V 3'],
390
+ joints: enpt.FloatArray['A1 ... An J 3'],
391
+ rotations: enpt.FloatArray['A1 ... An J 3'],
392
+ translation: enpt.FloatArray['A1 ... An 3'],
393
+ ) -> enpt.FloatArray['A1 ... An V 3']:
394
+ """Applies linear blend skinning to GNM vertices."""
395
+ return gnm_common.linear_blend_skinning(
396
+ local_vertices=vertices,
397
+ joint_positions=joints,
398
+ rotations=rotations,
399
+ translation=translation,
400
+ skinning_weights=self.skinning_weights,
401
+ joint_parent_indices=self.joint_parent_indices,
402
+ )
403
+
404
+ apply_linear_blend_skinning = vertex_positions_world
405
+
406
+ @property
407
+ def num_vertices(self) -> int:
408
+ return self.template_vertex_positions.shape[0]
409
+
410
+ @property
411
+ def num_joints(self) -> int:
412
+ return self.skinning_weights.shape[0]
413
+
414
+ @property
415
+ def identity_dim(self) -> int:
416
+ return self.vertex_identity_basis.shape[0]
417
+
418
+ @property
419
+ def expression_dim(self) -> int:
420
+ return self.expression_basis.shape[0]
421
+
422
+ @functools.cached_property
423
+ def edge_list(self) -> npt.NDArray[np.integer]:
424
+ """The quad topology represented as a list of directed edges."""
425
+ quads = np.array(self.quads)
426
+ e1 = quads.ravel()
427
+ e2 = np.roll(quads, -1, axis=1).ravel()
428
+ edges = np.stack([np.minimum(e1, e2), np.maximum(e1, e2)], axis=1)
429
+ edge_keys = e1 + self.num_vertices * e2
430
+ _, unique_indices = np.unique(edge_keys, return_index=True)
431
+ unique_undirected_edges = edges[unique_indices]
432
+ return np.vstack(
433
+ [unique_undirected_edges, np.fliplr(unique_undirected_edges)]
434
+ )
435
+
436
+ @functools.cached_property
437
+ def joint_connections(self) -> npt.NDArray[np.int32]:
438
+ """The bones of the GNM rig."""
439
+ return np.stack(
440
+ [
441
+ np.array(self.joint_parent_indices),
442
+ np.arange(self.num_joints, dtype=np.int32),
443
+ ],
444
+ axis=1,
445
+ )[1:]
446
+
447
+ @functools.cached_property
448
+ def joint_children_indices(self) -> Mapping[int, Sequence[int]]:
449
+ children = collections.defaultdict(list)
450
+ for joint, parent in enumerate(self.joint_parent_indices):
451
+ if joint == 0:
452
+ continue
453
+ children[parent].append(joint)
454
+ return dict(children)
455
+
456
+ @functools.cached_property
457
+ def vertex_uvs(self) -> npt.NDArray[np.floating]:
458
+ """Per-vertex UV texture coordinates."""
459
+ logging.warning(
460
+ 'These do not allow correct texturing across UV-seams. Please use '
461
+ 'per-quad or per-triangle UV coordinates when possible.'
462
+ )
463
+ quads = np.array(self.quads)
464
+ quad_uvs = np.array(self.quad_uvs)
465
+ indices = quads.ravel()
466
+ uvs = quad_uvs.reshape(-1, 2)
467
+ _, last_indices_reversed = np.unique(indices[::-1], return_index=True)
468
+ last_indices = len(indices) - 1 - last_indices_reversed
469
+ vertex_uvs = np.zeros((self.num_vertices, 2), dtype=uvs.dtype)
470
+ vertex_uvs[indices[last_indices]] = uvs[last_indices]
471
+ return vertex_uvs
472
+
473
+ def add_vertex_group(self, name: str, value: enpt.FloatArray['V']) -> None:
474
+ """Adds a new vertex group.
475
+
476
+ Args:
477
+ name: The name of the vertex group.
478
+ value: The vertex group values, shape (V,).
479
+ """
480
+ xnp = self.xnp
481
+ if name in self.vertex_group_names:
482
+ raise ValueError(f'Vertex group {name} already exists.')
483
+ if value.ndim != 1 or value.shape[0] != self.num_vertices:
484
+ raise ValueError(
485
+ f'Vertex group must be 1D array of length {self.num_vertices}.'
486
+ )
487
+
488
+ self.vertex_groups = xnp.concatenate(
489
+ [self.vertex_groups, value[None]], axis=0
490
+ )
491
+ self.vertex_group_names = list(self.vertex_group_names) + [name]
492
+ self._vertex_group_names_lookup[name] = len(self.vertex_group_names) - 1
493
+
494
+ @property
495
+ def skinning_segmentation(self) -> npt.NDArray[np.int32]:
496
+ return np.array(self.skinning_weights).argmax(axis=0)
497
+
498
+ @enp.check_and_normalize_arrays(strict=False)
499
+ def vertex_positions_bind_pose(
500
+ self,
501
+ identity: enpt.FloatArray['A1 ... An I'],
502
+ expression: enpt.FloatArray['A1 ... An E'],
503
+ ) -> enpt.FloatArray['A1 ... An V 3']:
504
+ return gnm_common.vertex_positions_bind_pose(
505
+ identity,
506
+ expression,
507
+ self.template_vertex_positions,
508
+ self.vertex_identity_basis,
509
+ self.expression_basis,
510
+ )
511
+
512
+ @enp.check_and_normalize_arrays(strict=False)
513
+ def joint_positions_bind_pose(
514
+ self, identity: enpt.FloatArray['A1 ... An I']
515
+ ) -> enpt.FloatArray['A1 ... An J 3']:
516
+ return gnm_common.joint_positions_bind_pose(
517
+ identity,
518
+ self.template_joint_positions,
519
+ self.joint_identity_basis,
520
+ )
521
+
522
+ @enp.check_and_normalize_arrays(strict=False)
523
+ def compute_pose_correctives(
524
+ self, rotations: enpt.FloatArray['A1 ... An J 3']
525
+ ) -> enpt.FloatArray['A1 ... An V 3']:
526
+ return gnm_common.compute_pose_correctives(
527
+ rotations,
528
+ self.pose_correctives_regressor,
529
+ self.template_vertex_positions,
530
+ self.num_joints,
531
+ self.num_vertices,
532
+ )
533
+
534
+ @enp.check_and_normalize_arrays(strict=False)
535
+ def joint_transforms_world(
536
+ self,
537
+ joints: enpt.FloatArray['A1 ... An J 3'],
538
+ rotations: enpt.FloatArray['A1 ... An J 3'],
539
+ translation: enpt.FloatArray['A1 ... An 3'],
540
+ ) -> enpt.FloatArray['A1 ... An J 4 4']:
541
+ return gnm_common.joint_transforms_world(
542
+ joints, rotations, translation, self.joint_parent_indices
543
+ )
544
+
545
+ @enp.check_and_normalize_arrays(strict=False)
546
+ def get_posed_joint_transforms(
547
+ self,
548
+ identity: enpt.FloatArray['A1 ... An I'],
549
+ rotations: enpt.FloatArray['A1 ... An J 3'],
550
+ translation: enpt.FloatArray['A1 ... An 3'],
551
+ ) -> enpt.FloatArray['A1 ... An J 4 4']:
552
+ self._check_parameter_shapes(
553
+ identity=identity, rotations=rotations, translation=translation
554
+ )
555
+ _check_batch_dims(
556
+ identity=identity, rotations=rotations, translation=translation
557
+ )
558
+ return self.joint_transforms_world(
559
+ joints=self.joint_positions_bind_pose(identity),
560
+ rotations=rotations,
561
+ translation=translation,
562
+ )
563
+
564
+ def vertex_group(self, name: str) -> npt.NDArray[np.floating]:
565
+ try:
566
+ return np.array(self.vertex_groups[self._vertex_group_names_lookup[name]])
567
+ except KeyError as exc:
568
+ raise KeyError(
569
+ f'Vertex group {name} not found in {self.vertex_group_names}.'
570
+ ) from exc
571
+
572
+ def vertex_group_mask(
573
+ self, *names: str, threshold: float = _NONZERO_THRESHOLD
574
+ ) -> npt.NDArray[bool]:
575
+ result_mask = np.zeros(self.num_vertices, dtype=bool)
576
+ for name in names:
577
+ operator, inverse = '|', False
578
+ if name[0] in '|&-':
579
+ operator, name = name[0], name[1:]
580
+ if name[0] == '~':
581
+ inverse, name = True, name[1:]
582
+ group_mask = self.vertex_group(name) > threshold
583
+ if inverse:
584
+ group_mask = ~group_mask
585
+ match operator:
586
+ case '|':
587
+ result_mask |= group_mask
588
+ case '&':
589
+ result_mask &= group_mask
590
+ case '-':
591
+ result_mask &= ~group_mask
592
+ return result_mask
593
+
594
+ def vertex_group_indices(
595
+ self, *names: str, threshold: float = _NONZERO_THRESHOLD
596
+ ) -> npt.NDArray[np.integer]:
597
+ return np.where(self.vertex_group_mask(*names, threshold=threshold))[0]
598
+
599
+ def quad_indices_for_group(self, *names: str) -> npt.NDArray[np.integer]:
600
+ vertex_indices = self.vertex_group_indices(*names)
601
+ quads = np.array(self.quads)
602
+ return np.where(np.all(np.isin(quads, vertex_indices), axis=-1))[0]
603
+
604
+ def triangle_indices_for_group(self, *names: str) -> npt.NDArray[np.integer]:
605
+ vertex_indices = self.vertex_group_indices(*names)
606
+ triangles = np.array(self.triangles)
607
+ return np.where(np.all(np.isin(triangles, vertex_indices), axis=-1))[0]
608
+
609
+ def quads_group(self, *names: str) -> npt.NDArray[np.integer]:
610
+ return np.array(self.quads)[self.quad_indices_for_group(*names)]
611
+
612
+ def triangles_group(self, *names: str) -> npt.NDArray[np.integer]:
613
+ return np.array(self.triangles)[self.triangle_indices_for_group(*names)]
614
+
615
+ def quad_uvs_group(self, *names: str) -> npt.NDArray[np.floating]:
616
+ return np.array(self.quad_uvs)[self.quad_indices_for_group(*names)]
617
+
618
+ def triangle_uvs_group(self, *names: str) -> npt.NDArray[np.floating]:
619
+ return np.array(self.triangle_uvs)[self.triangle_indices_for_group(*names)]
620
+
621
+ def vertex_uvs_group(self, *names: str) -> npt.NDArray[np.floating]:
622
+ return self.vertex_uvs[self.vertex_group_indices(*names)]
623
+
624
+ def compute_vertex_normals(
625
+ self, vertices: enpt.FloatArray['A1 ... An V 3']
626
+ ) -> enpt.FloatArray['A1 ... An V 3']:
627
+ """Computes vertex normals. Must be overridden by subclasses."""
628
+ raise NotImplementedError(
629
+ 'Subclasses must implement compute_vertex_normals.'
630
+ )
631
+
632
+ def prune_vertices(self, keep_vertices: enpt.IntArray['V_pruned']) -> None:
633
+ """Prunes model vertices in-place."""
634
+ xnp = self.xnp
635
+ num_vertices = self.num_vertices
636
+ keep_vertices = xnp.asarray(keep_vertices, dtype=xnp.int32)
637
+
638
+ self.template_vertex_positions = gnm_common.take(
639
+ self.template_vertex_positions, keep_vertices, axis=0, xnp=xnp
640
+ )
641
+ self.vertex_identity_basis = gnm_common.take(
642
+ self.vertex_identity_basis, keep_vertices, axis=1, xnp=xnp
643
+ )
644
+ self.expression_basis = gnm_common.take(
645
+ self.expression_basis, keep_vertices, axis=1, xnp=xnp
646
+ )
647
+ self.skinning_weights = gnm_common.take(
648
+ self.skinning_weights, keep_vertices, axis=1, xnp=xnp
649
+ )
650
+
651
+ mapper = _scatter_indices(keep_vertices, num_vertices, xnp)
652
+
653
+ quads = gnm_common.take(mapper, self.quads, xnp=xnp)
654
+ triangles = gnm_common.take(mapper, self.triangles, xnp=xnp)
655
+
656
+ # Remove quads/triangles containing negative indices.
657
+ quad_mask = xnp.all(quads >= 0, axis=-1)
658
+ triangle_mask = xnp.all(triangles >= 0, axis=-1)
659
+
660
+ quad_indices = xnp.where(quad_mask)[0]
661
+ triangle_indices = xnp.where(triangle_mask)[0]
662
+
663
+ self.quads = gnm_common.take(quads, quad_indices, axis=0, xnp=xnp)
664
+ self.triangles = gnm_common.take(
665
+ triangles, triangle_indices, axis=0, xnp=xnp
666
+ )
667
+
668
+ if self.pose_correctives_regressor is not None:
669
+ pose_correctives = xnp.reshape(
670
+ self.pose_correctives_regressor,
671
+ (self.num_joints * 9, num_vertices, 3),
672
+ )
673
+ pose_correctives = gnm_common.take(
674
+ pose_correctives, keep_vertices, axis=1, xnp=xnp
675
+ )
676
+ self.pose_correctives_regressor = xnp.reshape(
677
+ pose_correctives, (-1, keep_vertices.shape[0] * 3)
678
+ )
679
+
680
+
681
+ def _check_batch_dims(
682
+ identity: enpt.FloatArray['... I'] | None = None,
683
+ expression: enpt.FloatArray['... E'] | None = None,
684
+ rotations: enpt.FloatArray['... J 3'] | None = None,
685
+ translation: enpt.FloatArray['... 3'] | None = None,
686
+ ) -> tuple[int, ...]:
687
+ """Ensures that the leading batch dimensions of all inputs are the same."""
688
+ shapes = {}
689
+ if identity is not None:
690
+ shapes['identity'] = identity.shape[:-1]
691
+ if expression is not None:
692
+ shapes['expression'] = expression.shape[:-1]
693
+ if rotations is not None:
694
+ shapes['rotations'] = rotations.shape[:-2]
695
+ if translation is not None:
696
+ shapes['translation'] = translation.shape[:-1]
697
+
698
+ if not shapes:
699
+ return ()
700
+
701
+ first_name, first_shape = next(iter(shapes.items()))
702
+ for name, shape in shapes.items():
703
+ if shape != first_shape:
704
+ raise ValueError(
705
+ f'Mismatched batch dimensions: {first_name} has {first_shape}, '
706
+ f'but {name} has {shape}.'
707
+ )
708
+ return first_shape
709
+
710
+
711
+ def _scatter_indices(keep_vertices, num_vertices, xnp) -> Any:
712
+ """Computes a mapping array to translate pruned vertex indices."""
713
+ if enp.lazy.is_tf_xnp(xnp):
714
+ tf = enp.lazy.tf
715
+ return (
716
+ tf.scatter_nd(
717
+ indices=tf.expand_dims(keep_vertices, axis=-1),
718
+ updates=tf.range(tf.shape(keep_vertices)[0], dtype=tf.int32) + 1,
719
+ shape=(num_vertices,),
720
+ )
721
+ - 1
722
+ )
723
+ elif enp.lazy.is_jax_xnp(xnp):
724
+ # pytype: disable=import-error
725
+ import jax.numpy as jnp # pylint: disable=g-import-not-at-top,import-outside-toplevel
726
+ # pytype: enable=import-error
727
+ mapper = jnp.full((num_vertices,), -1, dtype=jnp.int32)
728
+ return mapper.at[keep_vertices].set(
729
+ jnp.arange(len(keep_vertices), dtype=jnp.int32)
730
+ )
731
+ elif enp.lazy.is_torch_xnp(xnp):
732
+ mapper = xnp.full((num_vertices,), -1, dtype=xnp.int32)
733
+ mapper[keep_vertices] = xnp.arange(len(keep_vertices), dtype=xnp.int32)
734
+ return mapper
735
+ else:
736
+ mapper = np.full((num_vertices,), -1, dtype=np.int32)
737
+ mapper[keep_vertices] = np.arange(len(keep_vertices), dtype=np.int32)
738
+ return mapper
gnm/shape/pyproject.toml ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ [build-system]
16
+ requires = ["setuptools>=61.0"]
17
+ build-backend = "setuptools.build_meta"
18
+
19
+ [project]
20
+ name = "gnm-shape"
21
+ version = "3.0.0"
22
+ description = "GNM Shape Library"
23
+ readme = "README.md"
24
+ requires-python = ">=3.10"
25
+ license = {text = "Apache-2.0"}
26
+ dependencies = [
27
+ "absl-py",
28
+ "etils",
29
+ "imageio",
30
+ "immutabledict",
31
+ "importlib_resources",
32
+ "ipywidgets",
33
+ "notebook",
34
+ "numpy",
35
+ "opencv-python",
36
+ "opt-einsum",
37
+ "rtree",
38
+ "scipy",
39
+ "tensorflow",
40
+ "tqdm",
41
+ "trimesh",
42
+ "typeguard",
43
+ ]
44
+
45
+ [project.optional-dependencies]
46
+ jax = [
47
+ "jax",
48
+ "jaxtyping",
49
+ ]
50
+ pytorch = [
51
+ "torch",
52
+ ]
53
+ all = [
54
+ "gnm-shape[jax,pytorch]",
55
+ ]
56
+ dev = [
57
+ "pytest",
58
+ "pylint",
59
+ "mediapy",
60
+ "tensorflow-graphics",
61
+ ]
62
+
63
+ [tool.setuptools]
64
+ packages = [
65
+ "gnm.shape",
66
+ "gnm.shape.fitting_utils",
67
+ "gnm.shape.visualization",
68
+ ]
69
+ package-dir = {"gnm.shape" = "."}
70
+
71
+ [tool.setuptools.package-data]
72
+ "gnm.shape" = [
73
+ "data/**/*.npz",
74
+ "data/**/*.png",
75
+ "data/**/*.jpg",
76
+ "data/**/*.md",
77
+ "data/**/*.h5",
78
+ "assets/**/*",
79
+ ]
gnm/shape/visualization/vertex_colors.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2026 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Per-vertex colors (V, 3) for GNM, highlighting eyes and teeth."""
16
+
17
+ from collections.abc import Sequence
18
+
19
+ from gnm.shape import gnm_numpy
20
+ import numpy as np
21
+
22
+ # UINT8 color values kept for easier pasting into Color Picker tools.
23
+ DEFAULT_COLOR = tuple([c / 255 for c in [50, 156, 237]])
24
+ ORANGE = tuple([c / 255 for c in [232, 138, 14]])
25
+ GREEN = tuple([c / 255 for c in [83, 227, 39]])
26
+ CYAN = tuple([c / 255 for c in [39, 227, 227]])
27
+
28
+ # Defines a mapping for shading particular regions. It is from a vertex group to
29
+ # a tuple of modifiers that scales and adds an offset to the given color value.
30
+ _VERTEX_GROUP_COLOR_MODIFIERS = {
31
+ 'skin': (1.0, 0.0),
32
+ 'scleras': (0.6, 0.4),
33
+ 'irises': (0.6, 0.0),
34
+ 'gums': (0.7, 0.0),
35
+ 'teeth': (0.6, 0.4),
36
+ 'tongue': (0.7, 0.0),
37
+ 'mouth_sock': (0.7, 0.0),
38
+ }
39
+
40
+
41
+ def get_vertex_colors(
42
+ gnm_np: gnm_numpy.GNM,
43
+ color: Sequence[float] = DEFAULT_COLOR,
44
+ ) -> np.ndarray:
45
+ """Per-vertex colors (V, 3) for GNM, highlighting eyes and teeth.
46
+
47
+ Args:
48
+ gnm_np: The GNM instance for which the colors will be generated. If None,
49
+ then use the GNM face model.
50
+ color: The RGB color of the skin. Color values should be in [0.0, 1.0]. The
51
+ irises will be darker, and the scleras and teeth will be brighter.
52
+
53
+ Returns:
54
+ Per-vertex colors (V, 3) for GNM, highlighting eyes and teeth.
55
+ """
56
+ color = np.array(color) # pyrefly: ignore[bad-assignment]
57
+ colors = np.zeros((gnm_np.num_vertices, 3))
58
+
59
+ for region, (scale, offset) in _VERTEX_GROUP_COLOR_MODIFIERS.items():
60
+ if region in gnm_np.vertex_group_names:
61
+ colors[gnm_np.vertex_group_indices(region)] = (
62
+ color * scale + offset # pyrefly: ignore[unsupported-operation]
63
+ )
64
+ return colors
65
+
66
+
67
+ def get_vertex_colors_for_inner_head(
68
+ gnm_np: gnm_numpy.GNM,
69
+ base_color: Sequence[float] = DEFAULT_COLOR,
70
+ ) -> np.ndarray:
71
+ """Per-vertex GNM colors, highlighting mouth sock and teeth/tongue.
72
+
73
+ Useful for visualizing the inner part of the head where we need to distinguish
74
+ between the mouth sock, upper teeth, lower teeth, and tongue.
75
+
76
+ Args:
77
+ gnm_np: The GNM instance for which the colors will be generated.
78
+ base_color: The RGB color of the skin (see `get_vertex_colors`).
79
+
80
+ Returns:
81
+ Per-vertex colors (V, 3) for GNM.
82
+
83
+ Raises:
84
+ ValueError: The GNM model does not include a head.
85
+ """
86
+ # The function only makes sense for GNM models includling a head.
87
+ colored_groups = [
88
+ 'mouth_sock',
89
+ 'upper_teeth_and_gums',
90
+ 'lower_teeth_and_gums',
91
+ 'tongue',
92
+ ]
93
+ if not np.all([g in gnm_np.vertex_group_names for g in colored_groups]):
94
+ raise ValueError('The GNM model does not include a head.')
95
+
96
+ colors = get_vertex_colors(color=base_color, gnm_np=gnm_np)
97
+ colors[gnm_np.vertex_group_indices('mouth_sock')] = ORANGE
98
+ colors[gnm_np.vertex_group_indices('upper_teeth_and_gums')] = GREEN
99
+ colors[gnm_np.vertex_group_indices('lower_teeth_and_gums', 'tongue')] = CYAN
100
+ return colors
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ numpy
2
+ scipy
3
+ trimesh
4
+ absl-py
5
+ etils
6
+ immutabledict
7
+ opt-einsum
8
+ typeguard
9
+ importlib_resources