Source code for abtem.core.fft

"""Module for handling Fourier transforms and convolution in abTEM."""

import math
import threading
import warnings
from itertools import product as _product
from typing import Tuple, TypeVar, overload

import dask.array as da
import numpy as np
from threadpoolctl import threadpool_limits  # type: ignore

from abtem.core import config
from abtem.core.backend import check_cupy_is_installed, get_array_module
from abtem.core.complex import complex_exponential
from abtem.core.grid import spatial_frequencies
from abtem.core.utils import get_dtype

try:
    import pyfftw  # type: ignore
except (ModuleNotFoundError, ImportError):
    pyfftw = None

try:
    import mkl_fft  # type: ignore
except ModuleNotFoundError:
    mkl_fft = None

try:
    import cupy as cp  # type: ignore
except ModuleNotFoundError:
    cp = None
except ImportError:
    if config.get("device") == "gpu":
        warnings.warn(
            "The CuPy library could not be imported. Please check your installation, or"
            "change your configuration to use CPU."
        )
    cp = None


# Deliberately NOT scipy.fft.next_fast_len's set: scipy targets pocketfft,
# whose kernels go up to radix 11, so next_fast_len returns 11-smooth lengths
# (next_fast_len(121) == 121 == 11**2). cuFFT documents optimized kernels only
# for sizes of the form 2^a * 3^b * 5^c * 7^d, so a factor-11 length falls off
# exactly the GPU fast path this module exists to protect. {2, 3, 5, 7} is the
# intersection guaranteed fast on every backend abTEM uses (FFTW, pocketfft,
# MKL, cuFFT, hipFFT).
_FAST_FFT_PRIMES = (2, 3, 5, 7)


[docs] def is_fast_fft_size(n: int) -> bool: """ Whether an FFT of length ``n`` runs on fast radix kernels. FFT libraries only ship optimized kernels for lengths whose prime factors are small (2, 3, 5 and 7 are supported everywhere). A length with a larger prime factor triggers a generic fallback -- on cuFFT the Bluestein algorithm, which pads internally to a power of two, costing several times the arithmetic and, on GPU, a workspace of several times the transform size. Parameters ---------- n : int The transform length. Returns ------- bool True if ``n`` factorizes completely into 2, 3, 5 and 7. """ n = int(n) if n < 1: return False for p in _FAST_FFT_PRIMES: while n % p == 0: n //= p return n == 1
[docs] def next_fast_fft_size(n: int) -> int: """ The smallest length >= ``n`` whose prime factors are all in {2, 3, 5, 7}. Useful for choosing grid sizes (``gpts``) that avoid the slow large-workspace Bluestein fallback on GPU. Stricter than ``scipy.fft.next_fast_len``, which returns 11-smooth lengths (fast for pocketfft on CPU, but off cuFFT's documented fast path). Parameters ---------- n : int The minimum transform length. Returns ------- int The next fast transform length. """ n = max(1, int(n)) while not is_fast_fft_size(n): n += 1 return n
_warned_slow_fft_shapes: set = set() # _fft_dispatch runs concurrently in dask worker threads, so the check-then-add # on the shape set must be atomic or the same shape can warn more than once. _warned_slow_fft_shapes_lock = threading.Lock() # Bluestein overhead only matters for large transforms; small odd-sized arrays # (e.g. interpolated measurements) are not worth a warning. _SLOW_FFT_WARN_MIN_ELEMENTS = 1024 * 1024 def _transform_lengths(shape, func_name: str = "fft2", kwargs=None) -> tuple[int, ...]: """ The array lengths a transform actually acts on. Only the transformed axes matter for the Bluestein fallback: batch axes are looped over, whatever their length. Which axes those are depends on the function -- ``fftn`` transforms all of them unless ``axes`` says otherwise, ``fft2`` the trailing two, ``fft`` a single one -- so reading the trailing two axes unconditionally misreports every non-2D transform. """ kwargs = kwargs or {} axes = kwargs.get("axes") # An explicit `s` overrides the array lengths (None entries keep theirs). s = kwargs.get("s") if axes is None: if func_name.endswith("fftn"): # numpy and cupy apply `s` to the TRAILING len(s) axes when no axes # are given, so deriving the axes from the array rank alone would # pair `s` with the wrong ones. count = len(s) if s is not None else len(shape) axes = tuple(range(len(shape) - count, len(shape))) elif func_name.endswith("fft2"): axes = (-2, -1) else: axes = (kwargs.get("axis", -1),) # Drop axes the array does not have. cupy's fft2 quietly reduces the axes # for arrays of fewer than two dimensions, and this runs ahead of every GPU # transform: raising here would break a transform that would otherwise # succeed, rather than merely skipping a diagnostic. ndim = len(shape) axes = tuple(axis for axis in axes if -ndim <= axis < ndim) lengths = [] for i, axis in enumerate(axes): length = shape[axis] if s is not None and i < len(s) and s[i] is not None: length = s[i] lengths.append(int(length)) return tuple(lengths) def _warn_slow_fft_size(shape, func_name: str = "fft2", kwargs=None): """Warn once per large transform whose transformed lengths force Bluestein.""" lengths = _transform_lengths(shape, func_name, kwargs) if not lengths: return # Test cheaply first, and record only shapes that actually warn: the # memo then holds at most one entry per warning ever emitted, and a run # whose grid is fast never touches the lock on the FFT hot path. if math.prod(lengths) < _SLOW_FFT_WARN_MIN_ELEMENTS: return if all(is_fast_fft_size(n) for n in lengths): return with _warned_slow_fft_shapes_lock: if lengths in _warned_slow_fft_shapes: return _warned_slow_fft_shapes.add(lengths) shown = " x ".join(str(n) for n in lengths) suggestion = " x ".join(str(next_fast_fft_size(n)) for n in lengths) # Only a 2D transform of the trailing axes is the wave-function grid the # user sets with gpts; for anything else (e.g. the 3D structure-factor # grid, which follows from g_max and the cell) that advice would be wrong. remedy = ( ", e.g. by setting gpts explicitly, or -- if this grid was derived from " "a numeric sampling -- with " "abtem.config.set({'grid.round-to-fast-fft': True})" if len(lengths) == 2 and len(shape) >= 2 and tuple(shape[-2:]) == lengths else "" ) warnings.warn( f"FFT size {shown} contains prime factors larger than 7; " "cuFFT falls back to the Bluestein algorithm, which is several times " "slower and allocates a workspace of several times the array size. " f"Consider adjusting the transformed grid to {suggestion}{remedy}.", UserWarning, )
[docs] def warn_if_slow_gpu_fft(x, func_name: str = "fft2", **kwargs): """ Emit the slow-FFT diagnostic for a transform run outside ``_fft_dispatch``. A few transforms call ``xp.fft`` directly rather than through the wrappers in this module -- ``structure_factor_to_potential`` does, because the FFTW backend here only ever transforms the trailing two axes and would silently turn its 3D transform into a 2D one. They still deserve the diagnostic, so they can call this alongside. Only GPU arrays are considered: the Bluestein workspace this warns about is a cuFFT concern. """ if cp is not None and isinstance(x, cp.ndarray): _warn_slow_fft_size(x.shape, func_name, kwargs)
def _raise_fft_lib_not_present(lib_name: str): raise RuntimeError( f"FFT library {lib_name} not present. Install this package or change the FFT" "library in your configuration." ) def _fft_name_to_fftw_direction(name: str) -> str: if name[0] == "i": direction = "FFTW_BACKWARD" else: direction = "FFTW_FORWARD" return direction def _new_fftw_object(array: np.ndarray, name: str, flags: tuple[str, ...] = ()): dummy = np.zeros_like(array) direction = _fft_name_to_fftw_direction(name) fftw_object = pyfftw.FFTW( dummy, dummy, axes=(-2, -1), direction=direction, threads=config.get("fftw.threads"), flags=(config.get("fftw.planning_effort"),) + flags, planning_timelimit=config.get("fftw.planning_timelimit"), ) fftw_object.update_arrays(array, array) return fftw_object
[docs] class CachedFFTWConvolution: def __init__(self): self._fftw_objects = None self._shape = None def __call__( self, array: np.ndarray, kernel: np.ndarray, overwrite_x: bool ) -> np.ndarray: if array.shape != self._shape: self._fftw_objects = None if self._fftw_objects is None: fftw_objects = { name: _new_fftw_object(array, name=name) for name in ("ifft2", "fft2") } self._fftw_objects = fftw_objects if not overwrite_x: array = array.copy() self._fftw_objects["fft2"].update_arrays(array, array) self._fftw_objects["ifft2"].update_arrays(array, array) array = self._fftw_objects["fft2"]() array *= kernel array = self._fftw_objects["ifft2"]() return array
[docs] def get_fftw_object( array: np.ndarray, name: str, allow_new_wisdom: bool = True, overwrite_x: bool = False, axes: tuple[int, ...] = (-2, -1), ): """ Get a pyfftw object for a given array and a given FFT function. The object is cached and reused if the array shape is the same. Parameters ---------- array : numpy.ndarray Array to create the FFT object for. name : str Name of the FFT function. allow_new_wisdom : bool, optional Allow new wisdom to be created. overwrite_x : bool, optional Allow the input array to be overwritten. Returns ------- pyfftw.FFTW FFTW object. """ direction = _fft_name_to_fftw_direction(name) flags: tuple[str, ...] = (config.get("fftw.planning_effort"),) if overwrite_x: flags += ("FFTW_DESTROY_INPUT",) try: fftw = pyfftw.FFTW( array, array, axes=axes, direction=direction, threads=config.get("fftw.threads"), flags=flags + ("FFTW_WISDOM_ONLY",), ) except RuntimeError as e: if str(e) != "No FFTW wisdom is known for this plan.": raise if not allow_new_wisdom and config.get("fftw.allow_fallback"): return getattr(pyfftw.builders, name)(array) elif not allow_new_wisdom: raise _new_fftw_object(array, name, flags=flags) return get_fftw_object( array, name, allow_new_wisdom=False, overwrite_x=overwrite_x, axes=axes ) return fftw
def _mkl_fft_dispatch( x: np.ndarray, func_name: str, overwrite_x: bool, **kwargs ) -> np.ndarray: if mkl_fft is None: _raise_fft_lib_not_present("mkl_fft") with threadpool_limits(limits=config.get("mkl.threads")): return getattr(mkl_fft, func_name)(x, overwrite_x=overwrite_x, **kwargs) def _fftw_dispatch( x: np.ndarray, func_name: str, overwrite_x: bool, **kwargs ) -> np.ndarray: if pyfftw is None: _raise_fft_lib_not_present("pyfftw") if not overwrite_x: x = x.copy() return get_fftw_object(x, func_name, overwrite_x=overwrite_x, **kwargs)() U = TypeVar("U", np.ndarray, da.core.Array) # Cache the parsed cuFFT cache limit + the config value it was derived from. # Revalidated on every GPU FFT dispatch without reparsing when the config is # unchanged (the common case in a hot loop). _CUFFT_CACHE_STATE: tuple[object, int] | None = None def _configure_cufft_cache(): """Apply ``cupy.fft-cache-size`` from the abTEM config to the cuFFT plan cache. Called on every GPU FFT dispatch so that runtime config changes take effect. The config value is a memory limit on the total workspace held by cached plans: * ``0 MB`` — disable plan caching entirely (plans destroyed after each call, workspace freed immediately). Use when VRAM is very tight; trades a small per-FFT planning overhead for zero persistent workspace. * positive — allow up to N bytes of cached workspace (``set_memsize``). Plans are reused for repeated same-shape transforms but the total persistent workspace is bounded. * ``-1`` — unlimited (CuPy default, plans cached indefinitely). * ``auto`` — 25 % of the device's total memory, resolved per device. The abTEM default is ``auto``. The bound must scale with the card: batch auto-sizing targets ~8 % of VRAM per batch and the live plan workspace is ~1x the batch bytes on fast-radix shapes (~2x on Bluestein shapes), so the live working set of a production scan is ~10-17 % of VRAM on any card — a fixed byte bound either evicts live plans on large cards (a flat 2 GB was measured to cost ~7 % on a 40 GB A100) or is needlessly tight on small ones. 25 % clears the live set with margin, while still capping the stale workspace an unlimited cache accumulates across the distinct batch shapes of successive computations. The limit is a ceiling, not a reservation — unused headroom costs no memory. """ global _CUFFT_CACHE_STATE raw = config.get("cupy.fft-cache-size", "auto") # The plan cache and the resolved "auto" limit are per device, so the # applied state is keyed on the current device as well as the raw value. device = cp.cuda.Device() if _CUFFT_CACHE_STATE is not None and _CUFFT_CACHE_STATE[0] == (raw, device.id): return if raw is None: limit = -1 # null = no bound, matching the sibling keys' convention elif raw == "auto": limit = device.mem_info[1] // 4 elif isinstance(raw, str): from dask.utils import parse_bytes limit = parse_bytes(raw) else: limit = int(raw) cache = cp.fft.config.get_plan_cache() if limit == 0: cache.set_size(0) # disable caching entirely elif limit > 0: if cache.get_size() == 0: cache.set_size(16) # re-enable a previously disabled cache cache.set_memsize(limit) else: # Explicitly restore "unlimited": an earlier bound (e.g. from the # "auto" default) must be undoable at runtime -- the oversized-plan # warning recommends exactly this. if cache.get_size() == 0: cache.set_size(16) cache.set_memsize(-1) _CUFFT_CACHE_STATE = ((raw, device.id), limit) _warned_plan_cache_bypass = False def _cupy_fft_with_cache_fallback(func, x, **kwargs): """Run a CuPy FFT, bypassing the plan cache for oversized plans. A bounded plan cache refuses to admit a single plan larger than its memsize bound, and CuPy raises instead of running the transform uncached (CUDA only; the ROCm path does not track plan memsize). Retry such calls with the cache temporarily disabled: the plan is built, used and discarded, costing replanning time per call instead of a crash. """ global _warned_plan_cache_bypass try: return func(x, **kwargs) except RuntimeError as exc: if "plan memsize is too large" not in str(exc).lower(): raise cache = cp.fft.config.get_plan_cache() size = cache.get_size() memsize = cache.get_memsize() if not _warned_plan_cache_bypass: _warned_plan_cache_bypass = True # The exceeded bound is positive in practice (CuPy only raises the # too-large error for bounded caches); guard the arithmetic anyway. bound = f" of {memsize / 1e9:.1f} GB" if memsize > 0 else "" suggested = f"{2 * memsize / 1e9:.0f} GB" if memsize > 0 else "32 GB" warnings.warn( f"A single cuFFT plan for shape {x.shape} exceeds the plan-cache " f"bound{bound}; running the transform uncached, which replans on " "every call for this shape. To avoid the replanning cost, either " "raise the bound, e.g. abtem.config.set({'cupy.fft-cache-size': " f"'{suggested}'" "}) (-1 = unlimited), or reduce the wave-function batch size, " "e.g. probe.scan(..., max_batch=8). Grid sizes with prime " "factors larger than 7 (Bluestein fallback) make plans several " "times larger -- an FFT-friendly gpts choice avoids this " "entirely." ) cache.set_size(0) try: return func(x, **kwargs) finally: cache.set_size(size) cache.set_memsize(memsize) def _fft_dispatch( x: U, func_name: str, overwrite_x: bool = False, **kwargs: dict, ) -> U: xp = get_array_module(x) if isinstance(x, np.ndarray): if config.get("fft") == "mkl": return _mkl_fft_dispatch(x, func_name, overwrite_x, **kwargs) elif config.get("fft") == "fftw": return _fftw_dispatch(x, func_name, overwrite_x, **kwargs) elif config.get("fft") == "numpy": return getattr(np.fft, func_name)(x, **kwargs) else: raise RuntimeError() if isinstance(x, da.core.Array): return da.map_blocks( _fft_dispatch, x, func_name=func_name, overwrite_x=overwrite_x, **kwargs, meta=xp.array((), dtype=get_dtype(complex=True)), ) check_cupy_is_installed() # type: ignore if isinstance(x, cp.ndarray): _configure_cufft_cache() _warn_slow_fft_size(x.shape, func_name, kwargs) return _cupy_fft_with_cache_fallback( getattr(cp.fft, func_name), x, **kwargs ) @overload def fft2(x: np.ndarray, overwrite_x: bool = False, **kwargs) -> np.ndarray: ... @overload def fft2(x: da.core.Array, overwrite_x: bool = False, **kwargs) -> da.core.Array: ...
[docs] def fft2(x: U, overwrite_x: bool = False, **kwargs) -> U: """ Compute the 2-dimensional discrete Fourier Transform. Using the FFT library specified in the configuration. """ return _fft_dispatch(x, func_name="fft2", overwrite_x=overwrite_x, **kwargs)
@overload def ifft2(x: np.ndarray, overwrite_x: bool = False, **kwargs) -> np.ndarray: ... @overload def ifft2(x: da.core.Array, overwrite_x: bool = False, **kwargs) -> da.core.Array: ...
[docs] def ifft2(x: U, overwrite_x: bool = False, **kwargs) -> U: """ Compute the 2-dimensional inverse discrete Fourier Transform. Using the FFT library specified in the configuration. """ return _fft_dispatch(x, func_name="ifft2", overwrite_x=overwrite_x, **kwargs)
@overload def fftn(x: np.ndarray, overwrite_x: bool = False, **kwargs) -> np.ndarray: ... @overload def fftn(x: da.core.Array, overwrite_x: bool = False, **kwargs) -> da.core.Array: ...
[docs] def fftn(x: U, overwrite_x: bool = False, **kwargs) -> U: """Compute the n-dimensional discrete Fourier Transform. Using the FFT library specified in the configuration.""" return _fft_dispatch(x, func_name="fftn", overwrite_x=overwrite_x, **kwargs)
@overload def ifftn(x: np.ndarray, overwrite_x: bool = False, **kwargs) -> np.ndarray: ... @overload def ifftn(x: da.core.Array, overwrite_x: bool = False, **kwargs) -> da.core.Array: ...
[docs] def ifftn(x: U, overwrite_x: bool = False, **kwargs) -> U: """ Compute the n-dimensional inverse discrete Fourier Transform. Using the FFT library specified in the configuration. """ return _fft_dispatch(x, func_name="ifftn", overwrite_x=overwrite_x, **kwargs)
def _fft2_convolve(x: U, kernel: U, overwrite_x: bool = False) -> U: x = fft2(x, overwrite_x=overwrite_x) try: x *= kernel except ValueError: x = x * kernel return ifft2(x, overwrite_x=overwrite_x) @overload def fft2_convolve( x: np.ndarray, kernel: np.ndarray, overwrite_x: bool = False ) -> np.ndarray: ... @overload def fft2_convolve( x: da.core.Array, kernel: np.ndarray, overwrite_x: bool = False ) -> da.core.Array: ...
[docs] def fft2_convolve(x: U, kernel: np.ndarray, overwrite_x: bool = False) -> U: """ Compute the 2-dimensional convolution of an array with a kernel. Parameters ---------- x : numpy.ndarray or da.core.Array Array to convolve. kernel : numpy.ndarray Convolution kernel. overwrite_x : bool, optional Overwrite the input array. Returns ------- numpy.ndarray or da.core.Array Convolved array. """ xp = get_array_module(x) if isinstance(x, np.ndarray): return _fft2_convolve(x, kernel, overwrite_x) if isinstance(x, da.core.Array): return da.map_blocks( _fft2_convolve, x, kernel=kernel, overwrite_x=overwrite_x, meta=xp.array((), dtype=get_dtype(complex=True)), ) check_cupy_is_installed() # type: ignore if isinstance(x, cp.ndarray): return _fft2_convolve(x, kernel, overwrite_x)
[docs] def fft_shift_kernel(positions: np.ndarray, shape: tuple[int, ...]) -> np.ndarray: """ Create an array representing one or more phase ramp(s) for shifting another array. Parameters ---------- positions : numpy.ndarray Array of positions to shift the array to. The last dimension should be the number of dimensions to shift. shape : tuple Shape of the array to shift. Returns ------- numpy.ndarray Array representing the phase ramp(s). """ xp = get_array_module(positions) if cp is None or not isinstance(positions, cp.ndarray): positions = np.array(positions) assert positions.shape[-1] == len(shape) dims = positions.shape[-1] n = len(positions.shape) - 1 k = list(spatial_frequencies(shape, (1.0,) * dims, xp=xp)) for i in range(dims): d = list(range(0, n)) + list(range(n, n + dims)) del d[i + n] expanded_positions = np.expand_dims( positions[..., i], tuple(range(n, n + dims)) ) k[i] = complex_exponential( -2 * np.pi * np.expand_dims(k[i], tuple(d)) * expanded_positions ) array = k[0] for i in range(1, dims): array = array * k[i] return array
[docs] def fft_shift(array: np.ndarray, positions: np.ndarray) -> np.ndarray: """ Shift an array in real space using Fourier space interpolation. Parameters ---------- array : numpy.ndarray Array to shift. positions : numpy.ndarray Array of positions to shift the array to. The last dimension should be the number of dimensions to shift. Returns ------- numpy.ndarray Shifted array """ return ifft2(fft2(array) * fft_shift_kernel(positions, array.shape[-2:]))
def _fft_interpolation_masks_1d(n1: int, n2: int) -> tuple[np.ndarray, np.ndarray]: mask1 = np.zeros(n1, dtype=bool) mask2 = np.zeros(n2, dtype=bool) if n2 > n1: mask1[:] = True if n1 == 1: mask2[0] = True elif n1 % 2 == 0: mask2[: n1 // 2] = True mask2[-n1 // 2 :] = True else: mask2[: n1 // 2 + 1] = True mask2[-n1 // 2 + 1 :] = True else: if n2 == 1: mask1[0] = True elif n2 % 2 == 0: mask1[: n2 // 2] = True mask1[-n2 // 2 :] = True else: mask1[: n2 // 2 + 1] = True mask1[-n2 // 2 + 1 :] = True mask2[:] = True return mask1, mask2
[docs] def fft_interpolation_masks( shape_in: tuple[int, ...], shape_out: tuple[int, ...] ) -> tuple[np.ndarray, np.ndarray]: """ Create boolean masks for interpolating between two arrays using Fourier space interpolation. Parameters ---------- shape_in : tuple of int Shape of the input array to interpolate from. shape_out : tuple of int Shape of the output array to interpolate to. Returns ------- tuple of numpy.ndarray Masks for the input and output arrays. """ mask1_1d = [] mask2_1d = [] for i, (n1, n2) in enumerate(zip(shape_in, shape_out)): m1, m2 = _fft_interpolation_masks_1d(n1, n2) s = [slice(None) if j == i else np.newaxis for j in range(len(shape_in))] mask1_1d += [m1[tuple(s)]] mask2_1d += [m2[tuple(s)]] mask1 = mask1_1d[0] for m in mask1_1d[1:]: mask1 = mask1 * m mask2 = mask2_1d[0] for m in mask2_1d[1:]: mask2 = mask2 * m return mask1, mask2
def _fft_interpolation_slices_1d(n1: int, n2: int) -> list[tuple[slice, slice]]: """ Return ``(in_slice, out_slice)`` pairs giving the low- and high-frequency segments to copy from an FFT-shift-free array of size ``n1`` to one of size ``n2``. Produces the same result as ``out[mask2] = inp[mask1]`` with masks from ``_fft_interpolation_masks_1d`` — but as contiguous slices, so cropping maps to a handful of memcpys instead of an advanced-indexing kernel over the full array. """ n = min(n1, n2) if n == 1: return [(slice(0, 1), slice(0, 1))] if n % 2 == 0: h = n // 2 t = n // 2 else: h = n // 2 + 1 t = n // 2 return [ (slice(0, h), slice(0, h)), (slice(n1 - t, n1), slice(n2 - t, n2)), ]
[docs] def fft_crop(array: np.ndarray, new_shape: tuple[int, ...], normalize: bool = False): """ Crop an array. It is assumed that the array is centered in Fourier space, this is used for real-space interpolation. Parameters ---------- array : numpy.ndarray Array to crop. new_shape : tuple of int New shape of the array. If the new shape is smaller than the input array, each preceding dimension is treated as a batch dimension. normalize : bool, optional If True, renormalize the array to conserve the total amplitude. Returns ------- numpy.ndarray Cropped array. """ xp = get_array_module(array) if len(new_shape) < len(array.shape): new_shape = array.shape[: -len(new_shape)] + new_shape # Build per-dimension slice-pair lists. Dimensions with equal in/out size # (e.g. batch dims) take a single full-slice pair; the rest contribute 1–2 # (in_slice, out_slice) segments matching the centered-spectrum layout. # The cartesian product yields up to 2**D corner copies (D = dims that # change) — typically 4 for the 2D FFT case — avoiding both the # materialisation of an N-dimensional boolean mask and advanced-indexing # kernels that stream the full array once per call. slice_pairs: list[list[tuple[slice, slice]]] = [] for n1, n2 in zip(array.shape, new_shape): if n1 == n2: slice_pairs.append([(slice(None), slice(None))]) else: slice_pairs.append(_fft_interpolation_slices_1d(n1, n2)) new_array = xp.zeros(new_shape, dtype=array.dtype) for combo in _product(*slice_pairs): in_sl = tuple(p[0] for p in combo) out_sl = tuple(p[1] for p in combo) new_array[out_sl] = array[in_sl] if normalize: new_array = new_array * np.prod(new_array.shape) / np.prod(array.shape) return new_array
[docs] def fft_interpolate( array: np.ndarray, new_shape: Tuple[int, ...], normalization: str = "values", overwrite_x: bool = False, ): """ Interpolate an array using Fourier space interpolation. Parameters ---------- array : numpy.ndarray Array to interpolate. new_shape : tuple of int New shape of the array. normalization : str, optional Normalization to apply to the array. Can be 'values' or 'amplitude'. overwrite_x : bool, optional Overwrite the input array. Returns ------- numpy.ndarray Interpolated array. """ old_size = np.prod(array.shape[-len(new_shape) :]) is_complex = np.iscomplexobj(array) array = array.astype(get_dtype(complex=True)) if len(new_shape) == 2: array = fft2(array, overwrite_x=overwrite_x) array = fft_crop(array, new_shape) array = ifft2(array, overwrite_x=overwrite_x) else: if len(new_shape) != len(array.shape): axes = tuple(range(len(array.shape) - len(new_shape), len(array.shape))) else: axes = tuple(range(len(array.shape))) array = fftn(array, overwrite_x=overwrite_x, axes=axes) array = fft_crop(array, new_shape) array = ifftn(array, overwrite_x=overwrite_x, axes=axes) if not is_complex: array = array.real if normalization == "values": array *= np.prod(array.shape[-len(new_shape) :]) / old_size elif normalization in ("amplitude", "intensity"): pass else: raise ValueError(f"Normalization '{normalization}' not recognized.") return array