xpunwrap 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xpunwrap/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ from collections.abc import Callable
2
+ from typing import Any
3
+
4
+ from ._ndarray_backend import get_ndarray_backend, set_ndarray_backend
5
+
6
+ from .algorithms import (
7
+ algo_ls_poisson,
8
+ algo_ls_poisson_pg,
9
+ algo_ls_weighted,
10
+ algo_skimage_unwrap,
11
+ )
12
+
13
+
14
+ def algos_available() -> dict[str, Callable[..., Any]]:
15
+ """Return all available phase unwrapping algorithms.
16
+
17
+ Returns
18
+ -------
19
+ Mapping from algorithm name to callable, in the order:
20
+ ``algo_ls_poisson``, ``algo_ls_poisson_pg``, ``algo_ls_weighted``,
21
+ ``algo_skimage_unwrap``.
22
+ """
23
+ algos = {}
24
+ for algo in [algo_ls_poisson,
25
+ algo_ls_poisson_pg,
26
+ algo_ls_weighted,
27
+ algo_skimage_unwrap]:
28
+ algos[algo.__name__] = algo
29
+ return algos
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import types
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+
9
+ def real_pi(xp: types.ModuleType, dtype: Any) -> Any:
10
+ """Return pi cast to the requested real dtype.
11
+
12
+ Parameters
13
+ ----------
14
+ xp : module
15
+ Active ndarray backend (``numpy`` or ``cupy``).
16
+ dtype : dtype-like
17
+ Target real floating-point dtype.
18
+
19
+ Returns
20
+ -------
21
+ scalar
22
+ ``pi`` as a scalar of ``dtype``.
23
+ """
24
+ dt = xp.dtype(dtype)
25
+ return dt.type(xp.pi)
26
+
27
+
28
+ def complex_dtype_for_real(xp: types.ModuleType, dtype: Any) -> np.dtype:
29
+ """Map a real dtype to the corresponding complex dtype.
30
+
31
+ ``float16`` and ``float32`` map to ``complex64``; ``float64`` maps to
32
+ ``complex128``. Complex inputs are returned unchanged. Uncommon real
33
+ dtypes fall back to ``complex128``.
34
+
35
+ Parameters
36
+ ----------
37
+ xp : module
38
+ Active ndarray backend (``numpy`` or ``cupy``).
39
+ dtype : dtype-like
40
+ Input dtype, real or complex.
41
+
42
+ Returns
43
+ -------
44
+ dtype
45
+ Corresponding complex dtype.
46
+ """
47
+ dt = xp.dtype(dtype)
48
+ if dt.kind == "c":
49
+ return dt
50
+ if dt == xp.float16 or dt == xp.float32:
51
+ return xp.complex64
52
+ if dt == xp.float64:
53
+ return xp.complex128
54
+ return xp.complex128
@@ -0,0 +1,96 @@
1
+ """
2
+ Module that controls and exposes the active ndarray backend.
3
+ NumPy is used for CPU-only. CuPy is used for GPU.
4
+ """
5
+
6
+ import importlib
7
+ import types
8
+ from typing import Any
9
+
10
+ _default_backend = "numpy"
11
+ _xp = importlib.import_module(_default_backend)
12
+
13
+
14
+ class NDArrayBackend:
15
+ """Proxy object exposing the current ndarray backend.
16
+
17
+ All attribute access is forwarded to the active backend module, so
18
+ ``xp.array``, ``xp.zeros``, etc. resolve to the correct implementation
19
+ without any conditional imports in calling code.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._xp = _xp
24
+
25
+ def get(self) -> types.ModuleType:
26
+ """Return the currently active backend module.
27
+
28
+ Returns
29
+ -------
30
+ module
31
+ The active ndarray backend (``numpy`` or ``cupy``).
32
+ """
33
+ return self._xp
34
+
35
+ def set(self, backend_name: str = "numpy") -> None:
36
+ """Switch the active ndarray backend.
37
+
38
+ Parameters
39
+ ----------
40
+ backend_name : str
41
+ Name of the backend to activate. ``"numpy"`` selects CPU,
42
+ ``"cupy"`` selects GPU. Default ``"numpy"``.
43
+
44
+ Raises
45
+ ------
46
+ ImportError
47
+ If the requested backend is not installed.
48
+ """
49
+ global _xp
50
+ try:
51
+ self._xp = importlib.import_module(backend_name)
52
+ _xp = self._xp # keep global in sync
53
+ except ModuleNotFoundError as err:
54
+ raise ImportError(f"The backend '{backend_name}' is not "
55
+ f"installed. Either install it or use the "
56
+ f"default backend: 'numpy'.") from err
57
+
58
+ def __getattr__(self, name: str) -> Any:
59
+ """Delegate attribute lookup to the active backend module.
60
+
61
+ Parameters
62
+ ----------
63
+ name : str
64
+ Attribute name.
65
+
66
+ Returns
67
+ -------
68
+ object
69
+ The corresponding attribute from the active backend.
70
+ """
71
+ return getattr(self._xp, name)
72
+
73
+ def is_numpy(self) -> bool:
74
+ """Return ``True`` if the active backend is NumPy (CPU).
75
+
76
+ Returns
77
+ -------
78
+ bool
79
+ """
80
+ return self._xp.__name__.startswith("numpy")
81
+
82
+ def is_cupy(self) -> bool:
83
+ """Return ``True`` if the active backend is CuPy (GPU).
84
+
85
+ Returns
86
+ -------
87
+ bool
88
+ """
89
+ return self._xp.__name__.startswith("cupy")
90
+
91
+
92
+ # Export a single global proxy instance
93
+ xp = NDArrayBackend()
94
+ # This is what is imported by the user
95
+ get_ndarray_backend = xp.get
96
+ set_ndarray_backend = xp.set
@@ -0,0 +1,4 @@
1
+ from .ls_poisson import algo_ls_poisson
2
+ from .ls_poisson_pg import algo_ls_poisson_pg
3
+ from .ls_weighted import algo_ls_weighted
4
+ from .skimage_unwrap import algo_skimage_unwrap
@@ -0,0 +1,92 @@
1
+ """
2
+ Shared building blocks for the least-squares Poisson unwrap algorithms.
3
+
4
+ These helpers are used by both ``ls_poisson`` and ``ls_poisson_pg``.
5
+ """
6
+
7
+ from .._dtype_utils import complex_dtype_for_real, real_pi
8
+ from .._ndarray_backend import xp
9
+ from ..fourier import get_fft_engine
10
+
11
+
12
+ def wrap_phase(x: xp.ndarray) -> xp.ndarray:
13
+ """
14
+ Wrap phase to [-pi, pi).
15
+
16
+ Parameters
17
+ ----------
18
+ x : xp.ndarray
19
+ Input phase values.
20
+
21
+ Returns
22
+ -------
23
+ xp.ndarray
24
+ Wrapped values in [-pi, pi).
25
+ """
26
+ dtype = x.dtype
27
+ pi = real_pi(xp, dtype)
28
+ two_pi = dtype.type(2) * pi
29
+ return (x + pi) % two_pi - pi
30
+
31
+
32
+ def divergence_stack(gx: xp.ndarray, gy: xp.ndarray) -> xp.ndarray:
33
+ """
34
+ Compute the periodic divergence of a wrapped-gradient stack.
35
+
36
+ Parameters
37
+ ----------
38
+ gx, gy : xp.ndarray
39
+ Gradient stacks, shape (N, H, W).
40
+
41
+ Returns
42
+ -------
43
+ xp.ndarray
44
+ Divergence, shape (N, H, W).
45
+ """
46
+ # Periodic backward-difference divergence used by the FFT Poisson solve.
47
+ # div g = (g_x - g_x shifted right) + (g_y - g_y shifted down)
48
+ return (
49
+ gx - xp.roll(gx, 1, axis=2)
50
+ + gy - xp.roll(gy, 1, axis=1)
51
+ )
52
+
53
+
54
+ def poisson_solve_fft_stack(rhs: xp.ndarray) -> xp.ndarray:
55
+ """
56
+ Solve the periodic Poisson equation in the Fourier domain.
57
+
58
+ Returns the positive-denominator solution; callers multiply by -1 to
59
+ match the discrete Laplacian sign convention.
60
+
61
+ Parameters
62
+ ----------
63
+ rhs : xp.ndarray
64
+ Right-hand side, shape (N, H, W).
65
+
66
+ Returns
67
+ -------
68
+ xp.ndarray
69
+ Solution, shape (N, H, W).
70
+ """
71
+ _, H, W = rhs.shape
72
+ dtype = rhs.dtype
73
+ pi = real_pi(xp, dtype)
74
+ two = dtype.type(2)
75
+
76
+ ky = xp.fft.fftfreq(H).astype(dtype, copy=False).reshape(1, H, 1)
77
+ kx = xp.fft.fftfreq(W).astype(dtype, copy=False).reshape(1, 1, W)
78
+
79
+ denom = (two - two * xp.cos(two * pi * kx)) + \
80
+ (two - two * xp.cos(two * pi * ky))
81
+
82
+ denom[:, 0, 0] = dtype.type(1)
83
+
84
+ complex_dtype = complex_dtype_for_real(xp, dtype)
85
+ complex_dt = xp.dtype(complex_dtype)
86
+ fft = get_fft_engine()
87
+ rhs_hat = fft.fft2(rhs.astype(complex_dtype, copy=False))
88
+ phi_hat = rhs_hat / denom
89
+ phi_hat[:, 0, 0] = complex_dt.type(0)
90
+
91
+ out = xp.real(fft.ifft2(phi_hat))
92
+ return out.astype(dtype, copy=False)
@@ -0,0 +1,65 @@
1
+ from .._ndarray_backend import xp
2
+ from ._ls_common import wrap_phase
3
+
4
+
5
+ def restore_mean_plane(
6
+ phase_unwrapped: xp.ndarray,
7
+ phase_wrapped: xp.ndarray,
8
+ ) -> xp.ndarray:
9
+ """Reintroduce the mean wrapped gradient plane removed by Poisson solvers.
10
+
11
+ Least-squares Poisson solvers discard the null-space component (a linear
12
+ ramp). This function estimates that ramp from the mean wrapped gradients of
13
+ the input and adds it back, anchored so that the value at pixel ``(0, 0)``
14
+ matches the original wrapped phase.
15
+
16
+ Parameters
17
+ ----------
18
+ phase_unwrapped : xp.ndarray
19
+ Solver output, shape (H, W) or (N, H, W).
20
+ phase_wrapped : xp.ndarray
21
+ Original wrapped phase, same shape as ``phase_unwrapped``.
22
+
23
+ Returns
24
+ -------
25
+ xp.ndarray
26
+ Phase with the mean gradient plane restored, same shape as input.
27
+
28
+ Raises
29
+ ------
30
+ ValueError
31
+ If ``phase_unwrapped`` is not 2-D or 3-D.
32
+ """
33
+ input_2d = False
34
+ if phase_unwrapped.ndim == 2:
35
+ input_2d = True
36
+ phase_unwrapped = xp.expand_dims(phase_unwrapped, axis=0)
37
+ phase_wrapped = xp.expand_dims(phase_wrapped, axis=0)
38
+ elif phase_unwrapped.ndim != 3:
39
+ raise ValueError(
40
+ "phase_unwrapped must have shape (H, W) or (N, H, W)."
41
+ )
42
+
43
+ dtype = phase_unwrapped.dtype
44
+
45
+ gx = wrap_phase(xp.diff(phase_wrapped, axis=2,
46
+ append=phase_wrapped[:, :, -1:]))
47
+ gy = wrap_phase(xp.diff(phase_wrapped, axis=1,
48
+ append=phase_wrapped[:, -1:, :]))
49
+
50
+ gx_mean = gx.mean(axis=(1, 2), keepdims=True)
51
+ gy_mean = gy.mean(axis=(1, 2), keepdims=True)
52
+
53
+ _, H, W = phase_unwrapped.shape
54
+ x_idx = xp.arange(W, dtype=dtype).reshape(1, 1, W)
55
+ y_idx = xp.arange(H, dtype=dtype).reshape(1, H, 1)
56
+ plane = gx_mean * x_idx + gy_mean * y_idx
57
+ anchor = (
58
+ phase_wrapped[:, 0, 0]
59
+ - (phase_unwrapped[:, 0, 0] + plane[:, 0, 0])
60
+ )
61
+ out = phase_unwrapped + plane + anchor[:, None, None]
62
+
63
+ if input_2d:
64
+ out = out[0]
65
+ return out
@@ -0,0 +1,68 @@
1
+ from .._ndarray_backend import xp
2
+ from ._ls_common import divergence_stack, poisson_solve_fft_stack, wrap_phase
3
+ from ._plane_utils import restore_mean_plane
4
+
5
+
6
+ def algo_ls_poisson(
7
+ phase_wrapped: xp.ndarray,
8
+ restore_plane: bool = False,
9
+ ) -> xp.ndarray:
10
+ """
11
+ Batched 2D phase unwrapping using a least-squares Poisson solver.
12
+
13
+ Parameters
14
+ ----------
15
+ phase_wrapped : xp.ndarray
16
+ Wrapped phase, shape (H, W) or (N, H, W), values in [-pi, pi).
17
+ restore_plane : bool, optional
18
+ If True, add back the mean wrapped gradient plane. Default False.
19
+
20
+ Returns
21
+ -------
22
+ phase_unwrapped : xp.ndarray
23
+ Unwrapped phase, same shape as input.
24
+
25
+ Notes
26
+ -----
27
+ The FFT Poisson solver assumes periodic boundary conditions: the input
28
+ domain is treated as if its left/right and top/bottom edges connect. No
29
+ zero-padding is applied before the FFT. If the wrapped phase is not
30
+ periodic at the boundaries, the solver may produce artifacts (ringing or
31
+ slope errors) near the domain edges. For non-periodic data consider
32
+ cropping the region of interest away from the edges or using
33
+ :func:`algo_ls_weighted`, which can suppress discontinuous boundary regions
34
+ via its border-weight mask.
35
+
36
+ References
37
+ ----------
38
+ .. [1] D. C. Ghiglia and M. D. Pritt, "Two-Dimensional Phase Unwrapping:
39
+ Theory, Algorithms, and Software," Wiley, 1998.
40
+
41
+ """
42
+ input_2d = False
43
+ if phase_wrapped.ndim == 2:
44
+ input_2d = True
45
+ phase_wrapped = xp.expand_dims(phase_wrapped, axis=0)
46
+ elif phase_wrapped.ndim != 3:
47
+ raise ValueError("phase_wrapped must have shape (H, W) or (N, H, W).")
48
+
49
+ dtype = phase_wrapped.dtype
50
+ # Wrapped gradients with periodic forward differences.
51
+ # This is the discrete operator used by the FFT Poisson solver.
52
+ gx = wrap_phase(xp.roll(phase_wrapped, -1, axis=2) - phase_wrapped)
53
+ gy = wrap_phase(xp.roll(phase_wrapped, -1, axis=1) - phase_wrapped)
54
+
55
+ rhs = divergence_stack(gx, gy)
56
+
57
+ # Solve the discrete Poisson equation in the Fourier domain.
58
+ # The FFT helper returns the positive-denominator form, so apply -1 to
59
+ # match the Laplacian sign convention here.
60
+ phase_unwrapped = poisson_solve_fft_stack(rhs)
61
+ phase_unwrapped *= dtype.type(-1)
62
+
63
+ if restore_plane:
64
+ phase_unwrapped = restore_mean_plane(phase_unwrapped, phase_wrapped)
65
+
66
+ if input_2d:
67
+ phase_unwrapped = phase_unwrapped[0]
68
+ return phase_unwrapped
@@ -0,0 +1,117 @@
1
+ from .._ndarray_backend import xp
2
+ from ._ls_common import divergence_stack, poisson_solve_fft_stack, wrap_phase
3
+ from ._plane_utils import restore_mean_plane
4
+
5
+
6
+ def algo_ls_poisson_pg(
7
+ phase_wrapped: xp.ndarray,
8
+ restore_plane: bool = False,
9
+ ) -> xp.ndarray:
10
+ """
11
+ Least-squares unwrapping with periodic gradient enforcement.
12
+
13
+ Parameters
14
+ ----------
15
+ phase_wrapped : xp.ndarray
16
+ Wrapped phase, shape (H, W) or (N, H, W), values in [-pi, pi).
17
+ restore_plane : bool, optional
18
+ If True, add back the mean wrapped gradient plane. Default False.
19
+
20
+ Returns
21
+ -------
22
+ phase_unwrapped : xp.ndarray
23
+ Unwrapped phase, same shape as input.
24
+
25
+ Notes
26
+ -----
27
+ The FFT Poisson solver assumes periodic boundary conditions: the input
28
+ domain is treated as if its left/right and top/bottom edges connect. No
29
+ zero-padding is applied before the FFT. This algorithm explicitly enforces
30
+ periodicity on the wrapped gradients (via
31
+ :func:`enforce_periodic_gradients_stack`) before solving, which reduces
32
+ gradient discontinuities at domain edges compared to
33
+ :func:`algo_ls_poisson`. If the wrapped phase is not periodic at the
34
+ boundaries, artifacts near the edges may still occur.
35
+
36
+ References
37
+ ----------
38
+ .. [1] D. C. Ghiglia and M. D. Pritt, "Two-Dimensional Phase Unwrapping:
39
+ Theory, Algorithms, and Software," Wiley, 1998.
40
+
41
+ """
42
+ input_2d = False
43
+ if phase_wrapped.ndim == 2:
44
+ input_2d = True
45
+ phase_wrapped = xp.expand_dims(phase_wrapped, axis=0)
46
+ elif phase_wrapped.ndim != 3:
47
+ raise ValueError("phase_wrapped must have shape (H, W) or (N, H, W).")
48
+
49
+ # Periodic gradients.
50
+ gx, gy = wrapped_gradients_stack(phase_wrapped)
51
+ gx, gy = enforce_periodic_gradients_stack(gx, gy)
52
+ rhs = divergence_stack(gx, gy)
53
+
54
+ # The FFT helper returns the positive-denominator form, so apply -1 to
55
+ # match the Laplacian sign convention here.
56
+ phi = poisson_solve_fft_stack(rhs)
57
+ phi *= phi.dtype.type(-1)
58
+
59
+ if restore_plane:
60
+ phi = restore_mean_plane(phi, phase_wrapped)
61
+ if input_2d:
62
+ phi = phi[0]
63
+ return phi
64
+
65
+
66
+ def wrapped_gradients_stack(
67
+ phi: xp.ndarray,
68
+ ) -> tuple[xp.ndarray, xp.ndarray]:
69
+ """
70
+ Wrapped forward gradients for a stack of phases.
71
+
72
+ Parameters
73
+ ----------
74
+ phi : xp.ndarray
75
+ Wrapped phase, shape (N, H, W).
76
+
77
+ Returns
78
+ -------
79
+ gx, gy : xp.ndarray
80
+ Wrapped gradients with shape (N, H, W).
81
+ """
82
+ gx = wrap_phase(phi[:, :, 1:] - phi[:, :, :-1])
83
+ gy = wrap_phase(phi[:, 1:, :] - phi[:, :-1, :])
84
+
85
+ # Pad with zeros to restore the full (N, H, W) shape lost by diff.
86
+ # The padded edge values are immediately overwritten with the correct
87
+ # periodic boundary values in enforce_periodic_gradients_stack.
88
+ gx = xp.pad(gx, ((0, 0), (0, 0), (0, 1)))
89
+ gy = xp.pad(gy, ((0, 0), (0, 1), (0, 0)))
90
+
91
+ return gx, gy
92
+
93
+
94
+ def enforce_periodic_gradients_stack(
95
+ gx: xp.ndarray,
96
+ gy: xp.ndarray,
97
+ ) -> tuple[xp.ndarray, xp.ndarray]:
98
+ """
99
+ Enforce periodic boundary conditions on wrapped gradients.
100
+
101
+ Parameters
102
+ ----------
103
+ gx, gy : xp.ndarray
104
+ Gradient stacks, shape (N, H, W).
105
+
106
+ Returns
107
+ -------
108
+ gx, gy : xp.ndarray
109
+ Periodic gradients, shape (N, H, W).
110
+ """
111
+ # x-direction periodicity
112
+ gx[:, :, -1] = gx[:, :, 0]
113
+
114
+ # y-direction periodicity
115
+ gy[:, -1, :] = gy[:, 0, :]
116
+
117
+ return gx, gy