transformnd.transforms
Implementations of some common transforms.
1"""Implementations of some common transforms.""" 2 3from .affine import Affine 4from .reflection import Reflect 5from .simple import Identity, Scale, Translate 6from .map_axis import MapAxis 7from .bijection import Bijection 8from .project_axis import ProjectAxis 9from .by_dimension import ByDimension, SubTransform 10from .vector_field import Coordinates, Displacements 11from .moving_least_squares import MovingLeastSquares 12from .thinplate import ThinPlateSplines 13 14__all__ = [ 15 "Affine", 16 "Identity", 17 "ProjectAxis", 18 "Reflect", 19 "Scale", 20 "Translate", 21 "MapAxis", 22 "Bijection", 23 "ByDimension", 24 "SubTransform", 25 "Coordinates", 26 "Displacements", 27 "MovingLeastSquares", 28 "ThinPlateSplines", 29]
23class Affine(Transform[ArrayT]): 24 """Affine transformation using an augmented matrix. 25 26 The transformation matrix is stored as a NumPy array (backend-neutral). 27 At apply()-time it is converted to the input coords' backend and device, 28 so the transform works transparently with NumPy, JAX, PyTorch, CuPy, etc. 29 30 Affines can be composed by matrix multiplication: `affine2 @ affine1`. 31 Note that the right hand transformation is effectively applied to the coordinates first, 32 so `(aff2 @ aff1).apply(coords) == (aff1 | aff2).apply(coords)`. 33 """ 34 35 def __init__( 36 self, 37 matrix: ArrayLike, 38 *, 39 spaces: Spaces = Spaces(None, None), 40 ): 41 """ 42 Parameters 43 ---------- 44 matrix 45 Affine transformation matrix, 46 i.e. a 2D array-like with shape `(Do + 1, Di + 1)`, 47 where the bottom row is all 0s except in the rightmost column, which is 1. 48 spaces 49 Optional source and target spaces 50 51 Raises 52 ------ 53 ValueError 54 Malformed matrix. 55 """ 56 m = as_floats(matrix) 57 if m.ndim != 2: 58 raise ValueError("Affine matrix must be 2D") 59 60 bottom_row = m[-1, :] 61 expected = np.zeros_like(bottom_row) 62 expected[-1] = 1 63 if not np.allclose(bottom_row, expected): 64 raise ValueError( 65 f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})." 66 ) 67 68 super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces) 69 70 self.matrix: np.ndarray = m 71 72 self._linear_map: np.ndarray | None = m[:-1, :-1] 73 if is_square(self._linear_map) and np.allclose( 74 self._linear_map, np.eye(self._linear_map.shape[0], dtype=self.matrix.dtype) 75 ): 76 self._linear_map = None 77 78 self._translation: np.ndarray | None = self.matrix[:-1, -1] 79 if np.allclose(np.zeros_like(self._translation), self._translation): 80 self._translation = None 81 82 def to_affine(self) -> Self | None: 83 return self 84 85 def cast_matrix(self, namespace, device) -> ArrayT: 86 return namespace.asarray(self.matrix, device=device) 87 88 def apply(self, coords: ArrayT) -> ArrayT: 89 coords = self._validate_coords(coords) 90 xp = array_namespace(coords) 91 d = xp_device(coords) 92 93 out = coords 94 95 if self._linear_map is not None: 96 lm = xp.asarray(self._linear_map, device=d) 97 out = coords @ xp.matrix_transpose(lm) 98 99 if self._translation is not None: 100 t = xp.asarray(self._translation, device=d) 101 if self._linear_map is None: 102 out = coords + t 103 else: 104 out += t 105 106 ## Padding and then unpadding the coords is slower, especially in C order 107 # coords = xp.concatenate( 108 # [coords, xp.ones((coords.shape[0], 1), dtype=coords.dtype)], # type: ignore[attr-defined] 109 # axis=1, 110 # ) 111 # out: ArrayT = (coords @ m.T)[:, :-1] # type: ignore[attr-defined] 112 113 return out 114 115 def invert(self) -> Self | None: 116 try: 117 inv = np.linalg.inv(self.matrix) 118 except np.linalg.LinAlgError: 119 return None 120 121 return type(self)( 122 inv, 123 spaces=self.spaces.invert(), 124 ) 125 126 def __matmul__(self, rhs: Affine[ArrayT]) -> Affine[ArrayT]: 127 """Compose two affine transforms by matrix multiplication. 128 129 As with affine matrices the right hand operand is effectively applied first. 130 131 Parameters 132 ---------- 133 rhs 134 The right-hand affine transform. 135 136 Returns 137 ------- 138 Affine[ArrayT] 139 The composed affine transform. 140 141 Raises 142 ------ 143 ValueError 144 Incompatible transforms. 145 """ 146 if not isinstance(rhs, Affine): 147 return NotImplemented 148 if self.ndims.source != rhs.ndims.target: 149 raise ValueError( 150 "Cannot compose affine transformations of different dimensionality" 151 ) 152 153 # this ordering looks wrong but this is the way affine transforms get combined; 154 # the sequence transform A followed by transform B is expressed B @ A 155 if not none_eq(self.spaces.source, rhs.spaces.target): 156 raise ValueError("Affine transforms do not share a space") 157 return Affine( 158 self.matrix @ rhs.matrix, 159 spaces=Spaces(rhs.spaces.source, self.spaces.target), 160 ) 161 162 def to_device(self, xp: ModuleType, device: str | None = None) -> "Affine[ArrayT]": 163 """Return a copy with the matrix placed on the given device/backend. 164 165 Use this before a tight apply() loop to avoid per-call host-to-device 166 transfers when coords live on GPU. 167 168 Parameters 169 ---------- 170 xp 171 Target array namespace (e.g. jax.numpy, torch). 172 device 173 Target device (e.g. from array_api_compat.device(array)). 174 175 Returns 176 ------- 177 Affine[ArrayT] 178 New instance with matrix on the target device. 179 """ 180 result = copy(self) 181 result.matrix = xp.asarray(self.matrix, device=device) 182 return result 183 184 @classmethod 185 def from_linear_map( 186 cls, 187 linear_map: ArrayLike, 188 translation: ArrayLike | None = None, 189 *, 190 spaces: Spaces = Spaces(None, None), 191 ) -> Affine[ArrayT]: 192 """Create an augmented affine matrix from a linear map, 193 with an optional translation. 194 195 Parameters 196 ---------- 197 linear_map 198 Shape `(Di, Do)` 199 translation 200 Translation to add to the matrix, by default 0 201 spaces 202 Optional source and target spaces 203 204 Returns 205 ------- 206 Affine[ArrayT] 207 The affine transform. 208 209 Raises 210 ------ 211 ValueError 212 If shapes are inconsistent. 213 """ 214 lin_map = as_floats(linear_map) 215 if lin_map.ndim != 2: 216 raise ValueError(f"Linear map must be 2D; got shape {lin_map.shape}") 217 matrix = np.zeros_like( 218 lin_map, shape=(lin_map.shape[0] + 1, lin_map.shape[1] + 1) 219 ) 220 matrix[:-1, :-1] = lin_map 221 matrix[-1, -1] = 1 222 if translation is not None: 223 t = as_floats(translation) 224 if len(t) != lin_map.shape[0]: 225 raise ValueError( 226 "Translation array must be the same length as linear map columns" 227 ) 228 matrix[:-1, -1] = translation 229 return cls(matrix, spaces=spaces) 230 231 @classmethod 232 def identity( 233 cls, 234 ndim: int, 235 *, 236 spaces: Spaces = Spaces(None, None), 237 ) -> Affine[ArrayT]: 238 """Create an identity affine transformation. 239 240 Parameters 241 ---------- 242 ndim 243 The dimensionality of the transform. 244 spaces 245 Optional source and target spaces 246 247 Returns 248 ------- 249 Affine[ArrayT] 250 The identity affine transform. 251 """ 252 return cls(np.eye(ndim + 1), spaces=spaces) 253 254 @classmethod 255 def translation( 256 cls, 257 translation: ArrayLike, 258 *, 259 spaces: Spaces = Spaces(None, None), 260 ) -> Affine[ArrayT]: 261 """Create an affine translation. 262 263 Parameters 264 ---------- 265 translation 266 D-length array of translation values. 267 spaces 268 Optional source and target spaces 269 270 Returns 271 ------- 272 Affine[ArrayT] 273 The translation affine transform. 274 275 Raises 276 ------ 277 ValueError 278 If translation array is not 1D. 279 """ 280 t = as_floats(translation) 281 if t.ndim != 1: 282 raise ValueError(f"Translation array must be 1D; got shape {t.shape}") 283 m = np.eye(len(t) + 1, dtype=t.dtype) 284 m[:-1, -1] = t 285 return cls(m, spaces=spaces) 286 287 @classmethod 288 def scaling( 289 cls, 290 scale: ArrayLike, 291 *, 292 spaces: Spaces = Spaces(None, None), 293 ) -> Affine[ArrayT]: 294 """Create an affine scaling. 295 296 Parameters 297 ---------- 298 scale 299 D-length array of scaling factors. 300 spaces 301 Optional source and target spaces 302 303 Returns 304 ------- 305 Affine[ArrayT] 306 The scaling affine transform. 307 308 Raises 309 ------ 310 ValueError 311 If scale array is not 1D. 312 """ 313 s = as_floats(scale) 314 if s.ndim != 1: 315 raise ValueError(f"Scale array must be 1D; got shape {s.shape}") 316 return cls.from_linear_map(np.diag(s), spaces=spaces) 317 318 @classmethod 319 def reflection( 320 cls, 321 axis: Union[int, Container[int]], 322 ndim: int, 323 *, 324 spaces: Spaces = Spaces(None, None), 325 ) -> Affine[ArrayT]: 326 """Create an affine reflection. 327 328 Parameters 329 ---------- 330 axis 331 A single axis or multiple to reflect in. 332 ndim 333 How many dimensions to work in. 334 spaces 335 Optional source and target spaces 336 337 Returns 338 ------- 339 Affine[ArrayT] 340 The reflection affine transform. 341 """ 342 if isinstance(axis, (int, np.integer)): 343 axis = [axis] 344 values = np.asarray([-1 if idx in axis else 1 for idx in range(ndim)]) 345 return cls.from_linear_map(np.diag(values.astype(float)), spaces=spaces) 346 347 @classmethod 348 def rotation2( 349 cls, 350 rotation: float, 351 degrees: bool = True, 352 clockwise: bool = False, 353 *, 354 spaces: Spaces = Spaces(None, None), 355 ) -> Affine[ArrayT]: 356 """Create a 2D affine rotation. 357 358 Parameters 359 ---------- 360 rotation 361 Angle to rotate. 362 degrees 363 Whether rotation is in degrees (rather than radians), by default True 364 clockwise 365 Whether rotation is clockwise, by default False 366 spaces 367 Optional source and target spaces 368 369 Returns 370 ------- 371 Affine[ArrayT] 372 The rotation affine transform. 373 """ 374 if degrees: 375 rotation = math.radians(rotation) 376 if clockwise: 377 rotation *= -1 378 c, s = math.cos(rotation), math.sin(rotation) 379 return cls.from_linear_map(np.array([[c, -s], [s, c]]), spaces=spaces) 380 381 @classmethod 382 def rotation3( 383 cls, 384 rotation: Union[float, tuple[float, float, float]], 385 degrees: bool = True, 386 clockwise: bool = False, 387 order: tuple[int, int, int] = (0, 1, 2), 388 *, 389 spaces: Spaces = Spaces(None, None), 390 ) -> Affine[ArrayT]: 391 """Create a 3D affine rotation. 392 393 Parameters 394 ---------- 395 rotation 396 Either a single rotation for all axes, or 1 for each. 397 degrees 398 Whether rotation is in degrees (rather than radians), by default True 399 clockwise 400 Whether rotation is clockwise, by default False 401 order 402 What order to apply the rotations, by default (0, 1, 2) 403 spaces 404 Optional source and target spaces 405 406 Returns 407 ------- 408 Affine[ArrayT] 409 The rotation affine transform. 410 411 Raises 412 ------ 413 ValueError 414 Incompatible order. 415 """ 416 if isinstance(rotation, (int, float)): 417 r = [rotation] * 3 418 else: 419 r = list(rotation) 420 421 if degrees: 422 r = [math.radians(x) for x in r] 423 if clockwise: 424 r = [-x for x in r] 425 426 if len(order) != 3 or set(order) != {0, 1, 2}: 427 raise ValueError("Order must contain only 0, 1, 2 in any order.") 428 429 c0, s0 = math.cos(r[0]), math.sin(r[0]) 430 c1, s1 = math.cos(r[1]), math.sin(r[1]) 431 c2, s2 = math.cos(r[2]), math.sin(r[2]) 432 433 rots = [ 434 np.array([[1, 0, 0], [0, c0, -s0], [0, s0, c0]]), 435 np.array([[c1, 0, s1], [0, 1, 0], [-s1, 0, c1]]), 436 np.array([[c2, -s2, 0], [s2, c2, 0], [0, 0, 1]]), 437 ] 438 rot = rots[order[0]] @ rots[order[1]] @ rots[order[2]] 439 return cls.from_linear_map(rot, spaces=spaces) 440 441 @classmethod 442 def shearing( 443 cls, 444 factor: Union[float, np.ndarray], 445 ndim: int | None = None, 446 *, 447 spaces: Spaces = Spaces(None, None), 448 ) -> Affine[ArrayT]: 449 """Create an affine shear. 450 451 `factor` can be a scalar to broadcast to all dimensions, 452 or a D-length list of D-1 lists. 453 The first inner list contains the shear factors in the first dimension 454 for all *but* the first dimension. 455 The second inner list contains the shear factors in the second dimension 456 for all the *but* the second dimension, etc. 457 458 Parameters 459 ---------- 460 factor 461 Shear scale factors; see above for more details. 462 ndim 463 If factor is scalar, broadcast to this many dimensions, by default None 464 spaces 465 Optional source and target spaces 466 467 Returns 468 ------- 469 Affine[ArrayT] 470 The shearing affine transform. 471 472 Raises 473 ------ 474 ValueError 475 Incompatible factor. 476 """ 477 if isinstance(factor, (int, float, complex)): 478 if ndim is None: 479 raise ValueError("If factor is scalar, ndim must be defined") 480 s = np.full((ndim, ndim - 1), factor) 481 else: 482 s = np.asarray(factor) 483 if s.ndim != 2 or s.shape[0] != s.shape[1] + 1: 484 raise ValueError("Factor must be of shape (D, D-1)") 485 ndim = s.shape[0] 486 487 assert ndim is not None 488 489 m = np.eye(ndim, dtype=s.dtype) 490 for col_idx in range(m.shape[1]): 491 it = iter(s[col_idx]) 492 for row_idx in range(m.shape[0] - 1): 493 if m[row_idx, col_idx] == 0: 494 m[row_idx, col_idx] = next(it) 495 return cls.from_linear_map(m, spaces=spaces) 496 497 def __eq__(self, other: object) -> bool: 498 if not isinstance(other, Affine): 499 return NotImplemented 500 return np.array_equal(self.matrix, other.matrix) and self.spaces == other.spaces 501 502 def is_identity(self) -> bool: 503 xp = array_namespace(self.matrix) 504 sh = xp.shape(self.matrix) 505 if sh[0] != sh[1]: 506 return False 507 identity = xp.eye(sh[0], dtype=self.matrix.dtype, device=self.matrix.device) 508 return xp.all(xp.equal(self.matrix, identity))
Affine transformation using an augmented matrix.
The transformation matrix is stored as a NumPy array (backend-neutral). At apply()-time it is converted to the input coords' backend and device, so the transform works transparently with NumPy, JAX, PyTorch, CuPy, etc.
Affines can be composed by matrix multiplication: affine2 @ affine1.
Note that the right hand transformation is effectively applied to the coordinates first,
so (aff2 @ aff1).apply(coords) == (aff1 | aff2).apply(coords).
35 def __init__( 36 self, 37 matrix: ArrayLike, 38 *, 39 spaces: Spaces = Spaces(None, None), 40 ): 41 """ 42 Parameters 43 ---------- 44 matrix 45 Affine transformation matrix, 46 i.e. a 2D array-like with shape `(Do + 1, Di + 1)`, 47 where the bottom row is all 0s except in the rightmost column, which is 1. 48 spaces 49 Optional source and target spaces 50 51 Raises 52 ------ 53 ValueError 54 Malformed matrix. 55 """ 56 m = as_floats(matrix) 57 if m.ndim != 2: 58 raise ValueError("Affine matrix must be 2D") 59 60 bottom_row = m[-1, :] 61 expected = np.zeros_like(bottom_row) 62 expected[-1] = 1 63 if not np.allclose(bottom_row, expected): 64 raise ValueError( 65 f"Transformation matrix is not affine (expected bottom row {expected}, got {bottom_row})." 66 ) 67 68 super().__init__(NDims(m.shape[1] - 1, m.shape[0] - 1), spaces=spaces) 69 70 self.matrix: np.ndarray = m 71 72 self._linear_map: np.ndarray | None = m[:-1, :-1] 73 if is_square(self._linear_map) and np.allclose( 74 self._linear_map, np.eye(self._linear_map.shape[0], dtype=self.matrix.dtype) 75 ): 76 self._linear_map = None 77 78 self._translation: np.ndarray | None = self.matrix[:-1, -1] 79 if np.allclose(np.zeros_like(self._translation), self._translation): 80 self._translation = None
Parameters
- matrix: Affine transformation matrix,
i.e. a 2D array-like with shape
(Do + 1, Di + 1), where the bottom row is all 0s except in the rightmost column, which is 1. - spaces: Optional source and target spaces
Raises
- ValueError: Malformed matrix.
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
88 def apply(self, coords: ArrayT) -> ArrayT: 89 coords = self._validate_coords(coords) 90 xp = array_namespace(coords) 91 d = xp_device(coords) 92 93 out = coords 94 95 if self._linear_map is not None: 96 lm = xp.asarray(self._linear_map, device=d) 97 out = coords @ xp.matrix_transpose(lm) 98 99 if self._translation is not None: 100 t = xp.asarray(self._translation, device=d) 101 if self._linear_map is None: 102 out = coords + t 103 else: 104 out += t 105 106 ## Padding and then unpadding the coords is slower, especially in C order 107 # coords = xp.concatenate( 108 # [coords, xp.ones((coords.shape[0], 1), dtype=coords.dtype)], # type: ignore[attr-defined] 109 # axis=1, 110 # ) 111 # out: ArrayT = (coords @ m.T)[:, :-1] # type: ignore[attr-defined] 112 113 return out
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
115 def invert(self) -> Self | None: 116 try: 117 inv = np.linalg.inv(self.matrix) 118 except np.linalg.LinAlgError: 119 return None 120 121 return type(self)( 122 inv, 123 spaces=self.spaces.invert(), 124 )
Invert the transformation, returning None if not possible.
162 def to_device(self, xp: ModuleType, device: str | None = None) -> "Affine[ArrayT]": 163 """Return a copy with the matrix placed on the given device/backend. 164 165 Use this before a tight apply() loop to avoid per-call host-to-device 166 transfers when coords live on GPU. 167 168 Parameters 169 ---------- 170 xp 171 Target array namespace (e.g. jax.numpy, torch). 172 device 173 Target device (e.g. from array_api_compat.device(array)). 174 175 Returns 176 ------- 177 Affine[ArrayT] 178 New instance with matrix on the target device. 179 """ 180 result = copy(self) 181 result.matrix = xp.asarray(self.matrix, device=device) 182 return result
Return a copy with the matrix placed on the given device/backend.
Use this before a tight apply() loop to avoid per-call host-to-device transfers when coords live on GPU.
Parameters
- xp: Target array namespace (e.g. jax.numpy, torch).
- device: Target device (e.g. from array_api_compat.device(array)).
Returns
- Affine[ArrayT]: New instance with matrix on the target device.
184 @classmethod 185 def from_linear_map( 186 cls, 187 linear_map: ArrayLike, 188 translation: ArrayLike | None = None, 189 *, 190 spaces: Spaces = Spaces(None, None), 191 ) -> Affine[ArrayT]: 192 """Create an augmented affine matrix from a linear map, 193 with an optional translation. 194 195 Parameters 196 ---------- 197 linear_map 198 Shape `(Di, Do)` 199 translation 200 Translation to add to the matrix, by default 0 201 spaces 202 Optional source and target spaces 203 204 Returns 205 ------- 206 Affine[ArrayT] 207 The affine transform. 208 209 Raises 210 ------ 211 ValueError 212 If shapes are inconsistent. 213 """ 214 lin_map = as_floats(linear_map) 215 if lin_map.ndim != 2: 216 raise ValueError(f"Linear map must be 2D; got shape {lin_map.shape}") 217 matrix = np.zeros_like( 218 lin_map, shape=(lin_map.shape[0] + 1, lin_map.shape[1] + 1) 219 ) 220 matrix[:-1, :-1] = lin_map 221 matrix[-1, -1] = 1 222 if translation is not None: 223 t = as_floats(translation) 224 if len(t) != lin_map.shape[0]: 225 raise ValueError( 226 "Translation array must be the same length as linear map columns" 227 ) 228 matrix[:-1, -1] = translation 229 return cls(matrix, spaces=spaces)
Create an augmented affine matrix from a linear map, with an optional translation.
Parameters
- linear_map: Shape
(Di, Do) - translation: Translation to add to the matrix, by default 0
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The affine transform.
Raises
- ValueError: If shapes are inconsistent.
231 @classmethod 232 def identity( 233 cls, 234 ndim: int, 235 *, 236 spaces: Spaces = Spaces(None, None), 237 ) -> Affine[ArrayT]: 238 """Create an identity affine transformation. 239 240 Parameters 241 ---------- 242 ndim 243 The dimensionality of the transform. 244 spaces 245 Optional source and target spaces 246 247 Returns 248 ------- 249 Affine[ArrayT] 250 The identity affine transform. 251 """ 252 return cls(np.eye(ndim + 1), spaces=spaces)
Create an identity affine transformation.
Parameters
- ndim: The dimensionality of the transform.
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The identity affine transform.
254 @classmethod 255 def translation( 256 cls, 257 translation: ArrayLike, 258 *, 259 spaces: Spaces = Spaces(None, None), 260 ) -> Affine[ArrayT]: 261 """Create an affine translation. 262 263 Parameters 264 ---------- 265 translation 266 D-length array of translation values. 267 spaces 268 Optional source and target spaces 269 270 Returns 271 ------- 272 Affine[ArrayT] 273 The translation affine transform. 274 275 Raises 276 ------ 277 ValueError 278 If translation array is not 1D. 279 """ 280 t = as_floats(translation) 281 if t.ndim != 1: 282 raise ValueError(f"Translation array must be 1D; got shape {t.shape}") 283 m = np.eye(len(t) + 1, dtype=t.dtype) 284 m[:-1, -1] = t 285 return cls(m, spaces=spaces)
Create an affine translation.
Parameters
- translation: D-length array of translation values.
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The translation affine transform.
Raises
- ValueError: If translation array is not 1D.
287 @classmethod 288 def scaling( 289 cls, 290 scale: ArrayLike, 291 *, 292 spaces: Spaces = Spaces(None, None), 293 ) -> Affine[ArrayT]: 294 """Create an affine scaling. 295 296 Parameters 297 ---------- 298 scale 299 D-length array of scaling factors. 300 spaces 301 Optional source and target spaces 302 303 Returns 304 ------- 305 Affine[ArrayT] 306 The scaling affine transform. 307 308 Raises 309 ------ 310 ValueError 311 If scale array is not 1D. 312 """ 313 s = as_floats(scale) 314 if s.ndim != 1: 315 raise ValueError(f"Scale array must be 1D; got shape {s.shape}") 316 return cls.from_linear_map(np.diag(s), spaces=spaces)
Create an affine scaling.
Parameters
- scale: D-length array of scaling factors.
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The scaling affine transform.
Raises
- ValueError: If scale array is not 1D.
318 @classmethod 319 def reflection( 320 cls, 321 axis: Union[int, Container[int]], 322 ndim: int, 323 *, 324 spaces: Spaces = Spaces(None, None), 325 ) -> Affine[ArrayT]: 326 """Create an affine reflection. 327 328 Parameters 329 ---------- 330 axis 331 A single axis or multiple to reflect in. 332 ndim 333 How many dimensions to work in. 334 spaces 335 Optional source and target spaces 336 337 Returns 338 ------- 339 Affine[ArrayT] 340 The reflection affine transform. 341 """ 342 if isinstance(axis, (int, np.integer)): 343 axis = [axis] 344 values = np.asarray([-1 if idx in axis else 1 for idx in range(ndim)]) 345 return cls.from_linear_map(np.diag(values.astype(float)), spaces=spaces)
Create an affine reflection.
Parameters
- axis: A single axis or multiple to reflect in.
- ndim: How many dimensions to work in.
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The reflection affine transform.
347 @classmethod 348 def rotation2( 349 cls, 350 rotation: float, 351 degrees: bool = True, 352 clockwise: bool = False, 353 *, 354 spaces: Spaces = Spaces(None, None), 355 ) -> Affine[ArrayT]: 356 """Create a 2D affine rotation. 357 358 Parameters 359 ---------- 360 rotation 361 Angle to rotate. 362 degrees 363 Whether rotation is in degrees (rather than radians), by default True 364 clockwise 365 Whether rotation is clockwise, by default False 366 spaces 367 Optional source and target spaces 368 369 Returns 370 ------- 371 Affine[ArrayT] 372 The rotation affine transform. 373 """ 374 if degrees: 375 rotation = math.radians(rotation) 376 if clockwise: 377 rotation *= -1 378 c, s = math.cos(rotation), math.sin(rotation) 379 return cls.from_linear_map(np.array([[c, -s], [s, c]]), spaces=spaces)
Create a 2D affine rotation.
Parameters
- rotation: Angle to rotate.
- degrees: Whether rotation is in degrees (rather than radians), by default True
- clockwise: Whether rotation is clockwise, by default False
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The rotation affine transform.
381 @classmethod 382 def rotation3( 383 cls, 384 rotation: Union[float, tuple[float, float, float]], 385 degrees: bool = True, 386 clockwise: bool = False, 387 order: tuple[int, int, int] = (0, 1, 2), 388 *, 389 spaces: Spaces = Spaces(None, None), 390 ) -> Affine[ArrayT]: 391 """Create a 3D affine rotation. 392 393 Parameters 394 ---------- 395 rotation 396 Either a single rotation for all axes, or 1 for each. 397 degrees 398 Whether rotation is in degrees (rather than radians), by default True 399 clockwise 400 Whether rotation is clockwise, by default False 401 order 402 What order to apply the rotations, by default (0, 1, 2) 403 spaces 404 Optional source and target spaces 405 406 Returns 407 ------- 408 Affine[ArrayT] 409 The rotation affine transform. 410 411 Raises 412 ------ 413 ValueError 414 Incompatible order. 415 """ 416 if isinstance(rotation, (int, float)): 417 r = [rotation] * 3 418 else: 419 r = list(rotation) 420 421 if degrees: 422 r = [math.radians(x) for x in r] 423 if clockwise: 424 r = [-x for x in r] 425 426 if len(order) != 3 or set(order) != {0, 1, 2}: 427 raise ValueError("Order must contain only 0, 1, 2 in any order.") 428 429 c0, s0 = math.cos(r[0]), math.sin(r[0]) 430 c1, s1 = math.cos(r[1]), math.sin(r[1]) 431 c2, s2 = math.cos(r[2]), math.sin(r[2]) 432 433 rots = [ 434 np.array([[1, 0, 0], [0, c0, -s0], [0, s0, c0]]), 435 np.array([[c1, 0, s1], [0, 1, 0], [-s1, 0, c1]]), 436 np.array([[c2, -s2, 0], [s2, c2, 0], [0, 0, 1]]), 437 ] 438 rot = rots[order[0]] @ rots[order[1]] @ rots[order[2]] 439 return cls.from_linear_map(rot, spaces=spaces)
Create a 3D affine rotation.
Parameters
- rotation: Either a single rotation for all axes, or 1 for each.
- degrees: Whether rotation is in degrees (rather than radians), by default True
- clockwise: Whether rotation is clockwise, by default False
- order: What order to apply the rotations, by default (0, 1, 2)
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The rotation affine transform.
Raises
- ValueError: Incompatible order.
441 @classmethod 442 def shearing( 443 cls, 444 factor: Union[float, np.ndarray], 445 ndim: int | None = None, 446 *, 447 spaces: Spaces = Spaces(None, None), 448 ) -> Affine[ArrayT]: 449 """Create an affine shear. 450 451 `factor` can be a scalar to broadcast to all dimensions, 452 or a D-length list of D-1 lists. 453 The first inner list contains the shear factors in the first dimension 454 for all *but* the first dimension. 455 The second inner list contains the shear factors in the second dimension 456 for all the *but* the second dimension, etc. 457 458 Parameters 459 ---------- 460 factor 461 Shear scale factors; see above for more details. 462 ndim 463 If factor is scalar, broadcast to this many dimensions, by default None 464 spaces 465 Optional source and target spaces 466 467 Returns 468 ------- 469 Affine[ArrayT] 470 The shearing affine transform. 471 472 Raises 473 ------ 474 ValueError 475 Incompatible factor. 476 """ 477 if isinstance(factor, (int, float, complex)): 478 if ndim is None: 479 raise ValueError("If factor is scalar, ndim must be defined") 480 s = np.full((ndim, ndim - 1), factor) 481 else: 482 s = np.asarray(factor) 483 if s.ndim != 2 or s.shape[0] != s.shape[1] + 1: 484 raise ValueError("Factor must be of shape (D, D-1)") 485 ndim = s.shape[0] 486 487 assert ndim is not None 488 489 m = np.eye(ndim, dtype=s.dtype) 490 for col_idx in range(m.shape[1]): 491 it = iter(s[col_idx]) 492 for row_idx in range(m.shape[0] - 1): 493 if m[row_idx, col_idx] == 0: 494 m[row_idx, col_idx] = next(it) 495 return cls.from_linear_map(m, spaces=spaces)
Create an affine shear.
factor can be a scalar to broadcast to all dimensions,
or a D-length list of D-1 lists.
The first inner list contains the shear factors in the first dimension
for all but the first dimension.
The second inner list contains the shear factors in the second dimension
for all the but the second dimension, etc.
Parameters
- factor: Shear scale factors; see above for more details.
- ndim: If factor is scalar, broadcast to this many dimensions, by default None
- spaces: Optional source and target spaces
Returns
- Affine[ArrayT]: The shearing affine transform.
Raises
- ValueError: Incompatible factor.
502 def is_identity(self) -> bool: 503 xp = array_namespace(self.matrix) 504 sh = xp.shape(self.matrix) 505 if sh[0] != sh[1]: 506 return False 507 identity = xp.eye(sh[0], dtype=self.matrix.dtype, device=self.matrix.device) 508 return xp.all(xp.equal(self.matrix, identity))
Whether this is a no-op transformation.
20class Identity(Transform[ArrayT]): 21 """No-op transformation.""" 22 23 def __init__( 24 self, 25 ndim: int, 26 *, 27 spaces: Spaces = Spaces(None, None), 28 ): 29 """ 30 Transform which does nothing. 31 32 Parameters 33 ---------- 34 ndim: 35 Number of dimensions of this transform. 36 spaces: 37 Optional source and target spaces 38 """ 39 src = chain_or(*spaces, default=None) 40 tgt = chain_or(*spaces[::-1], default=None) 41 super().__init__(NDims(ndim, ndim), spaces=Spaces(src, tgt)) 42 43 def invert(self) -> Transform[ArrayT]: 44 return type(self)(self.ndims.source, spaces=self.spaces.invert()) 45 46 def to_affine(self) -> Affine[ArrayT] | None: 47 return Affine[ArrayT].identity(self.ndims.source, spaces=self.spaces) 48 49 def apply(self, coords: ArrayT) -> ArrayT: 50 return coords
No-op transformation.
23 def __init__( 24 self, 25 ndim: int, 26 *, 27 spaces: Spaces = Spaces(None, None), 28 ): 29 """ 30 Transform which does nothing. 31 32 Parameters 33 ---------- 34 ndim: 35 Number of dimensions of this transform. 36 spaces: 37 Optional source and target spaces 38 """ 39 src = chain_or(*spaces, default=None) 40 tgt = chain_or(*spaces[::-1], default=None) 41 super().__init__(NDims(ndim, ndim), spaces=Spaces(src, tgt))
Transform which does nothing.
Parameters
- ndim:: Number of dimensions of this transform.
- spaces:: Optional source and target spaces
43 def invert(self) -> Transform[ArrayT]: 44 return type(self)(self.ndims.source, spaces=self.spaces.invert())
Invert the transformation, returning None if not possible.
13class ProjectAxis(Transform): 14 """Transform for adding and removing axes. 15 16 WARNING: inverting this transformation may be lossy. 17 """ 18 19 def __init__( 20 self, 21 dropped: set[int] | None = None, 22 created: set[int] | None = None, 23 source_ndim: int | None = None, 24 target_ndim: int | None = None, 25 *, 26 spaces: Spaces = Spaces(None, None), 27 ): 28 """Create a transform for adding and dropping axes. 29 30 At least one of source_ndim and target_ndim must be given. 31 32 Parameters 33 ---------- 34 dropped 35 Set of INPUT dimension indices to drop, if any. 36 created 37 Set of OUTPUT dimension indices which are new, if any. 38 source_ndim 39 If omitted, can be inferred from `target_ndim`. 40 target_ndim 41 If omitted, can be inferred from `source_ndim`. 42 spaces 43 Identifiers for source and target spaces, by default Spaces(None, None) 44 45 Raises 46 ------ 47 ValueError 48 Operations are inconsistent with given dimensionality, 49 or insufficient dimensionality information was given. 50 """ 51 self.dropped = dropped or set() 52 self.created = created or set() 53 54 if source_ndim is not None: 55 nd = source_ndim - len(self.dropped) + len(self.created) 56 if target_ndim is None: 57 target_ndim = nd 58 elif target_ndim != nd: 59 raise ValueError("Operations do not match expected target ndim") 60 61 elif target_ndim is not None: 62 nd = target_ndim - len(self.created) + len(self.dropped) 63 if source_ndim is None: 64 source_ndim = nd 65 elif source_ndim != nd: 66 raise ValueError("Operations do not match expected source ndim") 67 68 else: 69 raise ValueError("At least one of source_ndim or target_ndim must be given") 70 71 idxs: list[int | None] = list(range(source_ndim)) 72 for drop in sorted(self.dropped, reverse=True): 73 idxs.pop(drop) 74 for create in sorted(self.created): 75 idxs.insert(create, None) 76 self._idxs = idxs 77 78 super().__init__(NDims(source_ndim, target_ndim), spaces=spaces) 79 80 def apply(self, coords: ArrayT) -> ArrayT: 81 coords = self._validate_coords(coords) 82 if self.created: 83 xp = array_namespace(coords) 84 out = xp.zeros_like(coords, shape=(xp.shape(coords)[0], self.ndims.target)) 85 for idx, orig_idx in enumerate(self._idxs): 86 if orig_idx is not None: 87 out[:, idx] = coords[:, orig_idx] # type:ignore 88 89 else: 90 out = coords[:, self._idxs] # type:ignore 91 return out 92 93 def is_identity(self) -> bool: 94 return not self.created and not self.dropped 95 96 def to_affine(self) -> Affine | None: 97 m = np.eye(self.ndims.source) 98 out_m = self.apply(m) 99 return Affine.from_linear_map(out_m.T) 100 101 def invert(self) -> Self | None: 102 return type(self)( 103 self.created, 104 self.dropped, 105 source_ndim=self.ndims.target, 106 target_ndim=self.ndims.source, 107 spaces=self.spaces.invert(), 108 )
Transform for adding and removing axes.
WARNING: inverting this transformation may be lossy.
19 def __init__( 20 self, 21 dropped: set[int] | None = None, 22 created: set[int] | None = None, 23 source_ndim: int | None = None, 24 target_ndim: int | None = None, 25 *, 26 spaces: Spaces = Spaces(None, None), 27 ): 28 """Create a transform for adding and dropping axes. 29 30 At least one of source_ndim and target_ndim must be given. 31 32 Parameters 33 ---------- 34 dropped 35 Set of INPUT dimension indices to drop, if any. 36 created 37 Set of OUTPUT dimension indices which are new, if any. 38 source_ndim 39 If omitted, can be inferred from `target_ndim`. 40 target_ndim 41 If omitted, can be inferred from `source_ndim`. 42 spaces 43 Identifiers for source and target spaces, by default Spaces(None, None) 44 45 Raises 46 ------ 47 ValueError 48 Operations are inconsistent with given dimensionality, 49 or insufficient dimensionality information was given. 50 """ 51 self.dropped = dropped or set() 52 self.created = created or set() 53 54 if source_ndim is not None: 55 nd = source_ndim - len(self.dropped) + len(self.created) 56 if target_ndim is None: 57 target_ndim = nd 58 elif target_ndim != nd: 59 raise ValueError("Operations do not match expected target ndim") 60 61 elif target_ndim is not None: 62 nd = target_ndim - len(self.created) + len(self.dropped) 63 if source_ndim is None: 64 source_ndim = nd 65 elif source_ndim != nd: 66 raise ValueError("Operations do not match expected source ndim") 67 68 else: 69 raise ValueError("At least one of source_ndim or target_ndim must be given") 70 71 idxs: list[int | None] = list(range(source_ndim)) 72 for drop in sorted(self.dropped, reverse=True): 73 idxs.pop(drop) 74 for create in sorted(self.created): 75 idxs.insert(create, None) 76 self._idxs = idxs 77 78 super().__init__(NDims(source_ndim, target_ndim), spaces=spaces)
Create a transform for adding and dropping axes.
At least one of source_ndim and target_ndim must be given.
Parameters
- dropped: Set of INPUT dimension indices to drop, if any.
- created: Set of OUTPUT dimension indices which are new, if any.
- source_ndim: If omitted, can be inferred from
target_ndim. - target_ndim: If omitted, can be inferred from
source_ndim. - spaces: Identifiers for source and target spaces, by default Spaces(None, None)
Raises
- ValueError: Operations are inconsistent with given dimensionality, or insufficient dimensionality information was given.
80 def apply(self, coords: ArrayT) -> ArrayT: 81 coords = self._validate_coords(coords) 82 if self.created: 83 xp = array_namespace(coords) 84 out = xp.zeros_like(coords, shape=(xp.shape(coords)[0], self.ndims.target)) 85 for idx, orig_idx in enumerate(self._idxs): 86 if orig_idx is not None: 87 out[:, idx] = coords[:, orig_idx] # type:ignore 88 89 else: 90 out = coords[:, self._idxs] # type:ignore 91 return out
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
96 def to_affine(self) -> Affine | None: 97 m = np.eye(self.ndims.source) 98 out_m = self.apply(m) 99 return Affine.from_linear_map(out_m.T)
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
87class Reflect(Transform[np.ndarray]): 88 """Reflect coordinates about arbitrary planes.""" 89 90 def __init__( 91 self, 92 normals: ArrayLike, 93 point: float | ArrayLike = 0.0, 94 *, 95 spaces: Spaces = Spaces(None, None), 96 ): 97 """ 98 Parameters 99 ---------- 100 normals 101 Normal vectors to the planes of reflection. 102 Unitised internally. 103 point 104 Intersection point of all reflection planes 105 (can be broadcast from scalar), by default 0 (i.e. the origin) 106 spaces 107 Optional source and target spaces 108 109 Raises 110 ------ 111 ValueError 112 Inconsistent dimensionality 113 """ 114 normals = np.asarray(normals) 115 if normals.ndim == 1: 116 normals = [normals] 117 118 n1 = normals[0] 119 if ( 120 not np.isscalar(point) 121 and isinstance(point, Sequence) 122 and len(n1) != len(point) 123 ): 124 raise ValueError("Point and normals are not of the same dimensionality") 125 self.point: np.ndarray = np.asarray(point, dtype=float) 126 self.ndim = {len(n1)} 127 self.normals = [unitise(n) for n in normals] 128 # todo: matmul is associative, so turn this into an affine in 2/3D? 129 super().__init__(NDims(len(n1), len(n1)), spaces=spaces) 130 131 def apply(self, coords: np.ndarray) -> np.ndarray: 132 coords = self._validate_coords(coords) 133 out = coords - self.point 134 for n in self.normals: 135 # mul->sum vectorises dot product 136 # normals are unit, avoids unnecessary division by 1 137 out -= 2 * np.sum(coords * n, axis=1) * n 138 out += self.point 139 return out 140 141 @classmethod 142 def from_points( 143 cls, 144 points: ArrayLike, 145 *, 146 spaces: Spaces = Spaces(None, None), 147 ) -> Self: 148 """Infer a single plane of reflection from a minimal number of points on it. 149 150 Parameters 151 ---------- 152 points 153 NxD array of N points in D dimensions. N == D 154 spaces 155 Optional source and target spaces 156 157 Returns 158 ------- 159 Self 160 """ 161 point, normals = get_hyperplanes(np.asarray(points), unitise=False) 162 return cls(normals, point, spaces=spaces) 163 164 @classmethod 165 def from_axis( 166 cls, 167 axis: int | Sequence[int], 168 origin: ArrayLike, 169 *, 170 spaces: Spaces = Spaces(None, None), 171 ) -> Self: 172 """Reflect around hyperplane(s) parallel with axes. 173 174 Parameters 175 ---------- 176 axis 177 Index (or indices) of axes in which to reflect. 178 origin 179 Point around which to reflect. 180 spaces 181 Optional source and target spaces 182 183 Returns 184 ------- 185 Self 186 187 Raises 188 ------ 189 ValueError 190 Selected axis does not exist. 191 """ 192 origin = np.asarray(origin) 193 axis = ensure_tuple(axis) 194 195 for a in axis: 196 if a >= len(axis): 197 raise ValueError( 198 "Cannot reflect in axis which does not exist (too high)" 199 ) 200 201 normals = [] 202 for i in range(len(origin) - len(axis) + 1): 203 if i not in axis: 204 v = np.zeros_like(origin) 205 v[i] += 1 206 normals.append(v) 207 208 return cls(normals, origin, spaces=spaces) 209 210 def invert(self) -> Self | None: 211 return copy(self) 212 213 def to_affine(self) -> Affine[np.ndarray] | None: 214 # TODO: should be possible? 215 # rotate to align plane with origin and reflect it 216 return None
Reflect coordinates about arbitrary planes.
90 def __init__( 91 self, 92 normals: ArrayLike, 93 point: float | ArrayLike = 0.0, 94 *, 95 spaces: Spaces = Spaces(None, None), 96 ): 97 """ 98 Parameters 99 ---------- 100 normals 101 Normal vectors to the planes of reflection. 102 Unitised internally. 103 point 104 Intersection point of all reflection planes 105 (can be broadcast from scalar), by default 0 (i.e. the origin) 106 spaces 107 Optional source and target spaces 108 109 Raises 110 ------ 111 ValueError 112 Inconsistent dimensionality 113 """ 114 normals = np.asarray(normals) 115 if normals.ndim == 1: 116 normals = [normals] 117 118 n1 = normals[0] 119 if ( 120 not np.isscalar(point) 121 and isinstance(point, Sequence) 122 and len(n1) != len(point) 123 ): 124 raise ValueError("Point and normals are not of the same dimensionality") 125 self.point: np.ndarray = np.asarray(point, dtype=float) 126 self.ndim = {len(n1)} 127 self.normals = [unitise(n) for n in normals] 128 # todo: matmul is associative, so turn this into an affine in 2/3D? 129 super().__init__(NDims(len(n1), len(n1)), spaces=spaces)
Parameters
- normals: Normal vectors to the planes of reflection. Unitised internally.
- point: Intersection point of all reflection planes (can be broadcast from scalar), by default 0 (i.e. the origin)
- spaces: Optional source and target spaces
Raises
- ValueError: Inconsistent dimensionality
131 def apply(self, coords: np.ndarray) -> np.ndarray: 132 coords = self._validate_coords(coords) 133 out = coords - self.point 134 for n in self.normals: 135 # mul->sum vectorises dot product 136 # normals are unit, avoids unnecessary division by 1 137 out -= 2 * np.sum(coords * n, axis=1) * n 138 out += self.point 139 return out
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
141 @classmethod 142 def from_points( 143 cls, 144 points: ArrayLike, 145 *, 146 spaces: Spaces = Spaces(None, None), 147 ) -> Self: 148 """Infer a single plane of reflection from a minimal number of points on it. 149 150 Parameters 151 ---------- 152 points 153 NxD array of N points in D dimensions. N == D 154 spaces 155 Optional source and target spaces 156 157 Returns 158 ------- 159 Self 160 """ 161 point, normals = get_hyperplanes(np.asarray(points), unitise=False) 162 return cls(normals, point, spaces=spaces)
Infer a single plane of reflection from a minimal number of points on it.
Parameters
- points: NxD array of N points in D dimensions. N == D
- spaces: Optional source and target spaces
Returns
- Self
164 @classmethod 165 def from_axis( 166 cls, 167 axis: int | Sequence[int], 168 origin: ArrayLike, 169 *, 170 spaces: Spaces = Spaces(None, None), 171 ) -> Self: 172 """Reflect around hyperplane(s) parallel with axes. 173 174 Parameters 175 ---------- 176 axis 177 Index (or indices) of axes in which to reflect. 178 origin 179 Point around which to reflect. 180 spaces 181 Optional source and target spaces 182 183 Returns 184 ------- 185 Self 186 187 Raises 188 ------ 189 ValueError 190 Selected axis does not exist. 191 """ 192 origin = np.asarray(origin) 193 axis = ensure_tuple(axis) 194 195 for a in axis: 196 if a >= len(axis): 197 raise ValueError( 198 "Cannot reflect in axis which does not exist (too high)" 199 ) 200 201 normals = [] 202 for i in range(len(origin) - len(axis) + 1): 203 if i not in axis: 204 v = np.zeros_like(origin) 205 v[i] += 1 206 normals.append(v) 207 208 return cls(normals, origin, spaces=spaces)
Reflect around hyperplane(s) parallel with axes.
Parameters
- axis: Index (or indices) of axes in which to reflect.
- origin: Point around which to reflect.
- spaces: Optional source and target spaces
Returns
- Self
Raises
- ValueError: Selected axis does not exist.
213 def to_affine(self) -> Affine[np.ndarray] | None: 214 # TODO: should be possible? 215 # rotate to align plane with origin and reflect it 216 return None
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
103class Scale(Transform[ArrayT]): 104 """Scale coordinates by multiplication.""" 105 106 def __init__( 107 self, 108 scale: ArrayLike, 109 *, 110 spaces: Spaces = Spaces(None, None), 111 ): 112 """Simple scale transform. 113 114 All points are scaled, i.e. distance from the origin may also change. 115 116 Parameters 117 ---------- 118 scale 119 Scaling to apply in all dimensions, or each dimension. 120 spaces 121 Optional source and target spaces 122 123 Raises 124 ------ 125 ValueError 126 If scale is the wrong shape. 127 """ 128 self.scale = as_floats(scale) 129 if self.scale.ndim != 1: 130 raise ValueError(f"Scale must be 1D, got shape {self.scale.shape}") 131 super().__init__(NDims(len(self.scale), len(self.scale)), spaces=spaces) 132 133 def to_affine(self) -> Affine[ArrayT] | None: 134 return Affine[ArrayT].scaling(self.scale, spaces=self.spaces) 135 136 def apply(self, coords: ArrayT) -> ArrayT: 137 coords = self._validate_coords(coords) 138 xp = array_namespace(coords) 139 d = xp_device(coords) 140 return coords * xp.asarray(self.scale, device=d) 141 142 def invert(self) -> Self | None: 143 return type(self)( 144 1 / self.scale, 145 spaces=self.spaces.invert(), 146 ) 147 148 def to_device(self, xp: ModuleType, device: str | None = None) -> Self: 149 result = copy(self) 150 result.scale = xp.asarray(self.scale, device=device) 151 return result
Scale coordinates by multiplication.
106 def __init__( 107 self, 108 scale: ArrayLike, 109 *, 110 spaces: Spaces = Spaces(None, None), 111 ): 112 """Simple scale transform. 113 114 All points are scaled, i.e. distance from the origin may also change. 115 116 Parameters 117 ---------- 118 scale 119 Scaling to apply in all dimensions, or each dimension. 120 spaces 121 Optional source and target spaces 122 123 Raises 124 ------ 125 ValueError 126 If scale is the wrong shape. 127 """ 128 self.scale = as_floats(scale) 129 if self.scale.ndim != 1: 130 raise ValueError(f"Scale must be 1D, got shape {self.scale.shape}") 131 super().__init__(NDims(len(self.scale), len(self.scale)), spaces=spaces)
Simple scale transform.
All points are scaled, i.e. distance from the origin may also change.
Parameters
- scale: Scaling to apply in all dimensions, or each dimension.
- spaces: Optional source and target spaces
Raises
- ValueError: If scale is the wrong shape.
133 def to_affine(self) -> Affine[ArrayT] | None: 134 return Affine[ArrayT].scaling(self.scale, spaces=self.spaces)
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
136 def apply(self, coords: ArrayT) -> ArrayT: 137 coords = self._validate_coords(coords) 138 xp = array_namespace(coords) 139 d = xp_device(coords) 140 return coords * xp.asarray(self.scale, device=d)
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
142 def invert(self) -> Self | None: 143 return type(self)( 144 1 / self.scale, 145 spaces=self.spaces.invert(), 146 )
Invert the transformation, returning None if not possible.
148 def to_device(self, xp: ModuleType, device: str | None = None) -> Self: 149 result = copy(self) 150 result.scale = xp.asarray(self.scale, device=device) 151 return result
Return a copy of this transform with array parameters placed on the given device.
Useful for pre-allocating parameters on GPU before a tight apply() loop, avoiding per-call host-to-device transfers.
Parameters
- xp: The target array namespace (e.g. jax.numpy, torch).
- device: Target device (e.g. from array_api_compat.device(array)). If None, uses xp's default device.
Returns
- Self: A new transform instance with parameters on the target device, or NotImplemented if the subclass does not support device placement.
53class Translate(Transform[ArrayT]): 54 """Translate coordinates by addition.""" 55 56 def __init__( 57 self, 58 translation: ArrayLike, 59 *, 60 spaces: Spaces = Spaces(None, None), 61 ): 62 """Simple translation. 63 64 Parameters 65 ---------- 66 translation 67 Translation to apply in all dimensions, or each dimension. 68 spaces 69 Optional source and target spaces 70 71 Raises 72 ------ 73 ValueError 74 If the translation is the wrong shape 75 """ 76 self.translation = as_floats(translation) 77 if self.translation.ndim != 1: 78 raise ValueError( 79 f"Translation must be 1D, got shape {self.translation.shape}" 80 ) 81 super().__init__( 82 NDims(len(self.translation), len(self.translation)), spaces=spaces 83 ) 84 85 def to_affine(self) -> Affine[ArrayT] | None: 86 return Affine[ArrayT].translation(self.translation, spaces=self.spaces) 87 88 def apply(self, coords: ArrayT) -> ArrayT: 89 coords = self._validate_coords(coords) 90 xp = array_namespace(coords) 91 d = xp_device(coords) 92 return coords + xp.asarray(self.translation, device=d) 93 94 def invert(self) -> Transform | None: 95 return type(self)(-self.translation, spaces=self.spaces.invert()) 96 97 def to_device(self, xp: ModuleType, device: str | None = None) -> Self: 98 result = copy(self) 99 result.translation = xp.asarray(self.translation, device=device) 100 return result
Translate coordinates by addition.
56 def __init__( 57 self, 58 translation: ArrayLike, 59 *, 60 spaces: Spaces = Spaces(None, None), 61 ): 62 """Simple translation. 63 64 Parameters 65 ---------- 66 translation 67 Translation to apply in all dimensions, or each dimension. 68 spaces 69 Optional source and target spaces 70 71 Raises 72 ------ 73 ValueError 74 If the translation is the wrong shape 75 """ 76 self.translation = as_floats(translation) 77 if self.translation.ndim != 1: 78 raise ValueError( 79 f"Translation must be 1D, got shape {self.translation.shape}" 80 ) 81 super().__init__( 82 NDims(len(self.translation), len(self.translation)), spaces=spaces 83 )
Simple translation.
Parameters
- translation: Translation to apply in all dimensions, or each dimension.
- spaces: Optional source and target spaces
Raises
- ValueError: If the translation is the wrong shape
85 def to_affine(self) -> Affine[ArrayT] | None: 86 return Affine[ArrayT].translation(self.translation, spaces=self.spaces)
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
88 def apply(self, coords: ArrayT) -> ArrayT: 89 coords = self._validate_coords(coords) 90 xp = array_namespace(coords) 91 d = xp_device(coords) 92 return coords + xp.asarray(self.translation, device=d)
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
94 def invert(self) -> Transform | None: 95 return type(self)(-self.translation, spaces=self.spaces.invert())
Invert the transformation, returning None if not possible.
97 def to_device(self, xp: ModuleType, device: str | None = None) -> Self: 98 result = copy(self) 99 result.translation = xp.asarray(self.translation, device=device) 100 return result
Return a copy of this transform with array parameters placed on the given device.
Useful for pre-allocating parameters on GPU before a tight apply() loop, avoiding per-call host-to-device transfers.
Parameters
- xp: The target array namespace (e.g. jax.numpy, torch).
- device: Target device (e.g. from array_api_compat.device(array)). If None, uses xp's default device.
Returns
- Self: A new transform instance with parameters on the target device, or NotImplemented if the subclass does not support device placement.
12class MapAxis(Transform[ArrayT]): 13 """Map coordinates from one axis to another. 14 15 For example, x -> y and y -> x""" 16 17 def __init__( 18 self, 19 permutation: list[int], 20 *, 21 spaces: Spaces = Spaces(None, None), 22 ): 23 """Base class for transformations. 24 25 Parameters 26 ---------- 27 permutation 28 New order of column axis. For example, [1, 0] means x -> y and y -> x. 29 spaces 30 Optional source and target spaces 31 32 Raises 33 ------ 34 ValueError 35 If permutation does not contain all dimensions [0, N) exactly once. 36 """ 37 s_perm = sorted(permutation) 38 if any(a != b for a, b in enumerate(s_perm)): 39 raise ValueError( 40 "N-D permutation must contain all dimensions [0, N) exactly once" 41 ) 42 self.permutation = permutation 43 super().__init__(NDims(len(permutation), len(permutation)), spaces=spaces) 44 45 def is_identity(self) -> bool: 46 return all(a == b for a, b in enumerate(self.permutation)) 47 48 def to_affine(self) -> Affine[ArrayT] | None: 49 m = np.eye(self.ndims.source) 50 m = m[self.permutation, :] 51 return Affine.from_linear_map(m, spaces=self.spaces) # type: ignore 52 53 def apply(self, coords: ArrayT) -> ArrayT: 54 """Apply transformation to coordinates. 55 56 For example: 57 2-D with permutation [1, 0] will give you 58 [[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]] 59 """ 60 61 coords = self._validate_coords(coords) 62 xp = array_namespace(coords) 63 return xp.take(coords, self.permutation, 1) 64 65 def invert(self) -> Self | None: 66 return type(self)( 67 list(np.argsort(self.permutation)), 68 spaces=self.spaces.invert(), 69 )
Map coordinates from one axis to another.
For example, x -> y and y -> x
17 def __init__( 18 self, 19 permutation: list[int], 20 *, 21 spaces: Spaces = Spaces(None, None), 22 ): 23 """Base class for transformations. 24 25 Parameters 26 ---------- 27 permutation 28 New order of column axis. For example, [1, 0] means x -> y and y -> x. 29 spaces 30 Optional source and target spaces 31 32 Raises 33 ------ 34 ValueError 35 If permutation does not contain all dimensions [0, N) exactly once. 36 """ 37 s_perm = sorted(permutation) 38 if any(a != b for a, b in enumerate(s_perm)): 39 raise ValueError( 40 "N-D permutation must contain all dimensions [0, N) exactly once" 41 ) 42 self.permutation = permutation 43 super().__init__(NDims(len(permutation), len(permutation)), spaces=spaces)
Base class for transformations.
Parameters
- permutation: New order of column axis. For example, [1, 0] means x -> y and y -> x.
- spaces: Optional source and target spaces
Raises
- ValueError: If permutation does not contain all dimensions [0, N) exactly once.
48 def to_affine(self) -> Affine[ArrayT] | None: 49 m = np.eye(self.ndims.source) 50 m = m[self.permutation, :] 51 return Affine.from_linear_map(m, spaces=self.spaces) # type: ignore
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
53 def apply(self, coords: ArrayT) -> ArrayT: 54 """Apply transformation to coordinates. 55 56 For example: 57 2-D with permutation [1, 0] will give you 58 [[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]] 59 """ 60 61 coords = self._validate_coords(coords) 62 xp = array_namespace(coords) 63 return xp.take(coords, self.permutation, 1)
Apply transformation to coordinates.
For example: 2-D with permutation [1, 0] will give you [[x1, y1], [x2, y2]] -> [[y1, x1], [y2, x2]]
13class Bijection(Transform[ArrayT]): 14 """Map coordinates from one axis to another. 15 16 For example, x -> y and y -> x""" 17 18 def __init__( 19 self, 20 forward: Transform[ArrayT], 21 inverse: Transform[ArrayT], 22 *, 23 spaces: Spaces = Spaces(None, None), 24 ): 25 """Base class for transformations. 26 27 Parameters 28 ---------- 29 forward 30 The forward transformation. 31 inverse 32 The inverse transformation. 33 spaces 34 Optional source and target spaces 35 36 Raises 37 ------ 38 ValueError 39 If the forward and inverse dimensionalities don't match. 40 """ 41 src = same_or_none( 42 spaces.source, forward.spaces.source, inverse.spaces.target, default=None 43 ) 44 tgt = same_or_none( 45 spaces.target, forward.spaces.target, inverse.spaces.source, default=None 46 ) 47 48 self.forward = forward 49 self.inverse = inverse 50 if forward.ndims != inverse.ndims.invert(): 51 raise ValueError( 52 f"Bijection dimensionalities mismatch: fwd:{forward.ndims}, inv:{inverse.ndims}" 53 ) 54 super().__init__(self.forward.ndims, spaces=Spaces(src, tgt)) 55 56 def apply(self, coords: ArrayT) -> ArrayT: 57 return self.forward.apply(coords) 58 59 def invert(self) -> Self | None: 60 return type(self)(self.inverse, self.forward, spaces=self.spaces.invert()) 61 62 def is_identity(self) -> bool: 63 return self.forward.is_identity() and self.inverse.is_identity() 64 65 def to_affine(self) -> Affine[ArrayT] | None: 66 fwd = self.forward.to_affine() 67 if fwd is None: 68 return None 69 inv = self.inverse.to_affine() 70 if inv is None: 71 return None 72 73 inv_inv = inv.invert() 74 if inv_inv is None: 75 return None 76 77 xp = array_namespace(fwd.matrix) 78 if xp.equal(fwd.matrix, inv_inv.matrix): 79 return fwd 80 81 return None
Map coordinates from one axis to another.
For example, x -> y and y -> x
18 def __init__( 19 self, 20 forward: Transform[ArrayT], 21 inverse: Transform[ArrayT], 22 *, 23 spaces: Spaces = Spaces(None, None), 24 ): 25 """Base class for transformations. 26 27 Parameters 28 ---------- 29 forward 30 The forward transformation. 31 inverse 32 The inverse transformation. 33 spaces 34 Optional source and target spaces 35 36 Raises 37 ------ 38 ValueError 39 If the forward and inverse dimensionalities don't match. 40 """ 41 src = same_or_none( 42 spaces.source, forward.spaces.source, inverse.spaces.target, default=None 43 ) 44 tgt = same_or_none( 45 spaces.target, forward.spaces.target, inverse.spaces.source, default=None 46 ) 47 48 self.forward = forward 49 self.inverse = inverse 50 if forward.ndims != inverse.ndims.invert(): 51 raise ValueError( 52 f"Bijection dimensionalities mismatch: fwd:{forward.ndims}, inv:{inverse.ndims}" 53 ) 54 super().__init__(self.forward.ndims, spaces=Spaces(src, tgt))
Base class for transformations.
Parameters
- forward: The forward transformation.
- inverse: The inverse transformation.
- spaces: Optional source and target spaces
Raises
- ValueError: If the forward and inverse dimensionalities don't match.
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
59 def invert(self) -> Self | None: 60 return type(self)(self.inverse, self.forward, spaces=self.spaces.invert())
Invert the transformation, returning None if not possible.
62 def is_identity(self) -> bool: 63 return self.forward.is_identity() and self.inverse.is_identity()
Whether this is a no-op transformation.
65 def to_affine(self) -> Affine[ArrayT] | None: 66 fwd = self.forward.to_affine() 67 if fwd is None: 68 return None 69 inv = self.inverse.to_affine() 70 if inv is None: 71 return None 72 73 inv_inv = inv.invert() 74 if inv_inv is None: 75 return None 76 77 xp = array_namespace(fwd.matrix) 78 if xp.equal(fwd.matrix, inv_inv.matrix): 79 return fwd 80 81 return None
Convert the transform into affine, if conversion is possible.
Returns
- Affine[ArrayT] | None: The affine transformation, if conversion is possible. None otherwise.
63class ByDimension(Transform[ArrayT]): 64 """Apply transformations to subsets of the coordinates' dimensions. 65 66 Adapted from: https://ngff.openmicroscopy.org/specifications/dev/index.html#bydimension 67 """ 68 69 def __init__( 70 self, 71 subtransforms: list[SubTransform[ArrayT]], 72 fill_identity: int | None = None, 73 *, 74 spaces: Spaces = Spaces(None, None), 75 ): 76 """ 77 Parameters 78 ---------- 79 subtransforms 80 Transformations applying to subsets of the given coordinates. 81 fill_identity 82 If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions. 83 e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and `fill_identity=3`. 84 spaces 85 Optional source and target spaces 86 87 Raises 88 ------ 89 ValueError 90 If input or output axes are not valid. 91 """ 92 if fill_identity is not None: 93 to_fill_in = set(range(fill_identity)) 94 to_fill_out = set(range(fill_identity)) 95 for t in subtransforms: 96 for i in t.input_axes: 97 try: 98 to_fill_in.remove(i) 99 except KeyError: 100 pass 101 for i in t.output_axes: 102 try: 103 to_fill_out.remove(i) 104 except KeyError: 105 pass 106 subtransforms.append( 107 SubTransform( 108 Identity(len(to_fill_in)), sorted(to_fill_in), sorted(to_fill_out) 109 ) 110 ) 111 112 # check that input and output axes of sub transforms are disjoint 113 sorted_in = sorted(ax for t in subtransforms for ax in t.input_axes) 114 if sorted_in != list(range(len(sorted_in))): 115 raise ValueError("N-length input axes must go from 0 to N-1") 116 117 sorted_out = sorted(ax for t in subtransforms for ax in t.output_axes) 118 119 if sorted_out != list(range(len(sorted_out))): 120 raise ValueError("N-length output axes must go from 0 to N-1") 121 122 super().__init__(NDims(len(sorted_in), len(sorted_out)), spaces=spaces) 123 self.subtransforms = subtransforms 124 125 def apply(self, coords: ArrayT) -> ArrayT: 126 """Apply transformation to subset of coordinates.""" 127 coords = self._validate_coords(coords) 128 xp = array_namespace(coords) 129 output = xp.empty_like(coords) 130 for t in self.subtransforms: 131 transformed = t.transform.apply(xp.take(coords, t.input_axes, 1)) 132 for idx, o in enumerate(t.output_axes): 133 output[:, o] = transformed[:, idx] # type: ignore 134 return output 135 136 def invert(self) -> Transform[ArrayT] | None: 137 try: 138 inverted_transforms = [ 139 SubTransform[ArrayT]( 140 input_axes=t.output_axes, 141 output_axes=t.input_axes, 142 transform=~t.transform, 143 ) 144 for t in reversed(self.subtransforms) 145 ] 146 except NotImplementedError: 147 return None 148 149 return type(self)( 150 subtransforms=inverted_transforms, 151 spaces=self.spaces.invert(), 152 ) 153 154 def is_identity(self) -> bool: 155 for t in self.subtransforms: 156 if t.input_axes != t.output_axes or not t.transform.is_identity(): 157 return False 158 return True
Apply transformations to subsets of the coordinates' dimensions.
Adapted from: https://ngff.openmicroscopy.org/specifications/dev/index.html#bydimension
69 def __init__( 70 self, 71 subtransforms: list[SubTransform[ArrayT]], 72 fill_identity: int | None = None, 73 *, 74 spaces: Spaces = Spaces(None, None), 75 ): 76 """ 77 Parameters 78 ---------- 79 subtransforms 80 Transformations applying to subsets of the given coordinates. 81 fill_identity 82 If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions. 83 e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and `fill_identity=3`. 84 spaces 85 Optional source and target spaces 86 87 Raises 88 ------ 89 ValueError 90 If input or output axes are not valid. 91 """ 92 if fill_identity is not None: 93 to_fill_in = set(range(fill_identity)) 94 to_fill_out = set(range(fill_identity)) 95 for t in subtransforms: 96 for i in t.input_axes: 97 try: 98 to_fill_in.remove(i) 99 except KeyError: 100 pass 101 for i in t.output_axes: 102 try: 103 to_fill_out.remove(i) 104 except KeyError: 105 pass 106 subtransforms.append( 107 SubTransform( 108 Identity(len(to_fill_in)), sorted(to_fill_in), sorted(to_fill_out) 109 ) 110 ) 111 112 # check that input and output axes of sub transforms are disjoint 113 sorted_in = sorted(ax for t in subtransforms for ax in t.input_axes) 114 if sorted_in != list(range(len(sorted_in))): 115 raise ValueError("N-length input axes must go from 0 to N-1") 116 117 sorted_out = sorted(ax for t in subtransforms for ax in t.output_axes) 118 119 if sorted_out != list(range(len(sorted_out))): 120 raise ValueError("N-length output axes must go from 0 to N-1") 121 122 super().__init__(NDims(len(sorted_in), len(sorted_out)), spaces=spaces) 123 self.subtransforms = subtransforms
Parameters
- subtransforms: Transformations applying to subsets of the given coordinates.
- fill_identity: If not None, fill any missing input and output axes with identity transforms in order, up to a maximum number of dimensions.
e.g. if you have XYT imates which you only want to transform in XY, provide the XY subtransformations and
fill_identity=3. - spaces: Optional source and target spaces
Raises
- ValueError: If input or output axes are not valid.
125 def apply(self, coords: ArrayT) -> ArrayT: 126 """Apply transformation to subset of coordinates.""" 127 coords = self._validate_coords(coords) 128 xp = array_namespace(coords) 129 output = xp.empty_like(coords) 130 for t in self.subtransforms: 131 transformed = t.transform.apply(xp.take(coords, t.input_axes, 1)) 132 for idx, o in enumerate(t.output_axes): 133 output[:, o] = transformed[:, idx] # type: ignore 134 return output
Apply transformation to subset of coordinates.
136 def invert(self) -> Transform[ArrayT] | None: 137 try: 138 inverted_transforms = [ 139 SubTransform[ArrayT]( 140 input_axes=t.output_axes, 141 output_axes=t.input_axes, 142 transform=~t.transform, 143 ) 144 for t in reversed(self.subtransforms) 145 ] 146 except NotImplementedError: 147 return None 148 149 return type(self)( 150 subtransforms=inverted_transforms, 151 spaces=self.spaces.invert(), 152 )
Invert the transformation, returning None if not possible.
11class SubTransform[ArrayT]: 12 """Component of the `ByDimension` transformation. 13 14 Transformation to apply to subsets of the input dimensions and which output dimensions they calculate. 15 """ 16 17 def __init__( 18 self, 19 transform: Transform[ArrayT], 20 input_axes: list[int], 21 output_axes: list[int] | None = None, 22 ): 23 """ 24 Parameters 25 ---------- 26 transform 27 Transformation to apply to the subset of axes. 28 input_axes 29 Which axes to apply the transformation to, in order. 30 The length must match the input dimensionality of `transform`. 31 output_axes 32 Which axes to apply the transformation to, in order. 33 The length must match the input dimensionality of `transform`. 34 If None, re-use the input axes. 35 36 Raises 37 ------ 38 ValueError 39 `transform`'s dimensionality does not match the input/output axes. 40 """ 41 42 self.input_axes = input_axes 43 if output_axes is None: 44 self.output_axes = input_axes 45 else: 46 self.output_axes = output_axes 47 48 in_ndim = len(self.input_axes) 49 out_ndim = len(self.output_axes) 50 51 if transform.ndims.source != in_ndim: 52 raise ValueError( 53 f"Subtransform input dimensionality ({transform.ndims.source}) must match length of input_axes" 54 ) 55 if transform.ndims.target != out_ndim: 56 raise ValueError( 57 f"Subtransform output dimensionality ({transform.ndims.target}) must match length of output_axes" 58 ) 59 60 self.transform = transform
Component of the ByDimension transformation.
Transformation to apply to subsets of the input dimensions and which output dimensions they calculate.
17 def __init__( 18 self, 19 transform: Transform[ArrayT], 20 input_axes: list[int], 21 output_axes: list[int] | None = None, 22 ): 23 """ 24 Parameters 25 ---------- 26 transform 27 Transformation to apply to the subset of axes. 28 input_axes 29 Which axes to apply the transformation to, in order. 30 The length must match the input dimensionality of `transform`. 31 output_axes 32 Which axes to apply the transformation to, in order. 33 The length must match the input dimensionality of `transform`. 34 If None, re-use the input axes. 35 36 Raises 37 ------ 38 ValueError 39 `transform`'s dimensionality does not match the input/output axes. 40 """ 41 42 self.input_axes = input_axes 43 if output_axes is None: 44 self.output_axes = input_axes 45 else: 46 self.output_axes = output_axes 47 48 in_ndim = len(self.input_axes) 49 out_ndim = len(self.output_axes) 50 51 if transform.ndims.source != in_ndim: 52 raise ValueError( 53 f"Subtransform input dimensionality ({transform.ndims.source}) must match length of input_axes" 54 ) 55 if transform.ndims.target != out_ndim: 56 raise ValueError( 57 f"Subtransform output dimensionality ({transform.ndims.target}) must match length of output_axes" 58 ) 59 60 self.transform = transform
Parameters
- transform: Transformation to apply to the subset of axes.
- input_axes: Which axes to apply the transformation to, in order.
The length must match the input dimensionality of
transform. - output_axes: Which axes to apply the transformation to, in order.
The length must match the input dimensionality of
transform. If None, re-use the input axes.
Raises
- ValueError:
transform's dimensionality does not match the input/output axes.
130class Coordinates(BaseVectorField[ArrayT]): 131 """Look up the output coordinates in an array. 132 133 For input coordinate `(a, b, c)` and `vector_axis=-1`, 134 the output coordinate is `vector_field[a, b, c, :]. 135 136 Input coordinates outside the vector field return NaN. 137 138 REQUIRES: `vectorfield` extra for in-memory, 139 or `vectorfield-dask` extra for lazy chunked vector fields. 140 """ 141 142 def __init__( 143 self, 144 vector_field: ArrayT, 145 index_transform: Transform[ArrayT] | None = None, 146 interpolation_order: int = 3, 147 vector_axis: int = -1, 148 *, 149 spaces: Spaces = Spaces(None, None), 150 ): 151 """Use the input coordinates as array indices to look up output coordinates. 152 153 For input coordinate `(a, b, c)`, the output coordinate is `coordinates[a, b, c, :]`. 154 155 Input coordinates outside of the `vector_field` array return `NaN` output coordinates. 156 157 Parameters 158 ---------- 159 vector_field 160 Array with `Di + 1` dimensions, where `Di` is the input dimensionality. 161 index_transform 162 Transform the source coordinates into an array index 163 interpolation_order 164 Order of the spline interpolation used for coordinates which are not integer array indices. 165 vector_axis 166 Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). 167 spaces 168 References for source and target spaces 169 """ 170 super().__init__( 171 vector_field, 172 index_transform, 173 interpolation_order, 174 vector_axis, 175 spaces=spaces, 176 ) 177 178 def apply(self, coords: ArrayT) -> ArrayT: 179 return self._get_vectors(coords)
Look up the output coordinates in an array.
For input coordinate (a, b, c) and vector_axis=-1,
the output coordinate is `vector_field[a, b, c, :].
Input coordinates outside the vector field return NaN.
REQUIRES: vectorfield extra for in-memory,
or vectorfield-dask extra for lazy chunked vector fields.
142 def __init__( 143 self, 144 vector_field: ArrayT, 145 index_transform: Transform[ArrayT] | None = None, 146 interpolation_order: int = 3, 147 vector_axis: int = -1, 148 *, 149 spaces: Spaces = Spaces(None, None), 150 ): 151 """Use the input coordinates as array indices to look up output coordinates. 152 153 For input coordinate `(a, b, c)`, the output coordinate is `coordinates[a, b, c, :]`. 154 155 Input coordinates outside of the `vector_field` array return `NaN` output coordinates. 156 157 Parameters 158 ---------- 159 vector_field 160 Array with `Di + 1` dimensions, where `Di` is the input dimensionality. 161 index_transform 162 Transform the source coordinates into an array index 163 interpolation_order 164 Order of the spline interpolation used for coordinates which are not integer array indices. 165 vector_axis 166 Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). 167 spaces 168 References for source and target spaces 169 """ 170 super().__init__( 171 vector_field, 172 index_transform, 173 interpolation_order, 174 vector_axis, 175 spaces=spaces, 176 )
Use the input coordinates as array indices to look up output coordinates.
For input coordinate (a, b, c), the output coordinate is coordinates[a, b, c, :].
Input coordinates outside of the vector_field array return NaN output coordinates.
Parameters
- vector_field: Array with
Di + 1dimensions, whereDiis the input dimensionality. - index_transform: Transform the source coordinates into an array index
- interpolation_order: Order of the spline interpolation used for coordinates which are not integer array indices.
- vector_axis: Which axis of the
vector_fieldcontains the vector values; defaults to the last (-1). - spaces: References for source and target spaces
182class Displacements(BaseVectorField[ArrayT]): 183 """Look up a translation in an array and apply it to the input coordinates. 184 185 For input coordinate `(a, b, c)` and `vector_axis=-1`, 186 the output coordinate is `(a, b, c) + vector_field[a, b, c, :]. 187 188 Input coordinates outside the vector field return NaN. 189 190 REQUIRES: `vectorfield` extra for in-memory, 191 or `vectorfield-dask` extra for lazy chunked vector fields. 192 """ 193 194 def __init__( 195 self, 196 vector_field: ArrayT, 197 index_transform: Transform[ArrayT] | None = None, 198 interpolation_order: int = 3, 199 vector_axis: int = -1, 200 *, 201 spaces: Spaces = Spaces(None, None), 202 ): 203 """ 204 Parameters 205 ---------- 206 vector_field 207 Array with `Di + 1` dimensions, where `Di` is the input dimensionality. 208 index_transform 209 Transformation from source coordinate to array indices. 210 interpolation_order 211 Order of the spline interpolation used for coordinates which are not integer array indices. 212 vector_axis 213 Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). 214 spaces 215 References for source and target spaces 216 217 Raises 218 ------ 219 ValueError 220 If the index transform and vector field would change the coordinates' dimensionality, 221 or the index transform's dimensionality does not match the vector field's. 222 """ 223 super().__init__( 224 vector_field, 225 index_transform, 226 interpolation_order, 227 vector_axis, 228 spaces=spaces, 229 ) 230 if self.ndims.source != self.ndims.target: 231 raise ValueError("Displacements cannot change dimensionality") 232 233 def apply(self, coords: ArrayT) -> ArrayT: 234 coords = self._validate_coords(coords) 235 vecs = self._get_vectors(coords) 236 return coords + vecs # type:ignore
Look up a translation in an array and apply it to the input coordinates.
For input coordinate (a, b, c) and vector_axis=-1,
the output coordinate is `(a, b, c) + vector_field[a, b, c, :].
Input coordinates outside the vector field return NaN.
REQUIRES: vectorfield extra for in-memory,
or vectorfield-dask extra for lazy chunked vector fields.
194 def __init__( 195 self, 196 vector_field: ArrayT, 197 index_transform: Transform[ArrayT] | None = None, 198 interpolation_order: int = 3, 199 vector_axis: int = -1, 200 *, 201 spaces: Spaces = Spaces(None, None), 202 ): 203 """ 204 Parameters 205 ---------- 206 vector_field 207 Array with `Di + 1` dimensions, where `Di` is the input dimensionality. 208 index_transform 209 Transformation from source coordinate to array indices. 210 interpolation_order 211 Order of the spline interpolation used for coordinates which are not integer array indices. 212 vector_axis 213 Which axis of the `vector_field` contains the vector values; defaults to the last (`-1`). 214 spaces 215 References for source and target spaces 216 217 Raises 218 ------ 219 ValueError 220 If the index transform and vector field would change the coordinates' dimensionality, 221 or the index transform's dimensionality does not match the vector field's. 222 """ 223 super().__init__( 224 vector_field, 225 index_transform, 226 interpolation_order, 227 vector_axis, 228 spaces=spaces, 229 ) 230 if self.ndims.source != self.ndims.target: 231 raise ValueError("Displacements cannot change dimensionality")
Parameters
- vector_field: Array with
Di + 1dimensions, whereDiis the input dimensionality. - index_transform: Transformation from source coordinate to array indices.
- interpolation_order: Order of the spline interpolation used for coordinates which are not integer array indices.
- vector_axis: Which axis of the
vector_fieldcontains the vector values; defaults to the last (-1). - spaces: References for source and target spaces
Raises
- ValueError: If the index transform and vector field would change the coordinates' dimensionality, or the index transform's dimensionality does not match the vector field's.
233 def apply(self, coords: ArrayT) -> ArrayT: 234 coords = self._validate_coords(coords) 235 vecs = self._get_vectors(coords) 236 return coords + vecs # type:ignore
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
17class MovingLeastSquares(Transform[np.ndarray]): 18 """Moving least squares transformation. 19 20 Deform based on a matched pairs of source and target control points; see <https://dl.acm.org/doi/10.1145/1141911.1141920> 21 22 REQUIRES: `movingleastsquares` extra. 23 """ 24 25 def __init__( 26 self, 27 source_control_points: np.ndarray, 28 target_control_points: np.ndarray, 29 *, 30 spaces: Spaces = Spaces(None, None), 31 ): 32 """Non-rigid transforms powered by molesq package. 33 34 Parameters 35 ---------- 36 source_control_points 37 NxD array of control point coordinates in the source space. 38 target_control_points 39 NxD array of coordinates of the corresponding control points 40 in the target (deformed) space. 41 spaces 42 Optional source and target spaces 43 """ 44 from molesq.transform import Transformer 45 46 s = as_floats(source_control_points) 47 t = as_floats(target_control_points) 48 self._transformer = Transformer(s, t) 49 super().__init__( 50 NDims( 51 s.shape[1], 52 t.shape[1], 53 ), 54 spaces=spaces, 55 ) 56 57 def apply(self, coords: np.ndarray) -> np.ndarray: 58 coords = self._validate_coords(coords) 59 return self._transformer.transform(coords) 60 61 def is_identity(self) -> bool: 62 xp = array_namespace(self._transformer.control_points) 63 return xp.all( 64 xp.equal( 65 self._transformer.control_points, 66 self._transformer.deformed_control_points, 67 ) 68 ) 69 70 def invert(self) -> Self | None: 71 return type(self)( 72 self._transformer.deformed_control_points, 73 self._transformer.control_points, 74 spaces=self.spaces.invert(), 75 )
Moving least squares transformation.
Deform based on a matched pairs of source and target control points; see https://dl.acm.org/doi/10.1145/1141911.1141920
REQUIRES: movingleastsquares extra.
25 def __init__( 26 self, 27 source_control_points: np.ndarray, 28 target_control_points: np.ndarray, 29 *, 30 spaces: Spaces = Spaces(None, None), 31 ): 32 """Non-rigid transforms powered by molesq package. 33 34 Parameters 35 ---------- 36 source_control_points 37 NxD array of control point coordinates in the source space. 38 target_control_points 39 NxD array of coordinates of the corresponding control points 40 in the target (deformed) space. 41 spaces 42 Optional source and target spaces 43 """ 44 from molesq.transform import Transformer 45 46 s = as_floats(source_control_points) 47 t = as_floats(target_control_points) 48 self._transformer = Transformer(s, t) 49 super().__init__( 50 NDims( 51 s.shape[1], 52 t.shape[1], 53 ), 54 spaces=spaces, 55 )
Non-rigid transforms powered by molesq package.
Parameters
- source_control_points: NxD array of control point coordinates in the source space.
- target_control_points: NxD array of coordinates of the corresponding control points in the target (deformed) space.
- spaces: Optional source and target spaces
57 def apply(self, coords: np.ndarray) -> np.ndarray: 58 coords = self._validate_coords(coords) 59 return self._transformer.transform(coords)
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.
19class ThinPlateSplines(Transform[np.ndarray]): 20 """Thin plate splines transforms. 21 22 Deform based on matched pairs of control points. 23 24 REQUIRES: `thinplatesplines` extra. 25 """ 26 27 def __init__( 28 self, 29 source_control_points: np.ndarray, 30 target_control_points: np.ndarray, 31 *, 32 spaces: Spaces = Spaces(None, None), 33 ): 34 """Non-rigid control point based transforms in 2/3D. 35 36 Adapted from 37 https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py 38 39 Parameters 40 ---------- 41 source_control_points 42 NxD array of control point coordinates in the source space. 43 target_control_points 44 NxD array of control point coordinates in the target (deformed) space. 45 spaces 46 Optional source and target spaces 47 48 Raises 49 ------ 50 ValueError 51 Invalid control points. 52 """ 53 import morphops 54 55 self.source_control_points = as_floats(source_control_points) 56 self.target_control_points = as_floats(target_control_points) 57 58 if self.source_control_points.shape != self.target_control_points.shape: 59 raise ValueError("Control point arrays must be the same shape") 60 61 if self.source_control_points.ndim != 2: 62 raise ValueError("Control points array must be 2D") 63 64 ndim = self.source_control_points.shape[1] 65 66 self.W, self.A = morphops.tps_coefs( 67 self.source_control_points, 68 self.target_control_points, 69 ) 70 super().__init__(NDims(ndim, ndim), spaces=spaces) 71 72 def invert(self) -> Transform[np.ndarray] | None: 73 return type(self)( 74 self.target_control_points, 75 self.source_control_points, 76 spaces=self.spaces.invert(), 77 ) 78 79 def apply(self, coords: np.ndarray) -> np.ndarray: 80 import morphops 81 82 coords = self._validate_coords(coords) 83 U = morphops.K_matrix(coords, self.source_control_points) 84 P = morphops.P_matrix(coords) 85 # The warped pts are the affine part + the non-uniform part 86 return P @ self.A + U @ self.W
Thin plate splines transforms.
Deform based on matched pairs of control points.
REQUIRES: thinplatesplines extra.
27 def __init__( 28 self, 29 source_control_points: np.ndarray, 30 target_control_points: np.ndarray, 31 *, 32 spaces: Spaces = Spaces(None, None), 33 ): 34 """Non-rigid control point based transforms in 2/3D. 35 36 Adapted from 37 https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py 38 39 Parameters 40 ---------- 41 source_control_points 42 NxD array of control point coordinates in the source space. 43 target_control_points 44 NxD array of control point coordinates in the target (deformed) space. 45 spaces 46 Optional source and target spaces 47 48 Raises 49 ------ 50 ValueError 51 Invalid control points. 52 """ 53 import morphops 54 55 self.source_control_points = as_floats(source_control_points) 56 self.target_control_points = as_floats(target_control_points) 57 58 if self.source_control_points.shape != self.target_control_points.shape: 59 raise ValueError("Control point arrays must be the same shape") 60 61 if self.source_control_points.ndim != 2: 62 raise ValueError("Control points array must be 2D") 63 64 ndim = self.source_control_points.shape[1] 65 66 self.W, self.A = morphops.tps_coefs( 67 self.source_control_points, 68 self.target_control_points, 69 ) 70 super().__init__(NDims(ndim, ndim), spaces=spaces)
Non-rigid control point based transforms in 2/3D.
Adapted from https://github.com/schlegelp/navis/blob/master/navis/transforms/thinplate.py
Parameters
- source_control_points: NxD array of control point coordinates in the source space.
- target_control_points: NxD array of control point coordinates in the target (deformed) space.
- spaces: Optional source and target spaces
Raises
- ValueError: Invalid control points.
72 def invert(self) -> Transform[np.ndarray] | None: 73 return type(self)( 74 self.target_control_points, 75 self.source_control_points, 76 spaces=self.spaces.invert(), 77 )
Invert the transformation, returning None if not possible.
79 def apply(self, coords: np.ndarray) -> np.ndarray: 80 import morphops 81 82 coords = self._validate_coords(coords) 83 U = morphops.K_matrix(coords, self.source_control_points) 84 P = morphops.P_matrix(coords) 85 # The warped pts are the affine part + the non-uniform part 86 return P @ self.A + U @ self.W
Apply transformation.
Parameters
- coords: NxD array of N D-dimensional coordinates.
Returns
- ArrayT: Transformed coordinates in the same shape.