holonomy-lib 0.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- holonomy_explorer/__init__.py +1 -0
- holonomy_explorer/__main__.py +79 -0
- holonomy_explorer/batching.py +182 -0
- holonomy_explorer/common.py +187 -0
- holonomy_explorer/curvature.py +154 -0
- holonomy_explorer/datasets.py +346 -0
- holonomy_explorer/graphs.py +286 -0
- holonomy_explorer/server.py +172 -0
- holonomy_explorer/static/app.js +435 -0
- holonomy_explorer/static/charts.js +114 -0
- holonomy_explorer/static/index.html +45 -0
- holonomy_explorer/static/layout.js +85 -0
- holonomy_explorer/static/style.css +121 -0
- holonomy_explorer/static/view.js +195 -0
- holonomy_explorer/topology.py +118 -0
- holonomy_lib/__init__.py +9 -0
- holonomy_lib/_graph_utils.py +52 -0
- holonomy_lib/_quadrature.py +74 -0
- holonomy_lib/algebra/__init__.py +32 -0
- holonomy_lib/algebra/eigsh_sparse.py +251 -0
- holonomy_lib/algebra/lanczos.py +361 -0
- holonomy_lib/algebra/linear.py +180 -0
- holonomy_lib/algebra/solvers.py +547 -0
- holonomy_lib/convex/__init__.py +27 -0
- holonomy_lib/convex/least_squares.py +184 -0
- holonomy_lib/discrete_geometry/__init__.py +38 -0
- holonomy_lib/discrete_geometry/forman.py +242 -0
- holonomy_lib/discrete_geometry/ricci.py +855 -0
- holonomy_lib/experimental/__init__.py +13 -0
- holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
- holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
- holonomy_lib/hyperbolic/__init__.py +27 -0
- holonomy_lib/hyperbolic/frechet_mean.py +381 -0
- holonomy_lib/hyperbolic/heat_kernel.py +608 -0
- holonomy_lib/hyperbolic/manifold_inner.py +81 -0
- holonomy_lib/info_geometry/__init__.py +64 -0
- holonomy_lib/info_geometry/divergences.py +224 -0
- holonomy_lib/info_geometry/fisher.py +179 -0
- holonomy_lib/lie/__init__.py +54 -0
- holonomy_lib/lie/so3.py +385 -0
- holonomy_lib/lie/spherical_harmonics.py +189 -0
- holonomy_lib/manifolds/__init__.py +53 -0
- holonomy_lib/manifolds/comparison.py +343 -0
- holonomy_lib/manifolds/fixed_rank.py +453 -0
- holonomy_lib/manifolds/lorentz.py +699 -0
- holonomy_lib/manifolds/lorentzian.py +567 -0
- holonomy_lib/manifolds/product.py +425 -0
- holonomy_lib/manifolds/spd.py +504 -0
- holonomy_lib/manifolds/stereographic.py +680 -0
- holonomy_lib/optimization/__init__.py +42 -0
- holonomy_lib/optimization/base.py +65 -0
- holonomy_lib/optimization/sgd.py +112 -0
- holonomy_lib/py.typed +0 -0
- holonomy_lib/sheaf/__init__.py +74 -0
- holonomy_lib/sheaf/graph_sheaf.py +352 -0
- holonomy_lib/sheaf/laplacian.py +529 -0
- holonomy_lib/simplicial/__init__.py +55 -0
- holonomy_lib/simplicial/_face_lookup.py +99 -0
- holonomy_lib/simplicial/complex.py +168 -0
- holonomy_lib/simplicial/sparse_complex.py +145 -0
- holonomy_lib/simplicial/vietoris_rips.py +277 -0
- holonomy_lib/spectral/__init__.py +55 -0
- holonomy_lib/spectral/diffusion_map.py +169 -0
- holonomy_lib/spectral/effective_resistance.py +199 -0
- holonomy_lib/spectral/embedding.py +131 -0
- holonomy_lib/spectral/heat_kernel.py +236 -0
- holonomy_lib/spectral/laplacian.py +441 -0
- holonomy_lib/spectral/magnetic.py +351 -0
- holonomy_lib/spectral/spectral_dimension.py +104 -0
- holonomy_lib/tensor_calculus/__init__.py +23 -0
- holonomy_lib/tensor_calculus/decomposition.py +208 -0
- holonomy_lib/topology/__init__.py +28 -0
- holonomy_lib/topology/_filtration.py +147 -0
- holonomy_lib/topology/_reduction.py +227 -0
- holonomy_lib/topology/hodge.py +250 -0
- holonomy_lib/topology/persistent_homology.py +301 -0
- holonomy_lib-0.8.0.dist-info/METADATA +161 -0
- holonomy_lib-0.8.0.dist-info/RECORD +82 -0
- holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
- holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
- holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
- holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
holonomy_lib/lie/so3.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""SO(3) rotation-group primitives: batched, GPU-native.
|
|
5
|
+
|
|
6
|
+
The 3D rotation group SO(3) has a 3-dim Lie algebra so(3) ≅ R³ via the
|
|
7
|
+
hat map ω ↦ [ω]_×, where [ω]_× is the 3×3 skew-symmetric matrix
|
|
8
|
+
|
|
9
|
+
[[ 0, −ω_z, ω_y],
|
|
10
|
+
[ ω_z, 0, −ω_x],
|
|
11
|
+
[−ω_y, ω_x, 0 ]].
|
|
12
|
+
|
|
13
|
+
The Lie-exp map `so(3) → SO(3)` is the matrix exponential, which has
|
|
14
|
+
the closed-form Rodrigues formula:
|
|
15
|
+
|
|
16
|
+
exp([ω]_×) = I + (sin θ / θ) [ω]_× + ((1 − cos θ) / θ²) [ω]_ײ,
|
|
17
|
+
θ = ‖ω‖.
|
|
18
|
+
|
|
19
|
+
Its inverse, the Lie log, is smooth on rotations with angle below π.
|
|
20
|
+
At π the axis has a sign ambiguity; see `so3_log` for the
|
|
21
|
+
representative it returns.
|
|
22
|
+
|
|
23
|
+
References:
|
|
24
|
+
Hall (2015), §3.1: SO(3) and the Rodrigues formula.
|
|
25
|
+
Shoemake (1992): uniform-random rotations via quaternions.
|
|
26
|
+
Murray, Li, Sastry (1994). A Mathematical Introduction to Robotic
|
|
27
|
+
Manipulation, §2.3: the hat/vee maps and the SO(3) exp/log
|
|
28
|
+
closed forms used here.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import math
|
|
34
|
+
|
|
35
|
+
import torch
|
|
36
|
+
|
|
37
|
+
# Mathematical dimension of SO(3): the group lives in R^{3×3} and its
|
|
38
|
+
# Lie algebra is R^3. **Scale of validity**: hard-coded by the
|
|
39
|
+
# definition of "3D rotation"; not tunable. Cataloged as `SO3_DIM`.
|
|
40
|
+
SO3_DIM: int = 3
|
|
41
|
+
|
|
42
|
+
# Quaternion dimension. **Scale of validity**: hard-coded by the
|
|
43
|
+
# definition of "unit quaternion"; not tunable. Cataloged as
|
|
44
|
+
# `QUATERNION_DIM`.
|
|
45
|
+
QUATERNION_DIM: int = 4
|
|
46
|
+
|
|
47
|
+
# The near-pi crossover was calibrated in float64 against Rodrigues round
|
|
48
|
+
# trips. so3_log uses the larger of this and sqrt(eps) for the input dtype,
|
|
49
|
+
# so float32 (sqrt(eps) about 3.5e-4) gets a wider near-pi band.
|
|
50
|
+
# Cataloged as SO3_LOG_NEAR_PI_RAD; this is an angle in radians.
|
|
51
|
+
SO3_LOG_NEAR_PI_RAD: float = 1e-7
|
|
52
|
+
|
|
53
|
+
# For cos(theta) > 0, theta = arcsin(s) with s = sin(theta), so
|
|
54
|
+
# theta / sin(theta) = arcsin(s) / s = 1 + s² / 3! + O(s⁴).
|
|
55
|
+
# The small-angle neighborhood comes from dtype epsilon: with s² below
|
|
56
|
+
# sqrt(eps), the dropped s⁴ term is of order eps, which is roundoff. No
|
|
57
|
+
# separate angle tolerance is needed.
|
|
58
|
+
SO3_LOG_SERIES_SECOND_ORDER: float = 1.0 / math.factorial(SO3_DIM)
|
|
59
|
+
|
|
60
|
+
# Taylor coefficients of sin(theta)/theta and (1-cos(theta))/theta².
|
|
61
|
+
# They are reciprocal factorials from Rodrigues' formula, not fitted values.
|
|
62
|
+
SO3_EXP_SINC_LINEAR: float = 1.0 / 6.0
|
|
63
|
+
SO3_EXP_SINC_QUADRATIC: float = 1.0 / 120.0
|
|
64
|
+
SO3_EXP_COSC_LINEAR: float = 1.0 / 24.0
|
|
65
|
+
SO3_EXP_COSC_QUADRATIC: float = 1.0 / 720.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def axis_angle_to_matrix(
|
|
69
|
+
axis: torch.Tensor, angle: torch.Tensor,
|
|
70
|
+
) -> torch.Tensor:
|
|
71
|
+
"""Rodrigues formula: convert (unit axis, angle) → rotation matrix.
|
|
72
|
+
|
|
73
|
+
R = I + sin(θ)·K + (1 − cos(θ))·K², K = [axis]_×.
|
|
74
|
+
|
|
75
|
+
The axis is normalized to unit length internally so callers do
|
|
76
|
+
not need to pre-normalize.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
axis: `(..., 3)` axis vectors; will be normalized.
|
|
80
|
+
angle: `(...,)` rotation angle in radians.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
`(..., 3, 3)` rotation matrices in SO(3).
|
|
84
|
+
|
|
85
|
+
References:
|
|
86
|
+
Hall (2015), Theorem 3.3.
|
|
87
|
+
"""
|
|
88
|
+
if axis.ndim < 1 or axis.shape[-1] != SO3_DIM:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"axis must end with dim {SO3_DIM}; got axis.shape={tuple(axis.shape)}"
|
|
91
|
+
)
|
|
92
|
+
if axis.shape[:-1] != angle.shape:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"axis batch shape {tuple(axis.shape[:-1])} must match "
|
|
95
|
+
f"angle shape {tuple(angle.shape)}"
|
|
96
|
+
)
|
|
97
|
+
# A zero axis would divide by zero. The clamp keeps the division finite,
|
|
98
|
+
# and a zero axis then gives the identity.
|
|
99
|
+
floor = torch.finfo(axis.dtype).tiny
|
|
100
|
+
norm = torch.linalg.norm(axis, dim=-1, keepdim=True).clamp(min=floor)
|
|
101
|
+
axis_unit = axis / norm # (..., 3)
|
|
102
|
+
|
|
103
|
+
K = _hat(axis_unit) # (..., 3, 3)
|
|
104
|
+
s = torch.sin(angle).unsqueeze(dim=-1).unsqueeze(dim=-1) # (..., 1, 1)
|
|
105
|
+
c = torch.cos(angle).unsqueeze(dim=-1).unsqueeze(dim=-1)
|
|
106
|
+
eye = torch.eye(SO3_DIM, dtype=axis.dtype, device=axis.device)
|
|
107
|
+
eye = eye.expand(*axis.shape[:-1], SO3_DIM, SO3_DIM)
|
|
108
|
+
return eye + s * K + (1.0 - c) * (K @ K)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def matrix_to_axis_angle(R: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
112
|
+
"""Inverse Rodrigues: convert SO(3) matrix to (unit axis, angle).
|
|
113
|
+
|
|
114
|
+
Uses the Lie logarithm, then normalizes its rotation vector. At the
|
|
115
|
+
identity the angle is zero and the axis is chosen as (1, 0, 0).
|
|
116
|
+
The axis-angle representation is not differentiable at identity.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
R: `(..., 3, 3)` rotation matrix.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
`(axis, angle)`:
|
|
123
|
+
axis `(..., 3)` unit vector.
|
|
124
|
+
angle `(...,)` in `[0, π]`.
|
|
125
|
+
|
|
126
|
+
References:
|
|
127
|
+
Murray-Li-Sastry (1994), §2.3.3: the dual-branch logarithm.
|
|
128
|
+
"""
|
|
129
|
+
if R.shape[-2:] != (SO3_DIM, SO3_DIM):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"R must end with ({SO3_DIM}, {SO3_DIM}); got R.shape={tuple(R.shape)}"
|
|
132
|
+
)
|
|
133
|
+
omega = so3_log(R)
|
|
134
|
+
angle = torch.linalg.vector_norm(omega, dim=-1)
|
|
135
|
+
# The axis is undefined at the identity, where the rotation vector is
|
|
136
|
+
# zero. Only this axis-angle API has to pick one, so it uses (1, 0, 0).
|
|
137
|
+
nonzero = angle > 0
|
|
138
|
+
divisor = torch.where(nonzero, angle, torch.ones_like(angle))
|
|
139
|
+
axis = omega / divisor.unsqueeze(-1)
|
|
140
|
+
canonical = torch.zeros_like(axis)
|
|
141
|
+
canonical[..., 0] = 1.0
|
|
142
|
+
return torch.where(nonzero.unsqueeze(-1), axis, canonical), angle
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def so3_exp(omega: torch.Tensor) -> torch.Tensor:
|
|
146
|
+
"""Matrix exponential on so(3): ω ∈ R³ ↦ R ∈ SO(3).
|
|
147
|
+
|
|
148
|
+
Evaluates Rodrigues' formula. Near zero it uses Taylor polynomials for
|
|
149
|
+
the two coefficients, so the result and its gradient stay finite at
|
|
150
|
+
omega = 0.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
omega: `(..., 3)` Lie algebra elements.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
`(..., 3, 3)` rotation matrices.
|
|
157
|
+
|
|
158
|
+
References:
|
|
159
|
+
Hall (2015). Lie Groups, Lie Algebras, and Representations, §3.1.
|
|
160
|
+
Murray, Li, Sastry (1994). A Mathematical Introduction to Robotic
|
|
161
|
+
Manipulation, equation 2.14.
|
|
162
|
+
"""
|
|
163
|
+
if omega.ndim < 1 or omega.shape[-1] != SO3_DIM:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"omega must end with dim {SO3_DIM}; got omega.shape={tuple(omega.shape)}"
|
|
166
|
+
)
|
|
167
|
+
theta_squared = omega.square().sum(dim=-1)
|
|
168
|
+
small = theta_squared <= math.sqrt(torch.finfo(omega.dtype).eps)
|
|
169
|
+
# torch.where computes both branches everywhere, and a NaN or inf in the
|
|
170
|
+
# unused one would still turn into a NaN gradient. So the sqrt and the
|
|
171
|
+
# division get 1 wherever the small branch is active, and the polynomial
|
|
172
|
+
# sees 0 wherever it is not.
|
|
173
|
+
safe_squared = torch.where(small, torch.ones_like(theta_squared), theta_squared)
|
|
174
|
+
theta = torch.sqrt(safe_squared)
|
|
175
|
+
series_squared = torch.where(small, theta_squared, torch.zeros_like(theta_squared))
|
|
176
|
+
sinc = torch.where(
|
|
177
|
+
small,
|
|
178
|
+
1.0 - SO3_EXP_SINC_LINEAR * series_squared
|
|
179
|
+
+ SO3_EXP_SINC_QUADRATIC * series_squared.square(),
|
|
180
|
+
torch.sin(theta) / theta,
|
|
181
|
+
)
|
|
182
|
+
cosc = torch.where(
|
|
183
|
+
small,
|
|
184
|
+
0.5 - SO3_EXP_COSC_LINEAR * series_squared
|
|
185
|
+
+ SO3_EXP_COSC_QUADRATIC * series_squared.square(),
|
|
186
|
+
(1.0 - torch.cos(theta)) / safe_squared,
|
|
187
|
+
)
|
|
188
|
+
generator = _hat(omega)
|
|
189
|
+
eye = torch.eye(SO3_DIM, dtype=omega.dtype, device=omega.device)
|
|
190
|
+
return (
|
|
191
|
+
eye + sinc[..., None, None] * generator
|
|
192
|
+
+ cosc[..., None, None] * (generator @ generator)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def so3_log(R: torch.Tensor) -> torch.Tensor:
|
|
197
|
+
"""Principal rotation vector of an SO(3) matrix, with norm in [0, π].
|
|
198
|
+
|
|
199
|
+
Away from π, the skew part is sin(theta) times the rotation axis, so
|
|
200
|
+
the rotation vector is that part scaled by theta / sin(theta). Near
|
|
201
|
+
zero that quotient is replaced by its series 1 + sin²(theta) / 6.
|
|
202
|
+
Near π the sine is too small to carry the axis, so the symmetric part
|
|
203
|
+
determines it instead. The sign at exactly π is intrinsically
|
|
204
|
+
ambiguous, so the logarithm is not smooth there.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
R: `(..., 3, 3)` rotation matrices.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
`(..., 3)` rotation vectors.
|
|
211
|
+
|
|
212
|
+
References:
|
|
213
|
+
Murray, Li, Sastry (1994). A Mathematical Introduction to Robotic
|
|
214
|
+
Manipulation, §2.3.3, the logarithm of a rotation matrix.
|
|
215
|
+
"""
|
|
216
|
+
if R.shape[-2:] != (SO3_DIM, SO3_DIM):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"R must end with ({SO3_DIM}, {SO3_DIM}); got R.shape={tuple(R.shape)}"
|
|
219
|
+
)
|
|
220
|
+
skew_vector = _vee(0.5 * (R - R.mT))
|
|
221
|
+
sin_angle = torch.linalg.vector_norm(skew_vector, dim=-1)
|
|
222
|
+
sin_squared = (skew_vector * skew_vector).sum(dim=-1)
|
|
223
|
+
cos_angle = ((R.diagonal(dim1=-2, dim2=-1).sum(dim=-1) - 1.0) * 0.5)
|
|
224
|
+
angle = torch.atan2(sin_angle, cos_angle)
|
|
225
|
+
small = (cos_angle > 0) & (sin_squared < math.sqrt(torch.finfo(R.dtype).eps))
|
|
226
|
+
# The skew part only carries the axis when sin(theta) is not tiny, so
|
|
227
|
+
# this threshold switches to the symmetric part close to π.
|
|
228
|
+
near_pi = angle > math.pi - max(
|
|
229
|
+
SO3_LOG_NEAR_PI_RAD, math.sqrt(torch.finfo(R.dtype).eps),
|
|
230
|
+
)
|
|
231
|
+
# The angle / sine quotient is computed everywhere, so its sine is swapped
|
|
232
|
+
# for 1 in the small and near-π branches, where that quotient is not
|
|
233
|
+
# used. This matters in backward too: torch.where sends zero gradient
|
|
234
|
+
# into the unused branch, and zero times an infinite derivative is NaN.
|
|
235
|
+
safe_sine = torch.where(small | near_pi, torch.ones_like(sin_angle), sin_angle)
|
|
236
|
+
scale = torch.where(
|
|
237
|
+
small, 1.0 + SO3_LOG_SERIES_SECOND_ORDER * sin_squared, angle / safe_sine,
|
|
238
|
+
)
|
|
239
|
+
omega = scale.unsqueeze(-1) * skew_vector
|
|
240
|
+
|
|
241
|
+
# Sym(R) - cos(theta) I = (1 - cos(theta)) axis axis^T. Its largest
|
|
242
|
+
# diagonal selects a row with a nonzero axis component even at π.
|
|
243
|
+
eye = torch.eye(SO3_DIM, dtype=R.dtype, device=R.device)
|
|
244
|
+
symmetric = 0.5 * (R + R.mT) - cos_angle[..., None, None] * eye
|
|
245
|
+
# Away from π this part's axis is not used. At the identity, though,
|
|
246
|
+
# Sym(R) - cos(theta) I is exactly zero, so its row would be a 0 / 0
|
|
247
|
+
# normalization. Swap in the identity wherever this is not used. A NaN
|
|
248
|
+
# in an unused branch would still poison the gradient.
|
|
249
|
+
symmetric = torch.where(near_pi[..., None, None], symmetric, eye)
|
|
250
|
+
pivot = symmetric.diagonal(dim1=-2, dim2=-1).argmax(dim=-1)
|
|
251
|
+
row = symmetric.gather(
|
|
252
|
+
-2, pivot[..., None, None].expand(*R.shape[:-2], 1, SO3_DIM),
|
|
253
|
+
).squeeze(-2)
|
|
254
|
+
axis = row / torch.linalg.vector_norm(row, dim=-1, keepdim=True)
|
|
255
|
+
alignment = (axis * skew_vector).sum(dim=-1, keepdim=True)
|
|
256
|
+
axis = torch.where(alignment < 0, -axis, axis)
|
|
257
|
+
return torch.where(near_pi.unsqueeze(-1), angle.unsqueeze(-1) * axis, omega)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def compose(R1: torch.Tensor, R2: torch.Tensor) -> torch.Tensor:
|
|
261
|
+
"""SO(3) group product `R1 · R2`, a plain matmul.
|
|
262
|
+
|
|
263
|
+
It lives in this module so the group operations are together.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
R1, R2: broadcastable `(..., 3, 3)` rotation matrices.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
`(..., 3, 3)` rotation matrices, applying R2 before R1.
|
|
270
|
+
|
|
271
|
+
References:
|
|
272
|
+
Hall (2015). Lie Groups, Lie Algebras, and Representations, §1.2.
|
|
273
|
+
"""
|
|
274
|
+
if R1.shape[-2:] != (SO3_DIM, SO3_DIM) or R2.shape[-2:] != (SO3_DIM, SO3_DIM):
|
|
275
|
+
raise ValueError(
|
|
276
|
+
f"both inputs must end with ({SO3_DIM}, {SO3_DIM}); got "
|
|
277
|
+
f"R1.shape={tuple(R1.shape)}, R2.shape={tuple(R2.shape)}"
|
|
278
|
+
)
|
|
279
|
+
return R1 @ R2
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def random_so3(
|
|
283
|
+
batch_size: int,
|
|
284
|
+
generator: torch.Generator | None = None,
|
|
285
|
+
device: str | torch.device = "cpu",
|
|
286
|
+
dtype: torch.dtype = torch.float64,
|
|
287
|
+
) -> torch.Tensor:
|
|
288
|
+
"""Haar-uniform random rotation matrices.
|
|
289
|
+
|
|
290
|
+
Construction: sample a unit quaternion uniformly on S³ (the
|
|
291
|
+
Shoemake 1992 method), then convert to a rotation matrix. The
|
|
292
|
+
quaternion-Haar measure pushes through to the SO(3)-Haar measure
|
|
293
|
+
under the standard 2-to-1 cover.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
batch_size: number of independent rotations.
|
|
297
|
+
generator: torch.Generator for reproducibility.
|
|
298
|
+
device, dtype: tensor placement.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
`(batch_size, 3, 3)` SO(3) matrices.
|
|
302
|
+
|
|
303
|
+
References:
|
|
304
|
+
Shoemake (1992).
|
|
305
|
+
"""
|
|
306
|
+
if batch_size < 0:
|
|
307
|
+
raise ValueError(f"batch_size must be >= 0, got {batch_size}")
|
|
308
|
+
# Shoemake: three uniform [0, 1] samples u1, u2, u3 → unit
|
|
309
|
+
# quaternion (w, x, y, z) with q on S³ uniformly. The sampling
|
|
310
|
+
# and trig run on `device` so a `device="cuda"` call doesn't
|
|
311
|
+
# silently fall back to CPU for the per-sample scalar work. The
|
|
312
|
+
# generator must be on the matching device: if the caller wants
|
|
313
|
+
# GPU sampling, they must pass a CUDA generator.
|
|
314
|
+
target_device = torch.device(device)
|
|
315
|
+
# A CUDA generator reports `cuda` with no index, so compare the device
|
|
316
|
+
# type, and the index only when both sides name one.
|
|
317
|
+
generator_device = None if generator is None else generator.device
|
|
318
|
+
if generator_device is not None and (
|
|
319
|
+
generator_device.type != target_device.type
|
|
320
|
+
or (generator_device.index is not None
|
|
321
|
+
and target_device.index is not None
|
|
322
|
+
and generator_device.index != target_device.index)
|
|
323
|
+
):
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"generator is on {generator_device} but device={target_device} "
|
|
326
|
+
f"was requested; pass a generator on the target device or omit "
|
|
327
|
+
f"`generator` to use the default"
|
|
328
|
+
)
|
|
329
|
+
u = torch.rand(
|
|
330
|
+
batch_size, SO3_DIM,
|
|
331
|
+
generator=generator, dtype=dtype, device=target_device,
|
|
332
|
+
)
|
|
333
|
+
u1, u2, u3 = u[..., 0], u[..., 1], u[..., 2]
|
|
334
|
+
sqrt_u1 = torch.sqrt(u1)
|
|
335
|
+
sqrt_1_minus_u1 = torch.sqrt(1.0 - u1)
|
|
336
|
+
two_pi_u2 = 2.0 * math.pi * u2
|
|
337
|
+
two_pi_u3 = 2.0 * math.pi * u3
|
|
338
|
+
w = sqrt_1_minus_u1 * torch.sin(two_pi_u2)
|
|
339
|
+
x = sqrt_1_minus_u1 * torch.cos(two_pi_u2)
|
|
340
|
+
y = sqrt_u1 * torch.sin(two_pi_u3)
|
|
341
|
+
z = sqrt_u1 * torch.cos(two_pi_u3)
|
|
342
|
+
q = torch.stack([w, x, y, z], dim=-1) # (B, 4)
|
|
343
|
+
return _quaternion_to_matrix(q)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ============================================================
|
|
347
|
+
# Internal helpers
|
|
348
|
+
# ============================================================
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _hat(v: torch.Tensor) -> torch.Tensor:
|
|
352
|
+
"""Hat map: R³ → so(3). v ↦ [v]_× as a (..., 3, 3) skew-symmetric."""
|
|
353
|
+
zero = torch.zeros_like(v[..., 0])
|
|
354
|
+
x, y, z = v[..., 0], v[..., 1], v[..., 2]
|
|
355
|
+
row0 = torch.stack([zero, -z, y], dim=-1)
|
|
356
|
+
row1 = torch.stack([z, zero, -x], dim=-1)
|
|
357
|
+
row2 = torch.stack([-y, x, zero], dim=-1)
|
|
358
|
+
return torch.stack([row0, row1, row2], dim=-2)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _vee(K: torch.Tensor) -> torch.Tensor:
|
|
362
|
+
"""Vee map: skew(3) → R³. The inverse of `_hat`."""
|
|
363
|
+
return torch.stack(
|
|
364
|
+
[K[..., 2, 1], K[..., 0, 2], K[..., 1, 0]], dim=-1,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _quaternion_to_matrix(q: torch.Tensor) -> torch.Tensor:
|
|
369
|
+
"""Convert unit quaternions `(w, x, y, z)` to rotation matrices.
|
|
370
|
+
|
|
371
|
+
Standard formula; works for arbitrary batch shape ending in 4.
|
|
372
|
+
"""
|
|
373
|
+
# Components are read by index, in the (w, x, y, z) layout. The last
|
|
374
|
+
# index is written as QUATERNION_DIM - 1 so it is tied to the constant.
|
|
375
|
+
# The others are plain 0, 1, 2.
|
|
376
|
+
last = QUATERNION_DIM - 1
|
|
377
|
+
w, x, y, z = q[..., 0], q[..., 1], q[..., 2], q[..., last]
|
|
378
|
+
# Each product shows up in more than one entry, so compute it once.
|
|
379
|
+
wx, wy, wz = w * x, w * y, w * z
|
|
380
|
+
xx, yy, zz = x * x, y * y, z * z
|
|
381
|
+
xy, xz, yz = x * y, x * z, y * z
|
|
382
|
+
row0 = torch.stack([1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy)], dim=-1)
|
|
383
|
+
row1 = torch.stack([2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx)], dim=-1)
|
|
384
|
+
row2 = torch.stack([2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy)], dim=-1)
|
|
385
|
+
return torch.stack([row0, row1, row2], dim=-2)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Real spherical harmonics Y_lm on the unit 2-sphere, closed form
|
|
5
|
+
for l ≤ 4.
|
|
6
|
+
|
|
7
|
+
Real spherical harmonics are the orthonormal basis for square-
|
|
8
|
+
integrable functions on `S² ⊂ R³` that diagonalizes the Laplace-
|
|
9
|
+
Beltrami operator on the sphere. On the sphere, each `Y_lm` agrees with
|
|
10
|
+
a homogeneous polynomial of degree l in `(x, y, z)`. There are `2l + 1`
|
|
11
|
+
of them at each `l`, indexed by `m ∈ {−l, …, l}`. The total at
|
|
12
|
+
truncation `l_max` is `(l_max + 1)²` components.
|
|
13
|
+
|
|
14
|
+
In equivariant deep learning these serve as the spherical features that
|
|
15
|
+
rotate in a known way under SO(3). e3nn and SE(3)-Transformer use them
|
|
16
|
+
as steerable filters, and Clebsch-Gordan coefficients are what combine
|
|
17
|
+
different degrees into equivariant products.
|
|
18
|
+
|
|
19
|
+
The basis is orthonormal over the sphere and uses the Condon-Shortley-free
|
|
20
|
+
convention: every prefactor is positive, with no `(−1)^m` phase. Inputs
|
|
21
|
+
are direction vectors `(..., 3)`; the function normalizes to unit norm
|
|
22
|
+
internally, so callers can pass unnormalized vectors.
|
|
23
|
+
|
|
24
|
+
References:
|
|
25
|
+
Wikipedia, "Real spherical harmonics": the explicit formulas
|
|
26
|
+
transcribed here are the standard tesseral/sectoral basis.
|
|
27
|
+
Edmonds (1957), §2.5.
|
|
28
|
+
Geiger, M., Smidt, T. (2022). e3nn: Euclidean neural networks.
|
|
29
|
+
arXiv:2207.09453. The modern implementation in geometric
|
|
30
|
+
deep learning. To compare with e3nn.o3.spherical_harmonics, pass
|
|
31
|
+
directions[..., [1, 2, 0]], normalize=True, normalization="integral".
|
|
32
|
+
e3nn uses Cartesian order (x, y, z) at degree one; this basis uses
|
|
33
|
+
(y, z, x). See https://docs.e3nn.org/en/stable/api/o3/o3_sh.html.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import math
|
|
39
|
+
|
|
40
|
+
import torch
|
|
41
|
+
|
|
42
|
+
_SUPPORTED_L_MAX: int = 4
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def real_spherical_harmonics(
|
|
46
|
+
directions: torch.Tensor, l_max: int = 2,
|
|
47
|
+
) -> torch.Tensor:
|
|
48
|
+
"""Real spherical harmonics `Y_lm` at the given direction vectors.
|
|
49
|
+
|
|
50
|
+
For each input direction `(x, y, z)` (normalized internally),
|
|
51
|
+
returns the `(l_max + 1)²` real spherical harmonics in the
|
|
52
|
+
standard ordering:
|
|
53
|
+
|
|
54
|
+
[Y_{0,0},
|
|
55
|
+
Y_{1,-1}, Y_{1,0}, Y_{1,1},
|
|
56
|
+
Y_{2,-2}, Y_{2,-1}, Y_{2,0}, Y_{2,1}, Y_{2,2},
|
|
57
|
+
…,
|
|
58
|
+
Y_{l_max, -l_max}, …, Y_{l_max, l_max}]
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
directions: `(..., 3)` direction vectors. Need not be unit; the
|
|
62
|
+
function normalizes internally.
|
|
63
|
+
l_max: highest degree to compute. Must be in `[0, 4]`. Higher
|
|
64
|
+
degrees need the associated-Legendre recurrence, which is not
|
|
65
|
+
implemented.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
`(..., (l_max + 1)²)` real spherical harmonic values.
|
|
69
|
+
|
|
70
|
+
References:
|
|
71
|
+
Wikipedia, "Table of spherical harmonics": real form.
|
|
72
|
+
Edmonds (1957), §2.5.
|
|
73
|
+
"""
|
|
74
|
+
if directions.ndim < 1 or directions.shape[-1] != 3:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"directions must end with dim 3; got shape="
|
|
77
|
+
f"{tuple(directions.shape)}"
|
|
78
|
+
)
|
|
79
|
+
if not (0 <= l_max <= _SUPPORTED_L_MAX):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"l_max must be in [0, {_SUPPORTED_L_MAX}]; got l_max={l_max}. "
|
|
82
|
+
f"Higher degrees need the associated-Legendre recurrence "
|
|
83
|
+
f"(not implemented)."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# The clamp keeps a zero direction from dividing by zero. It then stays
|
|
87
|
+
# the zero vector, and the polynomials evaluated there are not meaningful
|
|
88
|
+
# SH values.
|
|
89
|
+
floor = torch.finfo(directions.dtype).tiny
|
|
90
|
+
norm = torch.linalg.norm(directions, dim=-1, keepdim=True).clamp(min=floor)
|
|
91
|
+
unit = directions / norm
|
|
92
|
+
x, y, z = unit[..., 0], unit[..., 1], unit[..., 2]
|
|
93
|
+
|
|
94
|
+
components: list[torch.Tensor] = []
|
|
95
|
+
|
|
96
|
+
# ----- l = 0 -----
|
|
97
|
+
# Y_{0,0} = 0.5 · sqrt(1/π). full_like keeps the batch shape, dtype and
|
|
98
|
+
# device of x, so the constant stacks with the other components.
|
|
99
|
+
y00 = torch.full_like(x, 0.5 * math.sqrt(1.0 / math.pi))
|
|
100
|
+
components.append(y00)
|
|
101
|
+
|
|
102
|
+
# Each block below adds one degree. Returning early for a smaller l_max
|
|
103
|
+
# means the higher blocks never run.
|
|
104
|
+
if l_max < 1:
|
|
105
|
+
return torch.stack(components, dim=-1)
|
|
106
|
+
|
|
107
|
+
# ----- l = 1 -----
|
|
108
|
+
# Y_{1,m} = sqrt(3/(4π)) · {y, z, x} for m = -1, 0, 1.
|
|
109
|
+
c1 = math.sqrt(3.0 / (4.0 * math.pi))
|
|
110
|
+
components.extend([c1 * y, c1 * z, c1 * x])
|
|
111
|
+
|
|
112
|
+
if l_max < 2:
|
|
113
|
+
return torch.stack(components, dim=-1)
|
|
114
|
+
|
|
115
|
+
# ----- l = 2 -----
|
|
116
|
+
# Y_{2,-2} = 0.5 · sqrt(15/π) · xy
|
|
117
|
+
# Y_{2,-1} = 0.5 · sqrt(15/π) · yz
|
|
118
|
+
# Y_{2,0} = 0.25 · sqrt(5/π) · (3z² − 1)
|
|
119
|
+
# Y_{2,1} = 0.5 · sqrt(15/π) · xz
|
|
120
|
+
# Y_{2,2} = 0.25 · sqrt(15/π) · (x² − y²)
|
|
121
|
+
c2a = 0.5 * math.sqrt(15.0 / math.pi)
|
|
122
|
+
c20 = 0.25 * math.sqrt(5.0 / math.pi)
|
|
123
|
+
c22 = 0.25 * math.sqrt(15.0 / math.pi)
|
|
124
|
+
components.extend([
|
|
125
|
+
c2a * x * y,
|
|
126
|
+
c2a * y * z,
|
|
127
|
+
c20 * (3.0 * z * z - 1.0),
|
|
128
|
+
c2a * x * z,
|
|
129
|
+
c22 * (x * x - y * y),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
if l_max < 3:
|
|
133
|
+
return torch.stack(components, dim=-1)
|
|
134
|
+
|
|
135
|
+
# ----- l = 3 -----
|
|
136
|
+
# Y_{3,-3} = 0.25 · sqrt(35/(2π)) · y(3x² − y²)
|
|
137
|
+
# Y_{3,-2} = 0.5 · sqrt(105/π) · xyz
|
|
138
|
+
# Y_{3,-1} = 0.25 · sqrt(21/(2π)) · y(5z² − 1)
|
|
139
|
+
# Y_{3,0} = 0.25 · sqrt(7/π) · (5z³ − 3z)
|
|
140
|
+
# Y_{3,1} = 0.25 · sqrt(21/(2π)) · x(5z² − 1)
|
|
141
|
+
# Y_{3,2} = 0.25 · sqrt(105/π) · z(x² − y²)
|
|
142
|
+
# Y_{3,3} = 0.25 · sqrt(35/(2π)) · x(x² − 3y²)
|
|
143
|
+
c3_a = 0.25 * math.sqrt(35.0 / (2.0 * math.pi))
|
|
144
|
+
c3_b = 0.5 * math.sqrt(105.0 / math.pi)
|
|
145
|
+
c3_c = 0.25 * math.sqrt(21.0 / (2.0 * math.pi))
|
|
146
|
+
c3_0 = 0.25 * math.sqrt(7.0 / math.pi)
|
|
147
|
+
c3_d = 0.25 * math.sqrt(105.0 / math.pi)
|
|
148
|
+
components.extend([
|
|
149
|
+
c3_a * y * (3.0 * x * x - y * y),
|
|
150
|
+
c3_b * x * y * z,
|
|
151
|
+
c3_c * y * (5.0 * z * z - 1.0),
|
|
152
|
+
c3_0 * (5.0 * z ** 3 - 3.0 * z),
|
|
153
|
+
c3_c * x * (5.0 * z * z - 1.0),
|
|
154
|
+
c3_d * z * (x * x - y * y),
|
|
155
|
+
c3_a * x * (x * x - 3.0 * y * y),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
if l_max < 4:
|
|
159
|
+
return torch.stack(components, dim=-1)
|
|
160
|
+
|
|
161
|
+
# ----- l = 4 -----
|
|
162
|
+
# Y_{4,-4} = 0.75 · sqrt(35/π) · xy(x² − y²)
|
|
163
|
+
# Y_{4,-3} = 0.75 · sqrt(35/(2π)) · yz(3x² − y²)
|
|
164
|
+
# Y_{4,-2} = 0.75 · sqrt(5/π) · xy(7z² − 1)
|
|
165
|
+
# Y_{4,-1} = 0.75 · sqrt(5/(2π)) · yz(7z² − 3)
|
|
166
|
+
# Y_{4,0} = 3/16 · sqrt(1/π) · (35z⁴ − 30z² + 3)
|
|
167
|
+
# Y_{4,1} = 0.75 · sqrt(5/(2π)) · xz(7z² − 3)
|
|
168
|
+
# Y_{4,2} = 0.375 · sqrt(5/π) · (x² − y²)(7z² − 1)
|
|
169
|
+
# Y_{4,3} = 0.75 · sqrt(35/(2π)) · xz(x² − 3y²)
|
|
170
|
+
# Y_{4,4} = 3/16 · sqrt(35/π) · (x⁴ − 6x²y² + y⁴)
|
|
171
|
+
c4_a = 0.75 * math.sqrt(35.0 / math.pi)
|
|
172
|
+
c4_b = 0.75 * math.sqrt(35.0 / (2.0 * math.pi))
|
|
173
|
+
c4_c = 0.75 * math.sqrt(5.0 / math.pi)
|
|
174
|
+
c4_d = 0.75 * math.sqrt(5.0 / (2.0 * math.pi))
|
|
175
|
+
c4_0 = (3.0 / 16.0) * math.sqrt(1.0 / math.pi)
|
|
176
|
+
c4_e = 0.375 * math.sqrt(5.0 / math.pi)
|
|
177
|
+
c4_f = (3.0 / 16.0) * math.sqrt(35.0 / math.pi)
|
|
178
|
+
components.extend([
|
|
179
|
+
c4_a * x * y * (x * x - y * y),
|
|
180
|
+
c4_b * y * z * (3.0 * x * x - y * y),
|
|
181
|
+
c4_c * x * y * (7.0 * z * z - 1.0),
|
|
182
|
+
c4_d * y * z * (7.0 * z * z - 3.0),
|
|
183
|
+
c4_0 * (35.0 * z ** 4 - 30.0 * z * z + 3.0),
|
|
184
|
+
c4_d * x * z * (7.0 * z * z - 3.0),
|
|
185
|
+
c4_e * (x * x - y * y) * (7.0 * z * z - 1.0),
|
|
186
|
+
c4_b * x * z * (x * x - 3.0 * y * y),
|
|
187
|
+
c4_f * (x ** 4 - 6.0 * x * x * y * y + y ** 4),
|
|
188
|
+
])
|
|
189
|
+
return torch.stack(components, dim=-1)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.manifolds; Riemannian manifold primitives, GPU-batched.
|
|
5
|
+
|
|
6
|
+
Currently implemented:
|
|
7
|
+
fixed_rank.FixedRankManifold; M_r(m, n), the rank-r matrices in R^{m×n}
|
|
8
|
+
spd.SPDManifold ; P(n), symmetric positive definite,
|
|
9
|
+
affine-invariant metric
|
|
10
|
+
lorentz.LorentzManifold ; H^n_k, hyperboloid model of hyperbolic
|
|
11
|
+
space of curvature k < 0 (Nickel-Kiela 2018)
|
|
12
|
+
stereographic.KappaStereographicManifold; parametric κ ∈ R, interpolates
|
|
13
|
+
spherical (κ>0), Euclidean (κ=0),
|
|
14
|
+
hyperbolic (κ<0) via gyro-algebra
|
|
15
|
+
(Bachmann-Bécigneul-Ganea 2020)
|
|
16
|
+
lorentzian.LorentzianManifold; flat Minkowski spacetime R^{1, n-1}
|
|
17
|
+
with pseudo-Riemannian (1, n-1)
|
|
18
|
+
signature; causal structure (timelike/
|
|
19
|
+
null/spacelike) + proper time/distance
|
|
20
|
+
(MTW 1973; O'Neill 1983)
|
|
21
|
+
product.ProductManifold ; Riemannian product M_1 × M_2 × ...
|
|
22
|
+
with the Pythagorean direct-sum
|
|
23
|
+
metric (Gu-Sala et al. 2019;
|
|
24
|
+
Skopek et al. 2019)
|
|
25
|
+
Planned (port/wrap from geoopt, geomstats, pymanopt):
|
|
26
|
+
sphere, stiefel, grassmann
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from holonomy_lib.manifolds.comparison import (
|
|
30
|
+
model_anisotropic_flux,
|
|
31
|
+
model_ball_volume,
|
|
32
|
+
model_sphere_area,
|
|
33
|
+
sn_kappa,
|
|
34
|
+
)
|
|
35
|
+
from holonomy_lib.manifolds.fixed_rank import FixedRankManifold
|
|
36
|
+
from holonomy_lib.manifolds.lorentz import LorentzManifold
|
|
37
|
+
from holonomy_lib.manifolds.lorentzian import LorentzianManifold
|
|
38
|
+
from holonomy_lib.manifolds.product import ProductManifold
|
|
39
|
+
from holonomy_lib.manifolds.spd import SPDManifold
|
|
40
|
+
from holonomy_lib.manifolds.stereographic import KappaStereographicManifold
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"FixedRankManifold",
|
|
44
|
+
"KappaStereographicManifold",
|
|
45
|
+
"LorentzManifold",
|
|
46
|
+
"LorentzianManifold",
|
|
47
|
+
"ProductManifold",
|
|
48
|
+
"SPDManifold",
|
|
49
|
+
"model_anisotropic_flux",
|
|
50
|
+
"model_ball_volume",
|
|
51
|
+
"model_sphere_area",
|
|
52
|
+
"sn_kappa",
|
|
53
|
+
]
|