diff --git a/.claude/skills/newop/SKILL.md b/.claude/skills/newop/SKILL.md new file mode 100644 index 00000000..0f1ae2e5 --- /dev/null +++ b/.claude/skills/newop/SKILL.md @@ -0,0 +1,124 @@ +--- +name: newop +description: Create a new PyLops linear operator following docs/source/adding.rst - class file, docstring, tests, docs entry and example. Use when the user asks to add/implement/port a new operator into PyLops, including porting an existing non-PyLops forward/adjoint implementation from a URL or a local file (e.g. "add a Foo operator", "turn this script into a PyLops operator", "port the operator at "). +--- + +Goal: add a new, PyLops-compliant `LinearOperator` to the library, complete with +docstring, registration, tests, docs entry and a gallery example, following +`docs/source/adding.rst` (the authoritative guide - read it if unsure). + +The operator may be written from scratch (from a mathematical description) or +**ported** from an existing non-PyLops implementation supplied as a **web link** +or a **local file**. Ask for the operator name and the source only if neither is +inferable from the invocation. + +## 0. Get the source material + +- **Web link**: fetch it with `WebFetch` (or the browser tools if the page needs + JS). Extract the actual forward/adjoint code, not the prose. +- **Local file**: read it in full. +- **Neither**: work from the user's mathematical description, and state the + assumed definition of the operator before writing code. + +Then write down explicitly, before touching `pylops/`: + +- what the forward map does, and its input/output shapes; +- whether the source's "adjoint" is a true adjoint (\(\mathbf{A}^H\)) or merely an + inverse/transpose/approximation - **this is the most common porting bug**; +- which source parameters become `__init__` arguments, which become derived + members, and which are irrelevant (e.g. plotting, I/O, CLI args); +- whether the operator is real- or complex-linear, and whether it is `explicit`. + +If the source adjoint is not the true adjoint, say so and implement the correct +adjoint - the dot-test in step 4 will fail otherwise. Never relax the dot-test +tolerance to make a wrong adjoint pass. + +## 1. Place the file + +- One class per file; file named after the class but **lowercase** + (`pylops/basicoperators/diagonal.py` holds `Diagonal`). Choose the subpackage by + theme: `basicoperators`, `signalprocessing`, `waveeqprocessing`, `optimization`, + etc. Create a new subpackage only if nothing fits. +- If the operator is just a composition of existing operators, write a **function** + returning the composed operator instead of a class (see `pylops.Laplacian`). +- Start the file with `__all__ = [""]`. +- Register it: add the import/`__all__` entry in the subpackage `__init__.py` + (and its module-level summary table), plus the top-level `pylops/__init__.py` + if the operator is meant to be user-facing as `pylops.`. + +## 2. Write the class + +Use `reference/operator_template.py` as the skeleton. Key rules: + +- Inherit from `pylops.LinearOperator` and initialize via + `super().__init__(dtype=np.dtype(dtype), dims=dims, dimsd=dimsd, name=name)`. + Prefer `dims`/`dimsd` over setting `shape` directly; `shape` is derived. + Set `explicit=True` only when the operator also exposes a dense matrix `A`. +- Decorate `_matvec`/`_rmatvec` with `@reshaped` when the operator is + n-dimensional, so `x` arrives shaped as `dims` (`dimsd` for `_rmatvec`) and the + return value is flattened for you. +- Use the backend helpers rather than raw NumPy so CuPy/JAX work: + `pylops.utils.backend.get_array_module`, `to_cupy_conditional`, and friends. + Do not `import numpy` for array creation inside `_matvec`/`_rmatvec`. +- Type-annotate with `pylops.utils.typing` (`NDArray`, `DTypeLike`, + `InputDimsLike`). +- Keep a `name` argument (default a short string) for `pylops.utils.describe`. +- Write the `numpydoc` docstring with, at minimum: one-line summary, expanded + description, `Parameters`, `Attributes` (when non-obvious), `Raises` (when the + `__init__` validates inputs), and a `Notes` section giving the maths of forward + and adjoint in `.. math::` blocks. Match the level of detail of neighbouring + operators. + +## 3. Add tests + +Add to the existing `pytests/test_*.py` matching the subpackage, or create a new +one following the same header (the `TEST_CUPY_PYLOPS` / `backend` guard block). +Follow `reference/test_template.py`: + +- module-level `par*` dicts, parametrized with `@pytest.mark.parametrize("par", [...])` + covering real/complex and, where relevant, square/over-/under-determined; +- an `assert dottest(Op, nr, nc, rtol=..., complexflag=0 if par["imag"] == 0 else 3, backend=backend)` + in every test of a new configuration; +- a forward check against an independently computed expected result + (e.g. `Op.todense() @ x`, or the original source implementation's output); +- an inversion round-trip with `lsqr` / `Op / y` and `assert_array_almost_equal` + when the operator is invertible; +- error-path tests for anything the `__init__` raises. + +## 4. Run + +Always use `uv`: + +```bash +uv run pytest pytests/test_.py -k -q +make lint_uv +``` + +Iterate until the dot-test and all assertions pass cleanly. + +## 5. Document + +- Add the operator name to the right `autosummary` block in + `docs/source/api/index.rst`. +- Add a gallery example `examples/plot_.py` (or a tutorial in + `tutorials/` for a heavier workflow), following the sphinx-gallery format of + `examples/plot_diagonal.py`: `r"""` title/underline/description `"""` header, + then `###...` comment blocks separating narrative from code, and matplotlib + figures showing forward and adjoint (and inversion, if relevant). + +## 6. Final checklist (from `docs/source/adding.rst`) + +Report back confirming each item: + +- [ ] single class (or function) in its own file, in a suitable `pylops` subpackage +- [ ] `__init__`, `_matvec`, `_rmatvec` implemented (plus `todense`/`matrix` if cheap) +- [ ] operator exported from the subpackage and top-level `__init__.py` +- [ ] numpydoc docstring with `Parameters` and a mathematical `Notes` section +- [ ] test added, `dottest` passes, forward/inverse checked +- [ ] listed in `docs/source/api/index.rst` +- [ ] used in at least one `examples/` script or `tutorials/` script +- [ ] `make lint_uv` clean + +When porting, close with a short note on what differed between the source +implementation and the PyLops version (adjoint correction, shape/flattening +conventions, dtype handling, removed I/O). diff --git a/.claude/skills/newop/reference/operator_template.py b/.claude/skills/newop/reference/operator_template.py new file mode 100644 index 00000000..316e901a --- /dev/null +++ b/.claude/skills/newop/reference/operator_template.py @@ -0,0 +1,97 @@ +"""Skeleton for a new PyLops operator. + +Copy into ``pylops//.py``, rename, and fill in. +Delete anything that does not apply. +""" + +__all__ = ["MyOperator"] + +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, to_cupy_conditional +from pylops.utils.decorators import reshaped +from pylops.utils.typing import DTypeLike, InputDimsLike, NDArray + + +class MyOperator(LinearOperator): + r"""One-line summary of the operator. + + Longer description of what the operator applies in forward mode and what + its adjoint does. + + .. versionadded:: X.Y.Z + + Parameters + ---------- + param : :obj:`numpy.ndarray` + Description of the main parameter. + dims : :obj:`list` or :obj:`int`, optional + Number of samples for each dimension of the model. + axis : :obj:`int`, optional + Axis along which the operator is applied. + dtype : :obj:`str`, optional + Type of elements in input array. + name : :obj:`str`, optional + Name of operator (to be used by :func:`pylops.utils.describe.describe`) + + Attributes + ---------- + shape : :obj:`tuple` + Operator shape. + explicit : :obj:`bool` + Operator contains a matrix that can be solved explicitly (``True``) or + not (``False``). + + Raises + ------ + ValueError + If ``param`` has incompatible size with ``dims``. + + Notes + ----- + In forward mode the operator applies + + .. math:: + y_i = \ldots \quad \forall i=1,2,\ldots,N + + and in adjoint mode + + .. math:: + x_i = \ldots \quad \forall i=1,2,\ldots,M + + """ + + def __init__( + self, + param: NDArray, + dims: int | InputDimsLike | None = None, + axis: int = -1, + dtype: DTypeLike = "float64", + name: str = "M", + ) -> None: + self.param = param + self.axis = axis + dims = param.shape if dims is None else _value_or_sized_to_tuple(dims) + # dimsd is the shape of the data (output of the forward) + dimsd = dims + super().__init__(dtype=np.dtype(dtype), dims=dims, dimsd=dimsd, name=name) + + @reshaped + def _matvec(self, x: NDArray) -> NDArray: + ncp = get_array_module(x) + if type(self.param) is not type(x): + self.param = to_cupy_conditional(x, self.param) + y = ncp.zeros(self.dimsd, dtype=self.dtype) + # ... forward implementation, y = A x + return y + + @reshaped + def _rmatvec(self, y: NDArray) -> NDArray: + ncp = get_array_module(y) + if type(self.param) is not type(y): + self.param = to_cupy_conditional(y, self.param) + x = ncp.zeros(self.dims, dtype=self.dtype) + # ... adjoint implementation, x = A^H y (conjugate for complex params!) + return x diff --git a/.claude/skills/newop/reference/test_template.py b/.claude/skills/newop/reference/test_template.py new file mode 100644 index 00000000..d125f5b1 --- /dev/null +++ b/.claude/skills/newop/reference/test_template.py @@ -0,0 +1,57 @@ +"""Skeleton test for a new PyLops operator. + +Merge into the ``pytests/test_.py`` that matches the operator. +Keep the CuPy guard header identical to the one already in that file. +""" + +import os + +if int(os.environ.get("TEST_CUPY_PYLOPS", 0)): + import cupy as np + from cupy.testing import assert_array_almost_equal + + backend = "cupy" +else: + import numpy as np + from numpy.testing import assert_array_almost_equal + + backend = "numpy" +import pytest + +from pylops.basicoperators import MyOperator # noqa: F401 (adjust import) +from pylops.optimization.basic import lsqr +from pylops.utils import dottest + +par1 = {"ny": 11, "nx": 11, "imag": 0, "dtype": "float64"} # square real +par2 = {"ny": 21, "nx": 11, "imag": 0, "dtype": "float64"} # overdetermined real +par1j = {"ny": 11, "nx": 11, "imag": 1j, "dtype": "complex128"} # square complex +par2j = {"ny": 21, "nx": 11, "imag": 1j, "dtype": "complex128"} # overdet. complex + + +@pytest.mark.parametrize("par", [(par1), (par2), (par1j), (par2j)]) +def test_MyOperator(par): + """Dot-test, forward and inversion for MyOperator""" + param = np.arange(par["nx"]) + 1.0 + par["imag"] * (np.arange(par["nx"]) + 1.0) + + Op = MyOperator(param, dtype=par["dtype"]) + assert dottest( + Op, + par["ny"], + par["nx"], + rtol=1e-6 if par["dtype"] in ("float64", "complex128") else 1e-3, + complexflag=0 if par["imag"] == 0 else 3, + backend=backend, + ) + + x = np.ones(par["nx"]) + par["imag"] * np.ones(par["nx"]) + y = Op * x + assert_array_almost_equal(y, Op.todense() @ x, decimal=6) + + xinv = lsqr(Op, y, x0=np.zeros_like(x), niter=300, show=0)[0] + assert_array_almost_equal(x, xinv, decimal=4) + + +def test_MyOperator_raises(): + """Check input validation of MyOperator""" + with pytest.raises(ValueError): + MyOperator(np.ones(5), dims=(4,)) diff --git a/AIPOLICY.md b/AIPOLICY.md index 04e00845..ab8f6420 100644 --- a/AIPOLICY.md +++ b/AIPOLICY.md @@ -36,5 +36,6 @@ More specifically, we currently provide: - ``.pi/prompts/optest.md`` / ``.claude/skills/optest``: a skill to increase the test coverage of an operator; +- ``.claude/skills/newop``: a skill to create a new operator from a mathematical description or a plain implementation of forward and adjoint from file or URL; 🤖🤖 **This Policy was written by humans and polished by AI** 🤖🤖 diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 8084d114..246ec9cb 100755 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -103,6 +103,7 @@ Signal processing Interp InterpCubicSpline Bilinear + Downsample2D FFT FFT2D FFTND diff --git a/examples/plot_downsample.py b/examples/plot_downsample.py new file mode 100644 index 00000000..872f8cc1 --- /dev/null +++ b/examples/plot_downsample.py @@ -0,0 +1,140 @@ +r""" +Downsampling +============ +This example shows how to use the +:py:class:`pylops.signalprocessing.Downsample2D` operator to reduce the size +of a 2-dimensional array along both of its directions. + +Downsampling is performed in two steps: an anti-aliasing Gaussian filter is +first applied to the input array, and the smoothed array is subsequently +subsampled by the required decimation factors. Whilst a naive subsampling of +the input array would fold any energy above the Nyquist wavenumber of the +coarse grid back onto the retained wavenumbers (i.e., aliasing), the Gaussian +filter removes such energy prior to decimation. + +As the operator is linear, its adjoint (and, more interestingly, its inverse) +can also be used to move back from the coarse to the fine grid; the latter +represents a very simple form of *super-resolution*. +""" + +import matplotlib.pyplot as plt +import numpy as np +from scipy import datasets + +import pylops + +plt.close("all") +np.random.seed(0) + +############################################################################### +# Let's start by creating a 2-dimensional input vector containing an image +# from the ``scipy.datasets`` family and downsample it by a factor of 4 in +# both directions. +x = datasets.face()[::2, ::2, 0].astype(np.float64) +nz, nx = x.shape + +Dop = pylops.signalprocessing.Downsample2D((nz, nx), factors=4) +y = Dop @ x + +print(Dop) +print(f"Model size: {Dop.dims}, Data size: {Dop.dimsd}") + +fig, axs = plt.subplots(1, 2, figsize=(10, 4)) +axs[0].imshow(x, cmap="gray") +axs[0].set_title(f"Original {Dop.dims}") +axs[0].axis("tight") +axs[1].imshow(y, cmap="gray") +axs[1].set_title(f"Downsampled {Dop.dimsd}") +axs[1].axis("tight") +plt.tight_layout() + +############################################################################### +# The role of the anti-aliasing Gaussian filter becomes evident if we take +# the input and output in the frequency domain. Note how a simple resampling +# of the input array (i.e., picking one every four samples in each direction) +# would lead to aliasing of the high wavenumbers, which is instead not present +# in the downsampled array. +Fop = pylops.signalprocessing.FFT2D((nz, nx), fftshift_after=True) +F1op = pylops.signalprocessing.FFT2D((nz // 4, nx // 4), fftshift_after=True) + +xf = Fop @ x +yf = F1op @ y +yf1 = F1op @ x[::4, ::4] + +fig, axs = plt.subplots(1, 3, figsize=(10, 4)) +axs[0].imshow( + np.abs(xf)[ + nz // 2 - nz // 8 : nz // 2 + nz // 8, nx // 2 - nx // 8 : nx // 2 + nx // 8 + ], + cmap="jet", + vmin=0, + vmax=0.005 * np.abs(xf).max(), +) +axs[0].set_title("Original (centered)") +axs[0].axis("tight") +axs[1].imshow(np.abs(yf), cmap="jet", vmin=0, vmax=0.005 * np.abs(yf).max()) +axs[1].set_title("Downsampled") +axs[1].axis("tight") +axs[2].imshow(np.abs(yf1), cmap="jet", vmin=0, vmax=0.005 * np.abs(yf1).max()) +axs[2].set_title("Resampled (no filter)") +axs[2].axis("tight") +plt.tight_layout() + +############################################################################### +# Similarly, if we take a synthetic image containing a rapidly oscillating +# pattern, where aliasing is easy to spot, we can see the difference between +# our downsampled image with the one obtained by simply picking one every +# four samples in each direction (which is equivalent to using ``sigma=0``). +nz1, nx1 = 201, 201 +iz, ix = np.meshgrid(np.arange(nz1), np.arange(nx1), indexing="ij") +xosc = np.sin(0.1 * np.sqrt((iz - nz1 // 2) ** 2 + (ix - nx1 // 2) ** 2) ** 2 / 10.0) + +Dop = pylops.signalprocessing.Downsample2D((nz1, nx1), factors=4) +Dop_noaa = pylops.signalprocessing.Downsample2D((nz1, nx1), factors=4, sigma=0.0) + +fig, axs = plt.subplots(1, 3, figsize=(12, 4)) +axs[0].imshow(xosc, cmap="gray") +axs[0].set_title("Original") +axs[0].axis("tight") +axs[1].imshow(Dop_noaa @ xosc, cmap="gray") +axs[1].set_title("Subsampled (aliased)") +axs[1].axis("tight") +axs[2].imshow(Dop @ xosc, cmap="gray") +axs[2].set_title("Downsampled (anti-aliased)") +axs[2].axis("tight") +plt.tight_layout() + +############################################################################### +# Finally, we consider the inverse problem: given the downsampled data, can we +# retrieve the original, finely sampled image? As the operator has many more +# columns than rows, this problem is heavily underdetermined and we must +# regularize it. Here we simply ask for a smooth solution by penalizing the +# Laplacian of the model. We compare the estimated model with the adjoint, +# which spreads each coarse sample back over the fine grid. +x = datasets.face()[400:528:2, 400:528:2, 0].astype(np.float64) +nz, nx = x.shape + +Dop = pylops.signalprocessing.Downsample2D((nz, nx), factors=2) +y = Dop @ x + +xadj = Dop.H @ y +D2op = pylops.Laplacian((nz, nx), weights=(1, 1), dtype="float64") +xinv = pylops.optimization.leastsquares.regularized_inversion( + Dop, y.ravel(), [D2op], epsRs=[np.sqrt(0.1)], **dict(iter_lim=200) +)[0] +xinv = xinv.reshape(nz, nx) + +fig, axs = plt.subplots(1, 4, figsize=(14, 4)) +axs[0].imshow(x, cmap="gray", vmin=0, vmax=255) +axs[0].set_title("Original") +axs[0].axis("tight") +axs[1].imshow(y, cmap="gray", vmin=0, vmax=255) +axs[1].set_title("Downsampled") +axs[1].axis("tight") +axs[2].imshow(xadj, cmap="gray") +axs[2].set_title("Adjoint") +axs[2].axis("tight") +axs[3].imshow(xinv, cmap="gray", vmin=0, vmax=255) +axs[3].set_title("Inverse") +axs[3].axis("tight") +plt.tight_layout() diff --git a/pylops/signalprocessing/__init__.py b/pylops/signalprocessing/__init__.py index c9d58262..de21b40a 100755 --- a/pylops/signalprocessing/__init__.py +++ b/pylops/signalprocessing/__init__.py @@ -18,6 +18,7 @@ Interp Interpolation operator. InterpCubicSpline Cubic Spline Interpolation operator. Bilinear Bilinear interpolation operator. + Downsample2D 2D downsampling operator. FFT One dimensional Fast-Fourier Transform. FFT2D Two dimensional Fast-Fourier Transform. FFTND N-dimensional Fast-Fourier Transform. @@ -59,6 +60,7 @@ from .interp import * from .interpspline import * from .bilinear import * +from .downsample2d import * from .radon2d import * from .radon3d import * from .fourierradon2d import * @@ -96,6 +98,7 @@ "Interp", "InterpCubicSpline", "Bilinear", + "Downsample2D", "Radon2D", "Radon3D", "FourierRadon2D", diff --git a/pylops/signalprocessing/downsample2d.py b/pylops/signalprocessing/downsample2d.py new file mode 100644 index 00000000..f811eb1d --- /dev/null +++ b/pylops/signalprocessing/downsample2d.py @@ -0,0 +1,235 @@ +__all__ = ["Downsample2D"] + +from typing import Literal + +import numpy as np + +from pylops import LinearOperator +from pylops.signalprocessing import Convolve2D +from pylops.utils._internal import _value_or_sized_to_tuple +from pylops.utils.backend import get_array_module, get_normalize_axis_index +from pylops.utils.decorators import reshaped +from pylops.utils.typing import DTypeLike, InputDimsLike, NDArray, SamplingLike + + +def _gaussian_kernel1d(sigma: float, truncate: float) -> NDArray: + """Create a normalized, symmetric 1d Gaussian kernel. + + The kernel is truncated at ``truncate`` standard deviations, leading to a + kernel of size :math:`2 \\lfloor \\text{truncate} \\sigma + 0.5 \\rfloor + 1`. + A unitary kernel (i.e., ``[1.]``) is returned when ``sigma=0``. + """ + if sigma == 0.0: + return np.ones(1) + radius = int(truncate * sigma + 0.5) + x = np.arange(-radius, radius + 1) + h = np.exp(-0.5 * (x / sigma) ** 2) + return h / h.sum() + + +class Downsample2D(LinearOperator): + r"""2D downsampling operator. + + Downsample a two (or more) dimensional array along a pair of ``axes`` by + applying an anti-aliasing Gaussian filter followed by subsampling with + a given decimation factor in each of the two directions. + + Parameters + ---------- + dims : :obj:`list` or :obj:`int` + Number of samples for each dimension. + factors : :obj:`int` or :obj:`tuple`, optional + Decimation factors along each of the two ``axes``. If a single value is + provided, the same factor is used in both directions. + sigma : :obj:`float` or :obj:`tuple`, optional + Standard deviations (in number of samples) of the Gaussian filter along + each of the two ``axes``. If a single value is provided, the same + standard deviation is used in both directions. If ``None``, the + standard deviations are set to ``(factor - 1) / 2`` for each direction. + truncate : :obj:`float`, optional + Number of standard deviations at which the Gaussian filter is + truncated. The filter has ``2 * int(truncate * sigma + 0.5) + 1`` + samples along each direction. + axes : :obj:`tuple`, optional + Axes along which downsampling is applied. + method : :obj:`str`, optional + Method used to calculate the Gaussian filtering (``auto``, ``direct`` + or ``fft``) - see :func:`scipy.signal.convolve` for details. + dtype : :obj:`str`, optional + Type of elements in input array. + name : :obj:`str`, optional + Name of operator (to be used by :func:`pylops.utils.describe.describe`) + + Attributes + ---------- + h : :obj:`numpy.ndarray` + 2d Gaussian filter applied prior to subsampling. + Cop : :obj:`pylops.signalprocessing.Convolve2D` + Gaussian filtering operator. + 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. + + For example, ``y_reshaped = (Op * x.ravel()).reshape(Op.dimsd)``. + shape : :obj:`tuple` + Operator shape. + explicit : :obj:`bool` + Operator contains a matrix that can be solved explicitly (``True``) or + not (``False``). + + Raises + ------ + ValueError + If ``dims`` has less than 2 dimensions, if ``axes``, ``factors``, or + ``sigma`` do not contain 2 elements, if any element of ``factors`` is + smaller than 1 or larger than half the size of the corresponding axis, + or if any element of ``sigma`` is negative. + + See Also + -------- + pylops.signalprocessing.Convolve2D : 2D convolution operator + pylops.Restriction : Restriction (or sampling) operator + + Notes + ----- + The Downsample2D operator reduces the size of a two-dimensional array + :math:`\mathbf{x}` of size :math:`n_0 \times n_1` by a factor + :math:`f_0` and :math:`f_1` along the first and second direction, + respectively. Direct subsampling of the input array would however lead to + aliasing of any energy above the Nyquist wavenumber of the coarse grid; + for this reason the array is first smoothed by a separable Gaussian kernel + + .. math:: + h[p, q] = g_{\sigma_0}[p]\, g_{\sigma_1}[q], \qquad + g_\sigma[p] = \frac{e^{-p^2 / (2\sigma^2)}} + {\sum_{p'} e^{-p'^2 / (2\sigma^2)}} + + with :math:`|p| \leq r_0`, :math:`|q| \leq r_1`, and + :math:`r_i = \lfloor \tau \sigma_i + 0.5 \rfloor` where :math:`\tau` is the + ``truncate`` parameter. In forward mode, filtering and subsampling are + applied one after the other + + .. math:: + y[i, j] = \sum_{p=-r_0}^{r_0} \sum_{q=-r_1}^{r_1} + h[p, q] \, x[f_0 i - p, f_1 j - q] + \quad \forall i=0,\ldots,\lceil n_0 / f_0 \rceil - 1, + \; j=0,\ldots,\lceil n_1 / f_1 \rceil - 1 + + where the input array is assumed to be zero-padded outside of its + boundaries. Since the adjoint of subsampling is zero-interleaving and the + adjoint of convolution is correlation, in adjoint mode the data is first + spread over the fine grid and then correlated with the same kernel + + .. math:: + x[k, l] = \sum_{p=-r_0}^{r_0} \sum_{q=-r_1}^{r_1} + h[p, q] \, \tilde{y}[k + p, l + q], \qquad + \tilde{y}[k, l] = + \begin{cases} + y[k / f_0, l / f_1] & k \bmod f_0 = 0 \land l \bmod f_1 = 0\\ + 0 & \text{otherwise} + \end{cases} + + Note that, as the Gaussian kernel is real and symmetric, the operator + is effectively the composition of a self-adjoint smoothing operator and a + restriction operator. + + """ + + def __init__( + self, + dims: InputDimsLike, + factors: int | InputDimsLike = 2, + sigma: float | SamplingLike | None = None, + truncate: float = 4.0, + axes: InputDimsLike = (-2, -1), + method: Literal["auto", "direct", "fft"] | None = "fft", + dtype: DTypeLike = "float64", + name: str = "D", + ) -> None: + # check dims + dims = _value_or_sized_to_tuple(dims) + if len(dims) < 2: + msg = "dims must contain at least 2 dimensions" + raise ValueError(msg) + + # check axes + if len(axes) != 2: + msg = "axes must contain 2 elements" + raise ValueError(msg) + axes = tuple(get_normalize_axis_index()(ax, len(dims)) for ax in axes) + + # check factors + factors = _value_or_sized_to_tuple(factors, repeat=2) + if len(factors) != 2: + msg = "factors must contain 2 elements" + raise ValueError(msg) + + for f, ax in zip(factors, axes, strict=True): + if f < 1: + msg = "factors must be greater or equal to 1" + raise ValueError(msg) + if f > dims[ax] // 2: + msg = ( + f"factor={f} is larger than the half of the " + f"number of samples ({dims[ax]}) along axis={ax}" + ) + raise ValueError(msg) + + if sigma is None: + sigma = tuple((f - 1) / 2.0 for f in factors) + else: + sigma = _value_or_sized_to_tuple(sigma, repeat=2) + if len(sigma) != 2: + msg = "sigma must contain 2 elements" + raise ValueError(msg) + if any(s < 0 for s in sigma): + msg = "sigma must be positive" + raise ValueError(msg) + + self.axes = axes + self.factors = factors + self.sigma = sigma + self.truncate = truncate + + # data dimensions after subsampling + dimsd = list(dims) + for f, ax in zip(factors, axes, strict=True): + dimsd[ax] = int(np.ceil(dims[ax] / f)) + + super().__init__( + dtype=np.dtype(dtype), dims=dims, dimsd=tuple(dimsd), name=name + ) + + # separable gaussian filter and associated convolution operator + h0 = _gaussian_kernel1d(sigma[0], truncate) + h1 = _gaussian_kernel1d(sigma[1], truncate) + self.h = np.outer(h0, h1).astype(self.dtype) + self.Cop = Convolve2D( + dims, + h=self.h, + offset=(h0.size // 2, h1.size // 2), + axes=axes, + method=method, + dtype=dtype, + ) + + # slices used to subsample the filtered model + self.slices = tuple( + slice(None, None, factors[axes.index(ax)]) if ax in axes else slice(None) + for ax in range(len(dims)) + ) + + @reshaped + def _matvec(self, x: NDArray) -> NDArray: + y = self.Cop._matvec(x.ravel()).reshape(self.dims) + return y[self.slices] + + @reshaped + def _rmatvec(self, x: NDArray) -> NDArray: + ncp = get_array_module(x) + y = ncp.zeros(self.dims, dtype=self.dtype) + y[self.slices] = x + return self.Cop._rmatvec(y.ravel()).reshape(self.dims) diff --git a/pylops/signalprocessing/radon2d.py b/pylops/signalprocessing/radon2d.py index 77bd0313..97c75f7b 100644 --- a/pylops/signalprocessing/radon2d.py +++ b/pylops/signalprocessing/radon2d.py @@ -144,7 +144,7 @@ def Radon2D( taxis: NDArray, haxis: NDArray, pxaxis: NDArray, - kind: Literal["linear", "parabolic", "hyperbolic"] = "linear", + kind: Literal["linear", "parabolic", "hyperbolic"] | Callable = "linear", centeredh: bool = True, interp: bool = True, onthefly: bool = False, diff --git a/pylops/signalprocessing/radon3d.py b/pylops/signalprocessing/radon3d.py index 96eabd0f..cbc7a935 100644 --- a/pylops/signalprocessing/radon3d.py +++ b/pylops/signalprocessing/radon3d.py @@ -164,7 +164,7 @@ def Radon3D( hxaxis: NDArray, pyaxis: NDArray, pxaxis: NDArray, - kind: Literal["linear", "parabolic", "hyperbolic"] = "linear", + kind: Literal["linear", "parabolic", "hyperbolic"] | Callable = "linear", centeredh: bool = True, interp: bool = True, onthefly: bool = False, diff --git a/pytests/test_downsample.py b/pytests/test_downsample.py new file mode 100644 index 00000000..2bb7e56a --- /dev/null +++ b/pytests/test_downsample.py @@ -0,0 +1,156 @@ +import os + +if int(os.environ.get("TEST_CUPY_PYLOPS", 0)): + import cupy as np + from cupy.testing import assert_array_almost_equal + + backend = "cupy" +else: + import numpy as np + from numpy.testing import assert_array_almost_equal + + backend = "numpy" + +import numpy as npp +import pytest +from scipy.ndimage import gaussian_filter + +from pylops.optimization.basic import lsqr +from pylops.signalprocessing import Downsample2D +from pylops.utils import dottest + +par1 = { + "ny": 21, + "nx": 15, + "factors": 3, + "imag": 0, + "dtype": "float64", +} # same factor, real +par2 = { + "ny": 20, + "nx": 16, + "factors": (2, 4), + "imag": 0, + "dtype": "float64", +} # different factors, real +par3 = { + "ny": 11, + "nx": 13, + "factors": 1, + "imag": 0, + "dtype": "float64", +} # unitary factor, real +par1j = { + "ny": 21, + "nx": 15, + "factors": 3, + "imag": 1j, + "dtype": "complex128", +} # same factor, complex +par2j = { + "ny": 20, + "nx": 16, + "factors": (2, 4), + "imag": 1j, + "dtype": "complex128", +} # different factors, complex + + +@pytest.mark.parametrize( + "kwargs", + [ + {"dims": (10,)}, + {"dims": (10, 10), "axes": (0,)}, + {"dims": (10, 10), "factors": (2, 2, 2)}, + {"dims": (10, 10), "factors": 0}, + {"dims": (10, 10), "factors": 11}, + {"dims": (10, 10), "sigma": (1.0, 1.0, 1.0)}, + {"dims": (10, 10), "sigma": -1.0}, + ], +) +def test_Downsample2D_raises(kwargs): + """Check input validation of Downsample2D""" + with pytest.raises(ValueError): + Downsample2D(**kwargs) + + +def test_Downsample2D_sigma(): + """Check that a null sigma leads to pure subsampling""" + x = np.random.normal(0.0, 1.0, (12, 9)) + Dop = Downsample2D((12, 9), factors=(3, 3), sigma=0.0) + assert Dop.h.shape == (1, 1) + assert_array_almost_equal(Dop @ x, x[::3, ::3], decimal=10) + + +def test_Downsample2D_ndim(): + """Check that Downsample2D can be applied to a subset of axes of a + 3-dimensional array + """ + Dop = Downsample2D((7, 9, 5), factors=2, axes=(0, 1)) + assert Dop.dimsd == (4, 5, 5) + assert dottest(Dop, *Dop.shape, rtol=1e-6, backend=backend) + + +@pytest.mark.parametrize("par", [(par1), (par2), (par3), (par1j), (par2j)]) +def test_Downsample2D(par): + """Dot-test and shapes for Downsample2D""" + Dop = Downsample2D( + (par["ny"], par["nx"]), factors=par["factors"], dtype=par["dtype"] + ) + factors = ( + (par["factors"], par["factors"]) + if isinstance(par["factors"], int) + else par["factors"] + ) + assert Dop.dimsd == ( + int(npp.ceil(par["ny"] / factors[0])), + int(npp.ceil(par["nx"] / factors[1])), + ) + assert dottest( + Dop, + *Dop.shape, + rtol=1e-6, + complexflag=0 if par["imag"] == 0 else 3, + backend=backend, + ) + + +@pytest.mark.parametrize("par", [(par1), (par2), (par1j), (par2j)]) +def test_Downsample2D_scipy(par): + """Compare Downsample2D forward with scipy + gaussian filtering plus subsampling""" + factors = ( + (par["factors"], par["factors"]) + if isinstance(par["factors"], int) + else par["factors"] + ) + sigma = tuple((f - 1) / 2.0 for f in factors) + + shape = (par["ny"], par["nx"]) + x = np.random.normal(0.0, 1.0, shape) + par["imag"] * np.random.normal( + 0.0, 1.0, shape + ) + Dop = Downsample2D( + (par["ny"], par["nx"]), factors=par["factors"], dtype=par["dtype"] + ) + y = Dop @ x + + xnp = np.asnumpy(x) if backend == "cupy" else x + ynp = gaussian_filter(xnp, sigma=sigma, truncate=4.0, mode="constant")[ + :: factors[0], :: factors[1] + ] + assert_array_almost_equal(y, np.asarray(ynp), decimal=10) + + +@pytest.mark.parametrize("par", [(par3)]) +def test_Downsample2D_inverse(par): + """Invert Downsample2D when no decimation is applied (factors=1) as in + this case the operator is a square, invertible smoothing operator + """ + x = np.random.normal(0.0, 1.0, (par["ny"], par["nx"])) + Dop = Downsample2D( + (par["ny"], par["nx"]), factors=par["factors"], sigma=0.6, dtype=par["dtype"] + ) + y = Dop @ x + xinv = lsqr(Dop, y.ravel(), x0=np.zeros(Dop.shape[1]), niter=500, show=0)[0] + assert_array_almost_equal(x.ravel(), xinv, decimal=3) diff --git a/pytests/test_radon.py b/pytests/test_radon.py index 787fa635..8f299c73 100644 --- a/pytests/test_radon.py +++ b/pytests/test_radon.py @@ -126,6 +126,18 @@ def test_unknown_engine(): _ = Radon3D(None, None, None, None, None, engine="foo") +@pytest.mark.skipif( + int(os.environ.get("TEST_CUPY_PYLOPS", 0)) == 1, reason="Not CuPy enabled" +) +def test_Radon2D_unknown_kind(): + """Check error is raised if unknown (and non-callable) kind is passed""" + t = np.arange(11, dtype=np.float64) * 0.005 + h = np.arange(21, dtype=np.float64) + px = np.linspace(0, 2e-2, 21, dtype=np.float64) + with pytest.raises(NotImplementedError, match="Wrong kind of basis function"): + _ = Radon2D(t, h, px, kind="foo") + + @pytest.mark.skipif( int(os.environ.get("TEST_CUPY_PYLOPS", 0)) == 1, reason="Not CuPy enabled" ) @@ -189,6 +201,33 @@ def test_Radon2D(par, dtype): assert_array_almost_equal(x.ravel(), xinv, decimal=1) +@pytest.mark.skipif( + int(os.environ.get("TEST_CUPY_PYLOPS", 0)) == 1, reason="Not CuPy enabled" +) +def test_Radon2D_callable_kind(): + """Dot-test for Radon2D operator when kind is a custom callable""" + dt, dh = 0.005, 1 + t = np.arange(par1["nt"], dtype=np.float64) * dt + h = np.arange(par1["nhx"], dtype=np.float64) * dh + px = np.linspace(0, par1["pxmax"], par1["npx"], dtype=np.float64) + + def _linear(x, t, px): + return t + px * x + + Rop = Radon2D( + t, + h, + px, + centeredh=par1["centeredh"], + interp=par1["interp"], + kind=_linear, + onthefly=False, + engine="numpy", + dtype=np.float64, + ) + assert dottest(Rop, par1["nhx"] * par1["nt"], par1["npx"] * par1["nt"]) + + @pytest.mark.skipif( int(os.environ.get("TEST_CUPY_PYLOPS", 0)) == 1, reason="Not CuPy enabled" )