warpfield.ndimage

  1import cupyx.scipy.signal.windows
  2import numpy as np
  3import cupy as cp
  4import cupyx
  5import scipy.ndimage
  6import cupyx.scipy.ndimage
  7import cupyx.scipy.signal
  8
  9
 10def dogfilter(vol, sigma_low=1, sigma_high=4, mode="reflect"):
 11    """Diffference of Gaussians filter
 12
 13    Args:
 14        vol (array_like): data to be filtered
 15        sigma_low (scalar or sequence of scalar): standard deviations
 16        sigma_high (scalar or sequence of scalar): standard deviations
 17        mode (str): The array borders are handled according to the given mode
 18
 19    Returns:
 20        (array_like): filtered data
 21
 22    See also:
 23        cupyx.scipy.ndimage.gaussian_filter
 24        skimage.filters.difference_of_gaussians
 25    """
 26    in_module = vol.__class__.__module__
 27    vol = cp.array(vol, "float32", copy=False)
 28    out = cupyx.scipy.ndimage.gaussian_filter(vol, sigma_low, mode=mode)
 29    out -= cupyx.scipy.ndimage.gaussian_filter(vol, sigma_high, mode=mode)
 30    if in_module == "numpy":
 31        out = out.get()
 32    return out
 33
 34
 35def periodic_smooth_decomposition_nd_rfft(img):
 36    """
 37    Decompose ND arrays of 2D images into periodic plus smooth components. This can help with edge artifacts in
 38    Fourier transforms.
 39
 40    Args:
 41        img (cupy.ndarray): input image or volume. The last two axes are treated as the image dimensions.
 42
 43    Returns:
 44        cupy.ndarray: periodic component
 45    """
 46    # compute border-difference
 47    B = cp.zeros_like(img)
 48    B[..., 0, :] = img[..., -1, :] - img[..., 0, :]
 49    B[..., -1, :] = -B[..., 0, :]
 50    B[..., :, 0] += img[..., :, -1] - img[..., :, 0]
 51    B[..., :, -1] -= img[..., :, -1] - img[..., :, 0]
 52
 53    # real FFT of border difference
 54    B_rfft = cp.fft.rfftn(B, axes=(-2, -1))
 55    del B
 56
 57    # build denom for full grid then slice to half-spectrum
 58    M, N = img.shape[-2:]
 59    q = cp.arange(M, dtype="float32").reshape(M, 1)
 60    r = cp.arange(N, dtype="float32").reshape(1, N)
 61    denom_full = 2 * cp.cos(2 * np.pi * q / M) + 2 * cp.cos(2 * np.pi * r / N) - 4
 62    # take only first N//2+1 columns
 63    denom_half = denom_full[:, : (N // 2 + 1)]
 64    denom_half[0, 0] = 1  # avoid divide by zero
 65
 66    # compute smooth in freq domain (half-spectrum)
 67    B_rfft /= denom_half
 68    B_rfft[..., 0, 0] = 0
 69
 70    # invert real FFT back to spatial
 71    # smooth = cp.fft.irfftn(B_rfft, s=(M, N), axes=(-2, -1))
 72    # periodic = img - smooth
 73    tmp = cp.fft.irfftn(B_rfft, s=(M, N), axes=(-2, -1))
 74    tmp *= -1
 75    tmp += img
 76    return tmp
 77
 78
 79def gausswin(shape, sigma):
 80    """Create Gaussian window of a given shape and sigma
 81
 82    Args:
 83        shape (list or tuple): shape along each dimension
 84        sigma (list or tuple): sigma along each dimension
 85
 86    Returns:
 87        (array_like): Gauss window
 88    """
 89    grid = np.indices(shape).astype("float32")
 90    for dim in range(len(grid)):
 91        grid[dim] -= shape[dim] // 2
 92        grid[dim] /= sigma[dim]
 93    out = np.exp(-(grid**2).sum(0) / 2)
 94    out /= out.sum()
 95    # out = np.fft.fftshift(out)
 96    return out
 97
 98
 99def gausskernel_sheared(sigma, shear=0, truncate=3):
100    """Create Gaussian window of a given shape and sigma. The window is sheared along the first two axes.
101
102    Args:
103        sigma (float or tuple of float): Standard deviation for Gaussian kernel.
104        shear (float): Shear factor in d_axis0 / d_axis1
105        truncate (float): Truncate the filter at this many standard deviations.
106
107    Returns:
108        window (array_like): n-dimensional window
109    """
110    # TODO: consider moving to .unshear
111
112    shape = (np.r_[sigma] * truncate * 2).astype("int")
113    shape[0] = np.maximum(shape[0], int(np.ceil(shape[1] * np.abs(shear))))
114    shape = (shape // 2) * 2 + 1
115    grid = np.indices(shape).astype("float32")
116    for dim in range(len(grid)):
117        grid[dim] -= shape[dim] // 2
118        grid[dim] /= sigma[dim]
119    grid[0] = grid[0] + shear * grid[1] * sigma[1] / sigma[0]
120    out = np.exp(-(grid**2).sum(0) / 2)
121    out /= out.sum()
122    return out
123
124
125def ndwindow(shape, window_func):
126    """Create a n-dimensional window function
127
128    Args:
129        shape (tuple): shape of the window
130        window_func (function): window function to be applied to each dimension
131
132    Returns:
133        window (array_like): n-dimensional window
134    """
135    out = 1
136    for i in range(len(shape)):
137        newshape = np.ones(len(shape), dtype="int")
138        newshape[i] = shape[i]
139        w = window_func(shape[i])
140        out = out * np.reshape(w, newshape)
141    return out
142
143
144def accumarray(coords, shape, weights=None, clip=False):
145    """Accumulate values into an array using given coordinates and weights
146
147    Args:
148        coords (array_like): 3-by-n array of coordinates
149        shape (tuple): shape of the output array
150        weights (array_like): weights to be accumulated. If None, all weights are set to 1
151        clip (bool): if True, clip coordinates to the shape of the output array, else ignore out-of-bounds coordinates. Default is False.
152
153    Returns:
154        accum (array_like): accumulated array of the given shape
155    """
156    assert coords.shape[0] == 3
157    coords = np.round(coords.reshape(3, -1)).astype("int")
158    if clip:
159        for d in len(shape):
160            coords[d] = np.minimum(np.maximum(coords[d], 0), shape[d] - 1)
161    else:
162        valid_ix = np.all((coords >= 0) & (coords < np.array(shape)[:, None]), axis=0)
163        coords = coords[:, valid_ix]
164        if weights is not None:
165            weights = weights.ravel()[valid_ix]
166    coords_as_ix = np.ravel_multi_index((*coords,), shape).ravel()
167    accum = np.bincount(coords_as_ix, minlength=np.prod(shape), weights=weights)
168    accum = accum.reshape(shape)
169    return accum
170
171
172def infill_nans(arr, sigma=0.5, truncate=50, ignore_warning=True):
173    """Infill NaNs in an array using Gaussian basis interpolation
174
175    Args:
176        arr (array_like): input array
177        sigma (float): standard deviation of the Gaussian basis function
178        truncate (float): truncate the filter at this many standard deviations. Note: values outside the truncation may still contain NaNs.
179        ignore_warning (bool): if True, ignore warnings about invalid values during division
180    """
181    nans = np.isnan(arr)
182    arr_zeros = arr.copy()
183    arr_zeros[nans] = 0
184    a = scipy.ndimage.gaussian_filter(np.array(arr_zeros, dtype="float64"), sigma=sigma, truncate=truncate)
185    b = scipy.ndimage.gaussian_filter(np.array(~nans, dtype="float64"), sigma=sigma, truncate=truncate)
186    if ignore_warning:
187        with np.errstate(invalid="ignore"):
188            out = (a / b).astype(arr.dtype)
189    else:
190        out = (a / b).astype(arr.dtype)
191    return out
192
193
194def sliding_block(data, block_size=100, block_stride=1):
195    """Create a sliding window/block view into the array with the given block shape and stride. The block slides across all dimensions of the array and extracts subsets of the array at all positions.
196
197    Args:
198        data (array_like): Array to create the sliding window view from
199        block_size (int or tuple of int): Size of window over each axis that takes part in the sliding block
200        block_stride (int or tuple of int): Stride of teh window along each axis
201
202    Returns:
203        view (ndarray): Sliding block view of the array.
204
205    See Also:
206        numpy.lib.stride_tricks.sliding_window_view
207        numpy.lib.stride_tricks.as_strided
208
209    """
210    block_stride *= np.ones(data.ndim, dtype="int")
211    block_size *= np.ones(data.ndim, dtype="int")
212    shape = np.r_[1 + (data.shape - block_size) // block_stride, block_size]
213    strides = np.r_[block_stride * data.strides, data.strides]
214    xp = cp.get_array_module(data)
215    out = xp.lib.stride_tricks.as_strided(data, shape, strides)
216    return out
217
218
219def upsampled_dft_rfftn(
220    data: cp.ndarray, upsampled_region_size, upsample_factor: int = 1, axis_offsets=None
221) -> cp.ndarray:
222    """
223    Performs an upsampled inverse DFT on a small region around given offsets,
224    taking as input the output of cupy.fft.rfftn (real-to-complex FFT).
225
226    This implements the Guizar‑Sicairos local DFT upsampling: no full zero‑padding,
227    just a small m×n patch at subpixel resolution.
228
229    Args:
230        data: A real-to-complex FFT array of shape (..., M, Nf),
231            where Nf = N//2 + 1 corresponds to an original real image width N.
232        upsampled_region_size: Size of the output patch (m, n). If an int is
233            provided, the same size is used for both dimensions.
234        upsample_factor: The integer upsampling factor in each axis.
235        axis_offsets: The center of the patch in original-pixel coordinates
236            (off_y, off_x). If None, defaults to (0, 0).
237
238    Returns:
239        A complex-valued array of shape (..., m, n) containing the
240        upsampled inverse DFT patch.
241    """
242    if data.ndim < 2:
243        raise ValueError("Input must have at least 2 dimensions")
244    *batch_shape, M, Nf = data.shape
245    # determine patch size
246    if isinstance(upsampled_region_size, int):
247        m, n = upsampled_region_size, upsampled_region_size
248    else:
249        m, n = upsampled_region_size
250    # full width of original image
251    N = (Nf - 1) * 2
252
253    # default offset: origin
254    off_y, off_x = (0.0, 0.0) if axis_offsets is None else axis_offsets
255
256    # reconstruct full complex FFT via Hermitian symmetry
257    full = cp.empty(batch_shape + [M, N], dtype=cp.complex64)
258    full[..., :Nf] = data
259    if Nf > 1:
260        tail = data[..., :, 1:-1]
261        full[..., Nf:] = tail[..., ::-1, ::-1].conj()
262
263    # frequency coordinates
264    fy = cp.fft.fftfreq(M)[None, :]  # shape (1, M)
265    fx = cp.fft.fftfreq(N)[None, :]  # shape (1, N)
266
267    # sample coordinates around offsets
268    y_idx = cp.arange(m) - (m // 2)
269    x_idx = cp.arange(n) - (n // 2)
270    y_coords = off_y[:, None] + y_idx[None, :] / upsample_factor  # (B, m)
271    x_coords = off_x[:, None] + x_idx[None, :] / upsample_factor  # (B, n)
272
273    # Build small inverse‐DFT kernels
274    ky = cp.exp(2j * cp.pi * y_coords[:, :, None] * fy[None, :, :]).astype("complex64")
275    kx = cp.exp(2j * cp.pi * x_coords[:, :, None] * fx[None, :, :]).astype("complex64")
276
277    # First apply along y: (B,m,M) × (B,M,N) -> (B,m,N)
278    out1 = cp.einsum("b m M, b M N -> b m N", ky, full)
279    # Then along x: (B,m,N) × (B,n,N)ᵀ -> (B,m,n)
280    patch = cp.einsum("b m N, b n N -> b m n", out1, kx)
281
282    return patch.real.reshape(*batch_shape, m, n)
283
284
285def zoom_chop_pad(
286    arr, target_shape=None, scale=(1, 1, 1), soft_edge=(0, 0, 0), shift=(0, 0, 0), flip=(False, False, False), cval=0
287):
288    """Zooms, softens, flips, shifts, and pads/crops a 3D array to match the target shape.
289
290    The conceptual order is as follows: zoom, soften, flip, shift, crop/pad.
291
292    Args:
293        arr (np.ndarray or cp.ndarray): The input array to be transformed
294        target_shape (tuple of int): The desired target shape to pad/crop to. Defaults to the shape of the input array.
295        scale (tuple): Zoom factors for each axis. Default: (1, 1, 1).
296        soft_edge (tuple of int): The size of the soft edge (Tukey envelope) to be applied to the input array, in voxels. Default: (0, 0, 0).
297        shift (tuple): Shifts for each axis, in voxels. Default: (0, 0, 0).
298        flip (tuple of bool): Whether to flip each axis. Default: (False, False, False).
299        cval (int, float): The value to use for padding. Default: 0.
300
301    Returns:
302        np.ndarray or cp.ndarray: The transformed array. Dtype is float32.
303    """
304
305    was_numpy = isinstance(arr, np.ndarray)
306
307    if target_shape is None:
308        target_shape = arr.shape
309
310    if any(s > 0 for s in soft_edge):
311        arr = cp.array(arr, dtype="float32", copy=True, order="C")
312        scaled_edge = np.array(soft_edge) / np.array(scale)
313        arr = soften_edges(arr, soft_edge=scaled_edge, copy=False)
314    else:
315        arr = cp.array(arr, dtype="float32", copy=False, order="C")
316
317    coords = cp.indices(target_shape, dtype=cp.float32)
318    for i in range(len(coords)):
319        coords[i] -= target_shape[i] / 2
320        coords[i] /= scale[i]
321        coords[i] += arr.shape[i] / 2
322        if flip[i]:
323            coords[i] *= -1
324            coords[i] += arr.shape[i] - 1
325        coords[i] -= shift[i]
326    result = cupyx.scipy.ndimage.map_coordinates(arr, coords, order=1, mode="constant", cval=cval)
327
328    if was_numpy:
329        result = result.get()
330    return result
331
332
333def soften_edges(arr, soft_edge=(0, 0, 0), copy=True):
334    """Apply a soft Tukey edge to the input array.
335
336    Args:
337        arr (np.ndarray or cp.ndarray): The input array
338        soft_edge (tuple of int): The size of the soft edge (Tukey envelope) to be applied to the input array, in voxels. Default: (0, 0, 0).
339        copy (bool): If True, a copy of the array is made. Default: True.
340
341    Returns:
342        np.ndarray or cp.ndarray: The transformed array. Dtype is float32.
343    """
344    was_numpy = isinstance(arr, np.ndarray)
345    input_dtype = arr.dtype
346    arr = cp.array(arr, dtype="float32", copy=copy)
347    if isinstance(soft_edge, int) or isinstance(soft_edge, float):
348        soft_edge = (soft_edge,) * arr.ndim
349    soft_edge = np.clip(soft_edge, 0, np.array(arr.shape) / 2)
350
351    for i in range(arr.ndim):
352        if soft_edge[i] > 0:
353            alpha = 2 * soft_edge[i] / arr.shape[i]
354            alpha = np.clip(alpha, 0, 1)
355            win = cupyx.scipy.signal.windows.tukey(arr.shape[i], alpha)
356            arr *= cp.moveaxis(win[:, None, None], 0, i)
357
358    arr = arr.astype(input_dtype, copy=False)
359    if was_numpy:
360        arr = arr.get()
361    return arr
362
363
364def zoom(arr, zoom_factors, order=1, mode="constant"):
365    """Zooms an array by given factors along each axis.
366
367    Args:
368        arr (np.ndarray or cp.ndarray): The input array to be zoomed.
369        zoom_factors (tuple of float): Zoom factors for each axis. Values greater than 1 result in a larger output array,
370            while values less than 1 result in a smaller array. Divide the physical voxel size of the input array by these values to get the physical voxel size of the output array.
371        order (int): The order of the spline interpolation. Default is 1 (linear).
372
373    Returns:
374        np.ndarray or cp.ndarray: The zoomed array.
375    """
376    was_numpy = isinstance(arr, np.ndarray)
377    arr = cp.array(arr, dtype="float32", copy=False, order="C")
378    out = cupyx.scipy.ndimage.zoom(arr, zoom_factors, order=order)
379    if was_numpy:
380        out = out.get()
381    return out
382
383
384def match_volumes(fixed, fixed_res, moving, moving_res, order=1, soft_edge=(0, 0, 0), cval=0, res_method="fixed"):
385    """
386    Rescale and pad the fixed and moving volumes so both have the same physical size and resolution.
387
388    Args:
389        fixed (ndarray): Fixed/reference volume.
390        fixed_res (tuple): Pixel size (resolution) for fixed, in physical units.
391        moving (ndarray): Moving volume.
392        moving_res (tuple): Pixel size (resolution) for moving, in physical units.
393        order (int): Interpolation order for zooming.
394        soft_edge (tuple): Soft edge parameter for padding.
395        cval (float): Constant value for padding.
396        res_method (str): How to choose the target resolution.
397            "fixed" (default): use fixed_res,
398            "min": use the finest (smallest) resolution,
399            "max": use the coarsest (largest) resolution,
400            "mean": use the mean of fixed_res and moving_res.
401
402    Returns:
403        fixed_out (ndarray): The fixed volume, rescaled and padded to the target resolution and physical size.
404        moving_out (ndarray): The moving volume, rescaled and padded to the target resolution and physical size.
405        target_res (tuple): The target resolution used for both volumes.
406    """
407    fixed_res = np.array(fixed_res)
408    moving_res = np.array(moving_res)
409    fixed_shape = np.array(fixed.shape)
410    moving_shape = np.array(moving.shape)
411
412    # Compute physical sizes
413    fixed_phys = fixed_shape * fixed_res
414    moving_phys = moving_shape * moving_res
415
416    # Target: match the larger physical size along each axis
417    target_phys = np.maximum(fixed_phys, moving_phys)
418
419    # Determine target resolution
420    if res_method == "fixed":
421        target_res = fixed_res
422    elif res_method == "min":
423        target_res = np.minimum(fixed_res, moving_res)
424    elif res_method == "max":
425        target_res = np.maximum(fixed_res, moving_res)
426    elif res_method == "mean":
427        target_res = (fixed_res + moving_res) / 2
428    else:
429        raise ValueError(f"Unknown target_res_type: {res_method}")
430
431    # Compute the target shape
432    target_shape = np.ceil(target_phys / target_res).astype(int)
433
434    # Rescale fixed
435    scale_fixed = fixed_res / target_res
436    fixed_out = zoom_chop_pad(
437        fixed, target_shape=target_shape, scale=scale_fixed, soft_edge=soft_edge, cval=cval, order=order
438    )
439
440    # Rescale moving
441    scale_moving = moving_res / target_res
442    moving_out = zoom_chop_pad(
443        moving, target_shape=target_shape, scale=scale_moving, soft_edge=soft_edge, cval=cval, order=order
444    )
445
446    return fixed_out, moving_out, tuple(target_res)
447
448
449def richardson_lucy_generic(img, convolve_psf, correlate_psf=None, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
450    """Richardson-Lucy deconvolution using arbitrary convolution operations, with optional Biggs acceleration.
451
452    Args:
453        img (ArrayLike): input image or volume
454        convolve_psf (Callable): function to convolve with PSF. Should take an image and return a convolved image. Ensure that the PSF is non-negative and normalized.
455        correlate_psf (Callable): function to correlate with PSF. Defaults to convolve_psf (if PSF is symmetric)
456        num_iter (int): number of iterations. Default is 5.
457        epsilon (float): small constant to prevent divide-by-zero. Default is 1e-3.
458        beta (float): acceleration parameter. Default is 0.0 (no acceleration). Typically, beta is in the range [0, 0.5].
459
460    Returns:
461        ndarray: deconvolved image
462    """
463    epsilon = cp.float32(epsilon)
464    img = cp.array(img, dtype="float32", copy=False)
465    cp.clip(img, 0, None, out=img)
466    if num_iter < 1:
467        return img
468    if correlate_psf is None:
469        correlate_psf = convolve_psf
470
471    if initial_guess is not None:
472        assert initial_guess.shape == img.shape, "Initial guess must have the same shape as the input image."
473        img_decon = cp.array(initial_guess, dtype="float32", copy=False)
474        cp.clip(img_decon, 0, None, out=img_decon)
475    else:   
476        img_decon = img.copy()
477    img_decon += epsilon
478
479    for i in range(num_iter):
480        img_decon *= correlate_psf(img / (convolve_psf(img_decon) + epsilon))
481
482        if beta > 0:
483            if i == 0: 
484                img_decon_prev = img_decon.copy()
485            else:
486                img_decon_new = img_decon.copy()
487                img_decon += beta * (img_decon - img_decon_prev)
488                cp.clip(img_decon, epsilon, None, out=img_decon)
489                img_decon_prev = img_decon_new
490    
491    return img_decon
492
493
494def richardson_lucy_fft(img, psf, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
495    """Richardson-Lucy deconvolution using FFT-based convolution and optional Biggs acceleration.
496
497    Args:
498        img (ndarray): input image or volume
499        psf (ndarray): point spread function (before fftshift)
500        num_iter (int): number of iterations
501        epsilon (float): small constant to avoid divide-by-zero
502        beta (float): Biggs acceleration parameter (0 = no acceleration)
503
504    Returns:
505        ndarray: deconvolved image
506    """
507    psf = cp.array(psf, dtype="float32")
508    cp.clip(psf, 0, None, out=psf)
509    psf /= psf.sum()
510
511    shape = img.shape
512    psf_ft = cp.fft.rfftn(cp.fft.ifftshift(psf), s=shape)
513    psf_ft_conj = cp.conj(psf_ft)
514
515    def convolve(x):
516        return cp.fft.irfftn(cp.fft.rfftn(x, s=shape) * psf_ft, s=shape)
517
518    def correlate(x):
519        return cp.fft.irfftn(cp.fft.rfftn(x, s=shape) * psf_ft_conj, s=shape)
520
521    out = richardson_lucy_generic(
522        img, convolve, correlate, num_iter=num_iter, epsilon=epsilon, beta=beta, initial_guess=initial_guess
523    )
524    return out
525
526
527def richardson_lucy_gaussian(img, sigmas, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
528    """Richardson-Lucy deconvolution using Gaussian convolution operations
529
530    Args:
531        img (ndarray): input image or volume
532        sigmas (list or ndarray): list of Gaussian sigmas along each dimension
533        num_iter (int): number of iterations
534        epsilon (float): small constant to prevent divide-by-zero
535        beta (float): acceleration parameter (0 = no acceleration)
536
537    Returns:
538        ndarray: deconvolved image
539    """
540    conv_with_gauss = lambda x: cupyx.scipy.ndimage.gaussian_filter(x, sigmas)
541    out = richardson_lucy_generic(
542        img, conv_with_gauss, num_iter=num_iter, epsilon=epsilon, beta=beta, initial_guess=initial_guess
543    )
544    return out
545
546
547def richardson_lucy_gaussian_shear(img, sigmas, shear, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
548    """Richardson-Lucy deconvolution using a sheared Gaussian psf
549
550    Args:
551        img (ndarray): input image or volume
552        sigmas (list or ndarray): list of Gaussian sigmas along each dimension
553        shear (scalar): shear ratio
554        num_iter (int): number of iterations
555        epsilon (float): small constant to prevent divide-by-zero
556        beta (float): acceleration parameter (0 = no acceleration)
557
558    Returns:
559        ndarray: deconvolved image
560    """
561    if shear == 0:
562        return richardson_lucy_gaussian(img, sigmas, num_iter)
563
564    sigmas = np.array(sigmas)
565    gw = cp.array(gausskernel_sheared(sigmas, shear=shear, truncate=4), "float32")
566    gw01 = gw.sum(2)[:, :, None]
567    gw01 /= gw01.sum()
568    gw2 = gw.sum(axis=(0, 1))[None, None, :]
569    gw2 /= gw2.sum()
570    conv_shear = lambda vol: cupyx.scipy.ndimage.convolve(cupyx.scipy.ndimage.convolve(vol, gw01), gw2)
571    out = richardson_lucy_generic(
572        img, conv_shear, num_iter=num_iter, epsilon=epsilon, beta=beta, initial_guess=initial_guess
573    )
574    return out
def dogfilter(vol, sigma_low=1, sigma_high=4, mode='reflect'):
11def dogfilter(vol, sigma_low=1, sigma_high=4, mode="reflect"):
12    """Diffference of Gaussians filter
13
14    Args:
15        vol (array_like): data to be filtered
16        sigma_low (scalar or sequence of scalar): standard deviations
17        sigma_high (scalar or sequence of scalar): standard deviations
18        mode (str): The array borders are handled according to the given mode
19
20    Returns:
21        (array_like): filtered data
22
23    See also:
24        cupyx.scipy.ndimage.gaussian_filter
25        skimage.filters.difference_of_gaussians
26    """
27    in_module = vol.__class__.__module__
28    vol = cp.array(vol, "float32", copy=False)
29    out = cupyx.scipy.ndimage.gaussian_filter(vol, sigma_low, mode=mode)
30    out -= cupyx.scipy.ndimage.gaussian_filter(vol, sigma_high, mode=mode)
31    if in_module == "numpy":
32        out = out.get()
33    return out

Diffference of Gaussians filter

Arguments:
  • vol (array_like): data to be filtered
  • sigma_low (scalar or sequence of scalar): standard deviations
  • sigma_high (scalar or sequence of scalar): standard deviations
  • mode (str): The array borders are handled according to the given mode
Returns:

(array_like): filtered data

See also:

cupyx.scipy.ndimage.gaussian_filter skimage.filters.difference_of_gaussians

def periodic_smooth_decomposition_nd_rfft(img):
36def periodic_smooth_decomposition_nd_rfft(img):
37    """
38    Decompose ND arrays of 2D images into periodic plus smooth components. This can help with edge artifacts in
39    Fourier transforms.
40
41    Args:
42        img (cupy.ndarray): input image or volume. The last two axes are treated as the image dimensions.
43
44    Returns:
45        cupy.ndarray: periodic component
46    """
47    # compute border-difference
48    B = cp.zeros_like(img)
49    B[..., 0, :] = img[..., -1, :] - img[..., 0, :]
50    B[..., -1, :] = -B[..., 0, :]
51    B[..., :, 0] += img[..., :, -1] - img[..., :, 0]
52    B[..., :, -1] -= img[..., :, -1] - img[..., :, 0]
53
54    # real FFT of border difference
55    B_rfft = cp.fft.rfftn(B, axes=(-2, -1))
56    del B
57
58    # build denom for full grid then slice to half-spectrum
59    M, N = img.shape[-2:]
60    q = cp.arange(M, dtype="float32").reshape(M, 1)
61    r = cp.arange(N, dtype="float32").reshape(1, N)
62    denom_full = 2 * cp.cos(2 * np.pi * q / M) + 2 * cp.cos(2 * np.pi * r / N) - 4
63    # take only first N//2+1 columns
64    denom_half = denom_full[:, : (N // 2 + 1)]
65    denom_half[0, 0] = 1  # avoid divide by zero
66
67    # compute smooth in freq domain (half-spectrum)
68    B_rfft /= denom_half
69    B_rfft[..., 0, 0] = 0
70
71    # invert real FFT back to spatial
72    # smooth = cp.fft.irfftn(B_rfft, s=(M, N), axes=(-2, -1))
73    # periodic = img - smooth
74    tmp = cp.fft.irfftn(B_rfft, s=(M, N), axes=(-2, -1))
75    tmp *= -1
76    tmp += img
77    return tmp

Decompose ND arrays of 2D images into periodic plus smooth components. This can help with edge artifacts in Fourier transforms.

Arguments:
  • img (cupy.ndarray): input image or volume. The last two axes are treated as the image dimensions.
Returns:

cupy.ndarray: periodic component

def gausswin(shape, sigma):
80def gausswin(shape, sigma):
81    """Create Gaussian window of a given shape and sigma
82
83    Args:
84        shape (list or tuple): shape along each dimension
85        sigma (list or tuple): sigma along each dimension
86
87    Returns:
88        (array_like): Gauss window
89    """
90    grid = np.indices(shape).astype("float32")
91    for dim in range(len(grid)):
92        grid[dim] -= shape[dim] // 2
93        grid[dim] /= sigma[dim]
94    out = np.exp(-(grid**2).sum(0) / 2)
95    out /= out.sum()
96    # out = np.fft.fftshift(out)
97    return out

Create Gaussian window of a given shape and sigma

Arguments:
  • shape (list or tuple): shape along each dimension
  • sigma (list or tuple): sigma along each dimension
Returns:

(array_like): Gauss window

def gausskernel_sheared(sigma, shear=0, truncate=3):
100def gausskernel_sheared(sigma, shear=0, truncate=3):
101    """Create Gaussian window of a given shape and sigma. The window is sheared along the first two axes.
102
103    Args:
104        sigma (float or tuple of float): Standard deviation for Gaussian kernel.
105        shear (float): Shear factor in d_axis0 / d_axis1
106        truncate (float): Truncate the filter at this many standard deviations.
107
108    Returns:
109        window (array_like): n-dimensional window
110    """
111    # TODO: consider moving to .unshear
112
113    shape = (np.r_[sigma] * truncate * 2).astype("int")
114    shape[0] = np.maximum(shape[0], int(np.ceil(shape[1] * np.abs(shear))))
115    shape = (shape // 2) * 2 + 1
116    grid = np.indices(shape).astype("float32")
117    for dim in range(len(grid)):
118        grid[dim] -= shape[dim] // 2
119        grid[dim] /= sigma[dim]
120    grid[0] = grid[0] + shear * grid[1] * sigma[1] / sigma[0]
121    out = np.exp(-(grid**2).sum(0) / 2)
122    out /= out.sum()
123    return out

Create Gaussian window of a given shape and sigma. The window is sheared along the first two axes.

Arguments:
  • sigma (float or tuple of float): Standard deviation for Gaussian kernel.
  • shear (float): Shear factor in d_axis0 / d_axis1
  • truncate (float): Truncate the filter at this many standard deviations.
Returns:

window (array_like): n-dimensional window

def ndwindow(shape, window_func):
126def ndwindow(shape, window_func):
127    """Create a n-dimensional window function
128
129    Args:
130        shape (tuple): shape of the window
131        window_func (function): window function to be applied to each dimension
132
133    Returns:
134        window (array_like): n-dimensional window
135    """
136    out = 1
137    for i in range(len(shape)):
138        newshape = np.ones(len(shape), dtype="int")
139        newshape[i] = shape[i]
140        w = window_func(shape[i])
141        out = out * np.reshape(w, newshape)
142    return out

Create a n-dimensional window function

Arguments:
  • shape (tuple): shape of the window
  • window_func (function): window function to be applied to each dimension
Returns:

window (array_like): n-dimensional window

def accumarray(coords, shape, weights=None, clip=False):
145def accumarray(coords, shape, weights=None, clip=False):
146    """Accumulate values into an array using given coordinates and weights
147
148    Args:
149        coords (array_like): 3-by-n array of coordinates
150        shape (tuple): shape of the output array
151        weights (array_like): weights to be accumulated. If None, all weights are set to 1
152        clip (bool): if True, clip coordinates to the shape of the output array, else ignore out-of-bounds coordinates. Default is False.
153
154    Returns:
155        accum (array_like): accumulated array of the given shape
156    """
157    assert coords.shape[0] == 3
158    coords = np.round(coords.reshape(3, -1)).astype("int")
159    if clip:
160        for d in len(shape):
161            coords[d] = np.minimum(np.maximum(coords[d], 0), shape[d] - 1)
162    else:
163        valid_ix = np.all((coords >= 0) & (coords < np.array(shape)[:, None]), axis=0)
164        coords = coords[:, valid_ix]
165        if weights is not None:
166            weights = weights.ravel()[valid_ix]
167    coords_as_ix = np.ravel_multi_index((*coords,), shape).ravel()
168    accum = np.bincount(coords_as_ix, minlength=np.prod(shape), weights=weights)
169    accum = accum.reshape(shape)
170    return accum

Accumulate values into an array using given coordinates and weights

Arguments:
  • coords (array_like): 3-by-n array of coordinates
  • shape (tuple): shape of the output array
  • weights (array_like): weights to be accumulated. If None, all weights are set to 1
  • clip (bool): if True, clip coordinates to the shape of the output array, else ignore out-of-bounds coordinates. Default is False.
Returns:

accum (array_like): accumulated array of the given shape

def infill_nans(arr, sigma=0.5, truncate=50, ignore_warning=True):
173def infill_nans(arr, sigma=0.5, truncate=50, ignore_warning=True):
174    """Infill NaNs in an array using Gaussian basis interpolation
175
176    Args:
177        arr (array_like): input array
178        sigma (float): standard deviation of the Gaussian basis function
179        truncate (float): truncate the filter at this many standard deviations. Note: values outside the truncation may still contain NaNs.
180        ignore_warning (bool): if True, ignore warnings about invalid values during division
181    """
182    nans = np.isnan(arr)
183    arr_zeros = arr.copy()
184    arr_zeros[nans] = 0
185    a = scipy.ndimage.gaussian_filter(np.array(arr_zeros, dtype="float64"), sigma=sigma, truncate=truncate)
186    b = scipy.ndimage.gaussian_filter(np.array(~nans, dtype="float64"), sigma=sigma, truncate=truncate)
187    if ignore_warning:
188        with np.errstate(invalid="ignore"):
189            out = (a / b).astype(arr.dtype)
190    else:
191        out = (a / b).astype(arr.dtype)
192    return out

Infill NaNs in an array using Gaussian basis interpolation

Arguments:
  • arr (array_like): input array
  • sigma (float): standard deviation of the Gaussian basis function
  • truncate (float): truncate the filter at this many standard deviations. Note: values outside the truncation may still contain NaNs.
  • ignore_warning (bool): if True, ignore warnings about invalid values during division
def sliding_block(data, block_size=100, block_stride=1):
195def sliding_block(data, block_size=100, block_stride=1):
196    """Create a sliding window/block view into the array with the given block shape and stride. The block slides across all dimensions of the array and extracts subsets of the array at all positions.
197
198    Args:
199        data (array_like): Array to create the sliding window view from
200        block_size (int or tuple of int): Size of window over each axis that takes part in the sliding block
201        block_stride (int or tuple of int): Stride of teh window along each axis
202
203    Returns:
204        view (ndarray): Sliding block view of the array.
205
206    See Also:
207        numpy.lib.stride_tricks.sliding_window_view
208        numpy.lib.stride_tricks.as_strided
209
210    """
211    block_stride *= np.ones(data.ndim, dtype="int")
212    block_size *= np.ones(data.ndim, dtype="int")
213    shape = np.r_[1 + (data.shape - block_size) // block_stride, block_size]
214    strides = np.r_[block_stride * data.strides, data.strides]
215    xp = cp.get_array_module(data)
216    out = xp.lib.stride_tricks.as_strided(data, shape, strides)
217    return out

Create a sliding window/block view into the array with the given block shape and stride. The block slides across all dimensions of the array and extracts subsets of the array at all positions.

Arguments:
  • data (array_like): Array to create the sliding window view from
  • block_size (int or tuple of int): Size of window over each axis that takes part in the sliding block
  • block_stride (int or tuple of int): Stride of teh window along each axis
Returns:

view (ndarray): Sliding block view of the array.

See Also:

numpy.lib.stride_tricks.sliding_window_view numpy.lib.stride_tricks.as_strided

def upsampled_dft_rfftn( data: cupy.ndarray, upsampled_region_size, upsample_factor: int = 1, axis_offsets=None) -> cupy.ndarray:
220def upsampled_dft_rfftn(
221    data: cp.ndarray, upsampled_region_size, upsample_factor: int = 1, axis_offsets=None
222) -> cp.ndarray:
223    """
224    Performs an upsampled inverse DFT on a small region around given offsets,
225    taking as input the output of cupy.fft.rfftn (real-to-complex FFT).
226
227    This implements the Guizar‑Sicairos local DFT upsampling: no full zero‑padding,
228    just a small m×n patch at subpixel resolution.
229
230    Args:
231        data: A real-to-complex FFT array of shape (..., M, Nf),
232            where Nf = N//2 + 1 corresponds to an original real image width N.
233        upsampled_region_size: Size of the output patch (m, n). If an int is
234            provided, the same size is used for both dimensions.
235        upsample_factor: The integer upsampling factor in each axis.
236        axis_offsets: The center of the patch in original-pixel coordinates
237            (off_y, off_x). If None, defaults to (0, 0).
238
239    Returns:
240        A complex-valued array of shape (..., m, n) containing the
241        upsampled inverse DFT patch.
242    """
243    if data.ndim < 2:
244        raise ValueError("Input must have at least 2 dimensions")
245    *batch_shape, M, Nf = data.shape
246    # determine patch size
247    if isinstance(upsampled_region_size, int):
248        m, n = upsampled_region_size, upsampled_region_size
249    else:
250        m, n = upsampled_region_size
251    # full width of original image
252    N = (Nf - 1) * 2
253
254    # default offset: origin
255    off_y, off_x = (0.0, 0.0) if axis_offsets is None else axis_offsets
256
257    # reconstruct full complex FFT via Hermitian symmetry
258    full = cp.empty(batch_shape + [M, N], dtype=cp.complex64)
259    full[..., :Nf] = data
260    if Nf > 1:
261        tail = data[..., :, 1:-1]
262        full[..., Nf:] = tail[..., ::-1, ::-1].conj()
263
264    # frequency coordinates
265    fy = cp.fft.fftfreq(M)[None, :]  # shape (1, M)
266    fx = cp.fft.fftfreq(N)[None, :]  # shape (1, N)
267
268    # sample coordinates around offsets
269    y_idx = cp.arange(m) - (m // 2)
270    x_idx = cp.arange(n) - (n // 2)
271    y_coords = off_y[:, None] + y_idx[None, :] / upsample_factor  # (B, m)
272    x_coords = off_x[:, None] + x_idx[None, :] / upsample_factor  # (B, n)
273
274    # Build small inverse‐DFT kernels
275    ky = cp.exp(2j * cp.pi * y_coords[:, :, None] * fy[None, :, :]).astype("complex64")
276    kx = cp.exp(2j * cp.pi * x_coords[:, :, None] * fx[None, :, :]).astype("complex64")
277
278    # First apply along y: (B,m,M) × (B,M,N) -> (B,m,N)
279    out1 = cp.einsum("b m M, b M N -> b m N", ky, full)
280    # Then along x: (B,m,N) × (B,n,N)ᵀ -> (B,m,n)
281    patch = cp.einsum("b m N, b n N -> b m n", out1, kx)
282
283    return patch.real.reshape(*batch_shape, m, n)

Performs an upsampled inverse DFT on a small region around given offsets, taking as input the output of cupy.fft.rfftn (real-to-complex FFT).

This implements the Guizar‑Sicairos local DFT upsampling: no full zero‑padding, just a small m×n patch at subpixel resolution.

Arguments:
  • data: A real-to-complex FFT array of shape (..., M, Nf), where Nf = N//2 + 1 corresponds to an original real image width N.
  • upsampled_region_size: Size of the output patch (m, n). If an int is provided, the same size is used for both dimensions.
  • upsample_factor: The integer upsampling factor in each axis.
  • axis_offsets: The center of the patch in original-pixel coordinates (off_y, off_x). If None, defaults to (0, 0).
Returns:

A complex-valued array of shape (..., m, n) containing the upsampled inverse DFT patch.

def zoom_chop_pad( arr, target_shape=None, scale=(1, 1, 1), soft_edge=(0, 0, 0), shift=(0, 0, 0), flip=(False, False, False), cval=0):
286def zoom_chop_pad(
287    arr, target_shape=None, scale=(1, 1, 1), soft_edge=(0, 0, 0), shift=(0, 0, 0), flip=(False, False, False), cval=0
288):
289    """Zooms, softens, flips, shifts, and pads/crops a 3D array to match the target shape.
290
291    The conceptual order is as follows: zoom, soften, flip, shift, crop/pad.
292
293    Args:
294        arr (np.ndarray or cp.ndarray): The input array to be transformed
295        target_shape (tuple of int): The desired target shape to pad/crop to. Defaults to the shape of the input array.
296        scale (tuple): Zoom factors for each axis. Default: (1, 1, 1).
297        soft_edge (tuple of int): The size of the soft edge (Tukey envelope) to be applied to the input array, in voxels. Default: (0, 0, 0).
298        shift (tuple): Shifts for each axis, in voxels. Default: (0, 0, 0).
299        flip (tuple of bool): Whether to flip each axis. Default: (False, False, False).
300        cval (int, float): The value to use for padding. Default: 0.
301
302    Returns:
303        np.ndarray or cp.ndarray: The transformed array. Dtype is float32.
304    """
305
306    was_numpy = isinstance(arr, np.ndarray)
307
308    if target_shape is None:
309        target_shape = arr.shape
310
311    if any(s > 0 for s in soft_edge):
312        arr = cp.array(arr, dtype="float32", copy=True, order="C")
313        scaled_edge = np.array(soft_edge) / np.array(scale)
314        arr = soften_edges(arr, soft_edge=scaled_edge, copy=False)
315    else:
316        arr = cp.array(arr, dtype="float32", copy=False, order="C")
317
318    coords = cp.indices(target_shape, dtype=cp.float32)
319    for i in range(len(coords)):
320        coords[i] -= target_shape[i] / 2
321        coords[i] /= scale[i]
322        coords[i] += arr.shape[i] / 2
323        if flip[i]:
324            coords[i] *= -1
325            coords[i] += arr.shape[i] - 1
326        coords[i] -= shift[i]
327    result = cupyx.scipy.ndimage.map_coordinates(arr, coords, order=1, mode="constant", cval=cval)
328
329    if was_numpy:
330        result = result.get()
331    return result

Zooms, softens, flips, shifts, and pads/crops a 3D array to match the target shape.

The conceptual order is as follows: zoom, soften, flip, shift, crop/pad.

Arguments:
  • arr (np.ndarray or cp.ndarray): The input array to be transformed
  • target_shape (tuple of int): The desired target shape to pad/crop to. Defaults to the shape of the input array.
  • scale (tuple): Zoom factors for each axis. Default: (1, 1, 1).
  • soft_edge (tuple of int): The size of the soft edge (Tukey envelope) to be applied to the input array, in voxels. Default: (0, 0, 0).
  • shift (tuple): Shifts for each axis, in voxels. Default: (0, 0, 0).
  • flip (tuple of bool): Whether to flip each axis. Default: (False, False, False).
  • cval (int, float): The value to use for padding. Default: 0.
Returns:

np.ndarray or cp.ndarray: The transformed array. Dtype is float32.

def soften_edges(arr, soft_edge=(0, 0, 0), copy=True):
334def soften_edges(arr, soft_edge=(0, 0, 0), copy=True):
335    """Apply a soft Tukey edge to the input array.
336
337    Args:
338        arr (np.ndarray or cp.ndarray): The input array
339        soft_edge (tuple of int): The size of the soft edge (Tukey envelope) to be applied to the input array, in voxels. Default: (0, 0, 0).
340        copy (bool): If True, a copy of the array is made. Default: True.
341
342    Returns:
343        np.ndarray or cp.ndarray: The transformed array. Dtype is float32.
344    """
345    was_numpy = isinstance(arr, np.ndarray)
346    input_dtype = arr.dtype
347    arr = cp.array(arr, dtype="float32", copy=copy)
348    if isinstance(soft_edge, int) or isinstance(soft_edge, float):
349        soft_edge = (soft_edge,) * arr.ndim
350    soft_edge = np.clip(soft_edge, 0, np.array(arr.shape) / 2)
351
352    for i in range(arr.ndim):
353        if soft_edge[i] > 0:
354            alpha = 2 * soft_edge[i] / arr.shape[i]
355            alpha = np.clip(alpha, 0, 1)
356            win = cupyx.scipy.signal.windows.tukey(arr.shape[i], alpha)
357            arr *= cp.moveaxis(win[:, None, None], 0, i)
358
359    arr = arr.astype(input_dtype, copy=False)
360    if was_numpy:
361        arr = arr.get()
362    return arr

Apply a soft Tukey edge to the input array.

Arguments:
  • arr (np.ndarray or cp.ndarray): The input array
  • soft_edge (tuple of int): The size of the soft edge (Tukey envelope) to be applied to the input array, in voxels. Default: (0, 0, 0).
  • copy (bool): If True, a copy of the array is made. Default: True.
Returns:

np.ndarray or cp.ndarray: The transformed array. Dtype is float32.

def zoom(arr, zoom_factors, order=1, mode='constant'):
365def zoom(arr, zoom_factors, order=1, mode="constant"):
366    """Zooms an array by given factors along each axis.
367
368    Args:
369        arr (np.ndarray or cp.ndarray): The input array to be zoomed.
370        zoom_factors (tuple of float): Zoom factors for each axis. Values greater than 1 result in a larger output array,
371            while values less than 1 result in a smaller array. Divide the physical voxel size of the input array by these values to get the physical voxel size of the output array.
372        order (int): The order of the spline interpolation. Default is 1 (linear).
373
374    Returns:
375        np.ndarray or cp.ndarray: The zoomed array.
376    """
377    was_numpy = isinstance(arr, np.ndarray)
378    arr = cp.array(arr, dtype="float32", copy=False, order="C")
379    out = cupyx.scipy.ndimage.zoom(arr, zoom_factors, order=order)
380    if was_numpy:
381        out = out.get()
382    return out

Zooms an array by given factors along each axis.

Arguments:
  • arr (np.ndarray or cp.ndarray): The input array to be zoomed.
  • zoom_factors (tuple of float): Zoom factors for each axis. Values greater than 1 result in a larger output array, while values less than 1 result in a smaller array. Divide the physical voxel size of the input array by these values to get the physical voxel size of the output array.
  • order (int): The order of the spline interpolation. Default is 1 (linear).
Returns:

np.ndarray or cp.ndarray: The zoomed array.

def match_volumes( fixed, fixed_res, moving, moving_res, order=1, soft_edge=(0, 0, 0), cval=0, res_method='fixed'):
385def match_volumes(fixed, fixed_res, moving, moving_res, order=1, soft_edge=(0, 0, 0), cval=0, res_method="fixed"):
386    """
387    Rescale and pad the fixed and moving volumes so both have the same physical size and resolution.
388
389    Args:
390        fixed (ndarray): Fixed/reference volume.
391        fixed_res (tuple): Pixel size (resolution) for fixed, in physical units.
392        moving (ndarray): Moving volume.
393        moving_res (tuple): Pixel size (resolution) for moving, in physical units.
394        order (int): Interpolation order for zooming.
395        soft_edge (tuple): Soft edge parameter for padding.
396        cval (float): Constant value for padding.
397        res_method (str): How to choose the target resolution.
398            "fixed" (default): use fixed_res,
399            "min": use the finest (smallest) resolution,
400            "max": use the coarsest (largest) resolution,
401            "mean": use the mean of fixed_res and moving_res.
402
403    Returns:
404        fixed_out (ndarray): The fixed volume, rescaled and padded to the target resolution and physical size.
405        moving_out (ndarray): The moving volume, rescaled and padded to the target resolution and physical size.
406        target_res (tuple): The target resolution used for both volumes.
407    """
408    fixed_res = np.array(fixed_res)
409    moving_res = np.array(moving_res)
410    fixed_shape = np.array(fixed.shape)
411    moving_shape = np.array(moving.shape)
412
413    # Compute physical sizes
414    fixed_phys = fixed_shape * fixed_res
415    moving_phys = moving_shape * moving_res
416
417    # Target: match the larger physical size along each axis
418    target_phys = np.maximum(fixed_phys, moving_phys)
419
420    # Determine target resolution
421    if res_method == "fixed":
422        target_res = fixed_res
423    elif res_method == "min":
424        target_res = np.minimum(fixed_res, moving_res)
425    elif res_method == "max":
426        target_res = np.maximum(fixed_res, moving_res)
427    elif res_method == "mean":
428        target_res = (fixed_res + moving_res) / 2
429    else:
430        raise ValueError(f"Unknown target_res_type: {res_method}")
431
432    # Compute the target shape
433    target_shape = np.ceil(target_phys / target_res).astype(int)
434
435    # Rescale fixed
436    scale_fixed = fixed_res / target_res
437    fixed_out = zoom_chop_pad(
438        fixed, target_shape=target_shape, scale=scale_fixed, soft_edge=soft_edge, cval=cval, order=order
439    )
440
441    # Rescale moving
442    scale_moving = moving_res / target_res
443    moving_out = zoom_chop_pad(
444        moving, target_shape=target_shape, scale=scale_moving, soft_edge=soft_edge, cval=cval, order=order
445    )
446
447    return fixed_out, moving_out, tuple(target_res)

Rescale and pad the fixed and moving volumes so both have the same physical size and resolution.

Arguments:
  • fixed (ndarray): Fixed/reference volume.
  • fixed_res (tuple): Pixel size (resolution) for fixed, in physical units.
  • moving (ndarray): Moving volume.
  • moving_res (tuple): Pixel size (resolution) for moving, in physical units.
  • order (int): Interpolation order for zooming.
  • soft_edge (tuple): Soft edge parameter for padding.
  • cval (float): Constant value for padding.
  • res_method (str): How to choose the target resolution. "fixed" (default): use fixed_res, "min": use the finest (smallest) resolution, "max": use the coarsest (largest) resolution, "mean": use the mean of fixed_res and moving_res.
Returns:

fixed_out (ndarray): The fixed volume, rescaled and padded to the target resolution and physical size. moving_out (ndarray): The moving volume, rescaled and padded to the target resolution and physical size. target_res (tuple): The target resolution used for both volumes.

def richardson_lucy_generic( img, convolve_psf, correlate_psf=None, num_iter=5, epsilon=0.001, beta=0.0, initial_guess=None):
450def richardson_lucy_generic(img, convolve_psf, correlate_psf=None, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
451    """Richardson-Lucy deconvolution using arbitrary convolution operations, with optional Biggs acceleration.
452
453    Args:
454        img (ArrayLike): input image or volume
455        convolve_psf (Callable): function to convolve with PSF. Should take an image and return a convolved image. Ensure that the PSF is non-negative and normalized.
456        correlate_psf (Callable): function to correlate with PSF. Defaults to convolve_psf (if PSF is symmetric)
457        num_iter (int): number of iterations. Default is 5.
458        epsilon (float): small constant to prevent divide-by-zero. Default is 1e-3.
459        beta (float): acceleration parameter. Default is 0.0 (no acceleration). Typically, beta is in the range [0, 0.5].
460
461    Returns:
462        ndarray: deconvolved image
463    """
464    epsilon = cp.float32(epsilon)
465    img = cp.array(img, dtype="float32", copy=False)
466    cp.clip(img, 0, None, out=img)
467    if num_iter < 1:
468        return img
469    if correlate_psf is None:
470        correlate_psf = convolve_psf
471
472    if initial_guess is not None:
473        assert initial_guess.shape == img.shape, "Initial guess must have the same shape as the input image."
474        img_decon = cp.array(initial_guess, dtype="float32", copy=False)
475        cp.clip(img_decon, 0, None, out=img_decon)
476    else:   
477        img_decon = img.copy()
478    img_decon += epsilon
479
480    for i in range(num_iter):
481        img_decon *= correlate_psf(img / (convolve_psf(img_decon) + epsilon))
482
483        if beta > 0:
484            if i == 0: 
485                img_decon_prev = img_decon.copy()
486            else:
487                img_decon_new = img_decon.copy()
488                img_decon += beta * (img_decon - img_decon_prev)
489                cp.clip(img_decon, epsilon, None, out=img_decon)
490                img_decon_prev = img_decon_new
491    
492    return img_decon

Richardson-Lucy deconvolution using arbitrary convolution operations, with optional Biggs acceleration.

Arguments:
  • img (ArrayLike): input image or volume
  • convolve_psf (Callable): function to convolve with PSF. Should take an image and return a convolved image. Ensure that the PSF is non-negative and normalized.
  • correlate_psf (Callable): function to correlate with PSF. Defaults to convolve_psf (if PSF is symmetric)
  • num_iter (int): number of iterations. Default is 5.
  • epsilon (float): small constant to prevent divide-by-zero. Default is 1e-3.
  • beta (float): acceleration parameter. Default is 0.0 (no acceleration). Typically, beta is in the range [0, 0.5].
Returns:

ndarray: deconvolved image

def richardson_lucy_fft(img, psf, num_iter=5, epsilon=0.001, beta=0.0, initial_guess=None):
495def richardson_lucy_fft(img, psf, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
496    """Richardson-Lucy deconvolution using FFT-based convolution and optional Biggs acceleration.
497
498    Args:
499        img (ndarray): input image or volume
500        psf (ndarray): point spread function (before fftshift)
501        num_iter (int): number of iterations
502        epsilon (float): small constant to avoid divide-by-zero
503        beta (float): Biggs acceleration parameter (0 = no acceleration)
504
505    Returns:
506        ndarray: deconvolved image
507    """
508    psf = cp.array(psf, dtype="float32")
509    cp.clip(psf, 0, None, out=psf)
510    psf /= psf.sum()
511
512    shape = img.shape
513    psf_ft = cp.fft.rfftn(cp.fft.ifftshift(psf), s=shape)
514    psf_ft_conj = cp.conj(psf_ft)
515
516    def convolve(x):
517        return cp.fft.irfftn(cp.fft.rfftn(x, s=shape) * psf_ft, s=shape)
518
519    def correlate(x):
520        return cp.fft.irfftn(cp.fft.rfftn(x, s=shape) * psf_ft_conj, s=shape)
521
522    out = richardson_lucy_generic(
523        img, convolve, correlate, num_iter=num_iter, epsilon=epsilon, beta=beta, initial_guess=initial_guess
524    )
525    return out

Richardson-Lucy deconvolution using FFT-based convolution and optional Biggs acceleration.

Arguments:
  • img (ndarray): input image or volume
  • psf (ndarray): point spread function (before fftshift)
  • num_iter (int): number of iterations
  • epsilon (float): small constant to avoid divide-by-zero
  • beta (float): Biggs acceleration parameter (0 = no acceleration)
Returns:

ndarray: deconvolved image

def richardson_lucy_gaussian(img, sigmas, num_iter=5, epsilon=0.001, beta=0.0, initial_guess=None):
528def richardson_lucy_gaussian(img, sigmas, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
529    """Richardson-Lucy deconvolution using Gaussian convolution operations
530
531    Args:
532        img (ndarray): input image or volume
533        sigmas (list or ndarray): list of Gaussian sigmas along each dimension
534        num_iter (int): number of iterations
535        epsilon (float): small constant to prevent divide-by-zero
536        beta (float): acceleration parameter (0 = no acceleration)
537
538    Returns:
539        ndarray: deconvolved image
540    """
541    conv_with_gauss = lambda x: cupyx.scipy.ndimage.gaussian_filter(x, sigmas)
542    out = richardson_lucy_generic(
543        img, conv_with_gauss, num_iter=num_iter, epsilon=epsilon, beta=beta, initial_guess=initial_guess
544    )
545    return out

Richardson-Lucy deconvolution using Gaussian convolution operations

Arguments:
  • img (ndarray): input image or volume
  • sigmas (list or ndarray): list of Gaussian sigmas along each dimension
  • num_iter (int): number of iterations
  • epsilon (float): small constant to prevent divide-by-zero
  • beta (float): acceleration parameter (0 = no acceleration)
Returns:

ndarray: deconvolved image

def richardson_lucy_gaussian_shear( img, sigmas, shear, num_iter=5, epsilon=0.001, beta=0.0, initial_guess=None):
548def richardson_lucy_gaussian_shear(img, sigmas, shear, num_iter=5, epsilon=1e-3, beta=0.0, initial_guess=None):
549    """Richardson-Lucy deconvolution using a sheared Gaussian psf
550
551    Args:
552        img (ndarray): input image or volume
553        sigmas (list or ndarray): list of Gaussian sigmas along each dimension
554        shear (scalar): shear ratio
555        num_iter (int): number of iterations
556        epsilon (float): small constant to prevent divide-by-zero
557        beta (float): acceleration parameter (0 = no acceleration)
558
559    Returns:
560        ndarray: deconvolved image
561    """
562    if shear == 0:
563        return richardson_lucy_gaussian(img, sigmas, num_iter)
564
565    sigmas = np.array(sigmas)
566    gw = cp.array(gausskernel_sheared(sigmas, shear=shear, truncate=4), "float32")
567    gw01 = gw.sum(2)[:, :, None]
568    gw01 /= gw01.sum()
569    gw2 = gw.sum(axis=(0, 1))[None, None, :]
570    gw2 /= gw2.sum()
571    conv_shear = lambda vol: cupyx.scipy.ndimage.convolve(cupyx.scipy.ndimage.convolve(vol, gw01), gw2)
572    out = richardson_lucy_generic(
573        img, conv_shear, num_iter=num_iter, epsilon=epsilon, beta=beta, initial_guess=initial_guess
574    )
575    return out

Richardson-Lucy deconvolution using a sheared Gaussian psf

Arguments:
  • img (ndarray): input image or volume
  • sigmas (list or ndarray): list of Gaussian sigmas along each dimension
  • shear (scalar): shear ratio
  • num_iter (int): number of iterations
  • epsilon (float): small constant to prevent divide-by-zero
  • beta (float): acceleration parameter (0 = no acceleration)
Returns:

ndarray: deconvolved image