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.
@@ -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