resampler
resampler: fast differentiable resizing and warping of arbitrary grids.
Hugues Hoppe 2026
[Open in Colab] [Kaggle] [MyBinder] [DeepNote] [GitHub source] [API docs] [PyPI package]
The notebook resampler_notebook.ipynb demonstrates the resampler library and contains documentation, usage examples, unit tests, and experiments.
Overview
The resampler library enables fast differentiable resizing and warping of arbitrary grids.
It supports:
grids of any dimension (e.g., 1D, 2D images, 3D video, 4D batches of videos), containing
samples of any shape (e.g., scalars, colors, motion vectors, Jacobian matrices) and
any numeric type (e.g.,
uint8,float64,complex128)within several array libraries (
numpy,torch, andjax);either
'dual'("half-integer") or'primal'grid-type for each dimension;many boundary rules, specified per dimension, extensible via subclassing;
an extensible set of filter kernels, selectable per dimension;
optional gamma transfer functions for correct linear-space filtering;
prefiltering for accurate antialiasing when
resizedownsampling;efficient backpropagation of gradients for
torchandjax;few dependencies (only
numpyandscipy) and no C extension code, yetfaster resizing than C++ implementation in
torch.nn.
A key strategy is to leverage existing sparse matrix representations and operations.
Example usage
!pip install -q mediapy numpy resampler
import mediapy as media
import numpy as np
import resampler
array = np.random.default_rng(1).random((4, 6, 3)) # 4x6 RGB image.
upsampled = resampler.resize(array, (128, 192)) # To 128x192 resolution.
media.show_images({'4x6': array, '128x192': upsampled}, height=128)
image = media.read_image('https://github.com/hhoppe/data/raw/main/image.png')
downsampled = resampler.resize(image, (32, 32))
media.show_images({'128x128': image, '32x32': downsampled}, height=128)
import matplotlib.pyplot as plt
array = np.array([3.0, 5.0, 8.0, 7.0]) # 4 source samples in 1D.
new_dual = resampler.resize(array, (32,)) # (default gridtype='dual') 8x resolution.
new_primal = resampler.resize(array, (25,), gridtype='primal') # 8x resolution.
_, axs = plt.subplots(1, 2, figsize=(7, 1.5))
axs[0].set_title("gridtype='dual'")
axs[0].plot((np.arange(len(array)) + 0.5) / len(array), array, 'o')
axs[0].plot((np.arange(len(new_dual)) + 0.5) / len(new_dual), new_dual, '.')
axs[1].set_title("gridtype='primal'")
axs[1].plot(np.arange(len(array)) / (len(array) - 1), array, 'o')
axs[1].plot(np.arange(len(new_primal)) / (len(new_primal) - 1), new_primal, '.')
plt.show()
batch_size = 4
batch_of_images = media.moving_circle((16, 16), batch_size)
upsampled = resampler.resize(batch_of_images, (batch_size, 64, 64))
media.show_videos({'original': batch_of_images, 'upsampled': upsampled}, fps=1)
original
upsampled
Most examples above use the default
resize() settings:
gridtype='dual'for both source and destination arrays,boundary='auto'which uses'reflect'for upsampling and'clamp'for downsampling,filter='lanczos3'(a Lanczos kernel with radius 3),gamma=Nonewhich by default uses the'power2'transfer function for theuint8image in the second example,scale=1.0, translate=0.0(no domain transformation),- default
precisionand outputdtype.
Advanced usage:
Map an image to a wider grid using custom scale and translate vectors,
with horizontal 'reflect' and vertical 'natural' boundary rules,
providing a constant value for the exterior,
using different filters (Lanczos and O-MOMS) in the two dimensions,
disabling gamma correction, performing computations in double-precision,
and returning an output array in single-precision:
new = resampler.resize(
image, (128, 512), boundary=('natural', 'reflect'), cval=(0.2, 0.7, 0.3),
filter=('lanczos3', 'omoms5'), gamma='identity', scale=(0.8, 0.25),
translate=(0.1, 0.35), precision='float64', dtype='float32')
media.show_images({'image': image, 'new': new})
Warp an image by transforming it using polar coordinates:
shape = image.shape[:2]
yx = ((np.indices(shape).T + 0.5) / shape - 0.5).T # [-0.5, 0.5]^2
radius, angle = np.linalg.norm(yx, axis=0), np.arctan2(*yx)
angle += (0.8 - radius).clip(0, 1) * 2.0 - 0.6
coords = np.dstack((np.sin(angle) * radius, np.cos(angle) * radius)) + 0.5
resampled = resampler.resample(image, coords, boundary='constant')
media.show_images({'image': image, 'resampled': resampled})
Limitations:
- Filters are assumed to be separable.
- Although
resizeimplements prefiltering,resampledoes not yet have it (and therefore may have aliased results if downsampling). - Differentiability is only with respect to the grid values, not wrt the resize shape, scale, translation, or the resampling coordinates.
1"""resampler: fast differentiable resizing and warping of arbitrary grids. 2 3.. include:: ../README.md 4""" 5 6from __future__ import annotations 7 8# Note that pydoc uses the module docstring in both __init__.py (for section headings) and 9# __init__.pyi (for the actual content)! 10 11__docformat__ = 'google' 12__version__ = '1.1.0' 13__version_info__ = tuple(int(num) for num in __version__.split('.')) 14 15import abc 16import dataclasses 17import functools 18import importlib 19import itertools 20import math 21import os 22import sys 23import types 24import typing 25from collections.abc import Callable, Iterable, Sequence 26from typing import Any, Generic, Literal, TypeAlias, TypeVar 27 28import numpy as np 29import numpy.typing as npt 30import scipy.interpolate 31import scipy.linalg 32import scipy.ndimage 33import scipy.sparse 34import scipy.sparse.linalg 35 36 37def _noop_decorator(*args: Any, **kwargs: Any) -> Any: 38 """Return function decorated with no-operation; invocable with or without args.""" 39 if len(args) != 1 or not callable(args[0]) or kwargs: 40 return _noop_decorator # Decorator is invoked with arguments; ignore them. 41 func: Callable[..., Any] = args[0] 42 return func 43 44 45try: 46 import numba 47except ModuleNotFoundError: 48 numba = sys.modules['numba'] = types.ModuleType('numba') 49 numba.njit = _noop_decorator # type: ignore[attr-defined] 50_USING_NUMBA = hasattr(numba, 'jit') 51 52if TYPE_CHECKING: 53 import jax.experimental.sparse 54 import jax.numpy 55 import torch 56 57 _DType: TypeAlias = np.dtype[Any] 58 _NDArray: TypeAlias = npt.NDArray[Any] 59 _DTypeLike: TypeAlias = npt.DTypeLike 60 _ArrayLike: TypeAlias = npt.ArrayLike 61 _TorchTensor: TypeAlias = torch.Tensor 62 _JaxArray: TypeAlias = jax.numpy.ndarray 63 64else: 65 # Typically, create named types for use in the `pdoc` documentation. 66 # But here, these are superseded by the declarations in __init__.pyi! 67 _DType: TypeAlias = Any 68 _NDArray: TypeAlias = Any 69 _DTypeLike: TypeAlias = Any 70 _ArrayLike: TypeAlias = Any 71 _TorchTensor: TypeAlias = Any 72 _JaxArray: TypeAlias = Any 73 74_Array = TypeVar('_Array', _NDArray, _TorchTensor, _JaxArray) 75_AnyArray = _NDArray | _TorchTensor | _JaxArray 76 77 78def _check_eq(a: Any, b: Any, /) -> None: 79 """If the two values or arrays are not equal, raise an exception with a useful message.""" 80 are_equal = np.all(a == b) if isinstance(a, np.ndarray) else a == b 81 if not are_equal: 82 raise AssertionError(f'{a!r} == {b!r}') 83 84 85def _real_precision(dtype: _DTypeLike, /) -> _DType: 86 """Return the type of the real part of a complex number.""" 87 return np.array([], dtype).real.dtype 88 89 90def _complex_precision(dtype: _DTypeLike, /) -> _DType: 91 """Return a complex type to represent a non-complex type.""" 92 return np.result_type(dtype, np.complex64) 93 94 95def _get_precision( 96 precision: _DTypeLike | None, dtypes: list[_DType], weight_dtypes: list[_DType], / 97) -> _DType: 98 """Return dtype based on desired precision or on data and weight types.""" 99 precision2 = np.dtype( 100 precision if precision is not None else np.result_type(np.float32, *dtypes, *weight_dtypes) 101 ) 102 if not np.issubdtype(precision2, np.inexact): 103 raise ValueError(f'Precision {precision2} is not floating or complex.') 104 check_complex = [precision2, *dtypes] 105 is_complex = [np.issubdtype(dtype, np.complexfloating) for dtype in check_complex] 106 if len(set(is_complex)) != 1: 107 s_types = ','.join(str(dtype) for dtype in check_complex) 108 raise ValueError(f'Types {s_types} must be all real or all complex.') 109 return precision2 110 111 112def _sinc(x: _ArrayLike, /) -> _NDArray: 113 """Return the value `np.sinc(x)` but improved to: 114 (1) ignore underflow that occurs at 0.0 for np.float32, and 115 (2) output exact zero for integer input values. 116 117 >>> _sinc(np.array([-3, -2, -1, 0], np.float32)) 118 array([0., 0., 0., 1.], dtype=float32) 119 120 >>> _sinc(np.array([-3, -2, -1, 0])) 121 array([0., 0., 0., 1.]) 122 123 >>> _sinc(0) 124 1.0 125 """ 126 x = np.asarray(x) 127 x_is_scalar = x.ndim == 0 128 with np.errstate(under='ignore'): 129 result = np.sinc(np.atleast_1d(x)) 130 result[x == np.floor(x)] = 0.0 131 result[x == 0] = 1.0 132 return result.item() if x_is_scalar else result 133 134 135def _is_symmetric(matrix: Any, /, tol: float = 1e-10) -> bool: 136 """Return True if the sparse matrix is symmetric.""" 137 norm = scipy.sparse.linalg.norm(matrix - matrix.transpose(), np.inf) 138 return bool(norm <= tol) 139 140 141def _cache_sampled_1d_function( 142 xmin: float, 143 xmax: float, 144 *, 145 num_samples: int = 3_600, 146 enable: bool = True, 147) -> Callable[[Callable[[_ArrayLike], _NDArray]], Callable[[_ArrayLike], _NDArray]]: 148 """Function decorator to linearly interpolate cached function values.""" 149 # Speed unchanged up to num_samples=12_000, then slow decrease until 100_000. 150 151 def wrap_it(func: Callable[[_ArrayLike], _NDArray]) -> Callable[[_ArrayLike], _NDArray]: 152 if not enable: 153 return func 154 155 dx = (xmax - xmin) / num_samples 156 x = np.linspace(xmin, xmax + dx, num_samples + 2, dtype=np.float32) 157 samples_func = func(x) 158 assert np.all(samples_func[[0, -1, -2]] == 0.0) 159 160 @functools.wraps(func) 161 def interpolate_using_cached_samples(x: _ArrayLike) -> _NDArray: 162 x = np.asarray(x) 163 index_float = np.clip((x - xmin) / dx, 0.0, num_samples) 164 index = index_float.astype(np.int64) 165 frac = np.subtract(index_float, index, dtype=np.float32) 166 return (1 - frac) * samples_func[index] + frac * samples_func[index + 1] 167 168 return interpolate_using_cached_samples 169 170 return wrap_it 171 172 173class _DownsampleIn2dUsingBoxFilter: 174 """Fast 2D box-filter downsampling using cached numba-jitted functions.""" 175 176 def __init__(self) -> None: 177 # Downsampling function for params (dtype, block_height, block_width, ch). 178 self._jitted_function: dict[tuple[_DType, int, int, int], Callable[[_NDArray], _NDArray]] = {} 179 180 def __call__(self, array: _NDArray, shape: tuple[int, int]) -> _NDArray: 181 assert _USING_NUMBA 182 assert array.ndim in (2, 3), array.ndim 183 _check_eq(len(shape), 2) 184 dtype = array.dtype 185 a = array[..., None] if array.ndim == 2 else array 186 height, width, ch = a.shape 187 new_height, new_width = shape 188 if height % new_height != 0 or width % new_width != 0: 189 raise ValueError(f'Shape {array.shape} not a multiple of {shape}.') 190 block_height, block_width = height // new_height, width // new_width 191 192 def func(array: _NDArray) -> _NDArray: 193 new_height = array.shape[0] // block_height 194 new_width = array.shape[1] // block_width 195 result = np.empty((new_height, new_width, ch), dtype) 196 totals = np.empty(ch, dtype) 197 factor = dtype.type(1.0 / (block_height * block_width)) 198 for y in numba.prange(new_height): # pylint: disable=not-an-iterable 199 for x in range(new_width): 200 # Introducing "y2, x2 = y * block_height, x * block_width" is actually slower. 201 if ch == 1: # All the branches involve compile-time constants. 202 total = dtype.type(0.0) 203 for yy in range(block_height): 204 for xx in range(block_width): 205 total += array[y * block_height + yy, x * block_width + xx, 0] 206 result[y, x, 0] = total * factor 207 elif ch == 3: 208 total0 = total1 = total2 = dtype.type(0.0) 209 for yy in range(block_height): 210 for xx in range(block_width): 211 total0 += array[y * block_height + yy, x * block_width + xx, 0] 212 total1 += array[y * block_height + yy, x * block_width + xx, 1] 213 total2 += array[y * block_height + yy, x * block_width + xx, 2] 214 result[y, x, 0] = total0 * factor 215 result[y, x, 1] = total1 * factor 216 result[y, x, 2] = total2 * factor 217 elif block_height * block_width >= 9: 218 for c in range(ch): 219 totals[c] = 0.0 220 for yy in range(block_height): 221 for xx in range(block_width): 222 for c in range(ch): 223 totals[c] += array[y * block_height + yy, x * block_width + xx, c] 224 for c in range(ch): 225 result[y, x, c] = totals[c] * factor 226 else: 227 for c in range(ch): 228 total = dtype.type(0.0) 229 for yy in range(block_height): 230 for xx in range(block_width): 231 total += array[y * block_height + yy, x * block_width + xx, c] 232 result[y, x, c] = total * factor 233 return result 234 235 signature = dtype, block_height, block_width, ch 236 jitted_function = self._jitted_function.get(signature) 237 if not jitted_function: 238 if 0: 239 print(f'Creating numba jit-wrapper for {signature}.') 240 jitted_function = numba.njit(func, parallel=True, fastmath=True, cache=True) 241 self._jitted_function[signature] = jitted_function 242 243 try: 244 result = jitted_function(a) 245 except RuntimeError: 246 message = ( 247 'resampler: This runtime error may be due to a corrupt resampler/__pycache__;' 248 ' try deleting that directory.' 249 ) 250 print(message, file=sys.stdout, flush=True) 251 print(message, file=sys.stderr, flush=True) 252 raise 253 254 return result[..., 0] if array.ndim == 2 else result 255 256 257_downsample_in_2d_using_box_filter = _DownsampleIn2dUsingBoxFilter() 258 259 260@numba.njit(nogil=True, fastmath=True, cache=True) # type: ignore[untyped-decorator] 261def _numba_serial_csr_dense_mult( 262 indptr: _NDArray, 263 indices: _NDArray, 264 data: _NDArray, 265 src: _NDArray, 266 dst: _NDArray, 267) -> None: 268 """Faster version of scipy.sparse._sparsetools.csr_matvecs(). 269 270 The single-threaded numba-jitted code is about 2x faster than the scipy C++. 271 """ 272 assert indptr.ndim == indices.ndim == data.ndim == 1 and src.ndim == dst.ndim == 2 273 assert len(indptr) == dst.shape[0] + 1 and src.shape[1] == dst.shape[1] 274 acc = data[0] * src[0] # Dummy initialization value, to infer correct shape and dtype. 275 for i in range(dst.shape[0]): 276 acc[:] = 0 277 for jj in range(indptr[i], indptr[i + 1]): 278 j = indices[jj] 279 acc += data[jj] * src[j] 280 dst[i] = acc 281 282 283# I tried using the "minimal" "parallel=" config but this did not result in any jit speedup: 284# parallel=dict(comprehension=False, prange=True, numpy=True, reduction=False, 285# setitem=False, stencil=False, fusion=False) 286@numba.njit(parallel=True, fastmath=True, cache=True) # type: ignore[untyped-decorator] 287def _numba_parallel_csr_dense_mult( 288 indptr: _NDArray, 289 indices: _NDArray, 290 data: _NDArray, 291 src: _NDArray, 292 dst: _NDArray, 293) -> None: 294 """Faster version of scipy.sparse._sparsetools.csr_matvecs(). 295 296 The single-threaded numba-jitted code is about 2x faster than the scipy C++. 297 The introduction of parallel omp threads provides another 2-4x speedup. 298 However, "parallel=True" leads to slow jitting (~3 s), so we cache the jitted code on disk. 299 """ 300 assert indptr.ndim == indices.ndim == data.ndim == 1 and src.ndim == dst.ndim == 2 301 assert len(indptr) == dst.shape[0] + 1 and src.shape[1] == dst.shape[1] 302 acc0 = data[0] * src[0] # Dummy initialization value, to infer correct shape and dtype. 303 # Default is static scheduling, which is fine. 304 for i in numba.prange(dst.shape[0]): # pylint: disable=not-an-iterable 305 acc = np.zeros_like(acc0) # Numba automatically hoists the allocation outside the loop. 306 for jj in range(indptr[i], indptr[i + 1]): 307 j = indices[jj] 308 acc += data[jj] * src[j] 309 dst[i] = acc 310 311 312@dataclasses.dataclass 313class _Arraylib(abc.ABC, Generic[_Array]): 314 """Abstract base class for abstraction of array libraries.""" 315 316 arraylib: str 317 """Name of array library (e.g., `'numpy'`, `'torch'`, `'jax'`).""" 318 319 array: _Array 320 321 @staticmethod 322 @abc.abstractmethod 323 def recognize(array: Any) -> bool: 324 """Return True if `array` is recognized by this _Arraylib.""" 325 326 @abc.abstractmethod 327 def numpy(self) -> _NDArray: 328 """Return a `numpy` version of `self.array`.""" 329 330 @abc.abstractmethod 331 def dtype(self) -> _DType: 332 """Return the equivalent of `self.array.dtype` as a `numpy` `dtype`.""" 333 334 @abc.abstractmethod 335 def astype(self, dtype: _DTypeLike) -> _Array: 336 """Return the equivalent of `self.array.astype(dtype, copy=False)` with `numpy` `dtype`.""" 337 338 def reshape(self, shape: tuple[int, ...]) -> _Array: 339 """Return the equivalent of `self.array.reshape(shape)`.""" 340 array: Any = self.array 341 return array.reshape(shape) 342 343 def possibly_make_contiguous(self) -> _Array: 344 """Return a contiguous copy of `self.array` or just `self.array` if already contiguous.""" 345 return self.array 346 347 @abc.abstractmethod 348 def clip(self, low: Any, high: Any, dtype: _DTypeLike | None = None) -> _Array: 349 """Return the equivalent of `self.array.clip(low, high, dtype=dtype)` with `numpy` `dtype`.""" 350 351 @abc.abstractmethod 352 def square(self) -> _Array: 353 """Return the equivalent of `np.square(self.array)`.""" 354 355 @abc.abstractmethod 356 def sqrt(self) -> _Array: 357 """Return the equivalent of `np.sqrt(self.array)`.""" 358 359 def getitem(self, indices: Any) -> _Array: 360 """Return the equivalent of `self.array[indices]` (a "gather" operation).""" 361 array: Any = self.array 362 return array[indices] 363 364 @abc.abstractmethod 365 def where(self, if_true: Any, if_false: Any) -> _Array: 366 """Return the equivalent of `np.where(self.array, if_true, if_false)`.""" 367 368 @abc.abstractmethod 369 def transpose(self, axes: Sequence[int]) -> _Array: 370 """Return the equivalent of `np.transpose(self.array, axes)`.""" 371 372 @abc.abstractmethod 373 def best_dims_order_for_resize(self, dst_shape: tuple[int, ...]) -> list[int]: 374 """Return the best order in which to process dims for resizing `self.array` to `dst_shape`.""" 375 376 @abc.abstractmethod 377 def premult_with_sparse(self, sparse: Any, num_threads: int | Literal['auto']) -> _Array: 378 """Return the multiplication of the `sparse` matrix and `self.array`.""" 379 380 @staticmethod 381 @abc.abstractmethod 382 def concatenate(arrays: Sequence[_Array], axis: int) -> _Array: 383 """Return the equivalent of `np.concatenate(arrays, axis)`.""" 384 385 @staticmethod 386 @abc.abstractmethod 387 def einsum(subscripts: str, *operands: _Array) -> _Array: 388 """Return the equivalent of `np.einsum(subscripts, *operands, optimize=True)`.""" 389 390 @staticmethod 391 @abc.abstractmethod 392 def make_sparse_matrix( 393 data: _NDArray, row_ind: _NDArray, col_ind: _NDArray, shape: tuple[int, int] 394 ) -> Any: 395 """Return the equivalent of `scipy.sparse.csr_matrix(data, (row_ind, col_ind), shape=shape)`. 396 However, the indices must be ordered and unique.""" 397 398 399class _NumpyArraylib(_Arraylib[_NDArray]): 400 """Numpy implementation of the array abstraction.""" 401 402 # pylint: disable=missing-function-docstring 403 404 def __init__(self, array: _NDArray) -> None: 405 super().__init__(arraylib='numpy', array=np.asarray(array)) 406 407 @staticmethod 408 def recognize(array: Any) -> bool: 409 return isinstance(array, (np.ndarray, np.number)) 410 411 def numpy(self) -> _NDArray: 412 return self.array 413 414 def dtype(self) -> _DType: 415 dtype: _DType = self.array.dtype 416 return dtype 417 418 def astype(self, dtype: _DTypeLike) -> _NDArray: 419 return self.array.astype(dtype, copy=False) 420 421 def clip(self, low: Any, high: Any, dtype: _DTypeLike | None = None) -> _NDArray: 422 return self.array.clip(low, high, dtype=dtype) 423 424 def square(self) -> _NDArray: 425 return np.square(self.array) 426 427 def sqrt(self) -> _NDArray: 428 return np.sqrt(self.array) 429 430 def where(self, if_true: Any, if_false: Any) -> _NDArray: 431 condition = self.array 432 return np.where(condition, if_true, if_false) 433 434 def transpose(self, axes: Sequence[int]) -> _NDArray: 435 return np.transpose(self.array, tuple(axes)) 436 437 def best_dims_order_for_resize(self, dst_shape: tuple[int, ...]) -> list[int]: 438 # Our heuristics: (1) a dimension with small scaling (especially minification) gets priority, 439 # and (2) timings show preference to resizing dimensions with larger strides first. 440 # The optimal ordering might be related to the logic in np.einsum_path(). (Unfortunately, 441 # np.einsum() does not support the sparse multiplications that we require here.) 442 src_shape: tuple[int, ...] = self.array.shape[: len(dst_shape)] 443 strides: Sequence[int] = self.array.strides 444 largest_stride_dim = max(range(len(src_shape)), key=lambda dim: strides[dim]) 445 446 def priority(dim: int) -> float: 447 scaling = dst_shape[dim] / src_shape[dim] 448 return scaling * ((0.49 if scaling < 1.0 else 0.65) if dim == largest_stride_dim else 1.0) 449 450 return sorted(range(len(src_shape)), key=priority) 451 452 def premult_with_sparse( 453 self, sparse: scipy.sparse.csr_matrix, num_threads: int | Literal['auto'] 454 ) -> _NDArray: 455 assert self.array.ndim == sparse.ndim == 2 and sparse.shape[1] == self.array.shape[0] 456 # Empirically faster than with default numba.config.NUMBA_NUM_THREADS (e.g., 24). 457 if _USING_NUMBA: 458 num_threads2 = min(6, os.cpu_count() or 1) if num_threads == 'auto' else num_threads 459 src = np.ascontiguousarray(self.array) # Like .ravel() in _mul_multivector(). 460 dtype = np.result_type(sparse.dtype, src.dtype) 461 dst = np.empty((sparse.shape[0], src.shape[1]), dtype) 462 num_scalar_multiplies = len(sparse.data) * src.shape[1] 463 is_small_size = num_scalar_multiplies < 200_000 464 if is_small_size or num_threads2 == 1: 465 _numba_serial_csr_dense_mult(sparse.indptr, sparse.indices, sparse.data, src, dst) 466 else: 467 numba.set_num_threads(num_threads2) 468 _numba_parallel_csr_dense_mult(sparse.indptr, sparse.indices, sparse.data, src, dst) 469 return dst 470 471 # Note that sicpy.sparse does not use multithreading. The "@" operation 472 # calls _spbase.__matmul__() -> _spbase._mul_dispatch() -> _cs_matrix._mul_multivector() -> 473 # scipy.sparse._sparsetools.csr_matvecs() in 474 # https://github.com/scipy/scipy/blob/main/scipy/sparse/sparsetools/csr.h 475 # which iteratively calls the (in theory, LEVEL 1 BLAS) function axpy() in 476 # https://github.com/scipy/scipy/blob/main/scipy/sparse/sparsetools/dense.h 477 return sparse @ self.array 478 479 @staticmethod 480 def concatenate(arrays: Sequence[_NDArray], axis: int) -> _NDArray: 481 return np.concatenate(arrays, axis) 482 483 @staticmethod 484 def einsum(subscripts: str, *operands: _NDArray) -> _NDArray: 485 return np.einsum(subscripts, *operands, optimize=True) 486 487 @staticmethod 488 def make_sparse_matrix( 489 data: _NDArray, row_ind: _NDArray, col_ind: _NDArray, shape: tuple[int, int] 490 ) -> scipy.sparse.csr_matrix: 491 return scipy.sparse.csr_matrix((data, (row_ind, col_ind)), shape=shape) 492 493 494class _TorchArraylib(_Arraylib[_TorchTensor]): 495 """Torch implementation of the array abstraction.""" 496 497 # pylint: disable=missing-function-docstring 498 499 def __init__(self, array: _NDArray) -> None: 500 import torch 501 502 self.torch = torch 503 super().__init__(arraylib='torch', array=self.torch.as_tensor(array)) 504 505 @staticmethod 506 def recognize(array: Any) -> bool: 507 return type(array).__module__ == 'torch' 508 509 def numpy(self) -> _NDArray: 510 return self.array.numpy() 511 512 def dtype(self) -> _DType: 513 numpy_type = { 514 self.torch.float32: np.float32, 515 self.torch.float64: np.float64, 516 self.torch.complex64: np.complex64, 517 self.torch.complex128: np.complex128, 518 self.torch.uint8: np.uint8, # No uint16, uint32, uint64. 519 self.torch.int16: np.int16, 520 self.torch.int32: np.int32, 521 self.torch.int64: np.int64, 522 }[self.array.dtype] 523 return np.dtype(numpy_type) 524 525 def astype(self, dtype: _DTypeLike) -> _TorchTensor: 526 torch_types: dict[Any, Any] = { 527 np.float32: self.torch.float32, 528 np.float64: self.torch.float64, 529 np.complex64: self.torch.complex64, 530 np.complex128: self.torch.complex128, 531 np.uint8: self.torch.uint8, # No uint16, uint32, uint64. 532 np.int16: self.torch.int16, 533 np.int32: self.torch.int32, 534 np.int64: self.torch.int64, 535 } 536 return self.array.type(torch_types[np.dtype(dtype).type]) 537 538 def possibly_make_contiguous(self) -> _TorchTensor: 539 return self.array.contiguous() 540 541 def clip(self, low: Any, high: Any, dtype: _DTypeLike | None = None) -> _TorchTensor: 542 array = self.array 543 array = _arr_astype(array, dtype) if dtype is not None else array 544 return array.clip(low, high) 545 546 def square(self) -> _TorchTensor: 547 return self.array.square() 548 549 def sqrt(self) -> _TorchTensor: 550 return self.array.sqrt() 551 552 def getitem(self, indices: Any) -> _TorchTensor: 553 if not isinstance(indices, tuple): 554 indices = indices.type(self.torch.int64) 555 return self.array[indices] # pylint: disable=unsubscriptable-object 556 557 def where(self, if_true: Any, if_false: Any) -> _TorchTensor: 558 condition = self.array 559 return if_true.where(condition, if_false) 560 561 def transpose(self, axes: Sequence[int]) -> _TorchTensor: 562 return self.torch.permute(self.array, tuple(axes)) 563 564 def best_dims_order_for_resize(self, dst_shape: tuple[int, ...]) -> list[int]: 565 # Similar to `_NumpyArraylib`. We access `array.stride()` instead of `array.strides`. 566 src_shape: tuple[int, ...] = self.array.shape[: len(dst_shape)] 567 strides: Sequence[int] = self.array.stride() 568 largest_stride_dim = max(range(len(src_shape)), key=lambda dim: strides[dim]) 569 570 def priority(dim: int) -> float: 571 scaling = dst_shape[dim] / src_shape[dim] 572 return scaling * ((0.49 if scaling < 1.0 else 0.65) if dim == largest_stride_dim else 1.0) 573 574 return sorted(range(len(src_shape)), key=priority) 575 576 def premult_with_sparse(self, sparse: Any, num_threads: int | Literal['auto']) -> _TorchTensor: 577 del num_threads 578 if np.issubdtype(_arr_dtype(self.array), np.complexfloating): 579 sparse = _arr_astype(sparse, _arr_dtype(self.array)) 580 return sparse @ self.array # Calls torch.sparse.mm(). 581 582 @staticmethod 583 def concatenate(arrays: Sequence[_TorchTensor], axis: int) -> _TorchTensor: 584 import torch 585 586 return torch.cat(tuple(arrays), axis) 587 588 @staticmethod 589 def einsum(subscripts: str, *operands: _TorchTensor) -> _TorchTensor: 590 import torch 591 592 operands = tuple(torch.as_tensor(operand) for operand in operands) 593 if any(np.issubdtype(_arr_dtype(array), np.complexfloating) for array in operands): 594 operands = tuple( 595 _arr_astype(array, _complex_precision(_arr_dtype(array))) for array in operands 596 ) 597 return torch.einsum(subscripts, *operands) 598 599 @staticmethod 600 def make_sparse_matrix( 601 data: _NDArray, row_ind: _NDArray, col_ind: _NDArray, shape: tuple[int, int] 602 ) -> _TorchTensor: 603 import torch 604 605 indices = np.vstack((row_ind, col_ind)) 606 with torch.sparse.check_sparse_tensor_invariants(enable=False): 607 return torch.sparse_coo_tensor(torch.as_tensor(indices), torch.as_tensor(data), shape) 608 # .coalesce() is unnecessary because indices/data are already merged. 609 610 611class _JaxArraylib(_Arraylib[_JaxArray]): 612 """Jax implementation of the array abstraction.""" 613 614 def __init__(self, array: _NDArray) -> None: 615 import jax.numpy 616 617 self.jnp = jax.numpy 618 super().__init__(arraylib='jax', array=self.jnp.asarray(array)) 619 620 @staticmethod 621 def recognize(array: Any) -> bool: 622 # e.g., jaxlib.xla_extension.DeviceArray, jax.interpreters.ad.JVPTracer 623 return type(array).__module__.startswith(('jaxlib.', 'jax.')) 624 625 def numpy(self) -> _NDArray: 626 # 2023-01-09: jax 0.3.17 "DeviceArray.to_py() has been deprecated. Use np.asarray(x) instead." 627 # Whereas array.to_py() and np.asarray(array) may return a non-writable np.ndarray, 628 # np.array(array) always returns a writable array but the copy may be more costly. 629 # return self.array.to_py() 630 return np.asarray(self.array) 631 632 def dtype(self) -> _DType: 633 return np.dtype(self.array.dtype) 634 635 def astype(self, dtype: _DTypeLike) -> _JaxArray: 636 return self.array.astype(np.dtype(dtype)) # (copy=False is unavailable) 637 638 def possibly_make_contiguous(self) -> _JaxArray: 639 return self.array.copy() 640 641 def clip(self, low: Any, high: Any, dtype: _DTypeLike | None = None) -> _JaxArray: 642 array = self.array 643 if dtype is not None: 644 array = array.astype(np.dtype(dtype)) # (copy=False is unavailable) 645 return self.jnp.clip(array, low, high) 646 647 def square(self) -> _JaxArray: 648 return self.jnp.square(self.array) 649 650 def sqrt(self) -> _JaxArray: 651 return self.jnp.sqrt(self.array) 652 653 def where(self, if_true: Any, if_false: Any) -> _JaxArray: 654 condition = self.array 655 return cast(Any, self.jnp.where(condition, if_true, if_false)) 656 657 def transpose(self, axes: Sequence[int]) -> _JaxArray: 658 return self.jnp.transpose(self.array, tuple(axes)) 659 660 def best_dims_order_for_resize(self, dst_shape: tuple[int, ...]) -> list[int]: 661 # Jax/XLA does not have strides. Arrays are contiguous, almost always in C order; see 662 # https://github.com/google/jax/discussions/7544#discussioncomment-1197038. 663 # We use a heuristic similar to `_TensorflowArraylib`. 664 src_shape: tuple[int, ...] = self.array.shape[: len(dst_shape)] 665 dims = list(range(len(src_shape))) 666 if len(dims) > 1 and dst_shape[0] / src_shape[0] > 1.0: 667 dims[:2] = [1, 0] 668 return dims 669 670 def premult_with_sparse( 671 self, sparse: jax.experimental.sparse.BCOO, num_threads: int | Literal['auto'] 672 ) -> _JaxArray: 673 del num_threads 674 return sparse @ self.array # Calls jax.bcoo_multiply_dense(). 675 676 @staticmethod 677 def concatenate(arrays: Sequence[_JaxArray], axis: int) -> _JaxArray: 678 import jax.numpy as jnp 679 680 return jnp.concatenate(arrays, axis) 681 682 @staticmethod 683 def einsum(subscripts: str, *operands: _JaxArray) -> _JaxArray: 684 import jax.numpy as jnp 685 686 return jnp.einsum(subscripts, *operands, optimize='greedy') 687 688 @staticmethod 689 def make_sparse_matrix( 690 data: _NDArray, row_ind: _NDArray, col_ind: _NDArray, shape: tuple[int, int] 691 ) -> Any: 692 # https://jax.readthedocs.io/en/latest/jax.experimental.sparse.html 693 import jax.experimental.sparse 694 import jax.numpy as jnp 695 696 indices = jnp.asarray(np.vstack((row_ind, col_ind)).T) 697 return jax.experimental.sparse.BCOO( 698 (jnp.asarray(data), indices), shape=shape, indices_sorted=True, unique_indices=True 699 ) 700 701 702_CANDIDATE_ARRAYLIBS = { 703 'numpy': _NumpyArraylib, 704 'torch': _TorchArraylib, 705 'jax': _JaxArraylib, 706} 707 708 709def _is_available(arraylib: str) -> bool: 710 """Return whether the array library (e.g. 'torch') is available as an installed package.""" 711 # Faster than trying to import it. 712 return importlib.util.find_spec(arraylib) is not None # type: ignore[attr-defined] 713 714 715_DICT_ARRAYLIBS: dict[str, Any] = { 716 arraylib: cls for arraylib, cls in _CANDIDATE_ARRAYLIBS.items() if _is_available(arraylib) 717} 718 719ARRAYLIBS = list(_DICT_ARRAYLIBS) 720"""Array libraries supported automatically in the resize and resampling operations. 721 722- The library is selected automatically based on the type of the `array` function parameter. 723 724- The class `_Arraylib` provides library-specific implementations of needed basic functions. 725 726- The `_arr_*()` functions dispatch the `_Arraylib` methods based on the array type. 727""" 728 729 730def _as_arr(array: _AnyArray, /) -> _Arraylib[Any]: 731 """Return `array` wrapped as an `_Arraylib` for dispatch of functions.""" 732 if isinstance(array, (tuple, list)): 733 raise ValueError(f'{array} not recognized. Perhaps convert it using np.asarray().') 734 for cls in _DICT_ARRAYLIBS.values(): 735 if cls.recognize(array): 736 return cls(array) 737 raise ValueError(f'{array} {type(array)} {type(array).__module__} unrecognized by {ARRAYLIBS}.') 738 739 740def _arr_arraylib(array: _AnyArray, /) -> str: 741 """Return the name of the `Arraylib` representing `array`.""" 742 return _as_arr(array).arraylib 743 744 745def _arr_numpy(array: _AnyArray, /) -> _NDArray: 746 """Return a `numpy` version of `array`.""" 747 return _as_arr(array).numpy() 748 749 750def _arr_dtype(array: _AnyArray, /) -> _DType: 751 """Return the equivalent of `array.dtype` as a `numpy` `dtype`.""" 752 return _as_arr(array).dtype() 753 754 755def _arr_shape(array: _AnyArray, /) -> tuple[int, ...]: 756 """Return `array.shape` as a `tuple` of `int`.""" 757 array2: Any = array 758 return tuple(array2.shape) 759 760 761def _arr_astype(array: _Array, dtype: _DTypeLike, /) -> _Array: 762 """Return the equivalent of `array.astype(dtype)` with `numpy` `dtype`.""" 763 return _as_arr(array).astype(dtype) 764 765 766def _arr_reshape(array: _Array, shape: tuple[int, ...], /) -> _Array: 767 """Return the equivalent of `array.reshape(shape).""" 768 return _as_arr(array).reshape(shape) 769 770 771def _arr_possibly_make_contiguous(array: _Array, /) -> _Array: 772 """Return a contiguous copy of `array` or just `array` if already contiguous.""" 773 return _as_arr(array).possibly_make_contiguous() 774 775 776def _arr_clip(array: _Array, low: Any, high: Any, /, dtype: _DTypeLike | None = None) -> _Array: 777 """Return the equivalent of `array.clip(low, high, dtype)` with `numpy` `dtype`.""" 778 return _as_arr(array).clip(low, high, dtype) 779 780 781def _arr_square(array: _Array, /) -> _Array: 782 """Return the equivalent of `np.square(array)`.""" 783 return _as_arr(array).square() 784 785 786def _arr_sqrt(array: _Array, /) -> _Array: 787 """Return the equivalent of `np.sqrt(array)`.""" 788 return _as_arr(array).sqrt() 789 790 791def _arr_getitem(array: _Array, indices: Any, /) -> _Array: 792 """Return the equivalent of `array[indices]`.""" 793 return _as_arr(array).getitem(indices) 794 795 796def _arr_where(condition: _Array, if_true: Any, if_false: Any, /) -> _Array: 797 """Return the equivalent of `np.where(condition, if_true, if_false)`.""" 798 return _as_arr(condition).where(if_true, if_false) 799 800 801def _arr_transpose(array: _Array, axes: Sequence[int], /) -> _Array: 802 """Return the equivalent of `np.transpose(array, axes)`.""" 803 return _as_arr(array).transpose(axes) 804 805 806def _arr_best_dims_order_for_resize(array: _AnyArray, dst_shape: tuple[int, ...], /) -> list[int]: 807 """Return the best order in which to process dims for resizing `array` to `dst_shape`.""" 808 return _as_arr(array).best_dims_order_for_resize(dst_shape) 809 810 811def _arr_matmul_sparse_dense( 812 sparse: Any, dense: _Array, /, *, num_threads: int | Literal['auto'] = 'auto' 813) -> _Array: 814 """Return the multiplication of the `sparse` and `dense` matrices.""" 815 assert num_threads == 'auto' or num_threads >= 1 816 return _as_arr(dense).premult_with_sparse(sparse, num_threads) 817 818 819def _arr_concatenate(arrays: Sequence[_Array], axis: int, /) -> _Array: 820 """Return the equivalent of `np.concatenate(arrays, axis)`.""" 821 arraylib = _arr_arraylib(arrays[0]) 822 return _DICT_ARRAYLIBS[arraylib].concatenate(arrays, axis) 823 824 825def _arr_einsum(subscripts: str, /, *operands: _Array) -> _Array: 826 """Return the equivalent of `np.einsum(subscripts, *operands, optimize=True)`.""" 827 arraylib = _arr_arraylib(operands[0]) 828 return _DICT_ARRAYLIBS[arraylib].einsum(subscripts, *operands) 829 830 831def _arr_swapaxes(array: _Array, axis1: int, axis2: int, /) -> _Array: 832 """Return the equivalent of `np.swapaxes(array, axis1, axis2)`.""" 833 ndim = len(_arr_shape(array)) 834 assert 0 <= axis1 < ndim and 0 <= axis2 < ndim, (axis1, axis2, ndim) 835 axes = list(range(ndim)) 836 axes[axis1] = axis2 837 axes[axis2] = axis1 838 return _arr_transpose(array, axes) 839 840 841def _arr_moveaxis(array: _Array, source: int, destination: int, /) -> _Array: 842 """Return the equivalent of `np.moveaxis(array, source, destination)`.""" 843 ndim = len(_arr_shape(array)) 844 assert 0 <= source < ndim and 0 <= destination < ndim, (source, destination, ndim) 845 axes = [n for n in range(ndim) if n != source] 846 axes.insert(destination, source) 847 return _arr_transpose(array, axes) 848 849 850def _make_sparse_matrix( 851 data: _NDArray, row_ind: _NDArray, col_ind: _NDArray, shape: tuple[int, int], arraylib: str, / 852) -> Any: 853 """Return the equivalent of `scipy.sparse.csr_matrix(data, (row_ind, col_ind), shape=shape)`. 854 However, indices must be ordered and unique.""" 855 return _DICT_ARRAYLIBS[arraylib].make_sparse_matrix(data, row_ind, col_ind, shape) 856 857 858def _make_array(array: _ArrayLike, arraylib: str, /) -> Any: 859 """Return an array from the library `arraylib` initialized with the `numpy` `array`.""" 860 return _DICT_ARRAYLIBS[arraylib](np.asarray(array)).array 861 862 863# Because np.ndarray supports strides, np.moveaxis() and np.transpose() are constant-time. 864# However, ndarray.reshape() creates a copy whenever the new shape cannot be expressed using 865# strides, e.g. dim=1 in an RGB image. 866# 867# torch.Tensor also supports strides, so torch.movedim() is constant-time and 868# Tensor.reshape() has the same copy-when-necessary behavior as numpy. 869# 870# In contrast, tf.Tensor does not support strides, so tf.transpose() returns a new permuted 871# tensor. However, tf.reshape() is always efficient. 872# 873# For jax.Array, both operations lower to XLA ops; under jit the compiler often fuses away 874# the transpose and turns the reshape into a bitcast, so neither has a fixed cost. 875 876 877def _block_shape_with_min_size( 878 shape: tuple[int, ...], min_size: int, compact: bool = True 879) -> tuple[int, ...]: 880 """Return shape of block (of size at least `min_size`) to subdivide shape.""" 881 if math.prod(shape) < min_size: 882 raise ValueError(f'Shape {shape} smaller than min_size {min_size}.') 883 if compact: 884 root = math.ceil(min_size ** (1 / len(shape))) 885 block_shape = np.minimum(shape, root) 886 for dim in range(len(shape)): 887 if block_shape[dim] == 2 and block_shape.prod() >= min_size * 2: 888 block_shape[dim] = 1 889 for dim in range(len(shape) - 1, -1, -1): 890 if block_shape.prod() < min_size: 891 block_shape[dim] = shape[dim] 892 else: 893 block_shape = np.ones_like(shape) 894 for dim in range(len(shape) - 1, -1, -1): 895 if block_shape.prod() < min_size: 896 block_shape[dim] = min(shape[dim], math.ceil(min_size / block_shape.prod())) 897 return tuple(block_shape) 898 899 900def _array_split(array: _Array, axis: int, num_sections: int) -> list[_Array]: 901 """Split `array` into `num_sections` along `axis`.""" 902 assert 0 <= axis < len(_arr_shape(array)) 903 assert 1 <= num_sections <= _arr_shape(array)[axis] 904 905 if 0: 906 split = np.array_split(array, num_sections, axis=axis) # Numpy-specific. 907 908 else: 909 # Adapted from https://github.com/numpy/numpy/blob/main/numpy/lib/shape_base.py#L739-L792. 910 num_total = _arr_shape(array)[axis] 911 num_each, num_extra = divmod(num_total, num_sections) 912 section_sizes = [0] + num_extra * [num_each + 1] + (num_sections - num_extra) * [num_each] 913 div_points = np.array(section_sizes).cumsum() 914 split = [] 915 tmp: Any = _arr_swapaxes(array, axis, 0) 916 for i in range(num_sections): 917 split.append(_arr_swapaxes(tmp[div_points[i] : div_points[i + 1]], axis, 0)) 918 919 return split 920 921 922def _split_array_into_blocks(array: Any, block_shape: Sequence[int], start_axis: int = 0) -> Any: 923 """Split `array` into nested lists of blocks of size at most `block_shape`.""" 924 # See https://stackoverflow.com/a/50305924. (If the block_shape is known to 925 # exactly partition the array, see https://stackoverflow.com/a/16858283.) 926 if len(block_shape) > len(_arr_shape(array)): 927 raise ValueError(f'Block ndim {len(block_shape)} > array ndim {len(_arr_shape(array))}.') 928 if start_axis == len(block_shape): 929 return array 930 931 num_sections = math.ceil(_arr_shape(array)[start_axis] / block_shape[start_axis]) 932 split = _array_split(array, start_axis, num_sections) 933 return [_split_array_into_blocks(split_a, block_shape, start_axis + 1) for split_a in split] 934 935 936def _map_function_over_blocks(blocks: Any, func: Callable[[Any], Any]) -> Any: 937 """Apply `func` to each block in the nested lists of `blocks`.""" 938 if isinstance(blocks, list): 939 return [_map_function_over_blocks(block, func) for block in blocks] 940 return func(blocks) 941 942 943def _merge_array_from_blocks(blocks: Any, axis: int = 0) -> Any: 944 """Merge an array from the nested lists of array blocks in `blocks`.""" 945 # More general than np.block() because the blocks can have additional dims. 946 if isinstance(blocks, list): 947 new_blocks = [_merge_array_from_blocks(block, axis + 1) for block in blocks] 948 return _arr_concatenate(new_blocks, axis) 949 return blocks 950 951 952@dataclasses.dataclass(frozen=True) 953class Gridtype(abc.ABC): 954 """Abstract base class for grid-types such as `'dual'` and `'primal'`. 955 956 In resampling operations, the grid-type may be specified separately as `src_gridtype` for the 957 source domain and `dst_gridtype` for the destination domain. Moreover, the grid-type may be 958 specified per domain dimension. 959 960 Examples: 961 `resize(source, shape, gridtype='primal')` # Sets both src and dst to be `'primal'` grids. 962 963 `resize(source, shape, src_gridtype=['dual', 'primal'], 964 dst_gridtype='dual')` # Source is `'dual'` in dim0 and `'primal'` in dim1. 965 """ 966 967 name: str 968 """Gridtype name.""" 969 970 @abc.abstractmethod 971 def min_size(self) -> int: 972 """Return the necessary minimum number of grid samples.""" 973 974 @abc.abstractmethod 975 def size_in_samples(self, size: int, /) -> int: 976 """Return the domain size in units of inter-sample spacing.""" 977 978 @abc.abstractmethod 979 def point_from_index(self, index: _NDArray, size: int, /) -> _NDArray: 980 """Return [0.0, 1.0] coordinates given [0, size - 1] indices.""" 981 982 @abc.abstractmethod 983 def index_from_point(self, point: _NDArray, size: int, /) -> _NDArray: 984 """Return location x given coordinates [0.0, 1.0], where x == 0.0 is the first grid sample 985 and x == size - 1.0 is the last grid sample.""" 986 987 @abc.abstractmethod 988 def reflect(self, index: _NDArray, size: int, /) -> _NDArray: 989 """Map integer sample indices to interior ones using boundary reflection.""" 990 991 @abc.abstractmethod 992 def wrap(self, index: _NDArray, size: int, /) -> _NDArray: 993 """Map integer sample indices to interior ones using wrapping.""" 994 995 @abc.abstractmethod 996 def reflect_clamp(self, index: _NDArray, size: int, /) -> _NDArray: 997 """Map integer sample indices to interior ones using reflect-clamp.""" 998 999 1000class DualGridtype(Gridtype): 1001 """Samples are at the center of cells in a uniform partition of the domain. 1002 1003 For a unit-domain dimension with N samples, each sample 0 <= i < N has position (i + 0.5) / N, 1004 e.g., [0.125, 0.375, 0.625, 0.875] for N = 4. 1005 """ 1006 1007 def __init__(self) -> None: 1008 super().__init__(name='dual') 1009 1010 def min_size(self) -> int: 1011 return 1 1012 1013 def size_in_samples(self, size: int, /) -> int: 1014 return size 1015 1016 def point_from_index(self, index: _NDArray, size: int, /) -> _NDArray: 1017 return (index + 0.5) / size 1018 1019 def index_from_point(self, point: _NDArray, size: int, /) -> _NDArray: 1020 return point * size - 0.5 1021 1022 def reflect(self, index: _NDArray, size: int, /) -> _NDArray: 1023 index = np.mod(index, size * 2) 1024 return np.where(index < size, index, 2 * size - 1 - index) 1025 1026 def wrap(self, index: _NDArray, size: int, /) -> _NDArray: 1027 return np.mod(index, size) 1028 1029 def reflect_clamp(self, index: _NDArray, size: int, /) -> _NDArray: 1030 return np.minimum(np.where(index < 0, -1 - index, index), size - 1) 1031 1032 1033class PrimalGridtype(Gridtype): 1034 """Samples are at the vertices of cells in a uniform partition of the domain. 1035 1036 For a unit-domain dimension with N samples, each sample 0 <= i < N has position i / (N - 1), 1037 e.g., [0, 1/3, 2/3, 1] for N = 4. 1038 """ 1039 1040 def __init__(self) -> None: 1041 super().__init__(name='primal') 1042 1043 def min_size(self) -> int: 1044 return 2 1045 1046 def size_in_samples(self, size: int, /) -> int: 1047 return size - 1 1048 1049 def point_from_index(self, index: _NDArray, size: int, /) -> _NDArray: 1050 return index / (size - 1) 1051 1052 def index_from_point(self, point: _NDArray, size: int, /) -> _NDArray: 1053 return point * (size - 1) 1054 1055 def reflect(self, index: _NDArray, size: int, /) -> _NDArray: 1056 index = np.mod(index, size * 2 - 2) 1057 return np.where(index < size, index, 2 * size - 2 - index) 1058 1059 def wrap(self, index: _NDArray, size: int, /) -> _NDArray: 1060 return np.mod(index, size - 1) 1061 1062 def reflect_clamp(self, index: _NDArray, size: int, /) -> _NDArray: 1063 return np.minimum(np.abs(index), size - 1) 1064 1065 1066_DICT_GRIDTYPES = { 1067 'dual': DualGridtype(), 1068 'primal': PrimalGridtype(), 1069} 1070 1071GRIDTYPES = list(_DICT_GRIDTYPES) 1072r"""Shortcut names for the two predefined grid types (specified per dimension): 1073 1074| `gridtype` | `'dual'`<br/>`DualGridtype()`<br/>(default) | `'primal'`<br/>`PrimalGridtype()`<br/> | 1075| --- |:---:|:---:| 1076| Sample positions in 2D<br/>and in 1D at different resolutions |  |  | 1077| Nesting of samples across resolutions | The samples positions do *not* nest. | The *even* samples remain at coarser scale. | 1078| Number $N_\ell$ of samples (per-dimension) at resolution level $\ell$ | $N_\ell=2^\ell$ | $N_\ell=2^\ell+1$ | 1079| Position of sample index $i$ within domain $[0, 1]$ | $\frac{i + 0.5}{N}$ ("half-integer" coordinates) | $\frac{i}{N-1}$ | 1080| Image resolutions ($N_\ell\times N_\ell$) for dyadic scales | $1\times1, ~~2\times2, ~~4\times4, ~~8\times8, ~\ldots$ | $2\times2, ~~3\times3, ~~5\times5, ~~9\times9, ~\ldots$ | 1081 1082See the source code for extensibility. 1083""" 1084 1085 1086def _get_gridtype(gridtype: str | Gridtype) -> Gridtype: 1087 """Return a `Gridtype`, which can be specified as a name in `GRIDTYPES`.""" 1088 return gridtype if isinstance(gridtype, Gridtype) else _DICT_GRIDTYPES[gridtype] 1089 1090 1091def _get_gridtypes( 1092 gridtype: str | Gridtype | None, 1093 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None, 1094 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None, 1095 src_ndim: int, 1096 dst_ndim: int, 1097) -> tuple[list[Gridtype], list[Gridtype]]: 1098 """Return per-dim source and destination grid types given all parameters.""" 1099 if gridtype is None and src_gridtype is None and dst_gridtype is None: 1100 gridtype = 'dual' 1101 if gridtype is not None: 1102 if src_gridtype is not None: 1103 raise ValueError('Cannot have both gridtype and src_gridtype.') 1104 if dst_gridtype is not None: 1105 raise ValueError('Cannot have both gridtype and dst_gridtype.') 1106 src_gridtype = dst_gridtype = gridtype 1107 src_gridtype2 = [_get_gridtype(g) for g in np.broadcast_to(np.array(src_gridtype), src_ndim)] 1108 dst_gridtype2 = [_get_gridtype(g) for g in np.broadcast_to(np.array(dst_gridtype), dst_ndim)] 1109 return src_gridtype2, dst_gridtype2 1110 1111 1112@dataclasses.dataclass(frozen=True) 1113class RemapCoordinates(abc.ABC): 1114 """Abstract base class for modifying the specified coordinates prior to evaluating the 1115 reconstruction kernels.""" 1116 1117 @abc.abstractmethod 1118 def __call__(self, point: _NDArray, /) -> _NDArray: 1119 ... 1120 1121 1122@dataclasses.dataclass(frozen=True) 1123class NoRemapCoordinates(RemapCoordinates): 1124 """The coordinates are not remapped.""" 1125 1126 def __call__(self, point: _NDArray, /) -> _NDArray: 1127 return point 1128 1129 1130@dataclasses.dataclass(frozen=True) 1131class MirrorRemapCoordinates(RemapCoordinates): 1132 """The coordinates are reflected across the domain boundaries so that they lie in the unit 1133 interval. The resulting function is continuous but not smooth across the boundaries.""" 1134 1135 def __call__(self, point: _NDArray, /) -> _NDArray: 1136 point = np.mod(point, 2.0) 1137 return np.where(point >= 1.0, 2.0 - point, point) 1138 1139 1140@dataclasses.dataclass(frozen=True) 1141class TileRemapCoordinates(RemapCoordinates): 1142 """The coordinates are mapped to the unit interval using a "modulo 1.0" operation. The resulting 1143 function is generally discontinuous across the domain boundaries.""" 1144 1145 def __call__(self, point: _NDArray, /) -> _NDArray: 1146 return np.mod(point, 1.0) 1147 1148 1149@dataclasses.dataclass(frozen=True) 1150class ExtendSamples(abc.ABC): 1151 """Abstract base class for replacing references to grid samples exterior to the unit domain by 1152 affine combinations of interior sample(s) and possibly the constant value (`cval`).""" 1153 1154 uses_cval: bool = False 1155 """True if some exterior samples are defined in terms of `cval`, i.e., if the computed weight 1156 is non-affine.""" 1157 1158 @abc.abstractmethod 1159 def __call__( 1160 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1161 ) -> tuple[_NDArray, _NDArray]: 1162 """Detect references to exterior samples, i.e., entries of `index` that lie outside the 1163 interval [0, size), and update these indices (and possibly their associated weights) to 1164 reference only interior samples. Return `new_index, new_weight`.""" 1165 1166 1167@dataclasses.dataclass(frozen=True) 1168class ReflectExtendSamples(ExtendSamples): 1169 """Find the interior sample by reflecting across domain boundaries.""" 1170 1171 def __call__( 1172 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1173 ) -> tuple[_NDArray, _NDArray]: 1174 index = gridtype.reflect(index, size) 1175 return index, weight 1176 1177 1178@dataclasses.dataclass(frozen=True) 1179class WrapExtendSamples(ExtendSamples): 1180 """Wrap the interior samples periodically. For a `'primal'` grid, the last 1181 sample is ignored as its value is replaced by the first sample.""" 1182 1183 def __call__( 1184 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1185 ) -> tuple[_NDArray, _NDArray]: 1186 index = gridtype.wrap(index, size) 1187 return index, weight 1188 1189 1190@dataclasses.dataclass(frozen=True) 1191class ClampExtendSamples(ExtendSamples): 1192 """Use the nearest interior sample.""" 1193 1194 def __call__( 1195 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1196 ) -> tuple[_NDArray, _NDArray]: 1197 index = index.clip(0, size - 1) 1198 return index, weight 1199 1200 1201@dataclasses.dataclass(frozen=True) 1202class ReflectClampExtendSamples(ExtendSamples): 1203 """Extend the grid samples from [0, 1] into [-1, 0] using reflection and then define grid 1204 samples outside [-1, 1] as that of the nearest sample.""" 1205 1206 def __call__( 1207 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1208 ) -> tuple[_NDArray, _NDArray]: 1209 index = gridtype.reflect_clamp(index, size) 1210 return index, weight 1211 1212 1213@dataclasses.dataclass(frozen=True) 1214class BorderExtendSamples(ExtendSamples): 1215 """Let all exterior samples have the constant value (`cval`).""" 1216 1217 def __init__(self) -> None: 1218 super().__init__(uses_cval=True) 1219 1220 def __call__( 1221 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1222 ) -> tuple[_NDArray, _NDArray]: 1223 low = index < 0 1224 weight[low] = 0.0 1225 index[low] = 0 1226 high = index >= size 1227 weight[high] = 0.0 1228 index[high] = size - 1 1229 return index, weight 1230 1231 1232@dataclasses.dataclass(frozen=True) 1233class ValidExtendSamples(ExtendSamples): 1234 """Assign all domain samples weight 1 and all outside samples weight 0. 1235 Compute a weighted reconstruction and divide by the reconstructed weight.""" 1236 1237 def __init__(self) -> None: 1238 super().__init__(uses_cval=True) 1239 1240 def __call__( 1241 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1242 ) -> tuple[_NDArray, _NDArray]: 1243 low = index < 0 1244 weight[low] = 0.0 1245 index[low] = 0 1246 high = index >= size 1247 weight[high] = 0.0 1248 index[high] = size - 1 1249 sum_weight = weight.sum(axis=-1) 1250 nonzero_sum = sum_weight != 0.0 1251 np.divide(weight, sum_weight[..., None], out=weight, where=nonzero_sum[..., None]) 1252 return index, weight 1253 1254 1255@dataclasses.dataclass(frozen=True) 1256class LinearExtendSamples(ExtendSamples): 1257 """Linearly extrapolate beyond boundary samples.""" 1258 1259 def __call__( 1260 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1261 ) -> tuple[_NDArray, _NDArray]: 1262 if size < 2: 1263 index = gridtype.reflect(index, size) 1264 return index, weight 1265 # For each boundary, define new columns in index and weight arrays to represent the last and 1266 # next-to-last samples. When we later construct the sparse resize matrix, we will sum the 1267 # duplicate index entries. 1268 low = index < 0 1269 high = index >= size 1270 w = np.empty((*weight.shape[:-1], weight.shape[-1] + 4), weight.dtype) 1271 x = index 1272 w[..., -4] = ((1 - x) * weight).sum(where=low, axis=-1) 1273 w[..., -3] = ((x) * weight).sum(where=low, axis=-1) 1274 x = (size - 1) - index 1275 w[..., -2] = ((x) * weight).sum(where=high, axis=-1) 1276 w[..., -1] = ((1 - x) * weight).sum(where=high, axis=-1) 1277 weight[low] = 0.0 1278 index[low] = 0 1279 weight[high] = 0.0 1280 index[high] = size - 1 1281 w[..., :-4] = weight 1282 weight = w 1283 new_index = np.empty(w.shape, index.dtype) 1284 new_index[..., :-4] = index 1285 # Let matrix (including zero values) be banded. 1286 new_index[..., -4:] = np.where(w[..., -4:] != 0.0, [0, 1, size - 2, size - 1], index[..., :1]) 1287 index = new_index 1288 return index, weight 1289 1290 1291@dataclasses.dataclass(frozen=True) 1292class QuadraticExtendSamples(ExtendSamples): 1293 """Quadratically extrapolate beyond boundary samples.""" 1294 1295 def __call__( 1296 self, index: _NDArray, weight: _NDArray, size: int, gridtype: Gridtype, / 1297 ) -> tuple[_NDArray, _NDArray]: 1298 # [Keys 1981] suggests this as x[-1] = 3*x[0] - 3*x[1] + x[2], calling it "cubic precision", 1299 # but it seems just quadratic. 1300 if size < 3: 1301 index = gridtype.reflect(index, size) 1302 return index, weight 1303 low = index < 0 1304 high = index >= size 1305 w = np.empty((*weight.shape[:-1], weight.shape[-1] + 6), weight.dtype) 1306 x = index 1307 w[..., -6] = (((0.5 * x - 1.5) * x + 1) * weight).sum(where=low, axis=-1) 1308 w[..., -5] = (((-x + 2) * x) * weight).sum(where=low, axis=-1) 1309 w[..., -4] = (((0.5 * x - 0.5) * x) * weight).sum(where=low, axis=-1) 1310 x = (size - 1) - index 1311 w[..., -3] = (((0.5 * x - 0.5) * x) * weight).sum(where=high, axis=-1) 1312 w[..., -2] = (((-x + 2) * x) * weight).sum(where=high, axis=-1) 1313 w[..., -1] = (((0.5 * x - 1.5) * x + 1) * weight).sum(where=high, axis=-1) 1314 weight[low] = 0.0 1315 index[low] = 0 1316 weight[high] = 0.0 1317 index[high] = size - 1 1318 w[..., :-6] = weight 1319 weight = w 1320 new_index = np.empty(w.shape, index.dtype) 1321 new_index[..., :-6] = index 1322 # Let matrix (including zero values) be banded. 1323 new_index[..., -6:] = np.where( 1324 w[..., -6:] != 0.0, [0, 1, 2, size - 3, size - 2, size - 1], index[..., :1] 1325 ) 1326 index = new_index 1327 return index, weight 1328 1329 1330@dataclasses.dataclass(frozen=True) 1331class OverrideExteriorValue: 1332 """Abstract base class to set the value outside some domain extent to a 1333 constant value (`cval`).""" 1334 1335 boundary_antialiasing: bool = True 1336 """Antialias the pixel values adjacent to the boundary of the extent.""" 1337 1338 uses_cval: bool = False 1339 """Modify some weights to introduce references to the `cval` constant value.""" 1340 1341 def __call__(self, weight: _NDArray, point: _NDArray, /) -> None: 1342 """For all `point` outside some extent, modify the weight to be zero.""" 1343 1344 def override_using_signed_distance( 1345 self, weight: _NDArray, point: _NDArray, signed_distance: _NDArray, / 1346 ) -> None: 1347 """Reduce sample weights for "outside" values based on the signed distance function, 1348 to effectively assign the constant value `cval`.""" 1349 all_points_inside_domain = np.all(signed_distance <= 0.0) 1350 if all_points_inside_domain: 1351 return 1352 if self.boundary_antialiasing and min(point.shape) >= 2: 1353 # For discontinuous coordinate mappings, we may need to somehow ignore 1354 # the large finite differences computed across the map discontinuities. 1355 gradient = np.gradient(point) 1356 gradient_norm = np.linalg.norm(np.atleast_2d(gradient), axis=0) 1357 signed_distance_in_samples = signed_distance / (gradient_norm + 1e-20) 1358 # Opacity is in linear space, which is correct if Gamma is set. 1359 opacity = (0.5 - signed_distance_in_samples).clip(0.0, 1.0) 1360 weight *= opacity[..., None] 1361 else: 1362 is_outside = signed_distance > 0.0 1363 weight[is_outside, :] = 0.0 1364 1365 1366@dataclasses.dataclass(frozen=True) 1367class NoOverrideExteriorValue(OverrideExteriorValue): 1368 """The function value is not overridden.""" 1369 1370 def __call__(self, weight: _NDArray, point: _NDArray, /) -> None: 1371 pass 1372 1373 1374@dataclasses.dataclass(frozen=True) 1375class UnitDomainOverrideExteriorValue(OverrideExteriorValue): 1376 """Values outside the unit interval [0, 1] are replaced by the constant `cval`.""" 1377 1378 def __init__(self, **kwargs: Any) -> None: 1379 super().__init__(uses_cval=True, **kwargs) 1380 1381 def __call__(self, weight: _NDArray, point: _NDArray, /) -> None: 1382 signed_distance = abs(point - 0.5) - 0.5 # Boundaries at 0.0 and 1.0. 1383 self.override_using_signed_distance(weight, point, signed_distance) 1384 1385 1386@dataclasses.dataclass(frozen=True) 1387class PlusMinusOneOverrideExteriorValue(OverrideExteriorValue): 1388 """Values outside the interval [-1, 1] are replaced by the constant `cval`.""" 1389 1390 def __init__(self, **kwargs: Any) -> None: 1391 super().__init__(uses_cval=True, **kwargs) 1392 1393 def __call__(self, weight: _NDArray, point: _NDArray, /) -> None: 1394 signed_distance = abs(point) - 1.0 # Boundaries at -1.0 and 1.0. 1395 self.override_using_signed_distance(weight, point, signed_distance) 1396 1397 1398@dataclasses.dataclass(frozen=True) 1399class Boundary: 1400 """Domain boundary rules. These define the reconstruction over the source domain near and beyond 1401 the domain boundaries. The rules may be specified separately for each domain dimension.""" 1402 1403 name: str = '' 1404 """Boundary rule name.""" 1405 1406 coord_remap: RemapCoordinates = NoRemapCoordinates() 1407 """Modify specified coordinates prior to evaluating the reconstruction kernels.""" 1408 1409 extend_samples: ExtendSamples = ReflectExtendSamples() 1410 """Define the value of each grid sample outside the unit domain as an affine combination of 1411 interior sample(s) and possibly the constant value (`cval`).""" 1412 1413 override_value: OverrideExteriorValue = NoOverrideExteriorValue() 1414 """Set the value outside some extent to a constant value (`cval`).""" 1415 1416 @property 1417 def uses_cval(self) -> bool: 1418 """True if weights may be non-affine, involving the constant value (`cval`).""" 1419 return self.extend_samples.uses_cval or self.override_value.uses_cval 1420 1421 def preprocess_coordinates(self, point: _NDArray, /) -> _NDArray: 1422 """Modify coordinates prior to evaluating the filter kernels.""" 1423 # Antialiasing across the tile boundaries may be feasible but seems hard. 1424 point = self.coord_remap(point) 1425 return point 1426 1427 def apply( 1428 self, index: _NDArray, weight: _NDArray, point: _NDArray, size: int, gridtype: Gridtype, / 1429 ) -> tuple[_NDArray, _NDArray]: 1430 """Replace exterior samples by combinations of interior samples.""" 1431 index, weight = self.extend_samples(index, weight, size, gridtype) 1432 self.override_reconstruction(weight, point) 1433 return index, weight 1434 1435 def override_reconstruction(self, weight: _NDArray, point: _NDArray, /) -> None: 1436 """For points outside an extent, modify weight to zero to assign `cval`.""" 1437 self.override_value(weight, point) 1438 1439 1440_DICT_BOUNDARIES = { 1441 'reflect': Boundary('reflect', extend_samples=ReflectExtendSamples()), 1442 'wrap': Boundary('wrap', extend_samples=WrapExtendSamples()), 1443 'tile': Boundary( 1444 'title', coord_remap=TileRemapCoordinates(), extend_samples=ReflectExtendSamples() 1445 ), 1446 'clamp': Boundary('clamp', extend_samples=ClampExtendSamples()), 1447 'border': Boundary('border', extend_samples=BorderExtendSamples()), 1448 'natural': Boundary( 1449 'natural', 1450 extend_samples=ValidExtendSamples(), 1451 override_value=UnitDomainOverrideExteriorValue(), 1452 ), 1453 'linear_constant': Boundary( 1454 'linear_constant', 1455 extend_samples=LinearExtendSamples(), 1456 override_value=UnitDomainOverrideExteriorValue(), 1457 ), 1458 'quadratic_constant': Boundary( 1459 'quadratic_constant', 1460 extend_samples=QuadraticExtendSamples(), 1461 override_value=UnitDomainOverrideExteriorValue(), 1462 ), 1463 'reflect_clamp': Boundary('reflect_clamp', extend_samples=ReflectClampExtendSamples()), 1464 'constant': Boundary( 1465 'constant', 1466 extend_samples=ReflectExtendSamples(), 1467 override_value=UnitDomainOverrideExteriorValue(), 1468 ), 1469 'linear': Boundary('linear', extend_samples=LinearExtendSamples()), 1470 'quadratic': Boundary('quadratic', extend_samples=QuadraticExtendSamples()), 1471} 1472 1473BOUNDARIES = list(_DICT_BOUNDARIES) 1474"""Shortcut names for some predefined boundary rules (as defined by `_DICT_BOUNDARIES`): 1475 1476| name | a.k.a. / comments | 1477|------------------------|-------------------| 1478| `'reflect'` | *reflected*, *symm*, *symmetric*, *mirror*, *grid-mirror* | 1479| `'wrap'` | *periodic*, *repeat*, *grid-wrap* | 1480| `'tile'` | like `'reflect'` within unit domain, then tile discontinuously | 1481| `'clamp'` | *clamped*, *nearest*, *edge*, *clamp-to-edge*, repeat last sample | 1482| `'border'` | *grid-constant*, use `cval` for samples outside unit domain | 1483| `'natural'` | *renormalize* using only interior samples, use `cval` outside domain | 1484| `'reflect_clamp'` | *mirror-clamp-to-edge* | 1485| `'constant'` | like `'reflect'` but replace by `cval` outside unit domain | 1486| `'linear'` | extrapolate from 2 last samples | 1487| `'quadratic'` | extrapolate from 3 last samples | 1488| `'linear_constant'` | like `'linear'` but replace by `cval` outside unit domain | 1489| `'quadratic_constant'` | like `'quadratic'` but replace by `cval` outside unit domain | 1490 1491These boundary rules may be specified per dimension. See the source code for extensibility 1492using the classes `RemapCoordinates`, `ExtendSamples`, and `OverrideExteriorValue`. 1493 1494**Boundary rules illustrated in 1D:** 1495 1496<center> 1497<img src="https://github.com/hhoppe/resampler/raw/main/media/boundary_rules_in_1D.png" width="100%"/> 1498</center> 1499 1500**Boundary rules illustrated in 2D:** 1501 1502<center> 1503<img src="https://github.com/hhoppe/resampler/raw/main/media/boundary_rules_in_2D.png" width="100%"/> 1504</center> 1505""" 1506 1507_OFTUSED_BOUNDARIES = ( 1508 'reflect wrap tile clamp border natural linear_constant quadratic_constant'.split() 1509) 1510"""A useful subset of `BOUNDARIES` for visualization in figures.""" 1511 1512 1513def _get_boundary(boundary: str | Boundary, /) -> Boundary: 1514 """Return a `Boundary`, which can be specified as a name in `BOUNDARIES`.""" 1515 return boundary if isinstance(boundary, Boundary) else _DICT_BOUNDARIES[boundary] 1516 1517 1518@dataclasses.dataclass(frozen=True) 1519class Filter(abc.ABC): 1520 """Abstract base class for filter kernel functions. 1521 1522 Each kernel is assumed to be a zero-phase filter, i.e., to be symmetric in a support 1523 interval [-radius, radius]. (Some sites instead define kernels over the interval [0, N] 1524 where N = 2 * radius.) 1525 1526 Portions of this code are adapted from the C++ library in 1527 https://github.com/hhoppe/Mesh-processing-library/blob/main/libHh/Filter.cpp 1528 1529 See also https://hhoppe.com/proj/filtering/. 1530 """ 1531 1532 name: str 1533 """Filter kernel name.""" 1534 1535 radius: float 1536 """Max absolute value of x for which self(x) is nonzero.""" 1537 1538 interpolating: bool = True 1539 """True if self(0) == 1.0 and self(i) == 0.0 for all nonzero integers i.""" 1540 1541 continuous: bool = True 1542 """True if the kernel function has $C^0$ continuity.""" 1543 1544 partition_of_unity: bool = True 1545 """True if the convolution of the kernel with a Dirac comb reproduces the 1546 unity function.""" 1547 1548 unit_integral: bool = True 1549 """True if the integral of the kernel function is 1.""" 1550 1551 requires_digital_filter: bool = False 1552 """True if the filter needs a pre/post digital filter for interpolation.""" 1553 1554 @abc.abstractmethod 1555 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1556 """Return evaluation of filter kernel at locations x.""" 1557 1558 1559class ImpulseFilter(Filter): 1560 """See https://en.wikipedia.org/wiki/Dirac_delta_function.""" 1561 1562 def __init__(self) -> None: 1563 super().__init__(name='impulse', radius=1e-20, continuous=False, partition_of_unity=False) 1564 1565 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1566 raise AssertionError('The Impulse is infinitely narrow, so cannot be directly evaluated.') 1567 1568 1569class BoxFilter(Filter): 1570 """See https://en.wikipedia.org/wiki/Box_function. 1571 1572 The kernel function has value 1.0 over the half-open interval [-.5, .5). 1573 """ 1574 1575 def __init__(self) -> None: 1576 super().__init__(name='box', radius=0.5, continuous=False) 1577 1578 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1579 use_asymmetric = True 1580 if use_asymmetric: 1581 x = np.asarray(x) 1582 return np.where((-0.5 <= x) & (x < 0.5), 1.0, 0.0) 1583 x = np.abs(x) 1584 return np.where(x < 0.5, 1.0, np.where(x == 0.5, 0.5, 0.0)) 1585 1586 1587class TrapezoidFilter(Filter): 1588 """Filter for antialiased "area-based" filtering. 1589 1590 Args: 1591 radius: Specifies the support [-radius, radius] of the filter, where 0.5 < radius <= 1.0. 1592 The special case `radius = None` is a placeholder that indicates that the filter will be 1593 replaced by a trapezoid of the appropriate radius (based on scaling) for correct 1594 antialiasing in both minification and magnification. 1595 1596 This filter is similar to the BoxFilter but with linearly sloped sides. It has value 1.0 1597 in the interval abs(x) <= 1.0 - radius and decreases linearly to value 0.0 in the interval 1598 1.0 - radius <= abs(x) <= radius, always with value 0.5 at x = 0.5. 1599 """ 1600 1601 def __init__(self, *, radius: float | None = None) -> None: 1602 if radius is None: 1603 super().__init__(name='trapezoid', radius=0.0) 1604 return 1605 if not 0.5 < radius <= 1.0: 1606 raise ValueError(f'Radius {radius} is outside the range (0.5, 1.0].') 1607 super().__init__(name=f'trapezoid_{radius}', radius=radius) 1608 1609 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1610 x = np.abs(x) 1611 assert 0.5 < self.radius <= 1.0 1612 return ((0.5 + 0.25 / (self.radius - 0.5)) - (0.5 / (self.radius - 0.5)) * x).clip(0.0, 1.0) 1613 1614 1615class TriangleFilter(Filter): 1616 """See https://en.wikipedia.org/wiki/Triangle_function. 1617 1618 Also known as the hat or tent function. It is used for piecewise-linear 1619 (or bilinear, or trilinear, ...) interpolation. 1620 """ 1621 1622 def __init__(self) -> None: 1623 super().__init__(name='triangle', radius=1.0) 1624 1625 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1626 return (1.0 - np.abs(x)).clip(0.0, 1.0) 1627 1628 1629class CubicFilter(Filter): 1630 """Family of cubic filters parameterized by two scalar parameters. 1631 1632 Args: 1633 b: first scalar parameter. 1634 c: second scalar parameter. 1635 1636 See https://en.wikipedia.org/wiki/Mitchell%E2%80%93Netravali_filters and 1637 https://doi.org/10.1145/378456.378514. 1638 1639 [D. P. Mitchell and A. N. Netravali. Reconstruction filters in computer graphics. 1640 Computer Graphics (Proceedings of ACM SIGGRAPH 1988), 22(4):221-228, 1988.] 1641 1642 - The filter has quadratic precision iff b + 2 * c == 1. 1643 - The filter is interpolating iff b == 0. 1644 - (b=1, c=0) is the (non-interpolating) cubic B-spline basis; 1645 - (b=1/3, c=1/3) is the Mitchell filter; 1646 - (b=0, c=0.5) is the Catmull-Rom spline (which has cubic precision); 1647 - (b=0, c=0.75) is the "sharper cubic" used in Photoshop and OpenCV. 1648 """ 1649 1650 def __init__(self, *, b: float, c: float, name: str | None = None) -> None: 1651 name = f'cubic_b{b}_c{c}' if name is None else name 1652 interpolating = b == 0 1653 super().__init__(name=name, radius=2.0, interpolating=interpolating) 1654 self.b, self.c = b, c 1655 1656 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1657 x = np.abs(x) 1658 b, c = self.b, self.c 1659 f3, f2, f0 = 2 - 9 / 6 * b - c, -3 + 2 * b + c, 1 - 1 / 3 * b 1660 g3, g2, g1, g0 = -b / 6 - c, b + 5 * c, -2 * b - 8 * c, 8 / 6 * b + 4 * c 1661 # (np.polynomial.polynomial.polyval(x, [f0, 0, f2, f3]) is almost 1662 # twice as slow; see also https://stackoverflow.com/questions/24065904) 1663 v01 = ((f3 * x + f2) * x) * x + f0 1664 v12 = ((g3 * x + g2) * x + g1) * x + g0 1665 return np.where(x < 1.0, v01, np.where(x < 2.0, v12, 0.0)) 1666 1667 1668class CatmullRomFilter(CubicFilter): 1669 """Cubic filter with cubic precision. Also known as Keys filter. 1670 1671 [E. Catmull, R. Rom. A class of local interpolating splines. Computer aided geometric 1672 design, 1974] 1673 [Wikipedia](https://en.wikipedia.org/wiki/Cubic_Hermite_spline#Catmull%E2%80%93Rom_spline) 1674 1675 [R. G. Keys. Cubic convolution interpolation for digital image processing. 1676 IEEE Trans. on Acoustics, Speech, and Signal Processing, 29(6), 1981.] 1677 https://ieeexplore.ieee.org/document/1163711/. 1678 """ 1679 1680 def __init__(self) -> None: 1681 super().__init__(b=0, c=0.5, name='cubic') 1682 1683 1684class MitchellFilter(CubicFilter): 1685 """See https://doi.org/10.1145/378456.378514. 1686 1687 [D. P. Mitchell and A. N. Netravali. Reconstruction filters in computer graphics. Computer 1688 Graphics (Proceedings of ACM SIGGRAPH 1988), 22(4):221-228, 1988.] 1689 """ 1690 1691 def __init__(self) -> None: 1692 super().__init__(b=1 / 3, c=1 / 3, name='mitchell') 1693 1694 1695class SharpCubicFilter(CubicFilter): 1696 """Cubic filter that is sharper than Catmull-Rom filter. 1697 1698 Used by some tools including OpenCV and Photoshop. 1699 1700 See https://en.wikipedia.org/wiki/Mitchell%E2%80%93Netravali_filters and 1701 https://entropymine.com/resamplescope/notes/photoshop/. 1702 """ 1703 1704 def __init__(self) -> None: 1705 super().__init__(b=0, c=0.75, name='sharpcubic') 1706 1707 1708class LanczosFilter(Filter): 1709 """High-quality filter: sinc function modulated by a sinc window. 1710 1711 Args: 1712 radius: Specifies the support window [-radius, radius] over which the filter is nonzero. 1713 sampled: If True, use a discretized approximation for improved speed. 1714 1715 See https://en.wikipedia.org/wiki/Lanczos_kernel. 1716 """ 1717 1718 def __init__(self, *, radius: int, sampled: bool = True) -> None: 1719 super().__init__( 1720 name=f'lanczos_{radius}', radius=radius, partition_of_unity=False, unit_integral=False 1721 ) 1722 1723 @_cache_sampled_1d_function(xmin=-radius, xmax=radius, enable=sampled) 1724 def _eval(x: _ArrayLike) -> _NDArray: 1725 x = np.abs(x) 1726 # Note that window[n] = sinc(2*n/N - 1), with 0 <= n <= N. 1727 # But, x = n - N/2, or equivalently, n = x + N/2, with -N/2 <= x <= N/2. 1728 window = _sinc(x / radius) # Zero-phase function w_0(x). 1729 return np.where(x < radius, _sinc(x) * window, 0.0) 1730 1731 self._function = _eval 1732 1733 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1734 return self._function(x) 1735 1736 1737class GeneralizedHammingFilter(Filter): 1738 """Sinc function modulated by a Hamming window. 1739 1740 Args: 1741 radius: Specifies the support window [-radius, radius] over which the filter is nonzero. 1742 a0: Scalar parameter, where 0.0 < a0 < 1.0. The case of a0=0.5 is the Hann filter. 1743 1744 See https://en.wikipedia.org/wiki/Window_function#Hann_and_Hamming_windows, 1745 and hamming() in https://github.com/scipy/scipy/blob/main/scipy/signal/windows/_windows.py. 1746 1747 Note that `'hamming3'` is `(radius=3, a0=25/46)`, which close to but different from `a0=0.54`. 1748 1749 See also np.hamming() and np.hanning(). 1750 """ 1751 1752 def __init__(self, *, radius: int, a0: float) -> None: 1753 super().__init__( 1754 name=f'hamming_{radius}', 1755 radius=radius, 1756 partition_of_unity=False, # 1:1.00242 av=1.00188 sd=0.00052909 1757 unit_integral=False, # 1.00188 1758 ) 1759 assert 0.0 < a0 < 1.0 1760 self.a0 = a0 1761 1762 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1763 x = np.abs(x) 1764 # Note that window[n] = a0 - (1 - a0) * cos(2 * pi * n / N), 0 <= n <= N. 1765 # With n = x + N/2, we get the zero-phase function w_0(x): 1766 window = self.a0 + (1.0 - self.a0) * np.cos(np.pi / self.radius * x) 1767 return np.where(x < self.radius, _sinc(x) * window, 0.0) 1768 1769 1770class KaiserFilter(Filter): 1771 """Sinc function modulated by a Kaiser-Bessel window. 1772 1773 See https://en.wikipedia.org/wiki/Kaiser_window, and example use in: 1774 [Karras et al. 20201. Alias-free generative adversarial networks. 1775 https://arxiv.org/pdf/2106.12423.pdf]. 1776 1777 See also np.kaiser(). 1778 1779 Args: 1780 radius: Value L/2 in the definition. It may be fractional for a (digital) resizing filter 1781 (sample spacing s != 1) with an even number of samples (dual grid), e.g., Eq. (6) 1782 in [Karras et al. 2021] --- this effects the precise shape of the window function. 1783 beta: Determines the trade-off between main-lobe width and side-lobe level. 1784 sampled: If True, use a discretized approximation for improved speed. 1785 """ 1786 1787 def __init__(self, *, radius: float, beta: float, sampled: bool = True) -> None: 1788 assert beta >= 0.0 1789 super().__init__( 1790 name=f'kaiser_{radius}_{beta}', radius=radius, partition_of_unity=False, unit_integral=False 1791 ) 1792 1793 @_cache_sampled_1d_function(xmin=-math.ceil(radius), xmax=math.ceil(radius), enable=sampled) 1794 def _eval(x: _ArrayLike) -> _NDArray: 1795 x = np.abs(x) 1796 window = np.i0(beta * np.sqrt((1.0 - np.square(x / radius)).clip(0.0, 1.0))) / np.i0(beta) 1797 return np.where(x <= radius + 1e-6, _sinc(x) * window, 0.0) 1798 1799 self._function = _eval 1800 1801 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1802 return self._function(x) 1803 1804 1805class BsplineFilter(Filter): 1806 """B-spline of a non-negative degree. 1807 1808 Args: 1809 degree: The polynomial degree of the B-spline segments. 1810 With `degree=0`, it is like `BoxFilter` except with f(0.5) = f(-0.5) = 0. 1811 With `degree=1`, it is identical to `TriangleFilter`. 1812 With `degree >= 2`, it is no longer interpolating. 1813 1814 See [Carl de Boor. A practical guide to splines. Springer, 2001.] 1815 https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.BSpline.html 1816 """ 1817 1818 def __init__(self, *, degree: int) -> None: 1819 if degree < 0: 1820 raise ValueError(f'Bspline of degree {degree} is invalid.') 1821 radius = (degree + 1) / 2 1822 interpolating = degree <= 1 1823 super().__init__(name=f'bspline{degree}', radius=radius, interpolating=interpolating) 1824 t = list(range(degree + 2)) 1825 self._bspline = scipy.interpolate.BSpline.basis_element(t) 1826 1827 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1828 x = np.abs(x) 1829 return np.where(x < self.radius, self._bspline(x + self.radius), 0.0) 1830 1831 1832class CardinalBsplineFilter(Filter): 1833 """Interpolating B-spline, achieved with aid of digital pre or post filter. 1834 1835 Args: 1836 degree: The polynomial degree of the B-spline segments. 1837 sampled: If True, use a discretized approximation for improved speed. 1838 1839 See [Hou and Andrews. Cubic splines for image interpolation and digital filtering, 1978] and 1840 [Unser et al. Fast B-spline transforms for continuous image representation and interpolation, 1841 1991]. 1842 """ 1843 1844 def __init__(self, *, degree: int, sampled: bool = True) -> None: 1845 self.degree = degree 1846 if degree < 0: 1847 raise ValueError(f'Bspline of degree {degree} is invalid.') 1848 radius = (degree + 1) / 2 1849 super().__init__( 1850 name=f'cardinal{degree}', 1851 radius=radius, 1852 requires_digital_filter=degree >= 2, 1853 continuous=degree >= 1, 1854 ) 1855 t = list(range(degree + 2)) 1856 bspline = scipy.interpolate.BSpline.basis_element(t) 1857 1858 @_cache_sampled_1d_function(xmin=-radius, xmax=radius, enable=sampled) 1859 def _eval(x: _ArrayLike) -> _NDArray: 1860 x = np.abs(x) 1861 return np.where(x < radius, bspline(x + radius), 0.0) 1862 1863 self._function = _eval 1864 1865 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1866 return self._function(x) 1867 1868 1869class OmomsFilter(Filter): 1870 """OMOMS interpolating filter, with aid of digital pre or post filter. 1871 1872 Args: 1873 degree: The polynomial degree of the filter segments. 1874 1875 Optimal MOMS (maximal-order-minimal-support) function; see [Blu and Thevenaz, MOMS: Maximal-order 1876 interpolation of minimal support, 2001]. 1877 https://infoscience.epfl.ch/record/63074/files/blu0101.pdf 1878 """ 1879 1880 def __init__(self, *, degree: int) -> None: 1881 if degree not in (3, 5): 1882 raise ValueError(f'Degree {degree} not supported.') 1883 super().__init__(name=f'omoms{degree}', radius=(degree + 1) / 2, requires_digital_filter=True) 1884 self.degree = degree 1885 1886 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1887 x = np.abs(x) 1888 match self.degree: 1889 case 3: 1890 v01 = ((0.5 * x - 1.0) * x + 3 / 42) * x + 26 / 42 1891 v12 = ((-7 / 42 * x + 1.0) * x - 85 / 42) * x + 58 / 42 1892 return np.where(x < 1.0, v01, np.where(x < 2.0, v12, 0.0)) 1893 case 5: 1894 v01 = ((((-1 / 12 * x + 1 / 4) * x - 5 / 99) * x - 9 / 22) * x - 1 / 792) * x + 229 / 440 1895 v12 = ( 1896 (((1 / 24 * x - 3 / 8) * x + 505 / 396) * x - 83 / 44) * x + 1351 / 1584 1897 ) * x + 839 / 2640 1898 v23 = ( 1899 (((-1 / 120 * x + 1 / 8) * x - 299 / 396) * x + 101 / 44) * x - 27811 / 7920 1900 ) * x + 5707 / 2640 1901 return np.where(x < 1.0, v01, np.where(x < 2.0, v12, np.where(x < 3.0, v23, 0.0))) 1902 case _: 1903 raise ValueError(self.degree) 1904 1905 1906class GaussianFilter(Filter): 1907 r"""See https://en.wikipedia.org/wiki/Gaussian_function. 1908 1909 Args: 1910 standard_deviation: Sets the Gaussian $\sigma$. The default value is 1.25/3.0, which 1911 creates a kernel that is as-close-as-possible to a partition of unity. 1912 """ 1913 1914 DEFAULT_STANDARD_DEVIATION = 1.25 / 3.0 1915 """This value creates a kernel that is as-close-as-possible to a partition of unity; see 1916 mesh_processing/test/GridOp_test.cpp: `0.93503:1.06497 av=1 sd=0.0459424`. 1917 Another possibility is 0.5, as suggested on p. 4 of [Ken Turkowski. Filters for common 1918 resampling tasks, 1990] for kernels with a support of 3 pixels. 1919 https://cadxfem.org/inf/ResamplingFilters.pdf 1920 """ 1921 1922 def __init__(self, *, standard_deviation: float = DEFAULT_STANDARD_DEVIATION) -> None: 1923 super().__init__( 1924 name=f'gaussian_{standard_deviation:.3f}', 1925 radius=np.ceil(8.0 * standard_deviation), # Sufficiently large. 1926 interpolating=False, 1927 partition_of_unity=False, 1928 ) 1929 self.standard_deviation = standard_deviation 1930 1931 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1932 x = np.abs(x) 1933 sdv = self.standard_deviation 1934 v0r = np.exp(np.square(x / sdv) / -2.0) / (np.sqrt(math.tau) * sdv) 1935 return np.where(x < self.radius, v0r, 0.0) 1936 1937 1938class NarrowBoxFilter(Filter): 1939 """Compact footprint, used for visualization of grid sample location. 1940 1941 Args: 1942 radius: Specifies the support [-radius, radius] of the narrow box function. (The default 1943 value 0.199 is an inexact 0.2 to avoid numerical ambiguities.) 1944 """ 1945 1946 def __init__(self, *, radius: float = 0.199) -> None: 1947 super().__init__( 1948 name='narrowbox', 1949 radius=radius, 1950 continuous=False, 1951 unit_integral=False, 1952 partition_of_unity=False, 1953 ) 1954 1955 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1956 radius = self.radius 1957 magnitude = 1.0 1958 x = np.asarray(x) 1959 return np.where((-radius <= x) & (x < radius), magnitude, 0.0) 1960 1961 1962_DEFAULT_FILTER = 'lanczos3' 1963 1964_DICT_FILTERS = { 1965 'impulse': ImpulseFilter(), 1966 'box': BoxFilter(), 1967 'trapezoid': TrapezoidFilter(), 1968 'triangle': TriangleFilter(), 1969 'cubic': CatmullRomFilter(), 1970 'sharpcubic': SharpCubicFilter(), 1971 'lanczos3': LanczosFilter(radius=3), 1972 'lanczos5': LanczosFilter(radius=5), 1973 'lanczos10': LanczosFilter(radius=10), 1974 'cardinal3': CardinalBsplineFilter(degree=3), 1975 'cardinal5': CardinalBsplineFilter(degree=5), 1976 'omoms3': OmomsFilter(degree=3), 1977 'omoms5': OmomsFilter(degree=5), 1978 'hamming3': GeneralizedHammingFilter(radius=3, a0=25 / 46), 1979 'kaiser3': KaiserFilter(radius=3.0, beta=7.12), 1980 'gaussian': GaussianFilter(), 1981 'bspline3': BsplineFilter(degree=3), 1982 'mitchell': MitchellFilter(), 1983 'narrowbox': NarrowBoxFilter(), 1984 # Not in FILTERS: 1985 'hamming1': GeneralizedHammingFilter(radius=1, a0=0.54), 1986 'hann3': GeneralizedHammingFilter(radius=3, a0=0.5), 1987 'lanczos4': LanczosFilter(radius=4), 1988} 1989 1990FILTERS = list(itertools.takewhile(lambda x: x != 'hamming1', _DICT_FILTERS)) 1991r"""Shortcut names for some predefined filter kernels (specified per dimension). 1992The names expand to: 1993 1994| name | `Filter` | a.k.a. / comments | 1995|----------------|-------------------------------|-------------------| 1996| `'impulse'` | `ImpulseFilter()` | *nearest* | 1997| `'box'` | `BoxFilter()` | non-antialiased box, e.g. ImageMagick | 1998| `'trapezoid'` | `TrapezoidFilter()` | *area* antialiasing, e.g. `cv.INTER_AREA` | 1999| `'triangle'` | `TriangleFilter()` | *linear* (*bilinear* in 2D), spline `order=1` | 2000| `'cubic'` | `CatmullRomFilter()` | *catmullrom*, *keys*, *bicubic* | 2001| `'sharpcubic'` | `SharpCubicFilter()` | `cv.INTER_CUBIC`, `torch 'bicubic'` | 2002| `'lanczos3'` | `LanczosFilter`(radius=3) | support window [-3, 3] | 2003| `'lanczos5'` | `LanczosFilter`(radius=5) | [-5, 5] | 2004| `'lanczos10'` | `LanczosFilter`(radius=10) | [-10, 10] | 2005| `'cardinal3'` | `CardinalBsplineFilter`(degree=3) | *spline interpolation*, `order=3`, *GF* | 2006| `'cardinal5'` | `CardinalBsplineFilter`(degree=5) | *spline interpolation*, `order=5`, *GF* | 2007| `'omoms3'` | `OmomsFilter`(degree=3) | non-$C^1$, [-3, 3], *GF* | 2008| `'omoms5'` | `OmomsFilter`(degree=5) | non-$C^1$, [-5, 5], *GF* | 2009| `'hamming3'` | `GeneralizedHammingFilter`(...) | (radius=3, a0=25/46) | 2010| `'kaiser3'` | `KaiserFilter`(radius=3.0, beta=7.12) | | 2011| `'gaussian'` | `GaussianFilter()` | non-interpolating, default $\sigma=1.25/3$ | 2012| `'bspline3'` | `BsplineFilter`(degree=3) | non-interpolating | 2013| `'mitchell'` | `MitchellFilter()` | *mitchellcubic* | 2014| `'narrowbox'` | `NarrowBoxFilter()` | for visualization of sample positions | 2015 2016The comment label *GF* denotes a [generalized filter](https://hhoppe.com/proj/filtering/), formed 2017as the composition of a finitely supported kernel and a discrete inverse convolution. 2018 2019**Some example filter kernels:** 2020 2021<center> 2022<img src="https://github.com/hhoppe/resampler/raw/main/media/filter_summary.png" width="100%"/> 2023</center> 2024 2025<br/>A more extensive set of filters is presented [here](#plots_of_filters) in the 2026[notebook](https://colab.research.google.com/github/hhoppe/resampler/blob/main/resampler_notebook.ipynb), 2027together with visualizations and analyses of the filter properties. 2028See the source code for extensibility. 2029""" 2030 2031 2032def _get_filter(filter: str | Filter, /) -> Filter: 2033 """Return a `Filter`, which can be specified as a name string key in `FILTERS`.""" 2034 return filter if isinstance(filter, Filter) else _DICT_FILTERS[filter] 2035 2036 2037def _to_float_01(array: _Array, /, dtype: _DTypeLike) -> _Array: 2038 """Scale uint to the range [0.0, 1.0], and clip float to [0.0, 1.0].""" 2039 array_dtype = _arr_dtype(array) 2040 dtype = np.dtype(dtype) 2041 assert np.issubdtype(dtype, np.floating) 2042 match array_dtype.type: 2043 case np.uint8 | np.uint16 | np.uint32: 2044 if _arr_arraylib(array) == 'numpy': 2045 result: Any = np.multiply(array, 1 / np.iinfo(array_dtype).max, dtype=dtype) 2046 return result 2047 return cast(_Array, _arr_astype(array, dtype) / np.iinfo(array_dtype).max) 2048 case _: 2049 assert np.issubdtype(array_dtype, np.floating) 2050 return _arr_clip(array, 0.0, 1.0, dtype) 2051 2052 2053def _from_float(array: _Array, /, dtype: _DTypeLike) -> _Array: 2054 """Convert a float in range [0.0, 1.0] to uint or float type.""" 2055 assert np.issubdtype(_arr_dtype(array), np.floating) 2056 dtype = np.dtype(dtype) 2057 match dtype.type: 2058 case np.uint8 | np.uint16: 2059 scale32 = cast(float, np.float32(np.iinfo(dtype.name).max)) 2060 return _arr_astype(cast(_Array, array * scale32 + 0.5), dtype) 2061 case np.uint32: 2062 scale64 = cast(float, np.float64(np.iinfo(dtype.name).max)) 2063 return _arr_astype(cast(_Array, array * scale64 + 0.5), dtype) 2064 case _: 2065 assert np.issubdtype(dtype, np.floating) 2066 return _arr_astype(array, dtype) 2067 2068 2069@dataclasses.dataclass(frozen=True) 2070class Gamma(abc.ABC): 2071 """Abstract base class for transfer functions on sample values. 2072 2073 Image/video content is often stored using a color component transfer function. 2074 See https://en.wikipedia.org/wiki/Gamma_correction. 2075 2076 Converts between integer types and [0.0, 1.0] internal value range. 2077 """ 2078 2079 name: str 2080 """Name of component transfer function.""" 2081 2082 @abc.abstractmethod 2083 def decode(self, array: _Array, /, dtype: _DTypeLike = np.float32) -> _Array: 2084 """Decode source sample values into floating-point, possibly nonlinearly. 2085 2086 Uint source values are mapped to the range [0.0, 1.0]. 2087 """ 2088 2089 @abc.abstractmethod 2090 def encode(self, array: _Array, /, dtype: _DTypeLike) -> _Array: 2091 """Encode float signal into destination samples, possibly nonlinearly. 2092 2093 Uint destination values are mapped from the range [0.0, 1.0]. 2094 2095 Note that non-integer destination types are not clipped to the range [0.0, 1.0]. 2096 If that is desired, it can be performed as a postprocess using `output.clip(0.0, 1.0)`. 2097 """ 2098 2099 2100class IdentityGamma(Gamma): 2101 """Identity component transfer function.""" 2102 2103 def __init__(self) -> None: 2104 super().__init__('identity') 2105 2106 def decode(self, array: _Array, /, dtype: _DTypeLike = np.float32) -> _Array: 2107 dtype = np.dtype(dtype) 2108 assert np.issubdtype(dtype, np.inexact) 2109 if np.issubdtype(_arr_dtype(array), np.unsignedinteger): 2110 return _to_float_01(array, dtype) 2111 return _arr_astype(array, dtype) 2112 2113 def encode(self, array: _Array, /, dtype: _DTypeLike) -> _Array: 2114 dtype = np.dtype(dtype) 2115 assert np.issubdtype(dtype, np.number) 2116 if np.issubdtype(dtype, np.unsignedinteger): 2117 return _from_float(_arr_clip(array, 0.0, 1.0), dtype) 2118 if np.issubdtype(dtype, np.integer): 2119 return _arr_astype(cast(_Array, array + 0.5), dtype) 2120 return _arr_astype(array, dtype) 2121 2122 2123class PowerGamma(Gamma): 2124 """Gamma correction using a power function.""" 2125 2126 def __init__(self, power: float) -> None: 2127 super().__init__(name=f'power_{power}') 2128 self.power = power 2129 2130 def decode(self, array: _Array, /, dtype: _DTypeLike = np.float32) -> _Array: 2131 dtype = np.dtype(dtype) 2132 assert np.issubdtype(dtype, np.floating) 2133 if _arr_dtype(array) == np.uint8 and self.power != 2: 2134 arraylib = _arr_arraylib(array) 2135 decode_table = _make_array(self.decode(np.arange(256, dtype=dtype) / 255), arraylib) 2136 return _arr_getitem(decode_table, array) 2137 2138 array = _to_float_01(array, dtype) 2139 return _arr_square(array) if self.power == 2 else cast(_Array, array**self.power) 2140 2141 def encode(self, array: _Array, /, dtype: _DTypeLike) -> _Array: 2142 array = _arr_clip(array, 0.0, 1.0) 2143 if self.power == 2: 2144 array = _arr_sqrt(array) 2145 else: 2146 array = cast(_Array, array ** (1.0 / self.power)) 2147 return _from_float(array, dtype) 2148 2149 2150class SrgbGamma(Gamma): 2151 """Gamma correction using sRGB; see https://en.wikipedia.org/wiki/SRGB.""" 2152 2153 def __init__(self) -> None: 2154 super().__init__(name='srgb') 2155 2156 def decode(self, array: _Array, /, dtype: _DTypeLike = np.float32) -> _Array: 2157 dtype = np.dtype(dtype) 2158 assert np.issubdtype(dtype, np.floating) 2159 if _arr_dtype(array) == np.uint8: 2160 arraylib = _arr_arraylib(array) 2161 decode_table = _make_array(self.decode(np.arange(256, dtype=dtype) / 255), arraylib) 2162 return _arr_getitem(decode_table, array) 2163 2164 x = _to_float_01(array, dtype) 2165 return _arr_where(cast(_Array, x > 0.04045), ((x + 0.055) / 1.055) ** 2.4, x / 12.92) 2166 2167 def encode(self, array: _Array, /, dtype: _DTypeLike) -> _Array: 2168 x = _arr_clip(array, 0.0, 1.0) 2169 # Unfortunately, exponentiation is slow, and np.digitize() is even slower. 2170 x = _arr_where( 2171 cast(_Array, x > 0.0031308), x ** (1.0 / 2.4) * 1.055 - (0.055 - 1e-17), x * 12.92 2172 ) 2173 return _from_float(x, dtype) 2174 2175 2176_DICT_GAMMAS = { 2177 'identity': IdentityGamma(), 2178 'power2': PowerGamma(2.0), 2179 'power22': PowerGamma(2.2), 2180 'srgb': SrgbGamma(), 2181} 2182 2183GAMMAS = list(_DICT_GAMMAS) 2184r"""Shortcut names for some predefined gamma-correction schemes: 2185 2186| name | `Gamma` | Decoding function<br/> (linear space from stored value) | Encoding function<br/> (stored value from linear space) | 2187|---|---|:---:|:---:| 2188| `'identity'` | `IdentityGamma()` | $l = e$ | $e = l$ | 2189| `'power2'` | `PowerGamma`(2.0) | $l = e^{2.0}$ | $e = l^{1/2.0}$ | 2190| `'power22'` | `PowerGamma`(2.2) | $l = e^{2.2}$ | $e = l^{1/2.2}$ | 2191| `'srgb'` | `SrgbGamma()` | $l = \left(\left(e + 0.055\right) / 1.055\right)^{2.4}$ | $e = l^{1/2.4} * 1.055 - 0.055$ | 2192 2193See the source code for extensibility. 2194""" 2195 2196 2197def _get_gamma(gamma: str | Gamma, /) -> Gamma: 2198 """Return a `Gamma`, which can be specified as a name in `GAMMAS`.""" 2199 return gamma if isinstance(gamma, Gamma) else _DICT_GAMMAS[gamma] 2200 2201 2202def _get_src_dst_gamma( 2203 gamma: str | Gamma | None, 2204 src_gamma: str | Gamma | None, 2205 dst_gamma: str | Gamma | None, 2206 src_dtype: _DType, 2207 dst_dtype: _DType, 2208) -> tuple[Gamma, Gamma]: 2209 if gamma is None and src_gamma is None and dst_gamma is None: 2210 src_uint = np.issubdtype(src_dtype, np.unsignedinteger) 2211 dst_uint = np.issubdtype(dst_dtype, np.unsignedinteger) 2212 if src_uint and dst_uint: 2213 # The default might ideally be 'srgb' but that conversion is costlier. 2214 gamma = 'power2' 2215 elif not src_uint and not dst_uint: 2216 gamma = 'identity' 2217 else: 2218 raise ValueError(f'Gamma must be specified because {src_dtype=} and {dst_dtype=}.') 2219 if gamma is not None: 2220 if src_gamma is not None: 2221 raise ValueError('Cannot specify both gamma and src_gamma.') 2222 if dst_gamma is not None: 2223 raise ValueError('Cannot specify both gamma and dst_gamma.') 2224 src_gamma = dst_gamma = gamma 2225 assert src_gamma and dst_gamma 2226 src_gamma = _get_gamma(src_gamma) 2227 dst_gamma = _get_gamma(dst_gamma) 2228 return src_gamma, dst_gamma 2229 2230 2231def _create_resize_matrix( 2232 src_size: int, 2233 dst_size: int, 2234 src_gridtype: Gridtype, 2235 dst_gridtype: Gridtype, 2236 boundary: Boundary, 2237 filter: Filter, 2238 prefilter: Filter | None = None, 2239 scale: float = 1.0, 2240 translate: float = 0.0, 2241 dtype: _DTypeLike = np.float64, 2242 arraylib: str = 'numpy', 2243) -> tuple[Any, _AnyArray | None]: 2244 """Compute affine weights for 1D resampling from `src_size` to `dst_size`. 2245 2246 Compute a sparse matrix in which each row expresses a destination sample value as a combination 2247 of source sample values depending on the boundary rule. If the combination is non-affine, 2248 the remainder (returned as `cval_weight`) is the contribution of the special constant value 2249 (cval) defined outside the domain. 2250 2251 Args: 2252 src_size: The number of samples within the source 1D domain. 2253 dst_size: The number of samples within the destination 1D domain. 2254 src_gridtype: Placement of the samples in the source domain grid. 2255 dst_gridtype: Placement of the output samples in the destination domain grid. 2256 boundary: The reconstruction boundary rule. 2257 filter: The reconstruction kernel (used for upsampling/magnification). 2258 prefilter: The prefilter kernel (used for downsampling/minification). If it is `None`, 2259 `filter` is used. 2260 scale: Scaling factor applied when mapping the source domain onto the destination domain. 2261 translate: Offset applied when mapping the scaled source domain onto the destination domain. 2262 dtype: Precision of computed resize matrix entries. 2263 arraylib: Representation of output. Must be an element of `ARRAYLIBS`. 2264 2265 Returns: 2266 sparse_matrix: Matrix whose rows express output sample values as affine combinations of the 2267 source sample values. 2268 cval_weight: Optional vector expressing the additional contribution of the constant value 2269 (`cval`) to the combination in each row of `sparse_matrix`. It equals one minus the sum of 2270 the weights in each matrix row. 2271 """ 2272 if src_size < src_gridtype.min_size(): 2273 raise ValueError(f'Source size {src_size} is too small for resize.') 2274 if dst_size < dst_gridtype.min_size(): 2275 raise ValueError(f'Destination size {dst_size} is too small for resize.') 2276 prefilter = filter if prefilter is None else prefilter 2277 dtype = np.dtype(dtype) 2278 assert np.issubdtype(dtype, np.floating) 2279 2280 scaling = dst_gridtype.size_in_samples(dst_size) / src_gridtype.size_in_samples(src_size) * scale 2281 is_minification = scaling < 1.0 2282 filter = prefilter if is_minification else filter 2283 if filter.name == 'trapezoid': 2284 radius = 0.5 + 0.5 * min(scaling, 1.0 / scaling) 2285 filter = TrapezoidFilter(radius=radius) 2286 radius = filter.radius 2287 num_samples = int(np.ceil(radius * 2 / scaling) if is_minification else np.ceil(radius * 2)) 2288 2289 dst_index = np.arange(dst_size, dtype=dtype) 2290 # Destination sample locations in unit domain [0, 1]. 2291 dst_position = dst_gridtype.point_from_index(dst_index, dst_size) 2292 2293 src_position = (dst_position - translate) / scale 2294 src_position = boundary.preprocess_coordinates(src_position) 2295 2296 # Sample positions mapped back to source unit domain [0, 1]. 2297 src_float_index = src_gridtype.index_from_point(src_position, src_size) 2298 src_first_index = ( 2299 np.floor(src_float_index + (0.5 if num_samples % 2 == 1 else 0.0)).astype(np.int32) 2300 - (num_samples - 1) // 2 2301 ) 2302 2303 sample_index = np.arange(num_samples, dtype=np.int32) 2304 src_index = src_first_index[:, None] + sample_index # (dst_size, num_samples) 2305 2306 def get_weight_matrix() -> _NDArray: 2307 if filter.name == 'impulse': 2308 return np.ones(src_index.shape, dtype) 2309 if is_minification: 2310 x = (src_float_index[:, None] - src_index.astype(dtype)) * scaling 2311 return filter(x) * scaling 2312 # Either same size or magnification. 2313 x = src_float_index[:, None] - src_index.astype(dtype) 2314 return filter(x) 2315 2316 weight = get_weight_matrix().astype(dtype, copy=False) 2317 2318 if filter.name != 'narrowbox' and (is_minification or not filter.partition_of_unity): 2319 weight = weight / weight.sum(axis=-1)[..., None] 2320 2321 src_index, weight = boundary.apply(src_index, weight, src_position, src_size, src_gridtype) 2322 shape = dst_size, src_size 2323 2324 def prepare_sparse_resize_matrix() -> tuple[_NDArray, _NDArray, _NDArray]: 2325 linearized = (src_index + np.indices(src_index.shape)[0] * src_size).ravel() 2326 values = weight.ravel() 2327 # Remove the zero weights. 2328 nonzero = values != 0.0 2329 linearized, values = linearized[nonzero], values[nonzero] 2330 # Sort and merge the duplicate indices. 2331 unique, unique_inverse = np.unique(linearized, return_inverse=True) 2332 data2 = np.ones(len(linearized), np.float32) 2333 row_ind2 = unique_inverse 2334 col_ind2 = np.arange(len(linearized)) 2335 shape2 = len(unique), len(linearized) 2336 csr = scipy.sparse.csr_matrix((data2, (row_ind2, col_ind2)), shape=shape2) 2337 data = csr * values # Merged values. 2338 row_ind, col_ind = unique // src_size, unique % src_size # Merged indices. 2339 return data, row_ind, col_ind 2340 2341 data, row_ind, col_ind = prepare_sparse_resize_matrix() 2342 resize_matrix = _make_sparse_matrix(data, row_ind, col_ind, shape, arraylib) 2343 2344 uses_cval = boundary.uses_cval or filter.name == 'narrowbox' 2345 cval_weight = _make_array(1.0 - weight.sum(axis=-1), arraylib) if uses_cval else None 2346 2347 return resize_matrix, cval_weight 2348 2349 2350def _apply_digital_filter_1d( 2351 array: _Array, 2352 gridtype: Gridtype, 2353 boundary: Boundary, 2354 cval: _ArrayLike, 2355 filter: Filter, 2356 /, 2357 *, 2358 axis: int = 0, 2359) -> _Array: 2360 """Apply inverse convolution to the specified dimension of the array. 2361 2362 Find the array coefficients such that convolution with the (continuous) filter (given 2363 gridtype and boundary) interpolates the original array values. 2364 """ 2365 assert filter.requires_digital_filter 2366 arraylib = _arr_arraylib(array) 2367 2368 if arraylib == 'torch': 2369 import torch.autograd 2370 2371 class InverseConvolution(torch.autograd.Function): # type: ignore[misc] # pylint: disable=abstract-method 2372 """Differentiable wrapper for _apply_digital_filter_1d.""" 2373 2374 @staticmethod 2375 # pylint: disable-next=arguments-differ 2376 def forward(ctx: Any, *args: _TorchTensor, **kwargs: Any) -> _TorchTensor: 2377 del ctx 2378 assert not kwargs 2379 (x,) = args 2380 a = _apply_digital_filter_1d_numpy( 2381 x.detach().numpy(), gridtype, boundary, cval, filter, axis, False 2382 ) 2383 return torch.as_tensor(a) 2384 2385 @staticmethod 2386 def backward(ctx: Any, *grad_outputs: _TorchTensor) -> _TorchTensor: 2387 del ctx 2388 (grad_output,) = grad_outputs 2389 a = _apply_digital_filter_1d_numpy( 2390 grad_output.detach().numpy(), gridtype, boundary, cval, filter, axis, True 2391 ) 2392 return torch.as_tensor(a) 2393 2394 return InverseConvolution.apply(array) 2395 2396 if arraylib == 'jax': 2397 import jax 2398 import jax.numpy as jnp 2399 # It seems rather difficult to implement this digital filter (inverse convolution) in Jax. 2400 # https://jax.readthedocs.io/en/latest/jax.scipy.html sadly omits scipy.signal.filtfilt(). 2401 # To include a (non-traceable) numpy function in Jax requires jax.experimental.host_callback 2402 # and/or defining a new jax.core.Primitive (which allows differentiability). See 2403 # https://github.com/google/jax/issues/1142#issuecomment-544286585 2404 # https://github.com/google/jax/blob/main/docs/notebooks/How_JAX_primitives_work.ipynb :-( 2405 # https://github.com/google/jax/issues/5934 2406 2407 @jax.custom_gradient # type: ignore[untyped-decorator] 2408 def jax_inverse_convolution(x: _JaxArray) -> Any: 2409 # This function is not jax-traceable due to the presence of to_py(), so jit and grad fail. 2410 x_py = np.asarray(x) # to_py() deprecated. 2411 a = _apply_digital_filter_1d_numpy(x_py, gridtype, boundary, cval, filter, axis, False) 2412 y = jnp.asarray(a) 2413 2414 def grad(grad_output: _JaxArray) -> _JaxArray: 2415 grad_output_py = np.asarray(grad_output) # to_py() deprecated. 2416 a = _apply_digital_filter_1d_numpy( 2417 grad_output_py, gridtype, boundary, cval, filter, axis, True 2418 ) 2419 return jnp.asarray(a) 2420 2421 return y, grad 2422 2423 return jax_inverse_convolution(array) 2424 2425 assert arraylib == 'numpy' 2426 array_np: Any = array 2427 result: Any = _apply_digital_filter_1d_numpy( 2428 array_np, gridtype, boundary, cval, filter, axis, False 2429 ) 2430 return result 2431 2432 2433def _apply_digital_filter_1d_numpy( 2434 array: _NDArray, 2435 gridtype: Gridtype, 2436 boundary: Boundary, 2437 cval: _ArrayLike, 2438 filter: Filter, 2439 axis: int, 2440 compute_backward: bool, 2441 /, 2442) -> _NDArray: 2443 """Version of _apply_digital_filter_1d` specialized to numpy array.""" 2444 assert np.issubdtype(array.dtype, np.inexact) 2445 cval = np.asarray(cval).astype(array.dtype, copy=False) 2446 2447 # Use fast spline_filter1d() if we have a compatible gridtype, boundary, and filter: 2448 mode = { 2449 ('reflect', 'dual'): 'reflect', 2450 ('reflect', 'primal'): 'mirror', 2451 ('wrap', 'dual'): 'grid-wrap', 2452 ('wrap', 'primal'): 'wrap', 2453 }.get((boundary.name, gridtype.name)) 2454 filter_is_compatible = isinstance(filter, CardinalBsplineFilter) 2455 use_split_filter1d = filter_is_compatible and mode 2456 if use_split_filter1d: 2457 assert isinstance(filter, CardinalBsplineFilter) # Help mypy. 2458 assert filter.degree >= 2 2459 # compute_backward=True is same: matrix is symmetric and cval is unused. 2460 return scipy.ndimage.spline_filter1d( 2461 array, axis=axis, order=filter.degree, mode=mode, output=array.dtype 2462 ) 2463 2464 array_dim = np.moveaxis(array, axis, 0) 2465 l = original_l = math.ceil(filter.radius) - 1 2466 x = np.arange(-l, l + 1, dtype=array.real.dtype) 2467 values = filter(x) 2468 size = array_dim.shape[0] 2469 src_index = np.arange(size)[:, None] + np.arange(len(values)) - l 2470 weight: _NDArray = np.full((size, len(values)), values) 2471 src_position = np.broadcast_to(0.5, len(values)) 2472 src_index, weight = boundary.apply(src_index, weight, src_position, size, gridtype) 2473 if gridtype.name == 'primal' and boundary.name == 'wrap': 2474 # Overwrite redundant last row to preserve unreferenced last sample and thereby make the 2475 # matrix non-singular. 2476 src_index[-1] = [size - 1] + [0] * (src_index.shape[1] - 1) 2477 weight[-1] = [1.0] + [0.0] * (weight.shape[1] - 1) 2478 bandwidth = abs(src_index - np.arange(size)[:, None]).max() 2479 is_banded = bandwidth <= l + 1 # Add one for quadratic boundary and l == 1. 2480 # Currently, matrix is always banded unless boundary.name == 'wrap'. 2481 2482 data = weight.reshape(-1).astype(array.dtype, copy=False) 2483 row_ind = np.arange(size).repeat(src_index.shape[1]) 2484 col_ind = src_index.reshape(-1) 2485 matrix = scipy.sparse.csr_matrix((data, (row_ind, col_ind)), shape=(size, size)) 2486 if compute_backward: 2487 matrix = matrix.transpose() 2488 2489 if boundary.uses_cval and not compute_backward: 2490 cval_weight = 1.0 - np.asarray(matrix.sum(axis=-1))[:, 0] 2491 if array_dim.ndim == 2: # Handle the case that we have array_flat. 2492 cval = np.tile(cval.reshape(-1), array_dim[0].size // cval.size) 2493 array_dim = array_dim - cval_weight.reshape(-1, *(1,) * array_dim[0].ndim) * cval 2494 2495 array_flat = array_dim.reshape(array_dim.shape[0], -1) 2496 2497 if is_banded: 2498 matrix = matrix.todia() 2499 assert np.all(np.diff(matrix.offsets) == 1) # Consecutive, often [-l, l]. 2500 l, u = -matrix.offsets[0], matrix.offsets[-1] 2501 assert l <= original_l + 1 and u <= original_l + 1, (l, u, original_l) 2502 options = dict(check_finite=False, overwrite_ab=True, overwrite_b=False) 2503 if _is_symmetric(matrix): 2504 array_flat = scipy.linalg.solveh_banded(matrix.data[-1 : l - 1 : -1], array_flat, **options) 2505 else: 2506 array_flat = scipy.linalg.solve_banded((l, u), matrix.data[::-1], array_flat, **options) 2507 2508 else: 2509 lu = scipy.sparse.linalg.splu(matrix.tocsc(), permc_spec='NATURAL') 2510 assert all(s <= size * len(values) for s in (lu.L.nnz, lu.U.nnz)) # Sparse. 2511 array_flat = lu.solve(array_flat) 2512 2513 array_dim = np.asarray(array_flat).reshape(array_dim.shape) 2514 return np.moveaxis(array_dim, 0, axis) 2515 2516 2517def resize( 2518 array: _Array, 2519 /, 2520 shape: Iterable[int], 2521 *, 2522 gridtype: str | Gridtype | None = None, 2523 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2524 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2525 boundary: str | Boundary | Iterable[str | Boundary] = 'auto', 2526 cval: _ArrayLike = 0.0, 2527 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 2528 prefilter: str | Filter | Iterable[str | Filter] | None = None, 2529 gamma: str | Gamma | None = None, 2530 src_gamma: str | Gamma | None = None, 2531 dst_gamma: str | Gamma | None = None, 2532 scale: float | Iterable[float] = 1.0, 2533 translate: float | Iterable[float] = 0.0, 2534 precision: _DTypeLike | None = None, 2535 dtype: _DTypeLike | None = None, 2536 dim_order: Iterable[int] | None = None, 2537 num_threads: int | Literal['auto'] = 'auto', 2538) -> _Array: 2539 """Resample `array` (a grid of sample values) onto a grid with resolution `shape`. 2540 2541 The source `array` is any object recognized by `ARRAYLIBS`. It is interpreted as a grid 2542 with `len(shape)` domain coordinate dimensions, where each grid sample value has shape 2543 `array.shape[len(shape):]`. 2544 2545 Some examples: 2546 2547 - A grayscale image has `array.shape = height, width` and resizing it with `len(shape) == 2` 2548 produces a new image of scalar values. 2549 - An RGB image has `array.shape = height, width, 3` and resizing it with `len(shape) == 2` 2550 produces a new image of RGB values. 2551 - An 3D grid of 3x3 Jacobians has `array.shape = Z, Y, X, 3, 3` and resizing it with 2552 `len(shape) == 3` produces a new 3D grid of Jacobians. 2553 2554 This function also allows scaling and translation from the source domain to the output domain 2555 through the parameters `scale` and `translate`. For more general transforms, see `resample`. 2556 2557 Args: 2558 array: Regular grid of source sample values, as an array object recognized by `ARRAYLIBS`. 2559 The array must have numeric type. Its first `len(shape)` dimensions are the domain 2560 coordinate dimensions. Each grid dimension must be at least 1 for a `'dual'` grid or 2561 at least 2 for a `'primal'` grid. 2562 shape: The number of grid samples in each coordinate dimension of the output array. The source 2563 `array` must have at least as many dimensions as `len(shape)`. 2564 gridtype: Placement of samples on all dimensions of both the source and output domain grids, 2565 specified as either a name in `GRIDTYPES` or a `Gridtype` instance. It defaults to `'dual'` 2566 if `gridtype`, `src_gridtype`, and `dst_gridtype` are all kept `None`. 2567 src_gridtype: Placement of the samples in the source domain grid for each dimension. 2568 Parameters `gridtype` and `src_gridtype` cannot both be set. 2569 dst_gridtype: Placement of the samples in the output domain grid for each dimension. 2570 Parameters `gridtype` and `dst_gridtype` cannot both be set. 2571 boundary: The reconstruction boundary rule for each dimension in `shape`, specified as either 2572 a name in `BOUNDARIES` or a `Boundary` instance. The special value `'auto'` uses `'reflect'` 2573 for upsampling and `'clamp'` for downsampling. 2574 cval: Constant value used beyond the samples by some boundary rules. It must be broadcastable 2575 onto `array.shape[len(shape):]`. It is subject to `src_gamma`. 2576 filter: The reconstruction kernel for each dimension in `shape`, specified as either a filter 2577 name in `FILTERS` or a `Filter` instance. It is used during upsampling (i.e., magnification). 2578 prefilter: The prefilter kernel for each dimension in `shape`, specified as either a filter 2579 name in `FILTERS` or a `Filter` instance. It is used during downsampling 2580 (i.e., minification). If `None`, it inherits the value of `filter`. The default 2581 `'lanczos3'` is good for natural images. For vector graphics images, `'trapezoid'` is better 2582 because it avoids ringing artifacts. 2583 gamma: Component transfer functions (e.g., gamma correction) applied when reading samples from 2584 `array` and when creating output grid samples. It is specified as either a name in `GAMMAS` 2585 or a `Gamma` instance. If both `array.dtype` and `dtype` are `uint`, the default is 2586 `'power2'`. If both are non-`uint`, the default is `'identity'`. Otherwise, `gamma` or 2587 `src_gamma`/`dst_gamma` must be set. Gamma correction assumes that float values are in the 2588 range [0.0, 1.0]. 2589 src_gamma: Component transfer function used to "decode" `array` samples. 2590 Parameters `gamma` and `src_gamma` cannot both be set. 2591 dst_gamma: Component transfer function used to "encode" the output samples. 2592 Parameters `gamma` and `dst_gamma` cannot both be set. 2593 scale: Scaling factor applied to each dimension of the source domain when it is mapped onto 2594 the destination domain. 2595 translate: Offset applied to each dimension of the scaled source domain when it is mapped onto 2596 the destination domain. 2597 precision: Inexact precision of intermediate computations. If `None`, it is determined based 2598 on `array.dtype` and `dtype`. 2599 dtype: Desired data type of the output array. If `None`, it is taken to be `array.dtype`. 2600 If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range 2601 to the uint range. 2602 dim_order: Override the automatically selected order in which the grid dimensions are resized. 2603 Must contain a permutation of `range(len(shape))`. 2604 num_threads: Used to determine multithread parallelism if `array` is from `numpy`. If set to 2605 `'auto'`, it is selected automatically. Otherwise, it must be a positive integer. 2606 2607 Returns: 2608 An array of the same class as the source `array`, with shape `shape + array.shape[len(shape):]` 2609 and data type `dtype`. 2610 2611 **Example of image upsampling:** 2612 2613 >>> array = np.random.default_rng(1).random((4, 6, 3)) # 4x6 RGB image. 2614 >>> upsampled = resize(array, (128, 192)) # To 128x192 resolution. 2615 2616 <center> 2617 <img src="https://github.com/hhoppe/resampler/raw/main/media/example_array_upsampled.png"/> 2618 </center> 2619 2620 **Example of image downsampling:** 2621 2622 >>> yx = (np.moveaxis(np.indices((96, 192)), 0, -1) + (0.5, 0.5)) / 96 2623 >>> radius = np.linalg.norm(yx - (0.75, 0.5), axis=-1) 2624 >>> array = np.cos((radius + 0.1) ** 0.5 * 70.0) * 0.5 + 0.5 2625 >>> downsampled = resize(array, (24, 48)) 2626 2627 <center> 2628 <img src="https://github.com/hhoppe/resampler/raw/main/media/example_array_downsampled2.png"/> 2629 </center> 2630 2631 **Unit test:** 2632 2633 >>> result = resize(np.array([1.0, 4.0, 5.0]), shape=(4,)) 2634 >>> assert np.allclose(result, [0.74240461, 2.88088827, 4.68647155, 5.02641199]) 2635 """ 2636 arraylib = _arr_arraylib(array) 2637 array_dtype = _arr_dtype(array) 2638 if not np.issubdtype(array_dtype, np.number): 2639 raise ValueError(f'Type {array_dtype} is not numeric.') 2640 shape2 = tuple(shape) 2641 array_ndim = len(_arr_shape(array)) 2642 if not 0 < len(shape2) <= array_ndim: 2643 raise ValueError(f'Shape {_arr_shape(array)} cannot be resized to {shape2}.') 2644 src_shape = _arr_shape(array)[: len(shape2)] 2645 src_gridtype2, dst_gridtype2 = _get_gridtypes( 2646 gridtype, src_gridtype, dst_gridtype, len(shape2), len(shape2) 2647 ) 2648 boundary2 = np.broadcast_to(np.array(boundary), len(shape2)) 2649 cval = np.broadcast_to(cval, _arr_shape(array)[len(shape2) :]) 2650 prefilter = filter if prefilter is None else prefilter 2651 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), len(shape2))] 2652 prefilter2 = [_get_filter(f) for f in np.broadcast_to(np.array(prefilter), len(shape2))] 2653 dtype = array_dtype if dtype is None else np.dtype(dtype) 2654 src_gamma2, dst_gamma2 = _get_src_dst_gamma(gamma, src_gamma, dst_gamma, array_dtype, dtype) 2655 scale2 = np.broadcast_to(np.array(scale), len(shape2)) 2656 translate2 = np.broadcast_to(np.array(translate), len(shape2)) 2657 del shape, src_gridtype, dst_gridtype, boundary, filter, prefilter 2658 del src_gamma, dst_gamma, scale, translate 2659 precision = _get_precision(precision, [array_dtype, dtype], []) 2660 weight_precision = _real_precision(precision) 2661 2662 is_noop = ( 2663 all(src == dst for src, dst in zip(src_shape, shape2, strict=True)) 2664 and all(gt1 == gt2 for gt1, gt2 in zip(src_gridtype2, dst_gridtype2, strict=True)) 2665 and all(f.interpolating for f in prefilter2) 2666 and np.all(scale2 == 1.0) 2667 and np.all(translate2 == 0.0) 2668 and src_gamma2 == dst_gamma2 2669 ) 2670 if is_noop: 2671 return array 2672 2673 if dim_order is None: 2674 dim_order = _arr_best_dims_order_for_resize(array, shape2) 2675 else: 2676 dim_order = tuple(dim_order) 2677 if sorted(dim_order) != list(range(len(shape2))): 2678 raise ValueError(f'{dim_order} not a permutation of {list(range(len(shape2)))}.') 2679 2680 array = src_gamma2.decode(array, precision) 2681 cval = _arr_numpy(src_gamma2.decode(cval, precision)) 2682 2683 can_use_fast_box_downsampling = ( 2684 _USING_NUMBA 2685 and arraylib == 'numpy' 2686 and len(shape2) == 2 2687 and array_ndim in (2, 3) 2688 and all(src > dst for src, dst in zip(src_shape, shape2, strict=True)) 2689 and all(src % dst == 0 for src, dst in zip(src_shape, shape2, strict=True)) 2690 and all(gridtype.name == 'dual' for gridtype in src_gridtype2) 2691 and all(gridtype.name == 'dual' for gridtype in dst_gridtype2) 2692 and all(f.name in ('box', 'trapezoid') for f in prefilter2) 2693 and np.all(scale2 == 1.0) 2694 and np.all(translate2 == 0.0) 2695 ) 2696 if can_use_fast_box_downsampling: 2697 array2 = _downsample_in_2d_using_box_filter(cast(_NDArray, array), shape2) 2698 return cast(_Array, dst_gamma2.encode(array2, dtype)) 2699 2700 # Multidimensional resize can be expressed using einsum() with multiple per-dim resize matrices, 2701 # e.g., as in jax.image.resize(). A benefit is to seek the optimal order of multiplications. 2702 # However, efficiency often requires sparse resize matrices, which are unsupported in einsum(). 2703 # Sparse tensors requested for tf.einsum: https://github.com/tensorflow/tensorflow/issues/43497 2704 # https://github.com/tensor-compiler/taco: C++ library that computes tensor algebra expressions 2705 # on sparse and dense tensors; however it does not interoperate with tensorflow, torch, or jax. 2706 2707 for dim in dim_order: 2708 skip_resize_on_this_dim = ( 2709 shape2[dim] == _arr_shape(array)[dim] 2710 and scale2[dim] == 1.0 2711 and translate2[dim] == 0.0 2712 and filter2[dim].interpolating 2713 ) 2714 if skip_resize_on_this_dim: 2715 continue 2716 2717 def get_is_minification() -> bool: 2718 src_in_samples = src_gridtype2[dim].size_in_samples(_arr_shape(array)[dim]) # noqa: B023 2719 dst_in_samples = dst_gridtype2[dim].size_in_samples(shape2[dim]) # noqa: B023 2720 return dst_in_samples / src_in_samples * scale2[dim] < 1.0 # noqa: B023 2721 2722 is_minification = get_is_minification() 2723 boundary_dim = boundary2[dim] 2724 if boundary_dim == 'auto': 2725 boundary_dim = 'clamp' if is_minification else 'reflect' 2726 boundary_dim = _get_boundary(boundary_dim) 2727 resize_matrix, cval_weight = _create_resize_matrix( 2728 _arr_shape(array)[dim], 2729 shape2[dim], 2730 src_gridtype=src_gridtype2[dim], 2731 dst_gridtype=dst_gridtype2[dim], 2732 boundary=boundary_dim, 2733 filter=filter2[dim], 2734 prefilter=prefilter2[dim], 2735 scale=scale2[dim], 2736 translate=translate2[dim], 2737 dtype=weight_precision, 2738 arraylib=arraylib, 2739 ) 2740 2741 array_dim: _Array = _arr_moveaxis(array, dim, 0) 2742 array_flat: Any = _arr_reshape(array_dim, (_arr_shape(array_dim)[0], -1)) 2743 array_flat = _arr_possibly_make_contiguous(array_flat) 2744 if not is_minification and filter2[dim].requires_digital_filter: 2745 array_flat = _apply_digital_filter_1d( 2746 array_flat, src_gridtype2[dim], boundary_dim, cval, filter2[dim] 2747 ) 2748 2749 array_flat = _arr_matmul_sparse_dense(resize_matrix, array_flat, num_threads=num_threads) 2750 if cval_weight is not None: 2751 cval_flat = np.broadcast_to(cval, _arr_shape(array_dim)[1:]).reshape(-1) 2752 cval_weight2: Any = cval_weight 2753 if np.issubdtype(array_dtype, np.complexfloating): 2754 cval_weight2 = _arr_astype(cval_weight2, array_dtype) # (Only necessary for 'tensorflow'.) 2755 array_flat += cval_weight2[:, None] * cval_flat 2756 2757 if is_minification and filter2[dim].requires_digital_filter: # use prefilter2[dim]? 2758 array_flat = _apply_digital_filter_1d( 2759 array_flat, dst_gridtype2[dim], boundary_dim, cval, filter2[dim] 2760 ) 2761 array_dim = _arr_reshape(array_flat, (_arr_shape(array_flat)[0], *_arr_shape(array_dim)[1:])) 2762 array = _arr_moveaxis(array_dim, 0, dim) 2763 2764 array = dst_gamma2.encode(cast(_Array, array), dtype) 2765 return array 2766 2767 2768_original_resize = resize 2769 2770 2771def resize_in_arraylib(array: _NDArray, /, *args: Any, arraylib: str, **kwargs: Any) -> _NDArray: 2772 """Evaluate the `resize()` operation using the specified array library from `ARRAYLIBS`.""" 2773 _check_eq(_arr_arraylib(array), 'numpy') 2774 return _arr_numpy(_original_resize(_make_array(array, arraylib), *args, **kwargs)) 2775 2776 2777def resize_in_numpy(array: _NDArray, /, *args: Any, **kwargs: Any) -> _NDArray: 2778 """Evaluate the `resize()` operation using the `numpy` library.""" 2779 return resize_in_arraylib(array, *args, arraylib='numpy', **kwargs) 2780 2781 2782def resize_in_torch(array: _NDArray, /, *args: Any, **kwargs: Any) -> _NDArray: 2783 """Evaluate the `resize()` operation using the `torch` library.""" 2784 return resize_in_arraylib(array, *args, arraylib='torch', **kwargs) 2785 2786 2787def resize_in_jax(array: _NDArray, /, *args: Any, **kwargs: Any) -> _NDArray: 2788 """Evaluate the `resize()` operation using the `jax` library.""" 2789 return resize_in_arraylib(array, *args, arraylib='jax', **kwargs) 2790 2791 2792def _resize_possibly_in_arraylib( 2793 array: _AnyArray, /, *args: Any, arraylib: str, **kwargs: Any 2794) -> _AnyArray: 2795 """If `array` is from numpy, evaluate `resize()` using the array library from `ARRAYLIBS`.""" 2796 if _arr_arraylib(array) == 'numpy': 2797 return _arr_numpy( 2798 _original_resize(_make_array(cast(_NDArray, array), arraylib), *args, **kwargs) 2799 ) 2800 return _original_resize(cast(Any, array), *args, **kwargs) 2801 2802 2803@functools.cache 2804def _create_jaxjit_resize() -> Callable[..., Any]: 2805 """Lazily invoke `jax.jit` on `resize`.""" 2806 import jax 2807 2808 jitted: Any = jax.jit( 2809 _original_resize, 2810 static_argnums=(1,), 2811 static_argnames=list(_original_resize.__kwdefaults__ or []), 2812 ) 2813 return jitted 2814 2815 2816def jaxjit_resize(array: _Array, /, *args: Any, **kwargs: Any) -> _Array: 2817 """Compute `resize` but with resize function jitted using Jax.""" 2818 return _create_jaxjit_resize()(array, *args, **kwargs) # pylint: disable=not-callable 2819 2820 2821def uniform_resize( 2822 array: _Array, 2823 /, 2824 shape: Iterable[int], 2825 *, 2826 object_fit: Literal['contain', 'cover'] = 'contain', 2827 gridtype: str | Gridtype | None = None, 2828 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2829 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2830 boundary: str | Boundary | Iterable[str | Boundary] = 'natural', # Instead of 'auto' default. 2831 scale: float | Iterable[float] = 1.0, 2832 translate: float | Iterable[float] = 0.0, 2833 **kwargs: Any, 2834) -> _Array: 2835 """Resample `array` onto a grid with resolution `shape` but with uniform scaling. 2836 2837 Calls function `resize` with `scale` and `translate` set such that the aspect ratio of `array` 2838 is preserved. The effect is similar to CSS `object-fit: contain`. 2839 The parameter `boundary` (whose default is changed to `'natural'`) determines the values assigned 2840 outside the source domain. 2841 2842 Args: 2843 array: Regular grid of source sample values. 2844 shape: The number of grid samples in each coordinate dimension of the output array. The source 2845 `array` must have at least as many dimensions as `len(shape)`. 2846 object_fit: Like CSS `object-fit`. If `'contain'`, `array` is resized uniformly to fit within 2847 `shape`. If `'cover'`, `array` is resized to fully cover `shape`. 2848 gridtype: Placement of samples on all dimensions of both the source and output domain grids. 2849 src_gridtype: Placement of the samples in the source domain grid for each dimension. 2850 dst_gridtype: Placement of the samples in the output domain grid for each dimension. 2851 boundary: The reconstruction boundary rule for each dimension in `shape`, specified as either 2852 a name in `BOUNDARIES` or a `Boundary` instance. The default is `'natural'`, which assigns 2853 `cval` to output points that map outside the source unit domain. 2854 scale: Parameter may not be specified. 2855 translate: Parameter may not be specified. 2856 **kwargs: Additional parameters for `resize` function (including `cval`). 2857 2858 Returns: 2859 An array with shape `shape + array.shape[len(shape):]`. 2860 2861 >>> uniform_resize(np.ones((2, 2)), (2, 4), filter='trapezoid') 2862 array([[0., 1., 1., 0.], 2863 [0., 1., 1., 0.]]) 2864 2865 >>> uniform_resize(np.ones((4, 8)), (2, 7), filter='trapezoid') 2866 array([[0. , 0.5, 1. , 1. , 1. , 0.5, 0. ], 2867 [0. , 0.5, 1. , 1. , 1. , 0.5, 0. ]]) 2868 2869 >>> a = np.arange(6.0).reshape(2, 3) 2870 >>> uniform_resize(a, (2, 2), filter='trapezoid', object_fit='cover') 2871 array([[0.5, 1.5], 2872 [3.5, 4.5]]) 2873 """ 2874 if scale != 1.0 or translate != 0.0: 2875 raise ValueError('`uniform_resize()` does not accept `scale` or `translate` parameters.') 2876 shape = tuple(shape) 2877 array_ndim = len(_arr_shape(array)) 2878 if not 0 < len(shape) <= array_ndim: 2879 raise ValueError(f'Shape {_arr_shape(array)} cannot be resized to {shape}.') 2880 src_gridtype2, dst_gridtype2 = _get_gridtypes( 2881 gridtype, src_gridtype, dst_gridtype, len(shape), len(shape) 2882 ) 2883 raw_scales = [ 2884 dst_gridtype2[dim].size_in_samples(shape[dim]) 2885 / src_gridtype2[dim].size_in_samples(_arr_shape(array)[dim]) 2886 for dim in range(len(shape)) 2887 ] 2888 scale0 = {'contain': min(raw_scales), 'cover': max(raw_scales)}[object_fit] 2889 scale2 = scale0 / np.array(raw_scales) 2890 translate = (1.0 - scale2) / 2 2891 return resize(array, shape, boundary=boundary, scale=scale2, translate=translate, **kwargs) 2892 2893 2894_MAX_BLOCK_SIZE_RECURSING = -999 # Special value to indicate re-invocation on partitioned blocks. 2895 2896 2897def resample( 2898 array: _Array, 2899 /, 2900 coords: _ArrayLike, 2901 *, 2902 gridtype: str | Gridtype | Iterable[str | Gridtype] = 'dual', 2903 boundary: str | Boundary | Iterable[str | Boundary] = 'auto', 2904 cval: _ArrayLike = 0.0, 2905 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 2906 prefilter: str | Filter | Iterable[str | Filter] | None = None, 2907 gamma: str | Gamma | None = None, 2908 src_gamma: str | Gamma | None = None, 2909 dst_gamma: str | Gamma | None = None, 2910 jacobian: _ArrayLike | None = None, 2911 precision: _DTypeLike | None = None, 2912 dtype: _DTypeLike | None = None, 2913 max_block_size: int = 40_000, 2914 debug: bool = False, 2915) -> _Array: 2916 """Interpolate `array` (a grid of samples) at specified unit-domain coordinates `coords`. 2917 2918 The last dimension of `coords` contains unit-domain coordinates at which to interpolate the 2919 domain grid samples in `array`. 2920 2921 The number of coordinates (`coords.shape[-1]`) determines how to interpret `array`: its first 2922 `coords.shape[-1]` dimensions define the grid, and the remaining dimensions describe each grid 2923 sample (e.g., scalar, vector, tensor). 2924 2925 Concretely, the grid has shape `array.shape[:coords.shape[-1]]` and each grid sample has shape 2926 `array.shape[coords.shape[-1]:]`. 2927 2928 Examples include: 2929 2930 - Resample a grayscale image with `array.shape = height, width` onto a new grayscale image with 2931 `new.shape = height2, width2` by using `coords.shape = height2, width2, 2`. 2932 2933 - Resample an RGB image with `array.shape = height, width, 3` onto a new RGB image with 2934 `new.shape = height2, width2, 3` by using `coords.shape = height2, width2, 2`. 2935 2936 - Sample an RGB image at `num` 2D points along a line segment by using `coords.shape = num, 2`. 2937 2938 - Sample an RGB image at a single 2D point by using `coords.shape = (2,)`. 2939 2940 - Sample a 3D grid of 3x3 Jacobians with `array.shape = nz, ny, nx, 3, 3` along a 2D plane by 2941 using `coords.shape = height, width, 3`. 2942 2943 - Map a grayscale image through a color map by using `array.shape = 256, 3` and 2944 `coords.shape = height, width`. 2945 2946 Args: 2947 array: Regular grid of source sample values, as an array object recognized by `ARRAYLIBS`. 2948 The array must have numeric type. The coordinate dimensions appear first, and 2949 each grid sample may have an arbitrary shape. Each grid dimension must be at least 1 for 2950 a `'dual'` grid or at least 2 for a `'primal'` grid. 2951 coords: Grid of points at which to resample `array`. The point coordinates are in the last 2952 dimension of `coords`. The domain associated with the source grid is a unit hypercube, 2953 i.e. with a range [0, 1] on each coordinate dimension. The output grid has shape 2954 `coords.shape[:-1]` and each of its grid samples has shape `array.shape[coords.shape[-1]:]`. 2955 gridtype: Placement of the samples in the source domain grid for each dimension, specified as 2956 either a name in `GRIDTYPES` or a `Gridtype` instance. It defaults to `'dual'`. 2957 boundary: The reconstruction boundary rule for each dimension in `coords.shape[-1]`, specified 2958 as either a name in `BOUNDARIES` or a `Boundary` instance. The special value `'auto'` uses 2959 `'reflect'` for upsampling and `'clamp'` for downsampling. 2960 cval: Constant value used beyond the samples by some boundary rules. It must be broadcastable 2961 onto the shape `array.shape[coords.shape[-1]:]`. It is subject to `src_gamma`. 2962 filter: The reconstruction kernel for each dimension in `coords.shape[-1]`, specified as either 2963 a filter name in `FILTERS` or a `Filter` instance. 2964 prefilter: The prefilter kernel for each dimension in `coords.shape[:-1]`, specified as either 2965 a filter name in `FILTERS` or a `Filter` instance. It is used during downsampling 2966 (i.e., minification). If `None`, it inherits the value of `filter`. 2967 gamma: Component transfer functions (e.g., gamma correction) applied when reading samples 2968 from `array` and when creating output grid samples. It is specified as either a name in 2969 `GAMMAS` or a `Gamma` instance. If both `array.dtype` and `dtype` are `uint`, the default 2970 is `'power2'`. If both are non-`uint`, the default is `'identity'`. Otherwise, `gamma` or 2971 `src_gamma`/`dst_gamma` must be set. Gamma correction assumes that float values are in the 2972 range [0.0, 1.0]. 2973 src_gamma: Component transfer function used to "decode" `array` samples. 2974 Parameters `gamma` and `src_gamma` cannot both be set. 2975 dst_gamma: Component transfer function used to "encode" the output samples. 2976 Parameters `gamma` and `dst_gamma` cannot both be set. 2977 jacobian: Optional array, which must be broadcastable onto the shape 2978 `coords.shape[:-1] + (coords.shape[-1], coords.shape[-1])`, storing for each point in the 2979 output grid the Jacobian matrix of the map from the unit output domain to the unit source 2980 domain. If omitted, it is estimated by computing finite differences on `coords`. 2981 precision: Inexact precision of intermediate computations. If `None`, it is determined based 2982 on `array.dtype`, `coords.dtype`, and `dtype`. 2983 dtype: Desired data type of the output array. If `None`, it is taken to be `array.dtype`. 2984 If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range 2985 to the uint range. 2986 max_block_size: If nonzero, maximum number of grid points in `coords` before the resampling 2987 evaluation gets partitioned into smaller blocks for reduced memory usage and better caching. 2988 debug: Show internal information. 2989 2990 Returns: 2991 A new sample grid of shape `coords.shape[:-1]`, represented as an array of shape 2992 `coords.shape[:-1] + array.shape[coords.shape[-1]:]`, of the same array library type as 2993 the source array. 2994 2995 **Example of resample operation:** 2996 2997 <center> 2998 <img src="https://github.com/hhoppe/resampler/raw/main/media/example_warp_coords.png"/> 2999 </center> 3000 3001 For reference, the identity resampling for a scalar-valued grid with the default grid-type 3002 `'dual'` is: 3003 3004 >>> array = np.random.default_rng(1).random((5, 7, 3)) 3005 >>> coords = (np.moveaxis(np.indices(array.shape), 0, -1) + 0.5) / array.shape 3006 >>> new_array = resample(array, coords) 3007 >>> assert np.allclose(new_array, array) 3008 3009 It is more efficient to use the function `resize` for the special case where the `coords` are 3010 obtained as simple scaling and translation of a new regular grid over the source domain: 3011 3012 >>> scale, translate, new_shape = (1.1, 1.2), (0.1, -0.2), (6, 8) 3013 >>> coords = (np.moveaxis(np.indices(new_shape), 0, -1) + 0.5) / new_shape 3014 >>> coords = (coords - translate) / scale 3015 >>> resampled = resample(array, coords) 3016 >>> resized = resize(array, new_shape, scale=scale, translate=translate) 3017 >>> assert np.allclose(resampled, resized) 3018 """ 3019 arraylib = _arr_arraylib(array) 3020 if len(_arr_shape(array)) == 0: 3021 array = _arr_reshape(array, (1,)) 3022 coords = np.atleast_1d(coords) 3023 if not np.issubdtype(_arr_dtype(array), np.number): 3024 raise ValueError(f'Type {_arr_dtype(array)} is not numeric.') 3025 if not np.issubdtype(coords.dtype, np.floating): 3026 raise ValueError(f'Type {coords.dtype} is not floating.') 3027 array_ndim = len(_arr_shape(array)) 3028 if coords.ndim == 1 and coords.shape[0] > 1 and array_ndim == 1: 3029 coords = coords[:, None] 3030 grid_ndim = coords.shape[-1] 3031 grid_shape = _arr_shape(array)[:grid_ndim] 3032 sample_shape = _arr_shape(array)[grid_ndim:] 3033 resampled_ndim = coords.ndim - 1 3034 resampled_shape = coords.shape[:-1] 3035 if grid_ndim > array_ndim: 3036 raise ValueError( 3037 f'There are more coordinate dimensions ({grid_ndim}) in {coords=}' 3038 f' than in array.shape={_arr_shape(array)}.' 3039 ) 3040 gridtype2 = [_get_gridtype(g) for g in np.broadcast_to(np.array(gridtype), grid_ndim)] 3041 boundary2 = np.broadcast_to(np.array(boundary), grid_ndim).tolist() 3042 cval = np.broadcast_to(cval, sample_shape) 3043 prefilter = filter if prefilter is None else prefilter 3044 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), grid_ndim)] 3045 prefilter2 = [_get_filter(f) for f in np.broadcast_to(np.array(prefilter), resampled_ndim)] 3046 dtype = _arr_dtype(array) if dtype is None else np.dtype(dtype) 3047 src_gamma2, dst_gamma2 = _get_src_dst_gamma(gamma, src_gamma, dst_gamma, _arr_dtype(array), dtype) 3048 del gridtype, boundary, filter, prefilter, src_gamma, dst_gamma 3049 if jacobian is not None: 3050 jacobian = np.broadcast_to(jacobian, resampled_shape + (coords.shape[-1],) * 2) 3051 precision = _get_precision(precision, [_arr_dtype(array), dtype], [coords.dtype]) 3052 weight_precision = _real_precision(precision) 3053 coords = coords.astype(weight_precision, copy=False) 3054 is_minification = False # Current limitation; no prefiltering! 3055 assert max_block_size >= 0 or max_block_size == _MAX_BLOCK_SIZE_RECURSING 3056 for dim in range(grid_ndim): 3057 if boundary2[dim] == 'auto': 3058 boundary2[dim] = 'clamp' if is_minification else 'reflect' 3059 boundary2[dim] = _get_boundary(boundary2[dim]) 3060 3061 if max_block_size != _MAX_BLOCK_SIZE_RECURSING: 3062 array = src_gamma2.decode(array, precision) 3063 for dim in range(grid_ndim): 3064 assert not is_minification 3065 if filter2[dim].requires_digital_filter: 3066 array = _apply_digital_filter_1d( 3067 array, gridtype2[dim], boundary2[dim], cval, filter2[dim], axis=dim 3068 ) 3069 cval = _arr_numpy(src_gamma2.decode(cval, precision)) 3070 3071 if math.prod(resampled_shape) > max_block_size > 0: 3072 block_shape = _block_shape_with_min_size(resampled_shape, max_block_size) 3073 if debug: 3074 print(f'(resample: splitting coords into blocks {block_shape}).') 3075 coord_blocks = _split_array_into_blocks(coords, block_shape) 3076 3077 def process_block(coord_block: _NDArray) -> _Array: 3078 return resample( 3079 cast(Any, array), 3080 coord_block, 3081 gridtype=gridtype2, 3082 boundary=boundary2, 3083 cval=cval, 3084 filter=filter2, 3085 prefilter=prefilter2, 3086 src_gamma='identity', 3087 dst_gamma=dst_gamma2, 3088 jacobian=jacobian, 3089 precision=precision, 3090 dtype=dtype, 3091 max_block_size=_MAX_BLOCK_SIZE_RECURSING, 3092 ) 3093 3094 result_blocks = _map_function_over_blocks(coord_blocks, process_block) 3095 array = _merge_array_from_blocks(result_blocks) 3096 return array 3097 3098 # A concrete example of upsampling: 3099 # array = np.ones((5, 7, 3)) # source RGB image has height=5 width=7 3100 # coords = np.random.default_rng(1).random((8, 9, 2)) # output RGB image has height=8 width=9 3101 # resample(array, coords, filter=('cubic', 'lanczos3')) 3102 # grid_shape = 5, 7 grid_ndim = 2 3103 # resampled_shape = 8, 9 resampled_ndim = 2 3104 # sample_shape = (3,) 3105 # src_float_index.shape = 8, 9 3106 # src_first_index.shape = 8, 9 3107 # sample_index.shape = (4,) for dim == 0, then (6,) for dim == 1 3108 # weight = [shape(8, 9, 4), shape(8, 9, 6)] 3109 # src_index = [shape(8, 9, 4), shape(8, 9, 6)] 3110 3111 # Both:[shape(8, 9, 4), shape(8, 9, 6)] 3112 weight: list[_NDArray] = [np.array([]) for _ in range(grid_ndim)] 3113 src_index: list[_NDArray] = [np.array([]) for _ in range(grid_ndim)] 3114 uses_cval = False 3115 all_num_samples = [] # will be [4, 6] 3116 3117 for dim in range(grid_ndim): 3118 src_size = grid_shape[dim] # scalar 3119 coords_dim = coords[..., dim] # (8, 9) 3120 radius = filter2[dim].radius # scalar 3121 num_samples = int(np.ceil(radius * 2)) # scalar 3122 all_num_samples.append(num_samples) 3123 3124 boundary_dim = boundary2[dim] 3125 coords_dim = boundary_dim.preprocess_coordinates(coords_dim) 3126 3127 # Sample positions mapped back to source unit domain [0, 1]. 3128 src_float_index = gridtype2[dim].index_from_point(coords_dim, src_size) # (8, 9) 3129 src_first_index = ( 3130 np.floor(src_float_index + (0.5 if num_samples % 2 == 1 else 0.0)).astype(np.int32) 3131 - (num_samples - 1) // 2 3132 ) # (8, 9) 3133 3134 sample_index = np.arange(num_samples, dtype=np.int32) # (4,) then (6,) 3135 src_index[dim] = src_first_index[..., None] + sample_index # (8, 9, 4) then (8, 9, 6) 3136 if filter2[dim].name == 'trapezoid': 3137 # (It might require changing the filter radius at every sample.) 3138 raise ValueError('resample() cannot use adaptive `trapezoid` filter.') 3139 if filter2[dim].name == 'impulse': 3140 weight[dim] = np.ones_like(src_index[dim], weight_precision) 3141 else: 3142 x = src_float_index[..., None] - src_index[dim].astype(weight_precision) 3143 weight[dim] = filter2[dim](x).astype(weight_precision, copy=False) 3144 if filter2[dim].name != 'narrowbox' and ( 3145 is_minification or not filter2[dim].partition_of_unity 3146 ): 3147 weight[dim] = weight[dim] / weight[dim].sum(axis=-1)[..., None] 3148 3149 src_index[dim], weight[dim] = boundary_dim.apply( 3150 src_index[dim], weight[dim], coords_dim, src_size, gridtype2[dim] 3151 ) 3152 if boundary_dim.uses_cval or filter2[dim].name == 'narrowbox': 3153 uses_cval = True 3154 3155 # Gather the samples. 3156 3157 # Recall that src_index = [shape(8, 9, 4), shape(8, 9, 6)]. 3158 src_index_expanded = [] 3159 for dim in range(grid_ndim): 3160 src_index_dim = np.moveaxis( 3161 src_index[dim].reshape(src_index[dim].shape + (1,) * (grid_ndim - 1)), 3162 resampled_ndim, 3163 resampled_ndim + dim, 3164 ) 3165 src_index_expanded.append(src_index_dim) 3166 indices = tuple(src_index_expanded) # (shape(8, 9, 4, 1), shape(8, 9, 1, 6)) 3167 samples = _arr_getitem(array, indices) # (8, 9, 4, 6, 3) 3168 3169 # Indirectly derive samples.ndim (which is unavailable during Tensorflow grad computation). 3170 samples_ndim = resampled_ndim + grid_ndim + len(sample_shape) 3171 3172 # Compute an Einstein summation over the samples and each of the per-dimension weights. 3173 3174 def label(dims: Iterable[int]) -> str: 3175 return ''.join(chr(ord('a') + i) for i in dims) 3176 3177 operands: list[Any] = [samples] # (8, 9, 4, 6, 3) 3178 assert samples_ndim < 26 # Letters 'a' through 'z'. 3179 labels = [label(range(samples_ndim))] # ['abcde'] 3180 for dim in range(grid_ndim): 3181 operands.append(weight[dim]) # (8, 9, 4), then (8, 9, 6) 3182 labels.append(label(list(range(resampled_ndim)) + [resampled_ndim + dim])) # 'abc' then 'abd' 3183 output_label = label( 3184 list(range(resampled_ndim)) + list(range(resampled_ndim + grid_ndim, samples_ndim)) 3185 ) # 'abe' 3186 subscripts = ','.join(labels) + '->' + output_label # 'abcde,abc,abd->abe' 3187 # Starting in numpy 2.0, np.einsum() outputs np.float64 even with all np.float32 inputs; 3188 # GPT: "aligns np.einsum with other functions where intermediate calculations use higher 3189 # precision (np.float64) regardless of input type when floating-point arithmetic is involved." 3190 # we could explicitly add the parameter `dtype=precision`. 3191 array = _arr_einsum(subscripts, *operands) # (8, 9, 3) 3192 3193 # Gathering `samples` is the memory bottleneck. It would be ideal if the gather() and einsum() 3194 # computations could be fused. In Jax, https://github.com/google/jax/issues/3206 suggests 3195 # that this may become possible. In any case, for large outputs it helps to partition the 3196 # evaluation over output tiles (using max_block_size). 3197 3198 if uses_cval: 3199 cval_weight = 1.0 - np.multiply.reduce( 3200 [weight[dim].sum(axis=-1) for dim in range(resampled_ndim)] 3201 ) # (8, 9) 3202 cval_weight_reshaped = cval_weight.reshape(cval_weight.shape + (1,) * len(sample_shape)) 3203 array += _make_array((cval_weight_reshaped * cval).astype(precision, copy=False), arraylib) 3204 3205 array = dst_gamma2.encode(array, dtype) 3206 return array 3207 3208 3209def resample_affine( 3210 array: _Array, 3211 /, 3212 shape: Iterable[int], 3213 matrix: _ArrayLike, 3214 *, 3215 gridtype: str | Gridtype | None = None, 3216 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 3217 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 3218 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 3219 prefilter: str | Filter | Iterable[str | Filter] | None = None, 3220 precision: _DTypeLike | None = None, 3221 dtype: _DTypeLike | None = None, 3222 **kwargs: Any, 3223) -> _Array: 3224 """Resample a source array using an affinely transformed grid of given shape. 3225 3226 The `matrix` transformation can be linear, 3227 `source_point = matrix @ destination_point`, 3228 or it can be affine where the last matrix column is an offset vector, 3229 `source_point = matrix @ (destination_point, 1.0)`. 3230 3231 Args: 3232 array: Regular grid of source sample values, as an array object recognized by `ARRAYLIBS`. 3233 The array must have numeric type. The number of grid dimensions is determined from 3234 `matrix.shape[0]`; the remaining dimensions are for each sample value and are all 3235 linearly interpolated. 3236 shape: Dimensions of the desired destination grid. The number of destination grid dimensions 3237 may be different from that of the source grid. 3238 matrix: 2D array for a linear or affine transform from unit-domain destination points 3239 (in a space with `len(shape)` dimensions) into unit-domain source points (in a space with 3240 `matrix.shape[0]` dimensions). If the matrix has `len(shape) + 1` columns, the last column 3241 is the affine offset (i.e., translation). 3242 gridtype: Placement of samples on all dimensions of both the source and output domain grids, 3243 specified as either a name in `GRIDTYPES` or a `Gridtype` instance. It defaults to `'dual'` 3244 if `gridtype`, `src_gridtype`, and `dst_gridtype` are all kept `None`. 3245 src_gridtype: Placement of samples in the source domain grid for each dimension. 3246 Parameters `gridtype` and `src_gridtype` cannot both be set. 3247 dst_gridtype: Placement of samples in the output domain grid for each dimension. 3248 Parameters `gridtype` and `dst_gridtype` cannot both be set. 3249 filter: The reconstruction kernel for each dimension in `matrix.shape[0]`, specified as either 3250 a filter name in `FILTERS` or a `Filter` instance. 3251 prefilter: The prefilter kernel for each dimension in `len(shape)`, specified as either 3252 a filter name in `FILTERS` or a `Filter` instance. It is used during downsampling 3253 (i.e., minification). If `None`, it inherits the value of `filter`. 3254 precision: Inexact precision of intermediate computations. If `None`, it is determined based 3255 on `array.dtype` and `dtype`. 3256 dtype: Desired data type of the output array. If `None`, it is taken to be `array.dtype`. 3257 If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range 3258 to the uint range. 3259 **kwargs: Additional parameters for `resample` function. 3260 3261 Returns: 3262 An array of the same class as the source `array`, representing a grid with specified `shape`, 3263 where each grid value is resampled from `array`. Thus the shape of the returned array is 3264 `shape + array.shape[matrix.shape[0]:]`. 3265 """ 3266 shape = tuple(shape) 3267 matrix = np.asarray(matrix) 3268 dst_ndim = len(shape) 3269 if matrix.ndim != 2: 3270 raise ValueError(f'Array {matrix} is not 2D matrix.') 3271 src_ndim = matrix.shape[0] 3272 # grid_shape = array.shape[:src_ndim] 3273 is_affine = matrix.shape[1] == dst_ndim + 1 3274 if src_ndim > len(_arr_shape(array)): 3275 raise ValueError( 3276 f'Matrix {matrix} has more rows ({matrix.shape[0]}) than ndim in' 3277 f' array.shape={_arr_shape(array)}.' 3278 ) 3279 if matrix.shape[1] != dst_ndim and not is_affine: 3280 raise ValueError( 3281 f'Matrix has {matrix.shape=}, but we expect either {dst_ndim} or {dst_ndim + 1} columns.' 3282 ) 3283 src_gridtype2, dst_gridtype2 = _get_gridtypes( 3284 gridtype, src_gridtype, dst_gridtype, src_ndim, dst_ndim 3285 ) 3286 prefilter = filter if prefilter is None else prefilter 3287 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), src_ndim)] 3288 prefilter2 = [_get_filter(f) for f in np.broadcast_to(np.array(prefilter), dst_ndim)] 3289 del src_gridtype, dst_gridtype, filter, prefilter 3290 dtype = _arr_dtype(array) if dtype is None else np.dtype(dtype) 3291 precision = _get_precision(precision, [_arr_dtype(array), dtype], []) 3292 weight_precision = _real_precision(precision) 3293 3294 dst_position_list = [] # per dimension 3295 for dim in range(dst_ndim): 3296 dst_size = shape[dim] 3297 dst_index = np.arange(dst_size, dtype=weight_precision) 3298 dst_position_list.append(dst_gridtype2[dim].point_from_index(dst_index, dst_size)) 3299 dst_position = np.meshgrid(*dst_position_list, indexing='ij') 3300 3301 linear_matrix = matrix[:, :-1] if is_affine else matrix 3302 src_position = np.tensordot(linear_matrix, dst_position, 1) 3303 coords = np.moveaxis(src_position, 0, -1) 3304 if is_affine: 3305 coords += matrix[:, -1] 3306 3307 # TODO: Based on grid_shape, shape, linear_matrix, and prefilter, determine a 3308 # convolution prefilter and apply it to bandlimit 'array', using boundary for padding. 3309 3310 return resample( 3311 array, 3312 coords, 3313 gridtype=src_gridtype2, 3314 filter=filter2, 3315 prefilter=prefilter2, 3316 precision=precision, 3317 dtype=dtype, 3318 **kwargs, 3319 ) 3320 3321 3322def _resize_using_resample( 3323 array: _Array, 3324 /, 3325 shape: Iterable[int], 3326 *, 3327 scale: _ArrayLike = 1.0, 3328 translate: _ArrayLike = 0.0, 3329 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 3330 fallback: bool = False, 3331 **kwargs: Any, 3332) -> _Array: 3333 """Use the more general `resample` operation for `resize`, as a debug tool.""" 3334 shape = tuple(shape) 3335 scale = np.broadcast_to(scale, len(shape)) 3336 translate = np.broadcast_to(translate, len(shape)) 3337 # TODO: let resample() do prefiltering for proper downsampling. 3338 src_shape = _arr_shape(array)[: len(shape)] 3339 has_minification = np.any(np.array(shape) < src_shape) or np.any(scale < 1.0) 3340 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), len(shape))] 3341 has_auto_trapezoid = any(f.name == 'trapezoid' for f in filter2) 3342 if fallback and (has_minification or has_auto_trapezoid): 3343 return _original_resize(array, shape, scale=scale, translate=translate, filter=filter, **kwargs) 3344 offset = -translate / scale 3345 matrix = np.concatenate([np.diag(1.0 / scale), offset[:, None]], axis=1) 3346 return resample_affine(array, shape, matrix, filter=filter, **kwargs) 3347 3348 3349def rotation_about_center_in_2d( 3350 src_shape: _ArrayLike, 3351 /, 3352 angle: float, 3353 *, 3354 new_shape: _ArrayLike | None = None, 3355 scale: float = 1.0, 3356) -> _NDArray: 3357 """Return the 3x3 matrix mapping destination into a source unit domain. 3358 3359 The returned matrix accounts for the possibly non-square domain shapes. 3360 3361 Args: 3362 src_shape: Resolution `(ny, nx)` of the source domain grid. 3363 angle: Angle in radians (positive from x to y axis) applied when mapping the source domain 3364 onto the destination domain. 3365 new_shape: Resolution `(ny, nx)` of the destination domain grid; it defaults to `src_shape`. 3366 scale: Scaling factor applied when mapping the source domain onto the destination domain. 3367 """ 3368 3369 def translation_matrix(vector: _NDArray) -> _NDArray: 3370 matrix = np.eye(len(vector) + 1) 3371 matrix[:-1, -1] = vector 3372 return matrix 3373 3374 def scaling_matrix(scale: _NDArray) -> _NDArray: 3375 return np.diag(tuple(scale) + (1.0,)) 3376 3377 def rotation_matrix_2d(angle: float) -> _NDArray: 3378 cos, sin = np.cos(angle), np.sin(angle) 3379 return np.array([[cos, sin, 0], [-sin, cos, 0], [0, 0, 1]]) 3380 3381 src_shape = np.asarray(src_shape) 3382 new_shape = src_shape if new_shape is None else np.asarray(new_shape) 3383 _check_eq(src_shape.shape, (2,)) 3384 _check_eq(new_shape.shape, (2,)) 3385 half = np.array([0.5, 0.5]) 3386 matrix = ( 3387 translation_matrix(half) 3388 @ scaling_matrix(min(src_shape) / src_shape) 3389 @ rotation_matrix_2d(angle) 3390 @ scaling_matrix(scale * new_shape / min(new_shape)) 3391 @ translation_matrix(-half) 3392 ) 3393 assert np.allclose(matrix[-1], [0.0, 0.0, 1.0]) 3394 return matrix 3395 3396 3397def rotate_image_about_center( 3398 image: _NDArray, 3399 /, 3400 angle: float, 3401 *, 3402 new_shape: _ArrayLike | None = None, 3403 scale: float = 1.0, 3404 num_rotations: int = 1, 3405 **kwargs: Any, 3406) -> _NDArray: 3407 """Return a copy of `image` rotated about its center. 3408 3409 Args: 3410 image: Source grid samples; the first two dimensions are spatial (ny, nx). 3411 angle: Angle in radians (positive from x to y axis) applied when mapping the source domain 3412 onto the destination domain. 3413 new_shape: Resolution `(ny, nx)` of the output grid; it defaults to `image.shape[:2]`. 3414 scale: Scaling factor applied when mapping the source domain onto the destination domain. 3415 num_rotations: Number of rotations (each by `angle`). Successive resamplings are useful in 3416 analyzing the filtering quality. 3417 **kwargs: Additional parameters for `resample_affine`. 3418 """ 3419 new_shape = image.shape[:2] if new_shape is None else np.asarray(new_shape) 3420 matrix = rotation_about_center_in_2d(image.shape[:2], angle, new_shape=new_shape, scale=scale) 3421 for _ in range(num_rotations): 3422 image = resample_affine(image, new_shape, matrix[:-1], **kwargs) 3423 return image 3424 3425 3426def _pil_image_resize( 3427 array: _ArrayLike, 3428 /, 3429 shape: Iterable[int], 3430 *, 3431 filter: str, 3432 boundary: str = 'natural', 3433 cval: float = 0.0, 3434) -> _NDArray: 3435 """Invoke `PIL.Image.resize` using the same parameters as `resize`.""" 3436 import PIL.Image 3437 3438 if boundary != 'natural': 3439 raise ValueError(f"{boundary=} must equal 'natural'.") 3440 del cval 3441 array = np.asarray(array) 3442 assert 1 <= array.ndim <= 3 3443 assert np.issubdtype(array.dtype, np.floating) 3444 shape = tuple(shape) 3445 _check_eq(len(shape), 2 if array.ndim >= 2 else 1) 3446 if array.ndim == 1: 3447 return _pil_image_resize(array[None], (1, *shape), filter=filter)[0] 3448 if not hasattr(PIL.Image, 'Resampling'): # Pillow<9.0 3449 PIL.Image.Resampling = PIL.Image # type: ignore 3450 filters = { 3451 'impulse': PIL.Image.Resampling.NEAREST, 3452 'box': PIL.Image.Resampling.BOX, 3453 'triangle': PIL.Image.Resampling.BILINEAR, 3454 'hamming1': PIL.Image.Resampling.HAMMING, 3455 'cubic': PIL.Image.Resampling.BICUBIC, 3456 'lanczos3': PIL.Image.Resampling.LANCZOS, 3457 } 3458 if filter not in filters: 3459 raise ValueError(f'{filter=} not in {filters=}.') 3460 pil_resample = filters[filter] 3461 ny, nx = shape 3462 if array.ndim == 2: 3463 return np.array(PIL.Image.fromarray(array).resize((nx, ny), resample=pil_resample), array.dtype) 3464 stack = [] 3465 for channel in np.moveaxis(array, -1, 0): 3466 pil_image = PIL.Image.fromarray(channel).resize((nx, ny), resample=pil_resample) 3467 stack.append(np.array(pil_image, array.dtype)) 3468 return np.dstack(stack) 3469 3470 3471def _cv_resize( 3472 array: _ArrayLike, 3473 /, 3474 shape: Iterable[int], 3475 *, 3476 filter: str, 3477 boundary: str = 'clamp', 3478 cval: float = 0.0, 3479) -> _NDArray: 3480 """Invoke `cv.resize` using the same parameters as `resize`.""" 3481 import cv2 as cv 3482 3483 if boundary != 'clamp': 3484 raise ValueError(f"{boundary=} must equal 'clamp'.") 3485 del cval 3486 array = np.asarray(array) 3487 assert 1 <= array.ndim <= 3 3488 shape = tuple(shape) 3489 _check_eq(len(shape), 2 if array.ndim >= 2 else 1) 3490 if array.ndim == 1: 3491 return _cv_resize(array[None], (1, *shape), filter=filter)[0] 3492 filters = { 3493 'impulse': cv.INTER_NEAREST, # Or consider cv.INTER_NEAREST_EXACT. 3494 'triangle': cv.INTER_LINEAR_EXACT, # Or just cv.INTER_LINEAR. 3495 'trapezoid': cv.INTER_AREA, 3496 'sharpcubic': cv.INTER_CUBIC, 3497 'lanczos4': cv.INTER_LANCZOS4, 3498 } 3499 if filter not in filters: 3500 raise ValueError(f'{filter=} not in {filters=}.') 3501 interpolation = filters[filter] 3502 result = cv.resize(array, shape[::-1], interpolation=interpolation) 3503 if array.ndim == 3 and result.ndim == 2: 3504 assert array.shape[2] == 1 3505 return result[..., None] # Add back the last dimension dropped by cv.resize(). 3506 return result 3507 3508 3509def _scipy_ndimage_resize( 3510 array: _ArrayLike, 3511 /, 3512 shape: Iterable[int], 3513 *, 3514 filter: str, 3515 boundary: str = 'reflect', 3516 cval: float = 0.0, 3517 scale: float | Iterable[float] = 1.0, 3518 translate: float | Iterable[float] = 0.0, 3519) -> _NDArray: 3520 """Invoke `scipy.ndimage.map_coordinates` using the same parameters as `resize`.""" 3521 array = np.asarray(array) 3522 shape = tuple(shape) 3523 assert 1 <= len(shape) <= array.ndim 3524 filters = {'box': 0, 'triangle': 1} | {f'cardinal{i}': i for i in range(2, 6)} 3525 if filter not in filters: 3526 raise ValueError(f'{filter=} not in {filters=}.') 3527 order = filters[filter] 3528 boundaries = {'reflect': 'reflect', 'wrap': 'grid-wrap', 'clamp': 'nearest', 'border': 'constant'} 3529 if boundary not in boundaries: 3530 raise ValueError(f'{boundary=} not in {boundaries=}.') 3531 mode = boundaries[boundary] 3532 shape_all = shape + array.shape[len(shape) :] 3533 coords = np.moveaxis(np.indices(shape_all, array.dtype), 0, -1) 3534 coords[..., : len(shape)] = ( 3535 (coords[..., : len(shape)] + 0.5) / shape - np.asarray(translate) 3536 ) / np.asarray(scale) * np.array(array.shape)[: len(shape)] - 0.5 3537 coords = np.moveaxis(coords, -1, 0) 3538 return scipy.ndimage.map_coordinates(array, coords, order=order, mode=mode, cval=cval) 3539 3540 3541def _skimage_transform_resize( 3542 array: _ArrayLike, 3543 /, 3544 shape: Iterable[int], 3545 *, 3546 filter: str, 3547 boundary: str = 'reflect', 3548 cval: float = 0.0, 3549) -> _NDArray: 3550 """Invoke `skimage.transform.resize` using the same parameters as `resize`.""" 3551 import skimage.transform 3552 3553 array = np.asarray(array) 3554 shape = tuple(shape) 3555 assert 1 <= len(shape) <= array.ndim 3556 filters = {'box': 0, 'triangle': 1} | {f'cardinal{i}': i for i in range(2, 6)} 3557 if filter not in filters: 3558 raise ValueError(f'{filter=} not in {filters=}.') 3559 order = filters[filter] 3560 boundaries = {'reflect': 'symmetric', 'wrap': 'wrap', 'clamp': 'edge', 'border': 'constant'} 3561 if boundary not in boundaries: 3562 raise ValueError(f'{boundary=} not in {boundaries=}.') 3563 mode = boundaries[boundary] 3564 shape_all = shape + array.shape[len(shape) :] 3565 # Default anti_aliasing=None automatically enables (poor) Gaussian prefilter if downsampling. 3566 # clip=False is the default behavior in `resampler` if the output type is non-integer. 3567 return skimage.transform.resize( 3568 array, shape_all, order=order, mode=mode, cval=cval, clip=False 3569 ) # type: ignore[no-untyped-call] 3570 3571 3572_TORCH_INTERPOLATE_MODE_FROM_FILTER = { 3573 'impulse': 'nearest-exact', # ('nearest' matches buggy OpenCV's INTER_NEAREST) 3574 'trapezoid': 'area', 3575 'triangle': 'bilinear', 3576 'sharpcubic': 'bicubic', 3577} 3578 3579 3580def _torch_nn_resize( 3581 array: _ArrayLike, 3582 /, 3583 shape: Iterable[int], 3584 *, 3585 filter: str, 3586 boundary: str = 'clamp', 3587 cval: float = 0.0, 3588 antialias: bool = False, 3589) -> _TorchTensor: 3590 """Invoke `torch.nn.functional.interpolate` using the same parameters as `resize`.""" 3591 import torch 3592 3593 if filter not in _TORCH_INTERPOLATE_MODE_FROM_FILTER: 3594 raise ValueError(f'{filter=} not in {_TORCH_INTERPOLATE_MODE_FROM_FILTER=}.') 3595 if boundary != 'clamp': 3596 raise ValueError(f"{boundary=} must equal 'clamp'.") 3597 del cval 3598 a = torch.as_tensor(array) 3599 del array 3600 assert 1 <= a.ndim <= 3 3601 shape = tuple(shape) 3602 _check_eq(len(shape), 2 if a.ndim >= 2 else 1) 3603 mode = _TORCH_INTERPOLATE_MODE_FROM_FILTER[filter] 3604 3605 def local_resize(a: _TorchTensor) -> _TorchTensor: 3606 # For upsampling, BILINEAR antialias is same PSNR and slower, 3607 # and BICUBIC antialias is worse PSNR and faster. 3608 # For downsampling, antialias improves PSNR for both BILINEAR and BICUBIC. 3609 # Default align_corners=None corresponds to False which is what we desire. 3610 return torch.nn.functional.interpolate(a, shape, mode=mode, antialias=antialias) 3611 3612 match a.ndim: 3613 case 1: 3614 shape = (1, *shape) 3615 return local_resize(a[None, None, None])[0, 0, 0] 3616 case 2: 3617 return local_resize(a[None, None])[0, 0] 3618 case _: 3619 return local_resize(a.moveaxis(2, 0)[None])[0].moveaxis(0, 2) 3620 3621 3622def _jax_image_resize( 3623 array: _ArrayLike, 3624 /, 3625 shape: Iterable[int], 3626 *, 3627 filter: str, 3628 boundary: str = 'natural', 3629 cval: float = 0.0, 3630 scale: float | Iterable[float] = 1.0, 3631 translate: float | Iterable[float] = 0.0, 3632) -> _JaxArray: 3633 """Invoke `jax.image.scale_and_translate` using the same parameters as `resize`.""" 3634 import jax.image 3635 import jax.numpy as jnp 3636 3637 filters = 'triangle cubic lanczos3 lanczos5'.split() 3638 if filter not in filters: 3639 raise ValueError(f'{filter=} not in {filters=}.') 3640 if boundary != 'natural': 3641 raise ValueError(f"{boundary=} must equal 'natural'.") 3642 # When `scale` or `translate` are applied, any region outside the unit domain is assigned value 0. 3643 # To be consistent, the parameter `cval` must be zero. 3644 if scale != 1.0 and cval != 0.0: 3645 raise ValueError(f'Non-unity {scale=} requires that {cval=} be zero.') 3646 if translate != 0.0 and cval != 0.0: 3647 raise ValueError(f'Nonzero {translate=} requires that {cval=} be zero.') 3648 array2 = jnp.asarray(array) 3649 del array 3650 shape = tuple(shape) 3651 assert len(shape) <= array2.ndim 3652 completed_shape = shape + (1,) * (array2.ndim - len(shape)) 3653 spatial_dims = list(range(len(shape))) 3654 scale2 = np.broadcast_to(np.array(scale), len(shape)) 3655 scale2 = scale2 / np.array(array2.shape[: len(shape)]) * np.array(shape) 3656 translate2 = np.broadcast_to(np.array(translate), len(shape)) 3657 translate2 = translate2 * np.array(shape) 3658 return jax.image.scale_and_translate( 3659 array2, completed_shape, spatial_dims, scale2, translate2, filter 3660 ) 3661 3662 3663_CANDIDATE_RESIZERS = { 3664 'resampler.resize': resize, 3665 'PIL.Image.resize': _pil_image_resize, 3666 'cv.resize': _cv_resize, 3667 'scipy.ndimage.map_coordinates': _scipy_ndimage_resize, 3668 'skimage.transform.resize': _skimage_transform_resize, 3669 'torch.nn.functional.interpolate': _torch_nn_resize, 3670 'jax.image.scale_and_translate': _jax_image_resize, 3671} 3672 3673 3674def _resizer_is_available(library_function: str) -> bool: 3675 """Return whether the resizer is available as an installed package.""" 3676 top_name = library_function.split('.', 1)[0] 3677 module = {'PIL': 'Pillow', 'cv': 'cv2'}.get(top_name, top_name) 3678 return importlib.util.find_spec(module) is not None # type: ignore[attr-defined] 3679 3680 3681_RESIZERS = { 3682 library_function: resizer 3683 for library_function, resizer in _CANDIDATE_RESIZERS.items() 3684 if _resizer_is_available(library_function) 3685} 3686 3687 3688def _find_closest_filter(filter: str, resizer: Callable[..., Any]) -> str: 3689 """Return the filter supported by `resizer` (i.e., `*_resize`) that is closest to `filter`.""" 3690 match filter: 3691 case 'box_like': 3692 return { 3693 _cv_resize: 'trapezoid', 3694 _skimage_transform_resize: 'box', 3695 _torch_nn_resize: 'trapezoid', 3696 }.get(resizer, 'box') 3697 case 'cubic_like': 3698 return { 3699 _cv_resize: 'sharpcubic', 3700 _scipy_ndimage_resize: 'cardinal3', 3701 _skimage_transform_resize: 'cardinal3', 3702 _torch_nn_resize: 'sharpcubic', 3703 }.get(resizer, 'cubic') 3704 case 'high_quality': 3705 return { 3706 _pil_image_resize: 'lanczos3', 3707 _cv_resize: 'lanczos4', 3708 _scipy_ndimage_resize: 'cardinal5', 3709 _skimage_transform_resize: 'cardinal5', 3710 _torch_nn_resize: 'sharpcubic', 3711 }.get(resizer, 'lanczos5') 3712 case _: 3713 return filter 3714 3715 3716# For Emacs: 3717# Local Variables: 3718# fill-column: 100 3719# End:
Array libraries supported automatically in the resize and resampling operations.
The library is selected automatically based on the type of the
arrayfunction parameter.The class
_Arraylibprovides library-specific implementations of needed basic functions.The
_arr_*()functions dispatch the_Arraylibmethods based on the array type.
953@dataclasses.dataclass(frozen=True) 954class Gridtype(abc.ABC): 955 """Abstract base class for grid-types such as `'dual'` and `'primal'`. 956 957 In resampling operations, the grid-type may be specified separately as `src_gridtype` for the 958 source domain and `dst_gridtype` for the destination domain. Moreover, the grid-type may be 959 specified per domain dimension. 960 961 Examples: 962 `resize(source, shape, gridtype='primal')` # Sets both src and dst to be `'primal'` grids. 963 964 `resize(source, shape, src_gridtype=['dual', 'primal'], 965 dst_gridtype='dual')` # Source is `'dual'` in dim0 and `'primal'` in dim1. 966 """ 967 968 name: str 969 """Gridtype name.""" 970 971 @abc.abstractmethod 972 def min_size(self) -> int: 973 """Return the necessary minimum number of grid samples.""" 974 975 @abc.abstractmethod 976 def size_in_samples(self, size: int, /) -> int: 977 """Return the domain size in units of inter-sample spacing.""" 978 979 @abc.abstractmethod 980 def point_from_index(self, index: _NDArray, size: int, /) -> _NDArray: 981 """Return [0.0, 1.0] coordinates given [0, size - 1] indices.""" 982 983 @abc.abstractmethod 984 def index_from_point(self, point: _NDArray, size: int, /) -> _NDArray: 985 """Return location x given coordinates [0.0, 1.0], where x == 0.0 is the first grid sample 986 and x == size - 1.0 is the last grid sample.""" 987 988 @abc.abstractmethod 989 def reflect(self, index: _NDArray, size: int, /) -> _NDArray: 990 """Map integer sample indices to interior ones using boundary reflection.""" 991 992 @abc.abstractmethod 993 def wrap(self, index: _NDArray, size: int, /) -> _NDArray: 994 """Map integer sample indices to interior ones using wrapping.""" 995 996 @abc.abstractmethod 997 def reflect_clamp(self, index: _NDArray, size: int, /) -> _NDArray: 998 """Map integer sample indices to interior ones using reflect-clamp."""
Abstract base class for grid-types such as 'dual' and 'primal'.
In resampling operations, the grid-type may be specified separately as src_gridtype for the
source domain and dst_gridtype for the destination domain. Moreover, the grid-type may be
specified per domain dimension.
Examples:
resize(source, shape, gridtype='primal')# Sets both src and dst to be'primal'grids.
resize(source, shape, src_gridtype=['dual', 'primal'], dst_gridtype='dual')# Source is'dual'in dim0 and'primal'in dim1.
Shortcut names for the two predefined grid types (specified per dimension):
gridtype |
'dual'DualGridtype()(default) |
'primal'PrimalGridtype() |
|---|---|---|
| Sample positions in 2D and in 1D at different resolutions |
![]() |
![]() |
| Nesting of samples across resolutions | The samples positions do not nest. | The even samples remain at coarser scale. |
| Number $N_\ell$ of samples (per-dimension) at resolution level $\ell$ | $N_\ell=2^\ell$ | $N_\ell=2^\ell+1$ |
| Position of sample index $i$ within domain $[0, 1]$ | $\frac{i + 0.5}{N}$ ("half-integer" coordinates) | $\frac{i}{N-1}$ |
| Image resolutions ($N_\ell\times N_\ell$) for dyadic scales | $1\times1, ~~2\times2, ~~4\times4, ~~8\times8, ~\ldots$ | $2\times2, ~~3\times3, ~~5\times5, ~~9\times9, ~\ldots$ |
See the source code for extensibility.
1399@dataclasses.dataclass(frozen=True) 1400class Boundary: 1401 """Domain boundary rules. These define the reconstruction over the source domain near and beyond 1402 the domain boundaries. The rules may be specified separately for each domain dimension.""" 1403 1404 name: str = '' 1405 """Boundary rule name.""" 1406 1407 coord_remap: RemapCoordinates = NoRemapCoordinates() 1408 """Modify specified coordinates prior to evaluating the reconstruction kernels.""" 1409 1410 extend_samples: ExtendSamples = ReflectExtendSamples() 1411 """Define the value of each grid sample outside the unit domain as an affine combination of 1412 interior sample(s) and possibly the constant value (`cval`).""" 1413 1414 override_value: OverrideExteriorValue = NoOverrideExteriorValue() 1415 """Set the value outside some extent to a constant value (`cval`).""" 1416 1417 @property 1418 def uses_cval(self) -> bool: 1419 """True if weights may be non-affine, involving the constant value (`cval`).""" 1420 return self.extend_samples.uses_cval or self.override_value.uses_cval 1421 1422 def preprocess_coordinates(self, point: _NDArray, /) -> _NDArray: 1423 """Modify coordinates prior to evaluating the filter kernels.""" 1424 # Antialiasing across the tile boundaries may be feasible but seems hard. 1425 point = self.coord_remap(point) 1426 return point 1427 1428 def apply( 1429 self, index: _NDArray, weight: _NDArray, point: _NDArray, size: int, gridtype: Gridtype, / 1430 ) -> tuple[_NDArray, _NDArray]: 1431 """Replace exterior samples by combinations of interior samples.""" 1432 index, weight = self.extend_samples(index, weight, size, gridtype) 1433 self.override_reconstruction(weight, point) 1434 return index, weight 1435 1436 def override_reconstruction(self, weight: _NDArray, point: _NDArray, /) -> None: 1437 """For points outside an extent, modify weight to zero to assign `cval`.""" 1438 self.override_value(weight, point)
Domain boundary rules. These define the reconstruction over the source domain near and beyond the domain boundaries. The rules may be specified separately for each domain dimension.
Shortcut names for some predefined boundary rules (as defined by _DICT_BOUNDARIES):
| name | a.k.a. / comments |
|---|---|
'reflect' |
reflected, symm, symmetric, mirror, grid-mirror |
'wrap' |
periodic, repeat, grid-wrap |
'tile' |
like 'reflect' within unit domain, then tile discontinuously |
'clamp' |
clamped, nearest, edge, clamp-to-edge, repeat last sample |
'border' |
grid-constant, use cval for samples outside unit domain |
'natural' |
renormalize using only interior samples, use cval outside domain |
'reflect_clamp' |
mirror-clamp-to-edge |
'constant' |
like 'reflect' but replace by cval outside unit domain |
'linear' |
extrapolate from 2 last samples |
'quadratic' |
extrapolate from 3 last samples |
'linear_constant' |
like 'linear' but replace by cval outside unit domain |
'quadratic_constant' |
like 'quadratic' but replace by cval outside unit domain |
These boundary rules may be specified per dimension. See the source code for extensibility
using the classes RemapCoordinates, ExtendSamples, and OverrideExteriorValue.
Boundary rules illustrated in 1D:
Boundary rules illustrated in 2D:
1519@dataclasses.dataclass(frozen=True) 1520class Filter(abc.ABC): 1521 """Abstract base class for filter kernel functions. 1522 1523 Each kernel is assumed to be a zero-phase filter, i.e., to be symmetric in a support 1524 interval [-radius, radius]. (Some sites instead define kernels over the interval [0, N] 1525 where N = 2 * radius.) 1526 1527 Portions of this code are adapted from the C++ library in 1528 https://github.com/hhoppe/Mesh-processing-library/blob/main/libHh/Filter.cpp 1529 1530 See also https://hhoppe.com/proj/filtering/. 1531 """ 1532 1533 name: str 1534 """Filter kernel name.""" 1535 1536 radius: float 1537 """Max absolute value of x for which self(x) is nonzero.""" 1538 1539 interpolating: bool = True 1540 """True if self(0) == 1.0 and self(i) == 0.0 for all nonzero integers i.""" 1541 1542 continuous: bool = True 1543 """True if the kernel function has $C^0$ continuity.""" 1544 1545 partition_of_unity: bool = True 1546 """True if the convolution of the kernel with a Dirac comb reproduces the 1547 unity function.""" 1548 1549 unit_integral: bool = True 1550 """True if the integral of the kernel function is 1.""" 1551 1552 requires_digital_filter: bool = False 1553 """True if the filter needs a pre/post digital filter for interpolation.""" 1554 1555 @abc.abstractmethod 1556 def __call__(self, x: _ArrayLike, /) -> _NDArray: 1557 """Return evaluation of filter kernel at locations x."""
Abstract base class for filter kernel functions.
Each kernel is assumed to be a zero-phase filter, i.e., to be symmetric in a support interval [-radius, radius]. (Some sites instead define kernels over the interval [0, N] where N = 2 * radius.)
Portions of this code are adapted from the C++ library in https://github.com/hhoppe/Mesh-processing-library/blob/main/libHh/Filter.cpp
See also https://hhoppe.com/proj/filtering/.
Shortcut names for some predefined filter kernels (specified per dimension). The names expand to:
| name | Filter |
a.k.a. / comments |
|---|---|---|
'impulse' |
ImpulseFilter() |
nearest |
'box' |
BoxFilter() |
non-antialiased box, e.g. ImageMagick |
'trapezoid' |
TrapezoidFilter() |
area antialiasing, e.g. cv.INTER_AREA |
'triangle' |
TriangleFilter() |
linear (bilinear in 2D), spline order=1 |
'cubic' |
CatmullRomFilter() |
catmullrom, keys, bicubic |
'sharpcubic' |
SharpCubicFilter() |
cv.INTER_CUBIC, torch 'bicubic' |
'lanczos3' |
LanczosFilter(radius=3) |
support window [-3, 3] |
'lanczos5' |
LanczosFilter(radius=5) |
[-5, 5] |
'lanczos10' |
LanczosFilter(radius=10) |
[-10, 10] |
'cardinal3' |
CardinalBsplineFilter(degree=3) |
spline interpolation, order=3, GF |
'cardinal5' |
CardinalBsplineFilter(degree=5) |
spline interpolation, order=5, GF |
'omoms3' |
OmomsFilter(degree=3) |
non-$C^1$, [-3, 3], GF |
'omoms5' |
OmomsFilter(degree=5) |
non-$C^1$, [-5, 5], GF |
'hamming3' |
GeneralizedHammingFilter(...) |
(radius=3, a0=25/46) |
'kaiser3' |
KaiserFilter(radius=3.0, beta=7.12) |
|
'gaussian' |
GaussianFilter() |
non-interpolating, default $\sigma=1.25/3$ |
'bspline3' |
BsplineFilter(degree=3) |
non-interpolating |
'mitchell' |
MitchellFilter() |
mitchellcubic |
'narrowbox' |
NarrowBoxFilter() |
for visualization of sample positions |
The comment label GF denotes a generalized filter, formed as the composition of a finitely supported kernel and a discrete inverse convolution.
Some example filter kernels:
A more extensive set of filters is presented here in the
notebook,
together with visualizations and analyses of the filter properties.
See the source code for extensibility.
2070@dataclasses.dataclass(frozen=True) 2071class Gamma(abc.ABC): 2072 """Abstract base class for transfer functions on sample values. 2073 2074 Image/video content is often stored using a color component transfer function. 2075 See https://en.wikipedia.org/wiki/Gamma_correction. 2076 2077 Converts between integer types and [0.0, 1.0] internal value range. 2078 """ 2079 2080 name: str 2081 """Name of component transfer function.""" 2082 2083 @abc.abstractmethod 2084 def decode(self, array: _Array, /, dtype: _DTypeLike = np.float32) -> _Array: 2085 """Decode source sample values into floating-point, possibly nonlinearly. 2086 2087 Uint source values are mapped to the range [0.0, 1.0]. 2088 """ 2089 2090 @abc.abstractmethod 2091 def encode(self, array: _Array, /, dtype: _DTypeLike) -> _Array: 2092 """Encode float signal into destination samples, possibly nonlinearly. 2093 2094 Uint destination values are mapped from the range [0.0, 1.0]. 2095 2096 Note that non-integer destination types are not clipped to the range [0.0, 1.0]. 2097 If that is desired, it can be performed as a postprocess using `output.clip(0.0, 1.0)`. 2098 """
Abstract base class for transfer functions on sample values.
Image/video content is often stored using a color component transfer function. See https://en.wikipedia.org/wiki/Gamma_correction.
Converts between integer types and [0.0, 1.0] internal value range.
Shortcut names for some predefined gamma-correction schemes:
| name | Gamma |
Decoding function (linear space from stored value) |
Encoding function (stored value from linear space) |
|---|---|---|---|
'identity' |
IdentityGamma() |
$l = e$ | $e = l$ |
'power2' |
PowerGamma(2.0) |
$l = e^{2.0}$ | $e = l^{1/2.0}$ |
'power22' |
PowerGamma(2.2) |
$l = e^{2.2}$ | $e = l^{1/2.2}$ |
'srgb' |
SrgbGamma() |
$l = \left(\left(e + 0.055\right) / 1.055\right)^{2.4}$ | $e = l^{1/2.4} * 1.055 - 0.055$ |
See the source code for extensibility.
2518def resize( 2519 array: _Array, 2520 /, 2521 shape: Iterable[int], 2522 *, 2523 gridtype: str | Gridtype | None = None, 2524 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2525 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2526 boundary: str | Boundary | Iterable[str | Boundary] = 'auto', 2527 cval: _ArrayLike = 0.0, 2528 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 2529 prefilter: str | Filter | Iterable[str | Filter] | None = None, 2530 gamma: str | Gamma | None = None, 2531 src_gamma: str | Gamma | None = None, 2532 dst_gamma: str | Gamma | None = None, 2533 scale: float | Iterable[float] = 1.0, 2534 translate: float | Iterable[float] = 0.0, 2535 precision: _DTypeLike | None = None, 2536 dtype: _DTypeLike | None = None, 2537 dim_order: Iterable[int] | None = None, 2538 num_threads: int | Literal['auto'] = 'auto', 2539) -> _Array: 2540 """Resample `array` (a grid of sample values) onto a grid with resolution `shape`. 2541 2542 The source `array` is any object recognized by `ARRAYLIBS`. It is interpreted as a grid 2543 with `len(shape)` domain coordinate dimensions, where each grid sample value has shape 2544 `array.shape[len(shape):]`. 2545 2546 Some examples: 2547 2548 - A grayscale image has `array.shape = height, width` and resizing it with `len(shape) == 2` 2549 produces a new image of scalar values. 2550 - An RGB image has `array.shape = height, width, 3` and resizing it with `len(shape) == 2` 2551 produces a new image of RGB values. 2552 - An 3D grid of 3x3 Jacobians has `array.shape = Z, Y, X, 3, 3` and resizing it with 2553 `len(shape) == 3` produces a new 3D grid of Jacobians. 2554 2555 This function also allows scaling and translation from the source domain to the output domain 2556 through the parameters `scale` and `translate`. For more general transforms, see `resample`. 2557 2558 Args: 2559 array: Regular grid of source sample values, as an array object recognized by `ARRAYLIBS`. 2560 The array must have numeric type. Its first `len(shape)` dimensions are the domain 2561 coordinate dimensions. Each grid dimension must be at least 1 for a `'dual'` grid or 2562 at least 2 for a `'primal'` grid. 2563 shape: The number of grid samples in each coordinate dimension of the output array. The source 2564 `array` must have at least as many dimensions as `len(shape)`. 2565 gridtype: Placement of samples on all dimensions of both the source and output domain grids, 2566 specified as either a name in `GRIDTYPES` or a `Gridtype` instance. It defaults to `'dual'` 2567 if `gridtype`, `src_gridtype`, and `dst_gridtype` are all kept `None`. 2568 src_gridtype: Placement of the samples in the source domain grid for each dimension. 2569 Parameters `gridtype` and `src_gridtype` cannot both be set. 2570 dst_gridtype: Placement of the samples in the output domain grid for each dimension. 2571 Parameters `gridtype` and `dst_gridtype` cannot both be set. 2572 boundary: The reconstruction boundary rule for each dimension in `shape`, specified as either 2573 a name in `BOUNDARIES` or a `Boundary` instance. The special value `'auto'` uses `'reflect'` 2574 for upsampling and `'clamp'` for downsampling. 2575 cval: Constant value used beyond the samples by some boundary rules. It must be broadcastable 2576 onto `array.shape[len(shape):]`. It is subject to `src_gamma`. 2577 filter: The reconstruction kernel for each dimension in `shape`, specified as either a filter 2578 name in `FILTERS` or a `Filter` instance. It is used during upsampling (i.e., magnification). 2579 prefilter: The prefilter kernel for each dimension in `shape`, specified as either a filter 2580 name in `FILTERS` or a `Filter` instance. It is used during downsampling 2581 (i.e., minification). If `None`, it inherits the value of `filter`. The default 2582 `'lanczos3'` is good for natural images. For vector graphics images, `'trapezoid'` is better 2583 because it avoids ringing artifacts. 2584 gamma: Component transfer functions (e.g., gamma correction) applied when reading samples from 2585 `array` and when creating output grid samples. It is specified as either a name in `GAMMAS` 2586 or a `Gamma` instance. If both `array.dtype` and `dtype` are `uint`, the default is 2587 `'power2'`. If both are non-`uint`, the default is `'identity'`. Otherwise, `gamma` or 2588 `src_gamma`/`dst_gamma` must be set. Gamma correction assumes that float values are in the 2589 range [0.0, 1.0]. 2590 src_gamma: Component transfer function used to "decode" `array` samples. 2591 Parameters `gamma` and `src_gamma` cannot both be set. 2592 dst_gamma: Component transfer function used to "encode" the output samples. 2593 Parameters `gamma` and `dst_gamma` cannot both be set. 2594 scale: Scaling factor applied to each dimension of the source domain when it is mapped onto 2595 the destination domain. 2596 translate: Offset applied to each dimension of the scaled source domain when it is mapped onto 2597 the destination domain. 2598 precision: Inexact precision of intermediate computations. If `None`, it is determined based 2599 on `array.dtype` and `dtype`. 2600 dtype: Desired data type of the output array. If `None`, it is taken to be `array.dtype`. 2601 If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range 2602 to the uint range. 2603 dim_order: Override the automatically selected order in which the grid dimensions are resized. 2604 Must contain a permutation of `range(len(shape))`. 2605 num_threads: Used to determine multithread parallelism if `array` is from `numpy`. If set to 2606 `'auto'`, it is selected automatically. Otherwise, it must be a positive integer. 2607 2608 Returns: 2609 An array of the same class as the source `array`, with shape `shape + array.shape[len(shape):]` 2610 and data type `dtype`. 2611 2612 **Example of image upsampling:** 2613 2614 >>> array = np.random.default_rng(1).random((4, 6, 3)) # 4x6 RGB image. 2615 >>> upsampled = resize(array, (128, 192)) # To 128x192 resolution. 2616 2617 <center> 2618 <img src="https://github.com/hhoppe/resampler/raw/main/media/example_array_upsampled.png"/> 2619 </center> 2620 2621 **Example of image downsampling:** 2622 2623 >>> yx = (np.moveaxis(np.indices((96, 192)), 0, -1) + (0.5, 0.5)) / 96 2624 >>> radius = np.linalg.norm(yx - (0.75, 0.5), axis=-1) 2625 >>> array = np.cos((radius + 0.1) ** 0.5 * 70.0) * 0.5 + 0.5 2626 >>> downsampled = resize(array, (24, 48)) 2627 2628 <center> 2629 <img src="https://github.com/hhoppe/resampler/raw/main/media/example_array_downsampled2.png"/> 2630 </center> 2631 2632 **Unit test:** 2633 2634 >>> result = resize(np.array([1.0, 4.0, 5.0]), shape=(4,)) 2635 >>> assert np.allclose(result, [0.74240461, 2.88088827, 4.68647155, 5.02641199]) 2636 """ 2637 arraylib = _arr_arraylib(array) 2638 array_dtype = _arr_dtype(array) 2639 if not np.issubdtype(array_dtype, np.number): 2640 raise ValueError(f'Type {array_dtype} is not numeric.') 2641 shape2 = tuple(shape) 2642 array_ndim = len(_arr_shape(array)) 2643 if not 0 < len(shape2) <= array_ndim: 2644 raise ValueError(f'Shape {_arr_shape(array)} cannot be resized to {shape2}.') 2645 src_shape = _arr_shape(array)[: len(shape2)] 2646 src_gridtype2, dst_gridtype2 = _get_gridtypes( 2647 gridtype, src_gridtype, dst_gridtype, len(shape2), len(shape2) 2648 ) 2649 boundary2 = np.broadcast_to(np.array(boundary), len(shape2)) 2650 cval = np.broadcast_to(cval, _arr_shape(array)[len(shape2) :]) 2651 prefilter = filter if prefilter is None else prefilter 2652 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), len(shape2))] 2653 prefilter2 = [_get_filter(f) for f in np.broadcast_to(np.array(prefilter), len(shape2))] 2654 dtype = array_dtype if dtype is None else np.dtype(dtype) 2655 src_gamma2, dst_gamma2 = _get_src_dst_gamma(gamma, src_gamma, dst_gamma, array_dtype, dtype) 2656 scale2 = np.broadcast_to(np.array(scale), len(shape2)) 2657 translate2 = np.broadcast_to(np.array(translate), len(shape2)) 2658 del shape, src_gridtype, dst_gridtype, boundary, filter, prefilter 2659 del src_gamma, dst_gamma, scale, translate 2660 precision = _get_precision(precision, [array_dtype, dtype], []) 2661 weight_precision = _real_precision(precision) 2662 2663 is_noop = ( 2664 all(src == dst for src, dst in zip(src_shape, shape2, strict=True)) 2665 and all(gt1 == gt2 for gt1, gt2 in zip(src_gridtype2, dst_gridtype2, strict=True)) 2666 and all(f.interpolating for f in prefilter2) 2667 and np.all(scale2 == 1.0) 2668 and np.all(translate2 == 0.0) 2669 and src_gamma2 == dst_gamma2 2670 ) 2671 if is_noop: 2672 return array 2673 2674 if dim_order is None: 2675 dim_order = _arr_best_dims_order_for_resize(array, shape2) 2676 else: 2677 dim_order = tuple(dim_order) 2678 if sorted(dim_order) != list(range(len(shape2))): 2679 raise ValueError(f'{dim_order} not a permutation of {list(range(len(shape2)))}.') 2680 2681 array = src_gamma2.decode(array, precision) 2682 cval = _arr_numpy(src_gamma2.decode(cval, precision)) 2683 2684 can_use_fast_box_downsampling = ( 2685 _USING_NUMBA 2686 and arraylib == 'numpy' 2687 and len(shape2) == 2 2688 and array_ndim in (2, 3) 2689 and all(src > dst for src, dst in zip(src_shape, shape2, strict=True)) 2690 and all(src % dst == 0 for src, dst in zip(src_shape, shape2, strict=True)) 2691 and all(gridtype.name == 'dual' for gridtype in src_gridtype2) 2692 and all(gridtype.name == 'dual' for gridtype in dst_gridtype2) 2693 and all(f.name in ('box', 'trapezoid') for f in prefilter2) 2694 and np.all(scale2 == 1.0) 2695 and np.all(translate2 == 0.0) 2696 ) 2697 if can_use_fast_box_downsampling: 2698 array2 = _downsample_in_2d_using_box_filter(cast(_NDArray, array), shape2) 2699 return cast(_Array, dst_gamma2.encode(array2, dtype)) 2700 2701 # Multidimensional resize can be expressed using einsum() with multiple per-dim resize matrices, 2702 # e.g., as in jax.image.resize(). A benefit is to seek the optimal order of multiplications. 2703 # However, efficiency often requires sparse resize matrices, which are unsupported in einsum(). 2704 # Sparse tensors requested for tf.einsum: https://github.com/tensorflow/tensorflow/issues/43497 2705 # https://github.com/tensor-compiler/taco: C++ library that computes tensor algebra expressions 2706 # on sparse and dense tensors; however it does not interoperate with tensorflow, torch, or jax. 2707 2708 for dim in dim_order: 2709 skip_resize_on_this_dim = ( 2710 shape2[dim] == _arr_shape(array)[dim] 2711 and scale2[dim] == 1.0 2712 and translate2[dim] == 0.0 2713 and filter2[dim].interpolating 2714 ) 2715 if skip_resize_on_this_dim: 2716 continue 2717 2718 def get_is_minification() -> bool: 2719 src_in_samples = src_gridtype2[dim].size_in_samples(_arr_shape(array)[dim]) # noqa: B023 2720 dst_in_samples = dst_gridtype2[dim].size_in_samples(shape2[dim]) # noqa: B023 2721 return dst_in_samples / src_in_samples * scale2[dim] < 1.0 # noqa: B023 2722 2723 is_minification = get_is_minification() 2724 boundary_dim = boundary2[dim] 2725 if boundary_dim == 'auto': 2726 boundary_dim = 'clamp' if is_minification else 'reflect' 2727 boundary_dim = _get_boundary(boundary_dim) 2728 resize_matrix, cval_weight = _create_resize_matrix( 2729 _arr_shape(array)[dim], 2730 shape2[dim], 2731 src_gridtype=src_gridtype2[dim], 2732 dst_gridtype=dst_gridtype2[dim], 2733 boundary=boundary_dim, 2734 filter=filter2[dim], 2735 prefilter=prefilter2[dim], 2736 scale=scale2[dim], 2737 translate=translate2[dim], 2738 dtype=weight_precision, 2739 arraylib=arraylib, 2740 ) 2741 2742 array_dim: _Array = _arr_moveaxis(array, dim, 0) 2743 array_flat: Any = _arr_reshape(array_dim, (_arr_shape(array_dim)[0], -1)) 2744 array_flat = _arr_possibly_make_contiguous(array_flat) 2745 if not is_minification and filter2[dim].requires_digital_filter: 2746 array_flat = _apply_digital_filter_1d( 2747 array_flat, src_gridtype2[dim], boundary_dim, cval, filter2[dim] 2748 ) 2749 2750 array_flat = _arr_matmul_sparse_dense(resize_matrix, array_flat, num_threads=num_threads) 2751 if cval_weight is not None: 2752 cval_flat = np.broadcast_to(cval, _arr_shape(array_dim)[1:]).reshape(-1) 2753 cval_weight2: Any = cval_weight 2754 if np.issubdtype(array_dtype, np.complexfloating): 2755 cval_weight2 = _arr_astype(cval_weight2, array_dtype) # (Only necessary for 'tensorflow'.) 2756 array_flat += cval_weight2[:, None] * cval_flat 2757 2758 if is_minification and filter2[dim].requires_digital_filter: # use prefilter2[dim]? 2759 array_flat = _apply_digital_filter_1d( 2760 array_flat, dst_gridtype2[dim], boundary_dim, cval, filter2[dim] 2761 ) 2762 array_dim = _arr_reshape(array_flat, (_arr_shape(array_flat)[0], *_arr_shape(array_dim)[1:])) 2763 array = _arr_moveaxis(array_dim, 0, dim) 2764 2765 array = dst_gamma2.encode(cast(_Array, array), dtype) 2766 return array
Resample array (a grid of sample values) onto a grid with resolution shape.
The source array is any object recognized by ARRAYLIBS. It is interpreted as a grid
with len(shape) domain coordinate dimensions, where each grid sample value has shape
array.shape[len(shape):].
Some examples:
- A grayscale image has
array.shape = height, widthand resizing it withlen(shape) == 2produces a new image of scalar values. - An RGB image has
array.shape = height, width, 3and resizing it withlen(shape) == 2produces a new image of RGB values. - An 3D grid of 3x3 Jacobians has
array.shape = Z, Y, X, 3, 3and resizing it withlen(shape) == 3produces a new 3D grid of Jacobians.
This function also allows scaling and translation from the source domain to the output domain
through the parameters scale and translate. For more general transforms, see resample.
Arguments:
- array: Regular grid of source sample values, as an array object recognized by
ARRAYLIBS. The array must have numeric type. Its firstlen(shape)dimensions are the domain coordinate dimensions. Each grid dimension must be at least 1 for a'dual'grid or at least 2 for a'primal'grid. - shape: The number of grid samples in each coordinate dimension of the output array. The source
arraymust have at least as many dimensions aslen(shape). - gridtype: Placement of samples on all dimensions of both the source and output domain grids,
specified as either a name in
GRIDTYPESor aGridtypeinstance. It defaults to'dual'ifgridtype,src_gridtype, anddst_gridtypeare all keptNone. - src_gridtype: Placement of the samples in the source domain grid for each dimension.
Parameters
gridtypeandsrc_gridtypecannot both be set. - dst_gridtype: Placement of the samples in the output domain grid for each dimension.
Parameters
gridtypeanddst_gridtypecannot both be set. - boundary: The reconstruction boundary rule for each dimension in
shape, specified as either a name inBOUNDARIESor aBoundaryinstance. The special value'auto'uses'reflect'for upsampling and'clamp'for downsampling. - cval: Constant value used beyond the samples by some boundary rules. It must be broadcastable
onto
array.shape[len(shape):]. It is subject tosrc_gamma. - filter: The reconstruction kernel for each dimension in
shape, specified as either a filter name inFILTERSor aFilterinstance. It is used during upsampling (i.e., magnification). - prefilter: The prefilter kernel for each dimension in
shape, specified as either a filter name inFILTERSor aFilterinstance. It is used during downsampling (i.e., minification). IfNone, it inherits the value offilter. The default'lanczos3'is good for natural images. For vector graphics images,'trapezoid'is better because it avoids ringing artifacts. - gamma: Component transfer functions (e.g., gamma correction) applied when reading samples from
arrayand when creating output grid samples. It is specified as either a name inGAMMASor aGammainstance. If botharray.dtypeanddtypeareuint, the default is'power2'. If both are non-uint, the default is'identity'. Otherwise,gammaorsrc_gamma/dst_gammamust be set. Gamma correction assumes that float values are in the range [0.0, 1.0]. - src_gamma: Component transfer function used to "decode"
arraysamples. Parametersgammaandsrc_gammacannot both be set. - dst_gamma: Component transfer function used to "encode" the output samples.
Parameters
gammaanddst_gammacannot both be set. - scale: Scaling factor applied to each dimension of the source domain when it is mapped onto the destination domain.
- translate: Offset applied to each dimension of the scaled source domain when it is mapped onto the destination domain.
- precision: Inexact precision of intermediate computations. If
None, it is determined based onarray.dtypeanddtype. - dtype: Desired data type of the output array. If
None, it is taken to bearray.dtype. If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range to the uint range. - dim_order: Override the automatically selected order in which the grid dimensions are resized.
Must contain a permutation of
range(len(shape)). - num_threads: Used to determine multithread parallelism if
arrayis fromnumpy. If set to'auto', it is selected automatically. Otherwise, it must be a positive integer.
Returns:
An array of the same class as the source
array, with shapeshape + array.shape[len(shape):]and data typedtype.
Example of image upsampling:
>>> array = np.random.default_rng(1).random((4, 6, 3)) # 4x6 RGB image.
>>> upsampled = resize(array, (128, 192)) # To 128x192 resolution.
Example of image downsampling:
>>> yx = (np.moveaxis(np.indices((96, 192)), 0, -1) + (0.5, 0.5)) / 96
>>> radius = np.linalg.norm(yx - (0.75, 0.5), axis=-1)
>>> array = np.cos((radius + 0.1) ** 0.5 * 70.0) * 0.5 + 0.5
>>> downsampled = resize(array, (24, 48))
Unit test:
>>> result = resize(np.array([1.0, 4.0, 5.0]), shape=(4,))
>>> assert np.allclose(result, [0.74240461, 2.88088827, 4.68647155, 5.02641199])
2817def jaxjit_resize(array: _Array, /, *args: Any, **kwargs: Any) -> _Array: 2818 """Compute `resize` but with resize function jitted using Jax.""" 2819 return _create_jaxjit_resize()(array, *args, **kwargs) # pylint: disable=not-callable
Compute resize but with resize function jitted using Jax.
2822def uniform_resize( 2823 array: _Array, 2824 /, 2825 shape: Iterable[int], 2826 *, 2827 object_fit: Literal['contain', 'cover'] = 'contain', 2828 gridtype: str | Gridtype | None = None, 2829 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2830 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 2831 boundary: str | Boundary | Iterable[str | Boundary] = 'natural', # Instead of 'auto' default. 2832 scale: float | Iterable[float] = 1.0, 2833 translate: float | Iterable[float] = 0.0, 2834 **kwargs: Any, 2835) -> _Array: 2836 """Resample `array` onto a grid with resolution `shape` but with uniform scaling. 2837 2838 Calls function `resize` with `scale` and `translate` set such that the aspect ratio of `array` 2839 is preserved. The effect is similar to CSS `object-fit: contain`. 2840 The parameter `boundary` (whose default is changed to `'natural'`) determines the values assigned 2841 outside the source domain. 2842 2843 Args: 2844 array: Regular grid of source sample values. 2845 shape: The number of grid samples in each coordinate dimension of the output array. The source 2846 `array` must have at least as many dimensions as `len(shape)`. 2847 object_fit: Like CSS `object-fit`. If `'contain'`, `array` is resized uniformly to fit within 2848 `shape`. If `'cover'`, `array` is resized to fully cover `shape`. 2849 gridtype: Placement of samples on all dimensions of both the source and output domain grids. 2850 src_gridtype: Placement of the samples in the source domain grid for each dimension. 2851 dst_gridtype: Placement of the samples in the output domain grid for each dimension. 2852 boundary: The reconstruction boundary rule for each dimension in `shape`, specified as either 2853 a name in `BOUNDARIES` or a `Boundary` instance. The default is `'natural'`, which assigns 2854 `cval` to output points that map outside the source unit domain. 2855 scale: Parameter may not be specified. 2856 translate: Parameter may not be specified. 2857 **kwargs: Additional parameters for `resize` function (including `cval`). 2858 2859 Returns: 2860 An array with shape `shape + array.shape[len(shape):]`. 2861 2862 >>> uniform_resize(np.ones((2, 2)), (2, 4), filter='trapezoid') 2863 array([[0., 1., 1., 0.], 2864 [0., 1., 1., 0.]]) 2865 2866 >>> uniform_resize(np.ones((4, 8)), (2, 7), filter='trapezoid') 2867 array([[0. , 0.5, 1. , 1. , 1. , 0.5, 0. ], 2868 [0. , 0.5, 1. , 1. , 1. , 0.5, 0. ]]) 2869 2870 >>> a = np.arange(6.0).reshape(2, 3) 2871 >>> uniform_resize(a, (2, 2), filter='trapezoid', object_fit='cover') 2872 array([[0.5, 1.5], 2873 [3.5, 4.5]]) 2874 """ 2875 if scale != 1.0 or translate != 0.0: 2876 raise ValueError('`uniform_resize()` does not accept `scale` or `translate` parameters.') 2877 shape = tuple(shape) 2878 array_ndim = len(_arr_shape(array)) 2879 if not 0 < len(shape) <= array_ndim: 2880 raise ValueError(f'Shape {_arr_shape(array)} cannot be resized to {shape}.') 2881 src_gridtype2, dst_gridtype2 = _get_gridtypes( 2882 gridtype, src_gridtype, dst_gridtype, len(shape), len(shape) 2883 ) 2884 raw_scales = [ 2885 dst_gridtype2[dim].size_in_samples(shape[dim]) 2886 / src_gridtype2[dim].size_in_samples(_arr_shape(array)[dim]) 2887 for dim in range(len(shape)) 2888 ] 2889 scale0 = {'contain': min(raw_scales), 'cover': max(raw_scales)}[object_fit] 2890 scale2 = scale0 / np.array(raw_scales) 2891 translate = (1.0 - scale2) / 2 2892 return resize(array, shape, boundary=boundary, scale=scale2, translate=translate, **kwargs)
Resample array onto a grid with resolution shape but with uniform scaling.
Calls function resize with scale and translate set such that the aspect ratio of array
is preserved. The effect is similar to CSS object-fit: contain.
The parameter boundary (whose default is changed to 'natural') determines the values assigned
outside the source domain.
Arguments:
- array: Regular grid of source sample values.
- shape: The number of grid samples in each coordinate dimension of the output array. The source
arraymust have at least as many dimensions aslen(shape). - object_fit: Like CSS
object-fit. If'contain',arrayis resized uniformly to fit withinshape. If'cover',arrayis resized to fully covershape. - gridtype: Placement of samples on all dimensions of both the source and output domain grids.
- src_gridtype: Placement of the samples in the source domain grid for each dimension.
- dst_gridtype: Placement of the samples in the output domain grid for each dimension.
- boundary: The reconstruction boundary rule for each dimension in
shape, specified as either a name inBOUNDARIESor aBoundaryinstance. The default is'natural', which assignscvalto output points that map outside the source unit domain. - scale: Parameter may not be specified.
- translate: Parameter may not be specified.
- **kwargs: Additional parameters for
resizefunction (includingcval).
Returns:
An array with shape
shape + array.shape[len(shape):].
>>> uniform_resize(np.ones((2, 2)), (2, 4), filter='trapezoid')
array([[0., 1., 1., 0.],
[0., 1., 1., 0.]])
>>> uniform_resize(np.ones((4, 8)), (2, 7), filter='trapezoid')
array([[0. , 0.5, 1. , 1. , 1. , 0.5, 0. ],
[0. , 0.5, 1. , 1. , 1. , 0.5, 0. ]])
>>> a = np.arange(6.0).reshape(2, 3)
>>> uniform_resize(a, (2, 2), filter='trapezoid', object_fit='cover')
array([[0.5, 1.5],
[3.5, 4.5]])
2898def resample( 2899 array: _Array, 2900 /, 2901 coords: _ArrayLike, 2902 *, 2903 gridtype: str | Gridtype | Iterable[str | Gridtype] = 'dual', 2904 boundary: str | Boundary | Iterable[str | Boundary] = 'auto', 2905 cval: _ArrayLike = 0.0, 2906 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 2907 prefilter: str | Filter | Iterable[str | Filter] | None = None, 2908 gamma: str | Gamma | None = None, 2909 src_gamma: str | Gamma | None = None, 2910 dst_gamma: str | Gamma | None = None, 2911 jacobian: _ArrayLike | None = None, 2912 precision: _DTypeLike | None = None, 2913 dtype: _DTypeLike | None = None, 2914 max_block_size: int = 40_000, 2915 debug: bool = False, 2916) -> _Array: 2917 """Interpolate `array` (a grid of samples) at specified unit-domain coordinates `coords`. 2918 2919 The last dimension of `coords` contains unit-domain coordinates at which to interpolate the 2920 domain grid samples in `array`. 2921 2922 The number of coordinates (`coords.shape[-1]`) determines how to interpret `array`: its first 2923 `coords.shape[-1]` dimensions define the grid, and the remaining dimensions describe each grid 2924 sample (e.g., scalar, vector, tensor). 2925 2926 Concretely, the grid has shape `array.shape[:coords.shape[-1]]` and each grid sample has shape 2927 `array.shape[coords.shape[-1]:]`. 2928 2929 Examples include: 2930 2931 - Resample a grayscale image with `array.shape = height, width` onto a new grayscale image with 2932 `new.shape = height2, width2` by using `coords.shape = height2, width2, 2`. 2933 2934 - Resample an RGB image with `array.shape = height, width, 3` onto a new RGB image with 2935 `new.shape = height2, width2, 3` by using `coords.shape = height2, width2, 2`. 2936 2937 - Sample an RGB image at `num` 2D points along a line segment by using `coords.shape = num, 2`. 2938 2939 - Sample an RGB image at a single 2D point by using `coords.shape = (2,)`. 2940 2941 - Sample a 3D grid of 3x3 Jacobians with `array.shape = nz, ny, nx, 3, 3` along a 2D plane by 2942 using `coords.shape = height, width, 3`. 2943 2944 - Map a grayscale image through a color map by using `array.shape = 256, 3` and 2945 `coords.shape = height, width`. 2946 2947 Args: 2948 array: Regular grid of source sample values, as an array object recognized by `ARRAYLIBS`. 2949 The array must have numeric type. The coordinate dimensions appear first, and 2950 each grid sample may have an arbitrary shape. Each grid dimension must be at least 1 for 2951 a `'dual'` grid or at least 2 for a `'primal'` grid. 2952 coords: Grid of points at which to resample `array`. The point coordinates are in the last 2953 dimension of `coords`. The domain associated with the source grid is a unit hypercube, 2954 i.e. with a range [0, 1] on each coordinate dimension. The output grid has shape 2955 `coords.shape[:-1]` and each of its grid samples has shape `array.shape[coords.shape[-1]:]`. 2956 gridtype: Placement of the samples in the source domain grid for each dimension, specified as 2957 either a name in `GRIDTYPES` or a `Gridtype` instance. It defaults to `'dual'`. 2958 boundary: The reconstruction boundary rule for each dimension in `coords.shape[-1]`, specified 2959 as either a name in `BOUNDARIES` or a `Boundary` instance. The special value `'auto'` uses 2960 `'reflect'` for upsampling and `'clamp'` for downsampling. 2961 cval: Constant value used beyond the samples by some boundary rules. It must be broadcastable 2962 onto the shape `array.shape[coords.shape[-1]:]`. It is subject to `src_gamma`. 2963 filter: The reconstruction kernel for each dimension in `coords.shape[-1]`, specified as either 2964 a filter name in `FILTERS` or a `Filter` instance. 2965 prefilter: The prefilter kernel for each dimension in `coords.shape[:-1]`, specified as either 2966 a filter name in `FILTERS` or a `Filter` instance. It is used during downsampling 2967 (i.e., minification). If `None`, it inherits the value of `filter`. 2968 gamma: Component transfer functions (e.g., gamma correction) applied when reading samples 2969 from `array` and when creating output grid samples. It is specified as either a name in 2970 `GAMMAS` or a `Gamma` instance. If both `array.dtype` and `dtype` are `uint`, the default 2971 is `'power2'`. If both are non-`uint`, the default is `'identity'`. Otherwise, `gamma` or 2972 `src_gamma`/`dst_gamma` must be set. Gamma correction assumes that float values are in the 2973 range [0.0, 1.0]. 2974 src_gamma: Component transfer function used to "decode" `array` samples. 2975 Parameters `gamma` and `src_gamma` cannot both be set. 2976 dst_gamma: Component transfer function used to "encode" the output samples. 2977 Parameters `gamma` and `dst_gamma` cannot both be set. 2978 jacobian: Optional array, which must be broadcastable onto the shape 2979 `coords.shape[:-1] + (coords.shape[-1], coords.shape[-1])`, storing for each point in the 2980 output grid the Jacobian matrix of the map from the unit output domain to the unit source 2981 domain. If omitted, it is estimated by computing finite differences on `coords`. 2982 precision: Inexact precision of intermediate computations. If `None`, it is determined based 2983 on `array.dtype`, `coords.dtype`, and `dtype`. 2984 dtype: Desired data type of the output array. If `None`, it is taken to be `array.dtype`. 2985 If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range 2986 to the uint range. 2987 max_block_size: If nonzero, maximum number of grid points in `coords` before the resampling 2988 evaluation gets partitioned into smaller blocks for reduced memory usage and better caching. 2989 debug: Show internal information. 2990 2991 Returns: 2992 A new sample grid of shape `coords.shape[:-1]`, represented as an array of shape 2993 `coords.shape[:-1] + array.shape[coords.shape[-1]:]`, of the same array library type as 2994 the source array. 2995 2996 **Example of resample operation:** 2997 2998 <center> 2999 <img src="https://github.com/hhoppe/resampler/raw/main/media/example_warp_coords.png"/> 3000 </center> 3001 3002 For reference, the identity resampling for a scalar-valued grid with the default grid-type 3003 `'dual'` is: 3004 3005 >>> array = np.random.default_rng(1).random((5, 7, 3)) 3006 >>> coords = (np.moveaxis(np.indices(array.shape), 0, -1) + 0.5) / array.shape 3007 >>> new_array = resample(array, coords) 3008 >>> assert np.allclose(new_array, array) 3009 3010 It is more efficient to use the function `resize` for the special case where the `coords` are 3011 obtained as simple scaling and translation of a new regular grid over the source domain: 3012 3013 >>> scale, translate, new_shape = (1.1, 1.2), (0.1, -0.2), (6, 8) 3014 >>> coords = (np.moveaxis(np.indices(new_shape), 0, -1) + 0.5) / new_shape 3015 >>> coords = (coords - translate) / scale 3016 >>> resampled = resample(array, coords) 3017 >>> resized = resize(array, new_shape, scale=scale, translate=translate) 3018 >>> assert np.allclose(resampled, resized) 3019 """ 3020 arraylib = _arr_arraylib(array) 3021 if len(_arr_shape(array)) == 0: 3022 array = _arr_reshape(array, (1,)) 3023 coords = np.atleast_1d(coords) 3024 if not np.issubdtype(_arr_dtype(array), np.number): 3025 raise ValueError(f'Type {_arr_dtype(array)} is not numeric.') 3026 if not np.issubdtype(coords.dtype, np.floating): 3027 raise ValueError(f'Type {coords.dtype} is not floating.') 3028 array_ndim = len(_arr_shape(array)) 3029 if coords.ndim == 1 and coords.shape[0] > 1 and array_ndim == 1: 3030 coords = coords[:, None] 3031 grid_ndim = coords.shape[-1] 3032 grid_shape = _arr_shape(array)[:grid_ndim] 3033 sample_shape = _arr_shape(array)[grid_ndim:] 3034 resampled_ndim = coords.ndim - 1 3035 resampled_shape = coords.shape[:-1] 3036 if grid_ndim > array_ndim: 3037 raise ValueError( 3038 f'There are more coordinate dimensions ({grid_ndim}) in {coords=}' 3039 f' than in array.shape={_arr_shape(array)}.' 3040 ) 3041 gridtype2 = [_get_gridtype(g) for g in np.broadcast_to(np.array(gridtype), grid_ndim)] 3042 boundary2 = np.broadcast_to(np.array(boundary), grid_ndim).tolist() 3043 cval = np.broadcast_to(cval, sample_shape) 3044 prefilter = filter if prefilter is None else prefilter 3045 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), grid_ndim)] 3046 prefilter2 = [_get_filter(f) for f in np.broadcast_to(np.array(prefilter), resampled_ndim)] 3047 dtype = _arr_dtype(array) if dtype is None else np.dtype(dtype) 3048 src_gamma2, dst_gamma2 = _get_src_dst_gamma(gamma, src_gamma, dst_gamma, _arr_dtype(array), dtype) 3049 del gridtype, boundary, filter, prefilter, src_gamma, dst_gamma 3050 if jacobian is not None: 3051 jacobian = np.broadcast_to(jacobian, resampled_shape + (coords.shape[-1],) * 2) 3052 precision = _get_precision(precision, [_arr_dtype(array), dtype], [coords.dtype]) 3053 weight_precision = _real_precision(precision) 3054 coords = coords.astype(weight_precision, copy=False) 3055 is_minification = False # Current limitation; no prefiltering! 3056 assert max_block_size >= 0 or max_block_size == _MAX_BLOCK_SIZE_RECURSING 3057 for dim in range(grid_ndim): 3058 if boundary2[dim] == 'auto': 3059 boundary2[dim] = 'clamp' if is_minification else 'reflect' 3060 boundary2[dim] = _get_boundary(boundary2[dim]) 3061 3062 if max_block_size != _MAX_BLOCK_SIZE_RECURSING: 3063 array = src_gamma2.decode(array, precision) 3064 for dim in range(grid_ndim): 3065 assert not is_minification 3066 if filter2[dim].requires_digital_filter: 3067 array = _apply_digital_filter_1d( 3068 array, gridtype2[dim], boundary2[dim], cval, filter2[dim], axis=dim 3069 ) 3070 cval = _arr_numpy(src_gamma2.decode(cval, precision)) 3071 3072 if math.prod(resampled_shape) > max_block_size > 0: 3073 block_shape = _block_shape_with_min_size(resampled_shape, max_block_size) 3074 if debug: 3075 print(f'(resample: splitting coords into blocks {block_shape}).') 3076 coord_blocks = _split_array_into_blocks(coords, block_shape) 3077 3078 def process_block(coord_block: _NDArray) -> _Array: 3079 return resample( 3080 cast(Any, array), 3081 coord_block, 3082 gridtype=gridtype2, 3083 boundary=boundary2, 3084 cval=cval, 3085 filter=filter2, 3086 prefilter=prefilter2, 3087 src_gamma='identity', 3088 dst_gamma=dst_gamma2, 3089 jacobian=jacobian, 3090 precision=precision, 3091 dtype=dtype, 3092 max_block_size=_MAX_BLOCK_SIZE_RECURSING, 3093 ) 3094 3095 result_blocks = _map_function_over_blocks(coord_blocks, process_block) 3096 array = _merge_array_from_blocks(result_blocks) 3097 return array 3098 3099 # A concrete example of upsampling: 3100 # array = np.ones((5, 7, 3)) # source RGB image has height=5 width=7 3101 # coords = np.random.default_rng(1).random((8, 9, 2)) # output RGB image has height=8 width=9 3102 # resample(array, coords, filter=('cubic', 'lanczos3')) 3103 # grid_shape = 5, 7 grid_ndim = 2 3104 # resampled_shape = 8, 9 resampled_ndim = 2 3105 # sample_shape = (3,) 3106 # src_float_index.shape = 8, 9 3107 # src_first_index.shape = 8, 9 3108 # sample_index.shape = (4,) for dim == 0, then (6,) for dim == 1 3109 # weight = [shape(8, 9, 4), shape(8, 9, 6)] 3110 # src_index = [shape(8, 9, 4), shape(8, 9, 6)] 3111 3112 # Both:[shape(8, 9, 4), shape(8, 9, 6)] 3113 weight: list[_NDArray] = [np.array([]) for _ in range(grid_ndim)] 3114 src_index: list[_NDArray] = [np.array([]) for _ in range(grid_ndim)] 3115 uses_cval = False 3116 all_num_samples = [] # will be [4, 6] 3117 3118 for dim in range(grid_ndim): 3119 src_size = grid_shape[dim] # scalar 3120 coords_dim = coords[..., dim] # (8, 9) 3121 radius = filter2[dim].radius # scalar 3122 num_samples = int(np.ceil(radius * 2)) # scalar 3123 all_num_samples.append(num_samples) 3124 3125 boundary_dim = boundary2[dim] 3126 coords_dim = boundary_dim.preprocess_coordinates(coords_dim) 3127 3128 # Sample positions mapped back to source unit domain [0, 1]. 3129 src_float_index = gridtype2[dim].index_from_point(coords_dim, src_size) # (8, 9) 3130 src_first_index = ( 3131 np.floor(src_float_index + (0.5 if num_samples % 2 == 1 else 0.0)).astype(np.int32) 3132 - (num_samples - 1) // 2 3133 ) # (8, 9) 3134 3135 sample_index = np.arange(num_samples, dtype=np.int32) # (4,) then (6,) 3136 src_index[dim] = src_first_index[..., None] + sample_index # (8, 9, 4) then (8, 9, 6) 3137 if filter2[dim].name == 'trapezoid': 3138 # (It might require changing the filter radius at every sample.) 3139 raise ValueError('resample() cannot use adaptive `trapezoid` filter.') 3140 if filter2[dim].name == 'impulse': 3141 weight[dim] = np.ones_like(src_index[dim], weight_precision) 3142 else: 3143 x = src_float_index[..., None] - src_index[dim].astype(weight_precision) 3144 weight[dim] = filter2[dim](x).astype(weight_precision, copy=False) 3145 if filter2[dim].name != 'narrowbox' and ( 3146 is_minification or not filter2[dim].partition_of_unity 3147 ): 3148 weight[dim] = weight[dim] / weight[dim].sum(axis=-1)[..., None] 3149 3150 src_index[dim], weight[dim] = boundary_dim.apply( 3151 src_index[dim], weight[dim], coords_dim, src_size, gridtype2[dim] 3152 ) 3153 if boundary_dim.uses_cval or filter2[dim].name == 'narrowbox': 3154 uses_cval = True 3155 3156 # Gather the samples. 3157 3158 # Recall that src_index = [shape(8, 9, 4), shape(8, 9, 6)]. 3159 src_index_expanded = [] 3160 for dim in range(grid_ndim): 3161 src_index_dim = np.moveaxis( 3162 src_index[dim].reshape(src_index[dim].shape + (1,) * (grid_ndim - 1)), 3163 resampled_ndim, 3164 resampled_ndim + dim, 3165 ) 3166 src_index_expanded.append(src_index_dim) 3167 indices = tuple(src_index_expanded) # (shape(8, 9, 4, 1), shape(8, 9, 1, 6)) 3168 samples = _arr_getitem(array, indices) # (8, 9, 4, 6, 3) 3169 3170 # Indirectly derive samples.ndim (which is unavailable during Tensorflow grad computation). 3171 samples_ndim = resampled_ndim + grid_ndim + len(sample_shape) 3172 3173 # Compute an Einstein summation over the samples and each of the per-dimension weights. 3174 3175 def label(dims: Iterable[int]) -> str: 3176 return ''.join(chr(ord('a') + i) for i in dims) 3177 3178 operands: list[Any] = [samples] # (8, 9, 4, 6, 3) 3179 assert samples_ndim < 26 # Letters 'a' through 'z'. 3180 labels = [label(range(samples_ndim))] # ['abcde'] 3181 for dim in range(grid_ndim): 3182 operands.append(weight[dim]) # (8, 9, 4), then (8, 9, 6) 3183 labels.append(label(list(range(resampled_ndim)) + [resampled_ndim + dim])) # 'abc' then 'abd' 3184 output_label = label( 3185 list(range(resampled_ndim)) + list(range(resampled_ndim + grid_ndim, samples_ndim)) 3186 ) # 'abe' 3187 subscripts = ','.join(labels) + '->' + output_label # 'abcde,abc,abd->abe' 3188 # Starting in numpy 2.0, np.einsum() outputs np.float64 even with all np.float32 inputs; 3189 # GPT: "aligns np.einsum with other functions where intermediate calculations use higher 3190 # precision (np.float64) regardless of input type when floating-point arithmetic is involved." 3191 # we could explicitly add the parameter `dtype=precision`. 3192 array = _arr_einsum(subscripts, *operands) # (8, 9, 3) 3193 3194 # Gathering `samples` is the memory bottleneck. It would be ideal if the gather() and einsum() 3195 # computations could be fused. In Jax, https://github.com/google/jax/issues/3206 suggests 3196 # that this may become possible. In any case, for large outputs it helps to partition the 3197 # evaluation over output tiles (using max_block_size). 3198 3199 if uses_cval: 3200 cval_weight = 1.0 - np.multiply.reduce( 3201 [weight[dim].sum(axis=-1) for dim in range(resampled_ndim)] 3202 ) # (8, 9) 3203 cval_weight_reshaped = cval_weight.reshape(cval_weight.shape + (1,) * len(sample_shape)) 3204 array += _make_array((cval_weight_reshaped * cval).astype(precision, copy=False), arraylib) 3205 3206 array = dst_gamma2.encode(array, dtype) 3207 return array
Interpolate array (a grid of samples) at specified unit-domain coordinates coords.
The last dimension of coords contains unit-domain coordinates at which to interpolate the
domain grid samples in array.
The number of coordinates (coords.shape[-1]) determines how to interpret array: its first
coords.shape[-1] dimensions define the grid, and the remaining dimensions describe each grid
sample (e.g., scalar, vector, tensor).
Concretely, the grid has shape array.shape[:coords.shape[-1]] and each grid sample has shape
array.shape[coords.shape[-1]:].
Examples include:
Resample a grayscale image with
array.shape = height, widthonto a new grayscale image withnew.shape = height2, width2by usingcoords.shape = height2, width2, 2.Resample an RGB image with
array.shape = height, width, 3onto a new RGB image withnew.shape = height2, width2, 3by usingcoords.shape = height2, width2, 2.Sample an RGB image at
num2D points along a line segment by usingcoords.shape = num, 2.Sample an RGB image at a single 2D point by using
coords.shape = (2,).Sample a 3D grid of 3x3 Jacobians with
array.shape = nz, ny, nx, 3, 3along a 2D plane by usingcoords.shape = height, width, 3.Map a grayscale image through a color map by using
array.shape = 256, 3andcoords.shape = height, width.
Arguments:
- array: Regular grid of source sample values, as an array object recognized by
ARRAYLIBS. The array must have numeric type. The coordinate dimensions appear first, and each grid sample may have an arbitrary shape. Each grid dimension must be at least 1 for a'dual'grid or at least 2 for a'primal'grid. - coords: Grid of points at which to resample
array. The point coordinates are in the last dimension ofcoords. The domain associated with the source grid is a unit hypercube, i.e. with a range [0, 1] on each coordinate dimension. The output grid has shapecoords.shape[:-1]and each of its grid samples has shapearray.shape[coords.shape[-1]:]. - gridtype: Placement of the samples in the source domain grid for each dimension, specified as
either a name in
GRIDTYPESor aGridtypeinstance. It defaults to'dual'. - boundary: The reconstruction boundary rule for each dimension in
coords.shape[-1], specified as either a name inBOUNDARIESor aBoundaryinstance. The special value'auto'uses'reflect'for upsampling and'clamp'for downsampling. - cval: Constant value used beyond the samples by some boundary rules. It must be broadcastable
onto the shape
array.shape[coords.shape[-1]:]. It is subject tosrc_gamma. - filter: The reconstruction kernel for each dimension in
coords.shape[-1], specified as either a filter name inFILTERSor aFilterinstance. - prefilter: The prefilter kernel for each dimension in
coords.shape[:-1], specified as either a filter name inFILTERSor aFilterinstance. It is used during downsampling (i.e., minification). IfNone, it inherits the value offilter. - gamma: Component transfer functions (e.g., gamma correction) applied when reading samples
from
arrayand when creating output grid samples. It is specified as either a name inGAMMASor aGammainstance. If botharray.dtypeanddtypeareuint, the default is'power2'. If both are non-uint, the default is'identity'. Otherwise,gammaorsrc_gamma/dst_gammamust be set. Gamma correction assumes that float values are in the range [0.0, 1.0]. - src_gamma: Component transfer function used to "decode"
arraysamples. Parametersgammaandsrc_gammacannot both be set. - dst_gamma: Component transfer function used to "encode" the output samples.
Parameters
gammaanddst_gammacannot both be set. - jacobian: Optional array, which must be broadcastable onto the shape
coords.shape[:-1] + (coords.shape[-1], coords.shape[-1]), storing for each point in the output grid the Jacobian matrix of the map from the unit output domain to the unit source domain. If omitted, it is estimated by computing finite differences oncoords. - precision: Inexact precision of intermediate computations. If
None, it is determined based onarray.dtype,coords.dtype, anddtype. - dtype: Desired data type of the output array. If
None, it is taken to bearray.dtype. If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range to the uint range. - max_block_size: If nonzero, maximum number of grid points in
coordsbefore the resampling evaluation gets partitioned into smaller blocks for reduced memory usage and better caching. - debug: Show internal information.
Returns:
A new sample grid of shape
coords.shape[:-1], represented as an array of shapecoords.shape[:-1] + array.shape[coords.shape[-1]:], of the same array library type as the source array.
Example of resample operation:
For reference, the identity resampling for a scalar-valued grid with the default grid-type
'dual' is:
>>> array = np.random.default_rng(1).random((5, 7, 3))
>>> coords = (np.moveaxis(np.indices(array.shape), 0, -1) + 0.5) / array.shape
>>> new_array = resample(array, coords)
>>> assert np.allclose(new_array, array)
It is more efficient to use the function resize for the special case where the coords are
obtained as simple scaling and translation of a new regular grid over the source domain:
>>> scale, translate, new_shape = (1.1, 1.2), (0.1, -0.2), (6, 8)
>>> coords = (np.moveaxis(np.indices(new_shape), 0, -1) + 0.5) / new_shape
>>> coords = (coords - translate) / scale
>>> resampled = resample(array, coords)
>>> resized = resize(array, new_shape, scale=scale, translate=translate)
>>> assert np.allclose(resampled, resized)
3210def resample_affine( 3211 array: _Array, 3212 /, 3213 shape: Iterable[int], 3214 matrix: _ArrayLike, 3215 *, 3216 gridtype: str | Gridtype | None = None, 3217 src_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 3218 dst_gridtype: str | Gridtype | Iterable[str | Gridtype] | None = None, 3219 filter: str | Filter | Iterable[str | Filter] = _DEFAULT_FILTER, 3220 prefilter: str | Filter | Iterable[str | Filter] | None = None, 3221 precision: _DTypeLike | None = None, 3222 dtype: _DTypeLike | None = None, 3223 **kwargs: Any, 3224) -> _Array: 3225 """Resample a source array using an affinely transformed grid of given shape. 3226 3227 The `matrix` transformation can be linear, 3228 `source_point = matrix @ destination_point`, 3229 or it can be affine where the last matrix column is an offset vector, 3230 `source_point = matrix @ (destination_point, 1.0)`. 3231 3232 Args: 3233 array: Regular grid of source sample values, as an array object recognized by `ARRAYLIBS`. 3234 The array must have numeric type. The number of grid dimensions is determined from 3235 `matrix.shape[0]`; the remaining dimensions are for each sample value and are all 3236 linearly interpolated. 3237 shape: Dimensions of the desired destination grid. The number of destination grid dimensions 3238 may be different from that of the source grid. 3239 matrix: 2D array for a linear or affine transform from unit-domain destination points 3240 (in a space with `len(shape)` dimensions) into unit-domain source points (in a space with 3241 `matrix.shape[0]` dimensions). If the matrix has `len(shape) + 1` columns, the last column 3242 is the affine offset (i.e., translation). 3243 gridtype: Placement of samples on all dimensions of both the source and output domain grids, 3244 specified as either a name in `GRIDTYPES` or a `Gridtype` instance. It defaults to `'dual'` 3245 if `gridtype`, `src_gridtype`, and `dst_gridtype` are all kept `None`. 3246 src_gridtype: Placement of samples in the source domain grid for each dimension. 3247 Parameters `gridtype` and `src_gridtype` cannot both be set. 3248 dst_gridtype: Placement of samples in the output domain grid for each dimension. 3249 Parameters `gridtype` and `dst_gridtype` cannot both be set. 3250 filter: The reconstruction kernel for each dimension in `matrix.shape[0]`, specified as either 3251 a filter name in `FILTERS` or a `Filter` instance. 3252 prefilter: The prefilter kernel for each dimension in `len(shape)`, specified as either 3253 a filter name in `FILTERS` or a `Filter` instance. It is used during downsampling 3254 (i.e., minification). If `None`, it inherits the value of `filter`. 3255 precision: Inexact precision of intermediate computations. If `None`, it is determined based 3256 on `array.dtype` and `dtype`. 3257 dtype: Desired data type of the output array. If `None`, it is taken to be `array.dtype`. 3258 If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range 3259 to the uint range. 3260 **kwargs: Additional parameters for `resample` function. 3261 3262 Returns: 3263 An array of the same class as the source `array`, representing a grid with specified `shape`, 3264 where each grid value is resampled from `array`. Thus the shape of the returned array is 3265 `shape + array.shape[matrix.shape[0]:]`. 3266 """ 3267 shape = tuple(shape) 3268 matrix = np.asarray(matrix) 3269 dst_ndim = len(shape) 3270 if matrix.ndim != 2: 3271 raise ValueError(f'Array {matrix} is not 2D matrix.') 3272 src_ndim = matrix.shape[0] 3273 # grid_shape = array.shape[:src_ndim] 3274 is_affine = matrix.shape[1] == dst_ndim + 1 3275 if src_ndim > len(_arr_shape(array)): 3276 raise ValueError( 3277 f'Matrix {matrix} has more rows ({matrix.shape[0]}) than ndim in' 3278 f' array.shape={_arr_shape(array)}.' 3279 ) 3280 if matrix.shape[1] != dst_ndim and not is_affine: 3281 raise ValueError( 3282 f'Matrix has {matrix.shape=}, but we expect either {dst_ndim} or {dst_ndim + 1} columns.' 3283 ) 3284 src_gridtype2, dst_gridtype2 = _get_gridtypes( 3285 gridtype, src_gridtype, dst_gridtype, src_ndim, dst_ndim 3286 ) 3287 prefilter = filter if prefilter is None else prefilter 3288 filter2 = [_get_filter(f) for f in np.broadcast_to(np.array(filter), src_ndim)] 3289 prefilter2 = [_get_filter(f) for f in np.broadcast_to(np.array(prefilter), dst_ndim)] 3290 del src_gridtype, dst_gridtype, filter, prefilter 3291 dtype = _arr_dtype(array) if dtype is None else np.dtype(dtype) 3292 precision = _get_precision(precision, [_arr_dtype(array), dtype], []) 3293 weight_precision = _real_precision(precision) 3294 3295 dst_position_list = [] # per dimension 3296 for dim in range(dst_ndim): 3297 dst_size = shape[dim] 3298 dst_index = np.arange(dst_size, dtype=weight_precision) 3299 dst_position_list.append(dst_gridtype2[dim].point_from_index(dst_index, dst_size)) 3300 dst_position = np.meshgrid(*dst_position_list, indexing='ij') 3301 3302 linear_matrix = matrix[:, :-1] if is_affine else matrix 3303 src_position = np.tensordot(linear_matrix, dst_position, 1) 3304 coords = np.moveaxis(src_position, 0, -1) 3305 if is_affine: 3306 coords += matrix[:, -1] 3307 3308 # TODO: Based on grid_shape, shape, linear_matrix, and prefilter, determine a 3309 # convolution prefilter and apply it to bandlimit 'array', using boundary for padding. 3310 3311 return resample( 3312 array, 3313 coords, 3314 gridtype=src_gridtype2, 3315 filter=filter2, 3316 prefilter=prefilter2, 3317 precision=precision, 3318 dtype=dtype, 3319 **kwargs, 3320 )
Resample a source array using an affinely transformed grid of given shape.
The matrix transformation can be linear,
source_point = matrix @ destination_point,
or it can be affine where the last matrix column is an offset vector,
source_point = matrix @ (destination_point, 1.0).
Arguments:
- array: Regular grid of source sample values, as an array object recognized by
ARRAYLIBS. The array must have numeric type. The number of grid dimensions is determined frommatrix.shape[0]; the remaining dimensions are for each sample value and are all linearly interpolated. - shape: Dimensions of the desired destination grid. The number of destination grid dimensions may be different from that of the source grid.
- matrix: 2D array for a linear or affine transform from unit-domain destination points
(in a space with
len(shape)dimensions) into unit-domain source points (in a space withmatrix.shape[0]dimensions). If the matrix haslen(shape) + 1columns, the last column is the affine offset (i.e., translation). - gridtype: Placement of samples on all dimensions of both the source and output domain grids,
specified as either a name in
GRIDTYPESor aGridtypeinstance. It defaults to'dual'ifgridtype,src_gridtype, anddst_gridtypeare all keptNone. - src_gridtype: Placement of samples in the source domain grid for each dimension.
Parameters
gridtypeandsrc_gridtypecannot both be set. - dst_gridtype: Placement of samples in the output domain grid for each dimension.
Parameters
gridtypeanddst_gridtypecannot both be set. - filter: The reconstruction kernel for each dimension in
matrix.shape[0], specified as either a filter name inFILTERSor aFilterinstance. - prefilter: The prefilter kernel for each dimension in
len(shape), specified as either a filter name inFILTERSor aFilterinstance. It is used during downsampling (i.e., minification). IfNone, it inherits the value offilter. - precision: Inexact precision of intermediate computations. If
None, it is determined based onarray.dtypeanddtype. - dtype: Desired data type of the output array. If
None, it is taken to bearray.dtype. If it is a uint type, the intermediate float values are rescaled from the [0.0, 1.0] range to the uint range. - **kwargs: Additional parameters for
resamplefunction.
Returns:
An array of the same class as the source
array, representing a grid with specifiedshape, where each grid value is resampled fromarray. Thus the shape of the returned array isshape + array.shape[matrix.shape[0]:].
3350def rotation_about_center_in_2d( 3351 src_shape: _ArrayLike, 3352 /, 3353 angle: float, 3354 *, 3355 new_shape: _ArrayLike | None = None, 3356 scale: float = 1.0, 3357) -> _NDArray: 3358 """Return the 3x3 matrix mapping destination into a source unit domain. 3359 3360 The returned matrix accounts for the possibly non-square domain shapes. 3361 3362 Args: 3363 src_shape: Resolution `(ny, nx)` of the source domain grid. 3364 angle: Angle in radians (positive from x to y axis) applied when mapping the source domain 3365 onto the destination domain. 3366 new_shape: Resolution `(ny, nx)` of the destination domain grid; it defaults to `src_shape`. 3367 scale: Scaling factor applied when mapping the source domain onto the destination domain. 3368 """ 3369 3370 def translation_matrix(vector: _NDArray) -> _NDArray: 3371 matrix = np.eye(len(vector) + 1) 3372 matrix[:-1, -1] = vector 3373 return matrix 3374 3375 def scaling_matrix(scale: _NDArray) -> _NDArray: 3376 return np.diag(tuple(scale) + (1.0,)) 3377 3378 def rotation_matrix_2d(angle: float) -> _NDArray: 3379 cos, sin = np.cos(angle), np.sin(angle) 3380 return np.array([[cos, sin, 0], [-sin, cos, 0], [0, 0, 1]]) 3381 3382 src_shape = np.asarray(src_shape) 3383 new_shape = src_shape if new_shape is None else np.asarray(new_shape) 3384 _check_eq(src_shape.shape, (2,)) 3385 _check_eq(new_shape.shape, (2,)) 3386 half = np.array([0.5, 0.5]) 3387 matrix = ( 3388 translation_matrix(half) 3389 @ scaling_matrix(min(src_shape) / src_shape) 3390 @ rotation_matrix_2d(angle) 3391 @ scaling_matrix(scale * new_shape / min(new_shape)) 3392 @ translation_matrix(-half) 3393 ) 3394 assert np.allclose(matrix[-1], [0.0, 0.0, 1.0]) 3395 return matrix
Return the 3x3 matrix mapping destination into a source unit domain.
The returned matrix accounts for the possibly non-square domain shapes.
Arguments:
- src_shape: Resolution
(ny, nx)of the source domain grid. - angle: Angle in radians (positive from x to y axis) applied when mapping the source domain onto the destination domain.
- new_shape: Resolution
(ny, nx)of the destination domain grid; it defaults tosrc_shape. - scale: Scaling factor applied when mapping the source domain onto the destination domain.
3398def rotate_image_about_center( 3399 image: _NDArray, 3400 /, 3401 angle: float, 3402 *, 3403 new_shape: _ArrayLike | None = None, 3404 scale: float = 1.0, 3405 num_rotations: int = 1, 3406 **kwargs: Any, 3407) -> _NDArray: 3408 """Return a copy of `image` rotated about its center. 3409 3410 Args: 3411 image: Source grid samples; the first two dimensions are spatial (ny, nx). 3412 angle: Angle in radians (positive from x to y axis) applied when mapping the source domain 3413 onto the destination domain. 3414 new_shape: Resolution `(ny, nx)` of the output grid; it defaults to `image.shape[:2]`. 3415 scale: Scaling factor applied when mapping the source domain onto the destination domain. 3416 num_rotations: Number of rotations (each by `angle`). Successive resamplings are useful in 3417 analyzing the filtering quality. 3418 **kwargs: Additional parameters for `resample_affine`. 3419 """ 3420 new_shape = image.shape[:2] if new_shape is None else np.asarray(new_shape) 3421 matrix = rotation_about_center_in_2d(image.shape[:2], angle, new_shape=new_shape, scale=scale) 3422 for _ in range(num_rotations): 3423 image = resample_affine(image, new_shape, matrix[:-1], **kwargs) 3424 return image
Return a copy of image rotated about its center.
Arguments:
- image: Source grid samples; the first two dimensions are spatial (ny, nx).
- angle: Angle in radians (positive from x to y axis) applied when mapping the source domain onto the destination domain.
- new_shape: Resolution
(ny, nx)of the output grid; it defaults toimage.shape[:2]. - scale: Scaling factor applied when mapping the source domain onto the destination domain.
- num_rotations: Number of rotations (each by
angle). Successive resamplings are useful in analyzing the filtering quality. - **kwargs: Additional parameters for
resample_affine.


upsampled




