transformnd 0.1.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.
- transformnd/__init__.py +24 -0
- transformnd/adapters/__init__.py +34 -0
- transformnd/adapters/base.py +167 -0
- transformnd/adapters/bounding_box.py +16 -0
- transformnd/adapters/pandas.py +46 -0
- transformnd/adapters/polars.py +44 -0
- transformnd/adapters/shapely.py +124 -0
- transformnd/base.py +441 -0
- transformnd/extents/__init__.py +0 -0
- transformnd/extents/base.py +10 -0
- transformnd/extents/bounding_box.py +36 -0
- transformnd/graph.py +192 -0
- transformnd/py.typed +0 -0
- transformnd/transforms/__init__.py +19 -0
- transformnd/transforms/affine.py +512 -0
- transformnd/transforms/bijection.py +68 -0
- transformnd/transforms/by_dimension.py +133 -0
- transformnd/transforms/map_axis.py +67 -0
- transformnd/transforms/moving_least_squares.py +66 -0
- transformnd/transforms/reflection.py +207 -0
- transformnd/transforms/simple.py +172 -0
- transformnd/transforms/thinplate.py +83 -0
- transformnd/util.py +251 -0
- transformnd-0.1.0.dist-info/METADATA +133 -0
- transformnd-0.1.0.dist-info/RECORD +26 -0
- transformnd-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Self
|
|
2
|
+
from array_api_compat import array_namespace
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
from ..base import Transform
|
|
6
|
+
from ..util import ArrayT, SpaceTuple, to_single_ndim
|
|
7
|
+
from ..transforms.affine import Affine
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MapAxis(Transform[ArrayT]):
|
|
11
|
+
"""Map coordinates from one axis to another.
|
|
12
|
+
|
|
13
|
+
For example, x -> y and y -> x"""
|
|
14
|
+
|
|
15
|
+
# ndim: Optional[Set[int]] = set(2)
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
permutation: list[int],
|
|
20
|
+
*,
|
|
21
|
+
spaces: SpaceTuple = (None, None),
|
|
22
|
+
):
|
|
23
|
+
"""Base class for transformations.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
permutation: list[int]
|
|
28
|
+
New order of column axis. For example, [1, 0] means x -> y and y -> x.
|
|
29
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
30
|
+
Optional source and target spaces
|
|
31
|
+
"""
|
|
32
|
+
s_perm = sorted(permutation)
|
|
33
|
+
if any(a != b for a, b in enumerate(s_perm)):
|
|
34
|
+
raise ValueError(
|
|
35
|
+
"N-D permutation must contain all dimensions [0, N) exactly once"
|
|
36
|
+
)
|
|
37
|
+
self.permutation = permutation
|
|
38
|
+
self.ndim = {len(permutation)}
|
|
39
|
+
self.spaces = spaces
|
|
40
|
+
|
|
41
|
+
def is_identity(self) -> bool:
|
|
42
|
+
return all(a == b for a, b in enumerate(self.permutation))
|
|
43
|
+
|
|
44
|
+
def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
|
|
45
|
+
ndim = to_single_ndim(ndim, self.ndim)
|
|
46
|
+
m = np.eye(ndim + 1)
|
|
47
|
+
perm = self.permutation + [ndim]
|
|
48
|
+
m = m[perm, :]
|
|
49
|
+
return Affine(m, spaces=self.spaces)
|
|
50
|
+
|
|
51
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
52
|
+
"""Apply transformation to coordinates.
|
|
53
|
+
|
|
54
|
+
For example:
|
|
55
|
+
2-D with permutation [1, 0] will give you
|
|
56
|
+
[[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]]
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
coords = self._validate_coords(coords)
|
|
60
|
+
xp = array_namespace(coords)
|
|
61
|
+
return xp.take(coords, self.permutation, 1)
|
|
62
|
+
|
|
63
|
+
def invert(self) -> Self | None:
|
|
64
|
+
return type(self)(
|
|
65
|
+
list(np.argsort(self.permutation)),
|
|
66
|
+
spaces=(self.spaces[1], self.spaces[0]),
|
|
67
|
+
)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""@public
|
|
2
|
+
Implementation of Moving Least Squares transformation.
|
|
3
|
+
|
|
4
|
+
Requires the `movingleastsquares` extra.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from array_api_compat import array_namespace
|
|
8
|
+
import numpy as np
|
|
9
|
+
from typing import Self
|
|
10
|
+
from molesq.transform import Transformer as _Transformer
|
|
11
|
+
|
|
12
|
+
from ..base import SpaceTuple, Transform
|
|
13
|
+
from ..util import invert_spaces
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MovingLeastSquares(Transform[np.ndarray]):
|
|
17
|
+
"""Moving least squares transformation.
|
|
18
|
+
|
|
19
|
+
Deform based on a matched pairs of source and target control points; see <https://dl.acm.org/doi/10.1145/1141911.1141920>
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
source_control_points: np.ndarray,
|
|
25
|
+
target_control_points: np.ndarray,
|
|
26
|
+
*,
|
|
27
|
+
spaces: SpaceTuple = (None, None),
|
|
28
|
+
):
|
|
29
|
+
"""Non-rigid transforms powered by molesq package.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
source_control_points : np.ndarray
|
|
34
|
+
NxD array of control point coordinates in the source space.
|
|
35
|
+
target_control_points : np.ndarray
|
|
36
|
+
NxD array of coordinates of the corresponding control points
|
|
37
|
+
in the target (deformed) space.
|
|
38
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
39
|
+
Optional source and target spaces
|
|
40
|
+
"""
|
|
41
|
+
super().__init__(spaces=spaces)
|
|
42
|
+
self._transformer = _Transformer(
|
|
43
|
+
np.asarray(source_control_points),
|
|
44
|
+
np.asarray(target_control_points),
|
|
45
|
+
)
|
|
46
|
+
self.ndim = {self._transformer.control_points.shape[1]}
|
|
47
|
+
|
|
48
|
+
def apply(self, coords: np.ndarray) -> np.ndarray:
|
|
49
|
+
coords = self._validate_coords(coords)
|
|
50
|
+
return self._transformer.transform(coords)
|
|
51
|
+
|
|
52
|
+
def is_identity(self) -> bool:
|
|
53
|
+
xp = array_namespace(self._transformer.control_points)
|
|
54
|
+
return xp.all(
|
|
55
|
+
xp.equal(
|
|
56
|
+
self._transformer.control_points,
|
|
57
|
+
self._transformer.deformed_control_points,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def invert(self) -> Self | None:
|
|
62
|
+
return type(self)(
|
|
63
|
+
self._transformer.deformed_control_points,
|
|
64
|
+
self._transformer.control_points,
|
|
65
|
+
spaces=invert_spaces(self.spaces),
|
|
66
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from copy import copy
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike
|
|
7
|
+
|
|
8
|
+
from ..base import SpaceTuple, Transform
|
|
9
|
+
from ..util import is_square
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def proj(u: np.ndarray, v: np.ndarray) -> np.ndarray:
|
|
13
|
+
return (np.inner(u, v) / np.inner(u, u)) * u
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def gram_schmidt(vecs: np.ndarray) -> np.ndarray:
|
|
17
|
+
"""
|
|
18
|
+
https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process
|
|
19
|
+
"""
|
|
20
|
+
if not is_square(vecs):
|
|
21
|
+
raise ValueError("Wrong number of dimensions")
|
|
22
|
+
|
|
23
|
+
out: list[np.ndarray] = []
|
|
24
|
+
for v in vecs:
|
|
25
|
+
b = v.copy()
|
|
26
|
+
|
|
27
|
+
for u in out:
|
|
28
|
+
b -= proj(u, v)
|
|
29
|
+
|
|
30
|
+
out.append(b)
|
|
31
|
+
return np.array(out)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_hyperplanes(
|
|
35
|
+
points: np.ndarray, unitise: bool = True, seed: int | None = None
|
|
36
|
+
) -> tuple[np.ndarray, list[np.ndarray]]:
|
|
37
|
+
"""
|
|
38
|
+
Reflective: point/line/.../hyperplane to be reflected around
|
|
39
|
+
|
|
40
|
+
Returns point-normal representation.
|
|
41
|
+
"""
|
|
42
|
+
points = np.asarray(points)
|
|
43
|
+
if points.ndim <= 1:
|
|
44
|
+
points = np.expand_dims(points, -1)
|
|
45
|
+
elif points.ndim > 2:
|
|
46
|
+
raise ValueError("Points must be 2D array")
|
|
47
|
+
|
|
48
|
+
n_points, ndim = points.shape
|
|
49
|
+
n_reflections = ndim - n_points + 1
|
|
50
|
+
|
|
51
|
+
if n_reflections <= 0:
|
|
52
|
+
raise ValueError("Too many points given, must be hyperplane or lower-dim")
|
|
53
|
+
|
|
54
|
+
point = points[0]
|
|
55
|
+
|
|
56
|
+
if n_points == 1:
|
|
57
|
+
return point, list(np.eye(len(point)))
|
|
58
|
+
|
|
59
|
+
# non-orthogonal vectors spanning provided reflective
|
|
60
|
+
# transpose into row vectors for easier vectorisation
|
|
61
|
+
reflective_vecs = np.diff(points, axis=0)
|
|
62
|
+
rng = np.random.default_rng(seed)
|
|
63
|
+
randoms = rng.random((n_reflections, ndim))
|
|
64
|
+
non_orth = np.concatenate((reflective_vecs, randoms), 0)
|
|
65
|
+
|
|
66
|
+
basis = gram_schmidt(non_orth)
|
|
67
|
+
extras = basis[-n_reflections:]
|
|
68
|
+
if unitise:
|
|
69
|
+
extras /= np.linalg.norm(extras, axis=1)
|
|
70
|
+
return point, list(extras)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def unitise(v: np.ndarray) -> np.ndarray:
|
|
74
|
+
return v / np.linalg.norm(v)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def ensure_tuple(obj: int | Sequence[int]) -> tuple[int, ...]:
|
|
78
|
+
if isinstance(obj, int):
|
|
79
|
+
return (obj,)
|
|
80
|
+
return tuple(obj)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Reflect(Transform[np.ndarray]):
|
|
84
|
+
"""Reflect coordinates about arbitrary planes."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
normals: ArrayLike,
|
|
89
|
+
point: float | ArrayLike = 0.0,
|
|
90
|
+
*,
|
|
91
|
+
spaces: SpaceTuple = (None, None),
|
|
92
|
+
):
|
|
93
|
+
"""
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
normals : sequence of arrays
|
|
97
|
+
Normal vectors to the planes of reflection.
|
|
98
|
+
Unitised internally.
|
|
99
|
+
point : float or array-like, optional
|
|
100
|
+
Intersection point of all reflection planes
|
|
101
|
+
(can be broadcast from scalar), by default 0 (i.e. the origin)
|
|
102
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
103
|
+
Optional source and target spaces
|
|
104
|
+
|
|
105
|
+
Raises
|
|
106
|
+
------
|
|
107
|
+
ValueError
|
|
108
|
+
Inconsistent dimensionality
|
|
109
|
+
"""
|
|
110
|
+
super().__init__(spaces=spaces)
|
|
111
|
+
normals = np.asarray(normals)
|
|
112
|
+
if normals.ndim == 1:
|
|
113
|
+
normals = [normals]
|
|
114
|
+
|
|
115
|
+
n1 = normals[0]
|
|
116
|
+
if (
|
|
117
|
+
not np.isscalar(point)
|
|
118
|
+
and isinstance(point, Sequence)
|
|
119
|
+
and len(n1) != len(point)
|
|
120
|
+
):
|
|
121
|
+
raise ValueError("Point and normals are not of the same dimensionality")
|
|
122
|
+
self.point: np.ndarray = np.asarray(point, dtype=float)
|
|
123
|
+
self.ndim = {len(n1)}
|
|
124
|
+
self.normals = [unitise(n) for n in normals]
|
|
125
|
+
# todo: matmul is associative, so turn this into an affine in 2/3D?
|
|
126
|
+
|
|
127
|
+
def apply(self, coords: np.ndarray) -> np.ndarray:
|
|
128
|
+
coords = self._validate_coords(coords)
|
|
129
|
+
out = coords - self.point
|
|
130
|
+
for n in self.normals:
|
|
131
|
+
# mul->sum vectorises dot product
|
|
132
|
+
# normals are unit, avoids unnecessary division by 1
|
|
133
|
+
out -= 2 * np.sum(coords * n, axis=1) * n
|
|
134
|
+
out += self.point
|
|
135
|
+
return out
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_points(
|
|
139
|
+
cls,
|
|
140
|
+
points: ArrayLike,
|
|
141
|
+
*,
|
|
142
|
+
spaces: SpaceTuple = (None, None),
|
|
143
|
+
):
|
|
144
|
+
"""Infer a single plane of reflection from a minimal number of points on it.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
points : array-like
|
|
149
|
+
NxD array of N points in D dimensions. N == D
|
|
150
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
151
|
+
Optional source and target spaces
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
Reflection
|
|
156
|
+
"""
|
|
157
|
+
point, normals = get_hyperplanes(np.asarray(points), unitise=False)
|
|
158
|
+
return cls(normals, point, spaces=spaces)
|
|
159
|
+
|
|
160
|
+
@classmethod
|
|
161
|
+
def from_axis(
|
|
162
|
+
cls,
|
|
163
|
+
axis: int | Sequence[int],
|
|
164
|
+
origin: ArrayLike,
|
|
165
|
+
*,
|
|
166
|
+
spaces: SpaceTuple = (None, None),
|
|
167
|
+
):
|
|
168
|
+
"""Reflect around hyperplane(s) parallel with axes.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
axis : int or sequence of int
|
|
173
|
+
Index (or indices) of axes in which to reflect.
|
|
174
|
+
origin : array-like
|
|
175
|
+
Point around which to reflect.
|
|
176
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
177
|
+
Optional source and target spaces
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
Reflection
|
|
182
|
+
|
|
183
|
+
Raises
|
|
184
|
+
------
|
|
185
|
+
ValueError
|
|
186
|
+
Selected axis does not exist.
|
|
187
|
+
"""
|
|
188
|
+
origin = np.asarray(origin)
|
|
189
|
+
axis = ensure_tuple(axis)
|
|
190
|
+
|
|
191
|
+
for a in axis:
|
|
192
|
+
if a >= len(axis):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"Cannot reflect in axis which does not exist (too high)"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
normals = []
|
|
198
|
+
for i in range(len(origin) - len(axis) + 1):
|
|
199
|
+
if i not in axis:
|
|
200
|
+
v = np.zeros_like(origin)
|
|
201
|
+
v[i] += 1
|
|
202
|
+
normals.append(v)
|
|
203
|
+
|
|
204
|
+
return cls(normals, origin, spaces=spaces)
|
|
205
|
+
|
|
206
|
+
def invert(self) -> Self | None:
|
|
207
|
+
return copy(self)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Simple transformations like rigid translation and scaling.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from copy import copy
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
from numpy.typing import ArrayLike
|
|
10
|
+
|
|
11
|
+
from array_api_compat import array_namespace
|
|
12
|
+
from array_api_compat import device as xp_device
|
|
13
|
+
from ..base import Transform
|
|
14
|
+
from ..util import ArrayT, chain_or, SpaceTuple, invert_spaces, to_single_ndim
|
|
15
|
+
from ..transforms.affine import Affine
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Identity(Transform[ArrayT]):
|
|
19
|
+
"""No-op transformation."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
spaces: SpaceTuple = (None, None),
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Transform which does nothing.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
32
|
+
Optional source and target spaces
|
|
33
|
+
|
|
34
|
+
Raises
|
|
35
|
+
------
|
|
36
|
+
ValueError
|
|
37
|
+
[description]
|
|
38
|
+
"""
|
|
39
|
+
src = chain_or(*spaces, default=None)
|
|
40
|
+
tgt = chain_or(*spaces[::-1], default=None)
|
|
41
|
+
if src != tgt:
|
|
42
|
+
raise ValueError("Source and target spaces are different")
|
|
43
|
+
super().__init__(spaces=(src, src))
|
|
44
|
+
|
|
45
|
+
def __invert__(self) -> Transform[ArrayT]:
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
|
|
49
|
+
ndim = to_single_ndim(ndim, self.ndim)
|
|
50
|
+
m = np.eye(ndim + 1)
|
|
51
|
+
return Affine(m, spaces=self.spaces)
|
|
52
|
+
|
|
53
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
54
|
+
xp = array_namespace(coords)
|
|
55
|
+
return xp.asarray(coords)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Translate(Transform[ArrayT]):
|
|
59
|
+
"""Translate coordinates by addition."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
translation: ArrayLike,
|
|
64
|
+
*,
|
|
65
|
+
spaces: SpaceTuple = (None, None),
|
|
66
|
+
):
|
|
67
|
+
"""Simple translation.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
translation : scalar or D-length array
|
|
72
|
+
Translation to apply in all dimensions, or each dimension.
|
|
73
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
74
|
+
Optional source and target spaces
|
|
75
|
+
|
|
76
|
+
Raises
|
|
77
|
+
------
|
|
78
|
+
ValueError
|
|
79
|
+
If the translation is the wrong shape
|
|
80
|
+
"""
|
|
81
|
+
super().__init__(spaces=spaces)
|
|
82
|
+
self.translation = np.asarray(translation)
|
|
83
|
+
if self.translation.ndim > 1:
|
|
84
|
+
raise ValueError("Translation must be scalar or 1D")
|
|
85
|
+
|
|
86
|
+
if self.translation.shape not in [(), (1,)]:
|
|
87
|
+
self.ndim = {self.translation.shape[0]}
|
|
88
|
+
# otherwise, can be broadcast to anything
|
|
89
|
+
|
|
90
|
+
def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
|
|
91
|
+
ndim = to_single_ndim(ndim, self.ndim)
|
|
92
|
+
|
|
93
|
+
m = np.eye(ndim + 1)
|
|
94
|
+
m[:-1, -1] = self.translation
|
|
95
|
+
return Affine(m, spaces=self.spaces)
|
|
96
|
+
|
|
97
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
98
|
+
coords = self._validate_coords(coords)
|
|
99
|
+
xp = array_namespace(coords)
|
|
100
|
+
d = xp_device(coords)
|
|
101
|
+
return coords + xp.asarray(self.translation, device=d)
|
|
102
|
+
|
|
103
|
+
def __invert__(self) -> Transform:
|
|
104
|
+
return type(self)(-self.translation, spaces=(self.spaces[1], self.spaces[0]))
|
|
105
|
+
|
|
106
|
+
def to_device(self, xp, device=None) -> Self:
|
|
107
|
+
result = copy(self)
|
|
108
|
+
result.translation = xp.asarray(self.translation, device=device)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Scale(Transform[ArrayT]):
|
|
113
|
+
"""Scale coordinates by multiplication."""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
scale: ArrayLike,
|
|
118
|
+
*,
|
|
119
|
+
spaces: SpaceTuple = (None, None),
|
|
120
|
+
):
|
|
121
|
+
"""Simple scale transform.
|
|
122
|
+
|
|
123
|
+
All points are scaled, i.e. distance from the origin may also change.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
scale : scalar or D-length array-like
|
|
128
|
+
Scaling to apply in all dimensions, or each dimension.
|
|
129
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
130
|
+
Optional source and target spaces
|
|
131
|
+
|
|
132
|
+
Raises
|
|
133
|
+
------
|
|
134
|
+
ValueError
|
|
135
|
+
If scale is the wrong shape.
|
|
136
|
+
"""
|
|
137
|
+
super().__init__(spaces=spaces)
|
|
138
|
+
self.scale = np.asarray(scale)
|
|
139
|
+
if self.scale.ndim > 1:
|
|
140
|
+
raise ValueError("Scale must be scalar or 1D")
|
|
141
|
+
|
|
142
|
+
if self.scale.shape not in [(), (1,)]:
|
|
143
|
+
self.ndim = {self.scale.shape[0]}
|
|
144
|
+
# otherwise, can be broadcast to anything
|
|
145
|
+
|
|
146
|
+
def to_affine(self, ndim: int | None = None) -> Affine[ArrayT] | None:
|
|
147
|
+
ndim = to_single_ndim(ndim, self.ndim)
|
|
148
|
+
|
|
149
|
+
if np.ndim(self.scale) == 0:
|
|
150
|
+
scale_vec = np.full(ndim, self.scale)
|
|
151
|
+
else:
|
|
152
|
+
scale_vec = self.scale
|
|
153
|
+
m = np.eye(ndim + 1)
|
|
154
|
+
m[:-1, :-1] = np.diag(scale_vec)
|
|
155
|
+
return Affine(m, spaces=self.spaces)
|
|
156
|
+
|
|
157
|
+
def apply(self, coords: ArrayT) -> ArrayT:
|
|
158
|
+
coords = self._validate_coords(coords)
|
|
159
|
+
xp = array_namespace(coords)
|
|
160
|
+
d = xp_device(coords)
|
|
161
|
+
return coords * xp.asarray(self.scale, device=d)
|
|
162
|
+
|
|
163
|
+
def invert(self) -> Self | None:
|
|
164
|
+
return type(self)(
|
|
165
|
+
1 / self.scale,
|
|
166
|
+
spaces=invert_spaces(self.spaces),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def to_device(self, xp, device=None) -> Self:
|
|
170
|
+
result = copy(self)
|
|
171
|
+
result.scale = xp.asarray(self.scale, device=device)
|
|
172
|
+
return result
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""@public
|
|
2
|
+
Thin plate splines transformations.
|
|
3
|
+
|
|
4
|
+
Requires the `thinplatesplines` extra.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
import morphops as mops
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ..base import SpaceTuple, Transform
|
|
13
|
+
from ..util import check_ndim, invert_spaces
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ThinPlateSplines(Transform[np.ndarray]):
|
|
19
|
+
"""Thin plate splines transforms.
|
|
20
|
+
|
|
21
|
+
Deform based on matched pairs of control points.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
ndim = {2, 3}
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
source_control_points: np.ndarray,
|
|
29
|
+
target_control_points: np.ndarray,
|
|
30
|
+
*,
|
|
31
|
+
spaces: SpaceTuple = (None, None),
|
|
32
|
+
):
|
|
33
|
+
"""Non-rigid control point based transforms in 2/3D.
|
|
34
|
+
|
|
35
|
+
Adapted from
|
|
36
|
+
https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
source_control_points : np.ndarray
|
|
41
|
+
NxD array of control point coordinates in the source space.
|
|
42
|
+
target_control_points : np.ndarray
|
|
43
|
+
NxD array of control point coordinates in the target (deformed) space.
|
|
44
|
+
spaces : tuple[SpaceRef, SpaceRef]
|
|
45
|
+
Optional source and target spaces
|
|
46
|
+
|
|
47
|
+
Raises
|
|
48
|
+
------
|
|
49
|
+
ValueError
|
|
50
|
+
Invalid control points.
|
|
51
|
+
"""
|
|
52
|
+
super().__init__(spaces=spaces)
|
|
53
|
+
self.source_control_points = np.asarray(source_control_points)
|
|
54
|
+
self.target_control_points = np.asarray(target_control_points)
|
|
55
|
+
|
|
56
|
+
if self.source_control_points.shape != self.target_control_points.shape:
|
|
57
|
+
raise ValueError("Control point arrays must be the same shape")
|
|
58
|
+
|
|
59
|
+
if self.source_control_points.ndim != 2:
|
|
60
|
+
raise ValueError("Control points array must be 2D")
|
|
61
|
+
|
|
62
|
+
ndim = self.source_control_points.shape[1]
|
|
63
|
+
check_ndim(ndim, self.ndim)
|
|
64
|
+
self.ndim = {ndim}
|
|
65
|
+
|
|
66
|
+
self.W, self.A = mops.tps_coefs(
|
|
67
|
+
self.source_control_points,
|
|
68
|
+
self.target_control_points,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def invert(self) -> Transform[np.ndarray] | None:
|
|
72
|
+
return type(self)(
|
|
73
|
+
self.target_control_points,
|
|
74
|
+
self.source_control_points,
|
|
75
|
+
spaces=invert_spaces(self.spaces),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def apply(self, coords: np.ndarray) -> np.ndarray:
|
|
79
|
+
coords = self._validate_coords(coords)
|
|
80
|
+
U = mops.K_matrix(coords, self.source_control_points)
|
|
81
|
+
P = mops.P_matrix(coords)
|
|
82
|
+
# The warped pts are the affine part + the non-uniform part
|
|
83
|
+
return P @ self.A + U @ self.W
|