Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- README.md +32 -6
- app.py +339 -0
- gnm/shape/__init__.py +14 -0
- gnm/shape/data/landmarks/head_sparse_68.txt +68 -0
- gnm/shape/data/versions/gnm_catalog.py +21 -0
- gnm/shape/data/versions/gnm_specs.py +34 -0
- gnm/shape/data/versions/v3_0/gnm_head.npz +3 -0
- gnm/shape/fitting_utils/project_on_pca.py +261 -0
- gnm/shape/fitting_utils/regularized_least_squares.py +131 -0
- gnm/shape/gnm_base.py +73 -0
- gnm/shape/gnm_common.py +505 -0
- gnm/shape/gnm_data_loader.py +247 -0
- gnm/shape/gnm_data_schema.py +41 -0
- gnm/shape/gnm_landmarks.py +102 -0
- gnm/shape/gnm_numpy.py +152 -0
- gnm/shape/gnm_test_utils.py +101 -0
- gnm/shape/gnm_utils.py +550 -0
- gnm/shape/gnm_xnp.py +738 -0
- gnm/shape/pyproject.toml +79 -0
- gnm/shape/visualization/vertex_colors.py +100 -0
- requirements.txt +9 -0
README.md
CHANGED
|
@@ -1,13 +1,39 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.20.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|