FastQuat 1.0b3__tar.gz → 1.0b4__tar.gz
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.
- fastquat-1.0b4/.github/workflows/ci.yml +71 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/.pre-commit-config.yaml +6 -14
- {fastquat-1.0b3 → fastquat-1.0b4}/PKG-INFO +4 -2
- {fastquat-1.0b3 → fastquat-1.0b4}/README.md +3 -1
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/api/quaternion.md +1 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/development.md +17 -20
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/index.md +1 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/user-guide/installation.md +2 -2
- {fastquat-1.0b3 → fastquat-1.0b4}/pyproject.toml +11 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/src/fastquat/quaternion.py +52 -25
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/test_rotation.py +74 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/uv.lock +22 -0
- fastquat-1.0b3/.github/workflows/ci.yml +0 -67
- {fastquat-1.0b3 → fastquat-1.0b4}/.github/workflows/release.yml +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/.gitignore +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/.readthedocs.yaml +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/.gitignore +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/Makefile +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/README.md +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/conf.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/user-guide/getting-started.ipynb +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/user-guide/tutorial-rotations.ipynb +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/docs/source/user-guide/tutorial-slerp.ipynb +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/src/fastquat/__init__.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/conftest.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/test_base.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/test_indexing.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/test_math.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/test_pow.py +0 -0
- {fastquat-1.0b3 → fastquat-1.0b4}/tests/test_tensor.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
permissions: {}
|
|
4
|
+
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches:
|
|
8
|
+
- main
|
|
9
|
+
pull_request:
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
concurrency:
|
|
13
|
+
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }}
|
|
14
|
+
cancel-in-progress: true
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
|
|
18
|
+
pre-commit:
|
|
19
|
+
name: Pre-commit
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
timeout-minutes: 5
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v7.0.1
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
26
|
+
with:
|
|
27
|
+
version: '0.12.18'
|
|
28
|
+
- name: Cache prek
|
|
29
|
+
uses: actions/cache@v6.1.0
|
|
30
|
+
with:
|
|
31
|
+
path: ~/.cache/prek
|
|
32
|
+
key: prek-${{ hashFiles('.pre-commit-config.yaml') }}
|
|
33
|
+
restore-keys: |
|
|
34
|
+
prek-
|
|
35
|
+
# ty resolves third-party imports from the environment, so jax has to be installed.
|
|
36
|
+
- name: Install dependencies
|
|
37
|
+
run: uv sync --locked --no-default-groups --group lint
|
|
38
|
+
- name: Run pre-commit
|
|
39
|
+
run: uv run --no-sync prek run --all-files --show-diff-on-failure --color always
|
|
40
|
+
|
|
41
|
+
test:
|
|
42
|
+
name: Run tests on Python ${{ matrix.python-version }}
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
timeout-minutes: 15
|
|
45
|
+
strategy:
|
|
46
|
+
fail-fast: false
|
|
47
|
+
matrix:
|
|
48
|
+
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
|
49
|
+
|
|
50
|
+
steps:
|
|
51
|
+
- name: Checkout code
|
|
52
|
+
uses: actions/checkout@v7.0.1
|
|
53
|
+
with:
|
|
54
|
+
fetch-depth: 0
|
|
55
|
+
|
|
56
|
+
- name: Install uv
|
|
57
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
58
|
+
with:
|
|
59
|
+
version: '0.12.18'
|
|
60
|
+
python-version: ${{ matrix.python-version }}
|
|
61
|
+
|
|
62
|
+
- name: Build wheel
|
|
63
|
+
run: uv build --wheel
|
|
64
|
+
|
|
65
|
+
- name: Install project's wheel and dependencies
|
|
66
|
+
run: |
|
|
67
|
+
uv sync --locked --no-install-project
|
|
68
|
+
uv pip install dist/fastquat-*.whl
|
|
69
|
+
|
|
70
|
+
- name: Run tests
|
|
71
|
+
run: uv run --no-sync pytest -v
|
|
@@ -7,9 +7,9 @@ repos:
|
|
|
7
7
|
- --all
|
|
8
8
|
|
|
9
9
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
10
|
-
rev: "v0.
|
|
10
|
+
rev: "v0.16.9"
|
|
11
11
|
hooks:
|
|
12
|
-
- id: ruff
|
|
12
|
+
- id: ruff-check
|
|
13
13
|
name: ruff linting
|
|
14
14
|
- id: ruff-format
|
|
15
15
|
name: ruff formatting
|
|
@@ -31,21 +31,13 @@ repos:
|
|
|
31
31
|
- id: check-merge-conflict
|
|
32
32
|
|
|
33
33
|
- repo: https://github.com/kynan/nbstripout
|
|
34
|
-
rev: 0.
|
|
34
|
+
rev: "0.9.1"
|
|
35
35
|
hooks:
|
|
36
36
|
- id: nbstripout
|
|
37
37
|
name: notebook stripping
|
|
38
38
|
args: [--extra-keys=metadata.language_info.version]
|
|
39
39
|
|
|
40
|
-
- repo: https://github.com/pre-commit
|
|
41
|
-
rev:
|
|
40
|
+
- repo: https://github.com/astral-sh/ty-pre-commit
|
|
41
|
+
rev: v0.0.84
|
|
42
42
|
hooks:
|
|
43
|
-
- id:
|
|
44
|
-
additional_dependencies:
|
|
45
|
-
- jax
|
|
46
|
-
args:
|
|
47
|
-
- --strict
|
|
48
|
-
- --show-error-codes
|
|
49
|
-
- --enable-error-code=ignore-without-code
|
|
50
|
-
- --allow-untyped-calls
|
|
51
|
-
files: ^fastquat/
|
|
43
|
+
- id: ty
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: FastQuat
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.0b4
|
|
4
4
|
Summary: High-performance quaternions with JAX support
|
|
5
5
|
Project-URL: homepage, https://fastquat.readthedocs.io
|
|
6
6
|
Project-URL: repository, https://github.com/CMBSciPol/fastquat
|
|
@@ -89,7 +89,7 @@ interpolated = q1.slerp(q2, t=0.5) # Halfway between q1 and q2
|
|
|
89
89
|
### Core Operations
|
|
90
90
|
- **Quaternion arithmetic**: Addition, multiplication, conjugation, inverse, power, exponentiation, logarithm
|
|
91
91
|
- **Normalization**: Efficient unit quaternion computation
|
|
92
|
-
- **Conversion**: To/from rotation matrices, Euler angles
|
|
92
|
+
- **Conversion**: To/from rotation matrices, axis-angle, Euler angles
|
|
93
93
|
- **Vector rotation**: Direct vector transformation
|
|
94
94
|
|
|
95
95
|
### Advanced Interpolation
|
|
@@ -124,11 +124,13 @@ from fastquat import Quaternion
|
|
|
124
124
|
key = jax.random.PRNGKey(42)
|
|
125
125
|
q_batch = Quaternion.random(key, shape=(1000,))
|
|
126
126
|
|
|
127
|
+
|
|
127
128
|
# JIT-compiled batch operations
|
|
128
129
|
@jax.jit
|
|
129
130
|
def batch_rotate(quaternions, vectors):
|
|
130
131
|
return quaternions.rotate_vector(vectors)
|
|
131
132
|
|
|
133
|
+
|
|
132
134
|
vectors = jax.random.normal(key, (1000, 3))
|
|
133
135
|
rotated_batch = batch_rotate(q_batch, vectors)
|
|
134
136
|
```
|
|
@@ -66,7 +66,7 @@ interpolated = q1.slerp(q2, t=0.5) # Halfway between q1 and q2
|
|
|
66
66
|
### Core Operations
|
|
67
67
|
- **Quaternion arithmetic**: Addition, multiplication, conjugation, inverse, power, exponentiation, logarithm
|
|
68
68
|
- **Normalization**: Efficient unit quaternion computation
|
|
69
|
-
- **Conversion**: To/from rotation matrices, Euler angles
|
|
69
|
+
- **Conversion**: To/from rotation matrices, axis-angle, Euler angles
|
|
70
70
|
- **Vector rotation**: Direct vector transformation
|
|
71
71
|
|
|
72
72
|
### Advanced Interpolation
|
|
@@ -101,11 +101,13 @@ from fastquat import Quaternion
|
|
|
101
101
|
key = jax.random.PRNGKey(42)
|
|
102
102
|
q_batch = Quaternion.random(key, shape=(1000,))
|
|
103
103
|
|
|
104
|
+
|
|
104
105
|
# JIT-compiled batch operations
|
|
105
106
|
@jax.jit
|
|
106
107
|
def batch_rotate(quaternions, vectors):
|
|
107
108
|
return quaternions.rotate_vector(vectors)
|
|
108
109
|
|
|
110
|
+
|
|
109
111
|
vectors = jax.random.normal(key, (1000, 3))
|
|
110
112
|
rotated_batch = batch_rotate(q_batch, vectors)
|
|
111
113
|
```
|
|
@@ -18,6 +18,7 @@ compilation, automatic differentiation, and vectorization.
|
|
|
18
18
|
.. automethod:: fastquat.Quaternion.from_array
|
|
19
19
|
.. automethod:: fastquat.Quaternion.from_scalar_vector
|
|
20
20
|
.. automethod:: fastquat.Quaternion.from_rotation_matrix
|
|
21
|
+
.. automethod:: fastquat.Quaternion.from_axis_angle
|
|
21
22
|
.. automethod:: fastquat.Quaternion.zeros
|
|
22
23
|
.. automethod:: fastquat.Quaternion.ones
|
|
23
24
|
.. automethod:: fastquat.Quaternion.full
|
|
@@ -112,20 +112,21 @@ from jax.typing import ArrayLike
|
|
|
112
112
|
|
|
113
113
|
|
|
114
114
|
class Quaternion:
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
115
|
+
...
|
|
116
|
+
|
|
117
|
+
def new_method(self, parameter: ArrayLike) -> Self:
|
|
118
|
+
"""Brief description of what the method does.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
parameter: Description of the parameter
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Description of the return value
|
|
125
|
+
"""
|
|
126
|
+
# Implementation using JAX operations
|
|
127
|
+
parameter = jnp.asarray(parameter)
|
|
128
|
+
result = jnp.some_operation(self.wxyz, parameter)
|
|
129
|
+
return Quaternion.from_array(result)
|
|
129
130
|
```
|
|
130
131
|
|
|
131
132
|
Then add tests:
|
|
@@ -138,15 +139,11 @@ import pytest
|
|
|
138
139
|
from fastquat import Quaternion
|
|
139
140
|
|
|
140
141
|
|
|
141
|
-
@pytest.mark.parametrize(
|
|
142
|
-
'parameter, expected_values',
|
|
143
|
-
[
|
|
144
|
-
(..., ...),
|
|
145
|
-
]
|
|
146
|
-
)
|
|
142
|
+
@pytest.mark.parametrize('parameter, expected_values', [(..., ...)])
|
|
147
143
|
@pytest.mark.parametrize('do_jit', [False, True])
|
|
148
144
|
def test_new_method(parameter, expected_values, do_jit):
|
|
149
145
|
"""Test the new method."""
|
|
146
|
+
|
|
150
147
|
def test_fn(q_, parameter_):
|
|
151
148
|
return q_.new_method(parameter_)
|
|
152
149
|
|
|
@@ -39,6 +39,7 @@ q2 = Quaternion(0.7071, 0.7071, 0.0, 0.0) # 90° rotation around x-axis
|
|
|
39
39
|
| Normalization | `q.normalize()` | Unit quaternion |
|
|
40
40
|
| Conjugate | `q.conj()` | Quaternion conjugate |
|
|
41
41
|
| Rotation | `q.rotate_vector(v)` | Rotate 3D vector |
|
|
42
|
+
| Axis-angle | `Quaternion.from_axis_angle(axis, angle)` | Rotation about an axis |
|
|
42
43
|
| SLERP | `q1.slerp(q2, t)` | Spherical interpolation |
|
|
43
44
|
| Log | `q.log()` | Quaternion logarithm |
|
|
44
45
|
| Exp | `q.exp()` | Quaternion exponential |
|
|
@@ -52,12 +52,12 @@ from fastquat import Quaternion
|
|
|
52
52
|
|
|
53
53
|
# Create a simple quaternion
|
|
54
54
|
q = Quaternion(1.0)
|
|
55
|
-
print(f
|
|
55
|
+
print(f'Identity quaternion: {q}')
|
|
56
56
|
|
|
57
57
|
# Test SLERP functionality
|
|
58
58
|
q2 = Quaternion(0.7071, 0.7071, 0.0, 0.0)
|
|
59
59
|
interpolated = q.slerp(q2, 0.5)
|
|
60
|
-
print(f
|
|
60
|
+
print(f'SLERP result: {interpolated}')
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
If this runs without errors, FastQuat is properly installed!
|
|
@@ -34,6 +34,7 @@ repository = 'https://github.com/CMBSciPol/fastquat'
|
|
|
34
34
|
[dependency-groups]
|
|
35
35
|
dev = [
|
|
36
36
|
{include-group = 'docs'},
|
|
37
|
+
{include-group = 'lint'},
|
|
37
38
|
'pytest>=9.0',
|
|
38
39
|
'pytest-notebook>=0.10',
|
|
39
40
|
'visu-hlo',
|
|
@@ -48,6 +49,7 @@ docs = [
|
|
|
48
49
|
'numpy>=1.24.0',
|
|
49
50
|
'pillow>=9.0.0',
|
|
50
51
|
]
|
|
52
|
+
lint = ['prek>=0.5.4']
|
|
51
53
|
cuda12 = ['jax[cuda12]>=0.4']
|
|
52
54
|
cuda13 = ['jax[cuda13]>=0.7; python_version>="3.11"']
|
|
53
55
|
|
|
@@ -83,5 +85,14 @@ select = [
|
|
|
83
85
|
[tool.ruff.format]
|
|
84
86
|
quote-style = 'single'
|
|
85
87
|
|
|
88
|
+
[tool.ty.rules]
|
|
89
|
+
dynamic-function-decorator-return = "error"
|
|
90
|
+
missing-type-argument = "error"
|
|
91
|
+
possibly-unresolved-reference = "warn"
|
|
92
|
+
unused-ignore-comment = "error"
|
|
93
|
+
|
|
94
|
+
[tool.ty.src]
|
|
95
|
+
include = ["src"]
|
|
96
|
+
|
|
86
97
|
[tool.uv]
|
|
87
98
|
cache-keys = [{ git = true }]
|
|
@@ -118,6 +118,32 @@ class Quaternion:
|
|
|
118
118
|
|
|
119
119
|
return cls.from_array(jnp.stack([w, x, y, z], axis=-1))
|
|
120
120
|
|
|
121
|
+
@classmethod
|
|
122
|
+
def from_axis_angle(cls, axis: ArrayLike, angle: ArrayLike) -> Self:
|
|
123
|
+
"""Create the unit quaternion of a rotation about an axis.
|
|
124
|
+
|
|
125
|
+
The rotation follows the right-hand rule: a positive angle rotates counterclockwise when
|
|
126
|
+
looking from the tip of the axis towards the origin.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
axis: Array of shape (..., 3) for the rotation axis. It does not need to be normalized.
|
|
130
|
+
angle: Array of shape (...) for the rotation angle, in radians.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Quaternion of shape broadcast(axis.shape[:-1], angle.shape).
|
|
134
|
+
"""
|
|
135
|
+
axis = jnp.asarray(axis)
|
|
136
|
+
angle = jnp.asarray(angle)
|
|
137
|
+
if axis.shape[-1:] != (3,):
|
|
138
|
+
raise ValueError(f'Axis must have shape (..., 3), got {axis.shape}')
|
|
139
|
+
dtype = jnp.result_type(axis, angle, float)
|
|
140
|
+
unit_axis = axis / jnp.linalg.norm(axis, axis=-1, keepdims=True)
|
|
141
|
+
half_angle = 0.5 * angle.astype(dtype)
|
|
142
|
+
scalar = jnp.cos(half_angle)
|
|
143
|
+
vector = jnp.sin(half_angle)[..., None] * unit_axis.astype(dtype)
|
|
144
|
+
scalar, vector = jnp.broadcast_arrays(scalar[..., None], vector)
|
|
145
|
+
return cls.from_scalar_vector(scalar[..., 0], vector)
|
|
146
|
+
|
|
121
147
|
@classmethod
|
|
122
148
|
def zeros(cls, shape: tuple[int, ...], dtype: DTypeLike | None = None) -> Self:
|
|
123
149
|
"""Create quaternions with all components set to 0.
|
|
@@ -167,7 +193,7 @@ class Quaternion:
|
|
|
167
193
|
|
|
168
194
|
@classmethod
|
|
169
195
|
def random(
|
|
170
|
-
cls, key:
|
|
196
|
+
cls, key: Array, shape: tuple[int, ...] = (), dtype: DTypeLike | None = None
|
|
171
197
|
) -> Self:
|
|
172
198
|
"""Generate normalized random quaternions.
|
|
173
199
|
|
|
@@ -180,7 +206,7 @@ class Quaternion:
|
|
|
180
206
|
Normalized Quaternion.
|
|
181
207
|
"""
|
|
182
208
|
data = jax.random.normal(key, shape + (4,), dtype=dtype)
|
|
183
|
-
return
|
|
209
|
+
return cls.from_array(data).normalize()
|
|
184
210
|
|
|
185
211
|
@property
|
|
186
212
|
def w(self) -> Array:
|
|
@@ -214,13 +240,13 @@ class Quaternion:
|
|
|
214
240
|
returns the quaternion [NaN, NaN, NaN, NaN].
|
|
215
241
|
"""
|
|
216
242
|
norm = abs(self)
|
|
217
|
-
return
|
|
243
|
+
return self.from_array(self.wxyz / jnp.expand_dims(norm, axis=-1))
|
|
218
244
|
|
|
219
245
|
def _inverse(self) -> Self:
|
|
220
246
|
"""Quaternion inverse (private method - use 1/q instead)."""
|
|
221
247
|
conj = self.conj()
|
|
222
248
|
norm_sq = jnp.sum(self.wxyz**2, axis=-1)
|
|
223
|
-
return
|
|
249
|
+
return self.from_array(conj.wxyz / jnp.expand_dims(norm_sq, axis=-1))
|
|
224
250
|
|
|
225
251
|
def to_components(self) -> tuple[Array, Array, Array, Array]:
|
|
226
252
|
return self.w, self.x, self.y, self.z
|
|
@@ -290,13 +316,13 @@ class Quaternion:
|
|
|
290
316
|
if self.ndim == 0:
|
|
291
317
|
raise TypeError('iteration over a 0-d quaternion')
|
|
292
318
|
for i in range(self.shape[0]):
|
|
293
|
-
yield
|
|
319
|
+
yield self.from_array(self.wxyz[i])
|
|
294
320
|
|
|
295
321
|
def __getitem__(self, idx: Any) -> Self:
|
|
296
322
|
"""Index or slice the tensor of quaternions."""
|
|
297
323
|
if not isinstance(idx, tuple):
|
|
298
324
|
idx = (idx,)
|
|
299
|
-
return
|
|
325
|
+
return self.from_array(self.wxyz[(*idx, slice(None))])
|
|
300
326
|
|
|
301
327
|
def __pos__(self) -> Self:
|
|
302
328
|
"""Quaternion positive."""
|
|
@@ -304,12 +330,12 @@ class Quaternion:
|
|
|
304
330
|
|
|
305
331
|
def __neg__(self) -> Self:
|
|
306
332
|
"""Quaternion negation."""
|
|
307
|
-
return
|
|
333
|
+
return self.from_array(-self.wxyz)
|
|
308
334
|
|
|
309
335
|
def __add__(self, other: Any) -> Self:
|
|
310
336
|
"""Quaternion addition."""
|
|
311
337
|
if isinstance(other, Quaternion):
|
|
312
|
-
return
|
|
338
|
+
return self.from_array(self.wxyz + other.wxyz)
|
|
313
339
|
|
|
314
340
|
try:
|
|
315
341
|
other = jnp.asarray(other)
|
|
@@ -319,7 +345,7 @@ class Quaternion:
|
|
|
319
345
|
if jnp.iscomplexobj(other):
|
|
320
346
|
raise NotImplementedError('Quaternion and complex addition is not implemented.')
|
|
321
347
|
|
|
322
|
-
return
|
|
348
|
+
return self.from_scalar_vector(self.w + other, self.vector)
|
|
323
349
|
|
|
324
350
|
def __radd__(self, other: Any) -> Self:
|
|
325
351
|
"""Quaternion addition."""
|
|
@@ -328,7 +354,7 @@ class Quaternion:
|
|
|
328
354
|
def __sub__(self, other: Any) -> Self:
|
|
329
355
|
"""Quaternion subtraction."""
|
|
330
356
|
if isinstance(other, Quaternion):
|
|
331
|
-
return
|
|
357
|
+
return self.from_array(self.wxyz - other.wxyz)
|
|
332
358
|
|
|
333
359
|
try:
|
|
334
360
|
other = jnp.asarray(other)
|
|
@@ -338,7 +364,7 @@ class Quaternion:
|
|
|
338
364
|
if jnp.iscomplexobj(other):
|
|
339
365
|
raise NotImplementedError('Quaternion and complex subtraction is not implemented.')
|
|
340
366
|
|
|
341
|
-
return
|
|
367
|
+
return self.from_scalar_vector(self.w - other, self.vector)
|
|
342
368
|
|
|
343
369
|
def __rsub__(self, other: Any) -> Self:
|
|
344
370
|
"""Quaternion subtraction."""
|
|
@@ -350,7 +376,7 @@ class Quaternion:
|
|
|
350
376
|
if jnp.iscomplexobj(other):
|
|
351
377
|
raise NotImplementedError('Quaternion and complex subtraction is not implemented.')
|
|
352
378
|
|
|
353
|
-
return
|
|
379
|
+
return self.from_scalar_vector(other - self.w, -self.vector)
|
|
354
380
|
|
|
355
381
|
def __mul__(self, other: Any) -> Self:
|
|
356
382
|
"""Quaternion multiplication."""
|
|
@@ -363,7 +389,7 @@ class Quaternion:
|
|
|
363
389
|
y = w1 * y2 - x1 * z2 + y1 * w2 + z1 * x2
|
|
364
390
|
z = w1 * z2 + x1 * y2 - y1 * x2 + z1 * w2
|
|
365
391
|
|
|
366
|
-
return
|
|
392
|
+
return self.from_array(jnp.stack([w, x, y, z], axis=-1))
|
|
367
393
|
|
|
368
394
|
try:
|
|
369
395
|
other = jnp.asarray(other)
|
|
@@ -373,7 +399,7 @@ class Quaternion:
|
|
|
373
399
|
if jnp.iscomplexobj(other):
|
|
374
400
|
raise NotImplementedError('Quaternion and complex multiplication is not implemented.')
|
|
375
401
|
|
|
376
|
-
return
|
|
402
|
+
return self.from_array(self.wxyz * jnp.expand_dims(other, axis=-1))
|
|
377
403
|
|
|
378
404
|
def __rmul__(self, other: Any) -> Self:
|
|
379
405
|
"""Quaternion multiplication."""
|
|
@@ -385,7 +411,7 @@ class Quaternion:
|
|
|
385
411
|
if jnp.iscomplexobj(other):
|
|
386
412
|
raise NotImplementedError('Quaternion and complex multiplication is not implemented.')
|
|
387
413
|
|
|
388
|
-
return
|
|
414
|
+
return self.from_array(jnp.expand_dims(other, axis=-1) * self.wxyz)
|
|
389
415
|
|
|
390
416
|
def __truediv__(self, other: Any) -> Self:
|
|
391
417
|
"""Quaternion division."""
|
|
@@ -400,7 +426,7 @@ class Quaternion:
|
|
|
400
426
|
if jnp.iscomplexobj(other):
|
|
401
427
|
raise NotImplementedError('Quaternion and complex division is not implemented.')
|
|
402
428
|
|
|
403
|
-
return
|
|
429
|
+
return self.from_array(self.wxyz / jnp.expand_dims(other, axis=-1))
|
|
404
430
|
|
|
405
431
|
def __rtruediv__(self, other: Any) -> Self:
|
|
406
432
|
"""Quaternion division."""
|
|
@@ -437,7 +463,7 @@ class Quaternion:
|
|
|
437
463
|
elif exponent == -1:
|
|
438
464
|
return self._inverse()
|
|
439
465
|
elif exponent == 0:
|
|
440
|
-
return
|
|
466
|
+
return self.ones(self.shape, self.dtype)
|
|
441
467
|
elif exponent == 1:
|
|
442
468
|
return self
|
|
443
469
|
elif exponent == 2:
|
|
@@ -445,8 +471,9 @@ class Quaternion:
|
|
|
445
471
|
return (exponent * self.log()).exp()
|
|
446
472
|
|
|
447
473
|
# General case: q^n = exp(n * log(q))
|
|
474
|
+
exponent = jnp.asarray(exponent)
|
|
448
475
|
result = (exponent * self.log()).exp().wxyz
|
|
449
|
-
return
|
|
476
|
+
return self.from_array(
|
|
450
477
|
jnp.where(
|
|
451
478
|
exponent[..., None] == 0, jnp.array([1.0, 0.0, 0.0, 0.0], dtype=self.dtype), result
|
|
452
479
|
)
|
|
@@ -481,7 +508,7 @@ class Quaternion:
|
|
|
481
508
|
)
|
|
482
509
|
log_q_vector = theta_over_vector_norm[..., None] * vector_part
|
|
483
510
|
|
|
484
|
-
return
|
|
511
|
+
return self.from_scalar_vector(log_norm, log_q_vector)
|
|
485
512
|
|
|
486
513
|
def exp(self) -> Self:
|
|
487
514
|
"""Compute quaternion exponential.
|
|
@@ -509,7 +536,7 @@ class Quaternion:
|
|
|
509
536
|
result_w = exp_scalar * cos_vnorm
|
|
510
537
|
result_vector = jnp.expand_dims(exp_scalar * sinc_vnorm, -1) * vector_part
|
|
511
538
|
|
|
512
|
-
return
|
|
539
|
+
return self.from_scalar_vector(result_w, result_vector)
|
|
513
540
|
|
|
514
541
|
@property
|
|
515
542
|
def nbytes(self) -> int:
|
|
@@ -562,12 +589,12 @@ class Quaternion:
|
|
|
562
589
|
|
|
563
590
|
def squeeze(self, axis=None) -> Self:
|
|
564
591
|
"""Supprime les dimensions de taille 1"""
|
|
565
|
-
return
|
|
592
|
+
return self.from_array(jnp.squeeze(self.wxyz, axis=axis))
|
|
566
593
|
|
|
567
594
|
def conjugate(self) -> Self:
|
|
568
595
|
"""Quaternion conjugate."""
|
|
569
596
|
sign = jnp.array([1, -1, -1, -1])
|
|
570
|
-
return
|
|
597
|
+
return self.from_array(self.wxyz * sign)
|
|
571
598
|
|
|
572
599
|
def conj(self) -> Self:
|
|
573
600
|
"""Quaternion conjugate."""
|
|
@@ -615,7 +642,7 @@ class Quaternion:
|
|
|
615
642
|
|
|
616
643
|
# Linear interpolation case
|
|
617
644
|
result_linear = q1.wxyz + jnp.expand_dims(t * (1 - t), -1) * (q2_corrected - q1.wxyz)
|
|
618
|
-
result_linear =
|
|
645
|
+
result_linear = self.from_array(result_linear).normalize()
|
|
619
646
|
|
|
620
647
|
# Spherical interpolation case
|
|
621
648
|
theta = jnp.arccos(jnp.clip(dot, 0.0, 1.0))
|
|
@@ -630,9 +657,9 @@ class Quaternion:
|
|
|
630
657
|
result_slerp = (
|
|
631
658
|
jnp.expand_dims(factor1, -1) * q1.wxyz + jnp.expand_dims(factor2, -1) * q2_corrected
|
|
632
659
|
)
|
|
633
|
-
result_slerp =
|
|
660
|
+
result_slerp = self.from_array(result_slerp)
|
|
634
661
|
|
|
635
662
|
# Choose between linear and spherical interpolation
|
|
636
663
|
result = jnp.where(jnp.expand_dims(use_linear, -1), result_linear.wxyz, result_slerp.wxyz)
|
|
637
664
|
|
|
638
|
-
return
|
|
665
|
+
return self.from_array(result)
|
|
@@ -79,6 +79,80 @@ def test_from_rotation_matrix_wrong_shape(do_jit):
|
|
|
79
79
|
func(wrong_matrix)
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
# from_axis_angle
|
|
83
|
+
@pytest.mark.parametrize(
|
|
84
|
+
'axis, angle, expected',
|
|
85
|
+
[
|
|
86
|
+
([0.0, 0.0, 1.0], 0.0, [1.0, 0.0, 0.0, 0.0]),
|
|
87
|
+
([1.0, 0.0, 0.0], jnp.pi, [0.0, 1.0, 0.0, 0.0]),
|
|
88
|
+
([0.0, 2.0, 0.0], jnp.pi / 2, [jnp.sqrt(0.5), 0.0, jnp.sqrt(0.5), 0.0]),
|
|
89
|
+
([0.0, 0.0, 1.0], -jnp.pi / 2, [jnp.sqrt(0.5), 0.0, 0.0, -jnp.sqrt(0.5)]),
|
|
90
|
+
],
|
|
91
|
+
)
|
|
92
|
+
@pytest.mark.parametrize('do_jit', [False, True])
|
|
93
|
+
def test_from_axis_angle(axis, angle, expected, do_jit):
|
|
94
|
+
"""Test from_axis_angle against known quaternions (axis need not be normalized)."""
|
|
95
|
+
func = Quaternion.from_axis_angle
|
|
96
|
+
if do_jit:
|
|
97
|
+
func = jax.jit(func)
|
|
98
|
+
|
|
99
|
+
q = func(jnp.array(axis), jnp.array(angle))
|
|
100
|
+
assert jnp.allclose(q.wxyz, jnp.array(expected), atol=1e-6)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.parametrize('do_jit', [False, True])
|
|
104
|
+
def test_from_axis_angle_right_hand_rule(do_jit):
|
|
105
|
+
"""A positive rotation about z maps x to y."""
|
|
106
|
+
|
|
107
|
+
def func(angle):
|
|
108
|
+
return Quaternion.from_axis_angle(jnp.array([0.0, 0.0, 1.0]), angle).rotate_vector(
|
|
109
|
+
jnp.array([1.0, 0.0, 0.0])
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if do_jit:
|
|
113
|
+
func = jax.jit(func)
|
|
114
|
+
|
|
115
|
+
assert jnp.allclose(func(jnp.pi / 2), jnp.array([0.0, 1.0, 0.0]), atol=1e-6)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_from_axis_angle_consistency_with_matrix():
|
|
119
|
+
"""The rotation matrix of from_axis_angle is Rodrigues' formula."""
|
|
120
|
+
axis = jnp.array([1.0, -2.0, 0.5])
|
|
121
|
+
angle = 0.7
|
|
122
|
+
n = axis / jnp.linalg.norm(axis)
|
|
123
|
+
k = jnp.array([[0, -n[2], n[1]], [n[2], 0, -n[0]], [-n[1], n[0], 0]])
|
|
124
|
+
expected = jnp.eye(3) + jnp.sin(angle) * k + (1 - jnp.cos(angle)) * k @ k
|
|
125
|
+
|
|
126
|
+
q = Quaternion.from_axis_angle(axis, angle)
|
|
127
|
+
assert jnp.allclose(q.to_rotation_matrix(), expected, atol=1e-6)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_from_axis_angle_broadcast():
|
|
131
|
+
"""Axes and angles broadcast against each other."""
|
|
132
|
+
axes = jnp.eye(3) # (3, 3)
|
|
133
|
+
angles = jnp.array([[0.1], [0.2]]) # (2, 1)
|
|
134
|
+
q = Quaternion.from_axis_angle(axes, angles)
|
|
135
|
+
assert q.shape == (2, 3)
|
|
136
|
+
assert jnp.allclose(abs(q), 1.0, atol=1e-6)
|
|
137
|
+
expected = Quaternion.from_axis_angle(axes[2], angles[1, 0])
|
|
138
|
+
assert jnp.allclose(q[1, 2].wxyz, expected.wxyz, atol=1e-6)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def test_from_axis_angle_grad_at_zero():
|
|
142
|
+
"""The derivative with respect to the angle is finite and exact at zero."""
|
|
143
|
+
|
|
144
|
+
def func(angle):
|
|
145
|
+
return Quaternion.from_axis_angle(jnp.array([0.0, 1.0, 0.0]), angle).wxyz
|
|
146
|
+
|
|
147
|
+
jac = jax.jacfwd(func)(0.0)
|
|
148
|
+
assert jnp.allclose(jac, jnp.array([0.0, 0.0, 0.5, 0.0]))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_from_axis_angle_wrong_shape():
|
|
152
|
+
with pytest.raises(ValueError, match='Axis must have shape'):
|
|
153
|
+
Quaternion.from_axis_angle(jnp.array([1.0, 0.0]), 0.1)
|
|
154
|
+
|
|
155
|
+
|
|
82
156
|
# to_rotation_matrix
|
|
83
157
|
@pytest.mark.parametrize('do_jit', [False, True])
|
|
84
158
|
def test_to_rotation_matrix_identity(do_jit):
|
|
@@ -666,6 +666,7 @@ dev = [
|
|
|
666
666
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
667
667
|
{ name = "numpy", version = "2.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
|
668
668
|
{ name = "pillow" },
|
|
669
|
+
{ name = "prek" },
|
|
669
670
|
{ name = "pytest" },
|
|
670
671
|
{ name = "pytest-notebook" },
|
|
671
672
|
{ name = "sphinx", version = "8.1.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
|
@@ -688,6 +689,9 @@ docs = [
|
|
|
688
689
|
{ name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
|
|
689
690
|
{ name = "sphinx-rtd-theme" },
|
|
690
691
|
]
|
|
692
|
+
lint = [
|
|
693
|
+
{ name = "prek" },
|
|
694
|
+
]
|
|
691
695
|
|
|
692
696
|
[package.metadata]
|
|
693
697
|
requires-dist = [
|
|
@@ -705,6 +709,7 @@ dev = [
|
|
|
705
709
|
{ name = "nbsphinx", specifier = ">=0.9.0" },
|
|
706
710
|
{ name = "numpy", specifier = ">=1.24.0" },
|
|
707
711
|
{ name = "pillow", specifier = ">=9.0.0" },
|
|
712
|
+
{ name = "prek", specifier = ">=0.5.4" },
|
|
708
713
|
{ name = "pytest", specifier = ">=9.0" },
|
|
709
714
|
{ name = "pytest-notebook", specifier = ">=0.10" },
|
|
710
715
|
{ name = "sphinx", specifier = ">=7.0.0" },
|
|
@@ -721,6 +726,7 @@ docs = [
|
|
|
721
726
|
{ name = "sphinx", specifier = ">=7.0.0" },
|
|
722
727
|
{ name = "sphinx-rtd-theme", specifier = ">=1.3.0" },
|
|
723
728
|
]
|
|
729
|
+
lint = [{ name = "prek", specifier = ">=0.5.4" }]
|
|
724
730
|
|
|
725
731
|
[[package]]
|
|
726
732
|
name = "fonttools"
|
|
@@ -2778,6 +2784,22 @@ wheels = [
|
|
|
2778
2784
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
2779
2785
|
]
|
|
2780
2786
|
|
|
2787
|
+
[[package]]
|
|
2788
|
+
name = "prek"
|
|
2789
|
+
version = "0.5.4"
|
|
2790
|
+
source = { registry = "https://pypi.org/simple" }
|
|
2791
|
+
sdist = { url = "https://files.pythonhosted.org/packages/30/f1/8f3143530bf43fa82f8873f4e536f318b6143dd5c09171aba0d465e21f04/prek-0.5.4.tar.gz", hash = "sha256:7f5bc061880141e362ef422e52188d0090794c0622e442e3157686d1e342780a", size = 565182, upload-time = "2026-09-28T04:54:43.453Z" }
|
|
2792
|
+
wheels = [
|
|
2793
|
+
{ url = "https://files.pythonhosted.org/packages/78/a7/f3a4a252c0846c5846dd86118ee0c1fddb7381dc35e3d44c2a5f3cea1757/prek-0.5.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:550ecaae0601f9405526c866a57cc6882ed1e9d8696ac1117b8703bb37d087eb", size = 6011113, upload-time = "2026-09-28T04:54:28.107Z" },
|
|
2794
|
+
{ url = "https://files.pythonhosted.org/packages/1b/e3/26638cf090b620e63e60a639b8e7d7eb38b9fbea74a0827a182fc922cf8e/prek-0.5.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a7ef67cc23e23c6e9c6ca0b27a7b12c19f16774d36b1c88ed035f2639995718d", size = 5534338, upload-time = "2026-09-28T04:54:30.214Z" },
|
|
2795
|
+
{ url = "https://files.pythonhosted.org/packages/a0/08/64b668eb088204650b96183cea739b56e6b179130c4398a8a83cbe9b125a/prek-0.5.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:59f2206850f643bafe46b28b4431525f8dcc5b7a763f271179bff10b8157870d", size = 5840937, upload-time = "2026-09-28T04:54:32.226Z" },
|
|
2796
|
+
{ url = "https://files.pythonhosted.org/packages/1f/ee/a0f847194eb6a2b67921b4c94c4fbf3f33a785affb190c6a2097344dbdd6/prek-0.5.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d5b0cab1219a7bc9cc90785c7436f563765440616543ede7dfa3ba5c332cede", size = 6234354, upload-time = "2026-09-28T04:54:34.176Z" },
|
|
2797
|
+
{ url = "https://files.pythonhosted.org/packages/32/9a/14186d90ed29b46650452068d6bd1ac233ca0ea41a3b35081c37f6b77b1c/prek-0.5.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8fc22db8132dab9aed4e152bf6a58a7f791f4c91c74305d65d96c48b8ec0bc62", size = 5845649, upload-time = "2026-09-28T04:54:36.252Z" },
|
|
2798
|
+
{ url = "https://files.pythonhosted.org/packages/e0/88/ff5bbbada4098f59740ab767f5c2f82d4110b90ea7db309a9bcf21f3dcb9/prek-0.5.4-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:19d4f3986dcd1dfa15f9417e32a75fb2ba084156dbd7ace161f0764281b8064b", size = 6351516, upload-time = "2026-09-28T04:54:38.539Z" },
|
|
2799
|
+
{ url = "https://files.pythonhosted.org/packages/c8/1a/0cc410232ef42750b3bf34babd6def4a3943ec98259d9fecda3b26fcd63f/prek-0.5.4-py3-none-win_amd64.whl", hash = "sha256:3100233ba802eff6a4b09bc045c8f50d599bb9d810fa234823bc1642938b224f", size = 5755956, upload-time = "2026-09-28T04:54:40.227Z" },
|
|
2800
|
+
{ url = "https://files.pythonhosted.org/packages/98/78/3fd53cc54468db37462885d3139eae40bb469d51beae4575c986b190ab6e/prek-0.5.4-py3-none-win_arm64.whl", hash = "sha256:9fc4ad2d647f70a8a3a9900849e1dd89472cf5a71d830f083b62cf87a0406e27", size = 5502478, upload-time = "2026-09-28T04:54:41.973Z" },
|
|
2801
|
+
]
|
|
2802
|
+
|
|
2781
2803
|
[[package]]
|
|
2782
2804
|
name = "prometheus-client"
|
|
2783
2805
|
version = "0.24.1"
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
branches:
|
|
6
|
-
- main
|
|
7
|
-
pull_request:
|
|
8
|
-
branches:
|
|
9
|
-
- main
|
|
10
|
-
|
|
11
|
-
concurrency:
|
|
12
|
-
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
-
cancel-in-progress: true
|
|
14
|
-
|
|
15
|
-
jobs:
|
|
16
|
-
|
|
17
|
-
pre-commit:
|
|
18
|
-
name: Pre-commit
|
|
19
|
-
runs-on: ubuntu-latest
|
|
20
|
-
steps:
|
|
21
|
-
- uses: actions/checkout@v4
|
|
22
|
-
- name: Set up Python
|
|
23
|
-
uses: actions/setup-python@v5
|
|
24
|
-
with:
|
|
25
|
-
python-version: '3.x'
|
|
26
|
-
- name: Install dependencies
|
|
27
|
-
run: |
|
|
28
|
-
python -m pip install --upgrade pip
|
|
29
|
-
python -m pip install pre-commit
|
|
30
|
-
- name: Run pre-commit
|
|
31
|
-
run: pre-commit run --all-files --show-diff-on-failure --color=always
|
|
32
|
-
|
|
33
|
-
test:
|
|
34
|
-
name: Run tests on Python ${{ matrix.python-version }}
|
|
35
|
-
runs-on: ubuntu-latest
|
|
36
|
-
strategy:
|
|
37
|
-
matrix:
|
|
38
|
-
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
|
39
|
-
|
|
40
|
-
steps:
|
|
41
|
-
- name: Checkout code
|
|
42
|
-
uses: actions/checkout@v4
|
|
43
|
-
with:
|
|
44
|
-
fetch-depth: 0
|
|
45
|
-
|
|
46
|
-
- name: Set up Python ${{ matrix.python-version }}
|
|
47
|
-
uses: actions/setup-python@v5
|
|
48
|
-
with:
|
|
49
|
-
python-version: ${{ matrix.python-version }}
|
|
50
|
-
|
|
51
|
-
- name: Install uv
|
|
52
|
-
uses: astral-sh/setup-uv@v6
|
|
53
|
-
with:
|
|
54
|
-
enable-cache: true
|
|
55
|
-
|
|
56
|
-
- name: Build wheel
|
|
57
|
-
run: uv build --wheel
|
|
58
|
-
|
|
59
|
-
- name: Install project's wheel and dependencies
|
|
60
|
-
run: |
|
|
61
|
-
uv sync --locked --no-install-project
|
|
62
|
-
uv pip install dist/fastquat-*.whl
|
|
63
|
-
|
|
64
|
-
- name: Run tests
|
|
65
|
-
run: |
|
|
66
|
-
source .venv/bin/activate
|
|
67
|
-
pytest -v
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|