Source code for pylops.signalprocessing.convolve1d

__all__ = ["Convolve1D"]

from collections.abc import Callable
from functools import partial
from typing import Literal

import numpy as np

from pylops import LinearOperator
from pylops.utils._internal import _value_or_sized_to_tuple
from pylops.utils.backend import (
    get_array_module,
    get_convolve,
    get_fftconvolve,
    get_oaconvolve,
    to_cupy_conditional,
)
from pylops.utils.decorators import reshaped
from pylops.utils.typing import DTypeLike, InputDimsLike, NDArray


def _choose_convfunc(
    x: NDArray,
    method: Literal["direct", "fft", "overlapadd"] | None,
    dims: InputDimsLike,
    axis: int = -1,
) -> tuple[Callable, str]:
    """Choose convolution function

    Choose and return the function handle to be used for convolution
    """
    if len(dims) == 1:
        if method is None:
            method = "direct"
        if method not in ("direct", "fft"):
            msg = "`method` must be direct or fft"
            raise ValueError(msg)
        convfunc = partial(get_convolve(x), method=method)
    else:
        if method is None:
            method = "fft"
        if method == "fft":
            convfunc = partial(get_fftconvolve(x), axes=axis)
        elif method == "overlapadd":
            convfunc = partial(get_oaconvolve(x), axes=axis)
        else:
            msg = "`method` must be fft or overlapadd"
            raise ValueError(msg)
    return convfunc, method


def _pad_along_axis(array: NDArray, pad_size: tuple, axis: int = 0) -> NDArray:
    """Pad an array along a single axis

    Add ``pad_size[0]`` zeros before and ``pad_size[1]`` zeros after ``array``
    along ``axis``, leaving all other axes untouched. Used to shift the filter
    so that its centre lies at the requested ``offset``.
    """
    ncp = get_array_module(array)
    npad = [(0, 0)] * array.ndim
    npad[axis] = pad_size
    return ncp.pad(array, pad_width=npad)


def _broadcast_dimsd(
    dims: InputDimsLike, hshape: tuple, axis: int, nsize: int
) -> tuple:
    """Shape of the data given the shapes of the model and the filter

    The filter is allowed to have more elements than the model along the
    dimensions other than ``axis`` (for example a single wavelet convolved with
    a set of traces): the forward then broadcasts the model over those
    dimensions, and the adjoint sums over them. ``nsize`` is the size of the
    data along ``axis``.
    """
    mshape = list(dims)
    mshape[axis] = 1
    bshape = [1] * len(dims) if len(hshape) == 1 else list(hshape)
    bshape[axis] = 1
    try:
        dimsd = list(np.broadcast_shapes(tuple(mshape), tuple(bshape)))
    except ValueError:
        msg = (
            f"`h` of shape {tuple(int(n) for n in hshape)} cannot be broadcast "
            f"against a model of shape {tuple(int(d) for d in dims)} over the "
            f"dimensions other than axis={axis}"
        )
        raise ValueError(msg) from None
    dimsd[axis] = nsize
    return tuple(dimsd)


def _broadcast_axes(dims: InputDimsLike, dimsd: InputDimsLike, axis: int) -> tuple:
    """Dimensions along which the filter broadcasts the model

    The forward expands the model over these dimensions, so the adjoint has to
    sum over them. They only depend on the shapes of model and data, and are
    therefore evaluated once at construction. ``axis`` is never one of them,
    as the adjoint always crops back to the size of the model along it.
    """
    ax = axis % len(dims)
    return tuple(i for i, d in enumerate(dims) if i != ax and d == 1 and dimsd[i] != 1)


def _take_centered(array: NDArray, size: int, axis: int) -> NDArray:
    """Extract the centre of ``array`` along ``axis``

    Follow the same convention as :py:func:`scipy.signal.fftconvolve` with
    ``mode="same"``, which cannot be used directly when the filter is broadcast
    over the other dimensions of the model (it would also crop those dimensions
    down to the filter's size). A basic slice is used instead of
    :func:`numpy.take` as this routine sits in the matvec of every convolution.
    """
    start = (array.shape[axis] - size) // 2
    indices = [slice(None)] * array.ndim
    indices[axis] = slice(start, start + size)
    return array[tuple(indices)]


class _Convolve1Dshort(LinearOperator):
    r"""1D convolution operator with compact filter (shorter than input signal)"""

    def __init__(
        self,
        dims: int | InputDimsLike,
        h: NDArray,
        offset: int = 0,
        axis: int = -1,
        method: Literal["direct", "fft", "overlapadd"] | None = None,
        dtype: DTypeLike = "float64",
        name: str = "C",
    ) -> None:
        ncp = get_array_module(h)
        dims = _value_or_sized_to_tuple(dims)
        dimsd = _broadcast_dimsd(dims, h.shape, axis, dims[axis])
        super().__init__(dtype=np.dtype(dtype), dims=dims, dimsd=dimsd, name=name)
        # ``mode="same"`` crops to the shape of the first input: usable in
        # forward mode only when the filter does not broadcast the model
        self.samemode = tuple(dimsd) == tuple(dims)
        self.sumaxes = _broadcast_axes(dims, dimsd, axis)
        self.axis = axis
        self.nh = h.size if h.ndim == 1 else h.shape[axis]
        if offset > self.nh - 1:
            msg = "`offset` must be smaller than h.shape[axis] - 1"
            raise ValueError(msg)
        self.h = h
        # axis of the filter along which convolution is applied (a 1d filter is
        # broadcast over the other dimensions of the model, so it is always its
        # last - and only - axis)
        haxis = -1 if h.ndim == 1 else axis
        self.offset = 2 * (self.nh // 2 - int(offset))
        if self.nh % 2 == 0:
            self.offset -= 1
        if self.offset != 0:
            self.h = _pad_along_axis(
                self.h,
                (max(self.offset, 0), -min(self.offset, 0)),
                axis=haxis,
            )
        # ``flip`` returns a view with negative strides, which the direct
        # convolution of some backends (notably cupy) reads incorrectly, so the
        # flipped filter is made contiguous before it reaches ``convfunc``
        self.hstar = ncp.ascontiguousarray(ncp.flip(self.h, axis=haxis).conj())

        # add dimensions to filter to match dimensions of model and data
        if self.h.ndim == 1:
            hdims = np.ones(len(self.dims), dtype=int)
            hdims[self.axis] = len(self.h)
            self.h = self.h.reshape(hdims)
            self.hstar = self.hstar.reshape(hdims)

        # choose method and function handle
        self.convfunc, self.method = _choose_convfunc(h, method, self.dims, self.axis)

    @reshaped
    def _matvec(self, x: NDArray) -> NDArray:
        if type(self.h) is not type(x):
            self.h = to_cupy_conditional(x, self.h)
            self.convfunc, self.method = _choose_convfunc(
                self.h, self.method, self.dims, self.axis
            )
        if self.samemode:
            return self.convfunc(x, self.h, mode="same")
        return _take_centered(self.convfunc(x, self.h), self.dims[self.axis], self.axis)

    @reshaped
    def _rmatvec(self, x: NDArray) -> NDArray:
        if type(self.hstar) is not type(x):
            self.hstar = to_cupy_conditional(x, self.hstar)
            self.convfunc, self.method = _choose_convfunc(
                self.hstar, self.method, self.dims, self.axis
            )
        # the adjoint of a broadcast forward sums over the broadcast dimensions
        y = self.convfunc(x, self.hstar, mode="same")
        return y.sum(axis=self.sumaxes, keepdims=True) if self.sumaxes else y


class _Convolve1Dlong(LinearOperator):
    """1D convolution operator with extended filter (larger than input signal)"""

    def __init__(
        self,
        dims: int | InputDimsLike,
        h: NDArray,
        offset: int = 0,
        axis: int = -1,
        method: Literal["direct", "fft", "overlapadd"] | None = None,
        dtype: DTypeLike = "float64",
        name: str = "C",
    ) -> None:
        ncp = get_array_module(h)
        dims = _value_or_sized_to_tuple(dims)
        nh = h.size if h.ndim == 1 else h.shape[axis]
        # the filter is longer than the model along ``axis``, so the data has the
        # same shape as the model with the size of ``axis`` set to the filter length
        dimsd = _broadcast_dimsd(dims, h.shape, axis, nh)
        super().__init__(dtype=np.dtype(dtype), dims=dims, dimsd=dimsd, name=name)
        self.sumaxes = _broadcast_axes(dims, dimsd, axis)

        # create filter
        self.axis = axis
        if offset > self.dims[self.axis] - 1:
            msg = "`offset` must be smaller than dims[axis] - 1"
            raise ValueError(msg)
        self.nh = nh
        self.h = h
        # axis of the filter along which convolution is applied (see _Convolve1Dshort)
        haxis = -1 if h.ndim == 1 else axis
        self.offset = 2 * (self.dims[self.axis] // 2 - int(offset))
        if self.dims[self.axis] % 2 == 0:
            self.offset -= 1
        # ``flip`` returns a view with negative strides, which the direct
        # convolution of some backends (notably cupy) reads incorrectly, so the
        # flipped filter is made contiguous before it reaches ``convfunc``
        self.hstar = ncp.ascontiguousarray(ncp.flip(self.h, axis=haxis).conj())

        self.pad = np.zeros((len(dims), 2), dtype=int)
        self.pad[self.axis, 0] = max(self.offset, 0)
        self.pad[self.axis, 1] = -min(self.offset, 0)

        self.padd = np.zeros((len(dims), 2), dtype=int)
        self.padd[self.axis, 1] = max(self.offset, 0)
        self.padd[self.axis, 0] = -min(self.offset, 0)

        # add dimensions to filter to match dimensions of model and data
        if self.h.ndim == 1:
            hdims = np.ones(len(self.dims), dtype=int)
            hdims[self.axis] = len(self.h)
            self.h = self.h.reshape(hdims)
            self.hstar = self.hstar.reshape(hdims)

        # ``mode="same"`` crops to the shape of the first input, here the filter:
        # usable in the forward only when the filter already has the data's shape
        self.samemode = tuple(self.h.shape) == tuple(self.dimsd)

        # choose method and function handle
        self.convfunc, self.method = _choose_convfunc(h, method, self.dims, self.axis)

    @reshaped
    def _matvec(self, x: NDArray) -> NDArray:
        ncp = get_array_module(x)
        if type(self.h) is not type(x):
            self.h = to_cupy_conditional(x, self.h)
            self.convfunc, self.method = _choose_convfunc(
                self.h, self.method, self.dims, self.axis
            )
        x = ncp.pad(x, self.pad)
        if self.samemode:
            return self.convfunc(self.h, x, mode="same")
        return _take_centered(self.convfunc(self.h, x), self.nh, self.axis)

    @reshaped
    def _rmatvec(self, x: NDArray) -> NDArray:
        ncp = get_array_module(x)
        if type(self.hstar) is not type(x):
            self.hstar = to_cupy_conditional(x, self.hstar)
            self.convfunc, self.method = _choose_convfunc(
                self.hstar, self.method, self.dims, self.axis
            )
        x = ncp.pad(x, self.padd)
        y = self.convfunc(self.hstar, x)
        # the adjoint of a broadcast forward sums over the broadcast dimensions
        y = _take_centered(y, self.dims[self.axis], self.axis)
        return y.sum(axis=self.sumaxes, keepdims=True) if self.sumaxes else y


[docs] class Convolve1D(LinearOperator): r"""1D convolution operator. Apply one-dimensional convolution with i) a compact filter (shorter than input signal) or ii) an extended filter (larger than input signal) to model (and data) along an ``axis`` of a multi-dimensional array. Parameters ---------- dims : :obj:`list` or :obj:`int` Number of samples for each dimension of the model h : :obj:`numpy.ndarray` Filter to be convolved to input signal. Either a 1d array, which is applied to every 1d slice of the model taken along ``axis``, or an array with the same number of dimensions as the model, which allows using a different filter for each slice. In the latter case the filter may also be larger than the model along the dimensions other than ``axis``, provided the model has size 1 along those dimensions: the forward then broadcasts the model over them and the adjoint sums over them. offset : :obj:`int` Index of the center of the filter axis : :obj:`int`, optional .. versionadded:: 2.0.0 Axis along which convolution is applied method : :obj:`str`, optional Method used to calculate the convolution (``direct``, ``fft``, or ``overlapadd``). Note that only ``direct`` and ``fft`` are allowed for a one-dimensional model, whilst ``fft`` and ``overlapadd`` are allowed for a multi-dimensional model. If ``None``, the method is chosen automatically (``direct`` for 1-dimensional inputs and ``fft`` for N-dimensional inputs) dtype : :obj:`str`, optional Type of elements in input array. name : :obj:`str`, optional .. versionadded:: 2.0.0 Name of operator (to be used by :func:`pylops.utils.describe.describe`) Attributes ---------- nh : :obj:`int` Length of the filter hstar : :obj:`numpy.ndarray` Conjugate time-reversed filter used in adjoint convfunc : :obj:`callable` Function handler used to perform convolution dims : :obj:`tuple` Shape of the array after the adjoint, but before flattening. For example, ``x_reshaped = (Op.H * y.ravel()).reshape(Op.dims)``. dimsd : :obj:`tuple` Shape of the array after the forward, but before flattening. Obtained by broadcasting ``dims`` against the shape of ``h`` over the dimensions other than ``axis``, whose size is that of the model for a compact filter and that of the filter for an extended one. Same as ``dims`` for a 1d compact filter. shape : :obj:`tuple` Operator shape. Raises ------ ValueError If ``offset`` is bigger than the size along ``axis`` of the filter (compact filter) or of the model (extended filter), minus one ValueError If ``method`` provided is not allowed ValueError If ``h`` has neither 1 nor ``len(dims)`` dimensions ValueError If the shape of ``h`` cannot be broadcast against ``dims`` over the dimensions other than ``axis`` Notes ----- The Convolve1D operator applies convolution between the input signal :math:`x(t)` and a filter kernel :math:`h(t)` in forward model: .. math:: y(t) = \int\limits_{-\infty}^{\infty} h(t-\tau) x(\tau) \,\mathrm{d}\tau This operation can be discretized as follows .. math:: y[n] = \sum_{m=-\infty}^{\infty} h[n-m] x[m] as well as performed in the frequency domain. .. math:: Y(f) = \mathscr{F} (h(t)) * \mathscr{F} (x(t)) For one dimensional inputs, Convolve1D operator uses :py:func:`scipy.signal.convolve`, which automatically chooses the best domain for the operation to be carried out. For signals in 2 or more dimensions, the default is instead the fft implementation :py:func:`scipy.signal.fftconvolve`, as this routine efficently operates on multi-dimensional arrays; the overlap-add implementation :py:func:`scipy.signal.oaconvolve` can be selected via the ``method`` parameter, and may be faster when the filter is much shorter than the signal. As the adjoint of convolution is correlation, Convolve1D operator applies correlation in the adjoint mode. In time domain: .. math:: x(t) = \int\limits_{-\infty}^{\infty} h(\tau)^* y(t+\tau) \,\mathrm{d}\tau or in frequency domain: .. math:: x(t) = \mathscr{F}^{-1} (H(f)^* * Y(f)) """ def __init__( self, dims: int | InputDimsLike, h: NDArray, offset: int = 0, axis: int = -1, method: Literal["direct", "fft", "overlapadd"] | None = None, dtype: DTypeLike = "float64", name: str = "C", ) -> None: dims = _value_or_sized_to_tuple(dims) if h.ndim not in (1, len(dims)): msg = ( f"`h` must have either 1 or {len(dims)} dimensions (the same as the " f"model), got {h.ndim}" ) raise ValueError(msg) nh = h.size if h.ndim == 1 else h.shape[axis] if nh <= dims[axis]: convop = _Convolve1Dshort else: convop = _Convolve1Dlong Op = convop( dims=dims, h=h, offset=offset, axis=axis, method=method, dtype=dtype, ) super().__init__(Op=Op, name=name) def _matvec(self, x: NDArray) -> NDArray: return super()._matvec(x) def _rmatvec(self, x: NDArray) -> NDArray: return super()._rmatvec(x)